test(napplet): SDK wire-conformance audit + pinned conformance suite

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
Claude
2026-06-21 20:25:20 +00:00
parent 0d2ac83ebb
commit ed3fbdd25e
2 changed files with 325 additions and 0 deletions
@@ -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/<domain>/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<string,string[]> }, 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.
@@ -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 `<domain>/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"}"""))
}
}