mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
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:
@@ -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(
|
||||
|
||||
+10
-4
@@ -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"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+6
-3
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+73
-116
@@ -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" })
|
||||
}
|
||||
}
|
||||
|
||||
+26
-3
@@ -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 {
|
||||
|
||||
+29
-2
@@ -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?
|
||||
}
|
||||
|
||||
+37
-24
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+37
-7
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -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?,
|
||||
|
||||
+22
-1
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+13
-8
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user