From 0cff3bf8e216e8fc9df987da25751c2008b09e3e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 23:59:53 +0000 Subject: [PATCH] refactor(napplet): extract host-agnostic NappletRequestRouter to commons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the decode → broker → encode orchestration out of Android's NappletBrokerService.handleMessage and into a pure, transport-free NappletRequestRouter in commons/jvmAndroid. It returns a small Outcome (Ignore / Reply / OpenSubscription / CloseSubscription / Push) that each host acts on, so the Android service and the future desktop host share the routing brain and can't drift on wire behavior. The service now resolves the broker and dispatches on the Outcome, supplying only the Messenger transport and the live relay subscription. openLiveSubscription takes the decoded filters from the router instead of re-decoding the payload, and the now-redundant process() is removed. Unit-tested in commons/jvmTest (NappletRequestRouterTest). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde --- .../amethyst/napplet/NappletBrokerService.kt | 56 ++------ .../commons/napplet/NappletRequestRouter.kt | 103 ++++++++++++++ .../napplet/NappletRequestRouterTest.kt | 130 ++++++++++++++++++ .../plans/2026-06-21-napplet-desktop-host.md | 14 +- 4 files changed, 256 insertions(+), 47 deletions(-) create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouterTest.kt 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 cf71084d97..ebfe0324e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -41,6 +41,7 @@ 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.NappletRequestRouter import com.vitorpamplona.amethyst.commons.napplet.NappletResource import com.vitorpamplona.amethyst.commons.napplet.NappletResourceGateway import com.vitorpamplona.amethyst.commons.napplet.NappletUploadGateway @@ -163,33 +164,20 @@ class NappletBrokerService : Service() { val requestType = runCatching { NappletProtocolJson.readType(payload) }.getOrNull() ?: "napplet" scope.launch { - // Fire-and-forget edge ops that don't go through the broker. - when (requestType) { - "relay.close" -> { - runCatching { NappletProtocolJson.readSubId(payload) }.getOrNull()?.let { closeLiveSubscription(it) } - reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Done)) - return@launch - } - "resource.cancel" -> { - reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Done)) - return@launch - } + // The shared, host-agnostic router owns decode → broker → encode and the subscribe-vs-reply + // decision (it stays wire-identical with the future desktop host). This service only supplies + // the broker, the Messenger transport, and the live relay subscription each Outcome implies. + val broker = broker() + if (broker == null) { + reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("No account is signed in."))) + return@launch } - - val response = process(identity, declared, payload) - - // A subscription is answered with relay.event/relay.eose/relay.closed pushes keyed by - // subId (matching @napplet/shim) — the host opens a live relay subscription once the - // broker authorizes it. A non-authorized subscription closes immediately with an EOSE. - val subId = if (requestType == "relay.subscribe") runCatching { NappletProtocolJson.readSubId(payload) }.getOrNull() else null - if (subId != null) { - if (response is NappletResponse.Subscribed) { - openLiveSubscription(subId, payload, replyTo) - } else { - push(replyTo, NappletProtocolJson.encodeRelayEose(subId)) - } - } else { - reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, response)) + when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) { + is NappletRequestRouter.Outcome.Ignore -> {} + is NappletRequestRouter.Outcome.Reply -> reply(replyTo, requestId, outcome.payload) + is NappletRequestRouter.Outcome.OpenSubscription -> openLiveSubscription(outcome.subId, outcome.filters, replyTo) + is NappletRequestRouter.Outcome.CloseSubscription -> closeLiveSubscription(outcome.subId) + is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) } } } return true @@ -202,19 +190,6 @@ class NappletBrokerService : Service() { ?.toSet() ?: emptySet() - private suspend fun process( - identity: NappletIdentity, - declared: Set, - payload: String, - ): NappletResponse { - val request = - runCatching { NappletProtocolJson.decodeRequest(payload) }.getOrNull() - ?: return NappletResponse.Failed("Malformed or unsupported request.") - - val broker = broker() ?: return NappletResponse.Failed("No account is signed in.") - return broker.handle(identity, request, declared) - } - /** * The broker for the *currently* signed-in account, cached and rebuilt only when the account * changes (reference identity). The gateways capture the account and read its flows live, so a @@ -501,11 +476,10 @@ class NappletBrokerService : Service() { */ private fun openLiveSubscription( nappletSubId: String, - payload: String, + filters: List, replyTo: Messenger, ) { val account = Amethyst.instance.sessionManager.loggedInAccount() - val filters = runCatching { NappletProtocolJson.decodeFilterList(payload) }.getOrDefault(emptyList()) val relays = account?.homeRelays?.flow?.value ?: emptySet() if (account == null || filters.isEmpty() || relays.isEmpty()) { push(replyTo, NappletProtocolJson.encodeRelayEose(nappletSubId)) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt new file mode 100644 index 0000000000..c4d1773115 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.napplet + +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +/** + * The host-agnostic orchestration brain: turns one raw applet envelope into an [Outcome] a platform + * host acts on. It owns the decode → broker → encode flow, the fire-and-forget edge ops + * (`relay.close`, `resource.cancel`), and the subscribe-vs-reply decision — everything *except* the + * transport (Messenger / IPC / in-process) and the live relay subscription, which are the host's job. + * + * Both the Android `:napplet` service and a future desktop host route through here, so their wire + * behavior can't drift. (The `shell.ready` handshake is handled at the WebView edge before the + * boundary, so the router never sees it.) + */ +object NappletRequestRouter { + sealed interface Outcome { + /** Nothing to do (e.g. a malformed fire-and-forget message). */ + data object Ignore : Outcome + + /** Send this `.result` payload back, correlated to the request's id. */ + data class Reply( + val payload: String, + ) : Outcome + + /** Open a live relay subscription; the host streams `relay.event`/`relay.eose`/`relay.closed` by [subId]. */ + data class OpenSubscription( + val subId: String, + val filters: List, + ) : Outcome + + /** Stop the live subscription [subId] (fire-and-forget; no reply). */ + data class CloseSubscription( + val subId: String, + ) : Outcome + + /** Push these envelope(s) to the applet immediately, unkeyed (e.g. an EOSE closing a refused sub). */ + data class Push( + val payloads: List, + ) : Outcome + } + + suspend fun route( + broker: NappletBroker, + identity: NappletIdentity, + declared: Set, + payload: String, + ): Outcome { + val requestType = runCatching { NappletProtocolJson.readType(payload) }.getOrNull() ?: "napplet" + + // Fire-and-forget edge ops that never reach the broker. + when (requestType) { + "relay.close" -> { + val subId = runCatching { NappletProtocolJson.readSubId(payload) }.getOrNull() + return if (subId != null) Outcome.CloseSubscription(subId) else Outcome.Ignore + } + "resource.cancel" -> + return Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, NappletResponse.Done)) + } + + val request = + runCatching { NappletProtocolJson.decodeRequest(payload) }.getOrNull() + ?: return Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("Malformed or unsupported request."))) + + val response = broker.handle(identity, request, declared) + + // A subscription streams pushes keyed by subId instead of sending a .result. + if (requestType == "relay.subscribe") { + val subId = + runCatching { NappletProtocolJson.readSubId(payload) }.getOrNull() + ?: return Outcome.Ignore + return if (response is NappletResponse.Subscribed) { + Outcome.OpenSubscription(subId, runCatching { NappletProtocolJson.decodeFilterList(payload) }.getOrDefault(emptyList())) + } else { + // Not authorized → close it immediately with an empty EOSE. + Outcome.Push(listOf(NappletProtocolJson.encodeRelayEose(subId))) + } + } + + return Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, response)) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouterTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouterTest.kt new file mode 100644 index 0000000000..42dd1972ea --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouterTest.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.napplet + +import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState +import com.vitorpamplona.amethyst.commons.napplet.permissions.InMemoryNappletPermissionStore +import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * The host-agnostic routing brain. Verifies that one raw envelope maps to the right [Outcome] for + * each host to act on — independent of any transport (Messenger / IPC / in-process). + */ +class NappletRequestRouterTest { + private val signer = NostrSignerInternal(KeyPair("00".repeat(31).plus("07").hexToByteArray())) + private val applet = NappletIdentity(authorPubKey = "aa".repeat(32), identifier = "demo") + private val allDeclared = NappletCapability.entries.toSet() + + private class Prompt( + private val answer: GrantState, + ) : NappletConsentPrompt { + override suspend fun request( + identity: NappletIdentity, + capability: NappletCapability, + request: NappletRequest, + ): GrantState = answer + } + + private class FakeRelay : NappletRelayGateway { + override suspend fun publish(event: Event): List = emptyList() + + override suspend fun query(filters: List): List = emptyList() + } + + private fun broker(answer: GrantState = GrantState.ALLOW_ONCE) = + NappletBroker( + signer, + NappletPermissionLedger(InMemoryNappletPermissionStore()), + Prompt(answer), + relay = FakeRelay(), + ) + + private suspend fun route( + payload: String, + answer: GrantState = GrantState.ALLOW_ONCE, + ) = NappletRequestRouter.route(broker(answer), applet, allDeclared, payload) + + @Test + fun relayCloseBecomesCloseSubscription() = + runTest { + assertEquals( + NappletRequestRouter.Outcome.CloseSubscription("s1"), + route("""{"type":"relay.close","subId":"s1"}"""), + ) + // No subId → nothing to do. + assertEquals(NappletRequestRouter.Outcome.Ignore, route("""{"type":"relay.close"}""")) + } + + @Test + fun resourceCancelRepliesDone() = + runTest { + val outcome = route("""{"type":"resource.cancel"}""") + assertIs(outcome) + assertTrue(outcome.payload.contains("resource.cancel.result")) + } + + @Test + fun malformedRequestRepliesFailed() = + runTest { + val outcome = route("""{"type":"inc.emit","topic":"t"}""") + assertIs(outcome) + assertTrue(outcome.payload.contains("failed")) + } + + @Test + fun queryRepliesWithItsResult() = + runTest { + val outcome = route("""{"type":"relay.query","filters":[{"kinds":[1]}]}""") + assertIs(outcome) + assertTrue(outcome.payload.contains("relay.query.result")) + } + + @Test + fun authorizedSubscribeOpensALiveSubscriptionWithAllFilters() = + runTest { + val outcome = route("""{"type":"relay.subscribe","subId":"s1","filters":[{"kinds":[1]},{"authors":["aa"]}]}""") + assertIs(outcome) + assertEquals("s1", outcome.subId) + assertEquals(2, outcome.filters.size) + assertEquals(listOf(1), outcome.filters[0].kinds) + assertEquals(listOf("aa"), outcome.filters[1].authors) + } + + @Test + fun refusedSubscribePushesAnEoseToCloseIt() = + runTest { + val outcome = route("""{"type":"relay.subscribe","subId":"s1","filters":[{"kinds":[1]}]}""", answer = GrantState.DENY) + assertIs(outcome) + assertEquals(1, outcome.payloads.size) + assertTrue(outcome.payloads.first().contains("relay.eose")) + } +} diff --git a/desktopApp/plans/2026-06-21-napplet-desktop-host.md b/desktopApp/plans/2026-06-21-napplet-desktop-host.md index 866d3befc8..8e693287ad 100644 --- a/desktopApp/plans/2026-06-21-napplet-desktop-host.md +++ b/desktopApp/plans/2026-06-21-napplet-desktop-host.md @@ -46,14 +46,16 @@ the envelope/push contract. See "Shared web assets" below for how to share them. ## Recommended shared extractions (do as part of, or just before, desktop work) -These reduce desktop reimplementation and prevent drift. None are done yet: +These reduce desktop reimplementation and prevent drift: -- **`NappletRequestRouter` (commons/jvmAndroid).** Today the orchestration (readType → `relay.close` +- **DONE: `NappletRequestRouter` (commons/jvmAndroid).** The orchestration (readType → `relay.close` / `resource.cancel` short-circuit → decode → `broker.handle` → encode reply / detect - `Subscribed`) lives in Android's `NappletBrokerService.handleMessage`. Extract it into a pure - router returning a small `Outcome` (`Reply(payload)` / `OpenSubscription(subId, filters)` / - `CloseSubscription(subId)`), so both hosts share the brain and only supply transport + relay - client. It's unit-testable in commons. + `Subscribed`) used to live in Android's `NappletBrokerService.handleMessage`. It is now a pure + router returning a small `Outcome` (`Ignore` / `Reply(payload)` / `OpenSubscription(subId, filters)` / + `CloseSubscription(subId)` / `Push(payloads)`), so both hosts share the brain and only supply the + broker + transport + live relay subscription. Unit-tested in `commons/jvmTest` + (`NappletRequestRouterTest`); Android's service consumes it via a `when (outcome)` dispatch. The + desktop host will route the exact same way. - **Shared web assets.** Move `shell.html` + `shim.js` into `commons/commonMain/composeResources/files/napplet/` and read them via the generated `Res.readBytes("files/napplet/...")` on both platforms (the Material Symbols font already lives in commons composeResources). This makes the web contract