mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 00:37:41 +00:00
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+39
-14
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user