diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b70f909ed..b96a91587 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,24 +144,17 @@ jobs: rust_tests: - *rust - 'tools/**' - - 'bindings/react-native/**' - - 'bindings/python/**' - - 'bindings/flutter/**' - - 'bindings/c/include/**' - - 'bindings/swift/README.md' - - 'bindings/web/scripts/**' - - 'bindings/web/src/nostr-signal.js' - - 'docs/spec/**' - - 'docs/bridges/README.md' - - 'docs/bridges/c.md' - - 'docs/bridges/flutter.md' - - 'docs/android-integration.md' - - 'docs/api-reference.md' - - 'docs/configuration.md' - - 'docs/ios-integration.md' - - 'docs/mls-integration.md' - - 'CLAUDE.md' - - 'QUICKSTART.md' + # The guards read binding sources (the bridges, the C header, + # the web driver's constants, the build scripts) by path, and + # `no_shipped_file_names_a_relay` reads every shipped doc, + # example, script and binding source for a relay URL, so every + # one of these trees and the top-level docs (CLAUDE.md, + # QUICKSTART.md, CHANGELOG.md) is a guard input. + - 'bindings/**' + - 'docs/**' + - 'examples/**' + - 'scripts/**' + - '*.md' - '.github/workflows/**' spec_vectors: diff --git a/CHANGELOG.md b/CHANGELOG.md index ba04962c0..8109736d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,19 @@ archived by series under [docs/changelog/](docs/changelog/); see the ### Added +- **The browser reaches devices it has no link to, over Nostr.** + `OfflineNode.start({ nostr: { relays } })` adds the Nostr transport every + binding shares, driven from the worker over the relays the application + names; the SDK names none and refuses an empty list. A message to a bare + address fetches the key package the device published and forms the + session, so two nodes that share only a relay can talk. It is the + transport and wire format the mobile bindings run; a browser to phone pair + has not been run on devices yet. + `resolveUsername` reads the username directory (`usernameDiscovery`, off + by default, publishes this profile and resolves others'), and + `establishSecureSession`, `createInvite` and `parseInvite` match the + native calls ([W12](docs/bridges/web.md#w12-the-nostr-transport-is-driven-from-the-worker-over-the-applications-relays)). + - **Shared React Native engine bridge.** Engine operations live in `ProtocolEngineHost` and `ProtocolEngineHostBridge`; `OfflineProtocolModule` delegates through `ProtocolEngineHostBridgeReactNative` with promise fallback @@ -152,6 +165,15 @@ archived by series under [docs/changelog/](docs/changelog/); see the ### Changed +- **No shipped file names a Nostr relay.** The docs, doc comments and the + Nostr example listed public relays; they now show a placeholder, and the + example starts with an empty list for the developer to fill. + `no_shipped_file_names_a_relay` keeps it so: a relay the SDK names becomes + the relay every application uses. +- **The Nostr driver guide no longer completes a resolution on the first + EOSE.** `docs/nostr.md` said so while the bridges and the username chapter + wait for every relay; the guide now says what the code does. + - **The workspace reads the clock through `offline_protocol_core::time`.** It is `std::time` on every target but the browser, where `std::time` compiles and panics on first use; there it is `web-time`. Nothing changes on any diff --git a/bindings/react-native/src/types.ts b/bindings/react-native/src/types.ts index 3ad5b9d97..af9f7d020 100644 --- a/bindings/react-native/src/types.ts +++ b/bindings/react-native/src/types.ts @@ -471,7 +471,7 @@ export interface ReticulumTransportConfig { export interface NostrTransportConfig { /** Enable Nostr transport (default: false) */ enabled: boolean; - /** List of Nostr relay WebSocket URLs (e.g., ["wss://relay.damus.io"]) */ + /** Nostr relay WebSocket URLs (`wss://`) the application chooses. The SDK names none. */ relayUrls?: string[]; /** Connection timeout in seconds (default: 30) */ connectionTimeout?: number; diff --git a/bindings/web/src/index.d.ts b/bindings/web/src/index.d.ts index 6c1e900ca..74e164a38 100644 --- a/bindings/web/src/index.d.ts +++ b/bindings/web/src/index.d.ts @@ -17,6 +17,8 @@ export interface StartOptions { storage?: "durable" | "memory"; /** Link to this origin's other tabs over a BroadcastChannel. Default true. */ tabs?: boolean; + /** The Nostr transport over relays the application names (W12). */ + nostr?: NostrOptions; /** Where worker.js is served, if not beside this module. */ workerUrl?: string | URL; /** @@ -26,6 +28,40 @@ export interface StartOptions { wasm?: BufferSource | WebAssembly.Module; } +export interface NostrOptions { + /** + * The relays to use, as ws:// or wss:// URLs. Required: the SDK names no + * relay of its own. + */ + relays: string[]; + /** + * Publish key packages so a device that knows only this node's address + * can reach it, and fetch others' (cold first contact). Default true. + */ + coldContact?: boolean; + /** + * Publish `profile` as a username in the relays' directory, and resolve + * others'. Default false: it binds a readable name to this address in + * public. Needs `coldContact`. + */ + usernameDiscovery?: boolean; +} + +/** Where `establishSecureSession` left a session. */ +export type SessionStart = "created" | "existing" | "pending"; + +/** An invite another device shared, verified. */ +export interface InviteInfo { + /** The address, checked to derive from `publicKey`. */ + address: string; + /** The Ed25519 identity key, base64. */ + publicKey: string; + /** A suggested name; the person may change it. */ + petname: string | null; + /** Whether a signature binds the petname to the key. */ + signed: boolean; +} + /** A group as this device holds it. */ export interface GroupInfo { group_id: string; @@ -66,7 +102,8 @@ export interface PageCarrier { * as a DOM event named by its `type` and as `event`, with the JSON as `detail`. * * Two more events are the binding's own. `error` carries a tick that failed - * and the node carried on. `fatal` carries why the node ended (its module + * and the node carried on, or a Nostr relay the node cannot use (one the + * browser will not dial, or one that closed this device's subscription). `fatal` carries why the node ended (its module * trapped, its worker failed, or it was terminated): every call in flight and * every later one rejects. Starting the node again with the same `appId` and * `profile` is the same device when its storage is durable. @@ -81,6 +118,21 @@ export class OfflineNode extends EventTarget { * or no session. */ sendMessage(to: string, content: string, options?: SendOptions): Promise; + /** + * Starts a session with a device. "pending" means its key package has not + * arrived; with relays, the node has asked them for it, and + * `secure_session_established` follows. + */ + establishSecureSession(address: string): Promise; + /** + * Starts a directory lookup; the answer is one `username_resolved` event + * with every claim. Needs `nostr.usernameDiscovery`. + */ + resolveUsername(username: string): Promise; + /** An invite naming this device, base64url. */ + createInvite(options?: { petname?: string; sign?: boolean }): Promise; + /** Opens and verifies an invite; rejects one that does not verify. */ + parseInvite(blob: string): Promise; createGroup(name: string): Promise<{ group_id: string; [field: string]: unknown }>; inviteToGroup(groupId: string, member: string): Promise; /** diff --git a/bindings/web/src/index.js b/bindings/web/src/index.js index 7cc1da6ec..832d0e2a4 100644 --- a/bindings/web/src/index.js +++ b/bindings/web/src/index.js @@ -2,6 +2,7 @@ export { WebRtcCarrier } from "./webrtc.js"; export { NostrSignaller } from "./nostr-signal.js"; +import { checkRelays } from "./nostr-relays.js"; export class OfflineNode extends EventTarget { #worker; @@ -43,15 +44,23 @@ export class OfflineNode extends EventTarget { /// worker, in sealed origin-private files) or "memory" (a new device every /// start). `tabs: false` leaves out the carrier between this origin's tabs, /// for a node that links only through carriers the page attaches. + /// + /// `nostr: { relays, coldContact, usernameDiscovery }` adds the Nostr + /// transport over the relays the application names (W12). The SDK names + /// none, so `relays` is required. `coldContact` (default on) publishes key + /// packages so a device that knows only this node's address can reach it; + /// `usernameDiscovery` (default off) publishes `profile` as a username. static async start({ appId, profile = "default", storage = "durable", tabs = true, + nostr, workerUrl, wasm, } = {}) { if (!appId) throw new Error("appId is required"); + if (nostr !== undefined) checkRelays(nostr?.relays); if (storage === "durable") { // Best effort: without it the browser may evict the origin's files // under storage pressure, identity included. Never awaited: Firefox @@ -64,7 +73,23 @@ export class OfflineNode extends EventTarget { const instance = crypto.randomUUID(); const node = new OfflineNode(new Worker(url, { type: "module" }), { appId, instance, tabs }); try { - node.address = await node.#call("start", { appId, profile, storage, tabs, instance, wasm }); + node.address = await node.#call("start", { + appId, + profile, + storage, + tabs, + instance, + wasm, + // The worker applies the defaults; it is the one place that does. + nostr: + nostr === undefined + ? undefined + : { + relays: [...nostr.relays], + coldContact: nostr.coldContact, + usernameDiscovery: nostr.usernameDiscovery, + }, + }); } catch (error) { // The worker kept nothing (no engine, lock or handle), but it is still // a running worker with a module in it. @@ -108,6 +133,36 @@ export class OfflineNode extends EventTarget { return this.#call("sendGroupMessage", { groupId, content, ephemeral }); } + /// Starts a session with `address`: "created" (the Welcome went out now), + /// "existing", or "pending" while its key package has not arrived. On a + /// node with relays, "pending" has asked them for the packages the device + /// published, and `secure_session_established` follows. A message sent + /// meanwhile waits for the session, so calling this first is optional. + establishSecureSession(address) { + return this.#call("establishSecureSession", { address }); + } + + /// Looks a username up in the directory the relays hold (W12). Resolves + /// once the lookup is under way; the answer is one `username_resolved` + /// event carrying every device that claims the name. A name is a claim, + /// not an identity: show the person the whole set, and keep the address + /// they choose. + resolveUsername(username) { + return this.#call("resolveUsername", { username }); + } + + /// An invite naming this device (base64url): its address and key, and an + /// optional petname that `sign: true` binds to the key. + createInvite({ petname, sign = false } = {}) { + return this.#call("createInvite", { petname, sign }); + } + + /// Opens an invite: `{ address, publicKey, petname, signed }`, the address + /// checked to derive from the key. Rejects an invite that does not verify. + parseInvite(blob) { + return this.#call("parseInvite", { blob }); + } + /// A fresh room code for NostrSignaller: eighty bits a person can read out. newRoom() { return this.#call("signalNewRoom"); diff --git a/bindings/web/src/nostr-relays.js b/bindings/web/src/nostr-relays.js new file mode 100644 index 000000000..b7f30202d --- /dev/null +++ b/bindings/web/src/nostr-relays.js @@ -0,0 +1,371 @@ +// The Nostr transport's sockets, in the worker beside the engine (W12). +// +// Invariants (docs/bridges/web.md): +// - The relays are the application's. This file is handed a list and dials +// exactly that list; it names no relay of its own. +// - It moves text. It reads what every binding's driver reads (a message's +// type, its subscription id, an OK's event id and verdict) and hands every +// EVENT to the engine whole, which decodes, unseals and attributes it. +// - A relay is live once it has confirmed this device's subscription with +// EOSE, and stays live while it keeps answering: every KEEP_ALIVE_MS it is +// sent a probe, a REQ for an event id nobody can have, and a relay that has +// not sent the probe's EOSE within PROBE_MS is dropped and redialled. A +// socket the network lost without closing it would otherwise count as a +// relay for ever. The probe is never the inbox subscription sent again: a +// replace is answered with every stored event since the receive watermark +// (an hour and more back, since gift wraps backdate created_at), so it +// would replay that history from every relay every minute. The engine +// hears "live" when the first relay is and "not live" when the last one +// stops, and hears "not live" before anything that relay held is released, +// so nothing is drained toward a relay set that is already empty. +// - An event is confirmed by the first relay that accepts it and fails only +// when every relay it went to refused it or dropped. The transport expires +// a confirmation nobody reported, so a silent relay costs a retry, not a +// lost message. +// - A resolution query is complete when every relay it went to has sent its +// own EOSE (or dropped), never on the first: one fast, empty or hostile +// relay must not decide the answer (docs/spec/username-discovery.md). It is +// closed on each relay as that relay finishes, and completed after +// COMPLETION_TIMEOUT_MS at the latest, below the engine's 30 s sweep, so the +// driver that knows which relays answered is the one that ends it. + +const RETRY_MIN_MS = 1_000; +const RETRY_MAX_MS = 30_000; +const KEEP_ALIVE_MS = 60_000; +const PROBE_MS = 10_000; +const COMPLETION_TIMEOUT_MS = 10_000; +// Events awaiting a verdict, at most. The transport bounds its own queue; +// this bounds the driver's copy if the relays never answer. +const MAX_PENDING_EVENTS = 1024; +// The probe asks for this id, which no event has: a relay answers it with its +// EOSE and nothing before it. +const NO_EVENT_ID = "0".repeat(64); + +function randomHex(bytes) { + const buffer = new Uint8Array(bytes); + crypto.getRandomValues(buffer); + return Array.from(buffer, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +/// Checks an application's relay list: a non-empty array of ws:// or wss:// +/// URLs. Throws with the reason otherwise. +export function checkRelays(relays) { + if (!Array.isArray(relays) || relays.length === 0) { + throw new Error( + "nostr.relays must list the relays to use (ws:// or wss:// URLs); the SDK names none", + ); + } + for (const relay of relays) { + let url; + try { + url = new URL(relay); + } catch { + throw new Error(`nostr.relays: ${relay} is not a URL`); + } + if (url.protocol !== "wss:" && url.protocol !== "ws:") { + throw new Error(`nostr.relays: ${relay} is not a ws:// or wss:// URL`); + } + } + return [...new Set(relays)]; +} + +export class NostrRelays { + #engine; + #slots = []; + #subscriptionId = `inbox-${randomHex(8)}`; + #probeId = `probe-${randomHex(8)}`; + // eventId -> { messageId, waiting: Set, reason } + #pending = new Map(); + // queryId -> { waiting: Set, deadline } + #queries = new Map(); + #keepAlive = null; + #keepAliveMs; + #probeMs; + #completionMs; + #live = false; + #closed = false; + #wake = () => this.#redialNow(); + + /// `engine` is what the worker hands over: `subscription(id)` returns the + /// REQ text, `event(text)` takes a relay's EVENT, `status(live)`, + /// `confirmed(messageId)`, `failed(messageId, reason)` and + /// `completed(queryId)` report back, and `warn(text)` reports a relay the + /// node cannot use (one the browser will not dial, or one that ends this + /// device's subscription), which the page would otherwise never learn of. + /// + /// `timers` exists for the driver's own test page, which cannot wait out a + /// minute per probe; a node always runs on the defaults. + constructor(relays, engine, timers = {}) { + this.#engine = engine; + this.#keepAliveMs = timers.keepAliveMs ?? KEEP_ALIVE_MS; + this.#probeMs = timers.probeMs ?? PROBE_MS; + this.#completionMs = timers.completionMs ?? COMPLETION_TIMEOUT_MS; + for (const url of checkRelays(relays)) { + // `refused`: this relay closed the subscription and has not confirmed + // one since, so the page has already been told. + this.#slots.push({ + url, + socket: null, + live: false, + confirm: null, + retry: null, + backoff: RETRY_MIN_MS, + refused: false, + }); + } + } + + get live() { + return this.#live; + } + + open() { + for (const slot of this.#slots) this.#connect(slot); + this.#keepAlive = setInterval(() => this.#probe(), this.#keepAliveMs); + self.addEventListener?.("online", this.#wake); + } + + /// Puts one signed event on every live relay. + publish({ messageId, eventId, eventJson }) { + const live = this.#slots.filter((slot) => slot.live); + if (live.length === 0) return this.#engine.failed(messageId, "no relay is live"); + if (this.#pending.size >= MAX_PENDING_EVENTS) { + const [oldest, entry] = this.#pending.entries().next().value; + this.#pending.delete(oldest); + this.#engine.failed(entry.messageId, "no relay answered"); + } + this.#pending.set(eventId, { messageId, waiting: new Set(live.map((s) => s.url)), reason: null }); + for (const slot of live) slot.socket.send(eventJson); + } + + /// Sends one resolution query to every live relay. + query({ queryId, reqJson }) { + const live = this.#slots.filter((slot) => slot.live); + if (live.length === 0) return this.#engine.completed(queryId); + const deadline = setTimeout(() => this.#finish(queryId), this.#completionMs); + this.#queries.set(queryId, { waiting: new Set(live.map((s) => s.url)), deadline }); + for (const slot of live) slot.socket.send(reqJson); + } + + /// Closes every socket, then says "not live", then ends every query. In + /// that order, as #lost does: each step runs the engine and flushes, and a + /// flush that still saw this node live would hand the next event or query + /// to a socket already closing. Going down fails every unconfirmed event + /// in the transport, so the driver's copy is forgotten, not reported. + close() { + if (this.#closed) return; + this.#closed = true; + clearInterval(this.#keepAlive); + self.removeEventListener?.("online", this.#wake); + for (const slot of this.#slots) { + clearTimeout(slot.retry); + slot.retry = null; + if (slot.socket?.readyState === WebSocket.OPEN) { + slot.socket.send(JSON.stringify(["CLOSE", this.#subscriptionId])); + } + this.#drop(slot); + } + this.#setLive(false); + this.#pending.clear(); + for (const queryId of [...this.#queries.keys()]) this.#finish(queryId); + } + + #connect(slot) { + if (this.#closed) return; + slot.retry = null; + let socket; + try { + socket = new WebSocket(slot.url); + } catch (error) { + // A URL the browser refuses to dial (a ws:// relay from an https: page + // is mixed content) is refused for good, and said so: with every relay + // refused the node would otherwise just never go live. + this.#engine.warn?.( + `nostr: the browser refused to dial ${slot.url}: ${error?.message ?? error}`, + ); + return; + } + slot.socket = socket; + socket.onopen = () => this.#subscribe(slot); + socket.onerror = () => this.#lost(slot, socket); + socket.onclose = () => this.#lost(slot, socket); + socket.onmessage = ({ data }) => { + if (typeof data === "string") this.#message(slot, data); + }; + } + + #subscribe(slot) { + let request; + try { + request = this.#engine.subscription(this.#subscriptionId); + } catch { + return this.#lost(slot, slot.socket); + } + slot.socket.send(request); + this.#await(slot); + } + + // Arms the confirmation deadline for the subscription or probe just sent. + #await(slot) { + clearTimeout(slot.confirm); + const socket = slot.socket; + slot.confirm = setTimeout(() => { + if (slot.socket === socket) this.#lost(slot, socket); + }, this.#probeMs); + } + + #probe() { + const probe = JSON.stringify(["REQ", this.#probeId, { ids: [NO_EVENT_ID] }]); + for (const slot of this.#slots) { + if (slot.live && slot.socket?.readyState === WebSocket.OPEN && !slot.confirm) { + slot.socket.send(probe); + this.#await(slot); + } + } + } + + // The relay answered the probe: it is still carrying this socket. + #probed(slot) { + clearTimeout(slot.confirm); + slot.confirm = null; + if (slot.socket?.readyState === WebSocket.OPEN) { + slot.socket.send(JSON.stringify(["CLOSE", this.#probeId])); + } + } + + #redialNow() { + for (const slot of this.#slots) { + if (slot.retry) { + clearTimeout(slot.retry); + this.#connect(slot); + } + } + } + + #message(slot, text) { + let parts; + try { + parts = JSON.parse(text); + } catch { + return; + } + if (!Array.isArray(parts)) return; + const [type, id] = parts; + if (type === "EVENT") { + if (id === this.#subscriptionId || this.#queries.has(id)) this.#engine.event(text); + } else if (type === "OK") { + this.#verdict(slot, id, parts[2] === true, parts[3]); + } else if (type === "EOSE") { + if (id === this.#subscriptionId) { + clearTimeout(slot.confirm); + slot.confirm = null; + slot.backoff = RETRY_MIN_MS; + slot.refused = false; + slot.live = true; + this.#setLive(true); + } else if (id === this.#probeId) { + this.#probed(slot); + } else { + this.#answered(slot, id); + } + } else if (type === "CLOSED") { + if (id === this.#subscriptionId) { + // A relay that ends this device's subscription is not carrying it. + // Said once until the relay confirms a subscription again: the redial + // meets the same refusal (a relay that wants authentication) every + // time, and a page told every backoff would hear it for ever. + if (!slot.refused) { + slot.refused = true; + this.#engine.warn?.( + `nostr: ${slot.url} closed this device's subscription: ${parts[2] ?? "no reason given"}`, + ); + } + this.#lost(slot, slot.socket); + } else if (id === this.#probeId) { + // A refusal is still an answer from a live relay. + this.#probed(slot); + } else { + this.#answered(slot, id); + } + } + } + + #verdict(slot, eventId, accepted, reason) { + const entry = this.#pending.get(eventId); + if (!entry) return; + if (accepted) { + this.#pending.delete(eventId); + return this.#engine.confirmed(entry.messageId); + } + entry.reason = typeof reason === "string" && reason ? reason : "relay rejected event"; + this.#released(entry, eventId, slot.url); + } + + // `url` will not answer for `eventId`; fail it once nobody else can. + #released(entry, eventId, url) { + entry.waiting.delete(url); + if (entry.waiting.size > 0) return; + this.#pending.delete(eventId); + this.#engine.failed(entry.messageId, entry.reason ?? "every relay dropped"); + } + + #answered(slot, queryId) { + const query = this.#queries.get(queryId); + if (!query || !query.waiting.delete(slot.url)) return; + if (slot.socket?.readyState === WebSocket.OPEN) { + slot.socket.send(JSON.stringify(["CLOSE", queryId])); + } + if (query.waiting.size === 0) this.#finish(queryId); + } + + #finish(queryId) { + const query = this.#queries.get(queryId); + if (!query) return; + clearTimeout(query.deadline); + this.#queries.delete(queryId); + for (const slot of this.#slots) { + if (query.waiting.has(slot.url) && slot.socket?.readyState === WebSocket.OPEN) { + slot.socket.send(JSON.stringify(["CLOSE", queryId])); + } + } + this.#engine.completed(queryId); + } + + #lost(slot, socket) { + if (slot.socket !== socket || socket === null) return; + this.#drop(slot); + // Before anything is released: each release runs the engine and flushes, + // and a flush that still saw this node live would drain the next query + // toward no relay, complete it empty and spend the peer's resolution + // rate limit on nothing. + this.#setLive(this.#slots.some((s) => s.live)); + for (const [eventId, entry] of [...this.#pending]) { + if (entry.waiting.has(slot.url)) this.#released(entry, eventId, slot.url); + } + for (const queryId of [...this.#queries.keys()]) this.#answered(slot, queryId); + if (this.#closed) return; + slot.retry = setTimeout(() => this.#connect(slot), slot.backoff); + slot.backoff = Math.min(slot.backoff * 2, RETRY_MAX_MS); + } + + #drop(slot) { + clearTimeout(slot.confirm); + slot.confirm = null; + slot.live = false; + const socket = slot.socket; + slot.socket = null; + if (!socket) return; + socket.onopen = socket.onerror = socket.onclose = socket.onmessage = null; + try { + socket.close(); + } catch { + // Closing a socket that never opened throws in some engines. + } + } + + #setLive(live) { + if (live === this.#live) return; + this.#live = live; + this.#engine.status(live); + } +} diff --git a/bindings/web/src/worker.js b/bindings/web/src/worker.js index bb678d5e9..4edb3fea2 100644 --- a/bindings/web/src/worker.js +++ b/bindings/web/src/worker.js @@ -14,6 +14,9 @@ // which opens a link by message and gets its frames back by message. The // engine cannot tell them apart, and the page's carriers obey the same // rules: the bytes are opaque, and every close reaches the other end. +// - The Nostr transport's sockets live here too, beside the engine they +// feed, when the application gives the node relays (W12). They are the +// application's relays: nothing here names one. // - Storage opens before the engine does (W8). A durable node holds its // account's lock and its four log handles until it stops or the worker // ends, and the store key is zeroed once the engine has it. @@ -29,14 +32,17 @@ import init, { signalSubscription, signalEvent, signalOpen, + parseInvite, } from "../pkg/offline_protocol_web.js"; import { openDurableStore } from "./store.js"; import { TabCarrier } from "./tab-carrier.js"; +import { NostrRelays, checkRelays } from "./nostr-relays.js"; const TICK_MS = 100; let engine = null; let carrier = null; +let nostr = null; let store = null; let timer = null; let dead = null; @@ -50,9 +56,10 @@ function die(error) { clearInterval(timer); try { carrier?.close(); + nostr?.close(); store?.close(); } catch { - // The carrier and the store are JavaScript and do not trap. + // The carriers and the store are JavaScript and do not trap. } post({ kind: "fatal", message: dead }); self.close(); @@ -85,25 +92,84 @@ function flush() { if (pageLinks.has(link)) self.postMessage({ kind: "frame", link, data }, [data.buffer]); else carrier?.send(link, data); } + if (nostr?.live) { + for (let next = engine.nostrNextEvent(); next !== undefined; next = engine.nostrNextEvent()) { + nostr.publish(JSON.parse(next)); + } + for (let next = engine.nostrNextQuery(); next !== undefined; next = engine.nostrNextQuery()) { + nostr.query(JSON.parse(next)); + } + } for (let event = engine.pollEvent(); event !== undefined; event = engine.pollEvent()) { post({ kind: "event", event: JSON.parse(event) }); } } +// What the relay sockets call. Each runs the engine, so each flushes: an +// answer to a relay leaves on the call that produced it, and a fatal trap in +// any of them ends the worker as a trap in a tick does. +function guarded(run) { + return (...args) => { + if (dead) return undefined; + try { + const result = run(...args); + flush(); + return result; + } catch (error) { + if (trapped(error)) die(error); + else post({ kind: "error", message: String(error?.message ?? error) }); + return undefined; + } + }; +} + +function relaysFor() { + return { + // Not `guarded`: it returns text rather than running the engine, and the + // driver treats a throw as a relay lost. A trap is still fatal here, as + // everywhere: without this the driver would take it for a lost relay and + // call the stopped module again on every redial. + subscription: (id) => { + try { + return engine.nostrSubscription(id); + } catch (error) { + if (trapped(error)) die(error); + throw error; + } + }, + event: guarded((text) => engine.nostrRelayEvent(text)), + status: guarded((live) => engine.nostrStatusChanged(live)), + confirmed: guarded((messageId) => engine.nostrConfirmSent(messageId)), + failed: guarded((messageId, reason) => engine.nostrSendFailed(messageId, reason)), + completed: guarded((queryId) => engine.nostrQueryCompleted(queryId)), + // A relay the node cannot use reaches the page as an `error` event. + warn: (text) => post({ kind: "error", message: text }), + }; +} + function post(message) { self.postMessage(message); } const handlers = { - async start({ appId, profile, storage = "durable", tabs = true, instance, wasm }) { + async start({ appId, profile, storage = "durable", tabs = true, instance, wasm, nostr: relays }) { if (engine) throw new Error("this worker already runs a node"); + const nostrRelays = relays === undefined ? undefined : checkRelays(relays.relays); // A host that cannot serve the module beside this file (an editor // plugin, an extension) hands over its bytes or a compiled module. await init(wasm === undefined ? undefined : { module_or_path: wasm }); // Nothing is kept from a start that fails: no engine, and no lock or // handle, so the page can start the node again once the cause is gone. let address; - const next = new WebEngine(appId, profile); + const next = + relays === undefined + ? new WebEngine(appId, profile) + : WebEngine.withNostr( + appId, + profile, + relays.coldContact ?? true, + relays.usernameDiscovery ?? false, + ); try { if (storage === "durable") { store = await openDurableStore(storageNamespace(appId, profile)); @@ -143,6 +209,10 @@ const handlers = { instance, ); } + if (nostrRelays) { + nostr = new NostrRelays(nostrRelays, relaysFor()); + nostr.open(); + } timer = setInterval(tick, TICK_MS); flush(); return address; @@ -196,6 +266,20 @@ const handlers = { const opened = signalOpen(room, message); return opened === undefined ? null : JSON.parse(opened); }, + // Sessions and the directory (W12). They work on any node; reaching a + // device by its address alone, and the directory, need relays. + establishSecureSession({ address }) { + return engine.establishSecureSession(address); + }, + resolveUsername({ username }) { + return engine.resolveUsername(username); + }, + createInvite({ petname, sign = false }) { + return engine.createInvite(petname ?? undefined, Boolean(sign)); + }, + parseInvite({ blob }) { + return JSON.parse(parseInvite(blob)); + }, sendGroupMessage({ groupId, content, ephemeral = false }) { return JSON.parse(engine.sendGroupMessage(groupId, content, Boolean(ephemeral))); }, @@ -203,6 +287,8 @@ const handlers = { clearInterval(timer); carrier?.close(); carrier = null; + nostr?.close(); + nostr = null; engine.stop(); flush(); pageLinks.clear(); diff --git a/bindings/web/test/nostr-relay.mjs b/bindings/web/test/nostr-relay.mjs index 95f871a8c..a4265d032 100644 --- a/bindings/web/test/nostr-relay.mjs +++ b/bindings/web/test/nostr-relay.mjs @@ -1,15 +1,22 @@ // A minimal NIP-01 relay for the browser tests: REQ, EVENT, CLOSE over a -// WebSocket, filters on `kinds`, `since` and `#t`. It stores nothing, which is -// all an ephemeral signalling event needs, and verifies nothing: the pages' -// own code (signal.rs) is what refuses a forged event, and a relay that -// forwards anything is the stricter test of that. A client that connects on +// WebSocket, filters on `kinds`, `since`, `limit` and any `#` tag. It keeps +// every event that is not ephemeral (kinds 20000 to 29999, which signalling +// uses) and replaces an addressable one (30000 to 39999) by kind, author and +// `d` tag, so a REQ is answered with what it holds before its EOSE, as the +// Nostr transport's key packages, directory records and messages need. It +// verifies nothing: the engine is what refuses a forged event, and a relay +// that forwards anything is the stricter test of that. A client that connects on // the path `/silent` is never answered: the relay that accepts a socket and // never confirms a subscription. One on `/once` confirms its first // subscription and then goes quiet, answering and forwarding nothing more: // a socket the network lost without closing it. On `/first/` the // first socket for a token is served and every later one is silent: a relay // that answered before a network change and now accepts and ignores a -// redial. Node's standard library only, so the test runner installs nothing. +// redial. On `/reject` every event is refused with `OK false`, and on +// `/closed` every subscription is ended with `CLOSED`, as a relay that wants +// authentication does. On `/slow` everything is answered, SLOW_MS late: a +// relay that answers after the others. Node's standard library only, so the +// test runner installs nothing. // // Usage: node test/nostr-relay.mjs @@ -18,7 +25,10 @@ import { createHash } from "node:crypto"; const port = Number(process.argv[2] ?? 8766); const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +const SLOW_MS = 600; const clients = new Set(); +// Every event kept, oldest first. +const stored = []; // The `/first/` paths a socket has already connected on. const served = new Set(); @@ -45,13 +55,43 @@ function frame(text) { function matches(filter, event) { if (Array.isArray(filter.kinds) && !filter.kinds.includes(event.kind)) return false; if (typeof filter.since === "number" && event.created_at < filter.since) return false; - if (Array.isArray(filter["#t"])) { - const topics = (event.tags ?? []).filter((t) => t[0] === "t").map((t) => t[1]); - if (!filter["#t"].some((t) => topics.includes(t))) return false; + for (const [key, wanted] of Object.entries(filter)) { + if (!key.startsWith("#") || !Array.isArray(wanted)) continue; + const held = (event.tags ?? []).filter((t) => t[0] === key.slice(1)).map((t) => t[1]); + if (!wanted.some((value) => held.includes(value))) return false; } return true; } +const dTag = (event) => (event.tags ?? []).find((t) => t[0] === "d")?.[1]; + +function keep(event) { + const kind = event?.kind; + if (typeof kind !== "number" || (kind >= 20000 && kind < 30000)) return; + if (kind >= 30000 && kind < 40000) { + const at = stored.findIndex( + (held) => held.kind === kind && held.pubkey === event.pubkey && dTag(held) === dTag(event), + ); + if (at !== -1) { + if (stored[at].created_at > event.created_at) return; + stored.splice(at, 1); + } + } + stored.push(event); +} + +// What a REQ's filters match among the kept events: newest first, each +// filter cut to its own `limit`. +function history(filters) { + const found = new Map(); + for (const filter of filters) { + let matched = stored.filter((event) => matches(filter, event)).reverse(); + if (typeof filter.limit === "number") matched = matched.slice(0, filter.limit); + for (const event of matched) found.set(event.id, event); + } + return [...found.values()]; +} + class Client { subscriptions = new Map(); #buffer = Buffer.alloc(0); @@ -71,7 +111,11 @@ class Client { send(message) { if (this.mode === "quiet") return; - if (!this.socket.destroyed) this.socket.write(frame(JSON.stringify(message))); + const write = () => { + if (!this.socket.destroyed) this.socket.write(frame(JSON.stringify(message))); + }; + if (this.mode === "slow") setTimeout(write, SLOW_MS); + else write(); } #read(chunk) { @@ -128,15 +172,21 @@ class Client { } if (this.silent) return; const [type, ...rest] = message; - if (type === "REQ") { + if (type === "REQ" && this.mode === "closed") { + this.send(["CLOSED", rest[0], "auth-required: this relay serves members only"]); + } else if (type === "REQ") { const [id, ...filters] = rest; this.subscriptions.set(id, filters); + for (const event of history(filters)) this.send(["EVENT", id, event]); this.send(["EOSE", id]); if (this.mode === "once") this.mode = "quiet"; } else if (type === "CLOSE") { this.subscriptions.delete(rest[0]); + } else if (type === "EVENT" && this.mode === "reject") { + this.send(["OK", rest[0]?.id ?? "", false, "blocked: this relay takes nothing"]); } else if (type === "EVENT") { const event = rest[0]; + keep(event); this.send(["OK", event?.id ?? "", true, ""]); for (const client of clients) { for (const [id, filters] of client.subscriptions) { @@ -156,7 +206,13 @@ server.on("upgrade", (request, socket) => { "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + `Sec-WebSocket-Accept: ${accept}\r\n\r\n`, ); - let mode = { "/silent": "silent", "/once": "once" }[request.url] ?? "open"; + let mode = { + "/silent": "silent", + "/once": "once", + "/reject": "reject", + "/closed": "closed", + "/slow": "slow", + }[request.url] ?? "open"; if (request.url.startsWith("/first/")) { mode = served.has(request.url) ? "silent" : "open"; served.add(request.url); diff --git a/bindings/web/test/nostr-relays.html b/bindings/web/test/nostr-relays.html new file mode 100644 index 000000000..cde2a6824 --- /dev/null +++ b/bindings/web/test/nostr-relays.html @@ -0,0 +1,201 @@ + + +running +

+
diff --git a/bindings/web/test/nostr-transport.html b/bindings/web/test/nostr-transport.html
new file mode 100644
index 000000000..e0a19b6cf
--- /dev/null
+++ b/bindings/web/test/nostr-transport.html
@@ -0,0 +1,95 @@
+
+
+running
+

+
diff --git a/crates/offline-protocol-transport/src/nostr.rs b/crates/offline-protocol-transport/src/nostr.rs
index 9457299e1..c3d8e864e 100644
--- a/crates/offline-protocol-transport/src/nostr.rs
+++ b/crates/offline-protocol-transport/src/nostr.rs
@@ -320,7 +320,8 @@ pub struct SignedNostrEvent {
 /// Nostr transport configuration.
 #[derive(Debug, Clone)]
 pub struct NostrConfig {
-    /// List of relay URLs to connect to (e.g., `["wss://relay.damus.io"]`).
+    /// Relay URLs to connect to (`wss://`), chosen by the application. The
+    /// SDK names none.
     /// The platform manages actual WebSocket connections.
     pub relay_urls: Vec,
     /// Connection timeout for reaching Nostr relays.
diff --git a/crates/offline-protocol-uniffi/src/lib.rs b/crates/offline-protocol-uniffi/src/lib.rs
index e6da08854..bbae572d3 100644
--- a/crates/offline-protocol-uniffi/src/lib.rs
+++ b/crates/offline-protocol-uniffi/src/lib.rs
@@ -22930,7 +22930,7 @@ mod tests {
     /// bridge edited and the other left alone is the same drift in half the
     /// fleet.
     #[test]
-    fn nostr_query_completion_timeout_matches_across_both_bridges() {
+    fn nostr_query_completion_timeout_matches_across_every_bridge() {
         const EXPECTED_MS: u64 = 10_000;
 
         let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
@@ -22965,6 +22965,14 @@ mod tests {
             kotlin.contains("COMPLETION_TIMEOUT_MS = 10_000L"),
             "NostrQueryTracker.kt must declare COMPLETION_TIMEOUT_MS = {EXPECTED_MS} ms"
         );
+
+        // The browser's driver is the third: it runs the same broadcast query
+        // over relays the page's application named.
+        let web = read_abs(manifest.join("../../bindings/web/src/nostr-relays.js"));
+        assert!(
+            web.contains("const COMPLETION_TIMEOUT_MS = 10_000;"),
+            "bindings/web/src/nostr-relays.js must declare COMPLETION_TIMEOUT_MS = {EXPECTED_MS} ms"
+        );
     }
 
     /// The bridges' signal-reading feed interval stays above the core's
diff --git a/crates/offline-protocol-web/src/bindings.rs b/crates/offline-protocol-web/src/bindings.rs
index 030ac8ea4..73303ee37 100644
--- a/crates/offline-protocol-web/src/bindings.rs
+++ b/crates/offline-protocol-web/src/bindings.rs
@@ -9,7 +9,8 @@ use std::sync::Arc;
 use wasm_bindgen::prelude::*;
 
 use crate::log_store::{Category, LogStats, LogStore, STORE_KEY_BYTES};
-use crate::node::{LinkId, Node, NodeError};
+use crate::node::{LinkId, Node, NodeError, NodeOptions};
+use crate::nostr_host::NostrOptions;
 use crate::opfs::{OpfsFile, SyncAccessHandle};
 use crate::signal;
 
@@ -61,6 +62,24 @@ pub fn signal_open(room: &str, relay_message: &str) -> Option {
     signal::open(room, relay_message, signal::now_unix_secs()).map(|o| o.to_json())
 }
 
+/// Opens an invite someone shared: `{address, publicKey, petname, signed}` as
+/// JSON, the address already checked to derive from the key. Refuses a
+/// malformed blob, an address that is not the key's, and a signature that
+/// does not verify; each means the invite must not be shown at all.
+#[wasm_bindgen(js_name = parseInvite)]
+pub fn parse_invite(blob: &str) -> Result {
+    use base64::Engine as _;
+    let invite = offline_protocol_mls::invite::parse_invite(blob)
+        .map_err(|e| JsError::new(&e.to_string()))?;
+    Ok(serde_json::json!({
+        "address": invite.address.to_string(),
+        "publicKey": base64::engine::general_purpose::STANDARD.encode(&invite.public_key),
+        "petname": invite.petname,
+        "signed": invite.signed,
+    })
+    .to_string())
+}
+
 type DurableStores = (Arc>, Arc>);
 
 fn js(error: NodeError) -> JsError {
@@ -108,6 +127,29 @@ impl WebEngine {
         })
     }
 
+    /// A stopped engine that also carries the Nostr slot (W12), which the
+    /// worker drives over relays the application named. `coldContact`
+    /// publishes key packages and resolves peers'; `usernameDiscovery`
+    /// publishes `profile` as a username and resolves others'.
+    #[wasm_bindgen(js_name = withNostr)]
+    pub fn with_nostr(
+        app_id: &str,
+        profile: &str,
+        cold_contact: bool,
+        username_discovery: bool,
+    ) -> Result {
+        let options = NodeOptions {
+            nostr: Some(NostrOptions {
+                cold_contact,
+                username_discovery,
+            }),
+        };
+        Ok(Self {
+            node: Node::with_options(app_id, profile, options).map_err(js)?,
+            durable: None,
+        })
+    }
+
     /// Mints a new identity held in memory and returns its `off1…` address.
     /// The identity ends with the worker.
     pub fn initialize(&mut self) -> Result {
@@ -267,6 +309,94 @@ impl WebEngine {
         self.node.group_info(group_id).map_err(js)
     }
 
+    /// Starts a session with a peer: "created" when this call sent the
+    /// Welcome, "existing" when one already exists, "pending" while the
+    /// peer's key package has not arrived (a node with Nostr has asked the
+    /// relays for it).
+    #[wasm_bindgen(js_name = establishSecureSession)]
+    pub fn establish_secure_session(&mut self, peer: &str) -> Result {
+        self.node
+            .establish_secure_session(peer)
+            .map(|start| start.as_str().to_string())
+            .map_err(js)
+    }
+
+    /// Resolves a username; exactly one `username_resolved` event follows.
+    #[wasm_bindgen(js_name = resolveUsername)]
+    pub fn resolve_username(&mut self, username: &str) -> Result {
+        self.node.resolve_username(username).map_err(js)
+    }
+
+    /// An invite for this device, base64url, optionally with a petname and
+    /// a signature binding it.
+    #[wasm_bindgen(js_name = createInvite)]
+    pub fn create_invite(&self, petname: Option, sign: bool) -> Result {
+        self.node
+            .create_invite(petname.as_deref(), sign)
+            .map_err(js)
+    }
+
+    /// The `REQ` text that receives this device's messages (W12). Sent on
+    /// every relay connect.
+    #[wasm_bindgen(js_name = nostrSubscription)]
+    pub fn nostr_subscription(&self, subscription_id: &str) -> Result {
+        self.node.nostr_subscription(subscription_id).map_err(js)
+    }
+
+    /// The worker has a live relay, or none.
+    #[wasm_bindgen(js_name = nostrStatusChanged)]
+    pub fn nostr_status_changed(&mut self, live: bool) -> Result<(), JsError> {
+        self.node.nostr_status_changed(live).map_err(js)
+    }
+
+    /// The next event to publish as `{messageId, eventId, eventJson}` JSON,
+    /// or `undefined`. Nothing while no relay is live.
+    #[wasm_bindgen(js_name = nostrNextEvent)]
+    pub fn nostr_next_event(&mut self) -> Option {
+        self.node.nostr_next_event().map(|event| {
+            serde_json::json!({
+                "messageId": event.message_id,
+                "eventId": event.event_id,
+                "eventJson": event.event_json,
+            })
+            .to_string()
+        })
+    }
+
+    /// A relay accepted the event carrying `messageId`.
+    #[wasm_bindgen(js_name = nostrConfirmSent)]
+    pub fn nostr_confirm_sent(&mut self, message_id: &str) {
+        self.node.nostr_confirm_sent(message_id);
+    }
+
+    /// No relay took the event carrying `messageId`.
+    #[wasm_bindgen(js_name = nostrSendFailed)]
+    pub fn nostr_send_failed(&mut self, message_id: &str, reason: Option) {
+        self.node.nostr_send_failed(message_id, reason);
+    }
+
+    /// The next resolution query as `{queryId, reqJson}` JSON, or
+    /// `undefined`. Sent to every live relay under `queryId`.
+    #[wasm_bindgen(js_name = nostrNextQuery)]
+    pub fn nostr_next_query(&mut self) -> Option {
+        self.node.nostr_next_query().map(|query| {
+            serde_json::json!({ "queryId": query.query_id, "reqJson": query.req_json }).to_string()
+        })
+    }
+
+    /// Every relay the query went to has answered, or the worker stopped
+    /// waiting.
+    #[wasm_bindgen(js_name = nostrQueryCompleted)]
+    pub fn nostr_query_completed(&mut self, query_id: &str) {
+        self.node.nostr_query_completed(query_id);
+    }
+
+    /// One `EVENT` message a relay sent, whole.
+    #[wasm_bindgen(js_name = nostrRelayEvent)]
+    pub fn nostr_relay_event(&mut self, relay_message: &str) -> Result<(), JsError> {
+        self.node.nostr_relay_event(relay_message).map_err(js)
+    }
+
     /// Sends to a group; returns the per-member message ids as JSON.
     /// `ephemeral` as for [`WebEngine::send_message`].
     #[wasm_bindgen(js_name = sendGroupMessage)]
diff --git a/crates/offline-protocol-web/src/lib.rs b/crates/offline-protocol-web/src/lib.rs
index 0f4a7d70f..646f7d2c1 100644
--- a/crates/offline-protocol-web/src/lib.rs
+++ b/crates/offline-protocol-web/src/lib.rs
@@ -10,7 +10,9 @@
 //! worker. [`memory_store`] remains for a node that should not.
 //!
 //! [`signal`] lets two pages that share a room code exchange their WebRTC
-//! connection codes through Nostr relays instead of by hand.
+//! connection codes through Nostr relays instead of by hand, and
+//! [`nostr_host`] drives the Nostr transport itself from the worker, with
+//! relays the application names.
 //!
 //! The crate also builds on every host, so the workspace's lint, docs and test
 //! runs cover it: everything below the bindings is plain Rust, and the tests
@@ -23,8 +25,11 @@ pub mod bindings;
 pub mod log_store;
 pub mod memory_store;
 pub mod node;
+pub mod nostr_host;
 pub mod opfs;
 pub mod signal;
 
+#[cfg(test)]
+mod nostr_tests;
 #[cfg(test)]
 mod tests;
diff --git a/crates/offline-protocol-web/src/node.rs b/crates/offline-protocol-web/src/node.rs
index d1701a22f..5029f2c2a 100644
--- a/crates/offline-protocol-web/src/node.rs
+++ b/crates/offline-protocol-web/src/node.rs
@@ -4,7 +4,9 @@
 //! have (a `BroadcastChannel` between tabs, a WebRTC data channel between
 //! devices) delivers whole messages between two endpoints the page set up.
 //! Each one is a peer stream in the sense of `docs/spec/stream-framing.md`,
-//! carried in the engine's one host slot (`wifi_direct`, named for history).
+//! carried in the engine's peer-stream slot (`wifi_direct`, named for
+//! history). A node the application gives relays also carries the Nostr
+//! slot, driven from the worker ([`crate::nostr_host`]).
 //!
 //! # Invariants
 //!
@@ -42,12 +44,13 @@ use offline_protocol::{
 };
 use offline_protocol_core::time::{Duration, Instant};
 use offline_protocol_transport::{
-    frame, PeerStreamReader, StreamEvent, Transport, TransportStatus, TransportType,
-    WifiDirectTransport,
+    frame, NostrTransport, PeerStreamReader, StreamEvent, Transport, TransportStatus,
+    TransportType, WifiDirectTransport,
 };
 use tracing::warn;
 
 use crate::memory_store::MemoryProtocolStateStorage;
+use crate::nostr_host::{NostrHost, NostrOptions};
 
 /// The page's handle for one carrier.
 pub type LinkId = u32;
@@ -86,7 +89,36 @@ impl From for NodeError {
     }
 }
 
-type NodeResult = Result;
+pub(crate) type NodeResult = Result;
+
+/// Where [`Node::establish_secure_session`] left a session.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum SessionStart {
+    /// This call created it and sent the Welcome.
+    Created,
+    /// One already existed.
+    Existing,
+    /// Waiting for the peer's key package.
+    Pending,
+}
+
+impl SessionStart {
+    /// The name the page sees.
+    pub fn as_str(self) -> &'static str {
+        match self {
+            Self::Created => "created",
+            Self::Existing => "existing",
+            Self::Pending => "pending",
+        }
+    }
+}
+
+/// What a node carries beyond its peer-stream links.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub struct NodeOptions {
+    /// The Nostr slot, when the application gives the node relays.
+    pub nostr: Option,
+}
 
 struct Link {
     reader: PeerStreamReader,
@@ -98,8 +130,10 @@ struct Link {
 
 /// One engine, its links, and the queues the page drains.
 pub struct Node {
-    protocol: OfflineProtocol,
-    events: Arc>>,
+    pub(crate) protocol: OfflineProtocol,
+    pub(crate) events: Arc>>,
+    /// The worker's half of the Nostr slot, when the node has one.
+    pub(crate) nostr_host: Option,
     links: BTreeMap,
     /// The one live link for each announced address.
     peers: HashMap,
@@ -113,16 +147,28 @@ pub struct Node {
 }
 
 impl Node {
+    /// A stopped engine with no identity yet, carrying peer-stream links only.
+    pub fn new(app_id: &str, profile: &str) -> NodeResult {
+        Self::with_options(app_id, profile, NodeOptions::default())
+    }
+
     /// A stopped engine with no identity yet.
     ///
-    /// Only the peer-stream slot is registered: a browser has no Bluetooth
-    /// peripheral, and the relay and Nostr sockets are not wired yet.
-    pub fn new(app_id: &str, profile: &str) -> NodeResult {
+    /// The peer-stream slot is always registered. The Nostr slot is
+    /// registered once the address exists, when `options.nostr` is set: the
+    /// transport derives its routing tag from the address it is built with.
+    /// A browser has no Bluetooth peripheral, and the internet relay's socket
+    /// is not wired.
+    pub fn with_options(app_id: &str, profile: &str, options: NodeOptions) -> NodeResult {
         let mut config = ProtocolConfig::new(app_id, profile);
         config.transport.ble_enabled = false;
         config.transport.internet_enabled = false;
         config.transport.reticulum_enabled = false;
-        config.transport.nostr_enabled = false;
+        config.transport.nostr_enabled = options.nostr.is_some();
+        if let Some(nostr) = options.nostr {
+            config.transport.nostr_cold_contact_enabled = nostr.cold_contact;
+            config.transport.nostr_username_discovery_enabled = nostr.username_discovery;
+        }
         config.transport.wifi_direct_enabled = true;
 
         let mut protocol = OfflineProtocol::new(config)?;
@@ -140,6 +186,7 @@ impl Node {
         Ok(Self {
             protocol,
             events,
+            nostr_host: options.nostr.map(|_| NostrHost::default()),
             links: BTreeMap::new(),
             peers: HashMap::new(),
             outbound: VecDeque::new(),
@@ -178,10 +225,30 @@ impl Node {
             .local_address()
             .ok_or_else(|| NodeError("MLS initialized without an address".to_string()))?
             .to_string();
+        // Every replacement is built before any is installed, as the FFI
+        // crate does, so the one fallible construction cannot leave the set
+        // half rebuilt.
+        let nostr = match self.nostr_host {
+            Some(_) => Some(
+                NostrTransport::new(address.as_str())
+                    .map_err(|e| NodeError(format!("could not create the Nostr transport: {e}")))?,
+            ),
+            None => None,
+        };
         self.protocol.transport_manager_mut().add_transport(
             TransportType::WiFiDirect,
             Box::new(WifiDirectTransport::new(address.as_str())),
         );
+        if let Some(nostr) = nostr {
+            self.protocol
+                .transport_manager_mut()
+                .add_transport(TransportType::Nostr, Box::new(nostr));
+            // The transport did not exist while `initialize_mls` restored
+            // state, so the persisted signing secret and receive watermark go
+            // in now. Without this the install's Nostr identity rotates on
+            // every start, stranding peers that seal to the key it advertised.
+            self.protocol.restore_nostr_transport_state();
+        }
         Ok(address)
     }
 
@@ -373,6 +440,7 @@ impl Node {
         content: &str,
         ephemeral: bool,
     ) -> NodeResult {
+        self.reach_over_nostr(recipient);
         let id = self.protocol.send_message_with(
             recipient,
             content,
@@ -437,6 +505,89 @@ impl Node {
         serde_json::to_string(&ids).map_err(|e| NodeError(e.to_string()))
     }
 
+    /// Starts, or advances, a session with `peer`, and says where it stands:
+    ///
+    /// - [`SessionStart::Created`]: this call built it and sent the Welcome;
+    /// - [`SessionStart::Existing`]: there already is one;
+    /// - [`SessionStart::Pending`]: no key package from `peer` yet. On a node
+    ///   with relays this call has asked them for the peer's published
+    ///   packages, and `secure_session_established` follows once one arrives.
+    ///
+    /// Pending is not an error here, as it is in the engine: for a device met
+    /// only by its address it is the ordinary first answer.
+    pub fn establish_secure_session(&mut self, peer: &str) -> NodeResult {
+        self.reach_over_nostr(peer);
+        match self.protocol.establish_secure_session(peer) {
+            Ok(Some(_)) => Ok(SessionStart::Created),
+            Ok(None) => Ok(SessionStart::Existing),
+            Err(offline_protocol::Error::SessionNotReady(_)) => Ok(SessionStart::Pending),
+            Err(error) => Err(error.into()),
+        }
+    }
+
+    /// Resolves a username to every device claiming it. `true` started the
+    /// lookup, `false` joined one in flight; either way exactly one
+    /// `username_resolved` event follows.
+    pub fn resolve_username(&mut self, username: &str) -> NodeResult {
+        Ok(self.protocol.resolve_username(username)?)
+    }
+
+    /// An invite for this device: its address and key, an optional petname,
+    /// and, when `sign` is set, a signature binding the petname to the key.
+    pub fn create_invite(&self, petname: Option<&str>, sign: bool) -> NodeResult {
+        let manager = self
+            .protocol
+            .mls_manager()
+            .ok_or_else(|| NodeError("initialize before creating an invite".to_string()))?;
+        let guard = manager
+            .read()
+            .map_err(|_| NodeError("MLS manager lock poisoned".to_string()))?;
+        let mls = |e: offline_protocol_mls::MlsError| NodeError(e.to_string());
+        let public_key = guard.get_identity_public_key().map_err(mls)?;
+        let address = offline_protocol_mls::MlsManager::derive_address(&public_key).map_err(mls)?;
+        let signature = if sign {
+            let payload = offline_protocol_mls::invite::invite_signing_payload(
+                &address,
+                &public_key,
+                petname,
+            )
+            .map_err(mls)?;
+            Some(guard.sign_data(&payload).map_err(mls)?)
+        } else {
+            None
+        };
+        offline_protocol_mls::invite::encode_invite(
+            &address,
+            &public_key,
+            petname,
+            signature.as_deref(),
+        )
+        .map_err(mls)
+    }
+
+    /// Asks the relays for `peer`'s published key packages when there is no
+    /// session with it yet and no live link to it. Nothing else does: a
+    /// session-less send only queues, and the engine's own key-package push
+    /// reaches only the mesh. That is a core gap on every binding, filed as
+    /// #633; once the engine asks for itself, this goes.
+    ///
+    /// A linked peer is skipped: the link carries the key-package exchange
+    /// within a tick, and a relay query would only publish which address
+    /// this node wants (its `#p` filter) for nothing.
+    fn reach_over_nostr(&self, peer: &str) {
+        if !self.nostr_enabled() || self.peers.contains_key(peer) || self.has_session(peer) {
+            return;
+        }
+        self.request_nostr_key_packages(peer);
+    }
+
+    fn has_session(&self, peer: &str) -> bool {
+        self.protocol
+            .mls_manager()
+            .and_then(|manager| manager.read().ok()?.has_session(peer).ok())
+            .unwrap_or(false)
+    }
+
     fn preamble(&self) -> NodeResult> {
         let manager = self
             .protocol
@@ -524,7 +675,7 @@ impl WifiDirect {
     }
 }
 
-fn push_event(queue: &Mutex>, event: &Event) {
+pub(crate) fn push_event(queue: &Mutex>, event: &Event) {
     let Ok(json) = event.to_json() else {
         return;
     };
diff --git a/crates/offline-protocol-web/src/nostr_host.rs b/crates/offline-protocol-web/src/nostr_host.rs
new file mode 100644
index 000000000..9bf941ec0
--- /dev/null
+++ b/crates/offline-protocol-web/src/nostr_host.rs
@@ -0,0 +1,394 @@
+//! The Nostr transport, driven from the worker (W12).
+//!
+//! No binding opens a relay socket in Rust: Swift, Kotlin and Python each hold
+//! their own and move text, and this module is the browser's copy of their
+//! host half. The worker holds the sockets; everything that decides what an
+//! event means happens here, through the one `NostrTransport` every binding
+//! shares.
+//!
+//! # Invariants
+//!
+//! - **The relays are the application's.** Nothing here names one, and the
+//!   worker refuses to enable Nostr without a list. A relay the SDK chose would
+//!   become the relay every application uses.
+//! - **A relay message is opened here, never in JavaScript.** The worker reads
+//!   a message's type, its subscription id and an `OK`'s verdict, the same
+//!   fields the mobile bridges read; an `EVENT` crosses whole and is decoded,
+//!   unsealed and attributed by [`Node::nostr_relay_event`].
+//! - **The receive path is the FFI's, step for step.** Unseal once, deliver
+//!   without a transport peer id (a Nostr key is not a protocol identity),
+//!   drain, advance the watermark only for bytes that decoded into a message,
+//!   then the reachability notice under the message's own sender. A different
+//!   order lets undecodable traffic to this device's public tag move the
+//!   watermark past history a relay still owes it.
+//! - **A resolution query belongs to the worker until it says so.** The query
+//!   ids handed out by [`Node::nostr_next_query`] are remembered until
+//!   [`Node::nostr_query_completed`], so an `EVENT` under one of them is a
+//!   record to open, and any other `EVENT` is a message.
+
+use std::collections::VecDeque;
+use std::sync::Arc;
+
+use base64::Engine as _;
+use offline_protocol::Event;
+use offline_protocol_transport::nostr::ResolvedRecord;
+use offline_protocol_transport::{NostrTransport, Transport, TransportStatus, TransportType};
+use serde_json::Value;
+use tracing::warn;
+
+use crate::node::{push_event, Node, NodeError, NodeResult};
+
+/// Resolution queries remembered at once, at most. The transport bounds its
+/// own queue at 64; this is the worker's copy of the ids it handed out, and a
+/// worker that never completes one must not grow it without bound.
+const MAX_OPEN_QUERIES: usize = 64;
+
+/// The largest relay message opened. The transport refuses to publish an
+/// event over 64 KiB and refuses a resolution record over the same size, so
+/// this leaves room for the `["EVENT", "", ...]` wrapper only.
+const MAX_RELAY_MESSAGE_BYTES: usize = 64 * 1024 + 256;
+
+/// What an application turns on when it gives the node relays.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct NostrOptions {
+    /// Publish key packages and resolve peers' (cold first contact).
+    pub cold_contact: bool,
+    /// Publish this profile as a username, and resolve others'. Off unless
+    /// asked for: it binds a readable name to an address in a public place.
+    pub username_discovery: bool,
+}
+
+impl Default for NostrOptions {
+    fn default() -> Self {
+        Self {
+            cold_contact: true,
+            username_discovery: false,
+        }
+    }
+}
+
+/// The worker's side of the transport: whether a relay is live, and the
+/// queries it holds.
+#[derive(Debug, Default)]
+pub(crate) struct NostrHost {
+    pub(crate) live: bool,
+    queries: VecDeque,
+}
+
+/// One signed event for the worker to publish.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct OutboundEvent {
+    /// The engine's id for what this event carries. The worker reports the
+    /// relays' verdict under it.
+    pub message_id: String,
+    /// The Nostr event id the relays' `OK` names.
+    pub event_id: String,
+    /// The complete `["EVENT", {...}]` text, sent verbatim.
+    pub event_json: String,
+}
+
+/// One resolution query for the worker to send to every live relay.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct OutboundQuery {
+    /// Also the subscription id the query runs under.
+    pub query_id: String,
+    /// The complete `["REQ", ...]` text.
+    pub req_json: String,
+}
+
+impl Node {
+    /// The `REQ` text that receives this device's messages, under
+    /// `subscription_id`. The worker sends it on every connect: its `since`
+    /// moves with the persisted receive watermark.
+    pub fn nostr_subscription(&self, subscription_id: &str) -> NodeResult {
+        self.with_nostr(|nostr| nostr.create_subscription(subscription_id))?
+            .map_err(|e| NodeError(e.to_string()))
+    }
+
+    /// The worker has a live relay (`true`) or none (`false`). The first
+    /// relay up flushes the outbox, as the FFI does.
+    pub fn nostr_status_changed(&mut self, live: bool) -> NodeResult<()> {
+        // The slot first: a node with options but no address yet has no
+        // transport, and must not record a liveness nothing acted on.
+        let transport = self.nostr_slot()?;
+        let host = self
+            .nostr_host
+            .as_mut()
+            .ok_or_else(|| NodeError("Nostr is not enabled on this node".to_string()))?;
+        let was_live = host.live;
+        host.live = live;
+        transport.on_status_changed(if live {
+            TransportStatus::Available
+        } else {
+            TransportStatus::Disconnected
+        });
+        if live && !was_live {
+            self.protocol.flush_outbox_all();
+        }
+        if live != was_live {
+            push_event(
+                &self.events,
+                &if live {
+                    Event::TransportSwitched {
+                        from: None,
+                        to: "Nostr".to_string(),
+                        reason: "Connected to Nostr relays".to_string(),
+                    }
+                } else {
+                    Event::TransportSwitched {
+                        from: Some("Nostr".to_string()),
+                        to: "None".to_string(),
+                        reason: "Disconnected from Nostr relays".to_string(),
+                    }
+                },
+            );
+        }
+        Ok(())
+    }
+
+    /// The next event to publish, while a relay is live. Key-package and
+    /// discovery publications come first, then messages, in the transport's
+    /// order.
+    pub fn nostr_next_event(&mut self) -> Option {
+        if !self.nostr_host.as_ref()?.live {
+            return None;
+        }
+        let transport = self.nostr_slot().ok()?;
+        let nostr = transport.as_any().downcast_ref::()?;
+        match nostr.get_next_signed_event() {
+            Ok(Some(signed)) => Some(OutboundEvent {
+                message_id: signed.message_id,
+                event_id: signed.event_id,
+                event_json: signed.event_json,
+            }),
+            Ok(None) => None,
+            Err(error) => {
+                warn!(%error, "nostr: could not sign an outbound event");
+                None
+            }
+        }
+    }
+
+    /// A relay accepted the event carrying `message_id`.
+    pub fn nostr_confirm_sent(&mut self, message_id: &str) {
+        if let Err(error) = self.protocol.on_transport_send_confirmed(message_id) {
+            warn!(%message_id, %error, "nostr: could not apply a send confirmation");
+        }
+        if let Ok(transport) = self.nostr_slot() {
+            transport.confirm_sent(message_id);
+        }
+    }
+
+    /// Every relay the event carrying `message_id` went to refused it, or
+    /// none could take it.
+    pub fn nostr_send_failed(&mut self, message_id: &str, reason: Option) {
+        let reason = reason.or_else(|| Some("Nostr transport send failed".to_string()));
+        if let Err(error) = self.protocol.on_transport_send_failed_via(
+            message_id,
+            reason,
+            Some(TransportType::Nostr),
+        ) {
+            warn!(%message_id, %error, "nostr: could not apply a send failure");
+        }
+        if let Ok(transport) = self.nostr_slot() {
+            transport.report_send_failure(message_id);
+        }
+    }
+
+    /// The next resolution query, while a relay is live. A username query
+    /// opens its accumulator here, at mint time, so a name nobody claims
+    /// still answers.
+    pub fn nostr_next_query(&mut self) -> Option {
+        if !self.nostr_host.as_ref()?.live {
+            return None;
+        }
+        let transport = self.nostr_slot().ok()?;
+        let nostr = transport.as_any().downcast_ref::()?;
+        let query = match nostr.next_query() {
+            Ok(Some(query)) => query,
+            Ok(None) => return None,
+            Err(error) => {
+                warn!(%error, "nostr: could not build a resolution query");
+                return None;
+            }
+        };
+        if let Some(username) = query.discovery_username.clone() {
+            self.protocol
+                .begin_username_resolution(query.query_id.clone(), username);
+        }
+        let host = self.nostr_host.as_mut()?;
+        if host.queries.len() >= MAX_OPEN_QUERIES {
+            host.queries.pop_front();
+        }
+        host.queries.push_back(query.query_id.clone());
+        Some(OutboundQuery {
+            query_id: query.query_id,
+            req_json: query.req_json,
+        })
+    }
+
+    /// Every relay the query went to has answered (or the worker stopped
+    /// waiting). A username resolution emits its one `username_resolved`
+    /// event here.
+    pub fn nostr_query_completed(&mut self, query_id: &str) {
+        if let Some(host) = self.nostr_host.as_mut() {
+            host.queries.retain(|open| open != query_id);
+        }
+        let Ok(transport) = self.nostr_slot() else {
+            return;
+        };
+        let Some(nostr) = transport.as_any().downcast_ref::() else {
+            return;
+        };
+        if nostr.complete_query(query_id).is_some() {
+            self.protocol.flush_username_resolution(query_id);
+        }
+    }
+
+    /// One `["EVENT", , {...}]` a relay sent, whole. Anything
+    /// that does not open (another device's traffic, a squatter on a public
+    /// tag, junk) is dropped: a public tag returns whatever a relay holds
+    /// there, so none of that is an error.
+    pub fn nostr_relay_event(&mut self, relay_message: &str) -> NodeResult<()> {
+        if relay_message.len() > MAX_RELAY_MESSAGE_BYTES {
+            warn!(
+                len = relay_message.len(),
+                "nostr: ignoring an oversized relay message"
+            );
+            return Ok(());
+        }
+        let Ok(Value::Array(parts)) = serde_json::from_str::(relay_message) else {
+            return Ok(());
+        };
+        let (Some(Value::String(kind)), Some(Value::String(subscription)), Some(event)) =
+            (parts.first(), parts.get(1), parts.get(2))
+        else {
+            return Ok(());
+        };
+        if kind != "EVENT" || !event.is_object() {
+            return Ok(());
+        }
+        let is_query = self
+            .nostr_host
+            .as_ref()
+            .is_some_and(|host| host.queries.iter().any(|open| open == subscription));
+        if is_query {
+            self.open_query_event(subscription, event)
+        } else {
+            self.receive_message_event(event)
+        }
+    }
+
+    /// Asks the relays for `peer`'s published key packages. The transport
+    /// rate-limits the request per peer, so a caller may repeat it.
+    pub(crate) fn request_nostr_key_packages(&self, peer: &str) -> bool {
+        self.with_nostr(|nostr| nostr.request_peer_key_packages(peer))
+            .unwrap_or(false)
+    }
+
+    /// Whether Nostr is wired on this node.
+    pub(crate) fn nostr_enabled(&self) -> bool {
+        self.nostr_host.is_some()
+    }
+
+    fn open_query_event(&mut self, query_id: &str, event: &Value) -> NodeResult<()> {
+        let event_json = event.to_string();
+        let opened = self
+            .with_nostr(|nostr| nostr.open_query_event(query_id, &event_json))?
+            .map_err(|e| NodeError(e.to_string()))?;
+        match opened {
+            Some(ResolvedRecord::KeyPackage { plaintext, .. }) => {
+                // Anyone can publish a record that opens: its seal key derives
+                // from the queried address. One that then does not decode is
+                // junk on a public tag, dropped like any other, not an error
+                // the page hears.
+                if let Err(error) = self.protocol.handle_resolved_key_package(&plaintext) {
+                    warn!(%error, "nostr: a resolved key-package record did not decode");
+                    return Ok(());
+                }
+                while self.protocol.receive_message().is_some() {}
+            }
+            Some(ResolvedRecord::Discovery {
+                username: _,
+                author,
+                plaintext,
+            }) => {
+                self.protocol
+                    .handle_resolved_discovery_record(query_id, &author, &plaintext);
+            }
+            None => {}
+        }
+        Ok(())
+    }
+
+    fn receive_message_event(&mut self, event: &Value) -> NodeResult<()> {
+        let (Some(sender), Some(content)) = (
+            event.get("pubkey").and_then(Value::as_str),
+            event.get("content").and_then(Value::as_str),
+        ) else {
+            return Ok(());
+        };
+        let created_at = event.get("created_at").and_then(Value::as_i64);
+        let transport = self.nostr_slot()?;
+        let Some(nostr) = transport.as_any().downcast_ref::() else {
+            return Ok(());
+        };
+        // Only the legacy unsealed form can match: a gift wrap is signed by a
+        // key used once, and nothing on it names its author.
+        if sender == nostr.public_key_hex() {
+            return Ok(());
+        }
+        let raw = base64::engine::general_purpose::STANDARD
+            .decode(content)
+            .unwrap_or_else(|_| content.as_bytes().to_vec());
+        let data = nostr.unseal_event_payload(sender, &raw).into_owned();
+        // The protocol sender is the identity; the Nostr key only unseals.
+        let real_sender = transport
+            .deserialize_message(&data)
+            .ok()
+            .map(|message| message.sender.as_str().to_string());
+        transport
+            .on_data_received(data)
+            .map_err(|e| NodeError(format!("Failed to process nostr message: {e}")))?;
+        while self.protocol.receive_message().is_some() {}
+        let Some(peer) = real_sender else {
+            warn!(nostr_pubkey = %sender, "nostr: undecodable frame; no discovery");
+            return Ok(());
+        };
+        if let Some(created_at) = created_at {
+            self.protocol.note_nostr_event_received(created_at);
+        }
+        if peer.is_empty() {
+            return Ok(());
+        }
+        let blocked = self.protocol.is_user_blocked(&peer);
+        self.protocol.on_neighbor_discovered(&peer);
+        if !blocked {
+            push_event(
+                &self.events,
+                &Event::NeighborDiscovered {
+                    peer_id: peer,
+                    transport: "Nostr".to_string(),
+                    rssi: None,
+                },
+            );
+        }
+        Ok(())
+    }
+
+    fn nostr_slot(&self) -> NodeResult> {
+        self.protocol
+            .transport_manager()
+            .get_transport(TransportType::Nostr)
+            .ok_or_else(|| NodeError("Nostr is not enabled on this node".to_string()))
+    }
+
+    fn with_nostr(&self, f: impl FnOnce(&NostrTransport) -> T) -> NodeResult {
+        let transport = self.nostr_slot()?;
+        let nostr = transport
+            .as_any()
+            .downcast_ref::()
+            .ok_or_else(|| NodeError("the Nostr slot holds another transport".to_string()))?;
+        Ok(f(nostr))
+    }
+}
diff --git a/crates/offline-protocol-web/src/nostr_tests.rs b/crates/offline-protocol-web/src/nostr_tests.rs
new file mode 100644
index 000000000..3e997b6a9
--- /dev/null
+++ b/crates/offline-protocol-web/src/nostr_tests.rs
@@ -0,0 +1,578 @@
+//! Two engines that share nothing but a relay, on one host.
+//!
+//! The relay here is the smallest one that behaves like a real relay for
+//! everything the transport relies on: it stores what is not ephemeral,
+//! replaces an addressable event by `(kind, pubkey, d)`, filters on `kinds`,
+//! `since`, `limit` and any `#` tag, answers a query with what it holds, and
+//! forwards a new event to every live subscription it matches. The worker's
+//! part (sockets, `OK` correlation, end-of-stored-events per relay) is the
+//! browser page's to test; this is the engine's.
+
+use std::time::Duration;
+
+use serde_json::Value;
+
+use crate::node::{Node, NodeOptions};
+use crate::nostr_host::NostrOptions;
+
+#[derive(Default)]
+struct Relay {
+    stored: Vec,
+}
+
+impl Relay {
+    fn publish(&mut self, event: &Value) {
+        let kind = event["kind"].as_u64().unwrap_or(0);
+        if (20_000..30_000).contains(&kind) {
+            return;
+        }
+        if (30_000..40_000).contains(&kind) {
+            let d = tag(event, "d");
+            self.stored.retain(|held| {
+                !(held["kind"] == event["kind"]
+                    && held["pubkey"] == event["pubkey"]
+                    && tag(held, "d") == d)
+            });
+        }
+        self.stored.push(event.clone());
+    }
+
+    fn matching(&self, filter: &Value) -> Vec {
+        let mut found: Vec = self
+            .stored
+            .iter()
+            .filter(|event| matches(filter, event))
+            .cloned()
+            .collect();
+        found.sort_by_key(|event| std::cmp::Reverse(event["created_at"].as_i64()));
+        if let Some(limit) = filter["limit"].as_u64() {
+            found.truncate(limit as usize);
+        }
+        found
+    }
+}
+
+fn tag(event: &Value, name: &str) -> Option {
+    let found = event["tags"].as_array()?.iter().find(|t| t[0] == name)?;
+    found[1].as_str().map(str::to_string)
+}
+
+fn matches(filter: &Value, event: &Value) -> bool {
+    if let Some(kinds) = filter["kinds"].as_array() {
+        if !kinds.contains(&event["kind"]) {
+            return false;
+        }
+    }
+    if let (Some(since), Some(at)) = (filter["since"].as_i64(), event["created_at"].as_i64()) {
+        if at < since {
+            return false;
+        }
+    }
+    for (key, wanted) in filter.as_object().into_iter().flatten() {
+        let Some(name) = key.strip_prefix('#') else {
+            continue;
+        };
+        let held: Vec<&Value> = event["tags"]
+            .as_array()
+            .into_iter()
+            .flatten()
+            .filter(|t| t[0] == name)
+            .map(|t| &t[1])
+            .collect();
+        let wanted = wanted.as_array().cloned().unwrap_or_default();
+        if !wanted.iter().any(|w| held.contains(&w)) {
+            return false;
+        }
+    }
+    true
+}
+
+struct Peer {
+    node: Node,
+    address: String,
+    events: Vec,
+    /// The standing subscription's filter, once the peer is "connected".
+    subscription: Option,
+}
+
+const SUBSCRIPTION: &str = "inbox";
+
+fn peer(profile: &str, username_discovery: bool) -> Peer {
+    let options = NodeOptions {
+        nostr: Some(NostrOptions {
+            cold_contact: true,
+            username_discovery,
+        }),
+    };
+    let mut node = Node::with_options("web-nostr-test", profile, options).expect("node");
+    let address = node.initialize().expect("initialize");
+    node.start().expect("start");
+    Peer {
+        node,
+        address,
+        events: Vec::new(),
+        subscription: None,
+    }
+}
+
+/// What a worker does when its first relay comes up: report it live, then
+/// subscribe, receiving what the relay already holds for this device.
+fn connect(relay: &Relay, p: &mut Peer) {
+    p.node.nostr_status_changed(true).expect("status");
+    let req: Value =
+        serde_json::from_str(&p.node.nostr_subscription(SUBSCRIPTION).expect("req")).expect("json");
+    assert_eq!(req[0], "REQ");
+    assert_eq!(req[1], SUBSCRIPTION);
+    let filter = req[2].clone();
+    for event in relay.matching(&filter) {
+        deliver(p, SUBSCRIPTION, &event);
+    }
+    p.subscription = Some(filter);
+}
+
+fn deliver(p: &mut Peer, subscription: &str, event: &Value) {
+    let message = serde_json::json!(["EVENT", subscription, event]).to_string();
+    p.node.nostr_relay_event(&message).expect("relay event");
+}
+
+/// One pass of what the worker and the relay do between them, then a tick.
+fn step(relay: &mut Relay, peers: &mut [&mut Peer]) {
+    for i in 0..peers.len() {
+        while let Some(out) = peers[i].node.nostr_next_event() {
+            let sent: Value = serde_json::from_str(&out.event_json).expect("event json");
+            assert_eq!(sent[0], "EVENT");
+            let event = sent[1].clone();
+            assert_eq!(event["id"], out.event_id.as_str(), "OK names this id");
+            relay.publish(&event);
+            peers[i].node.nostr_confirm_sent(&out.message_id);
+            for other in peers.iter_mut() {
+                let live = other
+                    .subscription
+                    .as_ref()
+                    .is_some_and(|filter| matches(filter, &event));
+                if live {
+                    deliver(other, SUBSCRIPTION, &event);
+                }
+            }
+        }
+        while let Some(query) = peers[i].node.nostr_next_query() {
+            let req: Value = serde_json::from_str(&query.req_json).expect("req json");
+            assert_eq!(req[1], query.query_id.as_str());
+            for event in relay.matching(&req[2]) {
+                deliver(peers[i], &query.query_id, &event);
+            }
+            peers[i].node.nostr_query_completed(&query.query_id);
+        }
+    }
+    for p in peers.iter_mut() {
+        p.node.process().expect("process");
+        while let Some(json) = p.node.poll_event() {
+            p.events
+                .push(serde_json::from_str(&json).expect("event json"));
+        }
+    }
+}
+
+fn run_until(
+    relay: &mut Relay,
+    a: &mut Peer,
+    b: &mut Peer,
+    mut done: impl FnMut(&Peer, &Peer) -> bool,
+) -> bool {
+    for _ in 0..400 {
+        step(relay, &mut [&mut *a, &mut *b]);
+        if done(a, b) {
+            return true;
+        }
+        std::thread::sleep(Duration::from_millis(25));
+    }
+    false
+}
+
+/// A few passes, so both peers' publications reach the relay before a test
+/// looks anything up: a query is answered with what the relay holds when it
+/// arrives, as a real relay answers it.
+fn settle(relay: &mut Relay, a: &mut Peer, b: &mut Peer) {
+    for _ in 0..4 {
+        step(relay, &mut [&mut *a, &mut *b]);
+    }
+}
+
+#[test]
+fn nothing_is_published_until_a_relay_is_live() {
+    let mut a = peer("alice", false);
+    a.node.process().expect("process");
+    assert!(
+        a.node.nostr_next_event().is_none(),
+        "a key package went out with no relay to carry it"
+    );
+    a.node.nostr_status_changed(true).expect("status");
+    assert!(
+        a.node.nostr_next_event().is_some(),
+        "a live relay receives the key-package publications"
+    );
+}
+
+#[test]
+fn a_device_known_only_by_its_address_is_reached_over_nostr() {
+    let mut relay = Relay::default();
+    let (mut a, mut b) = (peer("alice", false), peer("bob", false));
+    connect(&relay, &mut a);
+    connect(&relay, &mut b);
+    // Bob's key packages reach the relay before Alice knows he exists.
+    settle(&mut relay, &mut a, &mut b);
+    let b_addr = b.address.clone();
+    a.node
+        .send_message(&b_addr, "found you through a relay", false)
+        .expect("send");
+    let a_addr = a.address.clone();
+    assert!(
+        run_until(&mut relay, &mut a, &mut b, |_, b| {
+            b.events.iter().any(|e| {
+                e["type"] == "message_received"
+                    && e["content"] == "found you through a relay"
+                    && e["encrypted"] == true
+                    && e["sender"] == a_addr.as_str()
+            })
+        }),
+        "the message never arrived; Alice saw {:?}",
+        a.events
+            .iter()
+            .map(|e| e["type"].clone())
+            .collect::>()
+    );
+    assert!(
+        b.events.iter().any(|e| e["type"] == "neighbor_discovered"
+            && e["peer_id"] == a_addr.as_str()
+            && e["transport"] == "Nostr"),
+        "the sender is announced under the address its message proved"
+    );
+}
+
+#[test]
+fn a_username_resolves_to_every_device_claiming_it() {
+    let mut relay = Relay::default();
+    let (mut a, mut b) = (peer("alice", true), peer("bobby", true));
+    connect(&relay, &mut a);
+    connect(&relay, &mut b);
+    settle(&mut relay, &mut a, &mut b);
+    assert!(a.node.resolve_username("bobby").expect("resolve"));
+    let b_addr = b.address.clone();
+    assert!(run_until(&mut relay, &mut a, &mut b, |a, _| {
+        a.events.iter().any(|e| {
+            e["type"] == "username_resolved"
+                && e["username"] == "bobby"
+                && e["claims"]
+                    .as_array()
+                    .is_some_and(|claims| claims.iter().any(|c| c["address"] == b_addr.as_str()))
+        })
+    }));
+}
+
+#[test]
+fn a_name_nobody_claims_still_answers() {
+    let mut relay = Relay::default();
+    let (mut a, mut b) = (peer("alice", true), peer("bob", true));
+    connect(&relay, &mut a);
+    connect(&relay, &mut b);
+    assert!(a.node.resolve_username("nobody-here").expect("resolve"));
+    assert!(run_until(&mut relay, &mut a, &mut b, |a, _| {
+        a.events.iter().any(|e| {
+            e["type"] == "username_resolved"
+                && e["username"] == "nobody-here"
+                && e["claims"].as_array().is_some_and(Vec::is_empty)
+        })
+    }));
+}
+
+#[test]
+fn an_invite_opens_to_the_address_that_made_it() {
+    let a = peer("alice", false);
+    let blob = a.node.create_invite(Some("Alice"), true).expect("invite");
+    let invite = offline_protocol_mls::invite::parse_invite(&blob).expect("parse");
+    assert_eq!(invite.address.to_string(), a.address);
+    assert_eq!(invite.petname.as_deref(), Some("Alice"));
+    assert!(invite.signed);
+}
+
+#[test]
+fn a_session_with_a_device_met_only_by_address_starts_pending() {
+    use crate::node::SessionStart;
+    let mut relay = Relay::default();
+    let (mut a, mut b) = (peer("alice", false), peer("bob", false));
+    connect(&relay, &mut a);
+    connect(&relay, &mut b);
+    settle(&mut relay, &mut a, &mut b);
+    let b_addr = b.address.clone();
+    assert_eq!(
+        a.node.establish_secure_session(&b_addr).expect("establish"),
+        SessionStart::Pending
+    );
+    assert!(run_until(&mut relay, &mut a, &mut b, |a, _| {
+        a.events
+            .iter()
+            .any(|e| e["type"] == "secure_session_established" && e["peer_id"] == b_addr.as_str())
+    }));
+    assert_eq!(
+        a.node.establish_secure_session(&b_addr).expect("establish"),
+        SessionStart::Existing
+    );
+}
+
+#[test]
+fn a_node_without_relays_has_no_nostr_slot() {
+    let mut node = Node::new("web-nostr-test", "plain").expect("node");
+    node.initialize().expect("initialize");
+    assert!(node.nostr_status_changed(true).is_err());
+    assert!(node.nostr_subscription(SUBSCRIPTION).is_err());
+    assert!(node.nostr_next_event().is_none());
+}
+
+/// Two Nostr nodes joined by a peer-stream link, with `b`'s frames handed to
+/// `a` one at a time and no further than the one that announces `b`: past
+/// that, the frames that follow form the session and the lookup path would
+/// never run.
+fn linked_pair() -> (Peer, Peer) {
+    let (mut a, mut b) = (peer("alice", false), peer("bob", false));
+    let la = a.node.open_link().expect("link");
+    b.node.open_link().expect("link");
+    while let Some((_, data)) = b.node.next_outbound() {
+        assert_eq!(a.node.link_received(la, &data), None);
+        while let Some(json) = a.node.poll_event() {
+            a.events
+                .push(serde_json::from_str(&json).expect("event json"));
+        }
+        let announced = a
+            .events
+            .iter()
+            .any(|e| e["type"] == "neighbor_discovered" && e["peer_id"] == b.address.as_str());
+        if announced {
+            return (a, b);
+        }
+    }
+    panic!("the link never announced the other end");
+}
+
+/// A peer this node holds a live link to forms its session over the link;
+/// asking the relays for its key packages would only publish which address
+/// this node wants. Without the link check in `reach_over_nostr` a query
+/// comes out here.
+#[test]
+fn a_linked_peer_is_not_looked_up_on_the_relays() {
+    let (mut a, b) = linked_pair();
+    a.node.nostr_status_changed(true).expect("status");
+    let started = a
+        .node
+        .establish_secure_session(&b.address)
+        .expect("establish");
+    assert_ne!(
+        started,
+        crate::node::SessionStart::Existing,
+        "the session formed before the call, so the lookup path never ran"
+    );
+    a.node
+        .send_message(&b.address, "over the link", false)
+        .expect("send");
+    assert!(
+        a.node.nostr_next_query().is_none(),
+        "a linked peer was looked up on the relays"
+    );
+
+    // The control: a peer with no link is looked up.
+    let stranger = peer("carol", false);
+    a.node
+        .establish_secure_session(&stranger.address)
+        .expect("establish");
+    assert!(a.node.nostr_next_query().is_some());
+}
+
+/// A liveness report before the address exists is refused and not kept: the
+/// first report after it must still be the edge that flushes the outbox and
+/// tells the page.
+#[test]
+fn liveness_reported_before_an_address_is_not_kept() {
+    let options = NodeOptions {
+        nostr: Some(NostrOptions::default()),
+    };
+    let mut node = Node::with_options("web-nostr-test", "early", options).expect("node");
+    assert!(node.nostr_status_changed(true).is_err());
+    node.initialize().expect("initialize");
+    node.start().expect("start");
+    node.nostr_status_changed(true).expect("status");
+    let mut switched = false;
+    while let Some(json) = node.poll_event() {
+        let event: Value = serde_json::from_str(&json).expect("event json");
+        switched |= event["type"] == "transport_switched" && event["to"] == "Nostr";
+    }
+    assert!(
+        switched,
+        "the first real liveness report was taken as a repeat"
+    );
+}
+
+/// Going down is said once, and nothing is handed out to publish until a
+/// relay is live again; what was queued is still there then.
+#[test]
+fn going_down_stops_publication_and_says_so() {
+    let mut a = peer("alice", false);
+    // The tick queues the key-package publications.
+    a.node.process().expect("process");
+    a.node.nostr_status_changed(true).expect("status");
+    assert!(a.node.nostr_next_event().is_some());
+    while a.node.poll_event().is_some() {}
+
+    a.node.nostr_status_changed(false).expect("status");
+    a.node.nostr_status_changed(false).expect("repeat");
+    let downs = std::iter::from_fn(|| a.node.poll_event())
+        .map(|json| serde_json::from_str::(&json).expect("event json"))
+        .filter(|e| e["type"] == "transport_switched" && e["to"] == "None")
+        .count();
+    assert_eq!(
+        downs, 1,
+        "going down is one event, however often it is said"
+    );
+    assert!(a.node.nostr_next_event().is_none());
+
+    a.node.nostr_status_changed(true).expect("status");
+    assert!(
+        a.node.nostr_next_event().is_some(),
+        "the publications still queued went out once a relay was back"
+    );
+}
+
+/// Whatever a relay sends that does not open is dropped without an error,
+/// an event, or a step of the receive watermark. Anyone can address this
+/// device's public routing tag, so junk there must not move the window past
+/// history a relay still owes it.
+#[test]
+fn junk_from_a_relay_moves_nothing() {
+    use offline_protocol_transport::{NostrTransport, TransportType};
+
+    let mut a = peer("alice", false);
+    a.node.nostr_status_changed(true).expect("status");
+    while a.node.poll_event().is_some() {}
+    let watermark = |node: &Node| {
+        node.protocol
+            .transport_manager()
+            .get_transport(TransportType::Nostr)
+            .and_then(|t| {
+                t.as_any()
+                    .downcast_ref::()
+                    .map(NostrTransport::receive_watermark_secs)
+            })
+            .expect("nostr slot")
+    };
+    let before = watermark(&a.node);
+    // Through the seam the browser can read (W4): the clock guard holds tests
+    // to it as well.
+    let now = crate::signal::now_unix_secs();
+    let oversized = format!(
+        "[\"EVENT\",\"{SUBSCRIPTION}\",{{\"content\":\"{}\"}}]",
+        "A".repeat(70 * 1024)
+    );
+    for junk in [
+        "not json".to_string(),
+        "{}".to_string(),
+        "[\"EVENT\"]".to_string(),
+        format!("[\"EVENT\",\"{SUBSCRIPTION}\",\"not an object\"]"),
+        format!(
+            "[\"EVENT\",\"{SUBSCRIPTION}\",{{\"pubkey\":\"{}\",\"content\":\"AAAA\",\"created_at\":{now}}}]",
+            "ab".repeat(32)
+        ),
+        oversized,
+    ] {
+        a.node.nostr_relay_event(&junk).expect("junk is not an error");
+    }
+    a.node.process().expect("process");
+    assert_eq!(
+        watermark(&a.node),
+        before,
+        "junk moved the receive watermark"
+    );
+    let events: Vec = std::iter::from_fn(|| a.node.poll_event())
+        .map(|json| serde_json::from_str(&json).expect("event json"))
+        .collect();
+    assert!(
+        !events
+            .iter()
+            .any(|e| e["type"] == "message_received" || e["type"] == "neighbor_discovered"),
+        "junk surfaced as {events:?}"
+    );
+}
+
+/// A key-package record anyone can publish opens, because its seal key
+/// derives from the queried address, and then need not decode. That is junk
+/// on a public tag like any other: dropped, not an error the page hears.
+#[test]
+fn a_record_that_opens_but_does_not_decode_is_not_an_error() {
+    use crate::node::SessionStart;
+    use offline_protocol_transport::nostr_crypto::{
+        record_seal_keypair_for_address, NostrEvent, NostrKeypair,
+    };
+
+    let mut a = peer("alice", false);
+    let b = peer("bob", false);
+    a.node.nostr_status_changed(true).expect("status");
+    assert_eq!(
+        a.node
+            .establish_secure_session(&b.address)
+            .expect("establish"),
+        SessionStart::Pending
+    );
+    let query = a.node.nostr_next_query().expect("a lookup for bob");
+    let seal =
+        record_seal_keypair_for_address(&b.address.parse().expect("address")).expect("seal key");
+    let squatter = NostrKeypair::generate_ephemeral().expect("key");
+    let record = NostrEvent::create_key_package_publication(
+        &squatter,
+        "routing-tag",
+        seal.public_key_hex(),
+        "slot",
+        b"not a protocol message",
+    )
+    .expect("record");
+    let sent: Value =
+        serde_json::from_str(&record.to_relay_message().expect("relay message")).expect("json");
+    let message = serde_json::json!(["EVENT", query.query_id, sent[1]]).to_string();
+    a.node
+        .nostr_relay_event(&message)
+        .expect("a record that opens and does not decode is not an error");
+}
+
+fn nostr_node(cold_contact: bool, username_discovery: bool, profile: &str) -> Node {
+    let options = NodeOptions {
+        nostr: Some(NostrOptions {
+            cold_contact,
+            username_discovery,
+        }),
+    };
+    let mut node = Node::with_options("web-nostr-test", profile, options).expect("node");
+    node.initialize().expect("initialize");
+    node.start().expect("start");
+    node.nostr_status_changed(true).expect("status");
+    node
+}
+
+/// With cold contact off the node never asks the relays for anyone's key
+/// packages: not for a session, and not for a send.
+#[test]
+fn without_cold_contact_nobody_is_looked_up() {
+    let mut node = nostr_node(false, false, "quiet");
+    let stranger = peer("carol", false);
+    node.establish_secure_session(&stranger.address)
+        .expect("establish");
+    let _ = node.send_message(&stranger.address, "hello", false);
+    assert!(
+        node.nostr_next_query().is_none(),
+        "a node without cold contact looked a peer up"
+    );
+}
+
+/// The directory is off unless asked for, and a lookup on a node without it
+/// is refused rather than left to wait for an answer that never comes.
+#[test]
+fn without_the_directory_a_lookup_is_refused() {
+    let mut node = nostr_node(true, false, "private");
+    assert!(node.resolve_username("bobby").is_err());
+    assert!(node.nostr_next_query().is_none());
+}
diff --git a/crates/offline-protocol-web/src/tests.rs b/crates/offline-protocol-web/src/tests.rs
index f2ce2adda..3cef74bd3 100644
--- a/crates/offline-protocol-web/src/tests.rs
+++ b/crates/offline-protocol-web/src/tests.rs
@@ -399,6 +399,146 @@ fn the_browser_tests_run_in_ci() {
     }
 }
 
+/// A file named as a test fixture: a `test` or `tests` word in its name
+/// (`nostr_tests.rs`, `relay.test.ts`, `test-utils.js`) or a `Test`/`Tests`
+/// suffix on its stem (`ConsumerCheckTest.java`). A word, not a substring: a
+/// shipped `docs/latest-relays.md` or `contest.md` is still read.
+fn is_test_file(name: &str) -> bool {
+    let stem = name.split('.').next().unwrap_or(name);
+    name.split(['_', '-', '.'])
+        .any(|word| word.eq_ignore_ascii_case("test") || word.eq_ignore_ascii_case("tests"))
+        || stem.ends_with("Test")
+        || stem.ends_with("Tests")
+}
+
+#[test]
+fn the_relay_guard_skips_test_files_by_word() {
+    for fixture in [
+        "nostr_tests.rs",
+        "tests.rs",
+        "relay.test.ts",
+        "test-utils.js",
+        "test_bridge.py",
+        "ConsumerCheckTest.java",
+        "BridgeTests.swift",
+    ] {
+        assert!(
+            is_test_file(fixture),
+            "not taken for a test file: {fixture}"
+        );
+    }
+    for shipped in [
+        "latest-relays.md",
+        "contest.md",
+        "attestation.rs",
+        "nostr.md",
+    ] {
+        assert!(!is_test_file(shipped), "taken for a test file: {shipped}");
+    }
+}
+
+/// No shipped file names a relay. The relays a node uses are the
+/// application's choice, through its configuration, and a URL in a doc, a
+/// doc comment or an example is a default by another name: it becomes the
+/// relay every application copies. Test fixtures may name hosts, and the
+/// reserved names (`localhost`, loopback, `*.example`, `example.com` and its
+/// siblings, `*.invalid`, `*.test`) are placeholders, not relays.
+#[test]
+fn no_shipped_file_names_a_relay() {
+    let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
+    if read_repo_file(&root.join("Cargo.lock")).is_none() {
+        // A published .crate archive has no workspace around it.
+        return;
+    }
+    const SKIPPED_DIRS: &[&str] = &[
+        ".git",
+        "target",
+        "node_modules",
+        "pkg",
+        "build",
+        "Pods",
+        "DerivedData",
+        ".gradle",
+        "venv",
+        ".venv",
+        "dist",
+        "test",
+        "tests",
+        "__tests__",
+        "testdata",
+        "changelog",
+    ];
+    const SCANNED: &[&str] = &[
+        "md", "rs", "ts", "tsx", "js", "mjs", "swift", "kt", "py", "html", "toml", "yml", "yaml",
+        "dart", "rb", "java", "c", "h",
+    ];
+    let reserved = |host: &str| {
+        let host = host.trim_end_matches('.').to_ascii_lowercase();
+        host == "localhost"
+            || host.starts_with("127.")
+            || ["example", "invalid", "test", "localhost"]
+                .iter()
+                .any(|tld| host.ends_with(&format!(".{tld}")))
+            || ["example.com", "example.org", "example.net"]
+                .iter()
+                .any(|name| host == *name || host.ends_with(&format!(".{name}")))
+    };
+    let mut found = Vec::new();
+    let mut stack = vec![root.clone()];
+    while let Some(dir) = stack.pop() {
+        let Ok(entries) = std::fs::read_dir(&dir) else {
+            continue;
+        };
+        for entry in entries.flatten() {
+            let path = entry.path();
+            let name = entry.file_name().to_string_lossy().to_string();
+            if path.is_dir() {
+                if !SKIPPED_DIRS.contains(&name.as_str()) {
+                    stack.push(path);
+                }
+                continue;
+            }
+            let scanned = path
+                .extension()
+                .and_then(|e| e.to_str())
+                .is_some_and(|e| SCANNED.contains(&e));
+            if !scanned || is_test_file(&name) || name == "CHANGELOG.md" {
+                continue;
+            }
+            let Some(text) = read_repo_file(&path) else {
+                continue;
+            };
+            for (number, line) in text.lines().enumerate() {
+                for scheme in ["wss://", "ws://"] {
+                    let mut rest = line;
+                    while let Some(at) = rest.find(scheme) {
+                        let after = &rest[at + scheme.len()..];
+                        let host: String = after
+                            .chars()
+                            .take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-')
+                            .collect();
+                        let last = host.rsplit('.').next().unwrap_or("");
+                        let public = host.contains('.')
+                            && last.len() >= 2
+                            && last.chars().all(|c| c.is_ascii_alphabetic());
+                        if public && !reserved(&host) {
+                            let shown = path.strip_prefix(&root).unwrap_or(&path).display();
+                            found.push(format!("{shown}:{}: {scheme}{host}", number + 1));
+                        }
+                        rest = after;
+                    }
+                }
+            }
+        }
+    }
+    assert!(
+        found.is_empty(),
+        "shipped files name relays; the application chooses its own, so show a \
+         placeholder (wss://relay.example) instead:\n{}",
+        found.join("\n")
+    );
+}
+
 /// The page remembers a signalling session until the newest offer it has
 /// seen for it stops being fresh, by the bound the worker reports, rather
 /// than for a fixed time from first sight. A fixed hold forgets a session
diff --git a/docs/bridges/README.md b/docs/bridges/README.md
index 360eb8f90..ec21edbce 100644
--- a/docs/bridges/README.md
+++ b/docs/bridges/README.md
@@ -192,9 +192,9 @@ is what pins the call ordering that the Rust guard
 textually for the other two: the declaration is written before the outbox
 flush, and a Python test can simply observe the calls happen in that order.
 
-**The resolution-query completion deadline** is the sixth, and the newest: the
-Swift and Kotlin `NostrQueryTracker` each hold a copy of
-`COMPLETION_TIMEOUT_MS`, and a Rust guard reads both sources. Unlike the others
+**The resolution-query completion deadline** is the sixth: the Swift and
+Kotlin `NostrQueryTracker` and the browser's `nostr-relays.js` each hold a
+copy of `COMPLETION_TIMEOUT_MS`, and a Rust guard reads all three sources. Unlike the others
 this one is pinned for a *relationship* rather than a spelling: it has to stay
 below the engine's 30s resolution sweep, because a bridge that gives up later
 than the engine hands every silent-relay resolution to the sweep instead of to
diff --git a/docs/bridges/web.md b/docs/bridges/web.md
index 2da3319b3..79e6b1c1f 100644
--- a/docs/bridges/web.md
+++ b/docs/bridges/web.md
@@ -4,7 +4,10 @@ Covers the browser binding: the engine compiled for `wasm32-unknown-unknown`
 in `crates/offline-protocol-web`, and the JavaScript in `bindings/web` that
 runs it. Experimental. A node's identity and groups are durable, in sealed
 origin-private files; tabs of one origin link over a `BroadcastChannel`, and
-devices on one network over WebRTC, set up with two codes and no server.
+devices on one network over WebRTC, set up with two codes and no server. A
+node the application gives relays also carries the Nostr transport, which
+reaches any device by its address and resolves usernames, mobile ones
+included (W12).
 
 Read [the shared contract](README.md) first. This document covers what is
 specific to a page that runs the engine.
@@ -348,6 +351,95 @@ had no way to tell: a phone that had slept was unreachable until the app
 restarted. A join keeps none of this; it lasts seconds, and fails on its
 deadline.
 
+### W12. The Nostr transport is driven from the worker, over the application's relays
+
+A node started with `nostr: { relays }` registers the same `NostrTransport`
+every binding uses, once its address exists, and the worker holds the relay
+sockets beside the engine (`src/nostr-relays.js`). No binding opens a relay
+socket in Rust: the Swift, Kotlin and Python drivers each hold their own and
+move text, and this is the browser's copy of their host half
+(`crates/offline-protocol-web/src/nostr_host.rs`). The sockets sit in the
+worker rather than on the page, as the signalling ones do (W11), because
+nothing here hands bytes to a page-only object: a relay message goes to the
+engine and nowhere else.
+
+**The relays are the application's.** `start` refuses `nostr` without a
+non-empty list of `ws://` or `wss://` URLs, and no file the project ships
+names one (`no_shipped_file_names_a_relay`). A relay the SDK named would
+become the relay every application uses: a dependency nobody chose, and one
+place that sees every application's traffic timing.
+
+**The worker reads what every driver reads, and no more.** A relay
+message's type and subscription id, and an `OK`'s event id and verdict. An
+`EVENT` crosses to the engine whole, which decodes, unseals and attributes it
+along the FFI's own path: unseal once, deliver with no transport peer id (a
+Nostr key is not a protocol identity), drain, advance the receive watermark
+only for bytes that decoded into a message, then announce the message's own
+sender. In another order, junk addressed to this device's public routing tag
+would move the watermark past history a relay still owes it.
+
+**A relay is live while it keeps answering.** It is live from the first
+`EOSE` of this device's subscription, and every 60 s it is sent a probe: a
+`REQ` for an event id nobody can have, closed again on its `EOSE`. A relay
+that has not answered within 10 s is dropped and redialled, from 1 s
+doubling to 30 s, and coming back `online` redials at once. The probe is
+never the subscription sent again under its own id: a relay answers that
+replace with every stored event since the receive watermark, which sits more
+than an hour back because gift wraps backdate `created_at`, so it would
+replay that history from every relay every minute. The engine hears "live"
+when the first relay is, which flushes the outbox, and "not live" when the
+last one stops, before anything that relay held is failed or completed: a
+flush in between would hand the next query to no relay and spend that peer's
+five-minute resolution limit on an empty answer. Nothing is published to a
+relay that is not live, and a relay the browser will not dial (a `ws://` URL
+from an `https:` page) or that closes the subscription reaches the page as
+an `error` event, once until that relay confirms a subscription again: a
+relay that wants authentication refuses every redial, and a page told on
+each one would hear it for ever. Stopping the node closes every socket and
+says "not live" before it ends the queries still open, for the same reason
+a lost relay does.
+
+**An event is confirmed by the first relay that accepts it.** It goes to
+every live relay, and fails only when each of them refused it or dropped.
+The transport expires a confirmation nobody reported, so a silent relay costs
+a retry rather than a message.
+
+**A resolution query ends when every relay it went to has answered.** Each
+relay's `EOSE` closes the query there, and the query completes when the last
+one has, or after 10 s, below the engine's 30 s sweep, so the driver that
+knows which relays answered is the one that ends it. Completing on the first
+answer would hand the result to whichever relay is fastest, and an empty or
+hostile one wins that race by having nothing to send
+([the username chapter](../spec/username-discovery.md#a-resolution-ends-when-every-relay-has-answered)).
+The 10 s is the sixth hand-mirrored constant
+([C5](README.md#c5-hand-mirrored-constants-must-be-pinned-in-every-language)),
+pinned here as in the two mobile trackers.
+
+**Reaching a device by its address alone asks the relays for its key
+packages.** `sendMessage` and `establishSecureSession` to a device with no
+session and no live link ask for the packages it published (cold contact,
+on by default); a linked device forms its session over the link, and asking
+the relays anyway would only tell them which address this node wants;
+the first one that opens forms the session, and a message sent meanwhile
+waits for it. The engine does not ask on its own: a session-less send only
+queues, and its own key-package push reaches only the mesh (a gap on every
+binding, filed as
+[#633](https://github.com/Offline-Protocol/offline-protocol-sdk/issues/633);
+once the engine asks, this request goes). Without the request, two browsers that share only a relay never meet, which is what the
+negative control in `a_device_known_only_by_its_address_is_reached_over_nostr`
+shows. `establishSecureSession` answers `"created"`, `"existing"` or
+`"pending"`; for a device met only by its address, pending is the ordinary
+first answer.
+
+**The directory is one switch.** `usernameDiscovery` publishes `profile` as
+a username and lets this node resolve others', both or neither, as the
+chapter's gating rule requires, and it needs cold contact: a name that
+resolves to an address with no published key package dead-ends one hop
+later. `resolveUsername` resolves once the lookup is under way, and the
+answer is one `username_resolved` event carrying every claim. A name is a
+claim, not an identity: show the whole set and keep the address the person
+chooses.
+
 ## Which shared rules apply
 
 | Rule | How it reaches the web binding |
@@ -355,6 +447,7 @@ deadline.
 | [C1](README.md#c1-regenerate-every-binding-together) | Does not apply: nothing is generated from the definition. The surface is `#[wasm_bindgen]` on `WebEngine` |
 | [C2](README.md#c2-the-error-enum-is-append-only) | Does not apply yet: errors cross as a JavaScript `Error` carrying the engine's message, not a position |
 | [C3](README.md#c3-events-cross-as-opaque-json) | Applies unchanged: `pollEvent` returns the engine's JSON, and the client dispatches it as a DOM event named by its `type` |
+| [C5](README.md#c5-hand-mirrored-constants-must-be-pinned-in-every-language) | The resolution-query completion deadline is the one mirrored constant here, pinned in `nostr-relays.js` beside the two mobile trackers (W12) |
 | [C7](README.md#c7-ordering-constraints) | `initialize` before `start`: the address replaces the placeholder the transport was built on |
 | [C9](README.md#c9-bridge-behaviour-is-not-covered-by-the-rust-test-suite) | The browser test page is the binding's own test; the Rust tests in the crate run two engines over links on the host |
 | [C11](README.md#c11-a-storage-adapter-is-a-supported-extension-point-and-is-verified) | The durable log store passes both conformance suites on the host, and so does the in-memory protocol-state store (W8) |
@@ -373,6 +466,8 @@ deadline.
   `NostrSignaller` room and the relays it names), and the joining address
   shown to the people involved before anything is shared with it (R27, R28).
 - Its own STUN or TURN servers, if links must cross networks (W9).
+- Its own Nostr relays, if the node is to reach devices it has no link to
+  (W12). The SDK names none.
 - The engine module where the worker can fetch it: beside `worker.js`, or
   handed to `OfflineNode.start({ wasm })` by a host whose worker starts from
   a Blob URL (an editor plugin, an extension), where a relative fetch has
@@ -436,9 +531,27 @@ start there, because Firefox answers it by asking the user.
   socket, coming back `online` redials ahead of the backoff, and a redial
   the relay accepts and never confirms (its `/first/` path) is
   dropped and dialled again.
+- `nostr-transport.html`: two nodes with no link and no code, only a relay
+  (behind a dead one), on the Nostr transport. A message to a bare address
+  forms a session and arrives end-to-end encrypted, a username the other
+  published resolves to its address, an invite opens to its maker and a
+  tampered one is refused, and a node offered no relays refuses to start
+  (W12).
+- `nostr-relays.html`: the relay driver alone, with a recording engine and
+  its timers shortened. A keep-alive probe replays no stored event, an
+  `OK false` fails the event with the relay's reason, a relay that goes
+  quiet is reported down before its event fails and its query ends, a query
+  one relay never answers ends at its deadline rather than on the first
+  answer, a query two relays answer ends on the slower one's answer, a relay
+  that closes the subscription is reported once however often it is
+  redialled and never counts as live, and closing the driver reports it down
+  before it ends a query (W12).
+
   The relay is `test/nostr-relay.mjs`, a NIP-01 relay in Node's standard
-  library that stores and verifies nothing, so the page's own checks are what
-  refuse a forged event.
+  library. It keeps what is not ephemeral, replaces an addressable event by
+  kind, author and `d` tag, and answers a subscription with what it holds,
+  as the transport's key packages and directory records need; it verifies
+  nothing, so the engine's own checks are what refuse a forged event.
 
 `scripts/test-conformance.sh` runs
 `crates/offline-protocol-web/tests/browser_conformance.rs` in a dedicated
@@ -466,6 +579,10 @@ two-player game between devices over WebRTC with durable nodes.
   project runs none, and no page tests one.
 - **Signalling through the Offline Protocol relay.** Nostr carries it (W11);
   the relay's protocol has no frame for an unauthenticated room.
+- **The Offline Protocol relay as a transport.** Its handshake names the
+  device in an `X-Device-ID` header, which a browser cannot set on a
+  WebSocket, and it needs an application token. Nostr reaches the same
+  devices without either (W12).
 - **Ephemeral sends on the native bindings.** The engine has them and the
   browser exposes them; the UDL record does not yet carry the field
   ([ADR 0032](../adr/0032-ephemeral-sends-go-once-over-a-live-link.md)).
diff --git a/docs/configuration.md b/docs/configuration.md
index e3ba5b3d2..e6c8fae4a 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -270,7 +270,7 @@ macOS, Linux, and Windows can use the Python binding's snake_case
 | `transports.reticulum.autoReconnect` | boolean | true | Reconnect after the connection drops |
 | `transports.reticulum.maxReconnectAttempts` | number | 0 | Attempts before giving up (0 = infinite) |
 | `transports.nostr.enabled` | boolean | false | Enable Nostr relay transport (requires `relayUrls`) |
-| `transports.nostr.relayUrls` | string[] | `[]` | Nostr relay WebSocket URLs (e.g. `["wss://relay.damus.io"]`) |
+| `transports.nostr.relayUrls` | string[] | `[]` | Nostr relay WebSocket URLs (`wss://`) the application chooses. The SDK names none |
 | `transports.nostr.connectionTimeout` | number | 30 | Connection timeout in seconds |
 | `transports.nostr.autoReconnect` | boolean | true | Auto-reconnect on disconnect |
 | `transports.nostr.reconnectDelay` | number | 1000 | Reconnect delay in ms |
diff --git a/docs/dors-configuration.md b/docs/dors-configuration.md
index 4b2cc870a..21efc11cd 100644
--- a/docs/dors-configuration.md
+++ b/docs/dors-configuration.md
@@ -32,7 +32,7 @@ const config = {
     },
     nostr: {
       enabled: false, // Requires at least one relay URL
-      relayUrls: ['wss://relay.damus.io'],
+      relayUrls: ['wss://your-relay.example'], // the relays you choose; the SDK names none
     },
   },
   
diff --git a/docs/nostr.md b/docs/nostr.md
index c818a47c3..7a705a3b4 100644
--- a/docs/nostr.md
+++ b/docs/nostr.md
@@ -70,11 +70,9 @@ const protocol = new OfflineProtocol({
     ble: { enabled: true },
     nostr: {
       enabled: true,
-      relayUrls: [
-        'wss://relay.damus.io',
-        'wss://nos.lol',
-        'wss://relay.nostr.band',
-      ],
+      // The relays you choose. The SDK names none, and starts Nostr on
+      // nothing but this list.
+      relayUrls: ['wss://relay-one.example', 'wss://relay-two.example'],
       autoReconnect: true,
     },
   },
@@ -189,10 +187,13 @@ nostrQueryCompleted(queryId)                  // after the relay's EOSE
 3. Route `["EVENT", queryId, {...}]` to `nostrQueryEventReceived` with the event
    **object** (the third element) serialized — *not* through the message path.
    These are not messages; their content is sealed to a different key.
-4. On `["EOSE", queryId]`, send `["CLOSE", queryId]` and call
-   `nostrQueryCompleted`. The first EOSE closes it: leaving the subscription
-   open for the other relays' stragglers would keep a live filter on a peer's
-   routing tag for the life of the connection.
+4. On `["EOSE", queryId]` from a relay, send `["CLOSE", queryId]` to that
+   relay, and call `nostrQueryCompleted` once every relay the query went to
+   has answered, or after `COMPLETION_TIMEOUT_MS` (10 s). Never on the first
+   EOSE: that hands the answer to the fastest relay, and an empty or hostile
+   one wins by having nothing to send. Closing each relay as it finishes is
+   what keeps a live filter off a peer's routing tag
+   ([the username chapter](spec/username-discovery.md#a-resolution-ends-when-every-relay-has-answered)).
 5. If no relay is connected when a query is drained, call
    `nostrQueryCompleted(queryId)` immediately rather than dropping it — the
    transport otherwise holds an entry no answer will arrive for.
@@ -585,7 +586,7 @@ and [R28](security/threat-model.md#r28-a-signalling-room-code-admits-whoever-hol
 
 1. Verify `relayUrls` is non-empty and uses `wss://` (not `ws://`)
 2. Check the device has Internet — Nostr is not a substitute for connectivity
-3. Try a known-good public relay (`wss://relay.damus.io`) to isolate relay-specific issues
+3. Try a second relay you trust, to tell a relay-specific problem from a local one
 4. Watch the `transport_state` telemetry record for the Nostr transport's status transitions
 
 ### Messages Sent But Not Delivered
diff --git a/examples/nostr-example/README.md b/examples/nostr-example/README.md
index 74a5ffd3e..08b7d4101 100644
--- a/examples/nostr-example/README.md
+++ b/examples/nostr-example/README.md
@@ -44,10 +44,8 @@ npx react-native run-ios
 > To run on Android, scaffold an `android/` project for this directory first
 > (`npx react-native init` or an Expo prebuild).
 
-## Default Relays
+## Relays
 
-- `wss://relay.damus.io`
-- `wss://nos.lol`
-- `wss://relay.nostr.band`
-
-These are public relays. For production use, configure your own relay.
+The example ships with no relays, and neither does the SDK. List the ones you
+choose in `RELAYS` in `src/constants.ts` (`wss://` URLs) before running; with
+an empty list the Nostr transport stays down.
diff --git a/examples/nostr-example/src/constants.ts b/examples/nostr-example/src/constants.ts
index ebc4462f7..d2028fc1f 100644
--- a/examples/nostr-example/src/constants.ts
+++ b/examples/nostr-example/src/constants.ts
@@ -1,8 +1,7 @@
-export const DEFAULT_RELAYS = [
-  'wss://relay.damus.io',
-  'wss://nos.lol',
-  'wss://relay.nostr.band',
-];
+// The relays this app uses. Neither the SDK nor this example names one: a
+// relay the SDK picked would become the relay every app uses. List the ones
+// you choose (wss:// URLs) before running; with none, Nostr stays down.
+export const RELAYS: string[] = [];
 
 export const PROTOCOL_CONFIG = {
   appId: 'nostr-example',
@@ -10,7 +9,7 @@ export const PROTOCOL_CONFIG = {
     ble: {enabled: false},
     nostr: {
       enabled: true,
-      relayUrls: DEFAULT_RELAYS,
+      relayUrls: RELAYS,
       autoReconnect: true,
       maxReconnectAttempts: 0, // Infinite retries
     },
diff --git a/examples/nostr-example/src/context/ProtocolContext.tsx b/examples/nostr-example/src/context/ProtocolContext.tsx
index be7845900..6d08a5121 100644
--- a/examples/nostr-example/src/context/ProtocolContext.tsx
+++ b/examples/nostr-example/src/context/ProtocolContext.tsx
@@ -10,7 +10,7 @@ import {OfflineProtocol} from '@offline-protocol/mesh-sdk';
 import type {Neighbor, ChatMessage, Chat, LogEntry, ConnectionStatus} from '../types';
 import {
   PROTOCOL_CONFIG,
-  DEFAULT_RELAYS,
+  RELAYS,
   MAX_LOG_ENTRIES,
 } from '../constants';
 
@@ -346,7 +346,7 @@ export function ProtocolProvider({children}: {children: React.ReactNode}) {
 
     addLog('info', 'Protocol started with Nostr transport');
     addLog('info', `User ID: ${uid}`);
-    addLog('info', `Relays: ${DEFAULT_RELAYS.join(', ')}`);
+    addLog('info', `Relays: ${RELAYS.join(', ')}`);
   }, [handleEvent, addLog]);
 
   const stop = useCallback(async () => {
@@ -440,7 +440,7 @@ export function ProtocolProvider({children}: {children: React.ReactNode}) {
       } else {
         await protocolRef.current.enableTransport('nostr', {
           enabled: true,
-          relayUrls: DEFAULT_RELAYS,
+          relayUrls: RELAYS,
           autoReconnect: true,
         });
         setIsTransportEnabled(true);
diff --git a/examples/nostr-example/src/screens/OnboardingScreen.tsx b/examples/nostr-example/src/screens/OnboardingScreen.tsx
index dfcc6131d..ef100a19d 100644
--- a/examples/nostr-example/src/screens/OnboardingScreen.tsx
+++ b/examples/nostr-example/src/screens/OnboardingScreen.tsx
@@ -11,7 +11,7 @@ import {
 } from 'react-native';
 import {generateUserId, generateUserName} from '../utils';
 import {useProtocol} from '../context/ProtocolContext';
-import {DEFAULT_RELAYS} from '../constants';
+import {RELAYS} from '../constants';
 
 interface OnboardingScreenProps {
   onComplete: () => void;
@@ -87,9 +87,15 @@ export function OnboardingScreen({onComplete}: OnboardingScreenProps) {
 
         
           Relays
-          {DEFAULT_RELAYS.map(relay => (
-            {relay}
-          ))}
+          {RELAYS.length === 0 ? (
+            
+              None yet: list the relays you choose in src/constants.ts
+            
+          ) : (
+            RELAYS.map(relay => (
+              {relay}
+            ))
+          )}