diff --git a/amethyst/plans/2026-06-20-napplet-ecosystem-audit.md b/amethyst/plans/2026-06-20-napplet-ecosystem-audit.md index 1448e0af14..7b0956fa25 100644 --- a/amethyst/plans/2026-06-20-napplet-ecosystem-audit.md +++ b/amethyst/plans/2026-06-20-napplet-ecosystem-audit.md @@ -182,9 +182,12 @@ Still open (documented, not blocking basic napplets): `outbox`, `ifc`, `cvm` remain unknown→denied (no method spec available to build to). - **Method-name fidelity** — ✅ resolved. All standard method names/shapes are now confirmed against `@napplet/shim@0.16.0` (see the later update above), not guessed. -- **Identity read API** — `getProfile/getRelays/getFollows/...` and `identity.onChanged` are not - yet implemented (need account-data wiring + on-device verification of the return shapes); the - shim exposes `getPublicKey` + an `onChanged` no-op for now. +- **Identity read API** — ✅ partly landed. `identity.getProfile` (kind-0 content), `getRelays` + (NIP-65 read/write map), `getFollows` (kind-3 authors), `getMutes` and `getBlocked` (NIP-51 + decrypted user tags) now read from the active `Account` and return JSON, gated by the IDENTITY + consent (and deferred to remote/external signers). `getList`/`getZaps`/`getBadges` route through + but degrade to `Unsupported` for now; `identity.onChanged` is still a client-side no-op (needs + the live push channel). Return shapes still want on-device verification against a real napplet. - **On-device verification** of the whole round-trip with a real playground napplet. Revised ecosystem-compatibility estimate: **~80%** — real request/response napplets using diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index d8940ff7e6..5067c73fb0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletBroker 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.NappletIdentityGateway import com.vitorpamplona.amethyst.commons.napplet.NappletRelayGateway import com.vitorpamplona.amethyst.commons.napplet.NappletResource import com.vitorpamplona.amethyst.commons.napplet.NappletResourceGateway @@ -55,6 +56,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -63,6 +65,11 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject import okhttp3.OkHttpClient import okhttp3.Request import java.net.InetSocketAddress @@ -173,10 +180,58 @@ class NappletBrokerService : Service() { val resource = NappletResourceGateway { url -> fetchResource(account, url) } + val identityReads = NappletIdentityGateway { method, argument -> readIdentity(account, method, argument) } + // 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) + return NappletBroker(account.signer, ledger, consent, relay, storage, wallet, resource, upload = null, identityReads = identityReads) + } + + /** + * Reads a non-key identity datum from the active account as a JSON value string. Returns the + * literal `"null"` for an absent value, or `null` for a method this shell does not implement + * (the broker then answers `Unsupported`). All reads are public data — never key material. + */ + private fun readIdentity( + account: Account, + method: String, + argument: String?, + ): String? = + when (method) { + // The kind-0 content is itself the profile JSON object. + "getProfile" -> account.userMetadata.getUserMetadataEvent()?.content ?: "null" + "getFollows" -> jsonStringArray(account.kind3FollowList.flow.value.authors) + "getMutes" -> + jsonStringArray( + account.muteList.flow.value + .filterIsInstance() + .map { it.pubKey }, + ) + "getBlocked" -> + jsonStringArray( + account.blockPeopleList.flow.value + .filterIsInstance() + .map { it.pubKey }, + ) + "getRelays" -> relaysJson(account) + // getList/getZaps/getBadges and any other read are not implemented yet → Unsupported. + else -> null + } + + private fun jsonStringArray(items: Iterable): String = buildJsonArray { items.forEach { add(it) } }.toString() + + /** Builds `{ "": { "read": bool, "write": bool }, ... }` from the user's NIP-65 list. */ + private fun relaysJson(account: Account): String { + val relays = account.nip65RelayList.getNIP65RelayList()?.relays() ?: return "null" + return buildJsonObject { + relays.forEach { info -> + putJsonObject(info.relayUrl.url) { + put("read", info.type.isRead()) + put("write", info.type.isWrite()) + } + } + }.toString() } /** Fetches an https/data resource on the applet's behalf (it has no direct network). */ @@ -300,6 +355,7 @@ class NappletBrokerService : Service() { private fun summaryFor(request: NappletRequest): String = when (request) { is NappletRequest.GetPublicKey -> getString(R.string.napplet_consent_get_pubkey) + is NappletRequest.IdentityRead -> getString(R.string.napplet_consent_identity_read) is NappletRequest.Publish -> { val preview = request.content.take(160).trim() if (preview.isEmpty()) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt index 1c3329b13f..76116b1afc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt @@ -453,7 +453,15 @@ class NappletHostActivity : ComponentActivity() { }, identity: { getPublicKey: function(){ return field(call('identity.getPublicKey'), 'pubkey'); }, - // Live identity-change events and the rich read API (getProfile/getRelays/...) are a follow-up. + getProfile: function(){ return field(call('identity.getProfile'), 'result'); }, + getRelays: function(){ return field(call('identity.getRelays'), 'result'); }, + getFollows: function(){ return field(call('identity.getFollows'), 'result'); }, + getMutes: function(){ return field(call('identity.getMutes'), 'result'); }, + getBlocked: function(){ return field(call('identity.getBlocked'), 'result'); }, + getList: function(listType){ return field(call('identity.getList', { listType: listType }), 'result'); }, + getZaps: function(){ return field(call('identity.getZaps'), 'result'); }, + getBadges: function(){ return field(call('identity.getBadges'), 'result'); }, + // Live identity-change push is a follow-up; onChanged is a no-op subscription for now. onChanged: function(handler){ return { close: function(){} }; } }, // keys = keyboard / command action binding (NOT signing). Signing is shell-only via relay.publish. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJson.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJson.kt index fd473e722f..7e03efc8e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJson.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJson.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.add import kotlinx.serialization.json.buildJsonArray @@ -82,7 +83,16 @@ object NappletProtocolJson { "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 + else -> { + // Any other identity.* read (getProfile/getRelays/getFollows/getList/...) routes through + // a generic IdentityRead; the broker/gateway decides which are implemented. + val type = o.str("type") + if (type != null && type.startsWith("identity.")) { + NappletRequest.IdentityRead(type.removePrefix("identity."), o.str("listType") ?: o.str("argument")) + } else { + null + } + } } } @@ -121,6 +131,11 @@ object NappletProtocolJson { put("ok", true) put("values", buildJsonArray { response.values.forEach { add(it) } }) } + is NappletResponse.Json -> { + put("ok", true) + // The host already serialized the value; embed it (fall back to null if malformed). + put("result", runCatching { json.parseToJsonElement(response.raw) }.getOrDefault(JsonNull)) + } is NappletResponse.Bytes -> { put("ok", true) put("bytes", Base64.getEncoder().encodeToString(response.bytes)) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 47295ce95d..207ccd90a7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -770,6 +770,7 @@ Never allow Not now This napplet wants to read your public key. + This napplet wants to read your profile and account data. This napplet wants to sign and publish a kind %1$d event as you. This napplet wants to sign and publish a kind %1$d event as you: This napplet wants to send an encrypted event as you. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt index c7117a0fa7..b4efe59690 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt @@ -107,6 +107,25 @@ class NappletProtocolJsonTest { assertEquals(listOf(1), sub.filter.kinds) } + @Test + fun decodesIdentityReads() { + assertEquals(NappletRequest.IdentityRead("getProfile"), NappletProtocolJson.decodeRequest("""{"type":"identity.getProfile"}""")) + assertEquals(NappletRequest.IdentityRead("getFollows"), NappletProtocolJson.decodeRequest("""{"type":"identity.getFollows"}""")) + assertEquals(NappletRequest.IdentityRead("getList", "bookmarks"), NappletProtocolJson.decodeRequest("""{"type":"identity.getList","listType":"bookmarks"}""")) + // getPublicKey keeps its dedicated request type, not the generic read. + assertEquals(NappletRequest.GetPublicKey, NappletProtocolJson.decodeRequest("""{"type":"identity.getPublicKey"}""")) + } + + @Test + fun encodesIdentityJsonResultVerbatim() { + val arr = json.parseToJsonElement(NappletProtocolJson.encodeResponse("identity.getFollows", NappletResponse.Json("""["aa","bb"]"""))).jsonObject + assertEquals(2, arr["result"]?.jsonArray?.size) + + // A malformed payload degrades to a JSON null rather than throwing. + val bad = json.parseToJsonElement(NappletProtocolJson.encodeResponse("identity.getProfile", NappletResponse.Json("not json"))).jsonObject + assertEquals(JsonNull, bad["result"]) + } + @Test fun decodesStorageOps() { assertEquals(NappletRequest.StorageGet("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.getItem","key":"k"}""")) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt index 80f70a9394..c84b9f9300 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt @@ -66,6 +66,7 @@ class NappletBroker( private val wallet: NappletWalletGateway? = null, private val resource: NappletResourceGateway? = null, private val upload: NappletUploadGateway? = null, + private val identityReads: NappletIdentityGateway? = null, ) { /** * Authorizes and runs [request] on behalf of [identity]. [declared] is the capability set the @@ -143,6 +144,12 @@ class NappletBroker( is NappletRequest.GetPublicKey -> NappletResponse.PublicKey(signer.pubKey) + is NappletRequest.IdentityRead -> { + val gateway = identityReads ?: return NappletResponse.Unsupported("identity.${request.method}") + val raw = gateway.read(request.method, request.argument) ?: return NappletResponse.Unsupported("identity.${request.method}") + NappletResponse.Json(raw) + } + // The napplet supplies an unsigned template; the shell signs and publishes it. // created_at comes from the host, never the applet, so it cannot backdate. is NappletRequest.Publish -> signAndPublish(request.kind, request.tags, request.content) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt index d622c7f59f..1146d40844 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt @@ -54,6 +54,20 @@ interface NappletRelayGateway { suspend fun query(filter: Filter): List } +/** + * Bridges the broker to read-only account data for the [NappletCapability.IDENTITY] capability + * beyond the public key — profile (`getProfile`), relays (`getRelays`), follows (`getFollows`), + * mutes (`getMutes`), blocks (`getBlocked`). The host returns the datum as a JSON value string + * (the literal `"null"` for an absent value), or `null` if this shell does not implement [method] + * (the broker then answers `Unsupported`). A `null` gateway makes every such read `Unsupported`. + */ +fun interface NappletIdentityGateway { + suspend fun read( + method: String, + argument: String?, + ): String? +} + /** * A per-applet sandboxed key-value store for the [NappletCapability.STORAGE] capability. The * broker namespaces every call by the applet's coordinate, so one napplet can never read or diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt index 958cf0abfa..7228da6e5f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt @@ -50,6 +50,19 @@ sealed interface NappletRequest { override val capability get() = NappletCapability.IDENTITY } + /** + * Read a non-key identity datum (`identity.getProfile`/`getRelays`/`getFollows`/`getMutes`/…). + * [method] is the bare method name and [argument] carries an optional parameter (e.g. the list + * type for `getList`). The shell answers from the active account; a method this shell does not + * implement resolves to Unsupported. No private key material is ever returned. + */ + data class IdentityRead( + val method: String, + val argument: String? = null, + ) : NappletRequest { + override val capability get() = NappletCapability.IDENTITY + } + /** `shell.supports(domain, protocol?)` — capability negotiation; always answerable, no consent. */ data class ShellSupports( val domain: String, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt index 7dd5826767..3a487ec983 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt @@ -65,6 +65,14 @@ sealed interface NappletResponse { val values: List, ) : NappletResponse + /** + * A read result already serialized as a JSON value string (object/array/string, or the literal + * `"null"`). Used by `identity.*` reads, whose shapes vary; the host builds the JSON. + */ + data class Json( + val raw: String, + ) : NappletResponse + /** Result of a `resource.bytes` fetch. */ data class Bytes( val bytes: ByteArray, diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt index de3668b11a..0a5db6bf8f 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt @@ -389,6 +389,36 @@ class NappletBrokerTest { assertEquals(NappletResponse.Supported(false), broker.handle(applet, NappletRequest.ShellSupports("cvm"), declared)) } + @Test + fun identityReadReturnsGatewayJsonOrUnsupported() = + runTest { + val gateway = + NappletIdentityGateway { method, _ -> + if (method == "getFollows") """["aa","bb"]""" else null + } + val broker = + NappletBroker( + signer, + NappletPermissionLedger(InMemoryNappletPermissionStore()), + ScriptedPrompt(GrantState.ALLOW_ONCE), + identityReads = gateway, + ) + + val implemented = broker.handle(applet, NappletRequest.IdentityRead("getFollows"), allDeclared) + assertIs(implemented) + assertEquals("""["aa","bb"]""", implemented.raw) + + // A method the gateway doesn't implement degrades gracefully to Unsupported. + assertIs(broker.handle(applet, NappletRequest.IdentityRead("getZaps"), allDeclared)) + } + + @Test + fun identityReadIsUnsupportedWithoutAGateway() = + runTest { + val response = broker(ScriptedPrompt(GrantState.ALLOW_ONCE)).handle(applet, NappletRequest.IdentityRead("getProfile"), allDeclared) + assertIs(response) + } + @Test fun resourceAndUploadAreUnsupportedWithoutGateways() = runTest {