fix(napplet): correct wire shapes to @napplet/nap message types

Verified the result field names and wire discriminants against the
canonical @napplet/nap 0.15.0 message types and value-types, and fixed
several mismatches:

- identity reads return method-specific fields, not a generic "result":
  getProfile->profile, getRelays->relays, getFollows/getMutes/getBlocked
  ->pubkeys, getList->entries, getZaps->zaps, getBadges->badges.
- getProfile now builds a ProfileData object ({name, displayName, about,
  picture, banner, nip05, lud16, website}) from parsed kind-0 metadata,
  mapping display_name -> displayName, instead of dumping raw content.
- storage wire types are storage.get/set/remove/keys (the SDK functions
  are getItem/setItem/removeItem/keys); storage.keys returns `keys`.
- relay.publish / publishEncrypted carry the unsigned template in the
  `event` field (per @napplet/shim), not `template`.

Shim updated to match; codec round-trip tests lock the shapes.

Still open and documented: the transport is structured-clone objects
(not JSON strings) with a real Blob for resource.bytes, and
relay.subscribe uses relay.event/relay.eose push — the shell relay needs
an object<->string bridge + push channel, with on-device verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
Claude
2026-06-21 14:42:28 +00:00
parent 24638cc50c
commit 17b3252e76
5 changed files with 130 additions and 40 deletions
@@ -126,6 +126,40 @@ Acted on #1#5. The dialect mismatch is resolved:
- **`resource.bytes`** implemented for `https`/`data` (broker-fetched, Tor-routed,
consent-gated); `blossom:`/`nostr:` are a follow-up.
## Update (2026-06-21): return shapes verified against `@napplet/nap@0.15.0`
Pulled the canonical message types (`@napplet/nap` `*/types.d.ts` + `value-types`) and corrected
the result field names — several were wrong guesses. The authoritative wire:
| Method | Request `type` | Result field(s) |
|---|---|---|
| `identity.getPublicKey` | `identity.getPublicKey` | `pubkey: string` |
| `identity.getProfile` | `identity.getProfile` | `profile: ProfileData \| null` |
| `identity.getRelays` | `identity.getRelays` | `relays: Record<url, {read,write}>` |
| `identity.getFollows`/`getMutes`/`getBlocked` | same | `pubkeys: string[]` |
| `identity.getList` | `identity.getList` | `entries: string[]` |
| `identity.getZaps` / `getBadges` | same | `zaps[]` / `badges[]` |
| `storage.getItem/setItem/removeItem/keys` | **`storage.get`/`set`/`remove`/`keys`** | `value` / — / — / `keys: string[]` |
| `relay.publish` / `publishEncrypted` | same | template in the **`event`** field; result `{ok, event, eventId}` |
| `relay.query` | `relay.query` | `events: NostrEvent[]` |
| `resource.bytes` | `resource.bytes` | `blob: Blob`, `mime: string` |
`ProfileData` is `{ name?, displayName?, about?, picture?, banner?, nip05?, lud16?, website? }`
note **`displayName`** (camelCase), so the shell maps kind-0 `display_name``displayName` rather
than dumping raw content. Corrected in code: identity reads now emit method-specific fields;
`storage.*` wire types fixed (the *function* is `getItem`, the *envelope* is `storage.get`);
`storage.keys` returns `keys`; `relay.publish`/`publishEncrypted` read the template from `event`;
`getProfile` builds a `ProfileData` object. Locked by `NappletProtocolJsonTest`.
**Transport gap still open (the real blocker for stock napplets).** `@napplet/core` posts
**structured-clone objects** (not JSON strings) via `target.postMessage(obj)` and validates
cloneability — that is how `resource.bytes` returns a real `Blob`. Our shell relay
(`shell.html`) only forwards `typeof e.data === 'string'` and posts replies back as strings, so a
stock napplet's object messages are dropped today. Making the shell bridge object↔string (and
converting the `resource.bytes` base64 reply into a real `Blob`), plus the `relay.subscribe`
push channel (`relay.event`/`relay.eose`, no `.result`) and multi-`filters` queries, is the next
focused pass — and it needs on-device verification.
## Update (2026-06-20, later): verified against `@napplet/shim@0.16.0` and corrected
Pulled the authoritative SDK (`@napplet/shim` v0.16.0, npm/unpkg) and corrected the
@@ -199,8 +199,7 @@ class NappletBrokerService : Service() {
argument: String?,
): String? =
when (method) {
// The kind-0 content is itself the profile JSON object.
"getProfile" -> account.userMetadata.getUserMetadataEvent()?.content ?: "null"
"getProfile" -> profileJson(account)
"getFollows" -> jsonStringArray(account.kind3FollowList.flow.value.authors)
"getMutes" ->
jsonStringArray(
@@ -221,6 +220,21 @@ class NappletBrokerService : Service() {
private fun jsonStringArray(items: Iterable<String>): String = buildJsonArray { items.forEach { add(it) } }.toString()
/** Builds a `@napplet/nap` `ProfileData` object (note `displayName`, not `display_name`) from kind-0. */
private fun profileJson(account: Account): String {
val md = account.userMetadata.getUserMetadataEvent()?.contactMetaData() ?: return "null"
return buildJsonObject {
md.name?.let { put("name", it) }
md.displayName?.let { put("displayName", it) }
md.about?.let { put("about", it) }
md.picture?.let { put("picture", it) }
md.banner?.let { put("banner", it) }
md.nip05?.let { put("nip05", it) }
md.lud16?.let { put("lud16", it) }
md.website?.let { put("website", it) }
}.toString()
}
/** Builds `{ "<relay url>": { "read": bool, "write": bool }, ... }` from the user's NIP-65 list. */
private fun relaysJson(account: Account): String {
val relays = account.nip65RelayList.getNIP65RelayList()?.relays() ?: return "null"
@@ -453,14 +453,14 @@ class NappletHostActivity : ComponentActivity() {
},
identity: {
getPublicKey: function(){ return field(call('identity.getPublicKey'), 'pubkey'); },
getProfile: function(){ return field(call('identity.getProfile'), 'result'); },
getRelays: function(){ return field(call('identity.getRelays'), 'result'); },
getFollows: function(){ return field(call('identity.getFollows'), 'result'); },
getMutes: function(){ return field(call('identity.getMutes'), 'result'); },
getBlocked: function(){ return field(call('identity.getBlocked'), 'result'); },
getList: function(listType){ return field(call('identity.getList', { listType: listType }), 'result'); },
getZaps: function(){ return field(call('identity.getZaps'), 'result'); },
getBadges: function(){ return field(call('identity.getBadges'), 'result'); },
getProfile: function(){ return field(call('identity.getProfile'), 'profile'); },
getRelays: function(){ return field(call('identity.getRelays'), 'relays'); },
getFollows: function(){ return field(call('identity.getFollows'), 'pubkeys'); },
getMutes: function(){ return field(call('identity.getMutes'), 'pubkeys'); },
getBlocked: function(){ return field(call('identity.getBlocked'), 'pubkeys'); },
getList: function(listType){ return field(call('identity.getList', { listType: listType }), 'entries'); },
getZaps: function(){ return field(call('identity.getZaps'), 'zaps'); },
getBadges: function(){ return field(call('identity.getBadges'), 'badges'); },
// Live identity-change push is a follow-up; onChanged is a no-op subscription for now.
onChanged: function(handler){ return { close: function(){} }; }
},
@@ -472,9 +472,10 @@ class NappletHostActivity : ComponentActivity() {
onAction: function(actionId, cb){ return { close: function(){} }; }
},
relay: {
// publish takes an UNSIGNED template; the shell signs it and resolves to the signed event.
publish: function(template, options){ return field(call('relay.publish', { template: template, options: options }), 'event'); },
publishEncrypted: function(template, recipient, encryption){ return field(call('relay.publishEncrypted', { template: template, recipient: recipient, encryption: encryption || 'nip44' }), 'event'); },
// publish takes an UNSIGNED template (carried in the `event` field per @napplet/shim); the
// shell signs it and resolves to the signed event.
publish: function(template, options){ return field(call('relay.publish', { event: template, options: options }), 'event'); },
publishEncrypted: function(template, recipient, encryption){ return field(call('relay.publishEncrypted', { event: template, recipient: recipient, encryption: encryption || 'nip44' }), 'event'); },
query: function(filters){ return field(call('relay.query', normFilters(filters)), 'events'); },
// subscribe currently delivers the initial matches; a live tail is a follow-up.
subscribe: function(filters, onEvent, onEose, options){
@@ -487,10 +488,11 @@ class NappletHostActivity : ComponentActivity() {
}
},
storage: {
getItem: function(key){ return field(call('storage.getItem', { key: key }), 'value'); },
setItem: function(key, value){ return call('storage.setItem', { key: key, value: value }).then(function(){}); },
removeItem: function(key){ return call('storage.removeItem', { key: key }).then(function(){}); },
keys: function(){ return field(call('storage.keys'), 'values'); }
// SDK method names are getItem/setItem/removeItem/keys; the wire types are storage.get/set/remove/keys.
getItem: function(key){ return field(call('storage.get', { key: key }), 'value'); },
setItem: function(key, value){ return call('storage.set', { key: key, value: value }).then(function(){}); },
removeItem: function(key){ return call('storage.remove', { key: key }).then(function(){}); },
keys: function(){ return field(call('storage.keys'), 'keys'); }
},
// value.payInvoice is an Amethyst-specific extension (not part of @napplet/shim).
value: {
@@ -61,11 +61,11 @@ object NappletProtocolJson {
"shell.supports" -> NappletRequest.ShellSupports(o.req("domain"), o.str("protocol"))
"identity.getPublicKey" -> NappletRequest.GetPublicKey
"relay.publish" -> {
val t = o.template()
val t = o.eventTemplate()
NappletRequest.Publish(kind = t.kindOf(), tags = decodeTags(t), content = t.str("content") ?: "")
}
"relay.publishEncrypted" -> {
val t = o.template()
val t = o.eventTemplate()
NappletRequest.PublishEncrypted(
kind = t.kindOf(),
tags = decodeTags(t),
@@ -76,9 +76,9 @@ object NappletProtocolJson {
}
"relay.query" -> NappletRequest.QueryEvents(decodeFilter(o))
"relay.subscribe" -> NappletRequest.Subscribe(decodeFilter(o))
"storage.getItem" -> NappletRequest.StorageGet(o.req("key"))
"storage.setItem" -> NappletRequest.StorageSet(o.req("key"), o.req("value"))
"storage.removeItem" -> NappletRequest.StorageRemove(o.req("key"))
"storage.get" -> NappletRequest.StorageGet(o.req("key"))
"storage.set" -> NappletRequest.StorageSet(o.req("key"), o.req("value"))
"storage.remove" -> NappletRequest.StorageRemove(o.req("key"))
"storage.keys" -> NappletRequest.StorageKeys
"value.payInvoice" -> NappletRequest.PayInvoice(o.req("invoice"))
"resource.bytes" -> NappletRequest.ResourceBytes(o.req("url"))
@@ -129,12 +129,15 @@ object NappletProtocolJson {
}
is NappletResponse.Strings -> {
put("ok", true)
put("values", buildJsonArray { response.values.forEach { add(it) } })
// storage.keys returns `keys`; other string-list reads use `values`.
val field = if (requestType == "storage.keys") "keys" else "values"
put(field, buildJsonArray { response.values.forEach { add(it) } })
}
is NappletResponse.Json -> {
put("ok", true)
// identity.* reads return method-specific fields (profile/relays/pubkeys/entries/...).
// The host already serialized the value; embed it (fall back to null if malformed).
put("result", runCatching { json.parseToJsonElement(response.raw) }.getOrDefault(JsonNull))
put(identityResultField(requestType), runCatching { json.parseToJsonElement(response.raw) }.getOrDefault(JsonNull))
}
is NappletResponse.Bytes -> {
put("ok", true)
@@ -207,8 +210,20 @@ object NappletProtocolJson {
private fun JsonObject.strList(key: String): List<String>? = this[key]?.jsonArray?.map { it.jsonPrimitive.content }
/** The unsigned event template, taken from a `template` field if present, else the envelope itself. */
private fun JsonObject.template(): JsonObject = this["template"]?.jsonObject ?: this
/** The unsigned event template, carried in the `event` field (per `@napplet/shim`), else the envelope itself. */
private fun JsonObject.eventTemplate(): JsonObject = this["event"]?.jsonObject ?: this
private fun JsonObject.kindOf(): Int = getValue("kind").jsonPrimitive.int
/** The result field a given identity read returns, matching `@napplet/nap` identity message types. */
private fun identityResultField(requestType: String): String =
when (requestType) {
"identity.getRelays" -> "relays"
"identity.getFollows", "identity.getMutes", "identity.getBlocked" -> "pubkeys"
"identity.getProfile" -> "profile"
"identity.getList" -> "entries"
"identity.getZaps" -> "zaps"
"identity.getBadges" -> "badges"
else -> "result"
}
}
@@ -78,9 +78,9 @@ class NappletProtocolJsonTest {
}
@Test
fun decodesPublishFromAnUnsignedTemplate() {
// The napplet supplies only kind/tags/content — never id/pubkey/sig. The shell signs it.
val req = NappletProtocolJson.decodeRequest("""{"type":"relay.publish","id":"1","template":{"kind":1,"tags":[["t","x"]],"content":"gm"}}""")
fun decodesPublishFromAnUnsignedTemplateInTheEventField() {
// @napplet/shim carries the unsigned template in the `event` field. The shell signs it.
val req = NappletProtocolJson.decodeRequest("""{"type":"relay.publish","id":"1","event":{"kind":1,"tags":[["t","x"]],"content":"gm"}}""")
assertEquals(NappletRequest.Publish(1, arrayOf(arrayOf("t", "x")), "gm"), req)
}
@@ -88,7 +88,7 @@ class NappletProtocolJsonTest {
fun decodesPublishEncrypted() {
val req =
NappletProtocolJson.decodeRequest(
"""{"type":"relay.publishEncrypted","id":"1","template":{"kind":4,"tags":[],"content":"hi"},"recipient":"pk","encryption":"nip04"}""",
"""{"type":"relay.publishEncrypted","id":"1","event":{"kind":4,"tags":[],"content":"hi"},"recipient":"pk","encryption":"nip04"}""",
)
assertEquals(NappletRequest.PublishEncrypted(4, emptyArray(), "hi", "pk", "nip04"), req)
}
@@ -117,20 +117,44 @@ class NappletProtocolJsonTest {
}
@Test
fun encodesIdentityJsonResultVerbatim() {
val arr = json.parseToJsonElement(NappletProtocolJson.encodeResponse("identity.getFollows", NappletResponse.Json("""["aa","bb"]"""))).jsonObject
assertEquals(2, arr["result"]?.jsonArray?.size)
fun encodesIdentityJsonUnderMethodSpecificFields() {
// Field names must match @napplet/nap identity result messages, not a generic "result".
val follows = json.parseToJsonElement(NappletProtocolJson.encodeResponse("identity.getFollows", NappletResponse.Json("""["aa","bb"]"""))).jsonObject
assertEquals(2, follows["pubkeys"]?.jsonArray?.size)
val relays = json.parseToJsonElement(NappletProtocolJson.encodeResponse("identity.getRelays", NappletResponse.Json("""{"wss://r":{"read":true,"write":false}}"""))).jsonObject
assertEquals(
true,
relays["relays"]
?.jsonObject
?.get("wss://r")
?.jsonObject
?.get("read")
?.jsonPrimitive
?.boolean,
)
val profile = json.parseToJsonElement(NappletProtocolJson.encodeResponse("identity.getProfile", NappletResponse.Json("""{"name":"x"}"""))).jsonObject
assertEquals(
"x",
profile["profile"]
?.jsonObject
?.get("name")
?.jsonPrimitive
?.content,
)
// A malformed payload degrades to a JSON null rather than throwing.
val bad = json.parseToJsonElement(NappletProtocolJson.encodeResponse("identity.getProfile", NappletResponse.Json("not json"))).jsonObject
assertEquals(JsonNull, bad["result"])
assertEquals(JsonNull, bad["profile"])
}
@Test
fun decodesStorageOps() {
assertEquals(NappletRequest.StorageGet("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.getItem","key":"k"}"""))
assertEquals(NappletRequest.StorageSet("k", "v"), NappletProtocolJson.decodeRequest("""{"type":"storage.setItem","key":"k","value":"v"}"""))
assertEquals(NappletRequest.StorageRemove("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.removeItem","key":"k"}"""))
// Wire types are storage.get/set/remove/keys (the SDK functions are getItem/setItem/removeItem/keys).
assertEquals(NappletRequest.StorageGet("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.get","key":"k"}"""))
assertEquals(NappletRequest.StorageSet("k", "v"), NappletProtocolJson.decodeRequest("""{"type":"storage.set","key":"k","value":"v"}"""))
assertEquals(NappletRequest.StorageRemove("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.remove","key":"k"}"""))
assertEquals(NappletRequest.StorageKeys, NappletProtocolJson.decodeRequest("""{"type":"storage.keys"}"""))
}
@@ -201,12 +225,13 @@ class NappletProtocolJsonTest {
}
@Test
fun encodesStorageNullAsJsonNullAndKeysAsArray() {
val absent = json.parseToJsonElement(NappletProtocolJson.encodeResponse("storage.getItem", NappletResponse.StorageValue(null))).jsonObject
fun encodesStorageNullAsJsonNullAndKeysUnderKeysField() {
val absent = json.parseToJsonElement(NappletProtocolJson.encodeResponse("storage.get", NappletResponse.StorageValue(null))).jsonObject
assertEquals(JsonNull, absent["value"])
// storage.keys returns its array under `keys`, per @napplet/nap.
val keys = json.parseToJsonElement(NappletProtocolJson.encodeResponse("storage.keys", NappletResponse.Strings(listOf("a", "b")))).jsonObject
assertEquals(2, keys["values"]?.jsonArray?.size)
assertEquals(2, keys["keys"]?.jsonArray?.size)
}
@Test