From e82cce5187782ef3d9843531f003aa650ffc91d2 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sun, 19 Jul 2026 09:17:35 -0500 Subject: [PATCH 1/8] docs: start issue 139 session --- docs/agents/runs/cleanup-1-9-ledger.md | 29 ++++++++-------- docs/agents/runs/issue-139-session.md | 48 ++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 14 deletions(-) create mode 100644 docs/agents/runs/issue-139-session.md diff --git a/docs/agents/runs/cleanup-1-9-ledger.md b/docs/agents/runs/cleanup-1-9-ledger.md index 9553c3d..5ccb40a 100644 --- a/docs/agents/runs/cleanup-1-9-ledger.md +++ b/docs/agents/runs/cleanup-1-9-ledger.md @@ -9,7 +9,7 @@ - Feature branches: one branch per approved ticket, created from the latest integrated `staging` - Human owner: plebdev - Started: 2026-07-18 -- Current status: items 1–7 / issues #131–#137 merged into `staging`; item 8 / issue #138 is locally complete and awaiting its PR on `feature/public-behavior-test-seams` +- Current status: items 1–8 / issues #131–#138 merged into `staging`; item 9 / issue #139 is in progress on `feature/ephemeral-relay-internals` - Skill setup status: present and verified (`AGENTS.md`, GitHub issue tracker, triage labels, domain docs, ADRs, CI, CodeRabbit) ## Goal @@ -27,7 +27,7 @@ Complete cleanup items 1–9 from the staging audit end to end, branch by branch - Agent briefs: Grok 4.5 is the exclusive delegated sidecar; Cursor exposes the highest available tier as `cursor-grok-4.5-high`, which is used for all standards/spec passes - Review packets: `issue-131-review-packet.md` through `issue-137-review-packet.md`; created per later ticket - Local CodeRabbit report: `issue-131-coderabbit-local.md` through `issue-136-coderabbit-local.md`; created per later ticket -- PR URL: #140 merged for issue #131; #141 merged for issue #132; #142 merged for issue #133; #143 merged for issue #134; #144 merged for issue #135; #145 merged for issue #136; #146 merged for issue #137; issue #138 PR pending; always non-draft and targeting `staging` +- PR URL: #140 merged for issue #131; #141 merged for issue #132; #142 merged for issue #133; #143 merged for issue #134; #144 merged for issue #135; #145 merged for issue #136; #146 merged for issue #137; #147 merged for issue #138; issue #139 PR pending; always non-draft and targeting `staging` ## Commands @@ -39,17 +39,17 @@ Complete cleanup items 1–9 from the staging audit end to end, branch by branch ## Ticket Ledger -| Issue | Type | Status | Branch | Review | Verified | -| --------------------------------- | ---- | --------------- | --------------------------------------- | -------------------------------------------------------- | ------------------------------------- | -| #131 NIP-46 diagnostic redaction | AFK | merged | `feature/nip46-diagnostic-redaction` | Grok approved; CodeRabbit local/hosted clean after fixes | Jest/Bun 1054/1054; hosted CI green | -| #132 published declaration purity | AFK | merged | `feature/public-type-test-purity` | Grok pass; CodeRabbit local/hosted clean | Jest/Bun 1055/1055; hosted CI green | -| #133 shared diagnostic seam | AFK | merged | `feature/shared-diagnostics-completion` | Grok standards/spec pass; local and hosted clean | Jest/Bun 1067/1067; hosted CI green | -| #134 NIP-47 service lifecycle | AFK | merged | `feature/nip47-service-lifecycle` | Grok pass; CodeRabbit local/hosted clean | Jest/Bun 1073/1073; hosted CI green | -| #135 NIP-57 consolidation | AFK | merged | `feature/nip57-client-consolidation` | Grok pass; CodeRabbit local/hosted clean after fixes | Jest/Bun 1082/1082; hosted CI green | -| #136 NIP-46 protocol core | AFK | merged | `feature/nip46-protocol-core` | Grok and CodeRabbit local/hosted clean after fixes | Jest/Bun 1096/1096; hosted CI green | -| #137 default test feedback loop | AFK | merged | `feature/fast-default-test-loop` | Grok pass; CodeRabbit hosted finding fixed and confirmed | routine 1063; slow 40; coverage 1103 | -| #138 public behavior test seams | AFK | PR pending | `feature/public-behavior-test-seams` | Grok approved; CodeRabbit local clean after fixes | Jest/Bun 1061; slow 35; coverage 1096 | -| #139 ephemeral Relay internals | AFK | blocked by #138 | `feature/ephemeral-relay-internals` | pending | pending | +| Issue | Type | Status | Branch | Review | Verified | +| --------------------------------- | ---- | ----------- | --------------------------------------- | -------------------------------------------------------- | ------------------------------------ | +| #131 NIP-46 diagnostic redaction | AFK | merged | `feature/nip46-diagnostic-redaction` | Grok approved; CodeRabbit local/hosted clean after fixes | Jest/Bun 1054/1054; hosted CI green | +| #132 published declaration purity | AFK | merged | `feature/public-type-test-purity` | Grok pass; CodeRabbit local/hosted clean | Jest/Bun 1055/1055; hosted CI green | +| #133 shared diagnostic seam | AFK | merged | `feature/shared-diagnostics-completion` | Grok standards/spec pass; local and hosted clean | Jest/Bun 1067/1067; hosted CI green | +| #134 NIP-47 service lifecycle | AFK | merged | `feature/nip47-service-lifecycle` | Grok pass; CodeRabbit local/hosted clean | Jest/Bun 1073/1073; hosted CI green | +| #135 NIP-57 consolidation | AFK | merged | `feature/nip57-client-consolidation` | Grok pass; CodeRabbit local/hosted clean after fixes | Jest/Bun 1082/1082; hosted CI green | +| #136 NIP-46 protocol core | AFK | merged | `feature/nip46-protocol-core` | Grok and CodeRabbit local/hosted clean after fixes | Jest/Bun 1096/1096; hosted CI green | +| #137 default test feedback loop | AFK | merged | `feature/fast-default-test-loop` | Grok pass; CodeRabbit hosted finding fixed and confirmed | routine 1063; slow 40; coverage 1103 | +| #138 public behavior test seams | AFK | merged | `feature/public-behavior-test-seams` | Grok approved; CodeRabbit local/hosted findings resolved | Jest/Bun 1061; hosted CI green | +| #139 ephemeral Relay internals | AFK | in progress | `feature/ephemeral-relay-internals` | Grok design challenge complete | focused baseline 25/25 | ## Parked HITL Slices @@ -68,7 +68,8 @@ Complete cleanup items 1–9 from the staging audit end to end, branch by branch | #135 | `25e055d` | current Codex orchestrator; Grok 4.5 High reviewers | `0909227`, `1b12872`, `b66d483` | Grok passes; CodeRabbit local/hosted clean after fixes | focused 23/23; NIP-57 41/41; Jest/Bun 1082/1082; all local gates and four hosted lanes green | | #136 | `ed9fa4a` | current Codex orchestrator; Grok 4.5 High reviewers | PR #145 through merge `8b970e4` | Grok and CodeRabbit local/hosted clean after fixes | NIP-46 185/185; Jest/Bun 1096/1096; all local and hosted gates green | | #137 | `8b970e4` | current Codex orchestrator; Grok 4.5 High reviewers | PR #146 through merge `b33f31f` | Grok standards/spec passed after Bun 1.3.9 fix; CodeRabbit hosted finding fixed and explicitly confirmed | baseline 85/1096/58.793s; routine 84/1063/32.356s; slow 2/40/43.154s; coverage 86/1103; hosted CI green | -| #138 | `b33f31f` | current Codex orchestrator; Grok 4.5 High reviewers | `bfd3314` through `7bdbcfa` | Grok approved after fixes; final CodeRabbit local full-diff review clean | routine Jest/Bun 1061; slow Jest/Bun 35; coverage 1096; policies, lint, types, builds, examples, pack | +| #138 | `b33f31f` | current Codex orchestrator; Grok 4.5 High reviewers | PR #147 through merge `3c1e905` | Grok approved; CodeRabbit local clean; two hosted nits fixed and false positive withdrawn | routine Jest/Bun 1061; slow Jest/Bun 35; coverage 1096; all local gates and four hosted lanes green | +| #139 | `3c1e905` | current Codex orchestrator; Grok 4.5 High reviewers | pending | read-only Grok deep-module design challenge complete | focused public behavior baseline 25/25; implementation pending | ## Alignment Decisions diff --git a/docs/agents/runs/issue-139-session.md b/docs/agents/runs/issue-139-session.md new file mode 100644 index 0000000..a17acab --- /dev/null +++ b/docs/agents/runs/issue-139-session.md @@ -0,0 +1,48 @@ +# Issue Session: #139 Split Ephemeral Relay Internals + +## Issue + +- Issue: #139 — Split ephemeral Relay internals +- Fixed point before session: `3c1e905` (`staging` merge of PR #147) +- Worker session: current Codex orchestrator; Grok 4.5 High is the exclusive delegated read-only reviewer +- Commit: pending +- Status: in progress + +## Inputs + +- Spec issue: #130 — Complete the high-impact cleanup chain +- Ticket: #139 +- Relevant glossary terms: Relay, Nostr Event, Subscription Filter +- Relevant ADRs: ADR 0001 centralizes Nostr Event validation and keeps Relay behavior at the public interface +- Prototype answer and source branch, if any: none + +## Implementation + +- Public interface used: `NostrRelay` through `src/testing`, plus the compatible `snstr/testing` and `snstr/utils/ephemeral-relay` package subpaths +- Behaviors covered: native and in-memory connection lifecycle, session wire messages and cleanup, Subscription Filter matching, restart state reset, exact-URL disconnect observation, and package export compatibility +- `tdd` used: yes; tests stay at public `NostrRelay`/wire seams, with a compact pure matcher contract at its internal module interface +- Commands run during implementation: focused Jest/Bun public-behavior baseline 25/25; implementation checks pending +- Full suite command: `npm test && npm run test:slow && npm run test:bun && npm run test:bun:slow` (pending) + +## Review + +- Review fixed point: `3c1e905` +- Standards findings: pending +- Spec findings: pending +- Worthy fixes applied: pending +- Findings ignored with reasons: pending + +## Design Result + +- Keep `src/utils/ephemeral-relay.ts` as the stable public facade and composition root. +- Extract private connection/transport lifecycle, client-session protocol state, and pure Subscription Filter matching owners under `src/utils/ephemeral-relay/`. +- Keep cache and replaceable-event storage in the facade; do not add a shallow fourth store module. +- Do not expose internal modules through the package root or testing entrypoint. +- Give the session owner a narrow host interface for cache, subscriptions, storage, connection count, and broadcast behavior; do not leak `WebSocketServer` across that seam. + +## Risks + +- Close ordering is load-bearing: late connections must be rejected, sessions must drain, and the public `Relay` disconnect must be observable before `NostrRelay.close()` resolves. +- Native and in-memory transports have intentionally different shutdown mechanics that must retain identical observable behavior. +- NIP-46 kind `24133` routing currently has special `#p` behavior and must not be silently unified with general Subscription Filter matching in this structural slice. +- `cache`, `subs`, `conn`, `wss`, `store`, both package subpaths, and the legacy numeric purge option remain compatible public behavior. From d6504f6a988e567558b910a49f46d64fb90e6aa5 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sun, 19 Jul 2026 09:34:35 -0500 Subject: [PATCH 2/8] refactor: split ephemeral relay internals --- src/utils/ephemeral-relay.ts | 1049 ++--------------- src/utils/ephemeral-relay/client-session.ts | 644 ++++++++++ src/utils/ephemeral-relay/filter-match.ts | 83 ++ src/utils/ephemeral-relay/transport.ts | 286 +++++ tests/utils/ephemeral-relay-filter.test.ts | 61 + tests/utils/ephemeral-relay-internals.test.ts | 55 + tests/utils/ephemeral-relay-session.test.ts | 56 + 7 files changed, 1260 insertions(+), 974 deletions(-) create mode 100644 src/utils/ephemeral-relay/client-session.ts create mode 100644 src/utils/ephemeral-relay/filter-match.ts create mode 100644 src/utils/ephemeral-relay/transport.ts create mode 100644 tests/utils/ephemeral-relay-filter.test.ts create mode 100644 tests/utils/ephemeral-relay-internals.test.ts create mode 100644 tests/utils/ephemeral-relay-session.test.ts diff --git a/src/utils/ephemeral-relay.ts b/src/utils/ephemeral-relay.ts index 23d5db6..e16fefe 100644 --- a/src/utils/ephemeral-relay.ts +++ b/src/utils/ephemeral-relay.ts @@ -1,69 +1,25 @@ import EventEmitter from "events"; -import { WebSocket, WebSocketServer } from "ws"; -import { NostrEvent, NostrFilter } from "../types/nostr"; -import { - NostrRelayMessage, - NostrOkMessage, - NostrEoseMessage, -} from "../types/protocol"; -import { validateEvent } from "../nip01/event"; -import { isValidPublicKeyPoint } from "./key-validation"; -import { isHexOfLength } from "./wire-validation"; -import { - validateArrayAccess, - validateFilters, - safeArrayAccess, - SecurityValidationError, -} from "./security-validator"; -import { - InMemoryWebSocketServer, - registerInMemoryServer, - unregisterInMemoryServer, -} from "./inMemoryWebSocket"; +import { WebSocket } from "ws"; +import { NostrEvent } from "../types/nostr"; import { maybeUnref } from "./timers"; import { notifyRelayDisconnectObservers } from "./websocket"; +import { DiagnosticLogArgument, DiagnosticLogger, Logger } from "./logger"; import { - DiagnosticLogArgument, - DiagnosticLogger, - Logger, -} from "./logger"; - -/** - * Validates if a string is a valid 32-byte hex string (case-insensitive). - * Unlike isValidPublicKeyPoint, this accepts both uppercase and lowercase hex. - */ -function isValid32ByteHex(hex: string): boolean { - return isHexOfLength(hex, 64); -} - -/** - * Validates if a string is a valid 64-byte hex string (case-insensitive). - * Unlike isValidPublicKeyPoint, this accepts both uppercase and lowercase hex. - */ -function isValid64ByteHex(hex: string): boolean { - return isHexOfLength(hex, 128); -} + createClientSession, + RelaySession, + RelaySessionHost, + RelaySubscription, +} from "./ephemeral-relay/client-session"; +import { + createRelayTransport, + RelayTransport, +} from "./ephemeral-relay/transport"; /* ================ [ Configuration ] ================ */ // Prefer 127.0.0.1 over localhost to avoid IPv6 resolution issues in CI. const HOST = "ws://127.0.0.1"; -function toDiagnosticArgument(value: unknown): DiagnosticLogArgument { - if ( - value === null || - value === undefined || - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" || - typeof value === "object" - ) { - return value; - } - - return String(value); -} - function createNonThrowingDiagnosticLogger( logger: DiagnosticLogger, ): DiagnosticLogger { @@ -98,29 +54,21 @@ export interface NostrRelayOptions { logger?: DiagnosticLogger; } -interface Subscription { - filters: NostrFilter[]; - instance: ClientSession; - sub_id: string; -} - /* ================ [ Server Class ] ================ */ export class NostrRelay { private readonly _emitter: EventEmitter; private readonly _port: number; private readonly _purge: number | null; - private readonly _subs: Map; + private readonly _subs: Map; private readonly _logger: DiagnosticLogger; - private readonly _sessions: Set; + private readonly _sessions: Set; + private readonly _sessionHost: RelaySessionHost; - private _wss: WebSocketServer | null; - private _inMemoryServer: InMemoryWebSocketServer | null = null; + private _transport: RelayTransport | null = null; private _cache: NostrEvent[]; private _closePromise: Promise | null = null; private _purgeTimer: NodeJS.Timeout | null = null; - private _actualPort: number | null = null; - private _acceptingConnections = false; public conn: number; @@ -142,9 +90,22 @@ export class NostrRelay { options.logger ?? new Logger({ silent: true }), ); this._sessions = new Set(); - this._wss = null; + this._sessionHost = { + cachedEvents: () => this._cache, + subscriptions: () => this._subs, + store: (event) => this.store(event), + broadcast: (message, sender) => { + this.wss.clients.forEach((client) => { + if (client !== sender && client.readyState === WebSocket.OPEN) { + client.send(message); + } + }); + }, + clientDisconnected: () => { + this.conn = Math.max(0, this.conn - 1); + }, + }; this.conn = 0; - this._actualPort = null; } get cache() { @@ -156,15 +117,15 @@ export class NostrRelay { } get url() { - const port = this._actualPort || this._port; + const port = this._transport?.actualPort || this._port; return `${HOST}:${port}`; } get wss() { - if (this._wss === null) { + if (!this._transport) { throw new Error("websocket server not initialized"); } - return this._wss; + return this._transport.server; } async start() { @@ -172,194 +133,46 @@ export class NostrRelay { await this._closePromise; } - if (this._wss || this._inMemoryServer) { + if (this._transport) { return this; } - const handleConnection = (socket: WebSocket | EventEmitter) => { - if (!this._acceptingConnections) { - const closingSocket = socket as WebSocket; - try { - closingSocket.terminate(); - } catch { - try { - closingSocket.close(1001, "Relay shutting down"); - } catch { - // The Relay is already closing; there is no session state to retain. - } - } - return; - } - - const instance = new ClientSession( - this, - socket as WebSocket, - this._logger, - ); - this._sessions.add(instance); - void instance.closed.then(() => this._sessions.delete(instance)); - - socket.on("message", (msg: unknown) => - instance._handler( - typeof msg === "string" || msg instanceof String - ? msg.toString() - : Buffer.isBuffer(msg) - ? msg.toString() - : JSON.stringify(msg), - ), - ); - socket.on("error", (err: unknown) => - instance._onerr( - err instanceof Error - ? err - : new Error(String(err ?? "Unknown error")), - ), - ); - socket.on("close", (code: number) => instance._cleanup(code)); - - this.conn += 1; - }; - - const initialiseAfterStart = () => { - this._acceptingConnections = true; - if (this._purge !== null) { - if (this._purgeTimer) { - clearInterval(this._purgeTimer); - } - this._purgeTimer = setInterval(() => { - this._cache = []; - }, this._purge * 1000); - maybeUnref(this._purgeTimer); - } - this._logger.info("Relay started", { url: this.url }); - }; - - const shouldFallbackToInMemory = (error: unknown) => { - if (!error || typeof error !== "object") { - return false; - } - const code = - "code" in error && typeof (error as { code: unknown }).code === "string" - ? ((error as { code: string }).code as string) - : ""; - const message = - "message" in error && - typeof (error as { message: unknown }).message === "string" - ? ((error as { message: string }).message as string) - : ""; - // In restricted runtimes, binding to port 0 may report EADDRINUSE even - // though no specific port was requested; fall back to in-memory relay. - const isDynamicPortConflict = - this._port === 0 && - (code === "EADDRINUSE" || message.includes("EADDRINUSE")); - return ( - isDynamicPortConflict || - code === "EACCES" || - code === "EPERM" || - code === "EADDRNOTAVAIL" || - message.includes("EPERM") || - message.includes("EACCES") || - message.includes("EADDRNOTAVAIL") - ); - }; - - const resetFailedWebSocketServer = (wss: WebSocketServer) => { - wss.removeAllListeners(); - try { - wss.close(); - } catch { - // A listener that failed to bind may already be fully closed. - } - if (this._wss === wss) this._wss = null; - this._actualPort = null; - this._acceptingConnections = false; - }; - - const startInMemory = () => { - const { server, port } = registerInMemoryServer( - this._port === 0 ? undefined : this._port, - ); - - this._inMemoryServer = server; - this._wss = server as unknown as WebSocketServer; - this._actualPort = port; - - server.on( - "connection", - handleConnection as (socket: EventEmitter) => void, - ); - - initialiseAfterStart(); + const transport = createRelayTransport({ + port: this._port, + logger: this._logger, + onConnection: (socket) => { + const instance = createClientSession( + this._sessionHost, + socket, + this._logger, + ); + this._sessions.add(instance); + void instance.closed.then(() => this._sessions.delete(instance)); - return new Promise((res) => { - queueMicrotask(() => { - this._emitter.emit("connected"); - res(this); - }); - }); - }; + this.conn += 1; + }, + }); + this._transport = transport; - // Bun on Linux CI has shown flakiness with real TCP listeners (port 0 / ephemeral ports). - // Prefer the in-memory transport in Bun to keep the test suite deterministic. - if (this._port === 0 && typeof (globalThis as unknown as { Bun?: unknown }).Bun !== "undefined") { - return startInMemory(); + try { + await transport.start(); + } catch (error) { + if (this._transport === transport) this._transport = null; + throw error; } - return new Promise((resolve, reject) => { - try { - const wss = new WebSocketServer({ port: this._port, host: "127.0.0.1" }); - this._wss = wss; - wss.on("connection", handleConnection); - - const cleanup = () => { - wss.off("listening", onListening); - wss.off("error", onError); - }; - - const onListening = () => { - cleanup(); - const address = wss.address(); - if (address && typeof address === "object" && "port" in address) { - const port = - typeof address.port === "number" && address.port > 0 - ? address.port - : null; - this._actualPort = port; - } - // If we couldn't determine a usable port (e.g. Bun/compat oddities with port 0), - // fall back to in-memory transport rather than returning a ws://...:0 URL. - if (this._port === 0 && !this._actualPort) { - resetFailedWebSocketServer(wss); - startInMemory().then(resolve).catch(reject); - return; - } - - initialiseAfterStart(); - this._emitter.emit("connected"); - resolve(this); - }; - - const onError = (error: unknown) => { - cleanup(); - if (shouldFallbackToInMemory(error)) { - resetFailedWebSocketServer(wss); - startInMemory().then(resolve).catch(reject); - } else { - resetFailedWebSocketServer(wss); - reject(error); - } - }; - - wss.once("listening", onListening); - wss.once("error", onError); - } catch (error) { - if (shouldFallbackToInMemory(error)) { - startInMemory().then(resolve).catch(reject); - } else { - reject(error); - } + if (this._purge !== null) { + if (this._purgeTimer) { + clearInterval(this._purgeTimer); } - }); + this._purgeTimer = setInterval(() => { + this._cache = []; + }, this._purge * 1000); + maybeUnref(this._purgeTimer); + } + this._logger.info("Relay started", { url: this.url }); + this._emitter.emit("connected"); + return this; } onconnect(cb: () => void) { @@ -383,91 +196,32 @@ export class NostrRelay { } private async performClose(): Promise { - this._acceptingConnections = false; const closedUrl = this.url; - const inMemoryServer = this._inMemoryServer; - const wss = inMemoryServer ? null : this._wss; - const ownedTransport = inMemoryServer !== null || wss !== null; + const transport = this._transport; const sessions = [...this._sessions]; - const transportShutdown = wss - ? this.closeWebSocketTransport(wss) - : Promise.resolve(); this._emitter.removeAllListeners(); if (this._purgeTimer) clearInterval(this._purgeTimer); this._purgeTimer = null; - this._inMemoryServer = null; - this._wss = null; + this._transport = null; this._subs.clear(); this._cache = []; - const sessionShutdown = Promise.all( - sessions.map((session) => session.close()), - ); - - if (inMemoryServer) { - unregisterInMemoryServer(this._actualPort || this._port); - await sessionShutdown; - inMemoryServer.removeAllListeners(); - } else if (wss) { - let sessionTimeout: NodeJS.Timeout | null = null; - const timedOut = new Promise((resolve) => { - sessionTimeout = setTimeout(() => resolve(true), 1000); - maybeUnref(sessionTimeout); - }); - const sessionsClosed = sessionShutdown.then(() => false); - - if (await Promise.race([sessionsClosed, timedOut])) { - this._logger.warn("Relay client close timed out; forcing cleanup"); - sessions.forEach((session) => session.forceClose()); - await sessionShutdown; - } - if (sessionTimeout) clearTimeout(sessionTimeout); - - await transportShutdown; - wss.removeAllListeners(); - } - - if (ownedTransport) { + if (transport) { + await transport.close( + async () => { + await Promise.all(sessions.map((session) => session.close())); + }, + () => sessions.forEach((session) => session.forceClose()), + ); notifyRelayDisconnectObservers(closedUrl); } this._sessions.clear(); this.conn = 0; - this._actualPort = null; this._logger.info("Relay closed", { url: closedUrl }); } - private closeWebSocketTransport(wss: WebSocketServer): Promise { - return new Promise((resolve) => { - let timeout: NodeJS.Timeout | null = null; - const finish = () => { - if (timeout) clearTimeout(timeout); - timeout = null; - resolve(); - }; - - timeout = setTimeout(() => { - this._logger.warn( - "Relay transport close timed out; forcing cleanup", - ); - finish(); - }, 1000); - maybeUnref(timeout); - - try { - // Calling close immediately stops the transport accepting new sockets; - // its callback still waits for the tracked sessions to drain below. - wss.close(finish); - } catch (error) { - this._logger.warn("Relay transport close failed", { - error: toDiagnosticArgument(error), - }); - finish(); - } - }); - } - store(event: NostrEvent) { const isSimpleReplaceable = event.kind === 0 || @@ -583,656 +337,3 @@ export class NostrRelay { } } } - -/* ================ [ Instance Class ] ================ */ - -class ClientSession { - private _sid: string; - private readonly _relay: NostrRelay; - private readonly _socket: WebSocket; - private readonly _subs: Set; - private readonly _logger: DiagnosticLogger; - private readonly _closed: Promise; - private _resolveClosed!: () => void; - private _cleaned = false; - - constructor( - relay: NostrRelay, - socket: WebSocket, - logger: DiagnosticLogger, - ) { - this._relay = relay; - this._logger = logger; - this._closed = new Promise((resolve) => { - this._resolveClosed = resolve; - }); - // Generate cryptographically secure session ID - if (typeof crypto !== "undefined" && crypto.getRandomValues) { - const array = new Uint8Array(3); - crypto.getRandomValues(array); - this._sid = Array.from(array, (byte) => - byte.toString(16).padStart(2, "0"), - ).join(""); - } else if ( - typeof process !== "undefined" && - process.versions && - process.versions.node - ) { - try { - // Try to use require for CommonJS environments - // eslint-disable-next-line @typescript-eslint/no-var-requires - const nodeCrypto = require("crypto"); - this._sid = nodeCrypto.randomBytes(3).toString("hex"); - } catch (requireError) { - // If require fails (ESM environment), generate a fallback ID - // that will remain immutable for the session lifetime - const tempArray = new Uint8Array(3); - // Use Math.random as permanent fallback - for (let i = 0; i < tempArray.length; i++) { - tempArray[i] = Math.floor(Math.random() * 256); - } - this._sid = Array.from(tempArray, (byte) => - byte.toString(16).padStart(2, "0"), - ).join(""); - - // Log warning but keep the generated ID immutable - this._logger.warn( - "Using Math.random for session ID generation; provide crypto for stronger identifiers", - ); - } - } else { - // As a last resort, use Math.random with a timestamp component - // to ensure uniqueness even without crypto - const timestamp = Date.now(); - const random = Math.floor(Math.random() * 0xffffff); - this._sid = ((timestamp & 0xffffff) ^ random) - .toString(16) - .padStart(6, "0"); - } - this._socket = socket; - this._subs = new Set(); - - this.log.client("client connected"); - } - - get sid() { - return this._sid; - } - - get relay() { - return this._relay; - } - - get socket() { - return this._socket; - } - - get closed(): Promise { - return this._closed; - } - - close(): Promise { - if (this._cleaned) return this._closed; - - try { - if ( - this.socket.readyState === WebSocket.OPEN || - this.socket.readyState === WebSocket.CONNECTING - ) { - this.socket.close(1000, "Relay shutting down"); - } else if (this.socket.readyState === WebSocket.CLOSED) { - this._cleanup(1000); - } - } catch (error) { - this._logger.warn("Relay client close failed", { - sessionId: this._sid, - error: toDiagnosticArgument(error), - }); - this._cleanup(1006); - } - - return this._closed; - } - - forceClose(): void { - try { - this.socket.terminate(); - } catch { - // Cleanup below is authoritative even when the transport cannot terminate. - } - this._cleanup(1006); - } - - _cleanup(code: number) { - if (this._cleaned) return; - this._cleaned = true; - - try { - // First remove all subscriptions associated with this client - for (const subId of this._subs) { - this.remSub(subId); - } - this._subs.clear(); - - // Close the socket if it's still open - if (this.socket.readyState === WebSocket.OPEN) { - this.socket.close(); - } - - this.relay.conn = Math.max(0, this.relay.conn - 1); - this.log.client( - `[ ${this._sid} ]`, - "client disconnected with code:", - code, - ); - } catch (e) { - this._logger.error("Relay client cleanup failed", { - sessionId: this._sid, - error: toDiagnosticArgument(e), - }); - } finally { - this._resolveClosed(); - } - } - - _handler(message: string) { - try { - // Try to parse as JSON - const parsed = JSON.parse(message); - - // Handle NIP-46 messages (which might not follow standard Nostr format) - if (parsed && Array.isArray(parsed) && parsed.length > 0) { - // Check if it's a standard Nostr message - if (["EVENT", "REQ", "CLOSE"].includes(parsed[0])) { - const verb = parsed[0]; - - switch (verb) { - case "EVENT": - if (parsed.length !== 2) { - this.log.debug("EVENT message missing params:", parsed); - return this.send([ - "NOTICE", - "invalid: EVENT message missing params", - ]); - } - return this._onevent(parsed[1] as NostrEvent); - - case "REQ": - if (parsed.length < 2) { - this.log.debug("REQ message missing params:", parsed); - return this.send([ - "NOTICE", - "invalid: REQ message missing params", - ]); - } - { - const sub_id = parsed[1]; - if (typeof sub_id !== "string") { - return this.send([ - "NOTICE", - "invalid: REQ subscription id must be a string", - ]); - } - let filters: NostrFilter[]; - try { - filters = validateFilters(parsed.slice(2)); - } catch (error) { - if (error instanceof SecurityValidationError) { - return this.send(["NOTICE", "invalid: REQ filters"]); - } - throw error; - } - return this._onreq(sub_id, filters); - } - - case "CLOSE": - if (parsed.length !== 2) { - this.log.debug("CLOSE message missing params:", parsed); - return this.send([ - "NOTICE", - "invalid: CLOSE message missing params", - ]); - } - return this._onclose(parsed[1] as string); - } - } else { - // This could be a direct NIP-46 message, broadcast it to other clients - try { - this.relay.wss.clients.forEach((client) => { - if ( - client !== this.socket && - client.readyState === WebSocket.OPEN - ) { - client.send(message); - } - }); - return; - } catch (e) { - this.log.error("Error broadcasting message:", e); - return; - } - } - } - - this.log.debug("unhandled message format:", message); - return this.send(["NOTICE", "Unable to handle message"]); - } catch (e) { - this.log.debug("failed to parse message:\n\n", message); - return this.send(["NOTICE", "Unable to parse message"]); - } - } - - _onclose(sub_id: string) { - this.log.info("closed subscription:", sub_id); - this.remSub(sub_id); - } - - _onerr(err: Error) { - this.log.info("socket encountered an error:\n\n", err); - } - - async _onevent(event: NostrEvent) { - try { - // Special handling for NIP-46 events (kind 24133) - if (event.kind === 24133) { - // Validate basic structure but with NIP-46 specific validation - if (!(await this.validateNIP46Event(event))) { - this.log.debug("NIP-46 event failed validation:", event); - this.send([ - "OK", - event.id, - false, - "NIP-46 event failed validation", - ] as NostrOkMessage); - return; - } - - this.relay.store(event); - - // Find subscriptions that match this event - for (const [uid, sub] of this.relay.subs.entries()) { - for (const filter of sub.filters) { - if (filter.kinds?.includes(24133)) { - // Check for #p tag filter - safe array access - const pTags = event.tags - .filter((tag) => { - try { - return ( - validateArrayAccess(tag, 0) && - safeArrayAccess(tag, 0) === "p" - ); - } catch { - return false; - } - }) - .map((tag) => { - try { - return safeArrayAccess(tag, 1); - } catch { - return null; - } - }) - .filter((val): val is string => typeof val === "string"); - const pFilters = filter["#p"] || []; - - // If there's a #p filter, make sure the event matches it - if ( - pFilters.length > 0 && - !pTags.some((tag) => pFilters.includes(tag)) - ) { - continue; - } - - // Send to matching subscription - safe array access - try { - const uidParts = uid.split("/"); - if (validateArrayAccess(uidParts, 1)) { - const subId = safeArrayAccess(uidParts, 1); - if (typeof subId !== "string") { - continue; - } - sub.instance.send([ - "EVENT", - subId, - event, - ]); - break; - } - } catch (error) { - if (error instanceof SecurityValidationError) { - this.log.debug( - `Bounds checking error in subscription routing: ${error.message}`, - ); - } - continue; - } - } - } - } - - // Send OK message - this.send(["OK", event.id, true, ""] as NostrOkMessage); - return; - } - - // Standard event processing - this.log.client("received event id:", event.id); - this.log.debug("event:", event); - - // Standard event processing - wrap validateEvent in try-catch - try { - if (!(await validateEvent(event))) { - this.log.debug("event failed validation (returned false):", event); - this.send([ - "OK", - event.id, - false, - "event failed validation: validateEvent returned false", - ] as NostrOkMessage); - return; - } - } catch (validationError) { - // If validateEvent itself throws (e.g. NostrValidationError from getEventHash) - let errorMessage = "event validation error"; - if (validationError instanceof Error) { - errorMessage = validationError.message; - } - this.log.debug( - `event failed validation (threw error): ${errorMessage}`, - event, - ); - this.send([ - "OK", - event.id, - false, - `invalid: ${errorMessage}`, - ] as NostrOkMessage); - return; - } - - this.send(["OK", event.id, true, ""] as NostrOkMessage); - this.relay.store(event); - - for (const { filters, instance, sub_id } of this.relay.subs.values()) { - for (const filter of filters) { - if (match_filter(event, filter)) { - instance.log.client(`event matched subscription: ${sub_id}`); - instance.send(["EVENT", sub_id, event]); - } - } - } - } catch (e) { - this.log.error("Error processing event:", e); - } - } - - _onreq(sub_id: string, filters: NostrFilter[]): void { - if (filters.length === 0) { - this.log.client("request has no filters"); - return; - } - - this.log.client("received subscription request:", sub_id); - this.log.debug("filters:", filters); - - // Add subscription - this.addSub(sub_id, ...filters); - - // For each filter - let count = 0; - for (const filter of filters) { - // Set the limit count, if any - let limitCount = filter.limit; - - for (const event of this.relay.cache) { - // If limit is reached, stop sending events - if (limitCount !== undefined && limitCount <= 0) break; - - // Check if event matches filter - if (match_filter(event, filter)) { - this.send(["EVENT", sub_id, event]); - count++; - this.log.client(`event matched in cache: ${event.id}`); - this.log.client(`event matched subscription: ${sub_id}`); - - // Update limit counter - if (limitCount !== undefined) limitCount--; - } - } - } - - this.log.debug(`sent ${count} matching events from cache`); - - // Send EOSE - this.send(["EOSE", sub_id] as NostrEoseMessage); - } - - get log() { - const write = ( - level: "error" | "info" | "debug" | "trace", - messages: unknown[], - ) => { - const [message = "", ...args] = messages; - this._logger[level]( - `[Relay client ${this._sid}] ${String(message)}`, - ...args.map(toDiagnosticArgument), - ); - }; - - return { - client: (...msg: unknown[]) => write("trace", msg), - debug: (...msg: unknown[]) => write("debug", msg), - info: (...msg: unknown[]) => write("info", msg), - error: (...msg: unknown[]) => write("error", msg), - }; - } - - addSub(sub_id: string, ...filters: NostrFilter[]) { - const uid = `${this.sid}/${sub_id}`; - this.relay.subs.set(uid, { filters, instance: this, sub_id }); - this._subs.add(sub_id); - } - - remSub(subId: string) { - try { - const uid = `${this.sid}/${subId}`; - this.relay.subs.delete(uid); - this._subs.delete(subId); - } catch (e) { - // Ignore errors - } - } - - send(message: NostrRelayMessage) { - try { - if (this.socket.readyState === WebSocket.OPEN) { - this.socket.send(JSON.stringify(message)); - } - } catch (e) { - this.log.error("Failed to send message:", e); - } - } - - // Method to validate NIP-46 events - async validateNIP46Event(event: NostrEvent): Promise { - // Check required fields exist with proper types - if (!isValidPublicKeyPoint(event.pubkey)) { - this.log.debug("NIP-46 validation failed: invalid pubkey"); - return false; - } - - if (!event.created_at || typeof event.created_at !== "number") { - this.log.debug("NIP-46 validation failed: invalid created_at"); - return false; - } - - if (event.kind !== 24133) { - this.log.debug("NIP-46 validation failed: invalid kind"); - return false; - } - - if (!Array.isArray(event.tags)) { - this.log.debug("NIP-46 validation failed: invalid tags"); - return false; - } - - // For NIP-46, we need to have at least one p tag with a valid pubkey - const hasPTag = event.tags.some((tag: string[]) => { - try { - return ( - Array.isArray(tag) && - validateArrayAccess(tag, 0) && - validateArrayAccess(tag, 1) && - safeArrayAccess(tag, 0) === "p" && - typeof safeArrayAccess(tag, 1) === "string" && - isValidPublicKeyPoint(safeArrayAccess(tag, 1) as string) - ); - } catch (error) { - // If bounds checking fails, this tag is invalid - return false; - } - }); - - if (!hasPTag) { - // For debugging, log the tags structure - this.log.debug( - "NIP-46 validation failed: no valid p tag found", - JSON.stringify(event.tags), - ); - return false; - } - - if (typeof event.content !== "string") { - this.log.debug("NIP-46 validation failed: invalid content"); - return false; - } - - if ( - !event.sig || - typeof event.sig !== "string" || - !isValid64ByteHex(event.sig) - ) { - this.log.debug("NIP-46 validation failed: invalid signature"); - return false; - } - - // Verify signature for NIP-46 events using the canonical validateEvent - try { - if (!(await validateEvent(event))) { - this.log.debug( - "NIP-46 validation failed: invalid signature verification", - ); - return false; - } - } catch (error) { - this.log.debug( - "NIP-46 validation failed: error during signature verification", - error, - ); - return false; - } - - // Validate event.id: must be 64-char hex (case-insensitive) - if (!isValid32ByteHex(event.id)) { - this.log.debug("NIP-46 validation failed: invalid id format"); - return false; - } - - // For NIP-46, we've passed all the validation checks - return true; - } -} - -/* ================ [ Methods ] ================ */ - -function match_filter(event: NostrEvent, filter: NostrFilter = {}): boolean { - const { authors, ids, kinds, since, until, search, ...rest } = filter; - - // Extract all tag filters from rest - const tag_filters: string[][] = Object.entries(rest) - .filter((e) => e[0].startsWith("#")) - .map((e) => [e[0].slice(1), ...(e[1] as string[])]); - - if (ids !== undefined && !ids.includes(event.id)) { - return false; - } else if (since !== undefined && event.created_at < since) { - return false; - } else if (until !== undefined && event.created_at > until) { - return false; - } else if (authors !== undefined && !authors.includes(event.pubkey)) { - return false; - } else if (kinds !== undefined && !kinds.includes(event.kind)) { - return false; - } else if (search !== undefined && search.length > 0) { - const query = search.toLowerCase(); - const contentMatch = event.content.toLowerCase().includes(query); - const tagMatch = event.tags.some((tag) => - tag.some((v) => v.toLowerCase().includes(query)), - ); - if (!contentMatch && !tagMatch) return false; - return tag_filters.length > 0 ? match_tags(tag_filters, event.tags) : true; - } else if (tag_filters.length > 0) { - return match_tags(tag_filters, event.tags); - } else { - return true; - } -} - -function match_tags(filters: string[][], tags: string[][]): boolean { - // For each filter, we need to find at least one match in event tags - for (const filter of filters) { - let filterMatched = false; - - // Safe access to filter elements - try { - if (!validateArrayAccess(filter, 0)) { - filterMatched = true; // Empty filter matches everything - continue; - } - - const key = safeArrayAccess(filter, 0); - const terms = filter.slice(1); - - // Skip empty filter terms - if (terms.length === 0) { - filterMatched = true; - continue; - } - - // For each tag that matches the filter key - for (const tag of tags) { - try { - if (!validateArrayAccess(tag, 0) || safeArrayAccess(tag, 0) !== key) { - continue; - } - - const params = tag.slice(1); - - // For each term in the filter - for (const term of terms) { - // If any term matches any parameter, this filter condition is satisfied - if (params.includes(term)) { - filterMatched = true; - break; - } - } - - // If we found a match for this filter, we can stop checking tags - if (filterMatched) break; - } catch (error) { - // Skip malformed tags - continue; - } - } - } catch (error) { - // Skip malformed filters - continue; - } - - // If no match was found for this filter condition, event doesn't match - if (!filterMatched) return false; - } - - // All filter conditions were satisfied - return true; -} diff --git a/src/utils/ephemeral-relay/client-session.ts b/src/utils/ephemeral-relay/client-session.ts new file mode 100644 index 0000000..1d5fd1a --- /dev/null +++ b/src/utils/ephemeral-relay/client-session.ts @@ -0,0 +1,644 @@ +import { WebSocket } from "ws"; +import { validateEvent } from "../../nip01/event"; +import type { NostrEvent, NostrFilter } from "../../types/nostr"; +import type { + NostrEoseMessage, + NostrOkMessage, + NostrRelayMessage, +} from "../../types/protocol"; +import { isValidPublicKeyPoint } from "../key-validation"; +import type { DiagnosticLogArgument, DiagnosticLogger } from "../logger"; +import { + safeArrayAccess, + SecurityValidationError, + validateArrayAccess, + validateFilters, +} from "../security-validator"; +import { isHexOfLength } from "../wire-validation"; +import { matchesFilter } from "./filter-match"; + +function isValid32ByteHex(hex: string): boolean { + return isHexOfLength(hex, 64); +} + +function isValid64ByteHex(hex: string): boolean { + return isHexOfLength(hex, 128); +} + +function toDiagnosticArgument(value: unknown): DiagnosticLogArgument { + if ( + value === null || + value === undefined || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + typeof value === "object" + ) { + return value; + } + + return String(value); +} + +export interface RelaySession { + readonly closed: Promise; + close(): Promise; + forceClose(): void; + send(message: NostrRelayMessage): void; + sendMatchedEvent(subscriptionId: string, event: NostrEvent): void; +} + +export interface RelaySubscription { + filters: NostrFilter[]; + instance: RelaySession; + sub_id: string; +} + +export interface RelaySessionHost { + cachedEvents(): readonly NostrEvent[]; + subscriptions(): Map; + store(event: NostrEvent): void; + broadcast(message: string, sender: WebSocket): void; + clientDisconnected(): void; +} + +export function createClientSession( + host: RelaySessionHost, + socket: WebSocket, + logger: DiagnosticLogger, +): RelaySession { + const session = new ClientSession(host, socket, logger); + + socket.on("message", (message: unknown) => + session.handleMessage( + typeof message === "string" || message instanceof String + ? message.toString() + : Buffer.isBuffer(message) + ? message.toString() + : JSON.stringify(message), + ), + ); + socket.on("error", (error: unknown) => + session.handleError( + error instanceof Error + ? error + : new Error(String(error ?? "Unknown error")), + ), + ); + socket.on("close", (code: number) => session.cleanup(code)); + + return session; +} + +class ClientSession implements RelaySession { + private _sid: string; + private readonly _host: RelaySessionHost; + private readonly _socket: WebSocket; + private readonly _subs: Set; + private readonly _logger: DiagnosticLogger; + private readonly _closed: Promise; + private _resolveClosed!: () => void; + private _cleaned = false; + + constructor( + host: RelaySessionHost, + socket: WebSocket, + logger: DiagnosticLogger, + ) { + this._host = host; + this._logger = logger; + this._closed = new Promise((resolve) => { + this._resolveClosed = resolve; + }); + // Generate cryptographically secure session ID + if (typeof crypto !== "undefined" && crypto.getRandomValues) { + const array = new Uint8Array(3); + crypto.getRandomValues(array); + this._sid = Array.from(array, (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + } else if ( + typeof process !== "undefined" && + process.versions && + process.versions.node + ) { + try { + // Try to use require for CommonJS environments + // eslint-disable-next-line @typescript-eslint/no-var-requires + const nodeCrypto = require("crypto"); + this._sid = nodeCrypto.randomBytes(3).toString("hex"); + } catch (requireError) { + // If require fails (ESM environment), generate a fallback ID + // that will remain immutable for the session lifetime + const tempArray = new Uint8Array(3); + // Use Math.random as permanent fallback + for (let i = 0; i < tempArray.length; i++) { + tempArray[i] = Math.floor(Math.random() * 256); + } + this._sid = Array.from(tempArray, (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + + // Log warning but keep the generated ID immutable + this._logger.warn( + "Using Math.random for session ID generation; provide crypto for stronger identifiers", + ); + } + } else { + // As a last resort, use Math.random with a timestamp component + // to ensure uniqueness even without crypto + const timestamp = Date.now(); + const random = Math.floor(Math.random() * 0xffffff); + this._sid = ((timestamp & 0xffffff) ^ random) + .toString(16) + .padStart(6, "0"); + } + this._socket = socket; + this._subs = new Set(); + + this.log.client("client connected"); + } + + get sid() { + return this._sid; + } + + get host() { + return this._host; + } + + get socket() { + return this._socket; + } + + get closed(): Promise { + return this._closed; + } + + close(): Promise { + if (this._cleaned) return this._closed; + + try { + if ( + this.socket.readyState === WebSocket.OPEN || + this.socket.readyState === WebSocket.CONNECTING + ) { + this.socket.close(1000, "Relay shutting down"); + } else if (this.socket.readyState === WebSocket.CLOSED) { + this.cleanup(1000); + } + } catch (error) { + this._logger.warn("Relay client close failed", { + sessionId: this._sid, + error: toDiagnosticArgument(error), + }); + this.cleanup(1006); + } + + return this._closed; + } + + forceClose(): void { + try { + this.socket.terminate(); + } catch { + // Cleanup below is authoritative even when the transport cannot terminate. + } + this.cleanup(1006); + } + + cleanup(code: number): void { + if (this._cleaned) return; + this._cleaned = true; + + try { + // First remove all subscriptions associated with this client + for (const subId of this._subs) { + this.remSub(subId); + } + this._subs.clear(); + + // Close the socket if it's still open + if (this.socket.readyState === WebSocket.OPEN) { + this.socket.close(); + } + + this.host.clientDisconnected(); + this.log.client( + `[ ${this._sid} ]`, + "client disconnected with code:", + code, + ); + } catch (e) { + this._logger.error("Relay client cleanup failed", { + sessionId: this._sid, + error: toDiagnosticArgument(e), + }); + } finally { + this._resolveClosed(); + } + } + + handleMessage(message: string): void { + try { + // Try to parse as JSON + const parsed = JSON.parse(message); + + // Handle NIP-46 messages (which might not follow standard Nostr format) + if (parsed && Array.isArray(parsed) && parsed.length > 0) { + // Check if it's a standard Nostr message + if (["EVENT", "REQ", "CLOSE"].includes(parsed[0])) { + const verb = parsed[0]; + + switch (verb) { + case "EVENT": + if (parsed.length !== 2) { + this.log.debug("EVENT message missing params:", parsed); + return this.send([ + "NOTICE", + "invalid: EVENT message missing params", + ]); + } + void this.handleEvent(parsed[1] as NostrEvent); + return; + + case "REQ": + if (parsed.length < 2) { + this.log.debug("REQ message missing params:", parsed); + return this.send([ + "NOTICE", + "invalid: REQ message missing params", + ]); + } + { + const sub_id = parsed[1]; + if (typeof sub_id !== "string") { + return this.send([ + "NOTICE", + "invalid: REQ subscription id must be a string", + ]); + } + let filters: NostrFilter[]; + try { + filters = validateFilters(parsed.slice(2)); + } catch (error) { + if (error instanceof SecurityValidationError) { + return this.send(["NOTICE", "invalid: REQ filters"]); + } + throw error; + } + return this.handleRequest(sub_id, filters); + } + + case "CLOSE": + if (parsed.length !== 2) { + this.log.debug("CLOSE message missing params:", parsed); + return this.send([ + "NOTICE", + "invalid: CLOSE message missing params", + ]); + } + return this.handleClose(parsed[1] as string); + } + } else { + // This could be a direct NIP-46 message, broadcast it to other clients + try { + this.host.broadcast(message, this.socket); + return; + } catch (e) { + this.log.error("Error broadcasting message:", e); + return; + } + } + } + + this.log.debug("unhandled message format:", message); + return this.send(["NOTICE", "Unable to handle message"]); + } catch (e) { + this.log.debug("failed to parse message:\n\n", message); + return this.send(["NOTICE", "Unable to parse message"]); + } + } + + private handleClose(sub_id: string): void { + this.log.info("closed subscription:", sub_id); + this.remSub(sub_id); + } + + handleError(err: Error): void { + this.log.info("socket encountered an error:\n\n", err); + } + + private async handleEvent(event: NostrEvent): Promise { + try { + // Special handling for NIP-46 events (kind 24133) + if (event.kind === 24133) { + // Validate basic structure but with NIP-46 specific validation + if (!(await this.validateNIP46Event(event))) { + this.log.debug("NIP-46 event failed validation:", event); + this.send([ + "OK", + event.id, + false, + "NIP-46 event failed validation", + ] as NostrOkMessage); + return; + } + + this.host.store(event); + + // Find subscriptions that match this event + for (const [uid, sub] of this.host.subscriptions().entries()) { + for (const filter of sub.filters) { + if (filter.kinds?.includes(24133)) { + // Check for #p tag filter - safe array access + const pTags = event.tags + .filter((tag) => { + try { + return ( + validateArrayAccess(tag, 0) && + safeArrayAccess(tag, 0) === "p" + ); + } catch { + return false; + } + }) + .map((tag) => { + try { + return safeArrayAccess(tag, 1); + } catch { + return null; + } + }) + .filter((val): val is string => typeof val === "string"); + const pFilters = filter["#p"] || []; + + // If there's a #p filter, make sure the event matches it + if ( + pFilters.length > 0 && + !pTags.some((tag) => pFilters.includes(tag)) + ) { + continue; + } + + // Send to matching subscription - safe array access + try { + const uidParts = uid.split("/"); + if (validateArrayAccess(uidParts, 1)) { + const subId = safeArrayAccess(uidParts, 1); + if (typeof subId !== "string") { + continue; + } + sub.instance.send(["EVENT", subId, event]); + break; + } + } catch (error) { + if (error instanceof SecurityValidationError) { + this.log.debug( + `Bounds checking error in subscription routing: ${error.message}`, + ); + } + continue; + } + } + } + } + + // Send OK message + this.send(["OK", event.id, true, ""] as NostrOkMessage); + return; + } + + // Standard event processing + this.log.client("received event id:", event.id); + this.log.debug("event:", event); + + // Standard event processing - wrap validateEvent in try-catch + try { + if (!(await validateEvent(event))) { + this.log.debug("event failed validation (returned false):", event); + this.send([ + "OK", + event.id, + false, + "event failed validation: validateEvent returned false", + ] as NostrOkMessage); + return; + } + } catch (validationError) { + // If validateEvent itself throws (e.g. NostrValidationError from getEventHash) + let errorMessage = "event validation error"; + if (validationError instanceof Error) { + errorMessage = validationError.message; + } + this.log.debug( + `event failed validation (threw error): ${errorMessage}`, + event, + ); + this.send([ + "OK", + event.id, + false, + `invalid: ${errorMessage}`, + ] as NostrOkMessage); + return; + } + + this.send(["OK", event.id, true, ""] as NostrOkMessage); + this.host.store(event); + + for (const { filters, instance, sub_id } of this.host + .subscriptions() + .values()) { + for (const filter of filters) { + if (matchesFilter(event, filter)) { + instance.sendMatchedEvent(sub_id, event); + } + } + } + } catch (e) { + this.log.error("Error processing event:", e); + } + } + + private handleRequest(sub_id: string, filters: NostrFilter[]): void { + if (filters.length === 0) { + this.log.client("request has no filters"); + return; + } + + this.log.client("received subscription request:", sub_id); + this.log.debug("filters:", filters); + + // Add subscription + this.addSub(sub_id, ...filters); + + // For each filter + let count = 0; + for (const filter of filters) { + // Set the limit count, if any + let limitCount = filter.limit; + + for (const event of this.host.cachedEvents()) { + // If limit is reached, stop sending events + if (limitCount !== undefined && limitCount <= 0) break; + + // Check if event matches filter + if (matchesFilter(event, filter)) { + this.send(["EVENT", sub_id, event]); + count++; + this.log.client(`event matched in cache: ${event.id}`); + this.log.client(`event matched subscription: ${sub_id}`); + + // Update limit counter + if (limitCount !== undefined) limitCount--; + } + } + } + + this.log.debug(`sent ${count} matching events from cache`); + + // Send EOSE + this.send(["EOSE", sub_id] as NostrEoseMessage); + } + + get log() { + const write = ( + level: "error" | "info" | "debug" | "trace", + messages: unknown[], + ) => { + const [message = "", ...args] = messages; + this._logger[level]( + `[Relay client ${this._sid}] ${String(message)}`, + ...args.map(toDiagnosticArgument), + ); + }; + + return { + client: (...msg: unknown[]) => write("trace", msg), + debug: (...msg: unknown[]) => write("debug", msg), + info: (...msg: unknown[]) => write("info", msg), + error: (...msg: unknown[]) => write("error", msg), + }; + } + + addSub(sub_id: string, ...filters: NostrFilter[]) { + const uid = `${this.sid}/${sub_id}`; + this.host.subscriptions().set(uid, { filters, instance: this, sub_id }); + this._subs.add(sub_id); + } + + remSub(subId: string) { + try { + const uid = `${this.sid}/${subId}`; + this.host.subscriptions().delete(uid); + this._subs.delete(subId); + } catch (e) { + // Ignore errors + } + } + + send(message: NostrRelayMessage) { + try { + if (this.socket.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify(message)); + } + } catch (e) { + this.log.error("Failed to send message:", e); + } + } + + sendMatchedEvent(subscriptionId: string, event: NostrEvent): void { + this.log.client(`event matched subscription: ${subscriptionId}`); + this.send(["EVENT", subscriptionId, event]); + } + + // Method to validate NIP-46 events + async validateNIP46Event(event: NostrEvent): Promise { + // Check required fields exist with proper types + if (!isValidPublicKeyPoint(event.pubkey)) { + this.log.debug("NIP-46 validation failed: invalid pubkey"); + return false; + } + + if (!event.created_at || typeof event.created_at !== "number") { + this.log.debug("NIP-46 validation failed: invalid created_at"); + return false; + } + + if (event.kind !== 24133) { + this.log.debug("NIP-46 validation failed: invalid kind"); + return false; + } + + if (!Array.isArray(event.tags)) { + this.log.debug("NIP-46 validation failed: invalid tags"); + return false; + } + + // For NIP-46, we need to have at least one p tag with a valid pubkey + const hasPTag = event.tags.some((tag: string[]) => { + try { + return ( + Array.isArray(tag) && + validateArrayAccess(tag, 0) && + validateArrayAccess(tag, 1) && + safeArrayAccess(tag, 0) === "p" && + typeof safeArrayAccess(tag, 1) === "string" && + isValidPublicKeyPoint(safeArrayAccess(tag, 1) as string) + ); + } catch (error) { + // If bounds checking fails, this tag is invalid + return false; + } + }); + + if (!hasPTag) { + // For debugging, log the tags structure + this.log.debug( + "NIP-46 validation failed: no valid p tag found", + JSON.stringify(event.tags), + ); + return false; + } + + if (typeof event.content !== "string") { + this.log.debug("NIP-46 validation failed: invalid content"); + return false; + } + + if ( + !event.sig || + typeof event.sig !== "string" || + !isValid64ByteHex(event.sig) + ) { + this.log.debug("NIP-46 validation failed: invalid signature"); + return false; + } + + // Verify signature for NIP-46 events using the canonical validateEvent + try { + if (!(await validateEvent(event))) { + this.log.debug( + "NIP-46 validation failed: invalid signature verification", + ); + return false; + } + } catch (error) { + this.log.debug( + "NIP-46 validation failed: error during signature verification", + error, + ); + return false; + } + + // Validate event.id: must be 64-char hex (case-insensitive) + if (!isValid32ByteHex(event.id)) { + this.log.debug("NIP-46 validation failed: invalid id format"); + return false; + } + + // For NIP-46, we've passed all the validation checks + return true; + } +} diff --git a/src/utils/ephemeral-relay/filter-match.ts b/src/utils/ephemeral-relay/filter-match.ts new file mode 100644 index 0000000..819b4a3 --- /dev/null +++ b/src/utils/ephemeral-relay/filter-match.ts @@ -0,0 +1,83 @@ +import type { NostrEvent, NostrFilter } from "../../types/nostr"; +import { safeArrayAccess, validateArrayAccess } from "../security-validator"; + +/** Return whether a Nostr Event satisfies one Subscription Filter. */ +export function matchesFilter( + event: NostrEvent, + filter: NostrFilter = {}, +): boolean { + const { authors, ids, kinds, since, until, search, ...rest } = filter; + + const tagFilters: string[][] = Object.entries(rest) + .filter(([key]) => key.startsWith("#")) + .map(([key, values]) => [key.slice(1), ...(values as string[])]); + + if (ids !== undefined && !ids.includes(event.id)) { + return false; + } + if (since !== undefined && event.created_at < since) { + return false; + } + if (until !== undefined && event.created_at > until) { + return false; + } + if (authors !== undefined && !authors.includes(event.pubkey)) { + return false; + } + if (kinds !== undefined && !kinds.includes(event.kind)) { + return false; + } + if (search !== undefined && search.length > 0) { + const query = search.toLowerCase(); + const contentMatch = event.content.toLowerCase().includes(query); + const tagMatch = event.tags.some((tag) => + tag.some((value) => value.toLowerCase().includes(query)), + ); + if (!contentMatch && !tagMatch) return false; + return tagFilters.length > 0 ? matchesTags(tagFilters, event.tags) : true; + } + return tagFilters.length > 0 ? matchesTags(tagFilters, event.tags) : true; +} + +function matchesTags(filters: string[][], tags: string[][]): boolean { + for (const filter of filters) { + let filterMatched = false; + + try { + if (!validateArrayAccess(filter, 0)) { + filterMatched = true; + continue; + } + + const key = safeArrayAccess(filter, 0); + const terms = filter.slice(1); + + if (terms.length === 0) { + filterMatched = true; + continue; + } + + for (const tag of tags) { + try { + if (!validateArrayAccess(tag, 0) || safeArrayAccess(tag, 0) !== key) { + continue; + } + + const params = tag.slice(1); + if (terms.some((term) => params.includes(term))) { + filterMatched = true; + break; + } + } catch { + // Malformed tags do not satisfy a filter. + } + } + } catch { + // Malformed filters do not match. + } + + if (!filterMatched) return false; + } + + return true; +} diff --git a/src/utils/ephemeral-relay/transport.ts b/src/utils/ephemeral-relay/transport.ts new file mode 100644 index 0000000..0cd3574 --- /dev/null +++ b/src/utils/ephemeral-relay/transport.ts @@ -0,0 +1,286 @@ +import { EventEmitter } from "events"; +import { WebSocket, WebSocketServer } from "ws"; +import { + InMemoryWebSocketServer, + registerInMemoryServer, + unregisterInMemoryServer, +} from "../inMemoryWebSocket"; +import type { DiagnosticLogArgument, DiagnosticLogger } from "../logger"; +import { maybeUnref } from "../timers"; + +export interface RelayTransport { + readonly actualPort: number | null; + readonly server: WebSocketServer; + start(): Promise; + close( + closeSessions: () => Promise, + forceSessions: () => void, + ): Promise; +} + +interface RelayTransportOptions { + port: number; + logger: DiagnosticLogger; + onConnection(socket: WebSocket): void; +} + +function toDiagnosticArgument(value: unknown): DiagnosticLogArgument { + if ( + value === null || + value === undefined || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + typeof value === "object" + ) { + return value; + } + + return String(value); +} + +export function createRelayTransport( + options: RelayTransportOptions, +): RelayTransport { + return new ManagedRelayTransport(options); +} + +class ManagedRelayTransport implements RelayTransport { + private readonly port: number; + private readonly logger: DiagnosticLogger; + private readonly onConnection: (socket: WebSocket) => void; + private webSocketServer: WebSocketServer | null = null; + private inMemoryServer: InMemoryWebSocketServer | null = null; + private acceptingConnections = false; + private boundPort: number | null = null; + + constructor(options: RelayTransportOptions) { + this.port = options.port; + this.logger = options.logger; + this.onConnection = options.onConnection; + } + + get actualPort(): number | null { + return this.boundPort; + } + + get server(): WebSocketServer { + if (!this.webSocketServer) { + throw new Error("websocket server not initialized"); + } + return this.webSocketServer; + } + + async start(): Promise { + // Bun on Linux CI has shown flakiness with real TCP listeners (port 0 / + // ephemeral ports). Prefer the in-memory transport for deterministic tests. + if ( + this.port === 0 && + typeof (globalThis as unknown as { Bun?: unknown }).Bun !== "undefined" + ) { + await this.startInMemory(); + return; + } + + await new Promise((resolve, reject) => { + try { + const server = new WebSocketServer({ + port: this.port, + host: "127.0.0.1", + }); + this.webSocketServer = server; + server.on("connection", (socket) => this.accept(socket)); + + const cleanup = () => { + server.off("listening", onListening); + server.off("error", onError); + }; + + const onListening = () => { + cleanup(); + const address = server.address(); + if (address && typeof address === "object" && "port" in address) { + this.boundPort = + typeof address.port === "number" && address.port > 0 + ? address.port + : null; + } + + // Some compatibility runtimes can bind port 0 without reporting the + // selected port. Use the in-memory transport instead of exposing :0. + if (this.port === 0 && !this.boundPort) { + this.resetFailedWebSocketServer(server); + this.startInMemory().then(resolve).catch(reject); + return; + } + + this.acceptingConnections = true; + resolve(); + }; + + const onError = (error: unknown) => { + cleanup(); + if (this.shouldFallbackToInMemory(error)) { + this.resetFailedWebSocketServer(server); + this.startInMemory().then(resolve).catch(reject); + } else { + this.resetFailedWebSocketServer(server); + reject(error); + } + }; + + server.once("listening", onListening); + server.once("error", onError); + } catch (error) { + if (this.shouldFallbackToInMemory(error)) { + this.startInMemory().then(resolve).catch(reject); + } else { + reject(error); + } + } + }); + } + + async close( + closeSessions: () => Promise, + forceSessions: () => void, + ): Promise { + this.acceptingConnections = false; + + const inMemoryServer = this.inMemoryServer; + const webSocketServer = inMemoryServer ? null : this.webSocketServer; + const transportShutdown = webSocketServer + ? this.closeWebSocketTransport(webSocketServer) + : Promise.resolve(); + + if (inMemoryServer) { + unregisterInMemoryServer(this.boundPort || this.port); + await closeSessions(); + inMemoryServer.removeAllListeners(); + } else if (webSocketServer) { + const sessionShutdown = closeSessions(); + let sessionTimeout: NodeJS.Timeout | null = null; + const timedOut = new Promise((resolve) => { + sessionTimeout = setTimeout(() => resolve(true), 1000); + maybeUnref(sessionTimeout); + }); + const sessionsClosed = sessionShutdown.then(() => false); + + if (await Promise.race([sessionsClosed, timedOut])) { + this.logger.warn("Relay client close timed out; forcing cleanup"); + forceSessions(); + await sessionShutdown; + } + if (sessionTimeout) clearTimeout(sessionTimeout); + + await transportShutdown; + webSocketServer.removeAllListeners(); + } + + this.inMemoryServer = null; + this.webSocketServer = null; + this.boundPort = null; + } + + private accept(socket: WebSocket): void { + if (!this.acceptingConnections) { + try { + socket.terminate(); + } catch { + try { + socket.close(1001, "Relay shutting down"); + } catch { + // The Relay is already closing; there is no session state to retain. + } + } + return; + } + + this.onConnection(socket); + } + + private startInMemory(): Promise { + const { server, port } = registerInMemoryServer( + this.port === 0 ? undefined : this.port, + ); + + this.inMemoryServer = server; + this.webSocketServer = server as unknown as WebSocketServer; + this.boundPort = port; + server.on("connection", (socket: EventEmitter) => + this.accept(socket as WebSocket), + ); + this.acceptingConnections = true; + + return new Promise((resolve) => queueMicrotask(resolve)); + } + + private shouldFallbackToInMemory(error: unknown): boolean { + if (!error || typeof error !== "object") { + return false; + } + const code = + "code" in error && typeof (error as { code: unknown }).code === "string" + ? (error as { code: string }).code + : ""; + const message = + "message" in error && + typeof (error as { message: unknown }).message === "string" + ? (error as { message: string }).message + : ""; + // In restricted runtimes, binding to port 0 may report EADDRINUSE even + // though no specific port was requested; fall back to in-memory relay. + const isDynamicPortConflict = + this.port === 0 && + (code === "EADDRINUSE" || message.includes("EADDRINUSE")); + return ( + isDynamicPortConflict || + code === "EACCES" || + code === "EPERM" || + code === "EADDRNOTAVAIL" || + message.includes("EPERM") || + message.includes("EACCES") || + message.includes("EADDRNOTAVAIL") + ); + } + + private resetFailedWebSocketServer(server: WebSocketServer): void { + server.removeAllListeners(); + try { + server.close(); + } catch { + // A listener that failed to bind may already be fully closed. + } + if (this.webSocketServer === server) this.webSocketServer = null; + this.boundPort = null; + this.acceptingConnections = false; + } + + private closeWebSocketTransport(server: WebSocketServer): Promise { + return new Promise((resolve) => { + let timeout: NodeJS.Timeout | null = null; + const finish = () => { + if (timeout) clearTimeout(timeout); + timeout = null; + resolve(); + }; + + timeout = setTimeout(() => { + this.logger.warn("Relay transport close timed out; forcing cleanup"); + finish(); + }, 1000); + maybeUnref(timeout); + + try { + // Calling close immediately stops the transport accepting new sockets; + // its callback still waits for tracked sessions to drain. + server.close(finish); + } catch (error) { + this.logger.warn("Relay transport close failed", { + error: toDiagnosticArgument(error), + }); + finish(); + } + }); + } +} diff --git a/tests/utils/ephemeral-relay-filter.test.ts b/tests/utils/ephemeral-relay-filter.test.ts new file mode 100644 index 0000000..5e19934 --- /dev/null +++ b/tests/utils/ephemeral-relay-filter.test.ts @@ -0,0 +1,61 @@ +import { matchesFilter } from "../../src/utils/ephemeral-relay/filter-match"; +import type { NostrEvent } from "../../src/types/nostr"; + +const event: NostrEvent = { + id: "a".repeat(64), + pubkey: "b".repeat(64), + created_at: 1_700_000_000, + kind: 1, + tags: [["t", "nostr"]], + content: "A public note", + sig: "c".repeat(128), +}; + +describe("ephemeral Relay Subscription Filter matching", () => { + test("matches identifiers, authors, kinds, and inclusive time bounds", () => { + expect( + matchesFilter(event, { + ids: [event.id], + authors: [event.pubkey], + kinds: [1], + since: event.created_at, + until: event.created_at, + }), + ).toBe(true); + + expect(matchesFilter(event, { ids: ["d".repeat(64)] })).toBe(false); + expect(matchesFilter(event, { authors: ["e".repeat(64)] })).toBe(false); + expect(matchesFilter(event, { kinds: [7] })).toBe(false); + expect(matchesFilter(event, { since: event.created_at + 1 })).toBe(false); + expect(matchesFilter(event, { until: event.created_at - 1 })).toBe(false); + }); + + test("requires every tag filter and allows any value within each tag", () => { + const taggedEvent = { + ...event, + tags: [ + ["t", "nostr"], + ["p", "friend"], + ], + }; + + expect( + matchesFilter(taggedEvent, { + "#t": ["bitcoin", "nostr"], + "#p": ["friend"], + }), + ).toBe(true); + expect( + matchesFilter(taggedEvent, { + "#t": ["nostr"], + "#p": ["stranger"], + }), + ).toBe(false); + }); + + test("searches content and tag values case-insensitively", () => { + expect(matchesFilter(event, { search: "PUBLIC NOTE" })).toBe(true); + expect(matchesFilter(event, { search: "NOSTR" })).toBe(true); + expect(matchesFilter(event, { search: "missing" })).toBe(false); + }); +}); diff --git a/tests/utils/ephemeral-relay-internals.test.ts b/tests/utils/ephemeral-relay-internals.test.ts new file mode 100644 index 0000000..eec79a2 --- /dev/null +++ b/tests/utils/ephemeral-relay-internals.test.ts @@ -0,0 +1,55 @@ +import { readFileSync } from "fs"; +import { resolve } from "path"; + +const facadeSource = readFileSync( + resolve(process.cwd(), "src/utils/ephemeral-relay.ts"), + "utf8", +); +const sessionSource = readFileSync( + resolve(process.cwd(), "src/utils/ephemeral-relay/client-session.ts"), + "utf8", +); +const packageManifest = JSON.parse( + readFileSync(resolve(process.cwd(), "package.json"), "utf8"), +) as { exports: Record }; + +describe("ephemeral Relay internal ownership", () => { + test("Subscription Filter matching is owned by its internal module", () => { + expect(sessionSource).toContain('from "./filter-match"'); + expect(facadeSource).not.toMatch(/function match_filter\s*\(/); + expect(facadeSource).not.toMatch(/function match_tags\s*\(/); + }); + + test("client-session protocol state is owned by its internal module", () => { + expect(facadeSource).toContain('from "./ephemeral-relay/client-session"'); + expect(facadeSource).not.toMatch(/class ClientSession\s*{/); + }); + + test("client sessions depend on a narrow host instead of the Relay facade", () => { + expect(sessionSource).toContain("export interface RelaySessionHost"); + expect(sessionSource).not.toContain('from "../ephemeral-relay"'); + expect(facadeSource).toContain("createClientSession"); + expect(facadeSource).not.toMatch(/instance\._(?:handler|onerr|cleanup)/); + }); + + test("connection lifecycle is owned by its internal transport module", () => { + expect(facadeSource).toContain('from "./ephemeral-relay/transport"'); + expect(facadeSource).not.toContain("new WebSocketServer"); + expect(facadeSource).not.toContain("registerInMemoryServer"); + expect(facadeSource).not.toContain("unregisterInMemoryServer"); + expect(facadeSource).not.toContain("closeWebSocketTransport"); + expect(facadeSource).not.toContain("_acceptingConnections"); + }); + + test("private Relay owners do not become package entrypoints", () => { + expect(packageManifest.exports).not.toHaveProperty( + "./utils/ephemeral-relay/client-session", + ); + expect(packageManifest.exports).not.toHaveProperty( + "./utils/ephemeral-relay/filter-match", + ); + expect(packageManifest.exports).not.toHaveProperty( + "./utils/ephemeral-relay/transport", + ); + }); +}); diff --git a/tests/utils/ephemeral-relay-session.test.ts b/tests/utils/ephemeral-relay-session.test.ts new file mode 100644 index 0000000..cb90ef8 --- /dev/null +++ b/tests/utils/ephemeral-relay-session.test.ts @@ -0,0 +1,56 @@ +import { Relay } from "../../src/nip01/relay"; +import { RelayEvent } from "../../src/types/nostr"; +import { getRelaySocket, NostrRelay } from "../../src/testing"; + +function sendAndReceiveNotice(relay: Relay, message: string): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("Timed out waiting for Relay NOTICE")), + 1000, + ); + const handler = (_relayUrl: string, notice: string) => { + clearTimeout(timeout); + relay.off(RelayEvent.Notice, handler); + resolve(notice); + }; + relay.on(RelayEvent.Notice, handler); + + const socket = getRelaySocket(relay); + if (!socket) { + clearTimeout(timeout); + relay.off(RelayEvent.Notice, handler); + reject(new Error("Relay socket is not connected")); + return; + } + socket.send(message); + }); +} + +describe("ephemeral Relay client session", () => { + test("keeps malformed protocol handling compatible", async () => { + const server = new NostrRelay(0); + let client: Relay | null = null; + + try { + await server.start(); + client = new Relay(server.url, { + autoReconnect: false, + connectionTimeout: 1000, + }); + expect(await client.connect()).toBe(true); + + await expect( + sendAndReceiveNotice(client, JSON.stringify(["EVENT"])), + ).resolves.toBe("invalid: EVENT message missing params"); + await expect( + sendAndReceiveNotice(client, JSON.stringify(["CLOSE"])), + ).resolves.toBe("invalid: CLOSE message missing params"); + await expect(sendAndReceiveNotice(client, "not-json")).resolves.toBe( + "Unable to parse message", + ); + } finally { + client?.disconnect(); + await server.close(); + } + }); +}); From 7a4858679b03a73e4be3dabf93df9e55b57ae3c0 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sun, 19 Jul 2026 09:41:27 -0500 Subject: [PATCH 3/8] refactor: deepen relay internal boundaries --- src/utils/diagnostics.ts | 16 ++++ src/utils/ephemeral-relay.ts | 39 ++------- src/utils/ephemeral-relay/client-session.ts | 71 ++++++++-------- src/utils/ephemeral-relay/transport.ts | 31 ++++--- tests/utils/ephemeral-relay-internals.test.ts | 1 + tests/utils/ephemeral-relay-session.test.ts | 80 ++++++++++++++++++- 6 files changed, 148 insertions(+), 90 deletions(-) diff --git a/src/utils/diagnostics.ts b/src/utils/diagnostics.ts index ef913e5..cfdf72e 100644 --- a/src/utils/diagnostics.ts +++ b/src/utils/diagnostics.ts @@ -9,6 +9,22 @@ import { type DiagnosticMethod = keyof DiagnosticLogger; const SAFE_ERROR_NAME = /^(?:Error|[A-Z][A-Za-z0-9]{0,58}Error)$/; +/** Convert an unknown value into the structured diagnostic contract. */ +export function asDiagnosticArgument(value: unknown): DiagnosticLogArgument { + if ( + value === null || + value === undefined || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + typeof value === "object" + ) { + return value; + } + + return String(value); +} + /** Emit an observational diagnostic without allowing the sink to alter behavior. */ export function reportDiagnostic( logger: DiagnosticLogger, diff --git a/src/utils/ephemeral-relay.ts b/src/utils/ephemeral-relay.ts index e16fefe..7d93d35 100644 --- a/src/utils/ephemeral-relay.ts +++ b/src/utils/ephemeral-relay.ts @@ -1,9 +1,9 @@ import EventEmitter from "events"; -import { WebSocket } from "ws"; import { NostrEvent } from "../types/nostr"; +import { protectDiagnosticLogger } from "./diagnostics"; import { maybeUnref } from "./timers"; import { notifyRelayDisconnectObservers } from "./websocket"; -import { DiagnosticLogArgument, DiagnosticLogger, Logger } from "./logger"; +import { DiagnosticLogger, Logger } from "./logger"; import { createClientSession, RelaySession, @@ -20,30 +20,6 @@ import { // Prefer 127.0.0.1 over localhost to avoid IPv6 resolution issues in CI. const HOST = "ws://127.0.0.1"; -function createNonThrowingDiagnosticLogger( - logger: DiagnosticLogger, -): DiagnosticLogger { - const write = ( - level: keyof DiagnosticLogger, - message: string, - args: DiagnosticLogArgument[], - ) => { - try { - logger[level](message, ...args); - } catch { - // Diagnostics are observational and must not alter Relay behavior. - } - }; - - return { - error: (message, ...args) => write("error", message, args), - warn: (message, ...args) => write("warn", message, args), - info: (message, ...args) => write("info", message, args), - debug: (message, ...args) => write("debug", message, args), - trace: (message, ...args) => write("trace", message, args), - }; -} - /* ================ [ Interfaces ] ================ */ /** Optional lifecycle settings for the public ephemeral Relay. */ @@ -86,7 +62,7 @@ export class NostrRelay { this._port = port; this._purge = options.purgeInterval ?? null; this._subs = new Map(); - this._logger = createNonThrowingDiagnosticLogger( + this._logger = protectDiagnosticLogger( options.logger ?? new Logger({ silent: true }), ); this._sessions = new Set(); @@ -94,13 +70,8 @@ export class NostrRelay { cachedEvents: () => this._cache, subscriptions: () => this._subs, store: (event) => this.store(event), - broadcast: (message, sender) => { - this.wss.clients.forEach((client) => { - if (client !== sender && client.readyState === WebSocket.OPEN) { - client.send(message); - } - }); - }, + broadcast: (message, sender) => + this._transport?.broadcast(message, sender), clientDisconnected: () => { this.conn = Math.max(0, this.conn - 1); }, diff --git a/src/utils/ephemeral-relay/client-session.ts b/src/utils/ephemeral-relay/client-session.ts index 1d5fd1a..926f180 100644 --- a/src/utils/ephemeral-relay/client-session.ts +++ b/src/utils/ephemeral-relay/client-session.ts @@ -6,8 +6,9 @@ import type { NostrOkMessage, NostrRelayMessage, } from "../../types/protocol"; +import { asDiagnosticArgument } from "../diagnostics"; import { isValidPublicKeyPoint } from "../key-validation"; -import type { DiagnosticLogArgument, DiagnosticLogger } from "../logger"; +import type { DiagnosticLogger } from "../logger"; import { safeArrayAccess, SecurityValidationError, @@ -25,21 +26,7 @@ function isValid64ByteHex(hex: string): boolean { return isHexOfLength(hex, 128); } -function toDiagnosticArgument(value: unknown): DiagnosticLogArgument { - if ( - value === null || - value === undefined || - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" || - typeof value === "object" - ) { - return value; - } - - return String(value); -} - +/** Lifecycle and protocol operations exposed to the Relay composition root. */ export interface RelaySession { readonly closed: Promise; close(): Promise; @@ -48,12 +35,14 @@ export interface RelaySession { sendMatchedEvent(subscriptionId: string, event: NostrEvent): void; } +/** Subscription state shared between active client sessions. */ export interface RelaySubscription { filters: NostrFilter[]; instance: RelaySession; - sub_id: string; + subscriptionId: string; } +/** Narrow Relay capabilities required by one client session. */ export interface RelaySessionHost { cachedEvents(): readonly NostrEvent[]; subscriptions(): Map; @@ -62,6 +51,7 @@ export interface RelaySessionHost { clientDisconnected(): void; } +/** Create a client session and attach its socket event handlers. */ export function createClientSession( host: RelaySessionHost, socket: WebSocket, @@ -90,6 +80,7 @@ export function createClientSession( return session; } +/** Owns one client's wire protocol, subscriptions, and socket lifecycle. */ class ClientSession implements RelaySession { private _sid: string; private readonly _host: RelaySessionHost; @@ -190,7 +181,7 @@ class ClientSession implements RelaySession { } catch (error) { this._logger.warn("Relay client close failed", { sessionId: this._sid, - error: toDiagnosticArgument(error), + error: asDiagnosticArgument(error), }); this.cleanup(1006); } @@ -232,7 +223,7 @@ class ClientSession implements RelaySession { } catch (e) { this._logger.error("Relay client cleanup failed", { sessionId: this._sid, - error: toDiagnosticArgument(e), + error: asDiagnosticArgument(e), }); } finally { this._resolveClosed(); @@ -271,8 +262,8 @@ class ClientSession implements RelaySession { ]); } { - const sub_id = parsed[1]; - if (typeof sub_id !== "string") { + const subscriptionId = parsed[1]; + if (typeof subscriptionId !== "string") { return this.send([ "NOTICE", "invalid: REQ subscription id must be a string", @@ -287,7 +278,7 @@ class ClientSession implements RelaySession { } throw error; } - return this.handleRequest(sub_id, filters); + return this.handleRequest(subscriptionId, filters); } case "CLOSE": @@ -320,9 +311,9 @@ class ClientSession implements RelaySession { } } - private handleClose(sub_id: string): void { - this.log.info("closed subscription:", sub_id); - this.remSub(sub_id); + private handleClose(subscriptionId: string): void { + this.log.info("closed subscription:", subscriptionId); + this.remSub(subscriptionId); } handleError(err: Error): void { @@ -447,12 +438,12 @@ class ClientSession implements RelaySession { this.send(["OK", event.id, true, ""] as NostrOkMessage); this.host.store(event); - for (const { filters, instance, sub_id } of this.host + for (const { filters, instance, subscriptionId } of this.host .subscriptions() .values()) { for (const filter of filters) { if (matchesFilter(event, filter)) { - instance.sendMatchedEvent(sub_id, event); + instance.sendMatchedEvent(subscriptionId, event); } } } @@ -461,17 +452,17 @@ class ClientSession implements RelaySession { } } - private handleRequest(sub_id: string, filters: NostrFilter[]): void { + private handleRequest(subscriptionId: string, filters: NostrFilter[]): void { if (filters.length === 0) { this.log.client("request has no filters"); return; } - this.log.client("received subscription request:", sub_id); + this.log.client("received subscription request:", subscriptionId); this.log.debug("filters:", filters); // Add subscription - this.addSub(sub_id, ...filters); + this.addSub(subscriptionId, ...filters); // For each filter let count = 0; @@ -485,10 +476,10 @@ class ClientSession implements RelaySession { // Check if event matches filter if (matchesFilter(event, filter)) { - this.send(["EVENT", sub_id, event]); + this.send(["EVENT", subscriptionId, event]); count++; this.log.client(`event matched in cache: ${event.id}`); - this.log.client(`event matched subscription: ${sub_id}`); + this.log.client(`event matched subscription: ${subscriptionId}`); // Update limit counter if (limitCount !== undefined) limitCount--; @@ -499,7 +490,7 @@ class ClientSession implements RelaySession { this.log.debug(`sent ${count} matching events from cache`); // Send EOSE - this.send(["EOSE", sub_id] as NostrEoseMessage); + this.send(["EOSE", subscriptionId] as NostrEoseMessage); } get log() { @@ -510,7 +501,7 @@ class ClientSession implements RelaySession { const [message = "", ...args] = messages; this._logger[level]( `[Relay client ${this._sid}] ${String(message)}`, - ...args.map(toDiagnosticArgument), + ...args.map(asDiagnosticArgument), ); }; @@ -522,10 +513,14 @@ class ClientSession implements RelaySession { }; } - addSub(sub_id: string, ...filters: NostrFilter[]) { - const uid = `${this.sid}/${sub_id}`; - this.host.subscriptions().set(uid, { filters, instance: this, sub_id }); - this._subs.add(sub_id); + addSub(subscriptionId: string, ...filters: NostrFilter[]) { + const uid = `${this.sid}/${subscriptionId}`; + this.host.subscriptions().set(uid, { + filters, + instance: this, + subscriptionId, + }); + this._subs.add(subscriptionId); } remSub(subId: string) { diff --git a/src/utils/ephemeral-relay/transport.ts b/src/utils/ephemeral-relay/transport.ts index 0cd3574..096c93f 100644 --- a/src/utils/ephemeral-relay/transport.ts +++ b/src/utils/ephemeral-relay/transport.ts @@ -5,13 +5,15 @@ import { registerInMemoryServer, unregisterInMemoryServer, } from "../inMemoryWebSocket"; -import type { DiagnosticLogArgument, DiagnosticLogger } from "../logger"; +import { asDiagnosticArgument } from "../diagnostics"; +import type { DiagnosticLogger } from "../logger"; import { maybeUnref } from "../timers"; export interface RelayTransport { readonly actualPort: number | null; readonly server: WebSocketServer; start(): Promise; + broadcast(message: string, sender: WebSocket): void; close( closeSessions: () => Promise, forceSessions: () => void, @@ -24,27 +26,14 @@ interface RelayTransportOptions { onConnection(socket: WebSocket): void; } -function toDiagnosticArgument(value: unknown): DiagnosticLogArgument { - if ( - value === null || - value === undefined || - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" || - typeof value === "object" - ) { - return value; - } - - return String(value); -} - +/** Create one private owner for Relay connection and shutdown mechanics. */ export function createRelayTransport( options: RelayTransportOptions, ): RelayTransport { return new ManagedRelayTransport(options); } +/** Owns native/in-memory selection, connections, and ordered shutdown. */ class ManagedRelayTransport implements RelayTransport { private readonly port: number; private readonly logger: DiagnosticLogger; @@ -141,6 +130,14 @@ class ManagedRelayTransport implements RelayTransport { }); } + broadcast(message: string, sender: WebSocket): void { + this.server.clients.forEach((client) => { + if (client !== sender && client.readyState === WebSocket.OPEN) { + client.send(message); + } + }); + } + async close( closeSessions: () => Promise, forceSessions: () => void, @@ -277,7 +274,7 @@ class ManagedRelayTransport implements RelayTransport { server.close(finish); } catch (error) { this.logger.warn("Relay transport close failed", { - error: toDiagnosticArgument(error), + error: asDiagnosticArgument(error), }); finish(); } diff --git a/tests/utils/ephemeral-relay-internals.test.ts b/tests/utils/ephemeral-relay-internals.test.ts index eec79a2..57df9d7 100644 --- a/tests/utils/ephemeral-relay-internals.test.ts +++ b/tests/utils/ephemeral-relay-internals.test.ts @@ -39,6 +39,7 @@ describe("ephemeral Relay internal ownership", () => { expect(facadeSource).not.toContain("unregisterInMemoryServer"); expect(facadeSource).not.toContain("closeWebSocketTransport"); expect(facadeSource).not.toContain("_acceptingConnections"); + expect(facadeSource).not.toContain(".clients.forEach"); }); test("private Relay owners do not become package entrypoints", () => { diff --git a/tests/utils/ephemeral-relay-session.test.ts b/tests/utils/ephemeral-relay-session.test.ts index cb90ef8..ab381b5 100644 --- a/tests/utils/ephemeral-relay-session.test.ts +++ b/tests/utils/ephemeral-relay-session.test.ts @@ -1,6 +1,35 @@ import { Relay } from "../../src/nip01/relay"; -import { RelayEvent } from "../../src/types/nostr"; +import { createEvent, createSignedEvent } from "../../src/nip01/event"; +import { NostrEvent, RelayEvent } from "../../src/types/nostr"; import { getRelaySocket, NostrRelay } from "../../src/testing"; +import { getPublicKey } from "../../src/utils/crypto"; + +const PRIVATE_KEY = "1".repeat(64); + +async function createRelayEvent(content: string): Promise { + return createSignedEvent( + createEvent( + { + kind: 1, + tags: [], + content, + created_at: Math.floor(Date.now() / 1000), + }, + getPublicKey(PRIVATE_KEY), + ), + PRIVATE_KEY, + ); +} + +function withTimeout(promise: Promise, message: string): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => { + const timeout = setTimeout(() => reject(new Error(message)), 1000); + timeout.unref?.(); + }), + ]); +} function sendAndReceiveNotice(relay: Relay, message: string): Promise { return new Promise((resolve, reject) => { @@ -53,4 +82,53 @@ describe("ephemeral Relay client session", () => { await server.close(); } }); + + test("routes successful REQ, EOSE, EVENT, and CLOSE messages", async () => { + const server = new NostrRelay(0); + let publisher: Relay | null = null; + let subscriber: Relay | null = null; + + try { + await server.start(); + publisher = new Relay(server.url, { + autoReconnect: false, + connectionTimeout: 1000, + }); + subscriber = new Relay(server.url, { + autoReconnect: false, + connectionTimeout: 1000, + }); + expect(await publisher.connect()).toBe(true); + expect(await subscriber.connect()).toBe(true); + + let resolveEvent!: (event: NostrEvent) => void; + let resolveEose!: () => void; + const receivedEvent = new Promise((resolve) => { + resolveEvent = resolve; + }); + const receivedEose = new Promise((resolve) => { + resolveEose = resolve; + }); + const subscriptionId = subscriber.subscribe( + [{ kinds: [1] }], + resolveEvent, + resolveEose, + ); + + await withTimeout(receivedEose, "Timed out waiting for EOSE"); + const event = await createRelayEvent("session owner happy path"); + await expect( + publisher.publish(event, { timeout: 1000 }), + ).resolves.toMatchObject({ success: true }); + await expect( + withTimeout(receivedEvent, "Timed out waiting for EVENT"), + ).resolves.toMatchObject({ id: event.id }); + + subscriber.unsubscribe(subscriptionId); + } finally { + publisher?.disconnect(); + subscriber?.disconnect(); + await server.close(); + } + }); }); From f8e9aac107df42896c5f75efa02a16f1a4d43024 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sun, 19 Jul 2026 09:55:21 -0500 Subject: [PATCH 4/8] fix: harden relay transport cleanup --- src/utils/ephemeral-relay/client-session.ts | 22 ++----- src/utils/ephemeral-relay/transport.ts | 59 +++++++++++-------- tests/utils/ephemeral-relay-lifecycle.test.ts | 19 ++++++ tests/utils/ephemeral-relay-session.test.ts | 31 +++++++++- tests/utils/ephemeral-relay-transport.test.ts | 47 +++++++++++++++ 5 files changed, 136 insertions(+), 42 deletions(-) create mode 100644 tests/utils/ephemeral-relay-transport.test.ts diff --git a/src/utils/ephemeral-relay/client-session.ts b/src/utils/ephemeral-relay/client-session.ts index 926f180..b3e4562 100644 --- a/src/utils/ephemeral-relay/client-session.ts +++ b/src/utils/ephemeral-relay/client-session.ts @@ -339,7 +339,7 @@ class ClientSession implements RelaySession { this.host.store(event); // Find subscriptions that match this event - for (const [uid, sub] of this.host.subscriptions().entries()) { + for (const sub of this.host.subscriptions().values()) { for (const filter of sub.filters) { if (filter.kinds?.includes(24133)) { // Check for #p tag filter - safe array access @@ -372,25 +372,11 @@ class ClientSession implements RelaySession { continue; } - // Send to matching subscription - safe array access - try { - const uidParts = uid.split("/"); - if (validateArrayAccess(uidParts, 1)) { - const subId = safeArrayAccess(uidParts, 1); - if (typeof subId !== "string") { - continue; - } - sub.instance.send(["EVENT", subId, event]); - break; - } - } catch (error) { - if (error instanceof SecurityValidationError) { - this.log.debug( - `Bounds checking error in subscription routing: ${error.message}`, - ); - } + if (typeof sub.subscriptionId !== "string") { continue; } + sub.instance.sendMatchedEvent(sub.subscriptionId, event); + break; } } } diff --git a/src/utils/ephemeral-relay/transport.ts b/src/utils/ephemeral-relay/transport.ts index 096c93f..457492a 100644 --- a/src/utils/ephemeral-relay/transport.ts +++ b/src/utils/ephemeral-relay/transport.ts @@ -87,6 +87,7 @@ class ManagedRelayTransport implements RelayTransport { const onListening = () => { cleanup(); + server.on("error", (error) => this.handleRuntimeError(error)); const address = server.address(); if (address && typeof address === "object" && "port" in address) { this.boundPort = @@ -149,34 +150,39 @@ class ManagedRelayTransport implements RelayTransport { const transportShutdown = webSocketServer ? this.closeWebSocketTransport(webSocketServer) : Promise.resolve(); + let sessionTimeout: NodeJS.Timeout | null = null; - if (inMemoryServer) { - unregisterInMemoryServer(this.boundPort || this.port); - await closeSessions(); - inMemoryServer.removeAllListeners(); - } else if (webSocketServer) { - const sessionShutdown = closeSessions(); - let sessionTimeout: NodeJS.Timeout | null = null; - const timedOut = new Promise((resolve) => { - sessionTimeout = setTimeout(() => resolve(true), 1000); - maybeUnref(sessionTimeout); - }); - const sessionsClosed = sessionShutdown.then(() => false); - - if (await Promise.race([sessionsClosed, timedOut])) { - this.logger.warn("Relay client close timed out; forcing cleanup"); - forceSessions(); - await sessionShutdown; + try { + if (inMemoryServer) { + unregisterInMemoryServer(this.boundPort || this.port); + await closeSessions(); + } else if (webSocketServer) { + const sessionShutdown = closeSessions(); + const timedOut = new Promise((resolve) => { + sessionTimeout = setTimeout(() => resolve(true), 1000); + maybeUnref(sessionTimeout); + }); + const sessionsClosed = sessionShutdown.then(() => false); + + if (await Promise.race([sessionsClosed, timedOut])) { + this.logger.warn("Relay client close timed out; forcing cleanup"); + forceSessions(); + await sessionShutdown; + } } + } finally { if (sessionTimeout) clearTimeout(sessionTimeout); - await transportShutdown; - webSocketServer.removeAllListeners(); + try { + await transportShutdown; + inMemoryServer?.removeAllListeners(); + webSocketServer?.removeAllListeners(); + } finally { + this.inMemoryServer = null; + this.webSocketServer = null; + this.boundPort = null; + } } - - this.inMemoryServer = null; - this.webSocketServer = null; - this.boundPort = null; } private accept(socket: WebSocket): void { @@ -207,6 +213,7 @@ class ManagedRelayTransport implements RelayTransport { server.on("connection", (socket: EventEmitter) => this.accept(socket as WebSocket), ); + server.on("error", (error) => this.handleRuntimeError(error)); this.acceptingConnections = true; return new Promise((resolve) => queueMicrotask(resolve)); @@ -241,6 +248,12 @@ class ManagedRelayTransport implements RelayTransport { ); } + private handleRuntimeError(error: unknown): void { + this.logger.warn("Relay transport error", { + error: asDiagnosticArgument(error), + }); + } + private resetFailedWebSocketServer(server: WebSocketServer): void { server.removeAllListeners(); try { diff --git a/tests/utils/ephemeral-relay-lifecycle.test.ts b/tests/utils/ephemeral-relay-lifecycle.test.ts index 753a04a..bc42f88 100644 --- a/tests/utils/ephemeral-relay-lifecycle.test.ts +++ b/tests/utils/ephemeral-relay-lifecycle.test.ts @@ -227,6 +227,25 @@ describe("NostrRelay lifecycle", () => { ]); }); + test("reports server errors that occur after startup", async () => { + const { NostrRelay } = await import("../../src/testing"); + const { logger, warn } = createDiagnosticLogger(); + const relay = new NostrRelay(0, { logger }); + + try { + await relay.start(); + + expect(() => + relay.wss.emit("error", new Error("late failure")), + ).not.toThrow(); + expect(warn).toHaveBeenCalledWith("Relay transport error", { + error: expect.any(Error), + }); + } finally { + await relay.close(); + } + }); + test("a throwing diagnostic logger cannot alter Relay lifecycle behavior", async () => { const { NostrRelay } = await import("../../src/testing"); const relay = new NostrRelay(0, { diff --git a/tests/utils/ephemeral-relay-session.test.ts b/tests/utils/ephemeral-relay-session.test.ts index ab381b5..aef8b17 100644 --- a/tests/utils/ephemeral-relay-session.test.ts +++ b/tests/utils/ephemeral-relay-session.test.ts @@ -31,6 +31,19 @@ function withTimeout(promise: Promise, message: string): Promise { ]); } +async function waitFor(condition: () => boolean): Promise { + await withTimeout( + new Promise((resolve) => { + const check = () => { + if (condition()) resolve(); + else setTimeout(check, 10); + }; + check(); + }), + "Timed out waiting for Relay session state", + ); +} + function sendAndReceiveNotice(relay: Relay, message: string): Promise { return new Promise((resolve, reject) => { const timeout = setTimeout( @@ -103,6 +116,7 @@ describe("ephemeral Relay client session", () => { let resolveEvent!: (event: NostrEvent) => void; let resolveEose!: () => void; + const routedEvents: NostrEvent[] = []; const receivedEvent = new Promise((resolve) => { resolveEvent = resolve; }); @@ -111,7 +125,10 @@ describe("ephemeral Relay client session", () => { }); const subscriptionId = subscriber.subscribe( [{ kinds: [1] }], - resolveEvent, + (event) => { + routedEvents.push(event); + resolveEvent(event); + }, resolveEose, ); @@ -125,6 +142,18 @@ describe("ephemeral Relay client session", () => { ).resolves.toMatchObject({ id: event.id }); subscriber.unsubscribe(subscriptionId); + await waitFor(() => server.subs.size === 0); + + const eventAfterClose = await createRelayEvent( + "session owner after CLOSE", + ); + await expect( + publisher.publish(eventAfterClose, { timeout: 1000 }), + ).resolves.toMatchObject({ success: true }); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(routedEvents.map((routedEvent) => routedEvent.id)).toEqual([ + event.id, + ]); } finally { publisher?.disconnect(); subscriber?.disconnect(); diff --git a/tests/utils/ephemeral-relay-transport.test.ts b/tests/utils/ephemeral-relay-transport.test.ts new file mode 100644 index 0000000..849988d --- /dev/null +++ b/tests/utils/ephemeral-relay-transport.test.ts @@ -0,0 +1,47 @@ +import type { DiagnosticLogger } from "../../src/utils/logger"; +import { createRelayTransport } from "../../src/utils/ephemeral-relay/transport"; + +const logger: DiagnosticLogger = { + error: () => {}, + warn: () => {}, + info: () => {}, + debug: () => {}, + trace: () => {}, +}; + +describe("ephemeral Relay transport", () => { + test("releases its server when session shutdown rejects", async () => { + const globals = globalThis as typeof globalThis & { Bun?: unknown }; + const isBun = typeof globals.Bun !== "undefined"; + const hadBun = Object.prototype.hasOwnProperty.call(globals, "Bun"); + const previousBun = globals.Bun; + if (!isBun) globals.Bun = {}; + const transport = createRelayTransport({ + port: 0, + logger, + onConnection: () => {}, + }); + const shutdownError = new Error("session shutdown failed"); + + try { + await transport.start(); + + await expect( + transport.close( + async () => { + throw shutdownError; + }, + () => {}, + ), + ).rejects.toBe(shutdownError); + expect(() => transport.server).toThrow( + "websocket server not initialized", + ); + } finally { + if (!isBun) { + if (hadBun) globals.Bun = previousBun; + else delete globals.Bun; + } + } + }); +}); From 96dd79f5131a25138419bb8a6310fb084c054e00 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sun, 19 Jul 2026 10:09:38 -0500 Subject: [PATCH 5/8] test: stop relay session polling cleanly --- tests/utils/ephemeral-relay-session.test.ts | 53 ++++++++++++++------- 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/tests/utils/ephemeral-relay-session.test.ts b/tests/utils/ephemeral-relay-session.test.ts index aef8b17..4439255 100644 --- a/tests/utils/ephemeral-relay-session.test.ts +++ b/tests/utils/ephemeral-relay-session.test.ts @@ -22,26 +22,45 @@ async function createRelayEvent(content: string): Promise { } function withTimeout(promise: Promise, message: string): Promise { - return Promise.race([ - promise, - new Promise((_, reject) => { - const timeout = setTimeout(() => reject(new Error(message)), 1000); - timeout.unref?.(); - }), - ]); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(message)), 1000); + timeout.unref?.(); + void promise.then( + (value) => { + clearTimeout(timeout); + resolve(value); + }, + (error) => { + clearTimeout(timeout); + reject(error); + }, + ); + }); } async function waitFor(condition: () => boolean): Promise { - await withTimeout( - new Promise((resolve) => { - const check = () => { - if (condition()) resolve(); - else setTimeout(check, 10); - }; - check(); - }), - "Timed out waiting for Relay session state", - ); + await new Promise((resolve, reject) => { + let pollTimer: NodeJS.Timeout | null = null; + let settled = false; + const timeout = setTimeout(() => { + settled = true; + if (pollTimer) clearTimeout(pollTimer); + reject(new Error("Timed out waiting for Relay session state")); + }, 1000); + timeout.unref?.(); + + const check = () => { + if (settled) return; + if (condition()) { + settled = true; + clearTimeout(timeout); + resolve(); + return; + } + pollTimer = setTimeout(check, 10); + }; + check(); + }); } function sendAndReceiveNotice(relay: Relay, message: string): Promise { From 26a86ef4f56b1eb1a19b2186c51dae1b245a9e99 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sun, 19 Jul 2026 10:10:42 -0500 Subject: [PATCH 6/8] docs: record issue 139 review evidence --- docs/agents/runs/cleanup-1-9-ledger.md | 6 ++-- .../agents/runs/issue-139-coderabbit-local.md | 31 ++++++++++++++++ docs/agents/runs/issue-139-review-packet.md | 35 +++++++++++++++++++ docs/agents/runs/issue-139-session.md | 25 ++++++++----- 4 files changed, 86 insertions(+), 11 deletions(-) create mode 100644 docs/agents/runs/issue-139-coderabbit-local.md create mode 100644 docs/agents/runs/issue-139-review-packet.md diff --git a/docs/agents/runs/cleanup-1-9-ledger.md b/docs/agents/runs/cleanup-1-9-ledger.md index 5ccb40a..9e964e4 100644 --- a/docs/agents/runs/cleanup-1-9-ledger.md +++ b/docs/agents/runs/cleanup-1-9-ledger.md @@ -9,7 +9,7 @@ - Feature branches: one branch per approved ticket, created from the latest integrated `staging` - Human owner: plebdev - Started: 2026-07-18 -- Current status: items 1–8 / issues #131–#138 merged into `staging`; item 9 / issue #139 is in progress on `feature/ephemeral-relay-internals` +- Current status: items 1–8 / issues #131–#138 merged into `staging`; item 9 / issue #139 has completed implementation, verification, and local review on `feature/ephemeral-relay-internals`; PR pending - Skill setup status: present and verified (`AGENTS.md`, GitHub issue tracker, triage labels, domain docs, ADRs, CI, CodeRabbit) ## Goal @@ -49,7 +49,7 @@ Complete cleanup items 1–9 from the staging audit end to end, branch by branch | #136 NIP-46 protocol core | AFK | merged | `feature/nip46-protocol-core` | Grok and CodeRabbit local/hosted clean after fixes | Jest/Bun 1096/1096; hosted CI green | | #137 default test feedback loop | AFK | merged | `feature/fast-default-test-loop` | Grok pass; CodeRabbit hosted finding fixed and confirmed | routine 1063; slow 40; coverage 1103 | | #138 public behavior test seams | AFK | merged | `feature/public-behavior-test-seams` | Grok approved; CodeRabbit local/hosted findings resolved | Jest/Bun 1061; hosted CI green | -| #139 ephemeral Relay internals | AFK | in progress | `feature/ephemeral-relay-internals` | Grok design challenge complete | focused baseline 25/25 | +| #139 ephemeral Relay internals | AFK | ready for PR | `feature/ephemeral-relay-internals` | Grok review fixes applied; CodeRabbit findings resolved/triaged | Jest/Bun 1,102; all local gates green | ## Parked HITL Slices @@ -69,7 +69,7 @@ Complete cleanup items 1–9 from the staging audit end to end, branch by branch | #136 | `ed9fa4a` | current Codex orchestrator; Grok 4.5 High reviewers | PR #145 through merge `8b970e4` | Grok and CodeRabbit local/hosted clean after fixes | NIP-46 185/185; Jest/Bun 1096/1096; all local and hosted gates green | | #137 | `8b970e4` | current Codex orchestrator; Grok 4.5 High reviewers | PR #146 through merge `b33f31f` | Grok standards/spec passed after Bun 1.3.9 fix; CodeRabbit hosted finding fixed and explicitly confirmed | baseline 85/1096/58.793s; routine 84/1063/32.356s; slow 2/40/43.154s; coverage 86/1103; hosted CI green | | #138 | `b33f31f` | current Codex orchestrator; Grok 4.5 High reviewers | PR #147 through merge `3c1e905` | Grok approved; CodeRabbit local clean; two hosted nits fixed and false positive withdrawn | routine Jest/Bun 1061; slow Jest/Bun 35; coverage 1096; all local gates and four hosted lanes green | -| #139 | `3c1e905` | current Codex orchestrator; Grok 4.5 High reviewers | pending | read-only Grok deep-module design challenge complete | focused public behavior baseline 25/25; implementation pending | +| #139 | `3c1e905` | current Codex orchestrator; Grok 4.5 High reviewers | `d6504f6`, `7a48586`, `f8e9aac`, `96dd79f` plus review artifacts | Grok design/standards/spec passes; CodeRabbit in-scope findings fixed, scope changes rejected | focused 50/50; Jest/Bun 1,102/1,102; policies, lint, types, builds, examples, web, pack green | ## Alignment Decisions diff --git a/docs/agents/runs/issue-139-coderabbit-local.md b/docs/agents/runs/issue-139-coderabbit-local.md new file mode 100644 index 0000000..95ff582 --- /dev/null +++ b/docs/agents/runs/issue-139-coderabbit-local.md @@ -0,0 +1,31 @@ +# CodeRabbit Local Review: Issue #139 + +## Scope + +- Branch: `feature/ephemeral-relay-internals` +- Base: `staging` / fixed point `3c1e905` +- Full-diff command: `coderabbit review --agent --type all --base staging` +- Incremental command: `coderabbit review --agent --type uncommitted --base staging` + +## Findings Addressed + +| Severity | Finding | Resolution | +| --- | --- | --- | +| Minor | CLOSE coverage did not prove server-side subscription removal | Wait for `NostrRelay.subs` to empty, publish again, and assert no stale delivery | +| Major | NIP-46 routing re-derived the subscription ID from the map key | Route with the subscription owner's typed `subscriptionId` | +| Major | Rejected session shutdown could skip transport cleanup | Put timeout, listener, and owned-state cleanup under `finally` while preserving the original rejection | +| Major | Native server lost its only `error` listener after startup | Retain a runtime error handler for native and in-memory servers | +| Major | Timed-out test polling could continue scheduling callbacks | Make polling cancellation explicit and clear all timeout handles | + +## Findings Not Addressed + +| Severity | Finding | Reason | +| --- | --- | --- | +| Minor | Send an event only once when multiple filters in one subscription match | Pre-existing observable wire behavior; changing it is outside this compatibility-preserving structural ticket | +| Minor | Add a maximum connection count in transport acceptance | New product/security policy with no specified limit or existing shared policy; outside issue #139 | + +## Result + +- Final incremental review of the last polling fix: 0 findings. +- All production findings from the full-diff reviews were fixed except the two explicitly rejected scope changes above. +- Focused Jest/Bun regression checks and the complete project gate matrix passed after production fixes. diff --git a/docs/agents/runs/issue-139-review-packet.md b/docs/agents/runs/issue-139-review-packet.md new file mode 100644 index 0000000..fed2bc4 --- /dev/null +++ b/docs/agents/runs/issue-139-review-packet.md @@ -0,0 +1,35 @@ +# Review Packet: Issue #139 Split Ephemeral Relay Internals + +## Fixed Point and Spec + +- Fixed point: `3c1e905` +- Diff: `git diff 3c1e905...HEAD` +- Spec: GitHub issue #139, child of cleanup run #130 +- Standards: `AGENTS.md`, `CONTRIBUTING.md`, and the code-review smell baseline + +## Delivered Shape + +- `src/utils/ephemeral-relay.ts` remains the stable public facade and composition root. +- `transport.ts` owns native/in-memory selection, connection acceptance, fallback, runtime errors, and ordered shutdown. +- `client-session.ts` owns per-client wire protocol, subscriptions, NIP-46 routing, and socket cleanup through a narrow host interface. +- `filter-match.ts` owns pure Subscription Filter matching. +- Cache/replacement behavior stays in the facade; no new package entrypoint or production root export was added. + +## Review Decisions + +- Added missing JSDoc and camelCase names for the new private interfaces. +- Reused the shared diagnostics owner and moved server-client traversal behind transport. +- Added happy/error session messaging, filter semantics, transport-failure cleanup, late-error, lifecycle, and package-surface coverage. +- Retained the private transport factory: it keeps the implementation class hidden and is not accidental middle-man surface. +- Retained the cohesive client-session owner despite its size: splitting NIP-46 or wire-protocol behavior again is outside this ticket. +- Rejected a new connection cap and a legacy multi-filter delivery change because neither belongs to the compatibility-preserving structural scope. + +## Verification + +- Focused public/internal contracts: Jest and Bun 50/50 after CodeRabbit production fixes; final polling-only check 2/2 in both. +- Routine lanes: Jest/Bun 1,067 tests each. +- Slow lanes: Jest/Bun 35 tests each. +- Complete Jest coverage inventory: 1,102 tests. +- Green: command policy, package-manager policy, ESLint, strict TypeScript, CJS/ESM build, web entry checks, examples, and packed-consumer verification. +- Pack result: 19 referenced targets, 376 packed files, 55 web modules, 3 guarded Node fallbacks. +- CodeRabbit: all actionable in-scope findings fixed; final incremental review clean. diff --git a/docs/agents/runs/issue-139-session.md b/docs/agents/runs/issue-139-session.md index a17acab..828db40 100644 --- a/docs/agents/runs/issue-139-session.md +++ b/docs/agents/runs/issue-139-session.md @@ -5,8 +5,8 @@ - Issue: #139 — Split ephemeral Relay internals - Fixed point before session: `3c1e905` (`staging` merge of PR #147) - Worker session: current Codex orchestrator; Grok 4.5 High is the exclusive delegated read-only reviewer -- Commit: pending -- Status: in progress +- Commits: `d6504f6`, `7a48586`, `f8e9aac`, `96dd79f` plus review artifacts +- Status: implementation and local review complete; PR pending ## Inputs @@ -21,16 +21,17 @@ - Public interface used: `NostrRelay` through `src/testing`, plus the compatible `snstr/testing` and `snstr/utils/ephemeral-relay` package subpaths - Behaviors covered: native and in-memory connection lifecycle, session wire messages and cleanup, Subscription Filter matching, restart state reset, exact-URL disconnect observation, and package export compatibility - `tdd` used: yes; tests stay at public `NostrRelay`/wire seams, with a compact pure matcher contract at its internal module interface -- Commands run during implementation: focused Jest/Bun public-behavior baseline 25/25; implementation checks pending -- Full suite command: `npm test && npm run test:slow && npm run test:bun && npm run test:bun:slow` (pending) +- Commands run during implementation: focused Jest/Bun public-behavior baseline 25/25; final focused owner/lifecycle/session/filter set 50/50 after production review fixes; final polling-only confirmation 2/2 in both runtimes +- Full suite command: `npm test`, `npm run test:slow`, `npm run test:bun`, `npm run test:bun:slow`, and `npm run test:coverage:all` — green at 1,067 routine + 35 slow = 1,102 tests ## Review - Review fixed point: `3c1e905` -- Standards findings: pending -- Spec findings: pending -- Worthy fixes applied: pending -- Findings ignored with reasons: pending +- Standards findings: initial Grok pass found missing JSDoc, new snake_case ownership, duplicated diagnostic coercion, and transport client-set traversal; all were fixed. The private factory and cohesive session owner were retained deliberately. +- Spec findings: initial Grok pass found final gates/PR pending and session coverage error-only; gates are green and focused session coverage now proves REQ, EOSE, EVENT, CLOSE, malformed messages, and stale-route removal. PR delivery remains the next workflow step. +- Worthy fixes applied: shared diagnostic coercion/protection, transport-owned broadcast, camelCase subscription state, post-start server error handling, guaranteed shutdown cleanup, direct NIP-46 subscription routing, and cancellable test polling. +- Findings ignored with reasons: CodeRabbit's connection cap requires a new unspecified product policy; changing duplicate delivery for overlapping filters changes pre-existing observable behavior. Both are outside the structural compatibility scope. +- CodeRabbit: two full-diff passes completed; five in-scope findings fixed; final incremental review 0 findings. See `issue-139-coderabbit-local.md`. ## Design Result @@ -46,3 +47,11 @@ - Native and in-memory transports have intentionally different shutdown mechanics that must retain identical observable behavior. - NIP-46 kind `24133` routing currently has special `#p` behavior and must not be silently unified with general Subscription Filter matching in this structural slice. - `cache`, `subs`, `conn`, `wss`, `store`, both package subpaths, and the legacy numeric purge option remain compatible public behavior. + +## Verification Result + +- Routine Jest/Bun: 1,067/1,067 each. +- Slow Jest/Bun: 35/35 each. +- Complete Jest coverage inventory: 1,102/1,102. +- Policy, lint, strict types, CJS/ESM and examples builds, web exports, and pack verification: green. +- Pack verification: 19 referenced targets, 376 packed files, 55 web modules, 3 guarded Node fallbacks. From d2576f007509a77dd0004d444df66fd233d6c23b Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sun, 19 Jul 2026 10:17:38 -0500 Subject: [PATCH 7/8] docs: finalize issue 139 review --- docs/agents/runs/cleanup-1-9-ledger.md | 4 ++-- docs/agents/runs/issue-139-review-packet.md | 1 + docs/agents/runs/issue-139-session.md | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/agents/runs/cleanup-1-9-ledger.md b/docs/agents/runs/cleanup-1-9-ledger.md index 9e964e4..804d115 100644 --- a/docs/agents/runs/cleanup-1-9-ledger.md +++ b/docs/agents/runs/cleanup-1-9-ledger.md @@ -49,7 +49,7 @@ Complete cleanup items 1–9 from the staging audit end to end, branch by branch | #136 NIP-46 protocol core | AFK | merged | `feature/nip46-protocol-core` | Grok and CodeRabbit local/hosted clean after fixes | Jest/Bun 1096/1096; hosted CI green | | #137 default test feedback loop | AFK | merged | `feature/fast-default-test-loop` | Grok pass; CodeRabbit hosted finding fixed and confirmed | routine 1063; slow 40; coverage 1103 | | #138 public behavior test seams | AFK | merged | `feature/public-behavior-test-seams` | Grok approved; CodeRabbit local/hosted findings resolved | Jest/Bun 1061; hosted CI green | -| #139 ephemeral Relay internals | AFK | ready for PR | `feature/ephemeral-relay-internals` | Grok review fixes applied; CodeRabbit findings resolved/triaged | Jest/Bun 1,102; all local gates green | +| #139 ephemeral Relay internals | AFK | ready for PR | `feature/ephemeral-relay-internals` | Grok standards/spec approved; CodeRabbit findings resolved/triaged | Jest/Bun 1,102; all local gates green | ## Parked HITL Slices @@ -69,7 +69,7 @@ Complete cleanup items 1–9 from the staging audit end to end, branch by branch | #136 | `ed9fa4a` | current Codex orchestrator; Grok 4.5 High reviewers | PR #145 through merge `8b970e4` | Grok and CodeRabbit local/hosted clean after fixes | NIP-46 185/185; Jest/Bun 1096/1096; all local and hosted gates green | | #137 | `8b970e4` | current Codex orchestrator; Grok 4.5 High reviewers | PR #146 through merge `b33f31f` | Grok standards/spec passed after Bun 1.3.9 fix; CodeRabbit hosted finding fixed and explicitly confirmed | baseline 85/1096/58.793s; routine 84/1063/32.356s; slow 2/40/43.154s; coverage 86/1103; hosted CI green | | #138 | `b33f31f` | current Codex orchestrator; Grok 4.5 High reviewers | PR #147 through merge `3c1e905` | Grok approved; CodeRabbit local clean; two hosted nits fixed and false positive withdrawn | routine Jest/Bun 1061; slow Jest/Bun 35; coverage 1096; all local gates and four hosted lanes green | -| #139 | `3c1e905` | current Codex orchestrator; Grok 4.5 High reviewers | `d6504f6`, `7a48586`, `f8e9aac`, `96dd79f` plus review artifacts | Grok design/standards/spec passes; CodeRabbit in-scope findings fixed, scope changes rejected | focused 50/50; Jest/Bun 1,102/1,102; policies, lint, types, builds, examples, web, pack green | +| #139 | `3c1e905` | current Codex orchestrator; Grok 4.5 High reviewers | `d6504f6`, `7a48586`, `f8e9aac`, `96dd79f` plus review artifacts | Grok standards/spec approved with 0 findings; CodeRabbit in-scope findings fixed, scope changes rejected | focused 50/50; Jest/Bun 1,102/1,102; policies, lint, types, builds, examples, web, pack green | ## Alignment Decisions diff --git a/docs/agents/runs/issue-139-review-packet.md b/docs/agents/runs/issue-139-review-packet.md index fed2bc4..aa5aa9b 100644 --- a/docs/agents/runs/issue-139-review-packet.md +++ b/docs/agents/runs/issue-139-review-packet.md @@ -33,3 +33,4 @@ - Green: command policy, package-manager policy, ESLint, strict TypeScript, CJS/ESM build, web entry checks, examples, and packed-consumer verification. - Pack result: 19 referenced targets, 376 packed files, 55 web modules, 3 guarded Node fallbacks. - CodeRabbit: all actionable in-scope findings fixed; final incremental review clean. +- Grok 4.5 final review: Standards approved with 0 hard / 0 actionable judgement findings; Spec approved with 0 code/spec findings. diff --git a/docs/agents/runs/issue-139-session.md b/docs/agents/runs/issue-139-session.md index 828db40..f57e34d 100644 --- a/docs/agents/runs/issue-139-session.md +++ b/docs/agents/runs/issue-139-session.md @@ -27,8 +27,8 @@ ## Review - Review fixed point: `3c1e905` -- Standards findings: initial Grok pass found missing JSDoc, new snake_case ownership, duplicated diagnostic coercion, and transport client-set traversal; all were fixed. The private factory and cohesive session owner were retained deliberately. -- Spec findings: initial Grok pass found final gates/PR pending and session coverage error-only; gates are green and focused session coverage now proves REQ, EOSE, EVENT, CLOSE, malformed messages, and stale-route removal. PR delivery remains the next workflow step. +- Standards findings: initial Grok pass found missing JSDoc, new snake_case ownership, duplicated diagnostic coercion, and transport client-set traversal; all were fixed. Final Grok re-review approved with 0 hard and 0 actionable judgement findings. +- Spec findings: initial Grok pass found final gates/PR pending and session coverage error-only; gates are green and focused session coverage now proves REQ, EOSE, EVENT, CLOSE, malformed messages, and stale-route removal. Final Grok re-review approved with 0 code/spec findings and named the PR as the expected next action. - Worthy fixes applied: shared diagnostic coercion/protection, transport-owned broadcast, camelCase subscription state, post-start server error handling, guaranteed shutdown cleanup, direct NIP-46 subscription routing, and cancellable test polling. - Findings ignored with reasons: CodeRabbit's connection cap requires a new unspecified product policy; changing duplicate delivery for overlapping filters changes pre-existing observable behavior. Both are outside the structural compatibility scope. - CodeRabbit: two full-diff passes completed; five in-scope findings fixed; final incremental review 0 findings. See `issue-139-coderabbit-local.md`. From d5b7cf5220167da7f35321db4e06ce3849f38462 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sun, 19 Jul 2026 10:26:07 -0500 Subject: [PATCH 8/8] docs: correct issue 139 verification totals --- docs/agents/runs/cleanup-1-9-ledger.md | 8 ++++---- docs/agents/runs/issue-139-review-packet.md | 4 ++-- docs/agents/runs/issue-139-session.md | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/agents/runs/cleanup-1-9-ledger.md b/docs/agents/runs/cleanup-1-9-ledger.md index 804d115..ff29236 100644 --- a/docs/agents/runs/cleanup-1-9-ledger.md +++ b/docs/agents/runs/cleanup-1-9-ledger.md @@ -9,7 +9,7 @@ - Feature branches: one branch per approved ticket, created from the latest integrated `staging` - Human owner: plebdev - Started: 2026-07-18 -- Current status: items 1–8 / issues #131–#138 merged into `staging`; item 9 / issue #139 has completed implementation, verification, and local review on `feature/ephemeral-relay-internals`; PR pending +- Current status: items 1–8 / issues #131–#138 merged into `staging`; item 9 / issue #139 is open as PR #148 into `staging`; one hosted Node 20 rerun pending after an isolated loaded-suite timeout - Skill setup status: present and verified (`AGENTS.md`, GitHub issue tracker, triage labels, domain docs, ADRs, CI, CodeRabbit) ## Goal @@ -27,7 +27,7 @@ Complete cleanup items 1–9 from the staging audit end to end, branch by branch - Agent briefs: Grok 4.5 is the exclusive delegated sidecar; Cursor exposes the highest available tier as `cursor-grok-4.5-high`, which is used for all standards/spec passes - Review packets: `issue-131-review-packet.md` through `issue-137-review-packet.md`; created per later ticket - Local CodeRabbit report: `issue-131-coderabbit-local.md` through `issue-136-coderabbit-local.md`; created per later ticket -- PR URL: #140 merged for issue #131; #141 merged for issue #132; #142 merged for issue #133; #143 merged for issue #134; #144 merged for issue #135; #145 merged for issue #136; #146 merged for issue #137; #147 merged for issue #138; issue #139 PR pending; always non-draft and targeting `staging` +- PR URL: #140 merged for issue #131; #141 merged for issue #132; #142 merged for issue #133; #143 merged for issue #134; #144 merged for issue #135; #145 merged for issue #136; #146 merged for issue #137; #147 merged for issue #138; #148 open for issue #139; always non-draft and targeting `staging` ## Commands @@ -49,7 +49,7 @@ Complete cleanup items 1–9 from the staging audit end to end, branch by branch | #136 NIP-46 protocol core | AFK | merged | `feature/nip46-protocol-core` | Grok and CodeRabbit local/hosted clean after fixes | Jest/Bun 1096/1096; hosted CI green | | #137 default test feedback loop | AFK | merged | `feature/fast-default-test-loop` | Grok pass; CodeRabbit hosted finding fixed and confirmed | routine 1063; slow 40; coverage 1103 | | #138 public behavior test seams | AFK | merged | `feature/public-behavior-test-seams` | Grok approved; CodeRabbit local/hosted findings resolved | Jest/Bun 1061; hosted CI green | -| #139 ephemeral Relay internals | AFK | ready for PR | `feature/ephemeral-relay-internals` | Grok standards/spec approved; CodeRabbit findings resolved/triaged | Jest/Bun 1,102; all local gates green | +| #139 ephemeral Relay internals | AFK | PR #148 open | `feature/ephemeral-relay-internals` | Grok standards/spec approved; CodeRabbit findings resolved/triaged | Jest/Bun 1,109; local gates green; hosted rerun pending | ## Parked HITL Slices @@ -69,7 +69,7 @@ Complete cleanup items 1–9 from the staging audit end to end, branch by branch | #136 | `ed9fa4a` | current Codex orchestrator; Grok 4.5 High reviewers | PR #145 through merge `8b970e4` | Grok and CodeRabbit local/hosted clean after fixes | NIP-46 185/185; Jest/Bun 1096/1096; all local and hosted gates green | | #137 | `8b970e4` | current Codex orchestrator; Grok 4.5 High reviewers | PR #146 through merge `b33f31f` | Grok standards/spec passed after Bun 1.3.9 fix; CodeRabbit hosted finding fixed and explicitly confirmed | baseline 85/1096/58.793s; routine 84/1063/32.356s; slow 2/40/43.154s; coverage 86/1103; hosted CI green | | #138 | `b33f31f` | current Codex orchestrator; Grok 4.5 High reviewers | PR #147 through merge `3c1e905` | Grok approved; CodeRabbit local clean; two hosted nits fixed and false positive withdrawn | routine Jest/Bun 1061; slow Jest/Bun 35; coverage 1096; all local gates and four hosted lanes green | -| #139 | `3c1e905` | current Codex orchestrator; Grok 4.5 High reviewers | `d6504f6`, `7a48586`, `f8e9aac`, `96dd79f` plus review artifacts | Grok standards/spec approved with 0 findings; CodeRabbit in-scope findings fixed, scope changes rejected | focused 50/50; Jest/Bun 1,102/1,102; policies, lint, types, builds, examples, web, pack green | +| #139 | `3c1e905` | current Codex orchestrator; Grok 4.5 High reviewers | PR #148 through current head | Grok standards/spec approved with 0 findings; CodeRabbit in-scope findings fixed, scope changes rejected | focused 50/50; Jest/Bun 1,109/1,109; policies, lint, types, builds, examples, web, pack green | ## Alignment Decisions diff --git a/docs/agents/runs/issue-139-review-packet.md b/docs/agents/runs/issue-139-review-packet.md index aa5aa9b..3c39e26 100644 --- a/docs/agents/runs/issue-139-review-packet.md +++ b/docs/agents/runs/issue-139-review-packet.md @@ -27,9 +27,9 @@ ## Verification - Focused public/internal contracts: Jest and Bun 50/50 after CodeRabbit production fixes; final polling-only check 2/2 in both. -- Routine lanes: Jest/Bun 1,067 tests each. +- Routine lanes: Jest/Bun 1,074 tests each. - Slow lanes: Jest/Bun 35 tests each. -- Complete Jest coverage inventory: 1,102 tests. +- Complete Jest coverage inventory: 1,109 tests. - Green: command policy, package-manager policy, ESLint, strict TypeScript, CJS/ESM build, web entry checks, examples, and packed-consumer verification. - Pack result: 19 referenced targets, 376 packed files, 55 web modules, 3 guarded Node fallbacks. - CodeRabbit: all actionable in-scope findings fixed; final incremental review clean. diff --git a/docs/agents/runs/issue-139-session.md b/docs/agents/runs/issue-139-session.md index f57e34d..cf36ed5 100644 --- a/docs/agents/runs/issue-139-session.md +++ b/docs/agents/runs/issue-139-session.md @@ -6,7 +6,7 @@ - Fixed point before session: `3c1e905` (`staging` merge of PR #147) - Worker session: current Codex orchestrator; Grok 4.5 High is the exclusive delegated read-only reviewer - Commits: `d6504f6`, `7a48586`, `f8e9aac`, `96dd79f` plus review artifacts -- Status: implementation and local review complete; PR pending +- Status: PR #148 open into `staging`; hosted rerun pending after one loaded Node 20 NIP-46 timeout ## Inputs @@ -22,7 +22,7 @@ - Behaviors covered: native and in-memory connection lifecycle, session wire messages and cleanup, Subscription Filter matching, restart state reset, exact-URL disconnect observation, and package export compatibility - `tdd` used: yes; tests stay at public `NostrRelay`/wire seams, with a compact pure matcher contract at its internal module interface - Commands run during implementation: focused Jest/Bun public-behavior baseline 25/25; final focused owner/lifecycle/session/filter set 50/50 after production review fixes; final polling-only confirmation 2/2 in both runtimes -- Full suite command: `npm test`, `npm run test:slow`, `npm run test:bun`, `npm run test:bun:slow`, and `npm run test:coverage:all` — green at 1,067 routine + 35 slow = 1,102 tests +- Full suite command: `npm test`, `npm run test:slow`, `npm run test:bun`, `npm run test:bun:slow`, and `npm run test:coverage:all` — green at 1,074 routine + 35 slow = 1,109 tests ## Review @@ -50,8 +50,8 @@ ## Verification Result -- Routine Jest/Bun: 1,067/1,067 each. +- Routine Jest/Bun: 1,074/1,074 each. - Slow Jest/Bun: 35/35 each. -- Complete Jest coverage inventory: 1,102/1,102. +- Complete Jest coverage inventory: 1,109/1,109. - Policy, lint, strict types, CJS/ESM and examples builds, web exports, and pack verification: green. - Pack verification: 19 referenced targets, 376 packed files, 55 web modules, 3 guarded Node fallbacks.