mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
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:
@@ -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))
|
||||
|
||||
@@ -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"}"""))
|
||||
|
||||
+7
@@ -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)
|
||||
|
||||
+14
@@ -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
|
||||
|
||||
+13
@@ -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,
|
||||
|
||||
+8
@@ -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,
|
||||
|
||||
+30
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user