feat(napplet): implement the identity read API

Wire the upstream identity.* read methods (beyond getPublicKey) to the
active Account, returning JSON gated by the IDENTITY consent:

- getProfile  -> kind-0 metadata content
- getRelays   -> NIP-65 { "<url>": { read, write } } map
- getFollows  -> kind-3 followed author pubkeys
- getMutes    -> NIP-51 mute-list user pubkeys (decrypted)
- getBlocked  -> NIP-51 block-list user pubkeys (decrypted)

getList/getZaps/getBadges route through but degrade to Unsupported for
now; onChanged stays a client-side no-op until the live push channel
lands. Reads are public data only — never key material — and remote/
external signers still self-gate the consent.

Adds NappletRequest.IdentityRead, NappletResponse.Json, a
NappletIdentityGateway collaborator, codec round-trip for identity.*,
the shim methods, and unit tests. commons:jvmTest and the amethyst codec
test pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
Claude
2026-06-21 14:26:27 +00:00
parent b43c1c9eda
commit 24638cc50c
11 changed files with 180 additions and 6 deletions
@@ -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
@@ -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<UserTag>()
.map { it.pubKey },
)
"getBlocked" ->
jsonStringArray(
account.blockPeopleList.flow.value
.filterIsInstance<UserTag>()
.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>): String = buildJsonArray { items.forEach { add(it) } }.toString()
/** Builds `{ "<relay url>": { "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()) {
@@ -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.
@@ -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))
+1
View File
@@ -770,6 +770,7 @@
<string name="napplet_consent_deny_always">Never allow</string>
<string name="napplet_consent_not_now">Not now</string>
<string name="napplet_consent_get_pubkey">This napplet wants to read your public key.</string>
<string name="napplet_consent_identity_read">This napplet wants to read your profile and account data.</string>
<string name="napplet_consent_publish">This napplet wants to sign and publish a kind %1$d event as you.</string>
<string name="napplet_consent_publish_preview">This napplet wants to sign and publish a kind %1$d event as you:</string>
<string name="napplet_consent_publish_encrypted">This napplet wants to send an encrypted event as you.</string>
@@ -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"}"""))
@@ -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)
@@ -54,6 +54,20 @@ interface NappletRelayGateway {
suspend fun query(filter: Filter): List<Event>
}
/**
* 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
@@ -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,
@@ -65,6 +65,14 @@ sealed interface NappletResponse {
val values: List<String>,
) : 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,
@@ -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<NappletResponse.Json>(implemented)
assertEquals("""["aa","bb"]""", implemented.raw)
// A method the gateway doesn't implement degrades gracefully to Unsupported.
assertIs<NappletResponse.Unsupported>(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<NappletResponse.Unsupported>(response)
}
@Test
fun resourceAndUploadAreUnsupportedWithoutGateways() =
runTest {