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