diff --git a/amethyst/plans/2026-06-20-napplet-ecosystem-audit.md b/amethyst/plans/2026-06-20-napplet-ecosystem-audit.md index cbdb935292..e3e0de74b8 100644 --- a/amethyst/plans/2026-06-20-napplet-ecosystem-audit.md +++ b/amethyst/plans/2026-06-20-napplet-ecosystem-audit.md @@ -110,7 +110,40 @@ should keep them. 6. Lower priority / app-specific: `theme`, `notify`, `media`, `config`, `outbox`, `intent`, `inc`, `ifc`, `cvm`. -## Verdict +## Update (2026-06-20): ecosystem alignment landed + +Acted on #1–#5. The dialect mismatch is resolved: + +- **Envelope** is now `{type:".", id}` → `{type:"…​.result", id, ok, …}`, + matching upstream (codec + host shuttle + shim rewritten; round-trip unit-tested). +- **Namespaced `window.napplet.*`** shim: `shell.supports`, `identity.getPublicKey` + (+`onChanged` stub), `keys.{signEvent,nip04*,nip44*}`, `relay.{publish,query,subscribe}`, + `storage.{get,set,remove}`, `value.payInvoice`, `resource.{bytes,bytesAsObjectURL}`, + `upload.blob`. The applet's own SDK-targeted code now runs unchanged. +- **`shell.supports(domain)`** implemented (no consent; reflects declared+brokered domains). +- **Capabilities split** to the domain model: `SHELL`, `IDENTITY`, `KEYS`, `RELAY`, + `STORAGE`, `VALUE`, `RESOURCE`, `UPLOAD` (was `IDENTITY/RELAY/WALLET/STORAGE/NET`). +- **`resource.bytes`** implemented for `https`/`data` (broker-fetched, Tor-routed, + consent-gated); `blossom:`/`nostr:` are a follow-up. + +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 + auth event + server selection, which needs on-device verification. +- **Live push** — `relay.subscribe` returns initial matches via `query`; a live tail and + `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. +- **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. + +## Verdict (original assessment, pre-update) - **As a secure NIP-5A/5D renderer + broker:** ~85% — the hard, security-critical parts are done and tested; gaps are on-device verification and breadth of ops. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index 0a46915e9d..d5e6af9404 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -31,6 +31,7 @@ import android.os.Message import android.os.Messenger import android.os.Process import android.os.RemoteException +import android.util.Base64 import android.util.Log import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R @@ -39,6 +40,8 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.amethyst.commons.napplet.NappletConsentPrompt import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.commons.napplet.NappletRelayGateway +import com.vitorpamplona.amethyst.commons.napplet.NappletResource +import com.vitorpamplona.amethyst.commons.napplet.NappletResourceGateway import com.vitorpamplona.amethyst.commons.napplet.NappletWalletGateway import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest @@ -58,7 +61,13 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import okhttp3.OkHttpClient +import okhttp3.Request +import java.net.InetSocketAddress +import java.net.Proxy +import java.net.URLDecoder /** * The trust boundary's main-process endpoint. The untrusted `:napplet` process binds this @@ -109,9 +118,10 @@ class NappletBrokerService : Service() { ) val declared = parseDeclared(data.getString(NappletIpc.KEY_DECLARED)) + val requestType = runCatching { NappletProtocolJson.readType(payload) }.getOrNull() ?: "napplet" scope.launch { val response = process(identity, declared, payload) - reply(replyTo, requestId, NappletProtocolJson.encodeResponse(response)) + reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, response)) } return true } @@ -161,7 +171,67 @@ class NappletBrokerService : Service() { val wallet = NappletWalletGateway { invoice -> payInvoiceViaNwc(account, invoice) } - return NappletBroker(account.signer, ledger, consent, relay, storage, wallet) + val resource = NappletResourceGateway { url -> fetchResource(account, url) } + + // upload is intentionally not provided yet: a correct Blossom upload needs a content Uri, + // a signed authorization event, and server selection — wired end-to-end (protocol/shim) but + // the Android gateway is a follow-up that needs on-device verification. + return NappletBroker(account.signer, ledger, consent, relay, storage, wallet, resource, upload = null) + } + + /** Fetches an https/data resource on the applet's behalf (it has no direct network). */ + private suspend fun fetchResource( + account: Account, + url: String, + ): NappletResource? = + withContext(Dispatchers.IO) { + when { + url.startsWith("data:") -> decodeDataUrl(url) + url.startsWith("https://") -> { + val port = account.let { Amethyst.instance.torManager.activePortOrNull.value } ?: -1 + val client = + if (port > 0) { + OkHttpClient.Builder().proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))).build() + } else { + OkHttpClient() + } + runCatching { + client + .newCall( + Request + .Builder() + .url(url) + .get() + .build(), + ).execute() + .use { r -> + if (!r.isSuccessful) return@withContext null + val body = r.body.bytes() + val type = r.header("Content-Type") ?: "application/octet-stream" + NappletResource(body, type) + } + }.getOrNull() + } + // blossom: / nostr: schemes are a follow-up. + else -> null + } + } + + /** Parses a `data:[][;base64],` URL into bytes + content type. */ + private fun decodeDataUrl(url: String): NappletResource? { + val comma = url.indexOf(',') + if (comma < 0) return null + val meta = url.substring("data:".length, comma) + val data = url.substring(comma + 1) + val isBase64 = meta.endsWith(";base64") + val contentType = meta.removeSuffix(";base64").ifEmpty { "text/plain" } + val bytes = + if (isBase64) { + runCatching { Base64.decode(data, Base64.DEFAULT) }.getOrNull() ?: return null + } else { + URLDecoder.decode(data, "UTF-8").encodeToByteArray() + } + return NappletResource(bytes, contentType) } /** Bounded live relay fetch (EOSE/timeout) merged with the local cache, newest-first. */ @@ -252,6 +322,10 @@ class NappletBrokerService : Service() { getString(R.string.napplet_consent_pay) } } + is NappletRequest.ResourceBytes -> getString(R.string.napplet_consent_resource) + is NappletRequest.UploadBlob -> getString(R.string.napplet_consent_upload) + // Negotiation is resolved in the broker before consent; never shown to the user. + is NappletRequest.ShellSupports -> "" } private fun reply( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt index 799828b674..6a09fdc23e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt @@ -28,20 +28,26 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability @StringRes fun NappletCapability.labelRes(): Int = when (this) { + NappletCapability.SHELL -> R.string.napplet_cap_shell NappletCapability.IDENTITY -> R.string.napplet_cap_identity + NappletCapability.KEYS -> R.string.napplet_cap_keys NappletCapability.RELAY -> R.string.napplet_cap_relay - NappletCapability.WALLET -> R.string.napplet_cap_wallet NappletCapability.STORAGE -> R.string.napplet_cap_storage - NappletCapability.NET -> R.string.napplet_cap_net + NappletCapability.VALUE -> R.string.napplet_cap_value + NappletCapability.RESOURCE -> R.string.napplet_cap_resource + NappletCapability.UPLOAD -> R.string.napplet_cap_upload } /** Localized one-line description of what a capability lets a napplet do. */ @StringRes fun NappletCapability.descriptionRes(): Int = when (this) { + NappletCapability.SHELL -> R.string.napplet_cap_shell_desc NappletCapability.IDENTITY -> R.string.napplet_cap_identity_desc + NappletCapability.KEYS -> R.string.napplet_cap_keys_desc NappletCapability.RELAY -> R.string.napplet_cap_relay_desc - NappletCapability.WALLET -> R.string.napplet_cap_wallet_desc NappletCapability.STORAGE -> R.string.napplet_cap_storage_desc - NappletCapability.NET -> R.string.napplet_cap_net_desc + NappletCapability.VALUE -> R.string.napplet_cap_value_desc + NappletCapability.RESOURCE -> R.string.napplet_cap_resource_desc + NappletCapability.UPLOAD -> R.string.napplet_cap_upload_desc } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt index eba749f265..64f508654a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt @@ -321,9 +321,10 @@ class NappletHostActivity : ComponentActivity() { bridgeReplyProxy = replyProxy val raw = message.data ?: return + // The applet sends a full upstream envelope {type, id, ...}; we forward it verbatim and + // correlate on its id. The broker reads `type` to decode and to build the .result reply. val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return val id = envelope.optString("id").ifEmpty { return } - val payload = envelope.optString("payload").ifEmpty { return } val msg = Message.obtain(null, NappletIpc.MSG_REQUEST).apply { @@ -331,7 +332,7 @@ class NappletHostActivity : ComponentActivity() { data = Bundle().apply { putString(NappletIpc.KEY_REQUEST_ID, id) - putString(NappletIpc.KEY_PAYLOAD, payload) + putString(NappletIpc.KEY_PAYLOAD, raw) putString(NappletIpc.KEY_AUTHOR, author) putString(NappletIpc.KEY_IDENTIFIER, identifier) putString(NappletIpc.KEY_AGGREGATE_HASH, aggregateHash) @@ -361,12 +362,10 @@ class NappletHostActivity : ComponentActivity() { val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true - val envelope = - JSONObject().apply { - put("id", id) - put("response", payload) - } - bridgeReplyProxy?.postMessage(envelope.toString()) + // payload is the broker's {type:"...result", ok, ...}; inject the correlation id for the shim. + val result = runCatching { JSONObject(payload) }.getOrNull() ?: JSONObject() + result.put("id", id) + bridgeReplyProxy?.postMessage(result.toString()) return true } @@ -414,15 +413,20 @@ class NappletHostActivity : ComponentActivity() { "media-src 'self' https://napplet.local blob: data:; " + "connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none'" + // Namespaced window.napplet.* matching the upstream @napplet/web SDK, over the + // {type:"domain.action", id} envelope. The applet's own code (built against that SDK) + // therefore runs unchanged on this shell. private const val SHIM_JS = """ (function(){ if (window.__nappletShimInstalled) return; window.__nappletShimInstalled = true; var seq = 0, pending = {}; - function call(payload){ - return new Promise(function(resolve){ + function call(type, fields){ + return new Promise(function(resolve, reject){ var id = 'r' + (seq++); - pending[id] = resolve; - parent.postMessage(JSON.stringify({ id: id, payload: JSON.stringify(payload) }), '*'); + pending[id] = { resolve: resolve, reject: reject }; + var env = { type: type, id: id }; + if (fields) for (var k in fields) env[k] = fields[k]; + parent.postMessage(JSON.stringify(env), '*'); }); } window.addEventListener('message', function(e){ @@ -430,35 +434,57 @@ class NappletHostActivity : ComponentActivity() { if (typeof e.data !== 'string') return; var msg; try { msg = JSON.parse(e.data); } catch (_) { return; } if (!msg || !msg.id) return; - var cb = pending[msg.id]; if (!cb) return; delete pending[msg.id]; - var resp; try { resp = JSON.parse(msg.response); } catch (_) { resp = { type: 'failed', reason: 'bad response' }; } - cb(resp); - }); - function fail(r){ var e = new Error((r && r.reason) || (r && r.type) || 'napplet error'); e.napplet = r; throw e; } - function pubkey(r){ if (r.type === 'publicKey') return r.pubkey; fail(r); } - function evt(r){ if (r.type === 'signedEvent') return r.event; fail(r); } - function text(r){ if (r.type === 'text') return r.value; fail(r); } - function published(r){ if (r.type === 'published') return r.relays; fail(r); } - function events(r){ if (r.type === 'events') return r.events; fail(r); } - function storageValue(r){ if (r.type === 'storageValue') return r.value; fail(r); } - function paid(r){ if (r.type === 'paid') return r.preimage; fail(r); } - function done(r){ if (r.type === 'done') return true; fail(r); } - window.napplet = Object.freeze({ - getPublicKey: function(){ return call({ op: 'getPublicKey' }).then(pubkey); }, - signEvent: function(t){ return call({ op: 'signEvent', kind: t.kind, tags: t.tags || [], content: t.content || '' }).then(evt); }, - nip04Encrypt: function(peer, plaintext){ return call({ op: 'nip04Encrypt', peer: peer, plaintext: plaintext }).then(text); }, - nip04Decrypt: function(peer, ciphertext){ return call({ op: 'nip04Decrypt', peer: peer, ciphertext: ciphertext }).then(text); }, - nip44Encrypt: function(peer, plaintext){ return call({ op: 'nip44Encrypt', peer: peer, plaintext: plaintext }).then(text); }, - nip44Decrypt: function(peer, ciphertext){ return call({ op: 'nip44Decrypt', peer: peer, ciphertext: ciphertext }).then(text); }, - publish: function(ev){ return call({ op: 'publish', event: ev }).then(published); }, - queryEvents: function(filter){ return call({ op: 'queryEvents', filter: filter || {} }).then(events); }, - storage: Object.freeze({ - get: function(key){ return call({ op: 'storageGet', key: key }).then(storageValue); }, - set: function(key, value){ return call({ op: 'storageSet', key: key, value: value }).then(done); }, - remove: function(key){ return call({ op: 'storageRemove', key: key }).then(done); } - }), - payInvoice: function(invoice){ return call({ op: 'payInvoice', invoice: invoice }).then(paid); } + var p = pending[msg.id]; if (!p) return; delete pending[msg.id]; + if (msg.ok) p.resolve(msg); + else { var err = new Error(msg.reason || msg.operation || msg.error || 'napplet error'); err.napplet = msg; p.reject(err); } }); + function field(promise, name){ return promise.then(function(m){ return m[name]; }); } + 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.", "id", ...fields }` and replies + * are `{ "type": "..result", "id", "ok", ...fields }`. * - * The host process only ever *shuttles* these strings; decoding/encoding happens in the - * main-process broker so a compromised host cannot fabricate a typed request that skips a field. - * Uses kotlinx.serialization (a pure-JVM JSON impl) so it is unit-testable off-device. + * This is the only place the boundary parses untrusted applet input, so it is deliberately + * strict: an unrecognized `type` decodes to `null` (the broker denies it) and a malformed/short + * body throws (the broker wraps it into a `Failed`). Uses kotlinx.serialization so it is + * unit-testable off-device. */ object NappletProtocolJson { private val json = Json { ignoreUnknownKeys = true } - /** Parses an applet request. Returns `null` for an unrecognized `op` so the broker can deny it. */ - fun decodeRequest(jsonText: String): NappletRequest? { - val o = json.parseToJsonElement(jsonText).jsonObject - return when (o.str("op")) { - "getPublicKey" -> NappletRequest.GetPublicKey - "signEvent" -> + /** The `type` discriminant of a request envelope, used to build the matching `.result` type. */ + fun readType(envelopeJson: String): String? = json.parseToJsonElement(envelopeJson).jsonObject.str("type") + + /** Parses a request envelope. Returns `null` for an unrecognized `type` so the broker can deny it. */ + fun decodeRequest(envelopeJson: String): NappletRequest? { + val o = json.parseToJsonElement(envelopeJson).jsonObject + return when (o.str("type")) { + "shell.supports" -> NappletRequest.ShellSupports(o.req("domain")) + "identity.getPublicKey" -> NappletRequest.GetPublicKey + "keys.signEvent" -> NappletRequest.SignEvent( kind = o.getValue("kind").jsonPrimitive.int, tags = decodeTags(o), content = o.str("content") ?: "", ) - "nip04Encrypt" -> NappletRequest.Nip04Encrypt(o.req("peer"), o.req("plaintext")) - "nip04Decrypt" -> NappletRequest.Nip04Decrypt(o.req("peer"), o.req("ciphertext")) - "nip44Encrypt" -> NappletRequest.Nip44Encrypt(o.req("peer"), o.req("plaintext")) - "nip44Decrypt" -> NappletRequest.Nip44Decrypt(o.req("peer"), o.req("ciphertext")) - "publish" -> NappletRequest.Publish(Event.fromJson(o.getValue("event").jsonObject.toString())) - "queryEvents" -> NappletRequest.QueryEvents(decodeFilter(o.getValue("filter").jsonObject)) - "storageGet" -> NappletRequest.StorageGet(o.req("key")) - "storageSet" -> NappletRequest.StorageSet(o.req("key"), o.req("value")) - "storageRemove" -> NappletRequest.StorageRemove(o.req("key")) - "payInvoice" -> NappletRequest.PayInvoice(o.req("invoice")) + "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")) + "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")) else -> null } } - fun encodeResponse(response: NappletResponse): String = + /** Builds the `{type:".result", ok, ...}` reply (the host adds the `id`). */ + fun encodeResponse( + requestType: String, + response: NappletResponse, + ): String = buildJsonObject { + put("type", "$requestType.result") when (response) { is NappletResponse.PublicKey -> { - put("type", "publicKey") + put("ok", true) put("pubkey", response.pubkey) } is NappletResponse.SignedEvent -> { - put("type", "signedEvent") + put("ok", true) put("event", json.parseToJsonElement(response.event.toJson())) } is NappletResponse.Text -> { - put("type", "text") + put("ok", true) put("value", response.value) } is NappletResponse.Published -> { - put("type", "published") + put("ok", true) put("relays", buildJsonArray { response.relays.forEach { add(it) } }) } is NappletResponse.Events -> { - put("type", "events") + put("ok", true) put("events", buildJsonArray { response.events.forEach { add(json.parseToJsonElement(it.toJson())) } }) } + is NappletResponse.Supported -> { + put("ok", true) + put("supported", response.supported) + } is NappletResponse.StorageValue -> { - put("type", "storageValue") + put("ok", true) put("value", response.value) } + is NappletResponse.Bytes -> { + put("ok", true) + put("bytes", Base64.getEncoder().encodeToString(response.bytes)) + put("contentType", response.contentType) + } + is NappletResponse.Uploaded -> { + put("ok", true) + put("url", response.url) + } is NappletResponse.Paid -> { - put("type", "paid") + put("ok", true) put("preimage", response.preimage) } is NappletResponse.Done -> { - put("type", "done") + put("ok", true) } is NappletResponse.Denied -> { - put("type", "denied") + put("ok", false) + put("error", "denied") put("capability", response.capability.name) put("reason", response.reason) } is NappletResponse.Unsupported -> { - put("type", "unsupported") + put("ok", false) + put("error", "unsupported") put("operation", response.operation) } is NappletResponse.Failed -> { - put("type", "failed") + put("ok", false) + put("error", "failed") put("reason", response.reason) } } }.toString() - /** Parses a standard Nostr filter object (kinds/authors/ids/since/until/limit/search + `#x` tags). */ + /** Parses a Nostr filter from `filter` (object) or the first of `filters` (array). */ private fun decodeFilter(o: JsonObject): Filter { + val f = o["filter"]?.jsonObject ?: o["filters"]?.jsonArray?.firstOrNull()?.jsonObject ?: JsonObject(emptyMap()) + val tags = mutableMapOf>() - for ((key, value) in o) { + for ((key, value) in f) { if (key.startsWith("#") && key.length == 2) { tags[key.substring(1)] = value.jsonArray.map { it.jsonPrimitive.content } } } return Filter( - ids = o.strList("ids"), - authors = o.strList("authors"), - kinds = o["kinds"]?.jsonArray?.map { it.jsonPrimitive.int }, + ids = f.strList("ids"), + authors = f.strList("authors"), + kinds = f["kinds"]?.jsonArray?.map { it.jsonPrimitive.int }, tags = tags.ifEmpty { null }, - since = o["since"]?.jsonPrimitive?.long, - until = o["until"]?.jsonPrimitive?.long, - limit = o["limit"]?.jsonPrimitive?.int, - search = o.str("search"), + since = f["since"]?.jsonPrimitive?.long, + until = f["until"]?.jsonPrimitive?.long, + limit = f["limit"]?.jsonPrimitive?.int, + search = f.str("search"), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletPermissionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletPermissionsScreen.kt index 1d03bfe17a..cfbba00f71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletPermissionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletPermissionsScreen.kt @@ -332,9 +332,12 @@ private fun resolveTitle( private fun NappletCapability.symbol(): MaterialSymbol = when (this) { - NappletCapability.IDENTITY -> MaterialSymbols.Key + NappletCapability.SHELL -> MaterialSymbols.Tune + NappletCapability.IDENTITY -> MaterialSymbols.AccountCircle + NappletCapability.KEYS -> MaterialSymbols.Key NappletCapability.RELAY -> MaterialSymbols.Public - NappletCapability.WALLET -> MaterialSymbols.Bolt NappletCapability.STORAGE -> MaterialSymbols.Storage - NappletCapability.NET -> MaterialSymbols.Language + NappletCapability.VALUE -> MaterialSymbols.Bolt + NappletCapability.RESOURCE -> MaterialSymbols.Language + NappletCapability.UPLOAD -> MaterialSymbols.Upload } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 7920333f07..084f10fb7c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -747,16 +747,22 @@ This device\'s WebView is too old to run napplets safely. Napplet %1$s… + Shell Identity + Signing Relays - Wallet Storage - Network - Sign and encrypt as you + Payments + Network + Uploads + Ask which capabilities are available + Read your public key + Sign and encrypt as you Read and publish your events - Pay Lightning invoices Its own private storage - Direct network access + Pay Lightning invoices + Fetch web and Blossom resources + Upload files to your media server Capability: %1$s Always allow @@ -772,6 +778,8 @@ This napplet wants to read events from your relays. This napplet wants to use its private storage. This napplet wants to pay a Lightning invoice. + This napplet wants to fetch a web resource. + This napplet wants to upload a file to your media server. This napplet wants to pay a Lightning invoice for %1$d sat. This napplet wants to pay a Lightning invoice for %1$d sats. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt index 614c7d98b1..b0930d6f96 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt @@ -26,10 +26,12 @@ import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse import com.vitorpamplona.quartz.nip01Core.core.Event import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.boolean import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Assert.fail @@ -37,7 +39,8 @@ import org.junit.Test /** * Tests the one place the trust boundary parses untrusted applet input ([NappletProtocolJson]). - * Runs as a plain JVM unit test — the codec uses kotlinx.serialization, not Android's `org.json`. + * Verifies the upstream `{type:"domain.action", id}` envelope round-trips. Runs as a plain JVM + * unit test — the codec uses kotlinx.serialization, not Android's `org.json`. */ class NappletProtocolJsonTest { private val json = Json @@ -62,187 +65,141 @@ class NappletProtocolJsonTest { sig = "c".repeat(128), ) - // ---- decode requests ---- + // ---- decode requests (upstream "domain.action" envelope) ---- @Test fun decodesGetPublicKey() { - assertEquals(NappletRequest.GetPublicKey, NappletProtocolJson.decodeRequest("""{"op":"getPublicKey"}""")) + assertEquals(NappletRequest.GetPublicKey, NappletProtocolJson.decodeRequest("""{"type":"identity.getPublicKey","id":"1"}""")) } @Test - fun decodesSignEventWithTagsAndContent() { - val req = NappletProtocolJson.decodeRequest("""{"op":"signEvent","kind":1,"tags":[["t","napplet"],["e","abc"]],"content":"gm"}""") - assertEquals(NappletRequest.SignEvent(1, arrayOf(arrayOf("t", "napplet"), arrayOf("e", "abc")), "gm"), req) + fun decodesShellSupports() { + assertEquals(NappletRequest.ShellSupports("relay"), NappletProtocolJson.decodeRequest("""{"type":"shell.supports","id":"1","domain":"relay"}""")) } @Test - fun decodesSignEventWithMissingContentAsEmpty() { - val req = NappletProtocolJson.decodeRequest("""{"op":"signEvent","kind":7}""") - assertEquals(NappletRequest.SignEvent(7, emptyArray(), ""), req) + 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) } @Test - fun decodesEncryptDecryptOps() { - assertEquals(NappletRequest.Nip04Encrypt("pk", "hi"), NappletProtocolJson.decodeRequest("""{"op":"nip04Encrypt","peer":"pk","plaintext":"hi"}""")) - assertEquals(NappletRequest.Nip04Decrypt("pk", "ct"), NappletProtocolJson.decodeRequest("""{"op":"nip04Decrypt","peer":"pk","ciphertext":"ct"}""")) - assertEquals(NappletRequest.Nip44Encrypt("pk", "hi"), NappletProtocolJson.decodeRequest("""{"op":"nip44Encrypt","peer":"pk","plaintext":"hi"}""")) - assertEquals(NappletRequest.Nip44Decrypt("pk", "ct"), NappletProtocolJson.decodeRequest("""{"op":"nip44Decrypt","peer":"pk","ciphertext":"ct"}""")) + 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"}""")) } @Test fun decodesPublishPreservingTheEvent() { val ev = sampleEvent() - val req = NappletProtocolJson.decodeRequest("""{"op":"publish","event":${ev.toJson()}}""") as NappletRequest.Publish + val req = NappletProtocolJson.decodeRequest("""{"type":"relay.publish","id":"1","event":${ev.toJson()}}""") as NappletRequest.Publish assertEquals(ev.id, req.event.id) - assertEquals(ev.pubKey, req.event.pubKey) assertEquals("hello", req.event.content) } @Test - fun decodesQueryEventsFilter() { - val req = - NappletProtocolJson.decodeRequest( - """{"op":"queryEvents","filter":{"kinds":[1,30023],"authors":["aa"],"#t":["nostr"],"since":100,"limit":20}}""", - ) as NappletRequest.QueryEvents - val f = req.filter - assertEquals(listOf(1, 30023), f.kinds) - assertEquals(listOf("aa"), f.authors) - assertEquals(listOf("nostr"), f.tags?.get("t")) - assertEquals(100L, f.since) - assertEquals(20, f.limit) + fun decodesQueryFromFilterObjectOrFiltersArray() { + 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")) + assertEquals(5, single.filter.limit) + + val array = NappletProtocolJson.decodeRequest("""{"type":"relay.query","filters":[{"authors":["aa"]}]}""") as NappletRequest.QueryEvents + assertEquals(listOf("aa"), array.filter.authors) } @Test fun decodesStorageOps() { - assertEquals(NappletRequest.StorageGet("k"), NappletProtocolJson.decodeRequest("""{"op":"storageGet","key":"k"}""")) - assertEquals(NappletRequest.StorageSet("k", "v"), NappletProtocolJson.decodeRequest("""{"op":"storageSet","key":"k","value":"v"}""")) - assertEquals(NappletRequest.StorageRemove("k"), NappletProtocolJson.decodeRequest("""{"op":"storageRemove","key":"k"}""")) + 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"}""")) } @Test - fun decodesPayInvoice() { - assertEquals(NappletRequest.PayInvoice("lnbc1"), NappletProtocolJson.decodeRequest("""{"op":"payInvoice","invoice":"lnbc1"}""")) + fun decodesValueResourceUpload() { + assertEquals(NappletRequest.PayInvoice("lnbc1"), NappletProtocolJson.decodeRequest("""{"type":"value.payInvoice","invoice":"lnbc1"}""")) + assertEquals(NappletRequest.ResourceBytes("https://x"), NappletProtocolJson.decodeRequest("""{"type":"resource.bytes","url":"https://x"}""")) + // "SGk=" is base64 for "Hi" + val up = NappletProtocolJson.decodeRequest("""{"type":"upload","bytes":"SGk=","contentType":"text/plain"}""") as NappletRequest.UploadBlob + assertEquals("text/plain", up.contentType) + assertEquals("Hi", up.bytes.decodeToString()) } @Test - fun unknownOpDecodesToNull() { - assertNull(NappletProtocolJson.decodeRequest("""{"op":"deleteEverything"}""")) + fun unknownTypeDecodesToNull() { + assertNull(NappletProtocolJson.decodeRequest("""{"type":"inc.emit","id":"1"}""")) assertNull(NappletProtocolJson.decodeRequest("""{"foo":"bar"}""")) } @Test - fun malformedBodyThrows() { - assertThrowsAny { NappletProtocolJson.decodeRequest("not json at all") } + fun malformedOrMissingFieldThrows() { + assertThrowsAny { NappletProtocolJson.decodeRequest("not json") } + assertThrowsAny { NappletProtocolJson.decodeRequest("""{"type":"keys.signEvent","content":"x"}""") } } @Test - fun missingRequiredFieldThrows() { - // signEvent without the required `kind` must not silently succeed. - assertThrowsAny { NappletProtocolJson.decodeRequest("""{"op":"signEvent","content":"x"}""") } + fun readTypeReturnsTheDiscriminant() { + assertEquals("relay.publish", NappletProtocolJson.readType("""{"type":"relay.publish","id":"9"}""")) } - // ---- encode responses ---- + // ---- encode responses (".result" envelope) ---- @Test - fun encodesPublicKey() { - val o = json.parseToJsonElement(NappletProtocolJson.encodeResponse(NappletResponse.PublicKey("pk"))).jsonObject - assertEquals("publicKey", o["type"]?.jsonPrimitive?.content) + fun encodesSuccessWithResultTypeAndOkTrue() { + val o = json.parseToJsonElement(NappletProtocolJson.encodeResponse("identity.getPublicKey", NappletResponse.PublicKey("pk"))).jsonObject + assertEquals("identity.getPublicKey.result", o["type"]?.jsonPrimitive?.content) + assertTrue(o["ok"]!!.jsonPrimitive.boolean) assertEquals("pk", o["pubkey"]?.jsonPrimitive?.content) } @Test - fun encodesSignedEventAsNestedObject() { - val o = json.parseToJsonElement(NappletProtocolJson.encodeResponse(NappletResponse.SignedEvent(sampleEvent()))).jsonObject - assertEquals("signedEvent", o["type"]?.jsonPrimitive?.content) + fun encodesSupported() { + val o = json.parseToJsonElement(NappletProtocolJson.encodeResponse("shell.supports", NappletResponse.Supported(true))).jsonObject + assertEquals("shell.supports.result", o["type"]?.jsonPrimitive?.content) + assertTrue(o["supported"]!!.jsonPrimitive.boolean) + } + + @Test + fun encodesSignedEventAndEvents() { + val signed = json.parseToJsonElement(NappletProtocolJson.encodeResponse("keys.signEvent", NappletResponse.SignedEvent(sampleEvent()))).jsonObject assertEquals( "a".repeat(64), - o["event"] + signed["event"] ?.jsonObject ?.get("id") ?.jsonPrimitive ?.content, ) + + val events = json.parseToJsonElement(NappletProtocolJson.encodeResponse("relay.query", NappletResponse.Events(listOf(sampleEvent())))).jsonObject + assertEquals(1, events["events"]?.jsonArray?.size) } @Test - fun encodesEventsArray() { - val o = json.parseToJsonElement(NappletProtocolJson.encodeResponse(NappletResponse.Events(listOf(sampleEvent())))).jsonObject - assertEquals("events", o["type"]?.jsonPrimitive?.content) - assertEquals(1, o["events"]?.jsonArray?.size) - assertEquals( - "a".repeat(64), - o["events"] - ?.jsonArray - ?.get(0) - ?.jsonObject - ?.get("id") - ?.jsonPrimitive - ?.content, - ) - } - - @Test - fun encodesPublished() { - val o = json.parseToJsonElement(NappletProtocolJson.encodeResponse(NappletResponse.Published(listOf("wss://a", "wss://b")))).jsonObject - assertEquals("published", o["type"]?.jsonPrimitive?.content) - assertEquals(2, o["relays"]?.jsonArray?.size) - } - - @Test - fun encodesStorageValueWithNullAsJsonNull() { - val present = json.parseToJsonElement(NappletProtocolJson.encodeResponse(NappletResponse.StorageValue("v"))).jsonObject - assertEquals("v", present["value"]?.jsonPrimitive?.content) - - val absent = json.parseToJsonElement(NappletProtocolJson.encodeResponse(NappletResponse.StorageValue(null))).jsonObject + fun encodesStorageNullAsJsonNull() { + val absent = json.parseToJsonElement(NappletProtocolJson.encodeResponse("storage.get", NappletResponse.StorageValue(null))).jsonObject assertEquals(JsonNull, absent["value"]) } @Test - fun encodesPaidAndDone() { - val paid = json.parseToJsonElement(NappletProtocolJson.encodeResponse(NappletResponse.Paid(null))).jsonObject - assertEquals("paid", paid["type"]?.jsonPrimitive?.content) - assertEquals(JsonNull, paid["preimage"]) - - val done = json.parseToJsonElement(NappletProtocolJson.encodeResponse(NappletResponse.Done)).jsonObject - assertEquals("done", done["type"]?.jsonPrimitive?.content) + fun encodesBytesAsBase64() { + 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) } @Test - fun encodesDeniedWithCapabilityName() { - val o = - json - .parseToJsonElement(NappletProtocolJson.encodeResponse(NappletResponse.Denied(NappletCapability.IDENTITY, "nope"))) - .jsonObject - assertEquals("denied", o["type"]?.jsonPrimitive?.content) - assertEquals("IDENTITY", o["capability"]?.jsonPrimitive?.content) - assertEquals("nope", o["reason"]?.jsonPrimitive?.content) - } + fun encodesErrorsWithOkFalse() { + val denied = json.parseToJsonElement(NappletProtocolJson.encodeResponse("keys.signEvent", NappletResponse.Denied(NappletCapability.KEYS, "no"))).jsonObject + assertFalse(denied["ok"]!!.jsonPrimitive.boolean) + assertEquals("denied", denied["error"]?.jsonPrimitive?.content) + assertEquals("KEYS", denied["capability"]?.jsonPrimitive?.content) - @Test - fun encodesUnsupportedAndFailed() { - val unsupported = json.parseToJsonElement(NappletProtocolJson.encodeResponse(NappletResponse.Unsupported("payInvoice"))).jsonObject - assertEquals("unsupported", unsupported["type"]?.jsonPrimitive?.content) - assertEquals("payInvoice", unsupported["operation"]?.jsonPrimitive?.content) + val unsupported = json.parseToJsonElement(NappletProtocolJson.encodeResponse("upload", NappletResponse.Unsupported("upload"))).jsonObject + assertFalse(unsupported["ok"]!!.jsonPrimitive.boolean) + assertEquals("unsupported", unsupported["error"]?.jsonPrimitive?.content) - val failed = json.parseToJsonElement(NappletProtocolJson.encodeResponse(NappletResponse.Failed("boom"))).jsonObject - assertEquals("failed", failed["type"]?.jsonPrimitive?.content) + val failed = json.parseToJsonElement(NappletProtocolJson.encodeResponse("relay.publish", NappletResponse.Failed("boom"))).jsonObject assertEquals("boom", failed["reason"]?.jsonPrimitive?.content) } - - @Test - fun signedEventRoundTripsThroughEventFromJson() { - // The encoded event must be parseable back into an Event (the applet's wire <-> our model). - val encoded = NappletProtocolJson.encodeResponse(NappletResponse.SignedEvent(sampleEvent())) - val eventJson = - json - .parseToJsonElement(encoded) - .jsonObject - .getValue("event") - .jsonObject - .toString() - val restored = Event.fromJson(eventJson) - assertEquals("a".repeat(64), restored.id) - assertEquals(1, restored.kind) - assertTrue(restored.tags.any { it.firstOrNull() == "t" }) - } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt index 2393d50742..392e4c12a3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt @@ -63,6 +63,8 @@ class NappletBroker( private val relay: NappletRelayGateway? = null, private val storage: NappletStorage? = null, private val wallet: NappletWalletGateway? = null, + private val resource: NappletResourceGateway? = null, + private val upload: NappletUploadGateway? = null, ) { /** * Authorizes and runs [request] on behalf of [identity]. [declared] is the capability set the @@ -77,6 +79,12 @@ class NappletBroker( ): NappletResponse { val capability = request.capability + // shell.supports is capability negotiation: always answerable, no declaration/consent. + if (request is NappletRequest.ShellSupports) { + val cap = NappletCapability.fromNapDomain(request.domain) + return NappletResponse.Supported(cap != null && cap in declared) + } + if (capability !in declared) { return NappletResponse.Denied(capability, "This napplet did not declare the '${capability.name.lowercase()}' capability.") } @@ -110,8 +118,8 @@ class NappletBroker( } } - /** Identity 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 && signer !is NostrSignerInternal + /** 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 /** Downgrades a grant to one-shot when the capability forbids persisting that scope (e.g. payments). */ private fun effectiveGrant( @@ -129,6 +137,9 @@ class NappletBroker( request: NappletRequest, ): NappletResponse = when (request) { + // Negotiation is resolved in handle(); execute() is never reached for it. + is NappletRequest.ShellSupports -> NappletResponse.Supported(true) + is NappletRequest.GetPublicKey -> NappletResponse.PublicKey(signer.pubKey) is NappletRequest.SignEvent -> { @@ -174,9 +185,21 @@ class NappletBroker( } is NappletRequest.PayInvoice -> { - val gateway = wallet ?: return NappletResponse.Unsupported("payInvoice") + val gateway = wallet ?: return NappletResponse.Unsupported("value.payInvoice") NappletResponse.Paid(gateway.payInvoice(request.invoice)) } + + is NappletRequest.ResourceBytes -> { + val gateway = resource ?: return NappletResponse.Unsupported("resource.bytes") + val fetched = gateway.fetch(request.url) ?: return NappletResponse.Failed("Could not fetch the resource.") + NappletResponse.Bytes(fetched.bytes, fetched.contentType) + } + + is NappletRequest.UploadBlob -> { + val gateway = upload ?: return NappletResponse.Unsupported("upload") + val url = gateway.upload(request.bytes, request.contentType) ?: return NappletResponse.Failed("Upload failed.") + NappletResponse.Uploaded(url) + } } private suspend fun publish(event: Event): NappletResponse { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt index 35e6035bf8..282ba19c79 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt @@ -78,11 +78,38 @@ interface NappletStorage { } /** - * Bridges the broker to the user's wallet for the [NappletCapability.WALLET] capability. A - * `null` gateway makes wallet requests answer with `Unsupported` — there is intentionally no + * Bridges the broker to the user's wallet for the [NappletCapability.VALUE] capability. A + * `null` gateway makes value requests answer with `Unsupported` — there is intentionally no * default payment path, since a money-moving bridge must be verified end-to-end before it ships. */ fun interface NappletWalletGateway { /** Pays a BOLT-11 [invoice] and returns the preimage on success, or `null` if unconfirmed. */ suspend fun payInvoice(invoice: String): String? } + +/** A fetched resource: its [bytes] and best-effort [contentType]. */ +class NappletResource( + val bytes: ByteArray, + val contentType: String, +) + +/** + * Bridges the broker to sandboxed resource fetching for [NappletCapability.RESOURCE] + * (`resource.bytes`). The host fetches https/blossom/nostr/data URLs on the applet's behalf — + * the applet itself has no direct network (CSP `connect-src 'none'`). Returns `null` for an + * unsupported scheme or a failed fetch. + */ +fun interface NappletResourceGateway { + suspend fun fetch(url: String): NappletResource? +} + +/** + * Bridges the broker to Blossom upload for [NappletCapability.UPLOAD]. Returns the URL the blob + * can be fetched from, or `null` on failure. A `null` gateway answers with `Unsupported`. + */ +fun interface NappletUploadGateway { + suspend fun upload( + bytes: ByteArray, + contentType: String, + ): String? +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt index 3b1565bcff..42b86e6791 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt @@ -21,38 +21,48 @@ package com.vitorpamplona.amethyst.commons.napplet /** - * The capability classes a napplet shell can broker, each gating a set of dangerous - * operations behind the trust boundary. A napplet declares the NAP domains it needs - * via `requires` tags (`NappletManifest.requires()`); [fromNapDomain] maps each bare - * domain string to the capability the broker enforces. + * The capability classes a napplet shell can broker, aligned with the upstream NAP domains + * (`napplet/naps`, `@napplet/web`). A napplet declares the domains it needs via `requires` tags; + * [fromNapDomain] maps each bare domain string to the capability the broker enforces. * - * The mapping is intentionally **default-deny**: an unrecognized NAP domain maps to - * `null` and the shell must surface it as unknown rather than silently granting it. + * The mapping is **default-deny**: an unrecognized NAP domain maps to `null` and the shell must + * surface it as unknown rather than silently granting it. Domains we don't yet broker + * (`inc`, `intent`, `theme`, `notify`, `media`, `config`, `outbox`, `ifc`, `cvm`) therefore + * resolve to `null` for now. */ enum class NappletCapability { - /** Read the active pubkey, sign events, and NIP-04/44 encrypt/decrypt as the user. */ + /** `shell` — capability negotiation (`shell.supports`). Always available; needs no consent. */ + SHELL, + + /** `identity` — read-only identity queries (`getPublicKey`, `onChanged`). */ IDENTITY, - /** Publish events to, and read events from, the user's relays. */ + /** `keys` — sign events and NIP-04/44 encrypt/decrypt as the user. */ + KEYS, + + /** `relay` — publish, query, and subscribe to the user's relays. */ RELAY, - /** Request NIP-57 zaps / Lightning invoices (and, behind a stricter grant, NWC pay). */ - WALLET, - - /** A per-applet sandboxed key-value store, namespaced by [NappletIdentity] — never app storage. */ + /** `storage` — a per-applet sandboxed key-value store, namespaced by applet identity. */ STORAGE, - /** Direct outbound network to user-approved origins (widens the applet's CSP `connect-src`). */ - NET, + /** `value` — shell-mediated value transfer / zaps / invoice payment. */ + VALUE, + + /** `resource` — sandboxed fetching of https/blossom/nostr/data resources. */ + RESOURCE, + + /** `upload` — shell-mediated blob upload (Blossom). */ + UPLOAD, ; /** - * Whether the user must confirm **every single use** of this capability — i.e. no standing - * auto-approval is ever honored or offered. True for [WALLET]: a payment always prompts, with - * the amount shown, so an applet can never silently move money. + * Whether the user must confirm **every single use** — no standing auto-approval. True for + * [VALUE]: a payment always prompts with the amount shown, so a napplet can never silently + * move money. */ val requiresPerUseConsent: Boolean - get() = this == WALLET + get() = this == VALUE /** Whether a persistent "always allow" grant may be offered for, and kept for, this capability. */ val canGrantAlways: Boolean @@ -64,17 +74,20 @@ enum class NappletCapability { companion object { /** - * Maps a bare NAP domain (e.g. `identity`, `relay`, `storage`) to the capability the - * broker enforces, case-insensitively. Returns `null` for any domain the shell does - * not recognize — callers MUST treat that as "unknown, do not grant". + * Maps a bare NAP domain to the capability the broker enforces, case-insensitively. + * Returns `null` for any domain the shell does not recognize — callers MUST treat that as + * "unknown, do not grant". */ fun fromNapDomain(domain: String): NappletCapability? = when (domain.trim().lowercase()) { - "identity", "sign", "signer" -> IDENTITY + "shell" -> SHELL + "identity" -> IDENTITY + "keys", "sign", "signer", "nip04", "nip44" -> KEYS "relay", "relays" -> RELAY - "value", "wallet", "zap", "payments" -> WALLET "storage" -> STORAGE - "net", "network", "fetch" -> NET + "value", "wallet", "zap", "zaps", "payments" -> VALUE + "resource", "fetch", "net", "network" -> RESOURCE + "upload" -> UPLOAD else -> null } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt index 79ea8d2abf..b9aa11a2e9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt @@ -43,6 +43,13 @@ sealed interface NappletRequest { override val capability get() = NappletCapability.IDENTITY } + /** `shell.supports(domain)` — capability negotiation; always answerable, needs no consent. */ + data class ShellSupports( + val domain: String, + ) : 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 @@ -53,7 +60,7 @@ sealed interface NappletRequest { val tags: Array>, val content: String, ) : NappletRequest { - override val capability get() = NappletCapability.IDENTITY + override val capability get() = NappletCapability.KEYS override fun equals(other: Any?): Boolean { if (this === other) return true @@ -77,28 +84,28 @@ sealed interface NappletRequest { val peerPubKey: HexKey, val plaintext: String, ) : NappletRequest { - override val capability get() = NappletCapability.IDENTITY + override val capability get() = NappletCapability.KEYS } data class Nip04Decrypt( val peerPubKey: HexKey, val ciphertext: String, ) : NappletRequest { - override val capability get() = NappletCapability.IDENTITY + override val capability get() = NappletCapability.KEYS } data class Nip44Encrypt( val peerPubKey: HexKey, val plaintext: String, ) : NappletRequest { - override val capability get() = NappletCapability.IDENTITY + override val capability get() = NappletCapability.KEYS } data class Nip44Decrypt( val peerPubKey: HexKey, val ciphertext: String, ) : NappletRequest { - override val capability get() = NappletCapability.IDENTITY + override val capability get() = NappletCapability.KEYS } /** @@ -140,10 +147,33 @@ sealed interface NappletRequest { override val capability get() = NappletCapability.STORAGE } - /** Pay a BOLT-11 invoice from the user's wallet. */ + /** Pay a BOLT-11 invoice from the user's wallet (`value` domain). */ data class PayInvoice( val invoice: String, ) : NappletRequest { - override val capability get() = NappletCapability.WALLET + override val capability get() = NappletCapability.VALUE + } + + /** Fetch the bytes of an https/blossom/nostr/data resource (`resource.bytes`). */ + data class ResourceBytes( + val url: String, + ) : NappletRequest { + override val capability get() = NappletCapability.RESOURCE + } + + /** Upload a blob to the user's Blossom server (`upload`). */ + data class UploadBlob( + val bytes: ByteArray, + val contentType: String, + ) : NappletRequest { + override val capability get() = NappletCapability.UPLOAD + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is UploadBlob) return false + return contentType == other.contentType && bytes.contentEquals(other.bytes) + } + + override fun hashCode(): Int = 31 * contentType.hashCode() + bytes.contentHashCode() } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt index 31b4aa5693..ada15a5da0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt @@ -53,11 +53,35 @@ sealed interface NappletResponse { val events: List, ) : NappletResponse + /** Result of `shell.supports(domain)`. */ + data class Supported( + val supported: Boolean, + ) : NappletResponse + /** Result of a storage read; [value] is null when the key is absent. */ data class StorageValue( val value: String?, ) : NappletResponse + /** Result of a `resource.bytes` fetch. */ + data class Bytes( + val bytes: ByteArray, + val contentType: String, + ) : NappletResponse { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Bytes) return false + return contentType == other.contentType && bytes.contentEquals(other.bytes) + } + + override fun hashCode(): Int = 31 * contentType.hashCode() + bytes.contentHashCode() + } + + /** Result of an `upload`; [url] is where the blob can be fetched. */ + data class Uploaded( + val url: String, + ) : NappletResponse + /** Result of a successful invoice payment; [preimage] is null when unconfirmed. */ data class Paid( val preimage: String?, diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt index 87163fe5e8..156b86324a 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt @@ -355,7 +355,7 @@ class NappletBrokerTest { assertEquals(2, prompt.calls) // every payment prompts assertEquals(2, wallet.calls) // ALLOW_ALWAYS must not have been persisted for a per-use capability. - assertEquals(PermissionDecision.ASK, ledger.decide(applet, NappletCapability.WALLET)) + assertEquals(PermissionDecision.ASK, ledger.decide(applet, NappletCapability.VALUE)) } @Test @@ -384,4 +384,25 @@ class NappletBrokerTest { assertIs(response) } + + @Test + fun shellSupportsReflectsDeclaredCapabilitiesWithoutConsent() = + runTest { + // The DENY prompt would block anything that reached consent; supports must not. + val broker = broker(ScriptedPrompt(GrantState.DENY)) + val declared = setOf(NappletCapability.RELAY) + + assertEquals(NappletResponse.Supported(true), broker.handle(applet, NappletRequest.ShellSupports("relay"), declared)) + assertEquals(NappletResponse.Supported(false), broker.handle(applet, NappletRequest.ShellSupports("storage"), declared)) + // Unknown/unbrokered domain. + assertEquals(NappletResponse.Supported(false), broker.handle(applet, NappletRequest.ShellSupports("cvm"), declared)) + } + + @Test + fun resourceAndUploadAreUnsupportedWithoutGateways() = + runTest { + val broker = broker(ScriptedPrompt(GrantState.ALLOW_ONCE)) + assertIs(broker.handle(applet, NappletRequest.ResourceBytes("https://x"), allDeclared)) + assertIs(broker.handle(applet, NappletRequest.UploadBlob(ByteArray(0), "image/png"), allDeclared)) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapabilityTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapabilityTest.kt index f7e71a36bc..9dbad7dd48 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapabilityTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapabilityTest.kt @@ -29,30 +29,35 @@ import kotlin.test.assertTrue class NappletCapabilityTest { @Test fun mapsKnownDomainsCaseInsensitively() { + assertEquals(NappletCapability.SHELL, NappletCapability.fromNapDomain("shell")) assertEquals(NappletCapability.IDENTITY, NappletCapability.fromNapDomain("identity")) - assertEquals(NappletCapability.IDENTITY, NappletCapability.fromNapDomain(" IDENTITY ")) + assertEquals(NappletCapability.KEYS, NappletCapability.fromNapDomain("keys")) assertEquals(NappletCapability.RELAY, NappletCapability.fromNapDomain("Relay")) - assertEquals(NappletCapability.WALLET, NappletCapability.fromNapDomain("value")) - assertEquals(NappletCapability.STORAGE, NappletCapability.fromNapDomain("storage")) - assertEquals(NappletCapability.NET, NappletCapability.fromNapDomain("net")) + assertEquals(NappletCapability.VALUE, NappletCapability.fromNapDomain("value")) + assertEquals(NappletCapability.STORAGE, NappletCapability.fromNapDomain(" STORAGE ")) + assertEquals(NappletCapability.RESOURCE, NappletCapability.fromNapDomain("resource")) + assertEquals(NappletCapability.UPLOAD, NappletCapability.fromNapDomain("upload")) } @Test fun unknownDomainMapsToNullNotAFallbackGrant() { + // Domains we don't broker yet must stay unknown (default-deny), not fall through. + assertNull(NappletCapability.fromNapDomain("inc")) + assertNull(NappletCapability.fromNapDomain("intent")) + assertNull(NappletCapability.fromNapDomain("cvm")) assertNull(NappletCapability.fromNapDomain("filesystem")) assertNull(NappletCapability.fromNapDomain("")) - assertNull(NappletCapability.fromNapDomain("nostr")) } @Test fun resolveSeparatesKnownFromUnknownAndFlags() { - val resolved = resolveRequiredCapabilities(listOf("identity", "relay", "filesystem", "value")) + val resolved = resolveRequiredCapabilities(listOf("identity", "relay", "intent", "value")) assertEquals( - setOf(NappletCapability.IDENTITY, NappletCapability.RELAY, NappletCapability.WALLET), + setOf(NappletCapability.IDENTITY, NappletCapability.RELAY, NappletCapability.VALUE), resolved.capabilities, ) - assertEquals(listOf(UnknownNapDomain("filesystem")), resolved.unknown) + assertEquals(listOf(UnknownNapDomain("intent")), resolved.unknown) assertTrue(resolved.hasUnknown) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/permissions/NappletPermissionLedgerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/permissions/NappletPermissionLedgerTest.kt index f9e1a8f395..72cc2eb20c 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/permissions/NappletPermissionLedgerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/permissions/NappletPermissionLedgerTest.kt @@ -115,14 +115,14 @@ class NappletPermissionLedgerTest { ledger.record(applet, NappletCapability.IDENTITY, GrantState.ALLOW_ALWAYS) ledger.record(applet, NappletCapability.RELAY, GrantState.DENY) ledger.record(applet, NappletCapability.STORAGE, GrantState.ALLOW_SESSION) // not persisted - ledger.record(other, NappletCapability.WALLET, GrantState.DENY) + ledger.record(other, NappletCapability.VALUE, GrantState.DENY) val all = ledger.allPersistedGrants() assertEquals( mapOf(NappletCapability.IDENTITY to GrantState.ALLOW_ALWAYS, NappletCapability.RELAY to GrantState.DENY), all[applet.coordinate], ) - assertEquals(mapOf(NappletCapability.WALLET to GrantState.DENY), all[other.coordinate]) + assertEquals(mapOf(NappletCapability.VALUE to GrantState.DENY), all[other.coordinate]) } @Test