diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
index f7847f225a..1a99f3bf5f 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
@@ -1873,11 +1873,11 @@ object LocalCache : ILocalCache, ICacheProvider {
if (result is LnZapReceiptValidator.Result.Invalid &&
result.reason != LnZapReceiptValidator.Result.Reason.MISMATCHED_LNURL
) {
- Log.w("ZP", "dropping zap receipt ${event.id}: ${result.reason} ${result.detail ?: ""}")
+ Log.w("ZP") { "dropping zap receipt ${event.id}: ${result.reason} ${result.detail ?: ""}" }
return false
}
if (result is LnZapReceiptValidator.Result.Invalid) {
- Log.w("ZP", "zap receipt ${event.id} has mismatched lnurl tag (accepting per SHOULD)")
+ Log.w("ZP") { "zap receipt ${event.id} has mismatched lnurl tag (accepting per SHOULD)" }
}
note.loadEvent(event, author, repliesTo)
@@ -1908,7 +1908,7 @@ object LocalCache : ILocalCache, ICacheProvider {
try {
val info = resolver.resolve(recipientLnurlpUrl)
if (info == null) {
- Log.w("ZP", "could not fetch lnurlp for ${event.id}; not crediting")
+ Log.w("ZP") { "could not fetch lnurlp for ${event.id}; not crediting" }
return@launch
}
val result =
@@ -1920,11 +1920,11 @@ object LocalCache : ILocalCache, ICacheProvider {
if (result is LnZapReceiptValidator.Result.Invalid &&
result.reason != LnZapReceiptValidator.Result.Reason.MISMATCHED_LNURL
) {
- Log.w("ZP", "dropping zap receipt ${event.id}: ${result.reason} ${result.detail ?: ""}")
+ Log.w("ZP") { "dropping zap receipt ${event.id}: ${result.reason} ${result.detail ?: ""}" }
return@launch
}
if (result is LnZapReceiptValidator.Result.Invalid) {
- Log.w("ZP", "zap receipt ${event.id} has mismatched lnurl tag (accepting per SHOULD)")
+ Log.w("ZP") { "zap receipt ${event.id} has mismatched lnurl tag (accepting per SHOULD)" }
}
repliesTo.forEach { it.addZap(zapRequest, note) }
} catch (t: Throwable) {
@@ -2221,7 +2221,7 @@ object LocalCache : ILocalCache, ICacheProvider {
if (wasVerified || justVerify(event)) {
val expectedServicePubkey =
event.walletServicePubKey() ?: run {
- Log.w("LocalCache", "NWC request ${event.id} has no `p` tag; cannot register for response.")
+ Log.w("LocalCache") { "NWC request ${event.id} has no `p` tag; cannot register for response." }
return false
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt
index 69c9b0b9e9..f1f2eee12b 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt
@@ -111,7 +111,7 @@ class RelayProxyClientConnector(
lastTorConnection = it.torConnection
lastClearConnection = it.clearConnection
- Log.d("ManageRelayServices", "Relay Services have changed, reconnecting relays that need to (transportChanged=$transportChanged)")
+ Log.d("ManageRelayServices") { "Relay Services have changed, reconnecting relays that need to (transportChanged=$transportChanged)" }
client.reconnect(
onlyIfChanged = true,
ignoreRetryDelays = transportChanged,
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt
index 1f2a386148..d5898b181b 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt
@@ -257,7 +257,7 @@ class ScheduledPostStore(
Log.w(TAG) { "Failed to delete existing $storageFile before rename retry" }
}
if (!tmp.renameTo(storageFile)) {
- Log.e(TAG, "Failed to rename $tmp to $storageFile")
+ Log.e(TAG) { "Failed to rename $tmp to $storageFile" }
if (!tmp.delete()) {
Log.w(TAG) { "Failed to clean up temp file $tmp after rename failure" }
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt
index f104116d02..b9b339332f 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt
@@ -141,7 +141,7 @@ class ScheduledPostWorker(
}
val account = appModules.accountsCache.accounts.value[post.accountPubkey]
if (account == null) {
- Log.w(TAG, "Account ${post.accountPubkey} not loaded; releasing ${post.id} for retry")
+ Log.w(TAG) { "Account ${post.accountPubkey} not loaded; releasing ${post.id} for retry" }
store.releaseClaim(post.id)
continue
}
@@ -164,7 +164,7 @@ class ScheduledPostWorker(
val msg = "no relay acknowledged within ${OK_TIMEOUT_SEC}s"
store.markFailed(post.id, msg)
ScheduledPostNotifier.notifyFailed(applicationContext, post, msg)
- Log.w(TAG, "client.publish(${post.id}) failed: $msg")
+ Log.w(TAG) { "client.publish(${post.id}) failed: $msg" }
}
} catch (e: CancellationException) {
throw e
diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml
index 30ba5883aa..7416df43c6 100644
--- a/amethyst/src/main/res/values-zh-rCN/strings.xml
+++ b/amethyst/src/main/res/values-zh-rCN/strings.xml
@@ -1364,6 +1364,8 @@
账户设置
应用程序设置
+ 搜索设置
+ 找不到 \"%1$s \" 的设置
diff --git a/docs/namecoin-nip05-design.md b/docs/namecoin-nip05-design.md
index 557e88ff96..d590994fbf 100644
--- a/docs/namecoin-nip05-design.md
+++ b/docs/namecoin-nip05-design.md
@@ -25,39 +25,62 @@ This is censorship-resistant identity verification: no web server to seize, no D
│ ProxiedSocketFactory │ │
│ └── SOCKS5 proxy routing ───┘ │
├────────────────────────────────────┼────────────────────────┤
-│ Quartz Library │
+│ Quartz Library │
├────────────────────────────────────┼────────────────────────┤
│ NamecoinNameResolver │ │
│ ├── parseIdentifier() │ │
│ ├── extractFromDomainValue() │ (d/ namespace) │
│ ├── extractFromIdentityValue() │ (id/ namespace) │
+│ ├── NamecoinImportResolver │ (ifa-0001 imports) │
│ └── serverListProvider() │ (Tor/clearnet routing) │
│ │ │
-│ ElectrumxClient │ │
+│ CompositeNamecoinBackend │ │
+│ ├── primary ─── ElectrumxClient or NamecoinCoreRpcClient│
+│ ├── custom ElectrumX fallback │
+│ └── default ElectrumX fallback │
+│ │ │
+│ ElectrumXClient │ │
│ ├── buildNameIndexScript() │ │
│ ├── electrumScriptHash() │ │
-│ ├── parseNameScript() │ │
+│ ├── parseNameScript() │ (NAME_UPDATE + FIRSTUPDATE)│
│ └── socketFactory() ───┤ (injected, proxy-aware)│
+│ │ │
+│ NamecoinCoreRpcClient │ │
+│ └── HTTP(S) JSON-RPC ──── name_show ── full node │
│ ▼ │
-│ ┌───────────────────────────┐ │
-│ │ ElectrumX Server │ │
-│ │ (clearnet or .onion) │ │
-│ └───────────────────────────┘ │
+│ ┌────────────────────────────────────────────┐ │
+│ │ ElectrumX server OR Namecoin Core node │ │
+│ │ (clearnet, .onion, LAN, StartOS, umbrel) │ │
+│ └────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### Layer Separation
-- **`quartz/` (library)** — Protocol-level logic. No Android dependencies.
- - `ElectrumxClient` — TCP/TLS connection to ElectrumX, JSON-RPC, script parsing. Accepts an injected `SocketFactory` lambda for proxy/Tor support.
- - `NamecoinNameResolver` — Identifier parsing, value extraction, NIP-05 mapping. Accepts a `serverListProvider` lambda for dynamic server selection.
- - `NamecoinLookupCache` — LRU cache with TTL
- - `NamecoinNameResolverTest` — Unit tests for parsing and value extraction
+- **`quartz/` (library)** — Protocol-level logic. No Android dependencies in `commonMain`; TCP / TLS / HTTP clients live in `jvmAndroid`.
+ - `ElectrumXClient` — TCP/TLS connection to ElectrumX, JSON-RPC, script parsing. Accepts an injected `SocketFactory` lambda for proxy/Tor support. Path: `quartz/.../nip05DnsIdentifiers/namecoin/ElectrumXClient.kt` (`jvmAndroid`).
+ - `NamecoinCoreRpcClient` — HTTP(S) JSON-RPC client for a Namecoin Core full node. Lives in `jvmAndroid`. Same TOFU-pinning model as `ElectrumXClient`.
+ - `NamecoinNameResolver` — Identifier parsing, value extraction, NIP-05 mapping, ifa-0001 `import` expansion. Accepts a `serverListProvider` lambda for dynamic server selection and exposes `resolveDetailed()` returning a `NamecoinResolveOutcome` sealed type (Success / NameNotFound / NameExpired / NoNostrField / MalformedRecord / ServersUnreachable / InvalidIdentifier / Timeout).
+ - `CompositeNamecoinBackend` — Chains a primary backend (Core RPC or custom ElectrumX) with optional custom-ElectrumX and default-ElectrumX fallbacks, per `NamecoinFallbackPolicy`.
+ - `NamecoinImportResolver` — Resolves the [ifa-0001](https://github.com/namecoin/proposals/blob/master/ifa-0001.md) `import` item (string / array / array-of-arrays forms) before record extraction.
+ - `NamecoinLookupCache` — LRU cache with TTL.
+ - `NamecoinNameResolverTest`, `CompositeNamecoinBackendTest`, `NamecoinImportTest`, `NamecoinCoreRpcClientTest` — JVM unit tests.
-- **`amethyst/` (app)** — Android integration and Tor-aware wiring.
- - `NamecoinNameService` — Application singleton, initialized with a proxy-aware `ElectrumxClient`
- - `ProxiedSocketFactory` — `SocketFactory` implementation that routes through a SOCKS5 proxy (Tor)
- - `RoleBasedHttpClientBuilder.socketFactoryForNip05()` — Returns a proxy-aware or default `SocketFactory` based on current Tor settings
+- **`commons/` (Kotlin multiplatform)** — Settings schema shared by Android and Desktop.
+ - `NamecoinSettings` — Serializable config (backend choice, custom servers, Core RPC URL/creds, fallback toggles) used by both platforms' persistence layers.
+ - `NamecoinResolveState` — UI state model surfaced by search / on-chain zap rows.
+
+- **`amethyst/` (Android app)** — Android integration and Tor-aware wiring.
+ - `NamecoinNameService` — Application singleton, initialized with a proxy-aware `ElectrumXClient` and (optionally) a `NamecoinCoreRpcClient` via `CompositeNamecoinBackend`.
+ - `NamecoinSharedPreferences` — DataStore-backed persistence for `NamecoinSettings`, including TOFU-pinned PEM certs.
+ - `ProxiedSocketFactory` — `SocketFactory` implementation that routes through a SOCKS5 proxy (Tor).
+ - `RoleBasedHttpClientBuilder.socketFactoryForNip05()` — Returns a proxy-aware or default `SocketFactory` based on current Tor settings.
+ - `NamecoinSettingsScreen` / `NamecoinSettingsSection` — Backend picker, custom-server editor, Test Connection diagnostics, TOFU pin prompts.
+ - `NamecoinResolutionRow` — Inline indicator that surfaces a `NamecoinResolveState` in search results, on-chain zap dialogs, etc.
+
+- **`desktopApp/` (JVM desktop)** — Mirrors the Android wiring for Compose Desktop.
+ - `DesktopNamecoinNameService`, `DesktopNamecoinPreferences`, `LocalNamecoin` — lazy-initialized service stack so Namecoin code is not loaded until the user resolves a `.bit` name.
+ - `desktop/ui/settings/NamecoinSettingsSection.kt` — desktop equivalent of the Android settings UI.
## Tor & Proxy Integration
@@ -65,25 +88,19 @@ The ElectrumX connection respects the user's Tor settings to prevent IP leaks:
### Problem
-The original `ElectrumxClient` used raw `java.net.Socket` / `SSLSocket` directly, bypassing OkHttp entirely. This meant Namecoin lookups would leak the user's real IP even when they had configured Tor for NIP-05 verification traffic.
+The original `ElectrumXClient` used raw `java.net.Socket` / `SSLSocket` directly, bypassing OkHttp entirely. This meant Namecoin lookups would leak the user's real IP even when they had configured Tor for NIP-05 verification traffic.
### Solution
-1. **`ElectrumxClient`** accepts a `socketFactory: () -> SocketFactory` lambda (evaluated at each connection, not captured at construction)
+1. **`ElectrumXClient`** accepts a `socketFactory: () -> SocketFactory` lambda (evaluated at each connection, not captured at construction)
2. **`ProxiedSocketFactory`** creates sockets routed through a `java.net.Proxy` (SOCKS5)
3. **`RoleBasedHttpClientBuilder.socketFactoryForNip05()`** checks the user's NIP-05 Tor settings and returns either `SocketFactory.getDefault()` or a `ProxiedSocketFactory` with the active Tor SOCKS proxy
4. SSL is layered on top of the (possibly proxied) base socket via `SSLSocketFactory.createSocket(socket, host, port, autoClose)`, preserving the proxy tunnel
+5. The Namecoin Core RPC path reuses the same `socketFactoryForNip05()` plumbing, so a user-supplied node URL (onion or LAN) inherits the same Tor / proxy rules with no extra wiring
### Server Selection
-When Tor is enabled for NIP-05 traffic, the server list switches to prioritize onion routing:
-
-| Setting | Primary server | Fallback |
-|---|---|---|
-| **Tor off** | `electrumx.testls.space:50002` | `ulrichard.ch:50006`, `nmc2.lelux.fi:50006` |
-| **Tor on** | `.onion:50002` (see below) | `electrumx.testls.space:50002` (via Tor) |
-
-The `serverListProvider` lambda in `NamecoinNameResolver` is evaluated at resolution time, so toggling Tor settings takes effect immediately without restarting the app.
+The `serverListProvider` lambda in `NamecoinNameResolver` is evaluated at resolution time, so toggling Tor settings takes effect immediately without restarting the app. The current defaults are listed under [Default ElectrumX Servers](#default-electrumx-servers) below — when Tor is enabled for NIP-05 traffic, the resolver switches to `TOR_ELECTRUMX_SERVERS`, which prepends `.onion` endpoints to the clearnet set.
### Dynamic Evaluation
@@ -196,7 +213,7 @@ The integration is minimal and non-invasive:
The search bar resolves Namecoin identifiers in real-time via `SearchBarViewModel`:
1. A `namecoinResolvedUser` flow watches the search input with a 400ms debounce
-2. If the input matches any Namecoin format (`d/*`, `id/*`, `*.bit`, `*@*.bit`), it resolves via `NamecoinNameService` → `ElectrumxClient` → blockchain
+2. If the input matches any Namecoin format (`d/*`, `id/*`, `*.bit`, `*@*.bit`), it resolves via `NamecoinNameService` → `CompositeNamecoinBackend` (`ElectrumXClient` and/or `NamecoinCoreRpcClient`) → blockchain. `NamecoinResolutionRow` shows the live state (Resolving / Found / NameNotFound / NameExpired / MalformedRecord / NoNostrField / ServersUnreachable).
3. The resolved pubkey is used to get/create a `User` in `LocalCache`
4. The Namecoin-resolved user is prepended to the standard local search results (deduplicated)
@@ -204,20 +221,103 @@ This means typing `alice@example.bit`, `example.bit`, `d/example`, or `id/alice`
## Default ElectrumX Servers
-### Clearnet (Tor off)
-| Server | Port | TLS | Notes |
-|---|---|---|---|
-| `electrumx.testls.space` | 50002 | Yes (self-signed) | Primary |
-| `ulrichard.ch` | 50006 | Yes | Fallback |
-| `nmc2.lelux.fi` | 50006 | Yes | Fallback |
+Defined in `quartz/.../nip05DnsIdentifiers/namecoin/ElectrumXServer.kt` as `DEFAULT_ELECTRUMX_SERVERS` (clearnet) and `TOR_ELECTRUMX_SERVERS` (Tor-preferred).
-### Tor (Tor on for NIP-05)
-| Server | Port | TLS | Notes |
-|---|---|---|---|
-| `i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion` | 50002 | Yes (self-signed) | Primary — onion service for `electrumx.testls.space` |
-| `electrumx.testls.space` | 50002 | Yes (self-signed) | Fallback (routed through Tor SOCKS proxy) |
+### Clearnet (`DEFAULT_ELECTRUMX_SERVERS`)
+| Server | Port | TLS | Trust path | Notes |
+|---|---|---|---|---|
+| `electrumx.testls.space` | 50002 | Yes (self-signed) | Pinned | Primary |
+| `nmc2.bitcoins.sk` | 57002 | Yes (self-signed) | Pinned | Fallback |
+| `46.229.238.187` | 57002 | Yes (self-signed) | Pinned | Bare-IP peer of `nmc2.bitcoins.sk` (same operator/cert/box) |
+| `relay.testls.bit` | 50002 | Yes (self-signed) | Pinned | Second public Namecoin ElectrumX, co-located with the `wss://relay.testls.bit/` Nostr relay |
+| `23.158.233.10` | 50002 | Yes (self-signed) | Pinned | Bare-IP peer of `relay.testls.bit` |
+| `electrum.nmc.ethicnology.com` | 50002 | Yes (Let's Encrypt) | System CAs | Third public deployment ([ethicnology/namecoin-compose](https://github.com/ethicnology/namecoin-compose)); first entry that does NOT require a pinned cert |
-The `trustAllCerts` flag is set for servers with self-signed certificates. Users can configure custom servers via `NamecoinNameService.setCustomServers()`.
+### Tor (`TOR_ELECTRUMX_SERVERS`, used when "NIP-05 verifications via Tor" is on)
+| Server | Port | TLS | Trust path | Notes |
+|---|---|---|---|---|
+| `i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion` | 50002 | Yes (self-signed) | Pinned | Onion service for `electrumx.testls.space` |
+| `6cbn4rskfdr647otej7gpqlmpqcmj723vg2eoeuu7ljbwu6cpdebozyd.onion` | 50001 | No (plaintext) | n/a | Hidden service shared with the `relay.testls.bit` Nostr onion; onion key authenticates the endpoint |
+| `electrumx.testls.space` | 50002 | Yes (self-signed) | Pinned | Clearnet fallback (via Tor SOCKS) |
+| `nmc2.bitcoins.sk` | 57002 | Yes (self-signed) | Pinned | Clearnet fallback (via Tor SOCKS) |
+| `relay.testls.bit` | 50002 | Yes (self-signed) | Pinned | Clearnet fallback (via Tor SOCKS) |
+| `23.158.233.10` | 50002 | Yes (self-signed) | Pinned | Clearnet fallback (via Tor SOCKS) |
+| `electrum.nmc.ethicnology.com` | 50002 | Yes (Let's Encrypt) | System CAs | Clearnet fallback (via Tor SOCKS) |
+
+Servers with `usePinnedTrustStore = true` are validated against the hardcoded `PINNED_ELECTRUMX_CERTS` plus the user's TOFU-pinned cert store; `false` means the system trust store is sufficient (Let's Encrypt path).
+
+Users can add custom servers via the Namecoin settings screen — `NamecoinSettings.customServers` accepts `host:port` (TLS) or `host:port:tcp` (plaintext, useful for local `.onion`). When at least one custom server is configured it is used **exclusively**; the public defaults are skipped unless `fallbackToDefaultElectrumx` is enabled. Custom-server TLS certs are TOFU-pinned at first connection (see [Cert Pinning & TOFU](#cert-pinning--tofu)).
+
+## Backends, Composition, and Fallback Policy
+
+For a given resolution request, `NamecoinNameService` (or its desktop equivalent) builds a `CompositeNamecoinBackend` driven by the user's `NamecoinSettings`:
+
+```
+ primary fallback 1 fallback 2
+┌───────────────────────────┐ ┌───────────────────────────┐ ┌──────────────────────────┐
+│ backend = ELECTRUMX: │ │ (skipped — custom servers │ │ default ElectrumX │
+│ custom servers, else │─▶│ already are the primary) │─▶│ (`DEFAULT_ELECTRUMX_…`) │
+│ default servers │ │ │ │ if fallbackToDefault… │
+├───────────────────────────┤ ├───────────────────────────┤ ├──────────────────────────┤
+│ backend = NAMECOIN_CORE_RPC│ │ custom ElectrumX servers │ │ default ElectrumX │
+│ user-supplied node URL │─▶│ if fallbackToCustom… │─▶│ if fallbackToDefault… │
+└───────────────────────────┘ └───────────────────────────┘ └──────────────────────────┘
+```
+
+Key rules implemented in `CompositeNamecoinBackend`:
+
+- A definitive **"name not found"** answer short-circuits the chain (preserves privacy intent — the lookup already happened on the chosen backend).
+- **`ServersUnreachable`** (transport failures, all endpoints in a tier dead) cascades to the next configured tier.
+- `CancellationException` propagates immediately.
+- Both fallback toggles default to `false`; users must opt in explicitly. This matches the historical behaviour where custom ElectrumX servers were exclusive.
+
+### Namecoin Core RPC backend
+
+Set `backend = NAMECOIN_CORE_RPC` in settings and provide:
+
+- `url` — Full URL including scheme (`http://`, `https://`). Examples:
+ - StartOS: `https:///` (LAN cert auto-issued by StartOS — pin via TOFU)
+ - umbrel: `http://:8336/` or the "Connect From Outside" URL
+ - Raw host: `http://:8336/`
+ - Onion: `http://.onion:8336/` (routed through Tor SOCKS via `socketFactoryForNip05()`)
+- `username`, `password` — StartOS "RPC Credentials" or umbrel "Connect From Outside → RPC User / Password". Cookie-auth is not supported because users typically aren't on the node host.
+- `timeoutMs` — Per-call timeout, default 15 s, deliberately under the 20 s `NamecoinNameResolver` outer budget.
+- `usePinnedTrustStore` — Set true to route the HTTPS request through the pinned-cert socket factory (StartOS / umbrel LAN endpoints with self-signed root).
+
+The RPC client issues a single JSON-RPC `name_show` call, parses `expires_in`, and throws `NamecoinLookupException.NameExpired` when the name is expired. The Settings screen offers a **Test RPC** action that prompts the user to confirm the leaf cert fingerprint and pins it (TOFU) before the first real query.
+
+## ifa-0001 `import` resolution
+
+Namecoin's Domain Name Object spec allows a record to import items from another name via the `import` field ([ifa-0001](https://github.com/namecoin/proposals/blob/master/ifa-0001.md)). `NamecoinImportResolver` (in `quartz`) expands these before Quartz extracts NIP-05 fields, so a `.bit` name can centralise its Nostr config in a shared record.
+
+Behaviour:
+
+- Accepts canonical array-of-arrays form **and** the three short-hand forms (`"d/foo"`, `["d/foo"]`, `["d/foo", "sub"]`) that appear in real-world records.
+- Recurses up to depth 4 (the minimum the spec mandates); cycles are broken by a visited-set keyed on `name|selector`.
+- The importing object's items take precedence; a `null` item still suppresses the imported value.
+- Subdomain Selectors are resolved via the imported value's `map` tree before merging.
+- Failed imports (not found / malformed / network error) degrade to an empty `{}` rather than failing the whole resolution — keeps Quartz's existing best-effort namecoin behaviour intact.
+- Only items that Quartz actually consumes (e.g. `nostr`) are read after the merge; we do not recursively merge nested objects.
+
+## Cert Pinning & TOFU
+
+Namecoin's ElectrumX ecosystem predominantly uses self-signed TLS certs, so a plain system trust store would reject everything. Amethyst's model:
+
+- **`PINNED_ELECTRUMX_CERTS`** — hardcoded PEM bundle inside `ElectrumXClient.kt` covering the self-signed defaults (testls, nmc2.bitcoins.sk, relay.testls.bit, onion services). The bare-IP entries (`46.229.238.187`, `23.158.233.10`) work without SNI because the pin is on the DER SHA-256, not hostname.
+- **User-supplied PEM store** — TOFU-pinned certs captured the first time a custom ElectrumX server (or Namecoin Core RPC endpoint) is tested via the Settings UI. Persisted by `NamecoinSharedPreferences` (Android) / `DesktopNamecoinPreferences` (desktop).
+- **Let's Encrypt path** — `electrum.nmc.ethicnology.com` chains to a publicly-trusted cert, so its `ElectrumxServer` entry has `usePinnedTrustStore = false`. The system trust manager handles it; if the cert rotates we don't need a release to keep working.
+- **`.onion` cert handling** — Onion-routed connections skip cert pinning entirely (the onion key already authenticates the endpoint). Verified for the testls onion in 2025 and documented inline in `ElectrumXClient.kt`.
+- **Test Connection diagnostics** — Settings exposes a per-server test that returns `ServerTestResult { success, responseTimeMs, tlsVersion, serverCertPem, certFingerprint, error }`. The UI uses this to render success/failure plus a fingerprint-confirmation dialog for TOFU.
+
+## Name expiry
+
+Namecoin names expire after `NAME_EXPIRE_DEPTH = 36000` blocks (~250 days) if not renewed. Both backends now enforce this:
+
+- `ElectrumXClient` cross-references `current_height - height` against `NAME_EXPIRE_DEPTH` and throws `NamecoinLookupException.NameExpired(name)` for expired records; live `NameShowResult.expiresIn` is populated when the current height is known.
+- `NamecoinCoreRpcClient` reads the node's `expired` and `expires_in` fields from the JSON-RPC response and throws the same exception.
+- `NamecoinResolveOutcome.NameExpired` is surfaced to the search and on-chain zap UIs so users see why a `.bit` name failed to resolve.
+
+The resolver also distinguishes `MalformedRecord` (the on-chain value parsed but didn't conform to the expected shape) from `NoNostrField` (record is valid, just has no `nostr` item) — both are rendered with their own copy in `NamecoinResolutionRow`.
## Caching
@@ -228,29 +328,56 @@ The `trustAllCerts` flag is set for servers with self-signed certificates. Users
## Security Considerations
-- **Tor integration**: ElectrumX connections are routed through the user's Tor SOCKS proxy when NIP-05 Tor settings are enabled. This prevents IP leaks to ElectrumX servers. The onion server is preferred when Tor is active, providing end-to-end onion routing.
-- **Self-signed certificates**: The primary ElectrumX server uses a self-signed TLS cert. The `trustAllCerts` option accepts any certificate for that server. This is acceptable because the Namecoin blockchain itself provides the trust anchor — we verify names against on-chain data, not the transport layer. A MITM could return stale data but cannot forge name registrations.
-- **Name expiry**: Namecoin names expire after ~36,000 blocks (~250 days) if not renewed. The current implementation does not check expiry. Future work should compare the name's `height` + `expiresIn` against the current block height.
-- **Server trust**: The client trusts that the ElectrumX server returns accurate transaction data. For higher assurance, SPV proof verification could be added in the future.
+- **Tor integration**: ElectrumX and Namecoin Core RPC connections are routed through the user's Tor SOCKS proxy when NIP-05 Tor settings are enabled. This prevents IP leaks. The onion endpoints are preferred when Tor is active, providing end-to-end onion routing for both transports.
+- **Self-signed certificates**: Most public Namecoin ElectrumX servers use self-signed TLS certs. The `usePinnedTrustStore` flag (renamed from the original `trustAllCerts`) routes those connections through a SHA-256 pin set rather than blindly accepting any cert. This protects against arbitrary MITM while preserving the operator's ability to roll their own CA. The Namecoin blockchain itself remains the trust anchor for the name data; transport authentication only stops on-path tampering.
+- **TOFU pinning for custom endpoints**: Custom ElectrumX servers and the Namecoin Core RPC endpoint are TOFU-pinned via the Test Connection diagnostic. Subsequent requests refuse certs that don't match the pinned fingerprint.
+- **Name expiry**: Enforced — see [Name expiry](#name-expiry) above. Expired names surface as `NamecoinResolveOutcome.NameExpired` rather than a stale pubkey.
+- **Server trust**: The client still trusts that the ElectrumX server or Core RPC node returns accurate transaction data. For higher assurance, SPV proof verification could be added in the future; users who want to remove that trust today can run their own Namecoin Core node and point the Core RPC backend at it.
- **Dynamic proxy evaluation**: Socket factory and server list are evaluated per-request (via lambdas), ensuring Tor setting changes take effect immediately without stale socket reuse.
+- **Fail-closed routing**: Namecoin lookups go through `RoleBasedHttpClientBuilder`'s `PrivacyRouter`, so a misconfigured route raises a typed `BlockedRouteException` instead of silently falling back to clearnet.
-## Files Changed
+## Files
-### New files (quartz/)
-- `quartz/.../nip05/namecoin/ElectrumxClient.kt` — ElectrumX TCP/TLS client with injected `SocketFactory` for proxy support
-- `quartz/.../nip05/namecoin/NamecoinNameResolver.kt` — Identifier parsing, value extraction, dynamic server selection
-- `quartz/.../nip05/namecoin/NamecoinLookupCache.kt` — LRU cache with TTL
-- `quartz/src/jvmTest/.../NamecoinNameResolverTest.kt` — Unit tests
+Layout reflects the current `main` branch. Paths under `quartz` moved from the original `nip05/namecoin/` location to `nip05DnsIdentifiers/namecoin/` when Quartz's DNS-identifier package was reorganised.
-### New files (amethyst/)
-- `amethyst/.../service/namecoin/NamecoinNameService.kt` — App singleton, initialized with proxy-aware ElectrumxClient
-- `amethyst/.../model/privacyOptions/ProxiedSocketFactory.kt` — `SocketFactory` that routes through SOCKS5 proxy (Tor)
+### Quartz (`quartz/`)
-### Modified files
-- `amethyst/.../AppModules.kt` — Wires up resolver with Tor-aware socket factory and server list provider
-- `amethyst/.../model/privacyOptions/RoleBasedHttpClientBuilder.kt` — Added `socketFactoryForNip05()` for proxy-aware socket creation
-- `amethyst/.../ui/screen/loggedIn/search/SearchBarViewModel.kt` — Namecoin search resolution
-- `quartz/.../nip05DnsIdentifiers/Nip05Client.kt` — Route `.bit` identifiers to Namecoin resolver
+`commonMain`:
+- `nip05DnsIdentifiers/namecoin/ElectrumXServer.kt` — `ElectrumxServer`, `NameShowResult`, `NamecoinLookupException`, `ServerTestResult`, `DEFAULT_ELECTRUMX_SERVERS`, `TOR_ELECTRUMX_SERVERS`
+- `nip05DnsIdentifiers/namecoin/IElectrumXClient.kt` — common-source client interface
+- `nip05DnsIdentifiers/namecoin/NamecoinBackend.kt` — `NamecoinBackend` enum, `NamecoinCoreRpcConfig`, `NamecoinFallbackPolicy`
+- `nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt` — parser, `NamecoinResolveOutcome`, `resolveDetailed()`, server-list provider
+- `nip05DnsIdentifiers/namecoin/NamecoinImportResolver.kt` — ifa-0001 `import` expansion
+- `nip05DnsIdentifiers/namecoin/NamecoinLookupCache.kt` — LRU + TTL cache
+- `nip05DnsIdentifiers/namecoin/CompositeNamecoinBackend.kt` — primary + fallback chain
+
+`jvmAndroid` (Android + desktop JVM):
+- `nip05DnsIdentifiers/namecoin/ElectrumXClient.kt` — TCP/TLS ElectrumX client; parses NAME_UPDATE and NAME_FIRSTUPDATE outputs; checks expiry; TOFU pin store
+- `nip05DnsIdentifiers/namecoin/NamecoinCoreRpcClient.kt` — HTTP(S) JSON-RPC client for Namecoin Core (`name_show`)
+
+Tests under `quartz/src/jvmTest/.../nip05/namecoin/`:
+- `NamecoinNameResolverTest`, `CompositeNamecoinBackendTest`, `NamecoinImportTest`, `NamecoinCoreRpcClientTest`
+
+### Commons (`commons/`)
+- `commonMain/.../nip05DnsIdentifiers/namecoin/NamecoinSettings.kt` — serializable config shared by Android + desktop
+- `commonMain/.../nip05DnsIdentifiers/namecoin/NamecoinResolveState.kt` — UI state model used by search and on-chain zap rows
+- Companion `commonTest/.../NamecoinSettingsTest.kt`
+
+### Amethyst Android app (`amethyst/`)
+- `service/namecoin/NamecoinNameService.kt` — singleton tying together `CompositeNamecoinBackend`, the proxy-aware socket factory, server list provider, and the cache
+- `model/preferences/NamecoinSharedPreferences.kt` — DataStore persistence for `NamecoinSettings` + TOFU PEM store
+- `model/privacyOptions/ProxiedSocketFactory.kt` — `SocketFactory` routed through SOCKS5
+- `model/privacyOptions/RoleBasedHttpClientBuilder.kt` — `socketFactoryForNip05()` + `PrivacyRouter` integration
+- `ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt`, `NamecoinSettingsSection.kt` — backend picker, custom-server editor, Test Connection / TOFU prompts, fallback toggles
+- `ui/components/namecoin/NamecoinResolutionRow.kt` — inline `.bit` resolution indicator (search bar, on-chain zap recipient picker)
+- `ui/screen/loggedIn/search/SearchBarViewModel.kt` — `.bit` / `d/` / `id/` routing into `NamecoinNameService`
+- `AppModules.kt` — wires resolver, cache, settings flow, and lazy backend construction
+- Quartz integration point: `quartz/.../nip05DnsIdentifiers/Nip05Client.kt` — routes `.bit` identifiers to the Namecoin resolver
+
+### Desktop app (`desktopApp/`)
+- `service/namecoin/DesktopNamecoinNameService.kt`, `DesktopNamecoinPreferences.kt`, `LocalNamecoin.kt` — lazy-initialised desktop equivalent (Namecoin code is not loaded until needed)
+- `ui/settings/NamecoinSettingsSection.kt` — desktop settings UI mirroring the Android section
+- Unit test: `service/namecoin/DesktopNamecoinPreferencesTest.kt`
## Testing
@@ -271,25 +398,37 @@ adb install -r amethyst/build/outputs/apk/play/debug/amethyst-play-universal-deb
| Search query | Expected result | What it tests |
|---|---|---|
-| `m@testls.bit` | Resolves to Vitor Pamplona's profile | NIP-05 style `user@domain.bit` |
-| `testls.bit` | Resolves to Vitor Pamplona's profile (root `_` entry) | Bare domain `.bit` lookup |
-| `d/testls` | Resolves to Vitor Pamplona's profile | Direct `d/` namespace |
-| `id/someuser` | Resolves if registered on-chain | Direct `id/` namespace |
+| `m@testls.bit` | Resolves to Vitor Pamplona's profile (Namecoin row above the local results) | NIP-05 style `user@domain.bit` |
+| `testls.bit` | Resolves to the root `_` / first available entry | Bare domain `.bit` lookup |
+| `d/testls` | Resolves to Vitor Pamplona's profile | Direct `d/` namespace via `NamecoinResolutionRow` |
+| `id/someuser` | Resolves if registered on-chain | Direct `id/` namespace via `NamecoinResolutionRow` |
+| `nonexistent.bit` | `NameNotFound` indicator in the row | Negative-cached failure path |
+
+**On-chain zap tests** — open the on-chain zap send dialog:
+1. Type a `.bit` recipient → `NamecoinResolutionRow` should resolve and enable Send when a pubkey is found
+2. Type a user-search query → result chip should wrap and be selectable
+
+**Backend picker tests** — Settings → Namecoin:
+1. Default (ElectrumX, no custom servers) — search uses `DEFAULT_ELECTRUMX_SERVERS`
+2. Add a custom server, hit Test Connection → confirm fingerprint → TOFU pin persists across restarts
+3. Switch backend to **Namecoin Core RPC**, point at a local node (StartOS / umbrel / raw), hit Test RPC → confirm cert and pin, then search
+4. Toggle `fallbackToCustomElectrumx` / `fallbackToDefaultElectrumx` and verify cascade by killing the primary
**Tor tests** — enable Tor and set "NIP-05 verifications via Tor" to on:
-1. Search for `m@testls.bit` — should resolve via onion server
+1. Search for `m@testls.bit` — should resolve via the onion endpoint
2. Verify no direct clearnet connections to ElectrumX servers (use `tcpdump`)
3. Toggle Tor off — next search should use clearnet servers
+4. Switch backend to Core RPC with an onion URL — same Tor routing applies
**Verification test** — if a profile has a `.bit` address in its `nip05` field, the NIP-05 badge should verify via the blockchain instead of HTTP.
-**Network verification** — to confirm ElectrumX calls are being made:
+**Network verification** — to confirm Namecoin calls are being made:
```bash
adb root
-adb shell tcpdump -i any -nn port 50002 or port 50006
+adb shell tcpdump -i any -nn port 50001 or port 50002 or port 57002 or port 8336
```
-With Tor off, you should see TCP connections to `162.212.154.52:50002` (electrumx.testls.space).
-With Tor on, you should see connections to the local Tor SOCKS port only (no direct ElectrumX connections).
+With Tor off, you should see TCP connections to one of the entries in `DEFAULT_ELECTRUMX_SERVERS` (or the Core RPC port when that backend is selected).
+With Tor on, you should see connections to the local Tor SOCKS port only.
### Live test data
The name `d/testls` is registered on the Namecoin blockchain (block 551519+, last updated block 814278) with value:
diff --git a/jitpack.yml b/jitpack.yml
new file mode 100644
index 0000000000..74d32c2570
--- /dev/null
+++ b/jitpack.yml
@@ -0,0 +1,6 @@
+jdk:
+ - openjdk21
+before_install:
+ - echo 'allprojects { tasks.matching { it.name.toLowerCase().startsWith("sign") }.configureEach { it.enabled = false } }' > nosign.init.gradle
+install:
+ - ./gradlew :quartz:publishKotlinMultiplatformPublicationToMavenLocal :quartz:publishJvmPublicationToMavenLocal --init-script nosign.init.gradle
\ No newline at end of file
diff --git a/quartz/RELAY.md b/quartz/RELAY.md
index b182d1ecc4..dda794bae9 100644
--- a/quartz/RELAY.md
+++ b/quartz/RELAY.md
@@ -93,11 +93,13 @@ framing, subscription lifecycle), so there's no hand-written read loop.
```kotlin
class SearchEventSource(private val backend: SearchApi) : EventSource {
- override fun events(filters: List): Flow = flow {
+ override fun events(ctx: RequestContext, filters: List): Flow = flow {
+ // ctx says who is asking — score/restrict results from their perspective.
+ val viewer = ctx.authenticatedUsers.firstOrNull()
filters.forEach { f ->
f.search?.let { raw ->
val q = SearchQuery.parse(raw)
- backend.search(q.terms, domain = q.domain, language = q.language)
+ backend.search(q.terms, domain = q.domain, language = q.language, viewer = viewer)
.forEach { emit(it) }
}
}
@@ -140,6 +142,24 @@ is the storage-backed `SessionBackend`; `EventSourceBackend` adapts a
`EventSource`). Implement `SessionBackend` directly only if you need custom
control over the EVENT/negentropy paths as well as REQ/COUNT.
+### Caller-aware sources (who is asking)
+
+Every `events`/`count`/`countResult` call receives a `RequestContext` carrying
+the connection's `authenticatedUsers` (the pubkeys that completed NIP-42 on this
+socket) and a stable `connectionId`. That is what makes NIP-42 useful on a
+non-storage relay: a single shared `EventSource` instance can serve
+caller-relative results (trust/relevance scored from the viewer's perspective,
+"for-you" feeds), restricted content (a pubkey's DMs returned only to that
+pubkey), or per-connection tenancy — without smuggling auth state through a
+side channel. `ctx.authenticatedUsers` is a live view of the engine-owned
+connection scope, so a REQ that arrives after the AUTH sees the freshly
+recorded pubkey(s). (The engine records them on a successful NIP-42 AUTH; the
+policy reads the same scope to gate.)
+
+For per-connection state richer than the pubkey (e.g. a backend session token
+minted in `FullAuthPolicy.authorize`), downcast `ctx.policy` to your own policy
+subclass and read a typed field — the policy instance is itself per-connection.
+
## Policies
Policies control what clients can do. They validate commands and can rewrite filters.
@@ -360,16 +380,16 @@ are omitted), and `CONTENT_TYPE` is `application/nostr+json`.
## Approximate COUNT (NIP-45 HyperLogLog)
-`COUNT` is answered by `SessionBackend.countResult(filters)` (and
+`COUNT` is answered by `SessionBackend.countResult(ctx, filters)` (and
`EventSource.countResult`), which defaults to an exact count. To return a
mergeable HyperLogLog estimate instead — for the six canonical NIP-45 queries
(reaction/repost/quote/reply/comment/follower counts) — fold matching pubkeys
into an `HllBuilder` and return its `CountResult`:
```kotlin
-override suspend fun countResult(filters: List): CountResult {
+override suspend fun countResult(ctx: RequestContext, filters: List): CountResult {
val filter = filters.first()
- val hll = HyperLogLog.builderFor(filter) ?: return CountResult(count(filters))
+ val hll = HyperLogLog.builderFor(filter) ?: return CountResult(count(ctx, filters))
store.query(filter) { event -> hll.add(event.pubKey) }
return hll.toCountResult() // count = estimate, approximate = true, hll = registers
}
diff --git a/quartz/plans/2026-06-04-auth-scope-vs-policy.md b/quartz/plans/2026-06-04-auth-scope-vs-policy.md
new file mode 100644
index 0000000000..0ccf81358a
--- /dev/null
+++ b/quartz/plans/2026-06-04-auth-scope-vs-policy.md
@@ -0,0 +1,157 @@
+# Auth identity is connection scope, not policy state
+
+**Date:** 2026-06-04
+**Module:** `quartz` — `nip01Core/relay/server`
+**Status:** Proposed (review before implementing)
+
+## Problem
+
+`IRelayPolicy` is a *decision* interface (`accept(...)` → allow/reject/rewrite).
+But we attached `authenticatedUsers` (the set of pubkeys logged in on a
+connection) to the policy world via the `AuthScopedPolicy` mixin. That is
+connection *state*, not a decision. Storing it in the policy forced three
+artifacts whose only job is to route the state back out as scope:
+
+- `AuthScopedPolicy` — marker to locate the state,
+- `PolicyStack.authenticatedUsers` — union to forward it through the composition
+ wrapper (the session's real policy is usually `PolicyStack(LimitsPolicy, …)`),
+- `RequestContext`'s `as? AuthScopedPolicy` downcast — to read it back.
+
+`RequestContext` is *already* the connection-scope object (`connectionId`,
+`authenticatedUsers`). It should **own** the authenticated identity instead of
+reaching into the policy for it.
+
+## Why it got merged
+
+The gating policies need the state to decide: `FullAuthPolicy.accept(ReqCmd)`
+returns `auth-required` when no one is authenticated. Storing the set in the
+policy kept `accept()` self-contained. The fix is to let the policy *read* a
+scope it doesn't *own*.
+
+## Target model
+
+- **Connection scope** (engine-owned, per `RelaySession`) owns the mutable
+ `authenticatedUsers` set + `connectionId`. `RequestContext` is its read-only
+ view (what sources already receive).
+- **Policy stays pure decision.** `FullAuthPolicy` keeps `accept(AuthCmd)`
+ (validate the proof) and gating `accept(ReqCmd/CountCmd/EventCmd)`, but
+ *reads* the scope to gate instead of owning a set.
+- **The engine performs the single commit.** On a fully-approved AUTH, the
+ engine records the verified pubkey into the scope — one commit point, not a
+ field buried in a policy.
+
+Deleted by this change: `AuthScopedPolicy`, `PolicyStack.authenticatedUsers`,
+the `RequestContext` downcast, and `FullAuthPolicy`'s `authenticatedUsers` field
+/ `isAuthenticated()` / the `.add()` in `onAuthenticated`.
+
+## The two correctness invariants (must be preserved)
+
+1. **Only a *verified* identity may enter the scope.** A blind-accept policy
+ (`PassThroughPolicy`/`EmptyPolicy`, which accept `AUTH` without checking a
+ challenge or signature) must record *nothing*. Today this holds because only
+ `FullAuthPolicy.onAuthenticated` commits. We must not regress to "commit
+ whenever `accept` passed."
+2. **No partial/rolled-back auth.** A thrown `authorize`, or a downstream policy
+ rejecting the AUTH, must leave the connection unauthenticated. Guarded by
+ `authorizeThrowTurnsAuthIntoFailingOk`,
+ `policyRejectingAuthAfterFullAuthLeavesConnectionUnauthenticated`,
+ `failedReAuthKeepsPreviousValidAuthentication`,
+ `commandsRejectedAfterFailedAuthHook`.
+
+**Hard constraint:** `VerifyPolicy`/`VerifyAuthOnlyPolicy`/`EmptyPolicy` are
+shared `object` singletons. They must remain stateless — a per-connection scope
+ref may only be retained by a per-connection policy (built fresh by
+`policyBuilder`, i.e. `FullAuthPolicy`).
+
+## Mechanism
+
+Two small signature changes carry it:
+
+### A. Read side — inject the scope at connect
+
+`IRelayPolicy.onConnect(send)` → `onConnect(scope: RequestContext, send)`.
+
+- `PassThroughPolicy`, `VerifyEventsAndAuthPolicy` (singletons): ignore `scope`
+ (no storage — stays stateless).
+- `PolicyStack.onConnect`: forward `scope` to each child.
+- `FullAuthPolicy`: store the read-only `scope` (per-connection, safe) and read
+ `scope.authenticatedUsers` in its gating `accept(...)`.
+
+### B. Write side — `onAuthenticated` returns the commit decision
+
+`IRelayPolicy.onAuthenticated(pubKey, event)` → returns `Boolean`
+(default `false` = "I did not authenticate anyone; record nothing").
+
+- `FullAuthPolicy`: runs `authorize(...)` (may throw → engine catches → `OK
+ false`), then `return true`. No `.add()`.
+- `PolicyStack`: run **every** child (side effects) and OR the results, so a
+ chain authenticates iff some verifying member claims it:
+ `policies.fold(false) { acc, p -> p.onAuthenticated(pubKey, event) || acc }`
+ (left operand always evaluated → every hook runs).
+- Engine (`RelaySession.handleAuth`):
+ ```
+ val result = policy.accept(cmd) // proof check across chain
+ if (rejected) { OK false; return }
+ val record = try { policy.onAuthenticated(cmd.event.pubKey, cmd.event) }
+ catch (e) { OK false; return } // authorize threw → nothing recorded
+ if (record) scope.add(cmd.event.pubKey) // single engine-side commit
+ OK true
+ ```
+
+This keeps the write strictly engine-side (invariant 1: only `true`-returning
+verifying policies cause a commit; PassThrough returns `false`) and the commit
+after a no-throw `onAuthenticated` (invariant 2).
+
+## File-by-file
+
+| File | Change |
+|------|--------|
+| `backend/RequestContext.kt` | Drop the `as? AuthScopedPolicy` getter; `authenticatedUsers` becomes a plain backed property. Keep `policy` (for app-state downcast). |
+| `policies/AuthScopedPolicy.kt` | **Delete.** |
+| `policies/IRelayPolicy.kt` | `onConnect` gains `scope: RequestContext`; `onAuthenticated` returns `Boolean` (default `false`). |
+| `policies/FullAuthPolicy.kt` | Remove `authenticatedUsers` field, `isAuthenticated()`, the marker. Store injected `scope`; gate on `scope.authenticatedUsers`; `onAuthenticated` runs `authorize` then `return true`. |
+| `policies/PolicyStack.kt` | Drop `authenticatedUsers`/marker; forward `scope` in `onConnect`; OR-fold `onAuthenticated`. |
+| `policies/PassThroughPolicy.kt`, `VerifyPolicy.kt` | `onConnect(scope, send)` — ignore `scope` (stay stateless). |
+| `server/RelaySession.kt` | Own the mutable `authenticatedUsers` set behind `requestContext`; expose `requestContext` (read view) for observability/tests; pass it to `policy.onConnect`; perform the commit in `handleAuth`. |
+
+No change to `EventSource`/`SessionBackend`/`EventSourceBackend`/`LiveEventStore`
+— sources still get `RequestContext` and read `authenticatedUsers` exactly as
+now; only the *backing* moved.
+
+## Test mapping (assertions retarget; guarantees unchanged)
+
+`NostrServerAuthTest` currently inspects `(session.policy as FullAuthPolicy)
+.authenticatedUsers / .isAuthenticated()`. Retarget to the scope, e.g.
+`session.requestContext.authenticatedUsers` (expose `requestContext` on
+`RelaySession`).
+
+| Test | New path / why it still holds |
+|------|-------------------------------|
+| `authSucceedsWithValidEvent` | accept ✓ → `onAuthenticated`→true → engine commits → `requestContext.authenticatedUsers` has pubkey. |
+| `multipleUsersCanAuthenticate` | two successful AUTHs → engine commits each → set has both. |
+| `authorizeThrowTurnsAuthIntoFailingOk` | `authorize` throws → engine catches before commit → set empty. |
+| `policyRejectingAuthAfterFullAuthLeavesConnectionUnauthenticated` | chain `accept` rejected → `onAuthenticated` never called → no commit. |
+| `failedReAuthKeepsPreviousValidAuthentication` | 2nd accept rejected → no commit → set keeps 1st pubkey. |
+| `commandsRejectedAfterFailedAuthHook` | unchanged (gating reads empty scope). |
+| `EventSourceServerTest.sourceSeesAuthenticatedUserInContext` | unchanged — already reads `ctx.authenticatedUsers`. |
+
+## Alternatives considered
+
+- **Scope exposes `add()`, verifying policy writes it** (no `onAuthenticated`
+ signature change). Rejected: gives policies write access to scope and splits
+ the commit across N policies; the engine-side single commit is easier to audit
+ against invariant 2.
+- **Pass `scope` into every `accept(cmd, scope)`** instead of injecting at
+ `onConnect`. Rejected: ~4 methods × ~9 policies of churn for a dependency only
+ `FullAuthPolicy` uses.
+- **Generic `EventSource`** for a typed `AuthRequestContext`
+ (prior discussion). Out of scope; cascades type params through the shared
+ `SessionBackend`/storage path.
+
+## Risk
+
+Low surface, high-sensitivity (signed-in correctness). The four bypass tests are
+the spec; they pass unchanged in *behavior*, only their assertion target moves.
+The `onConnect`/`onAuthenticated` signature changes are mechanical but touch
+every policy + any external `IRelayPolicy` impl (source-breaking — acceptable
+for this in-tree 2025 SPI, same call as the `EventSource` ctx change).
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt
index 4b6c2d9903..0e2a224c9c 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.nip01Core.relay.server
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage
@@ -34,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
+import com.vitorpamplona.quartz.nip01Core.relay.server.backend.RequestContext
import com.vitorpamplona.quartz.nip01Core.relay.server.backend.SessionBackend
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult
@@ -74,6 +76,27 @@ class RelaySession(
) : AutoCloseable {
private val subscriptions = LargeCache()
+ /**
+ * The authenticated-identity store for this connection. The engine is the
+ * only writer (committed in [handleAuth] on a successful NIP-42 AUTH); the
+ * policy and the data plane read it through [requestContext].
+ */
+ private val authenticatedUsers = mutableSetOf()
+
+ /**
+ * The per-connection scope. Handed to the [policy] at connect (so gating
+ * policies can read the authenticated users) and to the [store] on every
+ * REQ/COUNT (so a source can see who is asking). [RequestContext.authenticatedUsers]
+ * is a live view of [authenticatedUsers], so a REQ after a NIP-42 AUTH sees
+ * the freshly recorded pubkey(s).
+ */
+ val requestContext: RequestContext =
+ object : RequestContext {
+ override val connectionId = id
+ override val policy = this@RelaySession.policy
+ override val authenticatedUsers: Set get() = this@RelaySession.authenticatedUsers
+ }
+
/** NIP-77 negentropy state for this connection. */
private val negentropy = NegSessionRegistry(store, ::send, negentropySettings)
@@ -189,7 +212,7 @@ class RelaySession(
val countResult =
try {
- store.countResult(filters)
+ store.countResult(requestContext, filters)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
@@ -210,16 +233,21 @@ class RelaySession(
// The whole policy chain validated the AUTH. onAuthenticated runs any
// post-verification I/O (e.g. exchanging the verified event for a
- // backend token) AND is where a policy commits the authentication, so a
- // throw here cleanly fails the login — nothing was committed to undo.
- try {
- policy.onAuthenticated(cmd.event.pubKey, cmd.event)
- } catch (e: CancellationException) {
- throw e
- } catch (e: Exception) {
- send(OkMessage.rejected(cmd.event.id, MachineReadablePrefix.ERROR, e.message ?: "authentication failed"))
- return
- }
+ // backend token) and votes on whether to record the identity. A throw
+ // here cleanly fails the login — the engine records nothing.
+ val record =
+ try {
+ policy.onAuthenticated(cmd.event)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ send(OkMessage.rejected(cmd.event.id, MachineReadablePrefix.ERROR, e.message ?: "authentication failed"))
+ return
+ }
+
+ // Single, engine-side commit into the connection scope — after the full
+ // chain approved and a verifying policy voted to record.
+ if (record) authenticatedUsers.add(cmd.event.pubKey)
send(OkMessage(cmd.event.id, true, ""))
}
@@ -254,6 +282,7 @@ class RelaySession(
scope.launch {
try {
store.query(
+ ctx = requestContext,
filters = filters,
onEach = { event ->
if (policy.canSendToSession(event)) {
@@ -285,7 +314,7 @@ class RelaySession(
}
init {
- policy.onConnect(::send)
+ policy.onConnect(requestContext, ::send)
}
companion object {
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/EventSource.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/EventSource.kt
index 96c460cc28..5cc9542690 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/EventSource.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/EventSource.kt
@@ -37,17 +37,27 @@ import kotlinx.coroutines.flow.count
*
* ```
* class SearchEventSource(private val backend: SearchApi) : EventSource {
- * override fun events(filters: List): Flow = flow {
+ * override fun events(ctx: RequestContext, filters: List): Flow = flow {
+ * // Tailor the answer to the caller: NIP-42 already told us who they are.
+ * val viewer = ctx.authenticatedUsers.firstOrNull()
* for (f in filters) {
* f.search?.let { raw ->
* val query = SearchQuery.parse(raw)
- * backend.search(query.terms, query.language).forEach { emit(it) }
+ * backend.search(query.terms, query.language, viewer).forEach { emit(it) }
* }
* }
* }
* }
* ```
*
+ * ## Who is asking
+ *
+ * Every call receives a [RequestContext] carrying the connection's
+ * [RequestContext.authenticatedUsers] (and [RequestContext.connectionId]). A
+ * single shared [EventSource] instance can therefore serve caller-relative
+ * results, restricted content, or per-connection tenancy without smuggling auth
+ * state in through a side channel — see [RequestContext].
+ *
* ## EOSE semantics
*
* The engine sends `EOSE` when the returned [Flow] **completes**. A source
@@ -62,18 +72,25 @@ import kotlinx.coroutines.flow.count
*/
interface EventSource {
/**
- * Returns the events matching [filters]. Emit each match and then let the
- * flow complete; completion is what triggers `EOSE`. The [filters] are the
- * (possibly policy-rewritten) filters from the REQ.
+ * Returns the events matching [filters] for the caller described by [ctx].
+ * Emit each match and then let the flow complete; completion is what
+ * triggers `EOSE`. The [filters] are the (possibly policy-rewritten) filters
+ * from the REQ; [ctx] carries the connection's authenticated identity.
*/
- fun events(filters: List): Flow
+ fun events(
+ ctx: RequestContext,
+ filters: List,
+ ): Flow
/**
- * Answers a NIP-45 COUNT. The default counts the events produced by
- * [events]; override it when the backend can count without materializing
- * every event.
+ * Answers a NIP-45 COUNT for the caller described by [ctx]. The default
+ * counts the events produced by [events]; override it when the backend can
+ * count without materializing every event.
*/
- suspend fun count(filters: List): Int = events(filters).count()
+ suspend fun count(
+ ctx: RequestContext,
+ filters: List,
+ ): Int = events(ctx, filters).count()
/**
* Answers a NIP-45 COUNT, optionally approximate and/or carrying a
@@ -81,5 +98,8 @@ interface EventSource {
* override to return `approximate`/`hll` (see
* [com.vitorpamplona.quartz.nip45Count.HllBuilder]).
*/
- suspend fun countResult(filters: List): CountResult = CountResult(count(filters))
+ suspend fun countResult(
+ ctx: RequestContext,
+ filters: List,
+ ): CountResult = CountResult(count(ctx, filters))
}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/EventSourceBackend.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/EventSourceBackend.kt
index abc20cdf90..1f0ce6c075 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/EventSourceBackend.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/EventSourceBackend.kt
@@ -36,15 +36,22 @@ class EventSourceBackend(
private val source: EventSource,
) : SessionBackend {
override suspend fun query(
+ ctx: RequestContext,
filters: List,
onEach: (Event) -> Unit,
onEose: () -> Unit,
) {
- source.events(filters).collect { onEach(it) }
+ source.events(ctx, filters).collect { onEach(it) }
onEose()
}
- override suspend fun count(filters: List): Int = source.count(filters)
+ override suspend fun count(
+ ctx: RequestContext,
+ filters: List,
+ ): Int = source.count(ctx, filters)
- override suspend fun countResult(filters: List): CountResult = source.countResult(filters)
+ override suspend fun countResult(
+ ctx: RequestContext,
+ filters: List,
+ ): CountResult = source.countResult(ctx, filters)
}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/LiveEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/LiveEventStore.kt
index 9bb4b66b15..4395926d19 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/LiveEventStore.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/LiveEventStore.kt
@@ -126,6 +126,7 @@ class LiveEventStore(
}
override suspend fun query(
+ ctx: RequestContext,
filters: List,
onEach: (Event) -> Unit,
onEose: () -> Unit,
@@ -191,7 +192,10 @@ class LiveEventStore(
}
}
- override suspend fun count(filters: List): Int = store.count(filters)
+ override suspend fun count(
+ ctx: RequestContext,
+ filters: List,
+ ): Int = store.count(filters)
/**
* One-shot snapshot query. Used by NIP-77 negentropy: the server
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/RequestContext.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/RequestContext.kt
new file mode 100644
index 0000000000..b492e80017
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/RequestContext.kt
@@ -0,0 +1,69 @@
+/*
+ * 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.quartz.nip01Core.relay.server.backend
+
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy
+
+/**
+ * The per-connection context handed to an [EventSource] (and a [SessionBackend])
+ * on every REQ/COUNT. It tells the data plane *who* is asking, so a single
+ * shared source can tailor its answer to the caller without smuggling state in
+ * through a side channel.
+ *
+ * This is the connection scope: the engine owns it (one per
+ * [com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession]) and records
+ * the authenticated pubkey(s) into it on a successful NIP-42 AUTH. That is what
+ * makes NIP-42 useful on a non-storage relay — [RequestContext] is the path
+ * that carries the caller's identity to the code that produces the events. With
+ * it, the same `EventSource` instance can serve:
+ *
+ * - **caller-relative results** — trust/relevance scored from
+ * [authenticatedUsers]'s perspective ("for-you" feeds, follow-aware search);
+ * - **restricted content** — a pubkey's DMs/private events returned only to
+ * that authenticated pubkey;
+ * - **paid / allow-listed** sets that depend on the authenticated identity;
+ * - **per-connection** quotas/tenancy keyed by [connectionId].
+ *
+ * For per-connection application state beyond the pubkey (e.g. a backend session
+ * token minted in [com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy.authorize]),
+ * downcast [policy] to your own [IRelayPolicy] subclass and read a typed field —
+ * the policy instance is itself per-connection, so it is the natural typed bag.
+ */
+interface RequestContext {
+ /**
+ * Stable, process-unique id of the connection this request arrived on
+ * (the owning [com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession.id]).
+ * Use it to key per-connection state a shared source keeps on the side.
+ */
+ val connectionId: Long
+
+ /** The connection's policy, for typed access to per-connection state. */
+ val policy: IRelayPolicy
+
+ /**
+ * The pubkeys that have authenticated on this connection via NIP-42. Empty
+ * when the connection is unauthenticated. Backed by the engine-owned scope
+ * and read live, so a REQ that arrives after a successful AUTH sees the
+ * freshly recorded pubkey(s).
+ */
+ val authenticatedUsers: Set
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/SessionBackend.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/SessionBackend.kt
index ceef1c8003..b57dcfca09 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/SessionBackend.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/SessionBackend.kt
@@ -45,19 +45,24 @@ import com.vitorpamplona.quartz.nip01Core.store.IdAndTime
*/
interface SessionBackend {
/**
- * Answers a REQ. Calls [onEach] for every matching event, then [onEose]
- * once the stored set is exhausted. A storage backend keeps suspending
- * after [onEose] to stream live events until the subscription is
- * cancelled; a finite source returns after [onEose].
+ * Answers a REQ on behalf of the caller described by [ctx]. Calls [onEach]
+ * for every matching event, then [onEose] once the stored set is exhausted.
+ * A storage backend keeps suspending after [onEose] to stream live events
+ * until the subscription is cancelled; a finite source returns after
+ * [onEose].
*/
suspend fun query(
+ ctx: RequestContext,
filters: List,
onEach: (Event) -> Unit,
onEose: () -> Unit,
)
- /** Answers a NIP-45 COUNT with an exact cardinality. */
- suspend fun count(filters: List): Int
+ /** Answers a NIP-45 COUNT with an exact cardinality for the caller in [ctx]. */
+ suspend fun count(
+ ctx: RequestContext,
+ filters: List,
+ ): Int
/**
* Answers a NIP-45 COUNT, allowing an approximate result and/or a
@@ -65,7 +70,10 @@ interface SessionBackend {
* The default returns the exact [count] with `approximate = false`; override
* to return `approximate`/`hll`.
*/
- suspend fun countResult(filters: List): CountResult = CountResult(count(filters))
+ suspend fun countResult(
+ ctx: RequestContext,
+ filters: List,
+ ): CountResult = CountResult(count(ctx, filters))
/**
* Handles an EVENT publish, reporting the per-event outcome through
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/FullAuthPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/FullAuthPolicy.kt
index 5c247f122c..e13a6cddc8 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/FullAuthPolicy.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/FullAuthPolicy.kt
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
+import com.vitorpamplona.quartz.nip01Core.relay.server.backend.RequestContext
import com.vitorpamplona.quartz.nip40Expiration.isExpired
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.utils.RandomInstance
@@ -40,11 +41,13 @@ import com.vitorpamplona.quartz.utils.TimeUtils
*
* Implements the full NIP-42 challenge/verify handshake: [onConnect] sends the
* [challenge] and [accept] (AuthCmd) validates the returned event (expiration,
- * freshness, challenge match, relay match). Crucially, [accept] does NOT mutate
- * state — the pubkey is recorded in [authenticatedUsers] only by [onAuthenticated],
- * which the engine calls once [accept] *and* the rest of the policy chain have
- * approved the AUTH. That single, late commit is why there is no rollback to
- * reason about: a rejected AUTH simply never reaches it.
+ * freshness, challenge match, relay match). This policy runs the auth *logic*
+ * but does not *own* the authenticated-identity store: the engine-owned
+ * connection [scope] holds it. [accept] does NOT mutate state, and
+ * [onAuthenticated] only votes `true` (after [authorize]) — the engine performs
+ * the single recording into the scope once the whole chain has approved. Gating
+ * decisions read [scope].`authenticatedUsers`. A rejected AUTH simply never
+ * reaches [onAuthenticated], so there is no rollback to reason about.
*
* To bridge to an external auth system, override [authorize] (a `suspend` hook)
* and do the post-verification I/O there — e.g. exchange the verified event for
@@ -57,13 +60,30 @@ open class FullAuthPolicy(
/** The challenge string sent to this client for NIP-42 authentication. */
val challenge: String = RandomInstance.randomChars(32)
- /** Set of pubkeys that have successfully authenticated on this session. */
- val authenticatedUsers = mutableSetOf()
+ /**
+ * The engine-owned connection scope, captured at [onConnect]. Read-only
+ * here: this policy reads [RequestContext.authenticatedUsers] to gate, while
+ * the engine is the only writer. Held safely because a [FullAuthPolicy] is
+ * built fresh per connection.
+ */
+ private lateinit var scope: RequestContext
- /** Returns true if at least one pubkey has authenticated. */
+ /**
+ * The pubkeys authenticated on this connection, read from the engine-owned
+ * scope. Exposed to subclasses so they can gate or rewrite on the caller's
+ * identity (restricted content, caller-relative filters) — the same set the
+ * data plane sees via [RequestContext.authenticatedUsers].
+ */
+ protected val authenticatedUsers: Set get() = scope.authenticatedUsers
+
+ /** Returns true if at least one pubkey has authenticated on this connection. */
fun isAuthenticated(): Boolean = authenticatedUsers.isNotEmpty()
- override fun onConnect(send: (Message) -> Unit) {
+ override fun onConnect(
+ scope: RequestContext,
+ send: (Message) -> Unit,
+ ) {
+ this.scope = scope
send(AuthMessage(challenge))
}
@@ -90,31 +110,24 @@ open class FullAuthPolicy(
}
/**
- * Commits the authentication. The engine calls this only after [accept] and
- * the whole policy chain have approved the AUTH, so this is the single point
- * where the pubkey is recorded. It runs [authorize] first (which may throw
- * to reject) and records the pubkey only on success — the connection is
- * never left authenticated behind a failing `OK`. `final`: override
- * [authorize], not this.
+ * Votes to record the authentication. The engine calls this only after
+ * [accept] and the whole policy chain have approved the AUTH. It runs
+ * [authorize] first (which may throw to reject — the engine then records
+ * nothing) and returns `true` so the engine records `event.pubKey` into the
+ * connection scope. `final`: override [authorize], not this.
*/
- final override suspend fun onAuthenticated(
- pubKey: HexKey,
- event: RelayAuthEvent,
- ) {
- authorize(pubKey, event)
- authenticatedUsers.add(pubKey)
+ final override suspend fun onAuthenticated(event: RelayAuthEvent): Boolean {
+ authorize(event)
+ return true
}
/**
* Hook for external authorization once the NIP-42 proof checks out — e.g.
* exchange [event] for a backend session token. Throw to reject the login
- * (the AUTH becomes `OK false` and the pubkey is not recorded). Runs before
- * the pubkey is committed. The default does nothing.
+ * (the AUTH becomes `OK false` and `event.pubKey` is not recorded). Runs
+ * before the pubkey is committed. The default does nothing.
*/
- open suspend fun authorize(
- pubKey: HexKey,
- event: RelayAuthEvent,
- ) {}
+ open suspend fun authorize(event: RelayAuthEvent) {}
override fun accept(cmd: EventCmd): PolicyResult =
if (isAuthenticated()) {
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/IRelayPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/IRelayPolicy.kt
index fa3bbb8d31..a6dac44927 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/IRelayPolicy.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/IRelayPolicy.kt
@@ -21,20 +21,30 @@
package com.vitorpamplona.quartz.nip01Core.relay.server.policies
import com.vitorpamplona.quartz.nip01Core.core.Event
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
+import com.vitorpamplona.quartz.nip01Core.relay.server.backend.RequestContext
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
/**
* Defines custom behavior for this relay.
*/
interface IRelayPolicy {
- fun onConnect(send: (Message) -> Unit)
+ /**
+ * Called once when the connection opens. [scope] is the engine-owned,
+ * read-only connection scope (id + authenticated users) — a per-connection
+ * policy may retain it to make later auth-aware decisions; shared singleton
+ * policies must ignore it and stay stateless. [send] pushes a message to the
+ * client (e.g. a NIP-42 AUTH challenge).
+ */
+ fun onConnect(
+ scope: RequestContext,
+ send: (Message) -> Unit,
+ )
/**
* Evaluates whether an incoming EVENT command should be accepted.
@@ -70,24 +80,26 @@ interface IRelayPolicy {
/**
* Called once an AUTH command has been [accept]ed by this policy *and* the
- * rest of the policy chain, before the success `OK` is sent. This is where
- * a policy commits the authentication and/or runs post-verification side
- * effects that need network or disk I/O — e.g. exchanging the verified
- * NIP-42 event for a backend session token — without leaking that logic into
- * the transport layer.
+ * rest of the policy chain, before the success `OK` is sent. Run any
+ * post-verification side effects that need network or disk I/O here — e.g.
+ * exchanging the verified NIP-42 event for a backend session token — without
+ * leaking that logic into the transport layer.
+ *
+ * The engine — not the policy — owns the authenticated-identity store. The
+ * return value is this policy's vote on whether `event.pubKey` should be
+ * recorded as authenticated on the connection: return `true` only if this
+ * policy actually verified the identity. The default returns `false`, so a
+ * policy that does not authenticate (e.g. a pass-through or a blind-accept)
+ * never causes an unverified pubkey to be recorded.
*
* Because it runs only after the whole chain approved the AUTH, throwing
- * here cleanly fails the login: the AUTH becomes `OK false` and, since the
- * commit lives here too, the connection is never left authenticated. The
- * default implementation does nothing.
+ * here cleanly fails the login: the AUTH becomes `OK false` and the engine
+ * records nothing.
*
- * @param pubKey The pubkey being authenticated.
- * @param event The verified NIP-42 auth event.
+ * @param event The verified NIP-42 auth event (its signer is the identity).
+ * @return `true` to have the engine record `event.pubKey` as authenticated.
*/
- suspend fun onAuthenticated(
- pubKey: HexKey,
- event: RelayAuthEvent,
- ) {}
+ suspend fun onAuthenticated(event: RelayAuthEvent): Boolean = false
/**
* Inspects a raw inbound message before it is parsed. Return a reason
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PassThroughPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PassThroughPolicy.kt
index 79d9a306ca..1d673f6e8b 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PassThroughPolicy.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PassThroughPolicy.kt
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
+import com.vitorpamplona.quartz.nip01Core.relay.server.backend.RequestContext
/**
* Convenience base that accepts everything by default. Subclasses
@@ -37,7 +38,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
* instantiate this directly.
*/
open class PassThroughPolicy : IRelayPolicy {
- override fun onConnect(send: (Message) -> Unit) {}
+ override fun onConnect(
+ scope: RequestContext,
+ send: (Message) -> Unit,
+ ) {}
override fun accept(cmd: EventCmd): PolicyResult = PolicyResult.Accepted(cmd)
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PolicyStack.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PolicyStack.kt
index 6393830c97..00f6ef6009 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PolicyStack.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PolicyStack.kt
@@ -21,13 +21,13 @@
package com.vitorpamplona.quartz.nip01Core.relay.server.policies
import com.vitorpamplona.quartz.nip01Core.core.Event
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
+import com.vitorpamplona.quartz.nip01Core.relay.server.backend.RequestContext
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
class PolicyStack(
@@ -35,8 +35,11 @@ class PolicyStack(
) : IRelayPolicy {
val policies = policies.toList()
- override fun onConnect(send: (Message) -> Unit) {
- policies.forEach { it.onConnect(send) }
+ override fun onConnect(
+ scope: RequestContext,
+ send: (Message) -> Unit,
+ ) {
+ policies.forEach { it.onConnect(scope, send) }
}
override fun accept(cmd: EventCmd) = runPolicies(cmd) { p, c -> p.accept(c) }
@@ -47,11 +50,10 @@ class PolicyStack(
override fun accept(cmd: AuthCmd) = runPolicies(cmd) { p, c -> p.accept(c) }
- override suspend fun onAuthenticated(
- pubKey: HexKey,
- event: RelayAuthEvent,
- ) {
- policies.forEach { it.onAuthenticated(pubKey, event) }
+ override suspend fun onAuthenticated(event: RelayAuthEvent): Boolean {
+ // Run every member (side effects) and record iff any one verified the
+ // identity. `fold` keeps the call on the left so no member is skipped.
+ return policies.fold(false) { recorded, p -> p.onAuthenticated(event) || recorded }
}
override fun acceptMessage(message: String): String? = policies.firstNotNullOfOrNull { it.acceptMessage(message) }
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/VerifyPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/VerifyPolicy.kt
index 8961ff9041..a97c1431fa 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/VerifyPolicy.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/VerifyPolicy.kt
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
+import com.vitorpamplona.quartz.nip01Core.relay.server.backend.RequestContext
/**
* Verifies the Schnorr signature + id hash of every incoming
@@ -43,7 +44,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
open class VerifyEventsAndAuthPolicy(
private val verifyEvents: Boolean,
) : IRelayPolicy {
- override fun onConnect(send: (Message) -> Unit) { }
+ override fun onConnect(
+ scope: RequestContext,
+ send: (Message) -> Unit,
+ ) { }
override fun accept(cmd: EventCmd) =
if (!verifyEvents || cmd.event.verify()) {
diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/EventSourceServerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/EventSourceServerTest.kt
index a3206fd8f9..292301107c 100644
--- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/EventSourceServerTest.kt
+++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/EventSourceServerTest.kt
@@ -26,11 +26,15 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
+import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.backend.EventSource
+import com.vitorpamplona.quartz.nip01Core.relay.server.backend.RequestContext
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
+import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip45Count.HllBuilder
+import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
@@ -70,7 +74,10 @@ class EventSourceServerTest {
private class FixedSource(
private val events: List,
) : EventSource {
- override fun events(filters: List): Flow = flowOf(*events.toTypedArray())
+ override fun events(
+ ctx: RequestContext,
+ filters: List,
+ ): Flow = flowOf(*events.toTypedArray())
}
@Test
@@ -117,11 +124,17 @@ class EventSourceServerTest {
val dispatcher = UnconfinedTestDispatcher(testScheduler)
val source =
object : EventSource {
- override fun events(filters: List): Flow = flowOf(event(1), event(2))
+ override fun events(
+ ctx: RequestContext,
+ filters: List,
+ ): Flow = flowOf(event(1), event(2))
- override suspend fun countResult(filters: List): CountResult {
+ override suspend fun countResult(
+ ctx: RequestContext,
+ filters: List,
+ ): CountResult {
val hll = HllBuilder(offset = 8)
- events(filters).collect { hll.add(it.pubKey) }
+ events(ctx, filters).collect { hll.add(it.pubKey) }
return hll.toCountResult()
}
}
@@ -161,7 +174,10 @@ class EventSourceServerTest {
val dispatcher = UnconfinedTestDispatcher(testScheduler)
val source =
object : EventSource {
- override fun events(filters: List): Flow = flow { throw RuntimeException("backend down") }
+ override fun events(
+ ctx: RequestContext,
+ filters: List,
+ ): Flow = flow { throw RuntimeException("backend down") }
}
EventSourceServer(source, parentContext = dispatcher).use { server ->
val collector = MessageCollector()
@@ -201,4 +217,63 @@ class EventSourceServerTest {
assertTrue(closed[0].contains("auth-required:"))
}
}
+
+ /** Builds a kind 22242 auth event; FullAuthPolicy checks challenge/relay/time, not the signature. */
+ private fun authEvent(
+ challenge: String,
+ relay: String,
+ ) = RelayAuthEvent(
+ id = hexId(99),
+ pubKey = pubkey,
+ createdAt = TimeUtils.now(),
+ tags =
+ arrayOf(
+ arrayOf("relay", relay),
+ arrayOf("challenge", challenge),
+ ),
+ content = "",
+ sig = sig,
+ )
+
+ private fun authJson(event: RelayAuthEvent) = OptimizedJsonMapper.toJson(AuthCmd(event))
+
+ @Test
+ fun sourceSeesAuthenticatedUserInContext() =
+ runTest {
+ val dispatcher = UnconfinedTestDispatcher(testScheduler)
+ val relay = NormalizedRelayUrl("wss://search.example.com/")
+
+ // A caller-aware source: it records who the engine says is asking.
+ var seenViewers: Set? = null
+ val source =
+ object : EventSource {
+ override fun events(
+ ctx: RequestContext,
+ filters: List,
+ ): Flow {
+ seenViewers = ctx.authenticatedUsers
+ return flowOf(event(1))
+ }
+ }
+
+ EventSourceServer(
+ source,
+ policyBuilder = { FullAuthPolicy(relay) },
+ parentContext = dispatcher,
+ ).use { server ->
+ val collector = MessageCollector()
+ val session = server.connect(collector.send)
+
+ // Complete the NIP-42 handshake using the engine's challenge.
+ val challenge = (OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage).challenge
+ session.receive(authJson(authEvent(challenge, relay.url)))
+
+ // A REQ after auth must hand the authenticated pubkey to the source.
+ session.receive("""["REQ","sub1",{"kinds":[1]}]""")
+
+ assertEquals(setOf(pubkey), seenViewers)
+ val events = collector.parsed().filterIsInstance()
+ assertEquals(1, events.size)
+ }
+ }
}
diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt
index d08f7476ee..07d9cc0667 100644
--- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt
+++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt
@@ -168,7 +168,7 @@ class NostrServerAuthTest {
assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains(",true,"))
assertTrue((session.policy as FullAuthPolicy).isAuthenticated())
- assertTrue(session.policy.authenticatedUsers.contains(pubkey))
+ assertTrue(session.requestContext.authenticatedUsers.contains(pubkey))
server.close()
}
@@ -313,7 +313,7 @@ class NostrServerAuthTest {
assertTrue(okMessages[0].contains(",true,"))
assertTrue(okMessages[1].contains(",true,"))
- val authedPubkeys = (session.policy as FullAuthPolicy).authenticatedUsers
+ val authedPubkeys = session.requestContext.authenticatedUsers
assertEquals(2, authedPubkeys.size)
assertTrue(authedPubkeys.contains(pubkey))
assertTrue(authedPubkeys.contains(pubkey2))
@@ -321,6 +321,34 @@ class NostrServerAuthTest {
server.close()
}
+ @Test
+ fun authenticationIsScopedPerConnection() =
+ runTest {
+ // Two connections on the SAME server. Each must see only the pubkey
+ // that authenticated on it — never the union across the relay.
+ val dispatcher = UnconfinedTestDispatcher(testScheduler)
+ val server = createServer(dispatcher = dispatcher)
+
+ val c1 = MessageCollector()
+ val s1 = server.connect(c1.sendCallback)
+ val ch1 = (OptimizedJsonMapper.fromJsonToMessage(c1.messages[0]) as AuthMessage).challenge
+ s1.receive(authJson(authEvent(challenge = ch1, pubKey = pubkey)))
+
+ val c2 = MessageCollector()
+ val s2 = server.connect(c2.sendCallback)
+ val ch2 = (OptimizedJsonMapper.fromJsonToMessage(c2.messages[0]) as AuthMessage).challenge
+ s2.receive(authJson(authEvent(challenge = ch2, pubKey = pubkey2)))
+
+ // Each connection's scope holds exactly its own authenticated user.
+ assertEquals(setOf(pubkey), s1.requestContext.authenticatedUsers)
+ assertEquals(setOf(pubkey2), s2.requestContext.authenticatedUsers)
+ // Cross-check: neither leaks the other's identity.
+ assertFalse(s1.requestContext.authenticatedUsers.contains(pubkey2))
+ assertFalse(s2.requestContext.authenticatedUsers.contains(pubkey))
+
+ server.close()
+ }
+
// -- NIP-42: requireAuth ---------------------------------------------------
@Test
@@ -545,11 +573,8 @@ class NostrServerAuthTest {
var hookPubkey: String? = null
val policy =
object : FullAuthPolicy(relayUrl) {
- override suspend fun authorize(
- pubKey: String,
- event: RelayAuthEvent,
- ) {
- hookPubkey = pubKey
+ override suspend fun authorize(event: RelayAuthEvent) {
+ hookPubkey = event.pubKey
}
}
@@ -574,10 +599,7 @@ class NostrServerAuthTest {
runTest {
val policy =
object : FullAuthPolicy(relayUrl) {
- override suspend fun authorize(
- pubKey: String,
- event: RelayAuthEvent,
- ): Unit = throw IllegalStateException("backend rejected user")
+ override suspend fun authorize(event: RelayAuthEvent): Unit = throw IllegalStateException("backend rejected user")
}
val dispatcher = UnconfinedTestDispatcher(testScheduler)
@@ -596,7 +618,7 @@ class NostrServerAuthTest {
// still-authenticated connection would be an auth bypass.
val authPolicy = session.policy as FullAuthPolicy
assertFalse(authPolicy.isAuthenticated())
- assertFalse(authPolicy.authenticatedUsers.contains(pubkey))
+ assertFalse(session.requestContext.authenticatedUsers.contains(pubkey))
server.close()
}
@@ -606,10 +628,7 @@ class NostrServerAuthTest {
runTest {
val policy =
object : FullAuthPolicy(relayUrl) {
- override suspend fun authorize(
- pubKey: String,
- event: RelayAuthEvent,
- ): Unit = throw IllegalStateException("backend rejected user")
+ override suspend fun authorize(event: RelayAuthEvent): Unit = throw IllegalStateException("backend rejected user")
}
val dispatcher = UnconfinedTestDispatcher(testScheduler)
@@ -653,7 +672,7 @@ class NostrServerAuthTest {
assertEquals(1, ok.size)
assertTrue(ok[0].contains(",false,"))
assertFalse(auth.isAuthenticated())
- assertFalse(auth.authenticatedUsers.contains(pubkey))
+ assertFalse(session.requestContext.authenticatedUsers.contains(pubkey))
// And a privileged REQ is still gated.
session.receive("""["REQ","sub1",{"kinds":[1]}]""")
@@ -686,12 +705,12 @@ class NostrServerAuthTest {
val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage
session.receive(authJson(authEvent(challenge = msg.challenge)))
- assertTrue(auth.authenticatedUsers.contains(pubkey))
+ assertTrue(session.requestContext.authenticatedUsers.contains(pubkey))
// Second AUTH for the SAME pubkey, rejected downstream.
session.receive(authJson(authEvent(challenge = msg.challenge)))
assertTrue(auth.isAuthenticated())
- assertTrue(auth.authenticatedUsers.contains(pubkey))
+ assertTrue(session.requestContext.authenticatedUsers.contains(pubkey))
server.close()
}
diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayLimitsServerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayLimitsServerTest.kt
index 961f288ad2..88b1c10e62 100644
--- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayLimitsServerTest.kt
+++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayLimitsServerTest.kt
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.server
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.server.backend.EventSource
+import com.vitorpamplona.quartz.nip01Core.relay.server.backend.RequestContext
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
@@ -44,7 +45,10 @@ class RelayLimitsServerTest {
private val emptySource =
object : EventSource {
- override fun events(filters: List): Flow = emptyFlow()
+ override fun events(
+ ctx: RequestContext,
+ filters: List,
+ ): Flow = emptyFlow()
}
private class Collector {
@@ -79,7 +83,10 @@ class RelayLimitsServerTest {
// An empty flow EOSEs immediately; no need to keep it open.
val source =
object : EventSource {
- override fun events(filters: List): Flow = emptyFlow()
+ override fun events(
+ ctx: RequestContext,
+ filters: List,
+ ): Flow = emptyFlow()
}
val server = EventSourceServer(source, parentContext = dispatcher, limits = RelayLimits(maxSubscriptions = 2))
val collector = Collector()
@@ -104,7 +111,10 @@ class RelayLimitsServerTest {
val seen = mutableListOf()
val source =
object : EventSource {
- override fun events(filters: List): Flow {
+ override fun events(
+ ctx: RequestContext,
+ filters: List,
+ ): Flow {
seen.add(filters.single().limit)
return emptyFlow()
}
diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayServerListenerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayServerListenerTest.kt
index b11c415752..9ddb377e91 100644
--- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayServerListenerTest.kt
+++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayServerListenerTest.kt
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.server
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.server.backend.EventSource
+import com.vitorpamplona.quartz.nip01Core.relay.server.backend.RequestContext
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -51,7 +52,10 @@ class RelayServerListenerTest {
private val emptySource =
object : EventSource {
- override fun events(filters: List): Flow = emptyFlow()
+ override fun events(
+ ctx: RequestContext,
+ filters: List,
+ ): Flow = emptyFlow()
}
@Test