diff --git a/amethyst/plans/2026-06-20-napplet-ecosystem-audit.md b/amethyst/plans/2026-06-20-napplet-ecosystem-audit.md index 7b0956fa25..c4a237c133 100644 --- a/amethyst/plans/2026-06-20-napplet-ecosystem-audit.md +++ b/amethyst/plans/2026-06-20-napplet-ecosystem-audit.md @@ -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` | +| `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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index 5067c73fb0..c4deb36dab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -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 = 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 `{ "": { "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" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt index 76116b1afc..3698b523b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt @@ -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: { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJson.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJson.kt index 7e03efc8e1..b305502b69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJson.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJson.kt @@ -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? = 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" + } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt index b4efe59690..933a4f7fa8 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt @@ -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