refactor(napplet): extract host-agnostic NappletRequestRouter to commons

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
Claude
2026-06-21 23:59:53 +00:00
parent 0b76518ef2
commit 0cff3bf8e2
4 changed files with 256 additions and 47 deletions
@@ -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<NappletCapability>,
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<Filter>,
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))
@@ -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<Filter>,
) : 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<String>,
) : Outcome
}
suspend fun route(
broker: NappletBroker,
identity: NappletIdentity,
declared: Set<NappletCapability>,
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))
}
}
@@ -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<String> = emptyList()
override suspend fun query(filters: List<Filter>): List<Event> = 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<NappletRequestRouter.Outcome.Reply>(outcome)
assertTrue(outcome.payload.contains("resource.cancel.result"))
}
@Test
fun malformedRequestRepliesFailed() =
runTest {
val outcome = route("""{"type":"inc.emit","topic":"t"}""")
assertIs<NappletRequestRouter.Outcome.Reply>(outcome)
assertTrue(outcome.payload.contains("failed"))
}
@Test
fun queryRepliesWithItsResult() =
runTest {
val outcome = route("""{"type":"relay.query","filters":[{"kinds":[1]}]}""")
assertIs<NappletRequestRouter.Outcome.Reply>(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<NappletRequestRouter.Outcome.OpenSubscription>(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<NappletRequestRouter.Outcome.Push>(outcome)
assertEquals(1, outcome.payloads.size)
assertTrue(outcome.payloads.first().contains("relay.eose"))
}
}
@@ -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