feat(napplet): align with the upstream napplet SDK (envelope, namespaced API, domains)

Acts on the ecosystem audit so real napplets built against @napplet/web can run.

Wire: switch to the upstream envelope {type:"<domain>.<action>", id} →
{type:"…​.result", id, ok, …} across the JS shim, NappletProtocolJson, and the host
shuttle (host forwards the verbatim envelope, injects id on the reply).

API: rewrite the injected window.napplet.* to the namespaced SDK surface —
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. subscribe currently returns the
initial matches via query (live tail is a follow-up).

Capabilities: split to the domain model — SHELL, IDENTITY, KEYS, RELAY, STORAGE,
VALUE, RESOURCE, UPLOAD (was IDENTITY/RELAY/WALLET/STORAGE/NET). shell.supports is
answered with no consent, reflecting declared+brokered domains; keys (signing) split
from identity (pubkey); signer-self-gating now covers both.

New ops: resource.bytes (https/data, broker-fetched, Tor-routed, consent-gated).
upload is wired end-to-end but its Android Blossom gateway is left unprovided
(Unsupported) pending the Uri + auth-event + server-selection integration.

Codec moved to java.util.Base64 (real on minSdk 26 and in JVM tests). Permissions
screen + capability labels updated to the 8 domains; new consent/label strings
localized. Broker + codec + capability tests updated/added.

:commons:jvmTest, :amethyst:testPlayDebugUnitTest (codec), and
:amethyst:compileFdroidDebugKotlin pass; spotless clean. See
plans/2026-06-20-napplet-ecosystem-audit.md (Update section) for the remaining gaps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
Claude
2026-06-20 16:54:06 +00:00
parent e54c78a6ea
commit 5ca44e277f
16 changed files with 543 additions and 262 deletions
@@ -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:"<domain>.<action>", 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.
@@ -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:[<mediatype>][;base64],<data>` 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(
@@ -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
}
@@ -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<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 napplet = {
shell: {
supports: function(domain){ return field(call('shell.supports', { domain: domain }), 'supported'); }
},
identity: {
getPublicKey: function(){ return field(call('identity.getPublicKey'), 'pubkey'); },
onChanged: function(handler){ /* live identity-change events are a follow-up */ }
},
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'); }
},
relay: {
publish: function(ev){ return field(call('relay.publish', { event: ev }), 'relays'); },
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){
if (typeof onEvent === 'function') events.forEach(function(ev){ onEvent(ev); });
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; }); }
},
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 || '' })); }); }
},
upload: {
blob: function(bytes, contentType){ return field(call('upload', { bytes: bytesToB64(bytes), contentType: contentType }), 'url'); }
}
};
window.napplet = Object.freeze(napplet);
})();
"""
}
@@ -35,113 +35,144 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import kotlinx.serialization.json.put
import java.util.Base64
/**
* Marshals the KMP-pure [NappletRequest] / [NappletResponse] types to and from the JSON the
* applet exchanges over `window.napplet.*`. This is the only place the boundary parses applet
* input, so it is deliberately strict: an unrecognized `op` decodes to `null` (the broker
* rejects it) and a malformed/short body throws (the broker wraps it into a `Failed`).
* Marshals the KMP-pure [NappletRequest] / [NappletResponse] types to and from the wire the
* applet exchanges over `window.napplet.*`. The envelope matches the upstream napplet SDK
* (`@napplet/web`): requests are `{ "type": "<domain>.<action>", "id", ...fields }` and replies
* are `{ "type": "<domain>.<action>.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:"<requestType>.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<String, List<String>>()
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"),
)
}
@@ -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
}
+13 -5
View File
@@ -747,16 +747,22 @@
<string name="napplet_webview_too_old">This device\'s WebView is too old to run napplets safely.</string>
<string name="napplet_fallback_title">Napplet %1$s…</string>
<!-- 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_relay">Relays</string>
<string name="napplet_cap_wallet">Wallet</string>
<string name="napplet_cap_storage">Storage</string>
<string name="napplet_cap_net">Network</string>
<string name="napplet_cap_identity_desc">Sign and encrypt as you</string>
<string name="napplet_cap_value">Payments</string>
<string name="napplet_cap_resource">Network</string>
<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_wallet_desc">Pay Lightning invoices</string>
<string name="napplet_cap_storage_desc">Its own private storage</string>
<string name="napplet_cap_net_desc">Direct network access</string>
<string name="napplet_cap_value_desc">Pay Lightning invoices</string>
<string name="napplet_cap_resource_desc">Fetch web and Blossom resources</string>
<string name="napplet_cap_upload_desc">Upload files to your media server</string>
<!-- Napplet consent dialog -->
<string name="napplet_consent_capability">Capability: %1$s</string>
<string name="napplet_consent_allow_always">Always allow</string>
@@ -772,6 +778,8 @@
<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>
<string name="napplet_consent_resource">This napplet wants to fetch a web resource.</string>
<string name="napplet_consent_upload">This napplet wants to upload a file to your media server.</string>
<plurals name="napplet_consent_pay_amount">
<item quantity="one">This napplet wants to pay a Lightning invoice for %1$d sat.</item>
<item quantity="other">This napplet wants to pay a Lightning invoice for %1$d sats.</item>
@@ -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" })
}
}
@@ -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 {
@@ -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?
}
@@ -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
}
}
@@ -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<Array<String>>,
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()
}
}
@@ -53,11 +53,35 @@ sealed interface NappletResponse {
val events: List<Event>,
) : 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?,
@@ -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<NappletResponse.Denied>(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<NappletResponse.Unsupported>(broker.handle(applet, NappletRequest.ResourceBytes("https://x"), allDeclared))
assertIs<NappletResponse.Unsupported>(broker.handle(applet, NappletRequest.UploadBlob(ByteArray(0), "image/png"), allDeclared))
}
}
@@ -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)
}
@@ -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