feat(napplet): live subscription tail, multi-filters, resource.cancel

Implements the remaining tractable conformance follow-ups:

- Live subscription tail: relay.subscribe now opens a real
  client.subscribe whose SubscriptionListener streams relay.event
  (stored + live), relay.eose, and relay.closed pushes keyed by subId,
  instead of a one-shot snapshot. relay.close unsubscribes (tracked in
  liveSubs, torn down in onDestroy). The broker only authorizes the
  subscription (RELAY consent) and returns Subscribed; the host owns the
  live stream. Shim dispatches relay.closed too.
- Multi-filters: relay.query/subscribe honor every filter in filters[],
  not just the first (decodeFilterList; gateway query(List<Filter>);
  queryEvents unions across filters; max limit applied).
- resource.cancel: accepted at the host edge as a no-op Done.

Conformance tests extended (multi-filter decode, relay.closed push);
commons:jvmTest and the amethyst napplet suite pass.

Still open: identity getList/getZaps/getBadges + onChanged (shapes
underspecified), the keys.action push (needs a host trigger UI), the
resource nostr: scheme, inc + the niche domains, and on-device
verification.

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 21:43:36 +00:00
parent 0f23051d54
commit cd2bae081e
11 changed files with 182 additions and 50 deletions
@@ -168,13 +168,14 @@ class NappletBroker(
is NappletRequest.QueryEvents -> {
val gateway = relay ?: return NappletResponse.Unsupported("relay.query")
NappletResponse.Events(gateway.query(request.filter))
NappletResponse.Events(gateway.query(request.filters))
}
// Live tailing is a follow-up; for now subscribe returns the initial matches.
// The broker only authorizes the subscription (consent + declaration); the host opens the
// live relay subscription and streams relay.event/relay.eose/relay.closed by subId.
is NappletRequest.Subscribe -> {
val gateway = relay ?: return NappletResponse.Unsupported("relay.subscribe")
NappletResponse.Events(gateway.query(request.filter))
relay ?: return NappletResponse.Unsupported("relay.subscribe")
NappletResponse.Subscribed
}
is NappletRequest.StorageGet -> {
@@ -50,8 +50,8 @@ interface NappletRelayGateway {
/** Publishes [event] and returns the relay URLs that accepted it (empty = nowhere reached). */
suspend fun publish(event: Event): List<String>
/** Returns events matching [filter] (e.g. from the local cache and/or a bounded relay fetch). */
suspend fun query(filter: Filter): List<Event>
/** Returns events matching any of [filters] (e.g. from the local cache and/or a bounded relay fetch). */
suspend fun query(filters: List<Filter>): List<Event>
}
/**
@@ -140,19 +140,19 @@ sealed interface NappletRequest {
}
}
/** Read events matching [filter] (from the cache and/or a bounded relay fetch). */
/** Read events matching [filters] (from the cache and/or a bounded relay fetch). */
data class QueryEvents(
val filter: Filter,
val filters: List<Filter>,
) : NappletRequest {
override val capability get() = NappletCapability.RELAY
}
/**
* Subscribe to events matching [filter]. The shell currently answers with the initial matches
* (like a query); a live tail over the existing reply channel is a follow-up.
* Subscribe to events matching [filters]. The shell opens a live relay subscription and pushes
* `relay.event`/`relay.eose`/`relay.closed` keyed by the applet's `subId`.
*/
data class Subscribe(
val filter: Filter,
val filters: List<Filter>,
) : NappletRequest {
override val capability get() = NappletCapability.RELAY
}
@@ -60,6 +60,9 @@ sealed interface NappletResponse {
val actionId: String,
) : NappletResponse
/** `relay.subscribe` was authorized; the host now streams `relay.event`/`relay.eose` pushes. */
data object Subscribed : NappletResponse
/** Result of a storage read; [value] is null when the key is absent. */
data class StorageValue(
val value: String?,
@@ -77,7 +77,7 @@ class NappletBrokerTest {
return listOf("wss://relay.example")
}
override suspend fun query(filter: Filter): List<Event> = queryResult
override suspend fun query(filters: List<Filter>): List<Event> = queryResult
}
private class RecordingWallet(
@@ -276,7 +276,7 @@ class NappletBrokerTest {
val response =
broker(ScriptedPrompt(GrantState.ALLOW_ONCE), relay = relay)
.handle(applet, NappletRequest.QueryEvents(Filter(kinds = listOf(1))), allDeclared)
.handle(applet, NappletRequest.QueryEvents(listOf(Filter(kinds = listOf(1)))), allDeclared)
assertEquals(NappletResponse.Events(listOf(cached)), response)
}