From ed3fbdd25ee3d1892a36fd6e8e33f7bd016a8c79 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 20:25:20 +0000 Subject: [PATCH] test(napplet): SDK wire-conformance audit + pinned conformance suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature-by-feature audit of our edge layer against the canonical @napplet/nap@0.15.0 / @napplet/core@0.15.0 message types, plus a test suite that pins the codec to the SDK's exact wire so drift fails CI. Audit (plans/2026-06-21-napplet-sdk-conformance-audit.md) catalogs every domain with the verified wire shapes and ranks the inconsistencies found: - 🔴 shell handshake missing: SDK uses shell.ready -> shell.init{capabilities, services} and answers supports() locally; we model a shell.supports request the SDK never sends, so a real napplet's capability env stays empty. - 🔴 host drops id-less messages (shell.ready / inc.emit / keys.unregisterAction). - 🔴 keys.* rejects at the boundary (only client-side stubs). - 🔴 upload non-conformant (upload vs upload.upload; flat base64 vs request:{data:Blob}; and a Blob can't cross our JSON string bridge). - ◐ relay query/subscribe use only the first of filters[]; identity getList/getZaps/getBadges + onChanged; resource nostr: + cancel; relay.closed + live tail. Conformant and now pinned by NappletSdkConformanceTest: relay publish/publishEncrypted/query/subscribe + event/eose pushes, identity reads (method-specific result fields), storage get/set/remove/keys, resource.bytes, and the error convention. Gap guards assert the current (non-conformant) behavior so each flips intentionally when fixed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde --- ...026-06-21-napplet-sdk-conformance-audit.md | 98 ++++++++ .../napplet/NappletSdkConformanceTest.kt | 227 ++++++++++++++++++ 2 files changed, 325 insertions(+) create mode 100644 amethyst/plans/2026-06-21-napplet-sdk-conformance-audit.md create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletSdkConformanceTest.kt diff --git a/amethyst/plans/2026-06-21-napplet-sdk-conformance-audit.md b/amethyst/plans/2026-06-21-napplet-sdk-conformance-audit.md new file mode 100644 index 0000000000..558c3e5991 --- /dev/null +++ b/amethyst/plans/2026-06-21-napplet-sdk-conformance-audit.md @@ -0,0 +1,98 @@ +# Napplet SDK conformance audit — feature by feature + +**Date:** 2026-06-21 +**Authoritative sources (verified, not from memory):** +`@napplet/nap@0.15.0` (`dist//types.d.ts` — the canonical wire message types), +`@napplet/shim@0.16.0` (the SDK napplets bundle), `@napplet/core@0.15.0` (base envelope + +shell handshake). Audited against branch `claude/awesome-pasteur-xwiwad`. + +Our edge layer: `NappletProtocolJson` (codec), `NappletRequest`/`NappletResponse` (commons), +`NappletHostActivity` (`SHIM_JS` + `shell.html` relay), `NappletBroker`, `NappletBrokerService`. + +## Base envelope & error convention (verified) + +- `NappletMessage` carries only **`type`** (`"domain.action"`). **There is no universal `id`** — + request/response pairs add `id`; fire-and-forget and handshake/push messages have **no `id`**. +- **No universal `ok`.** Each domain picks its own: `relay.publish`/`publishEncrypted`, + `outbox.publish`, `upload`, `intent` use `ok: boolean`; identity/storage/query results omit `ok` + and signal success by the data field's presence + an optional `error?: string`. The SDK shim + rejects when `error` is present and otherwise reads the domain's data field. + - **Ours:** we set `ok` on *every* result. Harmless (the SDK reads the data field and ignores the + extra `ok`), and our own injected shim relies on `ok`. ✅ compatible, ⚠️ non-canonical. + +## Transport (verified) — two architectural gaps + +1. **Structured-clone objects, not strings.** `@napplet/core` posts cloneable **objects**. Our + `shell.html` now bridges object↔string both ways (done earlier). ✅ +2. **🔴 The host drops id-less messages.** `NappletHostActivity.onShellMessage` does + `id = optString("id").ifEmpty { return }` — so every message **without an `id`** is dropped: + `shell.ready`, `inc.emit`, `keys.unregisterAction`. This silently breaks the shell handshake and + all fire-and-forget messages. **Must fix** to forward/handle id-less messages. +3. **🔴 Blob-carrying requests can't cross.** `upload.upload`'s request payload contains a `Blob` + (`data: Blob | ArrayBuffer`). Our applet→native bridge does `JSON.stringify`, which turns a Blob + into `{}`. Real-napplet uploads lose their bytes. Needs a Blob-aware request path. + +## Shell handshake (verified) — 🔴 not implemented + +The SDK does **not** send a `shell.supports` message. Instead: +- napplet posts **`shell.ready`** (no payload), and +- the shell replies **once** with **`shell.init`** = `{ capabilities: { domains: string[], + protocols: Record }, services: string[] }`. +- `shell.supports(capability, protocol?)` is then answered **synchronously and locally** from that + cached environment. + +**Ours:** we implement a `shell.supports` *request* (`ShellSupports` → `Supported`) and our injected +shim calls it async. A real napplet never sends `shell.supports`; it sends `shell.ready` — which our +host **drops** (no id) — so its cached environment stays empty and `supports()` returns `false` for +everything, likely making well-behaved napplets bail early. **Highest-impact gap.** +Fix: host answers `shell.ready` with a `shell.init` carrying the declared domains. + +## Per-domain conformance matrix + +Legend: ✅ conformant · ◐ partial · 🔴 mismatch/missing · ➖ not modeled. + +| Domain | SDK surface (wire) | Ours | Verdict | +|---|---|---|---| +| **shell** | `shell.ready`→`shell.init{capabilities,services}`; `supports()` local | `shell.supports` request→`{supported}` | 🔴 wrong model (no handshake) | +| **identity** | `getPublicKey`→`{pubkey}`; `getProfile`→`{profile}`; `getRelays`→`{relays}`; `getFollows`/`getMutes`/`getBlocked`→`{pubkeys}`; `getList`→`{entries}`; `getZaps`→`{zaps}`; `getBadges`→`{badges}`; `onChanged` push | getPublicKey ✅; profile/relays/follows/mutes/blocked ✅ (exact fields); getList/getZaps/getBadges → Unsupported; onChanged no-op | ◐ reads ✅, push/3 methods missing | +| **relay** | `publish{event}`→`{ok,event,eventId}`; `publishEncrypted{event,recipient,encryption}`; `query{filters[]}`→`{events}`; `subscribe{subId,filters,relay?}`; `close{subId}`; pushes `relay.event{subId,event,resources?}`, `relay.eose{subId}`, `relay.closed{subId,reason?}` | publish/publishEncrypted ✅ (read `event`); query ◐ (first filter only); subscribe ✅ + event/eose push ✅ (snapshot); close ✅ (no-op); `relay.closed` not emitted | ◐ strong; multi-filter + live tail + `relay.closed` open | +| **storage** | `get`→`{value}`; `set`; `remove`; `keys`→`{keys}` (512 KB quota) | ✅ exact (`get/set/remove/keys`, `value`/`keys` fields) | ✅ (quota not enforced) | +| **resource** | `bytes{url}`→`{blob,mime}`; `cancel`; https/blossom/nostr/data | bytes ✅ (shell builds Blob); https/data/blossom ✅; nostr ➖; cancel ➖ | ◐ nostr + cancel missing | +| **keys** | `registerAction{action}`→`{actionId,binding?}`; `unregisterAction{actionId}`; pushes `keys.action{actionId}`, `keys.bindings{bindings[]}` | client-side no-op stubs only; **host rejects** `keys.*` (decodes to null) | 🔴 real napplets' `registerAction` rejects | +| **upload** | `upload.upload{request:{data:Blob,mimeType?,...}}`→`{ok,uploadId,status,url?,sha256?,...}`; `upload.status` | type `upload` + `{bytes(base64),contentType}`→`{url}`; gateway null→Unsupported | 🔴 wrong type + shape + Blob transport | +| **inc** | `inc.emit`; `inc.subscribe`/`.result`; `inc.unsubscribe`; `inc.event` push; channel mode (`inc.channel.*`) | ➖ (deferred; maps to null→denied) | ➖ not modeled | +| **intent** | invoke a napplet by archetype | ➖ | ➖ | +| **theme/notify/media/config/outbox/ifc/cvm** | further domains | ➖ | ➖ | + +## Inconsistencies, ranked + +1. **🔴 Shell handshake missing** (`shell.ready`→`shell.init`). Real napplets get an empty + capability environment → `supports()` false → likely bail. *Fix: host emits `shell.init`.* +2. **🔴 Id-less messages dropped** by the host. Breaks the handshake + every fire-and-forget + (`inc.emit`, `keys.unregisterAction`). *Fix: forward/handle id-less messages.* +3. **🔴 `keys.*` rejects** for real napplets (we only stub client-side). *Fix: decode + `keys.registerAction`/`unregisterAction`, answer a stub `{actionId}`; later wire `keys.action`.* +4. **🔴 `upload` non-conformant** (`upload` vs `upload.upload`, flat base64 vs `request:{data:Blob}}`, + `{url}` vs rich `UploadResult`) AND the Blob can't cross our string bridge. *Fix: realign the + wire + a Blob-aware request path when the gateway lands.* +5. **◐ `relay.query`/`subscribe` use only the first of `filters[]`.** Multi-filter napplets get + partial results. *Fix: honor all filters.* +6. **◐ Identity `getList`/`getZaps`/`getBadges` + `onChanged`** unimplemented. +7. **◐ `resource` `nostr:` scheme + `resource.cancel`** unimplemented. +8. **◐ `relay.closed` push** not emitted; **live subscription tail** absent (snapshot only). +9. **⚠️ Non-canonical `ok` on every result** (harmless, but not how the SDK signals success for + identity/storage/query). + +## What the conformance tests assert + +`NappletSdkConformanceTest` (amethyst, JVM) pins the codec to the **exact SDK wire** for the +methods we support, so a regression that drifts from `@napplet/nap` fails CI: + +- **Requests:** the SDK's exact envelope (`relay.publish{event}`, `storage.get`, `identity.*`, + `resource.bytes`, multi-`filters`) decodes to the right `NappletRequest`. +- **Results:** `encodeResponse` emits the SDK's exact field names (`pubkey`, `profile`, `relays`, + `pubkeys`, `entries`, `value`, `keys`, `events`, `event`/`eventId`, `bytes`/`mime`). +- **Pushes:** `relay.event{subId,event}` / `relay.eose{subId}` match the SDK push shapes. +- **Gap guards:** tests that *document current behavior* for the known gaps (`shell.ready`, + `keys.registerAction`, `upload.upload`, `inc.emit` currently decode to `null`), each annotated + with the audit item so the day we fix them the guard flips intentionally. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletSdkConformanceTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletSdkConformanceTest.kt new file mode 100644 index 0000000000..e426e38c84 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletSdkConformanceTest.kt @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet + +import com.vitorpamplona.amethyst.commons.napplet.NappletCapability +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Wire-contract conformance against `@napplet/nap@0.15.0` + `@napplet/core@0.15.0`. Each test + * pins [NappletProtocolJson] to the SDK's *exact* request envelope and result field names, so a + * regression that drifts from the published message types fails CI. The "gap guard" tests document + * the known non-conformant surface (see `plans/2026-06-21-napplet-sdk-conformance-audit.md`): when + * one of those is fixed, its guard flips intentionally and is updated alongside the fix. + * + * Field names are quoted from the canonical `/types.d.ts` message interfaces. + */ +class NappletSdkConformanceTest { + private val json = Json + + private fun result( + requestType: String, + response: NappletResponse, + ) = json.parseToJsonElement(NappletProtocolJson.encodeResponse(requestType, response)).jsonObject + + private fun sampleEvent() = + Event( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1_700_000_000L, + kind = 1, + tags = arrayOf(arrayOf("t", "x")), + content = "gm", + sig = "c".repeat(128), + ) + + // ---------- relay (Relay*Message) ---------- + + @Test + fun relayPublishRequestCarriesTheTemplateInTheEventField() { + // RelayPublishMessage: { type:'relay.publish', id, event } + 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) + } + + @Test + fun relayPublishEncryptedRequestMatches() { + // RelayPublishEncryptedMessage: { type:'relay.publishEncrypted', id, event, recipient, encryption? } + val req = NappletProtocolJson.decodeRequest("""{"type":"relay.publishEncrypted","id":"1","event":{"kind":4,"tags":[],"content":"hi"},"recipient":"pk","encryption":"nip04"}""") + assertEquals(NappletRequest.PublishEncrypted(4, emptyArray(), "hi", "pk", "nip04"), req) + } + + @Test + fun relayQueryAndSubscribeReadTheFiltersArray() { + // RelayQueryMessage: { type:'relay.query', id, filters: NostrFilter[] } + val q = NappletProtocolJson.decodeRequest("""{"type":"relay.query","id":"1","filters":[{"kinds":[1],"authors":["aa"]}]}""") as NappletRequest.QueryEvents + assertEquals(listOf(1), q.filter.kinds) + assertEquals(listOf("aa"), q.filter.authors) + + // RelaySubscribeMessage: { type:'relay.subscribe', id, subId, filters, relay? } + val s = NappletProtocolJson.decodeRequest("""{"type":"relay.subscribe","id":"1","subId":"s1","filters":[{"kinds":[7]}]}""") as NappletRequest.Subscribe + assertEquals(listOf(7), s.filter.kinds) + // The subId the pushes are keyed by is read from the raw envelope by the service. + assertEquals("s1", NappletProtocolJson.readSubId("""{"type":"relay.subscribe","id":"1","subId":"s1","filters":[{}]}""")) + } + + @Test + fun relayPublishResultCarriesEventAndEventId() { + // RelayPublishResultMessage: { type:'relay.publish.result', id, ok, event?, eventId?, error? } + val o = result("relay.publish", NappletResponse.Published(sampleEvent(), listOf("wss://r"))) + assertEquals("relay.publish.result", o["type"]?.jsonPrimitive?.content) + assertEquals( + "a".repeat(64), + o["event"] + ?.jsonObject + ?.get("id") + ?.jsonPrimitive + ?.content, + ) + assertEquals("a".repeat(64), o["eventId"]?.jsonPrimitive?.content) + } + + @Test + fun relayQueryResultCarriesEvents() { + // RelayQueryResultMessage: { type:'relay.query.result', id, events, error? } + val o = result("relay.query", NappletResponse.Events(listOf(sampleEvent()))) + assertEquals(1, o["events"]?.jsonArray?.size) + } + + @Test + fun relayEventAndEosePushesMatchTheSdk() { + // RelayEventMessage (PUSH): { type:'relay.event', subId, event } + val ev = json.parseToJsonElement(NappletProtocolJson.encodeRelayEvent("s1", sampleEvent())).jsonObject + assertEquals("relay.event", ev["type"]?.jsonPrimitive?.content) + assertEquals("s1", ev["subId"]?.jsonPrimitive?.content) + assertEquals( + "a".repeat(64), + ev["event"] + ?.jsonObject + ?.get("id") + ?.jsonPrimitive + ?.content, + ) + + // RelayEoseMessage (PUSH): { type:'relay.eose', subId } + val eose = json.parseToJsonElement(NappletProtocolJson.encodeRelayEose("s1")).jsonObject + assertEquals("relay.eose", eose["type"]?.jsonPrimitive?.content) + assertEquals("s1", eose["subId"]?.jsonPrimitive?.content) + } + + // ---------- identity (Identity*Message) ---------- + + @Test + fun identityGetPublicKeyKeepsItsOwnRequestAndPubkeyField() { + assertEquals(NappletRequest.GetPublicKey, NappletProtocolJson.decodeRequest("""{"type":"identity.getPublicKey","id":"1"}""")) + assertEquals("pk", result("identity.getPublicKey", NappletResponse.PublicKey("pk"))["pubkey"]?.jsonPrimitive?.content) + } + + @Test + fun identityReadsDecodeGenericallyAndEncodeMethodSpecificFields() { + // identity.getProfile/getRelays/getFollows/getMutes/getBlocked/getList → IdentityRead + assertEquals(NappletRequest.IdentityRead("getProfile"), NappletProtocolJson.decodeRequest("""{"type":"identity.getProfile","id":"1"}""")) + assertEquals(NappletRequest.IdentityRead("getList", "bookmarks"), NappletProtocolJson.decodeRequest("""{"type":"identity.getList","id":"1","listType":"bookmarks"}""")) + + // Result field per @napplet/nap: getProfile→profile, getRelays→relays, + // getFollows/getMutes/getBlocked→pubkeys, getList→entries. + assertTrue(result("identity.getProfile", NappletResponse.Json("""{"name":"x"}""")).containsKey("profile")) + assertTrue(result("identity.getRelays", NappletResponse.Json("""{"wss://r":{"read":true,"write":true}}""")).containsKey("relays")) + assertTrue(result("identity.getFollows", NappletResponse.Json("""["aa"]""")).containsKey("pubkeys")) + assertTrue(result("identity.getMutes", NappletResponse.Json("""["aa"]""")).containsKey("pubkeys")) + assertTrue(result("identity.getBlocked", NappletResponse.Json("""["aa"]""")).containsKey("pubkeys")) + assertTrue(result("identity.getList", NappletResponse.Json("""["aa"]""")).containsKey("entries")) + } + + // ---------- storage (Storage*Message) ---------- + + @Test + fun storageWireTypesAndFieldsMatch() { + // Wire types are storage.get/set/remove/keys (SDK functions getItem/setItem/removeItem/keys). + assertEquals(NappletRequest.StorageGet("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.get","id":"1","key":"k"}""")) + assertEquals(NappletRequest.StorageSet("k", "v"), NappletProtocolJson.decodeRequest("""{"type":"storage.set","id":"1","key":"k","value":"v"}""")) + assertEquals(NappletRequest.StorageRemove("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.remove","id":"1","key":"k"}""")) + assertEquals(NappletRequest.StorageKeys, NappletProtocolJson.decodeRequest("""{"type":"storage.keys","id":"1"}""")) + + // StorageGetResultMessage.value, StorageKeysResultMessage.keys + assertTrue(result("storage.get", NappletResponse.StorageValue("v")).containsKey("value")) + assertEquals(2, result("storage.keys", NappletResponse.Strings(listOf("a", "b")))["keys"]?.jsonArray?.size) + } + + // ---------- resource (Resource*Message) ---------- + + @Test + fun resourceBytesRequestAndResultMatch() { + assertEquals(NappletRequest.ResourceBytes("https://x"), NappletProtocolJson.decodeRequest("""{"type":"resource.bytes","id":"1","url":"https://x"}""")) + // The host emits base64 bytes + mime; shell.html rebuilds the Blob the SDK expects. + val o = result("resource.bytes", NappletResponse.Bytes("Hi".encodeToByteArray(), "text/plain")) + assertEquals("SGk=", o["bytes"]?.jsonPrimitive?.content) + assertEquals("text/plain", o["mime"]?.jsonPrimitive?.content) + } + + // ---------- error convention ---------- + + @Test + fun failuresExposeAnErrorFieldTheSdkShimRejectsOn() { + // The SDK shim rejects when `error` is present, regardless of domain. + assertTrue(result("relay.publish", NappletResponse.Denied(NappletCapability.RELAY, "no")).containsKey("error")) + assertTrue(result("identity.getProfile", NappletResponse.Unsupported("identity.getProfile")).containsKey("error")) + assertTrue(result("relay.query", NappletResponse.Failed("boom")).containsKey("error")) + } + + // ---------- gap guards (documented non-conformance; flip these when fixed) ---------- + + @Test + fun gapShellHandshakeIsNotHandled() { + // SDK posts shell.ready (no id) and expects a shell.init reply; we don't model it yet. + // Audit item #1. NOTE: our codec also doesn't recognize shell.ready as a request. + assertNull(NappletProtocolJson.decodeRequest("""{"type":"shell.ready"}""")) + } + + @Test + fun gapKeysActionsAreNotHandledAtTheBoundary() { + // SDK: keys.registerAction / keys.unregisterAction. We only stub them client-side, so a real + // napplet's registerAction currently rejects. Audit item #3. + assertNull(NappletProtocolJson.decodeRequest("""{"type":"keys.registerAction","id":"1","action":{"id":"a","label":"A"}}""")) + assertNull(NappletProtocolJson.decodeRequest("""{"type":"keys.unregisterAction","actionId":"a"}""")) + } + + @Test + fun gapUploadWireIsNonConformant() { + // SDK sends upload.upload with a Blob in request.data; we only decode a non-standard `upload`. + // Audit item #4. + assertNull(NappletProtocolJson.decodeRequest("""{"type":"upload.upload","id":"1","request":{"mimeType":"image/png"}}""")) + } + + @Test + fun gapIncIsNotModeled() { + // inter-napplet comms (inc.emit / inc.subscribe / inc.event) are not modeled. Audit item #6/➖. + assertNull(NappletProtocolJson.decodeRequest("""{"type":"inc.emit","topic":"t"}""")) + } +}