mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
fix(napplet): align shell to verified @napplet/shim 0.15+ contract
Reverse-engineered the authoritative @napplet/shim (npm v0.16.0) and
corrected the napplet host to its real wire contract. The prior commit
carried guessed method names that would break real ecosystem napplets.
- Signing model: napplets never get a sign() (per upstream "signing and
encryption are mediated by the shell"). Dropped keys.signEvent /
keys.nip04* / keys.nip44*. relay.publish now takes an UNSIGNED template
and the broker signs it as the user, returning the signed event;
added relay.publishEncrypted (shell encrypts + signs + publishes).
- keys -> keyboard/command actions (registerAction/unregisterAction/
onAction), client-side no-op stubs (not yet wired to the host keyboard).
- storage: get/set/remove -> getItem/setItem/removeItem; added storage.keys
end-to-end (protocol, broker, DataStore, shim).
- resource.bytes returns a Blob (shim builds from {bytes, mime}).
- shell.supports gains optional protocol arg; added shell.ready/onReady/
services stubs.
- relay.subscribe wired (initial matches; live tail still a follow-up).
- value.payInvoice and upload.blob kept as clearly-marked Amethyst-specific
extensions (no upstream equivalent; real napplets never call them).
Broker now defers per-signature consent to remote/external signers via a
signsAsUser flag (publish/publishEncrypted) instead of an IDENTITY/KEYS
capability check. Tests updated; commons:jvmTest and the amethyst codec
round-trip test pass.
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,52 @@ 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-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
|
||||
implementation to its real contract. Commit `5ca44e27` had carried several wrong guesses; the
|
||||
verified surface is:
|
||||
|
||||
| Namespace | Verified methods (v0.16.0) |
|
||||
|---|---|
|
||||
| `shell` | `supports(domain, protocol?)` (sync), `ready()`, `onReady(cb)`, `services` |
|
||||
| `identity` | `getPublicKey()`, `onChanged(h)`, + read API (`getRelays/getProfile/getFollows/getList/getZaps/getMutes/getBlocked/getBadges`) |
|
||||
| `keys` | **keyboard/command actions** — `registerAction/unregisterAction/onAction` (NOT signing) |
|
||||
| `relay` | `publish(template, options?)` → signed `NostrEvent`, `publishEncrypted(template, recipient, encryption?)`, `query(filters)`, `subscribe(filters, onEvent, onEose, options?)` |
|
||||
| `storage` | `getItem/setItem/removeItem/keys` (512 KB quota; `instance.*` variant) |
|
||||
| `resource` | `bytes(url)` → `Blob`, `bytesAsObjectURL(url)` |
|
||||
| `inc` | `emit(topic, extraTags?, content?)`, `on(topic, cb)` |
|
||||
|
||||
Crucial design fact, quoted: **"signing and encryption are mediated by the shell via
|
||||
`relay.publish()` and `relay.publishEncrypted()`"** and *"no cryptographic dependencies — the
|
||||
shim sends JSON envelope messages and the shell handles identity"*. **There is no `sign()` and no
|
||||
raw nip04/44 in the napplet surface.** There is **no `value` or `upload` domain** in v0.16.0.
|
||||
|
||||
Corrections landed (this commit):
|
||||
|
||||
- **Signing model fixed (the big one).** Dropped the bogus `keys.signEvent` / `keys.nip04*` /
|
||||
`keys.nip44*` napplet ops. `relay.publish` now takes an **unsigned template** (`kind/tags/content`)
|
||||
and the broker signs it as the user and returns the signed event — exactly the upstream contract.
|
||||
Added `relay.publishEncrypted` (broker encrypts to recipient with nip44/nip04, then signs +
|
||||
publishes). The broker still defers the per-signature prompt to remote/external signers
|
||||
(`signsAsUser` + non-internal signer) and honors standing DENY.
|
||||
- **`keys` re-pointed to keyboard actions** (`registerAction/unregisterAction/onAction`),
|
||||
implemented as client-side no-op stubs (not yet wired to the host keyboard) so action-using
|
||||
napplets don't crash. They never cross the broker boundary.
|
||||
- **`storage` renamed** to `getItem/setItem/removeItem` and **`storage.keys`** added end-to-end
|
||||
(protocol + broker + DataStore + shim), matching upstream.
|
||||
- **`resource.bytes` now returns a `Blob`** (shim builds it from `{bytes, mime}`); wire field
|
||||
renamed `contentType`→`mime`.
|
||||
- **`shell.supports(domain, protocol?)`** gained the optional protocol arg; added `shell.ready()`,
|
||||
`onReady`, `services` stubs.
|
||||
- **`relay.subscribe`** wired (initial matches; live tail still a follow-up).
|
||||
- `value.payInvoice` and `upload.blob` are **kept as clearly-marked Amethyst-specific extensions**
|
||||
(no upstream equivalent in v0.16.0) — a real `@napplet/shim` napplet never calls them, so they
|
||||
can't conflict.
|
||||
|
||||
Verified off-device: `commons:jvmTest` (broker + capability + ledger) and the amethyst codec
|
||||
round-trip test (`NappletProtocolJsonTest`) both green.
|
||||
|
||||
Still open (documented, not blocking basic napplets):
|
||||
- **`upload`** — wired end-to-end (protocol/shim/capability) but the Android Blossom
|
||||
gateway is unprovided (`Unsupported`): a correct upload needs a content Uri + signed
|
||||
@@ -134,14 +180,17 @@ Still open (documented, not blocking basic napplets):
|
||||
`identity.onChanged`/`inc.on` need a push channel over the existing reply proxy.
|
||||
- **Underspecified domains** — `inc`, `intent`, `theme`, `notify`, `media`, `config`,
|
||||
`outbox`, `ifc`, `cvm` remain unknown→denied (no method spec available to build to).
|
||||
- **Method-name fidelity** — `relay.*`/`storage.*`/`shell.supports`/`identity.getPublicKey`
|
||||
are confirmed from upstream; `keys.*`, `value.*`, `upload.*`, `resource.*` are best-guess
|
||||
names that should be checked against the `@napplet/web` source before release.
|
||||
- **Method-name fidelity** — ✅ resolved. All standard method names/shapes are now confirmed
|
||||
against `@napplet/shim@0.16.0` (see the later update above), not guessed.
|
||||
- **Identity read API** — `getProfile/getRelays/getFollows/...` and `identity.onChanged` are not
|
||||
yet implemented (need account-data wiring + on-device verification of the return shapes); the
|
||||
shim exposes `getPublicKey` + an `onChanged` no-op for now.
|
||||
- **On-device verification** of the whole round-trip with a real playground napplet.
|
||||
|
||||
Revised ecosystem-compatibility estimate: **~70%** (was ~25%) — real request/response
|
||||
napplets that use identity/keys/relay/storage/value/resource + `shell.supports` now run;
|
||||
gaps are upload, live subscriptions, the niche domains, and device verification.
|
||||
Revised ecosystem-compatibility estimate: **~80%** — real request/response napplets using
|
||||
identity(getPublicKey)/relay(publish/publishEncrypted/query/subscribe)/storage/resource +
|
||||
`shell.supports` now run against the *verified* contract; remaining gaps are the identity read
|
||||
API, keyboard-action wiring, live subscription tails, the niche domains, and device verification.
|
||||
|
||||
## Verdict (original assessment, pre-update)
|
||||
|
||||
|
||||
Binary file not shown.
@@ -300,19 +300,17 @@ class NappletBrokerService : Service() {
|
||||
private fun summaryFor(request: NappletRequest): String =
|
||||
when (request) {
|
||||
is NappletRequest.GetPublicKey -> getString(R.string.napplet_consent_get_pubkey)
|
||||
is NappletRequest.SignEvent -> {
|
||||
is NappletRequest.Publish -> {
|
||||
val preview = request.content.take(160).trim()
|
||||
if (preview.isEmpty()) {
|
||||
getString(R.string.napplet_consent_sign, request.kind)
|
||||
getString(R.string.napplet_consent_publish, request.kind)
|
||||
} else {
|
||||
getString(R.string.napplet_consent_sign_preview, request.kind) + "\n“$preview”"
|
||||
getString(R.string.napplet_consent_publish_preview, request.kind) + "\n“$preview”"
|
||||
}
|
||||
}
|
||||
is NappletRequest.Nip04Encrypt, is NappletRequest.Nip44Encrypt -> getString(R.string.napplet_consent_encrypt)
|
||||
is NappletRequest.Nip04Decrypt, is NappletRequest.Nip44Decrypt -> getString(R.string.napplet_consent_decrypt)
|
||||
is NappletRequest.Publish -> getString(R.string.napplet_consent_publish)
|
||||
is NappletRequest.QueryEvents -> getString(R.string.napplet_consent_query)
|
||||
is NappletRequest.StorageGet, is NappletRequest.StorageSet, is NappletRequest.StorageRemove ->
|
||||
is NappletRequest.PublishEncrypted -> getString(R.string.napplet_consent_publish_encrypted)
|
||||
is NappletRequest.QueryEvents, is NappletRequest.Subscribe -> getString(R.string.napplet_consent_query)
|
||||
is NappletRequest.StorageGet, is NappletRequest.StorageSet, is NappletRequest.StorageRemove, is NappletRequest.StorageKeys ->
|
||||
getString(R.string.napplet_consent_storage)
|
||||
is NappletRequest.PayInvoice -> {
|
||||
val sats = runCatching { LnInvoiceUtil.getAmountInSats(request.invoice).toLong() }.getOrNull()
|
||||
|
||||
@@ -442,44 +442,57 @@ class NappletHostActivity : ComponentActivity() {
|
||||
function normFilters(filters){ return Array.isArray(filters) ? { filters: filters } : { filter: filters || {} }; }
|
||||
function b64ToBytes(b64){ var bin = atob(b64); var u = new Uint8Array(bin.length); for (var i=0;i<bin.length;i++) u[i]=bin.charCodeAt(i); return u; }
|
||||
function bytesToB64(bytes){ var u = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); var s=''; for (var i=0;i<u.length;i++) s+=String.fromCharCode(u[i]); return btoa(s); }
|
||||
var actionSeq = 0;
|
||||
var napplet = {
|
||||
shell: {
|
||||
supports: function(domain){ return field(call('shell.supports', { domain: domain }), 'supported'); }
|
||||
// supports() is synchronous in @napplet/shim; we expose a sync proxy backed by an async check.
|
||||
supports: function(domain, protocol){ return field(call('shell.supports', { domain: domain, protocol: protocol }), 'supported'); },
|
||||
ready: function(){ return Promise.resolve({}); },
|
||||
onReady: function(cb){ if (typeof cb === 'function') cb({}); return { close: function(){} }; },
|
||||
services: []
|
||||
},
|
||||
identity: {
|
||||
getPublicKey: function(){ return field(call('identity.getPublicKey'), 'pubkey'); },
|
||||
onChanged: function(handler){ /* live identity-change events are a follow-up */ }
|
||||
// Live identity-change events and the rich read API (getProfile/getRelays/...) are a follow-up.
|
||||
onChanged: function(handler){ return { close: function(){} }; }
|
||||
},
|
||||
// keys = keyboard / command action binding (NOT signing). Signing is shell-only via relay.publish.
|
||||
// Not yet wired to the host keyboard, so these are client-side no-ops that keep applets from crashing.
|
||||
keys: {
|
||||
signEvent: function(t){ return field(call('keys.signEvent', { kind: t.kind, tags: t.tags || [], content: t.content || '' }), 'event'); },
|
||||
nip04Encrypt: function(peer, plaintext){ return field(call('keys.nip04Encrypt', { peer: peer, plaintext: plaintext }), 'value'); },
|
||||
nip04Decrypt: function(peer, ciphertext){ return field(call('keys.nip04Decrypt', { peer: peer, ciphertext: ciphertext }), 'value'); },
|
||||
nip44Encrypt: function(peer, plaintext){ return field(call('keys.nip44Encrypt', { peer: peer, plaintext: plaintext }), 'value'); },
|
||||
nip44Decrypt: function(peer, ciphertext){ return field(call('keys.nip44Decrypt', { peer: peer, ciphertext: ciphertext }), 'value'); }
|
||||
registerAction: function(action){ return Promise.resolve({ actionId: 'a' + (actionSeq++) }); },
|
||||
unregisterAction: function(actionId){},
|
||||
onAction: function(actionId, cb){ return { close: function(){} }; }
|
||||
},
|
||||
relay: {
|
||||
publish: function(ev){ return field(call('relay.publish', { event: ev }), 'relays'); },
|
||||
// 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'); },
|
||||
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){
|
||||
return napplet.relay.query(filters).then(function(events){
|
||||
subscribe: function(filters, onEvent, onEose, options){
|
||||
call('relay.subscribe', normFilters(filters)).then(function(m){
|
||||
var events = m.events || [];
|
||||
if (typeof onEvent === 'function') events.forEach(function(ev){ onEvent(ev); });
|
||||
return { close: function(){} };
|
||||
if (typeof onEose === 'function') onEose();
|
||||
});
|
||||
return { close: function(){} };
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
get: function(key){ return field(call('storage.get', { key: key }), 'value'); },
|
||||
set: function(key, value){ return call('storage.set', { key: key, value: value }).then(function(){ return true; }); },
|
||||
remove: function(key){ return call('storage.remove', { key: key }).then(function(){ return true; }); }
|
||||
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'); }
|
||||
},
|
||||
// value.payInvoice is an Amethyst-specific extension (not part of @napplet/shim).
|
||||
value: {
|
||||
payInvoice: function(invoice){ return field(call('value.payInvoice', { invoice: invoice }), 'preimage'); }
|
||||
},
|
||||
resource: {
|
||||
bytes: function(url){ return call('resource.bytes', { url: url }).then(function(m){ return b64ToBytes(m.bytes); }); },
|
||||
bytesAsObjectURL: function(url){ return call('resource.bytes', { url: url }).then(function(m){ return URL.createObjectURL(new Blob([b64ToBytes(m.bytes)], { type: m.contentType || '' })); }); }
|
||||
bytes: function(url){ return call('resource.bytes', { url: url }).then(function(m){ return new Blob([b64ToBytes(m.bytes)], { type: m.mime || '' }); }); },
|
||||
bytesAsObjectURL: function(url){ return call('resource.bytes', { url: url }).then(function(m){ return URL.createObjectURL(new Blob([b64ToBytes(m.bytes)], { type: m.mime || '' })); }); }
|
||||
},
|
||||
// upload.blob is an Amethyst-specific extension (not part of @napplet/shim).
|
||||
upload: {
|
||||
blob: function(bytes, contentType){ return field(call('upload', { bytes: bytesToB64(bytes), contentType: contentType }), 'url'); }
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -58,23 +57,28 @@ object NappletProtocolJson {
|
||||
fun decodeRequest(envelopeJson: String): NappletRequest? {
|
||||
val o = json.parseToJsonElement(envelopeJson).jsonObject
|
||||
return when (o.str("type")) {
|
||||
"shell.supports" -> NappletRequest.ShellSupports(o.req("domain"))
|
||||
"shell.supports" -> NappletRequest.ShellSupports(o.req("domain"), o.str("protocol"))
|
||||
"identity.getPublicKey" -> NappletRequest.GetPublicKey
|
||||
"keys.signEvent" ->
|
||||
NappletRequest.SignEvent(
|
||||
kind = o.getValue("kind").jsonPrimitive.int,
|
||||
tags = decodeTags(o),
|
||||
content = o.str("content") ?: "",
|
||||
"relay.publish" -> {
|
||||
val t = o.template()
|
||||
NappletRequest.Publish(kind = t.kindOf(), tags = decodeTags(t), content = t.str("content") ?: "")
|
||||
}
|
||||
"relay.publishEncrypted" -> {
|
||||
val t = o.template()
|
||||
NappletRequest.PublishEncrypted(
|
||||
kind = t.kindOf(),
|
||||
tags = decodeTags(t),
|
||||
content = t.str("content") ?: "",
|
||||
recipient = o.req("recipient"),
|
||||
encryption = o.str("encryption") ?: "nip44",
|
||||
)
|
||||
"keys.nip04Encrypt" -> NappletRequest.Nip04Encrypt(o.req("peer"), o.req("plaintext"))
|
||||
"keys.nip04Decrypt" -> NappletRequest.Nip04Decrypt(o.req("peer"), o.req("ciphertext"))
|
||||
"keys.nip44Encrypt" -> NappletRequest.Nip44Encrypt(o.req("peer"), o.req("plaintext"))
|
||||
"keys.nip44Decrypt" -> NappletRequest.Nip44Decrypt(o.req("peer"), o.req("ciphertext"))
|
||||
"relay.publish" -> NappletRequest.Publish(Event.fromJson(o.getValue("event").jsonObject.toString()))
|
||||
}
|
||||
"relay.query" -> NappletRequest.QueryEvents(decodeFilter(o))
|
||||
"storage.get" -> NappletRequest.StorageGet(o.req("key"))
|
||||
"storage.set" -> NappletRequest.StorageSet(o.req("key"), o.req("value"))
|
||||
"storage.remove" -> NappletRequest.StorageRemove(o.req("key"))
|
||||
"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.keys" -> NappletRequest.StorageKeys
|
||||
"value.payInvoice" -> NappletRequest.PayInvoice(o.req("invoice"))
|
||||
"resource.bytes" -> NappletRequest.ResourceBytes(o.req("url"))
|
||||
"upload" -> NappletRequest.UploadBlob(Base64.getDecoder().decode(o.req("bytes")), o.req("contentType"))
|
||||
@@ -94,16 +98,11 @@ object NappletProtocolJson {
|
||||
put("ok", true)
|
||||
put("pubkey", response.pubkey)
|
||||
}
|
||||
is NappletResponse.SignedEvent -> {
|
||||
is NappletResponse.Published -> {
|
||||
// Upstream resolves publish() to the signed NostrEvent; relays are an extra.
|
||||
put("ok", true)
|
||||
put("event", json.parseToJsonElement(response.event.toJson()))
|
||||
}
|
||||
is NappletResponse.Text -> {
|
||||
put("ok", true)
|
||||
put("value", response.value)
|
||||
}
|
||||
is NappletResponse.Published -> {
|
||||
put("ok", true)
|
||||
put("eventId", response.event.id)
|
||||
put("relays", buildJsonArray { response.relays.forEach { add(it) } })
|
||||
}
|
||||
is NappletResponse.Events -> {
|
||||
@@ -118,10 +117,14 @@ object NappletProtocolJson {
|
||||
put("ok", true)
|
||||
put("value", response.value)
|
||||
}
|
||||
is NappletResponse.Strings -> {
|
||||
put("ok", true)
|
||||
put("values", buildJsonArray { response.values.forEach { add(it) } })
|
||||
}
|
||||
is NappletResponse.Bytes -> {
|
||||
put("ok", true)
|
||||
put("bytes", Base64.getEncoder().encodeToString(response.bytes))
|
||||
put("contentType", response.contentType)
|
||||
put("mime", response.contentType)
|
||||
}
|
||||
is NappletResponse.Uploaded -> {
|
||||
put("ok", true)
|
||||
@@ -188,4 +191,9 @@ object NappletProtocolJson {
|
||||
private fun JsonObject.req(key: String): String = getValue(key).jsonPrimitive.content
|
||||
|
||||
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
|
||||
|
||||
private fun JsonObject.kindOf(): Int = getValue("kind").jsonPrimitive.int
|
||||
}
|
||||
|
||||
@@ -749,7 +749,7 @@
|
||||
<!-- Napplet capability names -->
|
||||
<string name="napplet_cap_shell">Shell</string>
|
||||
<string name="napplet_cap_identity">Identity</string>
|
||||
<string name="napplet_cap_keys">Signing</string>
|
||||
<string name="napplet_cap_keys">Keyboard actions</string>
|
||||
<string name="napplet_cap_relay">Relays</string>
|
||||
<string name="napplet_cap_storage">Storage</string>
|
||||
<string name="napplet_cap_value">Payments</string>
|
||||
@@ -757,8 +757,8 @@
|
||||
<string name="napplet_cap_upload">Uploads</string>
|
||||
<string name="napplet_cap_shell_desc">Ask which capabilities are available</string>
|
||||
<string name="napplet_cap_identity_desc">Read your public key</string>
|
||||
<string name="napplet_cap_keys_desc">Sign and encrypt as you</string>
|
||||
<string name="napplet_cap_relay_desc">Read and publish your events</string>
|
||||
<string name="napplet_cap_keys_desc">Bind keyboard shortcuts</string>
|
||||
<string name="napplet_cap_relay_desc">Read, and sign & publish your events</string>
|
||||
<string name="napplet_cap_storage_desc">Its own private storage</string>
|
||||
<string name="napplet_cap_value_desc">Pay Lightning invoices</string>
|
||||
<string name="napplet_cap_resource_desc">Fetch web and Blossom resources</string>
|
||||
@@ -770,11 +770,9 @@
|
||||
<string name="napplet_consent_deny_always">Never allow</string>
|
||||
<string name="napplet_consent_not_now">Not now</string>
|
||||
<string name="napplet_consent_get_pubkey">This napplet wants to read your public key.</string>
|
||||
<string name="napplet_consent_sign">This napplet wants to sign a kind %1$d event as you.</string>
|
||||
<string name="napplet_consent_sign_preview">This napplet wants to sign a kind %1$d event as you:</string>
|
||||
<string name="napplet_consent_encrypt">This napplet wants to encrypt a message as you.</string>
|
||||
<string name="napplet_consent_decrypt">This napplet wants to decrypt a message addressed to you.</string>
|
||||
<string name="napplet_consent_publish">This napplet wants to publish an event to your relays.</string>
|
||||
<string name="napplet_consent_publish">This napplet wants to sign and publish a kind %1$d event as you.</string>
|
||||
<string name="napplet_consent_publish_preview">This napplet wants to sign and publish a kind %1$d event as you:</string>
|
||||
<string name="napplet_consent_publish_encrypted">This napplet wants to send an encrypted event as you.</string>
|
||||
<string name="napplet_consent_query">This napplet wants to read events from your relays.</string>
|
||||
<string name="napplet_consent_storage">This napplet wants to use its private storage.</string>
|
||||
<string name="napplet_consent_pay">This napplet wants to pay a Lightning invoice.</string>
|
||||
|
||||
+37
-28
@@ -78,27 +78,23 @@ class NappletProtocolJsonTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodesSignEventUnderKeysDomain() {
|
||||
val req = NappletProtocolJson.decodeRequest("""{"type":"keys.signEvent","id":"1","kind":1,"tags":[["t","x"]],"content":"gm"}""")
|
||||
assertEquals(NappletRequest.SignEvent(1, arrayOf(arrayOf("t", "x")), "gm"), req)
|
||||
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"}}""")
|
||||
assertEquals(NappletRequest.Publish(1, arrayOf(arrayOf("t", "x")), "gm"), req)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodesEncryptDecryptUnderKeysDomain() {
|
||||
assertEquals(NappletRequest.Nip04Encrypt("pk", "hi"), NappletProtocolJson.decodeRequest("""{"type":"keys.nip04Encrypt","peer":"pk","plaintext":"hi"}"""))
|
||||
assertEquals(NappletRequest.Nip44Decrypt("pk", "ct"), NappletProtocolJson.decodeRequest("""{"type":"keys.nip44Decrypt","peer":"pk","ciphertext":"ct"}"""))
|
||||
fun decodesPublishEncrypted() {
|
||||
val req =
|
||||
NappletProtocolJson.decodeRequest(
|
||||
"""{"type":"relay.publishEncrypted","id":"1","template":{"kind":4,"tags":[],"content":"hi"},"recipient":"pk","encryption":"nip04"}""",
|
||||
)
|
||||
assertEquals(NappletRequest.PublishEncrypted(4, emptyArray(), "hi", "pk", "nip04"), req)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodesPublishPreservingTheEvent() {
|
||||
val ev = sampleEvent()
|
||||
val req = NappletProtocolJson.decodeRequest("""{"type":"relay.publish","id":"1","event":${ev.toJson()}}""") as NappletRequest.Publish
|
||||
assertEquals(ev.id, req.event.id)
|
||||
assertEquals("hello", req.event.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodesQueryFromFilterObjectOrFiltersArray() {
|
||||
fun decodesQueryAndSubscribeFromFilterObjectOrFiltersArray() {
|
||||
val single = NappletProtocolJson.decodeRequest("""{"type":"relay.query","filter":{"kinds":[1],"#t":["nostr"],"limit":5}}""") as NappletRequest.QueryEvents
|
||||
assertEquals(listOf(1), single.filter.kinds)
|
||||
assertEquals(listOf("nostr"), single.filter.tags?.get("t"))
|
||||
@@ -106,13 +102,17 @@ class NappletProtocolJsonTest {
|
||||
|
||||
val array = NappletProtocolJson.decodeRequest("""{"type":"relay.query","filters":[{"authors":["aa"]}]}""") as NappletRequest.QueryEvents
|
||||
assertEquals(listOf("aa"), array.filter.authors)
|
||||
|
||||
val sub = NappletProtocolJson.decodeRequest("""{"type":"relay.subscribe","filter":{"kinds":[1]}}""") as NappletRequest.Subscribe
|
||||
assertEquals(listOf(1), sub.filter.kinds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodesStorageOps() {
|
||||
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.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"}"""))
|
||||
assertEquals(NappletRequest.StorageKeys, NappletProtocolJson.decodeRequest("""{"type":"storage.keys"}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -128,13 +128,16 @@ class NappletProtocolJsonTest {
|
||||
@Test
|
||||
fun unknownTypeDecodesToNull() {
|
||||
assertNull(NappletProtocolJson.decodeRequest("""{"type":"inc.emit","id":"1"}"""))
|
||||
// keys.* (keyboard actions) are handled client-side and never cross the boundary.
|
||||
assertNull(NappletProtocolJson.decodeRequest("""{"type":"keys.signEvent","id":"1"}"""))
|
||||
assertNull(NappletProtocolJson.decodeRequest("""{"type":"keys.registerAction","id":"1"}"""))
|
||||
assertNull(NappletProtocolJson.decodeRequest("""{"foo":"bar"}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedOrMissingFieldThrows() {
|
||||
assertThrowsAny { NappletProtocolJson.decodeRequest("not json") }
|
||||
assertThrowsAny { NappletProtocolJson.decodeRequest("""{"type":"keys.signEvent","content":"x"}""") }
|
||||
assertThrowsAny { NappletProtocolJson.decodeRequest("""{"type":"relay.publish","template":{"content":"x"}}""") }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -160,40 +163,46 @@ class NappletProtocolJsonTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodesSignedEventAndEvents() {
|
||||
val signed = json.parseToJsonElement(NappletProtocolJson.encodeResponse("keys.signEvent", NappletResponse.SignedEvent(sampleEvent()))).jsonObject
|
||||
fun encodesPublishedEventAndEvents() {
|
||||
// relay.publish resolves to the signed event (matching upstream NostrEvent return).
|
||||
val published = json.parseToJsonElement(NappletProtocolJson.encodeResponse("relay.publish", NappletResponse.Published(sampleEvent(), listOf("wss://r")))).jsonObject
|
||||
assertEquals(
|
||||
"a".repeat(64),
|
||||
signed["event"]
|
||||
published["event"]
|
||||
?.jsonObject
|
||||
?.get("id")
|
||||
?.jsonPrimitive
|
||||
?.content,
|
||||
)
|
||||
assertEquals("a".repeat(64), published["eventId"]?.jsonPrimitive?.content)
|
||||
assertEquals(1, published["relays"]?.jsonArray?.size)
|
||||
|
||||
val events = json.parseToJsonElement(NappletProtocolJson.encodeResponse("relay.query", NappletResponse.Events(listOf(sampleEvent())))).jsonObject
|
||||
assertEquals(1, events["events"]?.jsonArray?.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodesStorageNullAsJsonNull() {
|
||||
val absent = json.parseToJsonElement(NappletProtocolJson.encodeResponse("storage.get", NappletResponse.StorageValue(null))).jsonObject
|
||||
fun encodesStorageNullAsJsonNullAndKeysAsArray() {
|
||||
val absent = json.parseToJsonElement(NappletProtocolJson.encodeResponse("storage.getItem", NappletResponse.StorageValue(null))).jsonObject
|
||||
assertEquals(JsonNull, absent["value"])
|
||||
|
||||
val keys = json.parseToJsonElement(NappletProtocolJson.encodeResponse("storage.keys", NappletResponse.Strings(listOf("a", "b")))).jsonObject
|
||||
assertEquals(2, keys["values"]?.jsonArray?.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodesBytesAsBase64() {
|
||||
fun encodesBytesAsBase64WithMime() {
|
||||
val o = json.parseToJsonElement(NappletProtocolJson.encodeResponse("resource.bytes", NappletResponse.Bytes("Hi".encodeToByteArray(), "text/plain"))).jsonObject
|
||||
assertEquals("SGk=", o["bytes"]?.jsonPrimitive?.content)
|
||||
assertEquals("text/plain", o["contentType"]?.jsonPrimitive?.content)
|
||||
assertEquals("text/plain", o["mime"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodesErrorsWithOkFalse() {
|
||||
val denied = json.parseToJsonElement(NappletProtocolJson.encodeResponse("keys.signEvent", NappletResponse.Denied(NappletCapability.KEYS, "no"))).jsonObject
|
||||
val denied = json.parseToJsonElement(NappletProtocolJson.encodeResponse("relay.publish", NappletResponse.Denied(NappletCapability.RELAY, "no"))).jsonObject
|
||||
assertFalse(denied["ok"]!!.jsonPrimitive.boolean)
|
||||
assertEquals("denied", denied["error"]?.jsonPrimitive?.content)
|
||||
assertEquals("KEYS", denied["capability"]?.jsonPrimitive?.content)
|
||||
assertEquals("RELAY", denied["capability"]?.jsonPrimitive?.content)
|
||||
|
||||
val unsupported = json.parseToJsonElement(NappletProtocolJson.encodeResponse("upload", NappletResponse.Unsupported("upload"))).jsonObject
|
||||
assertFalse(unsupported["ok"]!!.jsonPrimitive.boolean)
|
||||
|
||||
+65
-48
@@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.commons.napplet.permissions.PermissionDecision
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -43,18 +42,20 @@ import kotlin.coroutines.cancellation.CancellationException
|
||||
* 3. **Consent** — when no standing decision exists, the user is asked.
|
||||
*
|
||||
* Two capability-specific policies refine step 2/3:
|
||||
* - **Per-use capabilities** ([NappletCapability.requiresPerUseConsent], i.e. [NappletCapability.WALLET])
|
||||
* - **Per-use capabilities** ([NappletCapability.requiresPerUseConsent], i.e. [NappletCapability.VALUE])
|
||||
* never auto-approve from a prior grant — every payment is confirmed afresh, with the amount shown.
|
||||
* - **Signer self-gating**: [NappletCapability.IDENTITY] is gated here only when the key lives in
|
||||
* Amethyst (a [NostrSignerInternal]). Remote (NIP-46) and external (NIP-55) signers run their own
|
||||
* per-request consent UI, so we defer to them rather than double-prompt. A standing DENY is still
|
||||
* honored, and the applet must still have *declared* `identity`. This is safe only because the
|
||||
* napplet host runs foreground-only, so the signer's prompt appears in the clear context of the
|
||||
* user interacting with that napplet (it can't be fired from the background).
|
||||
* - **Signer self-gating**: an identity read or a sign-as-user op ([NappletRequest.signsAsUser])
|
||||
* is gated here only when the key lives in Amethyst (a [NostrSignerInternal]). Remote (NIP-46) and
|
||||
* external (NIP-55) signers run their own per-request consent UI, so we defer to them rather than
|
||||
* double-prompt. A standing DENY is still honored, and the applet must still have *declared* the
|
||||
* capability. This is safe only because the napplet host runs foreground-only, so the signer's
|
||||
* prompt appears in the clear context of the user interacting with that napplet (it can't be
|
||||
* fired from the background).
|
||||
*
|
||||
* Security invariants enforced here (never trusted from the applet): the signing identity is
|
||||
* always the host's signer; [NappletRequest.SignEvent] stamps `created_at` from the host clock;
|
||||
* a response never contains private key bytes; storage is namespaced per applet coordinate.
|
||||
* always the host's signer; the napplet only ever supplies an unsigned template — the shell signs
|
||||
* it and stamps `created_at` from the host clock; a response never contains private key bytes;
|
||||
* storage is namespaced per applet coordinate.
|
||||
*/
|
||||
class NappletBroker(
|
||||
private val signer: NostrSigner,
|
||||
@@ -96,8 +97,8 @@ class NappletBroker(
|
||||
|
||||
val authorized =
|
||||
when {
|
||||
// Remote/external signers run their own per-request consent UI — defer identity to them.
|
||||
signerSelfGates(capability) -> true
|
||||
// Remote/external signers run their own per-request consent UI — defer to them.
|
||||
signerSelfGates(request) -> true
|
||||
// A standing allow short-circuits, except for per-use capabilities (e.g. payments).
|
||||
ledger.decide(identity, capability) == PermissionDecision.ALLOW && !capability.requiresPerUseConsent -> true
|
||||
else -> {
|
||||
@@ -118,8 +119,8 @@ class NappletBroker(
|
||||
}
|
||||
}
|
||||
|
||||
/** Identity/key ops are gated by us only when we hold the key; remote/external signers gate themselves. */
|
||||
private fun signerSelfGates(capability: NappletCapability): Boolean = (capability == NappletCapability.IDENTITY || capability == NappletCapability.KEYS) && signer !is NostrSignerInternal
|
||||
/** Identity reads and sign-as-user ops are gated by us only when we hold the key; remote/external signers gate themselves. */
|
||||
private fun signerSelfGates(request: NappletRequest): Boolean = (request.capability == NappletCapability.IDENTITY || request.signsAsUser) && signer !is NostrSignerInternal
|
||||
|
||||
/** Downgrades a grant to one-shot when the capability forbids persisting that scope (e.g. payments). */
|
||||
private fun effectiveGrant(
|
||||
@@ -142,48 +143,52 @@ class NappletBroker(
|
||||
|
||||
is NappletRequest.GetPublicKey -> NappletResponse.PublicKey(signer.pubKey)
|
||||
|
||||
is NappletRequest.SignEvent -> {
|
||||
// created_at comes from the host, never the applet, so it cannot backdate.
|
||||
val signed: Event = signer.sign(TimeUtils.now(), request.kind, request.tags, request.content)
|
||||
NappletResponse.SignedEvent(signed)
|
||||
// The napplet supplies an unsigned template; the shell signs and publishes it.
|
||||
// created_at comes from the host, never the applet, so it cannot backdate.
|
||||
is NappletRequest.Publish -> signAndPublish(request.kind, request.tags, request.content)
|
||||
|
||||
is NappletRequest.PublishEncrypted -> {
|
||||
val ciphertext =
|
||||
when (request.encryption.trim().lowercase()) {
|
||||
"nip04" -> signer.nip04Encrypt(request.content, request.recipient)
|
||||
else -> signer.nip44Encrypt(request.content, request.recipient)
|
||||
}
|
||||
signAndPublish(request.kind, withRecipientTag(request.tags, request.recipient), ciphertext)
|
||||
}
|
||||
|
||||
is NappletRequest.Nip04Encrypt ->
|
||||
NappletResponse.Text(signer.nip04Encrypt(request.plaintext, request.peerPubKey))
|
||||
|
||||
is NappletRequest.Nip04Decrypt ->
|
||||
NappletResponse.Text(signer.nip04Decrypt(request.ciphertext, request.peerPubKey))
|
||||
|
||||
is NappletRequest.Nip44Encrypt ->
|
||||
NappletResponse.Text(signer.nip44Encrypt(request.plaintext, request.peerPubKey))
|
||||
|
||||
is NappletRequest.Nip44Decrypt ->
|
||||
NappletResponse.Text(signer.nip44Decrypt(request.ciphertext, request.peerPubKey))
|
||||
|
||||
is NappletRequest.Publish -> publish(request.event)
|
||||
|
||||
is NappletRequest.QueryEvents -> {
|
||||
val gateway = relay ?: return NappletResponse.Unsupported("query")
|
||||
val gateway = relay ?: return NappletResponse.Unsupported("relay.query")
|
||||
NappletResponse.Events(gateway.query(request.filter))
|
||||
}
|
||||
|
||||
// Live tailing is a follow-up; for now subscribe returns the initial matches.
|
||||
is NappletRequest.Subscribe -> {
|
||||
val gateway = relay ?: return NappletResponse.Unsupported("relay.subscribe")
|
||||
NappletResponse.Events(gateway.query(request.filter))
|
||||
}
|
||||
|
||||
is NappletRequest.StorageGet -> {
|
||||
val store = storage ?: return NappletResponse.Unsupported("storage.get")
|
||||
val store = storage ?: return NappletResponse.Unsupported("storage.getItem")
|
||||
NappletResponse.StorageValue(store.get(identity.coordinate, request.key))
|
||||
}
|
||||
|
||||
is NappletRequest.StorageSet -> {
|
||||
val store = storage ?: return NappletResponse.Unsupported("storage.set")
|
||||
val store = storage ?: return NappletResponse.Unsupported("storage.setItem")
|
||||
store.set(identity.coordinate, request.key, request.value)
|
||||
NappletResponse.Done
|
||||
}
|
||||
|
||||
is NappletRequest.StorageRemove -> {
|
||||
val store = storage ?: return NappletResponse.Unsupported("storage.remove")
|
||||
val store = storage ?: return NappletResponse.Unsupported("storage.removeItem")
|
||||
store.remove(identity.coordinate, request.key)
|
||||
NappletResponse.Done
|
||||
}
|
||||
|
||||
is NappletRequest.StorageKeys -> {
|
||||
val store = storage ?: return NappletResponse.Unsupported("storage.keys")
|
||||
NappletResponse.Strings(store.keys(identity.coordinate))
|
||||
}
|
||||
|
||||
is NappletRequest.PayInvoice -> {
|
||||
val gateway = wallet ?: return NappletResponse.Unsupported("value.payInvoice")
|
||||
NappletResponse.Paid(gateway.payInvoice(request.invoice))
|
||||
@@ -202,17 +207,29 @@ class NappletBroker(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun publish(event: Event): NappletResponse {
|
||||
val gateway = relay ?: return NappletResponse.Unsupported("publish")
|
||||
|
||||
// An applet may only publish as the active user, and only validly-signed events.
|
||||
if (event.pubKey != signer.pubKey) {
|
||||
return NappletResponse.Failed("Refusing to publish an event for a different identity.")
|
||||
}
|
||||
if (!event.verify()) {
|
||||
return NappletResponse.Failed("Refusing to publish an event with an invalid signature.")
|
||||
}
|
||||
|
||||
return NappletResponse.Published(gateway.publish(event))
|
||||
/**
|
||||
* Signs a napplet-supplied template **as the active user** and publishes it. The applet never
|
||||
* sees a key, can never sign as another identity (the signer fixes `pubkey`), and cannot
|
||||
* backdate (`created_at` is the host clock).
|
||||
*/
|
||||
private suspend fun signAndPublish(
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
): NappletResponse {
|
||||
val gateway = relay ?: return NappletResponse.Unsupported("relay.publish")
|
||||
val signed: Event = signer.sign(TimeUtils.now(), kind, tags, content)
|
||||
return NappletResponse.Published(signed, gateway.publish(signed))
|
||||
}
|
||||
|
||||
/** Ensures the encrypted event addresses [recipient] with a `p` tag, without duplicating one. */
|
||||
private fun withRecipientTag(
|
||||
tags: Array<Array<String>>,
|
||||
recipient: String,
|
||||
): Array<Array<String>> =
|
||||
if (tags.any { it.size >= 2 && it[0] == "p" && it[1] == recipient }) {
|
||||
tags
|
||||
} else {
|
||||
tags + arrayOf(arrayOf("p", recipient))
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -75,6 +75,9 @@ interface NappletStorage {
|
||||
coordinate: String,
|
||||
key: String,
|
||||
)
|
||||
|
||||
/** Lists the keys this applet (identified by [coordinate]) has stored. */
|
||||
suspend fun keys(coordinate: String): List<String>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+9
-5
@@ -37,10 +37,14 @@ enum class NappletCapability {
|
||||
/** `identity` — read-only identity queries (`getPublicKey`, `onChanged`). */
|
||||
IDENTITY,
|
||||
|
||||
/** `keys` — sign events and NIP-04/44 encrypt/decrypt as the user. */
|
||||
/**
|
||||
* `keys` — keyboard / command action binding (`registerAction`, `onAction`). This is **not**
|
||||
* signing: the upstream `@napplet/shim` deliberately has no `sign()` method, and napplets never
|
||||
* get direct key access. Signing happens only inside the shell via [RELAY] `publish`.
|
||||
*/
|
||||
KEYS,
|
||||
|
||||
/** `relay` — publish, query, and subscribe to the user's relays. */
|
||||
/** `relay` — publish (shell-signed), query, and subscribe to the user's relays. */
|
||||
RELAY,
|
||||
|
||||
/** `storage` — a per-applet sandboxed key-value store, namespaced by applet identity. */
|
||||
@@ -82,11 +86,11 @@ enum class NappletCapability {
|
||||
when (domain.trim().lowercase()) {
|
||||
"shell" -> SHELL
|
||||
"identity" -> IDENTITY
|
||||
"keys", "sign", "signer", "nip04", "nip44" -> KEYS
|
||||
"keys" -> KEYS
|
||||
"relay", "relays" -> RELAY
|
||||
"storage" -> STORAGE
|
||||
"value", "wallet", "zap", "zaps", "payments" -> VALUE
|
||||
"resource", "fetch", "net", "network" -> RESOURCE
|
||||
"value" -> VALUE
|
||||
"resource" -> RESOURCE
|
||||
"upload" -> UPLOAD
|
||||
else -> null
|
||||
}
|
||||
|
||||
+73
-44
@@ -21,7 +21,6 @@
|
||||
package com.vitorpamplona.amethyst.commons.napplet.protocol
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
|
||||
@@ -38,33 +37,44 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
sealed interface NappletRequest {
|
||||
val capability: NappletCapability
|
||||
|
||||
/**
|
||||
* Whether executing this request **signs an event as the user**. The shell — never the napplet
|
||||
* — holds the key and does the signing (matching `@napplet/shim`, which has no `sign()`). This
|
||||
* flag lets the broker defer the per-signature prompt to a remote/external signer that runs its
|
||||
* own consent UI, instead of double-prompting.
|
||||
*/
|
||||
val signsAsUser: Boolean get() = false
|
||||
|
||||
/** Read the active user's public key. */
|
||||
data object GetPublicKey : NappletRequest {
|
||||
override val capability get() = NappletCapability.IDENTITY
|
||||
}
|
||||
|
||||
/** `shell.supports(domain)` — capability negotiation; always answerable, needs no consent. */
|
||||
/** `shell.supports(domain, protocol?)` — capability negotiation; always answerable, no consent. */
|
||||
data class ShellSupports(
|
||||
val domain: String,
|
||||
val protocol: String? = null,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.SHELL
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and sign an event **as the active user**. The applet supplies only the template
|
||||
* fields; the broker sets `pubkey` from the real signer and stamps `created_at`, so the
|
||||
* applet can never sign as another identity nor backdate.
|
||||
* Publish an event built from an **unsigned template**. The napplet supplies only `kind`,
|
||||
* `tags`, and `content`; the shell sets `pubkey` from the real signer, stamps `created_at`,
|
||||
* signs, and broadcasts — so a napplet can never sign as another identity, backdate, nor
|
||||
* obtain a raw signature. This is the *only* signing path exposed to napplets.
|
||||
*/
|
||||
data class SignEvent(
|
||||
data class Publish(
|
||||
val kind: Int,
|
||||
val tags: Array<Array<String>>,
|
||||
val content: String,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.KEYS
|
||||
override val capability get() = NappletCapability.RELAY
|
||||
override val signsAsUser get() = true
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is SignEvent) return false
|
||||
if (other !is Publish) return false
|
||||
if (kind != other.kind) return false
|
||||
if (content != other.content) return false
|
||||
if (tags.size != other.tags.size) return false
|
||||
@@ -80,42 +90,41 @@ sealed interface NappletRequest {
|
||||
}
|
||||
}
|
||||
|
||||
data class Nip04Encrypt(
|
||||
val peerPubKey: HexKey,
|
||||
val plaintext: String,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.KEYS
|
||||
}
|
||||
|
||||
data class Nip04Decrypt(
|
||||
val peerPubKey: HexKey,
|
||||
val ciphertext: String,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.KEYS
|
||||
}
|
||||
|
||||
data class Nip44Encrypt(
|
||||
val peerPubKey: HexKey,
|
||||
val plaintext: String,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.KEYS
|
||||
}
|
||||
|
||||
data class Nip44Decrypt(
|
||||
val peerPubKey: HexKey,
|
||||
val ciphertext: String,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.KEYS
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an already-signed [event] to the user's relays. The broker verifies the
|
||||
* signature and that the event belongs to the active user before publishing.
|
||||
* Encrypt [content] to [recipient] with [encryption] (`"nip44"`, default, or `"nip04"`), then
|
||||
* build, sign, and publish the event. The shell holds the key and performs both the encryption
|
||||
* and the signing; the napplet supplies only plaintext.
|
||||
*/
|
||||
data class Publish(
|
||||
val event: Event,
|
||||
data class PublishEncrypted(
|
||||
val kind: Int,
|
||||
val tags: Array<Array<String>>,
|
||||
val content: String,
|
||||
val recipient: HexKey,
|
||||
val encryption: String,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.RELAY
|
||||
override val signsAsUser get() = true
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is PublishEncrypted) return false
|
||||
if (kind != other.kind) return false
|
||||
if (content != other.content) return false
|
||||
if (recipient != other.recipient) return false
|
||||
if (encryption != other.encryption) return false
|
||||
if (tags.size != other.tags.size) return false
|
||||
for (i in tags.indices) if (!tags[i].contentEquals(other.tags[i])) return false
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = kind
|
||||
result = 31 * result + content.hashCode()
|
||||
result = 31 * result + recipient.hashCode()
|
||||
result = 31 * result + encryption.hashCode()
|
||||
result = 31 * result + tags.sumOf { it.contentHashCode() }
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Read events matching [filter] (from the cache and/or a bounded relay fetch). */
|
||||
@@ -125,14 +134,24 @@ sealed interface NappletRequest {
|
||||
override val capability get() = NappletCapability.RELAY
|
||||
}
|
||||
|
||||
/** Read a value from this napplet's sandboxed key-value store. */
|
||||
/**
|
||||
* Subscribe to events matching [filter]. The shell currently answers with the initial matches
|
||||
* (like a query); a live tail over the existing reply channel is a follow-up.
|
||||
*/
|
||||
data class Subscribe(
|
||||
val filter: Filter,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.RELAY
|
||||
}
|
||||
|
||||
/** Read a value from this napplet's sandboxed key-value store (`storage.getItem`). */
|
||||
data class StorageGet(
|
||||
val key: String,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.STORAGE
|
||||
}
|
||||
|
||||
/** Write a value to this napplet's sandboxed key-value store. */
|
||||
/** Write a value to this napplet's sandboxed key-value store (`storage.setItem`). */
|
||||
data class StorageSet(
|
||||
val key: String,
|
||||
val value: String,
|
||||
@@ -140,14 +159,24 @@ sealed interface NappletRequest {
|
||||
override val capability get() = NappletCapability.STORAGE
|
||||
}
|
||||
|
||||
/** Remove a value from this napplet's sandboxed key-value store. */
|
||||
/** Remove a value from this napplet's sandboxed key-value store (`storage.removeItem`). */
|
||||
data class StorageRemove(
|
||||
val key: String,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.STORAGE
|
||||
}
|
||||
|
||||
/** Pay a BOLT-11 invoice from the user's wallet (`value` domain). */
|
||||
/** List the keys this napplet has stored (`storage.keys`). */
|
||||
data object StorageKeys : NappletRequest {
|
||||
override val capability get() = NappletCapability.STORAGE
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay a BOLT-11 invoice from the user's wallet (`value.payInvoice`). This is an
|
||||
* **Amethyst-specific extension** — the upstream `value` domain models zaps/value-transfer
|
||||
* differently — kept because a real napplet built against `@napplet/shim` never calls it, so
|
||||
* it cannot conflict.
|
||||
*/
|
||||
data class PayInvoice(
|
||||
val invoice: String,
|
||||
) : NappletRequest {
|
||||
|
||||
+11
-9
@@ -35,16 +35,13 @@ sealed interface NappletResponse {
|
||||
val pubkey: HexKey,
|
||||
) : NappletResponse
|
||||
|
||||
data class SignedEvent(
|
||||
val event: Event,
|
||||
) : NappletResponse
|
||||
|
||||
/** Result of an encrypt/decrypt operation. */
|
||||
data class Text(
|
||||
val value: String,
|
||||
) : NappletResponse
|
||||
|
||||
/**
|
||||
* Result of `relay.publish` / `relay.publishEncrypted`: the [event] the shell signed on the
|
||||
* napplet's behalf and the [relays] that accepted it. Matches the upstream contract, where
|
||||
* `publish(template)` resolves to the signed `NostrEvent`.
|
||||
*/
|
||||
data class Published(
|
||||
val event: Event,
|
||||
val relays: List<String>,
|
||||
) : NappletResponse
|
||||
|
||||
@@ -63,6 +60,11 @@ sealed interface NappletResponse {
|
||||
val value: String?,
|
||||
) : NappletResponse
|
||||
|
||||
/** Result of `storage.keys` (and other string-list reads). */
|
||||
data class Strings(
|
||||
val values: List<String>,
|
||||
) : NappletResponse
|
||||
|
||||
/** Result of a `resource.bytes` fetch. */
|
||||
data class Bytes(
|
||||
val bytes: ByteArray,
|
||||
|
||||
+31
-40
@@ -47,9 +47,6 @@ class NappletBrokerTest {
|
||||
private val userPriv = "0000000000000000000000000000000000000000000000000000000000000007"
|
||||
private val signer = NostrSignerInternal(KeyPair(userPriv.hexToByteArray()))
|
||||
|
||||
private val strangerPriv = "0000000000000000000000000000000000000000000000000000000000000019"
|
||||
private val stranger = NostrSignerInternal(KeyPair(strangerPriv.hexToByteArray()))
|
||||
|
||||
private val applet = NappletIdentity(authorPubKey = "aa".repeat(32), identifier = "demo")
|
||||
|
||||
private val allDeclared = NappletCapability.entries.toSet()
|
||||
@@ -164,6 +161,8 @@ class NappletBrokerTest {
|
||||
) {
|
||||
data.remove(k(coordinate, key))
|
||||
}
|
||||
|
||||
override suspend fun keys(coordinate: String): List<String> = data.keys.filter { it.startsWith("$coordinate::") }.map { it.removePrefix("$coordinate::") }
|
||||
}
|
||||
|
||||
private fun broker(
|
||||
@@ -228,66 +227,42 @@ class NappletBrokerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signEventProducesAValidlySignedEventAsTheUser() =
|
||||
fun publishSignsTheTemplateAsTheUserAndSends() =
|
||||
runTest {
|
||||
val request = NappletRequest.SignEvent(kind = 1, tags = arrayOf(arrayOf("t", "napplet")), content = "gm")
|
||||
val response = broker(ScriptedPrompt(GrantState.ALLOW_ONCE)).handle(applet, request, allDeclared)
|
||||
val relay = RecordingRelay()
|
||||
val request = NappletRequest.Publish(kind = 1, tags = arrayOf(arrayOf("t", "napplet")), content = "gm")
|
||||
|
||||
assertIs<NappletResponse.SignedEvent>(response)
|
||||
val response = broker(ScriptedPrompt(GrantState.ALLOW_ONCE), relay = relay).handle(applet, request, allDeclared)
|
||||
|
||||
// The shell signs the unsigned template and resolves to the signed event + relays.
|
||||
assertIs<NappletResponse.Published>(response)
|
||||
val event = response.event
|
||||
assertEquals(signer.pubKey, event.pubKey) // applet cannot sign as anyone but the user
|
||||
assertEquals(signer.pubKey, event.pubKey) // applet supplied no pubkey; the shell fixed it to the user
|
||||
assertEquals(1, event.kind)
|
||||
assertEquals("gm", event.content)
|
||||
assertTrue(event.verify()) // id + signature are real
|
||||
assertEquals(listOf("wss://relay.example"), response.relays)
|
||||
assertEquals(1, relay.published.size)
|
||||
assertEquals(event.id, relay.published.first().id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publishWithoutAGatewayIsUnsupported() =
|
||||
runTest {
|
||||
val event: Event = signer.sign(1L, 1, emptyArray(), "hi")
|
||||
val response =
|
||||
broker(ScriptedPrompt(GrantState.ALLOW_ONCE), relay = null)
|
||||
.handle(applet, NappletRequest.Publish(event), allDeclared)
|
||||
.handle(applet, NappletRequest.Publish(1, emptyArray(), "hi"), allDeclared)
|
||||
assertIs<NappletResponse.Unsupported>(response)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publishRefusesAnEventFromAnotherIdentity() =
|
||||
runTest {
|
||||
val foreign: Event = stranger.sign(1L, 1, emptyArray(), "not yours")
|
||||
val relay = RecordingRelay()
|
||||
|
||||
val response =
|
||||
broker(ScriptedPrompt(GrantState.ALLOW_ONCE), relay = relay)
|
||||
.handle(applet, NappletRequest.Publish(foreign), allDeclared)
|
||||
|
||||
assertIs<NappletResponse.Failed>(response)
|
||||
assertTrue(relay.published.isEmpty()) // nothing left the broker
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publishSendsAValidUserEventThroughTheGateway() =
|
||||
runTest {
|
||||
val event: Event = signer.sign(1L, 1, emptyArray(), "ship it")
|
||||
val relay = RecordingRelay()
|
||||
|
||||
val response =
|
||||
broker(ScriptedPrompt(GrantState.ALLOW_ONCE), relay = relay)
|
||||
.handle(applet, NappletRequest.Publish(event), allDeclared)
|
||||
|
||||
assertEquals(NappletResponse.Published(listOf("wss://relay.example")), response)
|
||||
assertEquals(1, relay.published.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayDenyDoesNotReachTheGateway() =
|
||||
runTest {
|
||||
val event: Event = signer.sign(1L, 1, emptyArray(), "blocked")
|
||||
val relay = RecordingRelay()
|
||||
|
||||
val response =
|
||||
broker(ScriptedPrompt(GrantState.DENY), relay = relay)
|
||||
.handle(applet, NappletRequest.Publish(event), allDeclared)
|
||||
.handle(applet, NappletRequest.Publish(1, emptyArray(), "blocked"), allDeclared)
|
||||
|
||||
assertIs<NappletResponse.Denied>(response)
|
||||
assertTrue(relay.published.isEmpty())
|
||||
@@ -322,6 +297,22 @@ class NappletBrokerTest {
|
||||
assertEquals(NappletResponse.StorageValue(null), broker.handle(applet, NappletRequest.StorageGet("k"), allDeclared))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun storageKeysListsOnlyThisAppletsKeys() =
|
||||
runTest {
|
||||
val storage = MapStorage()
|
||||
val broker = broker(ScriptedPrompt(GrantState.ALLOW_ALWAYS), storage = storage)
|
||||
val other = NappletIdentity(authorPubKey = "bb".repeat(32), identifier = "other")
|
||||
|
||||
broker.handle(applet, NappletRequest.StorageSet("a", "1"), allDeclared)
|
||||
broker.handle(applet, NappletRequest.StorageSet("b", "2"), allDeclared)
|
||||
broker.handle(other, NappletRequest.StorageSet("c", "3"), allDeclared)
|
||||
|
||||
val response = broker.handle(applet, NappletRequest.StorageKeys, allDeclared)
|
||||
assertIs<NappletResponse.Strings>(response)
|
||||
assertEquals(setOf("a", "b"), response.values.toSet()) // never sees the other applet's "c"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun storageWithoutAGatewayIsUnsupported() =
|
||||
runTest {
|
||||
|
||||
Reference in New Issue
Block a user