diff --git a/docs/manual-signer-tests.md b/docs/manual-signer-tests.md index 2cd3c1a..67671d0 100644 --- a/docs/manual-signer-tests.md +++ b/docs/manual-signer-tests.md @@ -125,3 +125,43 @@ With the pasted-key (local) signer configured: signed events (pubkey + sig) and the public key ever leave the page. - [ ] Repeat the sweep once for a NIP-46 session: WebSocket frames to the bunker relay carry only encrypted kind 24133 envelopes. + +## 8. First-party relay (`wss://nbread.lol/relay`) — issue #5 + +The editor prepends the first-party relay to every publish broadcast, so a +normal publish already exercises the write path. These checks confirm the +relay is externally reachable and enforces NIP-42 auth + the claimed-handle +allowlist. Uses [`nak`](https://github.com/fiatjaf/nak) (any NIP-01 CLI works). + +- [ ] **NIP-11 reachable**: `nak relay wss://nbread.lol/relay` (or + `curl -H "Accept: application/nostr+json" https://nbread.lol/relay`) + returns the document with `supported_nips` including `42` and + `limitation.restricted_writes: true`. +- [ ] **Publish → relay readback**: publish a post from the editor (any + signer), then read it straight back off the first-party relay by author: + `nak req -k 30023 -a wss://nbread.lol/relay` returns + your 30023 event (and EOSE), and the same post is live at + `https://.nbread.lol/` — relay and blog agree because they + share one store. +- [ ] **Claimed key writes (NIP-42)**: with your CLAIMED nbread key, + `nak event -k 30023 -c "relay auth test" --sec --auth + wss://nbread.lol/relay` — nak answers the `AUTH` challenge, signs the + kind 22242, and the relay returns `OK … true`; the event is then + readable via `nak req`. +- [ ] **Unclaimed key refused**: repeat the previous step with a key that has + NO claimed nbread handle. After AUTH succeeds, the EVENT is rejected with + `OK … false "restricted: writes are limited to claimed nbread.lol + handles"` — nothing is stored. +- [ ] **Unauthenticated write refused**: `nak event -k 30023 --sec + wss://nbread.lol/relay` WITHOUT `--auth` → `OK … false + "auth-required: …"` and no post appears. +- [ ] **Wrong kind refused**: `nak event -k 1 -c hi --sec + --auth wss://nbread.lol/relay` → `OK … false "restricted: only kinds + 30023, 5, and 0 are accepted"`. +- [ ] **Delete propagates**: delete a post from the editor (kind 5), then + `nak req -k 30023 -a wss://nbread.lol/relay` no longer + returns the tombstoned post (but `-k 5` still returns the delete marker). +- [ ] **External client reads an nbread post**: open the post's `naddr`/`nevent` + on a third-party long-form reader (e.g. habla.news) configured to include + `wss://nbread.lol/relay`, and confirm it loads the nbread-hosted 30023 — + reads are open (no auth) to anyone. diff --git a/docs/ops.md b/docs/ops.md index ef748fc..973349d 100644 --- a/docs/ops.md +++ b/docs/ops.md @@ -83,6 +83,8 @@ abuse bounds, not politeness. | `GET /search` | `search:ip` → 30/min (non-empty `q` only) | — | FTS MATCH (sanitized) + join, LIMIT 20 | | `GET /npub1…` (+ `/rss.xml`, `/atom.xml`, `/:slug`) | `npub:ip` → 60/min *(P7)*; relay mirror sessions additionally: per-pubkey cooldown 300s (Cache API marker) + `npub-mirror:ip` → 30/day + `npub-mirror:global` → 500/day, ≤10 verifications/session | — | ≤100-row post list per view (the P7 limiter closes the unmetered-read gap) | | `GET /.well-known/nostr.json` | — | `max-age=300` | 1-row indexed read (WAF backstop); blocked/unknown → `{"names":{}}` | +| `GET /relay` (ws upgrade) | `relay:ip` → 30/10min (Worker-side, **fail closed**) | — | denied upgrade never spends a DO request; accepted → one DO request opening a hibernatable ws session (no duration billing). Registered on the outer app **before** `securityHeaders` (a 101 is immutable) | +| `GET /relay` (NIP-11 / info) | — | `max-age=3600` (NIP-11 doc) | Worker-served, **zero DO cost**: `Accept: application/nostr+json` → NIP-11 document (`CORS *`); bare GET → plain-text info page. Per-ws-session budgets live in the DO (`relay:ev:pk` 30/5min, `relay:global:store` 500/day, 256 concurrent conns, 120 msg/min/conn) | | `GET /admin` | ADMIN_PUBKEY gate (404 otherwise) | — | 1 KV session read + ≤200-row blocked list | | `POST /admin/block`, `POST /admin/unblock` | gate + `admin:pk` → 30/5min *(P7)* | — | 1 D1 write + 1 KV gen bump | @@ -144,6 +146,12 @@ them.** Configure once after Gate B: document plus a couple of same-origin assets; post images/media load from external origins — so 6 rps sustained is far above human browsing. Shared NAT (CGNAT) bursts may occasionally trip it; mitigation lasts only 10s. + **Relay note**: a `wss://nbread.lol/relay` connection counts as **one** + request against this rule — the HTTP UPGRADE handshake. WebSocket frames + after the upgrade are NOT individual zone requests, so the rule does not + bound relay message throughput (the DO's own per-connection 120 msg/min and + the Worker-side `relay:ip` 30/10min upgrade limit do that); it only bounds + the rate of new connections from one IP. - **Then take action**: **Block** - **For duration**: 10 seconds (free-plan mitigation timeout) - **Why 60, and when to tighten**: the WAF is the ONLY control over the @@ -196,6 +204,7 @@ editor's slugify never mints such shapes. | **D1 rows written** | 100,000 | **All limiters fail CLOSED** → 429s on challenge/login/discover-miss/search/npub/mirror; nonce issuance fails → logins stop | This is the deliberate fail-safe posture: the platform read paths (cached blogs, discover hits) keep serving. WAF-block the source; wait for reset | | **Cache API** | best-effort | All cache layers degrade to uncached (every layer is try/caught) → D1/CPU load rises, correctness unchanged | Watch D1 budgets (above); usually transient | | **Worker requests** | 100,000/day | Cloudflare serves errors once exceeded | WAF rate rule is the main dial; scanner-path block cuts the noise floor | +| **Durable Object requests** | 100,000/day (free plan, SQLite backend) | The relay stops accepting connections — `wss://nbread.lol/relay` upgrades error; existing ws sessions may drop. **Blogs, editor, `/api/mirror`, and cron are unaffected** — the relay is **additive, never load-bearing** (its store is the shared D1 `events` table, so nothing published is lost) | WAF-block the upgrade source; the Worker-side `relay:ip` 30/10min + the DO's 256-concurrent-conn cap bound the burn. Hibernating connections accrue **no duration billing**, and protocol pings are auto-ponged without waking the object; quota resets daily (UTC) | Observability is enabled in `wrangler.jsonc`; `wrangler tail` gives live logs (rate-limit denials log their key via `console.error` on D1 failures diff --git a/migrations/0005_relay.sql b/migrations/0005_relay.sql new file mode 100644 index 0000000..467a26f --- /dev/null +++ b/migrations/0005_relay.sql @@ -0,0 +1,13 @@ +-- Migration number: 0005 relay +-- First-party relay (#5) support. +-- +-- The relay REQ engine's dominant query is authors (+ kinds) ordered by +-- created_at DESC. The existing idx_events_feed(kind, deleted, created_at) +-- cannot serve that ordering for a pubkey-scoped scan, so add a dedicated +-- author+time index. +CREATE INDEX idx_events_author_time ON events(pubkey, created_at DESC); + +-- The relay endpoint lives at the apex path wss://nbread.lol/relay, but +-- reserve the handle anyway so no blog ever claims relay.nbread.lol. +INSERT OR IGNORE INTO reserved_handles (handle) VALUES + ('relay'); diff --git a/scripts/smoke.sh b/scripts/smoke.sh index bc1e928..a6234cc 100644 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -138,6 +138,35 @@ check_post() { fi } +# check_accept +# Like check(), but sends an Accept header — used for the NIP-11 relay +# document (application/nostr+json). Leaves the response body in +# /tmp/smoke_body and headers in /tmp/smoke_headers for the check_body_contains +# / check_header_contains assertions that follow. +check_accept() { + local desc="$1" expected="$2" host_header="$3" path="$4" accept="$5" + local args=(-s -o /tmp/smoke_body -D /tmp/smoke_headers -w '%{http_code}' \ + --max-time 15 -H "Accept: $accept") + local url + if [[ "$TARGET" == "local" ]]; then + url="$BASE$path" + if [[ -n "$host_header" ]]; then + args+=(-H "X-Forwarded-Host: $host_header") + fi + else + url="https://$host_header$path" + fi + local code + code=$(curl "${args[@]}" "$url" || echo "000") + if [[ "$code" == "$expected" ]]; then + echo "PASS [$code] $desc" + PASS=$((PASS + 1)) + else + echo "FAIL [$code != $expected] $desc" + FAIL=$((FAIL + 1)) + fi +} + # --- P0: hello checks --------------------------------------------------------- check "apex / responds 200" 200 "$MAIN_HOST" "/" check_body_contains "apex / mentions nbread.lol" "nbread.lol" @@ -232,6 +261,18 @@ fi check "admin surface hidden (disabled or anonymous)" 404 "$MAIN_HOST" "/admin" check_post "admin actions hidden too" 404 "$MAIN_HOST" "/admin/block" '{"target":"alice"}' +# --- P8: first-party relay (#5) -------------------------------------------------- +# A bare GET is the plain-text info page (Worker-served, no DO cost); the +# NIP-11 document comes back only for Accept: application/nostr+json. The ws +# upgrade itself is exercised by the integration suite (curl can't drive a +# NIP-01 session) and by the manual checklist, not here. +check "relay info page responds 200" 200 "$MAIN_HOST" "/relay" +check_body_contains "relay info page names the ws endpoint" "wss://$MAIN_HOST/relay" +check_accept "relay serves the NIP-11 document" 200 "$MAIN_HOST" "/relay" "application/nostr+json" +check_body_contains "relay NIP-11 advertises NIP-42" '"supported_nips":\[1,9,11,42\]' +check_body_contains "relay NIP-11 restricts writes" '"restricted_writes":true' +check_header_contains "relay NIP-11 sends CORS *" "access-control-allow-origin: \*" + # --- P5 MANUAL check (documented, not automated): full write→render loop --------- # The end-to-end publish flow needs a REAL NIP-07 extension signing in a real # browser, which curl cannot drive. Once per release, verify by hand: diff --git a/src/app.ts b/src/app.ts index c219255..1015a3b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -13,6 +13,7 @@ import { authRoutes } from "./routes/auth"; import { dashboardRoutes } from "./routes/dashboard"; import { adminRoutes } from "./routes/admin"; import { wellknownRoutes } from "./routes/wellknown"; +import { relayEndpoint } from "./relay/http"; import { MainNotFound } from "./views/main/not-found"; /** @@ -63,6 +64,13 @@ blogApp.route("/", tenantRoutes); // --- Outer app ---------------------------------------------------------------- export const app = new Hono(); +// First-party relay endpoint BEFORE the middleware stack: a successful +// WebSocket upgrade is an immutable 101 response (securityHeaders' +// headers.set() would throw on it), and guard/tenant/csrf/session must never +// run per-upgrade. The handler does its own host self-check and sets its own +// headers (src/relay/http.ts). +app.all("/relay", relayEndpoint); + // Security headers FIRST so they wrap every outcome — including guard 404s // (unknown hosts), tenant 404s (unclaimed/blocked subdomains), and cache // hits served inside the blog sub-app. diff --git a/src/cron/refresh.ts b/src/cron/refresh.ts index 4644013..53281c8 100644 --- a/src/cron/refresh.ts +++ b/src/cron/refresh.ts @@ -20,6 +20,7 @@ import { fetchEvents } from "../nostr/relay"; import { bumpGen, mirrorEvent } from "../services/mirror"; import { storedEventIds } from "../services/events"; import type { NostrEvent } from "../nostr/event"; +import { isSelfRelayHost } from "../relay/url"; /** Max NEW events verified+mirrored per user per cron run (contract: ~5). */ export const REFRESH_VERIFY_CAP = 5; @@ -166,7 +167,13 @@ async function refreshUser( // their own relays would otherwise never be mirrored by cron. Merge their // list ahead of the service defaults (deduped). const configured = readBlogSettings(user.settings).relays; - const relays = [...new Set([...configured, ...baseRelays])]; + // Filter out our own first-party relay AFTER the merge (users may paste + // wss://nbread.lol/relay into their settings): a Worker-to-own-zone ws + // subrequest won't reliably re-enter this Worker, and the relay shares the + // same D1 events store anyway — reading ourselves is a no-op at best. + const relays = [...new Set([...configured, ...baseRelays])].filter( + (url) => !isSelfRelayHost(url, env), + ); const since = readSince(user.settings); const { events: collected, windowClosed } = await collectBacklog( relays, diff --git a/src/index.ts b/src/index.ts index e088c17..c04775d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,10 @@ import { app } from "./app"; import { runRefresh } from "./cron/refresh"; import { sweepRateLimits } from "./services/ratelimit"; +// First-party relay Durable Object (wrangler.jsonc durable_objects binding +// RELAY_DO + migrations v1 new_sqlite_classes — the free-plan variant). +export { RelayDO } from "./relay/do"; + export default { fetch: app.fetch, diff --git a/src/relay/do.ts b/src/relay/do.ts new file mode 100644 index 0000000..6568224 --- /dev/null +++ b/src/relay/do.ts @@ -0,0 +1,648 @@ +/** + * RelayDO (packet 3): the first-party relay's single global Durable Object. + * + * Topology (plan §B, binding): one instance via idFromName("relay:v1"), + * WebSocket Hibernation API, `new_sqlite_classes` migration (free-plan + * requirement). NO setTimeout/setInterval/alarms anywhere — a timer would + * keep the object out of hibernation and start the duration-billing clock. + * + * State model: + * - Per-connection auth state lives in the socket ATTACHMENT + * (serializeAttachment, survives hibernation, kept well under the 16 KiB + * cap — see ConnState). + * - Subscriptions live in DO SQLite (`subs` rows, created lazily) so REQs + * survive hibernation too. Persistent event storage is NOT here: writes + * go through mirrorEvent into the shared D1 `events` table and reads run + * SQL over `events.raw` (queryEvents) — relay and blog can never disagree. + * - Message-rate / AUTH-attempt counters are in-memory only and reset on + * hibernation (accepted: hibernation implies the connection was idle). + * + * All protocol logic is in RelayCore.handleMessage — a testable core with + * injected env/subs-store/clock that returns frames + effects and performs no + * socket I/O. The RelayDO hibernation handlers are a thin shell around it. + */ +import { bytesToHex } from "@noble/hashes/utils.js"; +import { pickEventFields, type NostrEvent } from "../nostr/event"; +import { mirrorEvent, type MirrorResult } from "../services/mirror"; +import { rateLimitAllows } from "../services/ratelimit"; +import { getUserByPubkey } from "../services/users"; +import { matchesAnyFilter, sanitizeFilters } from "./filters"; +import { CREATED_AT_UPPER_LIMIT_SECONDS } from "./nip11"; +import { + authFrame, + closedFrame, + eoseFrame, + eventFrame, + MAX_MESSAGE_LENGTH, + MAX_SUBSCRIPTIONS_PER_CONN, + noticeFrame, + okFrame, + parseClientMessage, + validateAuthEvent, +} from "./protocol"; +import { queryEvents, type QueryRow } from "./query"; +import type { SanitizedFilter } from "./types"; + +// --- Caps (plan §B literals) --------------------------------------------------- + +/** Global concurrent-connection cap; upgrades beyond it get a 503. */ +export const MAX_CONNECTIONS = 256; +/** Per-connection inbound message budget (in-memory fixed window). */ +export const MAX_MESSAGES_PER_MINUTE = 120; +const MESSAGE_WINDOW_SECONDS = 60; +/** Max NIP-42 AUTH attempts per connection before a 1008 close. */ +export const MAX_AUTH_ATTEMPTS = 5; +/** How long one positive allowlist lookup stays cached in the attachment. */ +export const ALLOWLIST_CACHE_SECONDS = 300; +/** Per-pubkey EVENT budget: 30 per 5 minutes (D1 rate_limits, fail-closed). */ +export const EVENT_PK_MAX = 30; +export const EVENT_PK_WINDOW_SECONDS = 300; +/** Global daily store budget — bounds KV gen-bump burn to half the 1k/day. */ +export const GLOBAL_STORE_MAX = 500; +export const GLOBAL_STORE_WINDOW_SECONDS = 86_400; + +/** Kinds the relay accepts for writes (everything else is rejected). */ +export const ALLOWED_EVENT_KINDS: ReadonlySet = new Set([0, 5, 30023]); + +// --- Connection state (socket attachment) --------------------------------------- + +/** + * Per-connection state persisted in the hibernatable socket's attachment. + * Tiny by construction (two 64-hex strings + a number — far below the 16 KiB + * attachment cap). + */ +export type ConnState = { + /** Random UUID; also the socket's hibernation tag (fan-out addressing). */ + connId: string; + /** NIP-42 challenge issued for THIS connection (64-hex). */ + challenge: string; + /** Pubkey proven via AUTH, or null while unauthenticated. */ + authedPubkey: string | null; + /** + * Epoch seconds until which the POSITIVE allowlist check (claimed handle, + * not blocked) for authedPubkey is cached. 0 = not cached. Reset on every + * successful AUTH so a re-auth as a different key can never inherit it. + */ + allowedUntil: number; +}; + +// --- Subscription store ---------------------------------------------------------- + +/** One persisted subscription (filters = JSON of SanitizedFilter[]). */ +export type SubRow = { conn_id: string; sub_id: string; filters: string }; + +/** + * Ephemeral subscription bookkeeping. The DO backs this with its SQLite + * (SqlSubsStore below); unit tests drive RelayCore with an in-memory stub. + */ +export interface SubsStore { + /** Open subscriptions for a connection EXCLUDING subId (REQ replaces same-id). */ + countOther(connId: string, subId: string): number; + /** Upsert (NIP-01: a REQ reusing a subId replaces the old subscription). */ + put(connId: string, subId: string, filtersJson: string): void; + delete(connId: string, subId: string): void; + deleteConn(connId: string): void; + /** Every live subscription (fan-out scan). */ + all(): SubRow[]; +} + +/** DO SQLite implementation; the table is created lazily on first touch. */ +class SqlSubsStore implements SubsStore { + private ensured = false; + + constructor(private readonly sql: SqlStorage) {} + + private ensure(): void { + if (this.ensured) return; + this.sql.exec( + `CREATE TABLE IF NOT EXISTS subs ( + conn_id TEXT NOT NULL, + sub_id TEXT NOT NULL, + filters TEXT NOT NULL, + PRIMARY KEY (conn_id, sub_id) + )`, + ); + this.ensured = true; + } + + countOther(connId: string, subId: string): number { + this.ensure(); + const row = this.sql + .exec<{ n: number }>( + "SELECT COUNT(*) AS n FROM subs WHERE conn_id = ? AND sub_id != ?", + connId, + subId, + ) + .one(); + return row.n; + } + + put(connId: string, subId: string, filtersJson: string): void { + this.ensure(); + this.sql.exec( + "INSERT OR REPLACE INTO subs (conn_id, sub_id, filters) VALUES (?, ?, ?)", + connId, + subId, + filtersJson, + ); + } + + delete(connId: string, subId: string): void { + this.ensure(); + this.sql.exec( + "DELETE FROM subs WHERE conn_id = ? AND sub_id = ?", + connId, + subId, + ); + } + + deleteConn(connId: string): void { + this.ensure(); + this.sql.exec("DELETE FROM subs WHERE conn_id = ?", connId); + } + + all(): SubRow[] { + this.ensure(); + return this.sql + .exec("SELECT conn_id, sub_id, filters FROM subs") + .toArray(); + } + + /** + * Drop rows whose connection is no longer live (crash/eviction leftovers — + * webSocketClose normally cleans up). liveConnIds is bounded by + * MAX_CONNECTIONS, comfortably inside SQLite's bind-parameter limit. + */ + sweep(liveConnIds: string[]): void { + this.ensure(); + if (liveConnIds.length === 0) { + this.sql.exec("DELETE FROM subs"); + return; + } + const ph = liveConnIds.map(() => "?").join(", "); + this.sql.exec( + `DELETE FROM subs WHERE conn_id NOT IN (${ph})`, + ...liveConnIds, + ); + } +} + +// --- Protocol core ---------------------------------------------------------------- + +/** Everything one inbound frame produces; the DO shell applies it verbatim. */ +export type HandleOutcome = { + /** Frames for THIS connection, in send order. */ + frames: string[]; + /** Live fan-out frames addressed by connId (may include the sender). */ + fanout: { connId: string; frame: string }[]; + /** Present when the attachment changed and must be re-serialized. */ + updatedConn?: ConnState; + /** Present when the connection must be terminated after sending frames. */ + close?: { code: number; reason: string }; +}; + +/** Terse client-facing D1-failure message (NIP-01 `error:` machine prefix). */ +const UNAVAILABLE = "error: temporarily unavailable"; + +/** + * The relay's pure-ish protocol engine: no sockets, no DO APIs — just env + * (D1/KV via the reused services), a SubsStore, and an injectable clock. + * handleMessage never throws; every failure collapses to frames. + */ +export class RelayCore { + /** Per-connection message-rate windows (in-memory; reset on hibernation). */ + private readonly msgWindows = new Map< + string, + { windowStart: number; count: number } + >(); + /** Per-connection AUTH attempt counts (in-memory; reset on hibernation). */ + private readonly authAttempts = new Map(); + + constructor( + private readonly env: Env, + private readonly subs: SubsStore, + private readonly now: () => number = () => Math.floor(Date.now() / 1000), + ) {} + + /** Forget everything about a closed connection (counters + subs rows). */ + dropConn(connId: string): void { + this.msgWindows.delete(connId); + this.authAttempts.delete(connId); + this.subs.deleteConn(connId); + } + + /** Handle one raw inbound text frame for a connection. */ + async handleMessage(conn: ConnState, raw: string): Promise { + const out: HandleOutcome = { frames: [], fanout: [] }; + + if (!this.allowMessage(conn.connId)) { + out.frames.push(noticeFrame("rate-limited: too many messages")); + out.close = { code: 1008, reason: "message rate exceeded" }; + return out; + } + + const msg = parseClientMessage(raw, MAX_MESSAGE_LENGTH); + switch (msg.type) { + case "event": + return this.handleEvent(conn, msg.event, out); + case "req": + return this.handleReq(conn, msg.subId, msg.filters, out); + case "close": + // NIP-01: no confirmation frame for CLOSE. + this.subs.delete(conn.connId, msg.subId); + return out; + case "auth": + return this.handleAuth(conn, msg.event, out); + case "invalid": + // A structurally broken EVENT that still carried a plausible 64-hex + // id gets a machine-readable OK-false; everything else a NOTICE. + out.frames.push( + msg.id !== undefined + ? okFrame(msg.id, false, msg.reason) + : noticeFrame(msg.reason), + ); + return out; + } + } + + /** + * EVENT rejection ladder (plan §B, order is binding): kind allowlist → + * auth state → pubkey binding → D1 handle allowlist (5-min positive cache + * in the attachment) → rate limits (fail-closed) → mirrorEvent → fan-out. + */ + private async handleEvent( + conn: ConnState, + ev: NostrEvent, + out: HandleOutcome, + ): Promise { + if (!ALLOWED_EVENT_KINDS.has(ev.kind)) { + out.frames.push( + okFrame(ev.id, false, "restricted: only kinds 30023, 5, and 0 are accepted"), + ); + return out; + } + + if (conn.authedPubkey === null) { + out.frames.push( + okFrame(ev.id, false, "auth-required: authenticate with your nbread key first"), + ); + // Re-issue the challenge so well-behaved clients (editor.js NIP-42 + // handler) can AUTH and re-send without reconnecting. + out.frames.push(authFrame(conn.challenge)); + return out; + } + + if (ev.pubkey !== conn.authedPubkey) { + out.frames.push( + okFrame(ev.id, false, "restricted: event pubkey does not match the authenticated key"), + ); + return out; + } + + // D1 allowlist: claimed handle, not blocked. Positive results are cached + // in the attachment for 5 minutes; negatives are never cached (a user + // claiming their handle mid-connection starts publishing immediately). + const now = this.now(); + if (now >= conn.allowedUntil) { + let allowed: boolean; + try { + const user = await getUserByPubkey(this.env, conn.authedPubkey); + allowed = user !== null && user.handle !== null && user.blocked === 0; + } catch { + out.frames.push(okFrame(ev.id, false, UNAVAILABLE)); + return out; + } + if (!allowed) { + out.frames.push( + okFrame(ev.id, false, "restricted: writes are limited to claimed nbread.lol handles"), + ); + return out; + } + conn = { ...conn, allowedUntil: now + ALLOWLIST_CACHE_SECONDS }; + out.updatedConn = conn; + } + + // Reject events dated too far in the future. Enforces the NIP-11 + // created_at_upper_limit advertised in nip11.ts (constant imported from + // there so the gate and the advertisement can never drift). Without it a + // claimed handle could pin a future-dated post atop every cross-author REQ + // and lock its own replaceable slot until wall time catches up. + if (ev.created_at > now + CREATED_AT_UPPER_LIMIT_SECONDS) { + out.frames.push( + okFrame(ev.id, false, "invalid: created_at is too far in the future"), + ); + return out; + } + + // Rate limits (D1 fixed-window, FAIL-CLOSED). Per-pubkey first: denied + // requests still count, so a single hot key burns its own window without + // draining the global daily store budget. + const pkOk = await rateLimitAllows( + this.env, + `relay:ev:pk:${ev.pubkey}`, + EVENT_PK_MAX, + EVENT_PK_WINDOW_SECONDS, + ); + const globalOk = + pkOk && + (await rateLimitAllows( + this.env, + "relay:global:store", + GLOBAL_STORE_MAX, + GLOBAL_STORE_WINDOW_SECONDS, + )); + if (!globalOk) { + out.frames.push(okFrame(ev.id, false, "rate-limited: slow down")); + return out; + } + + let result: MirrorResult; + try { + result = await mirrorEvent(this.env, ev); + } catch { + out.frames.push(okFrame(ev.id, false, UNAVAILABLE)); + return out; + } + if (result === "stale") { + out.frames.push( + okFrame(ev.id, false, "duplicate: a newer version of this replaceable event is already stored"), + ); + return out; + } + if (result === "invalid") { + out.frames.push( + okFrame(ev.id, false, "invalid: id or signature verification failed"), + ); + return out; + } + + out.frames.push(okFrame(ev.id, true, "")); + + // Tombstone guard: mirrorEvent returns "stored" even for a kind-30023 + // whose address is covered by a delete horizon — it lands with deleted=1. + // queryEvents filters WHERE deleted=0, so a fresh REQ would never serve + // such a row; do not live-fan it either, or a subscriber sees a post the + // author has deleted. Only kind 30023 can be tombstoned on the store path + // (kind 0/5 always land deleted=0), so the extra read is scoped to it. + if (ev.kind === 30023) { + let servable: boolean; + try { + const row = await this.env.DB.prepare( + "SELECT deleted FROM events WHERE id = ?", + ) + .bind(ev.id) + .first<{ deleted: number }>(); + servable = row !== null && row.deleted === 0; + } catch { + servable = false; // fail closed: never leak a possibly-deleted post + } + if (!servable) return out; + } + + // Live fan-out: serve subscribers the same canonical 7-field JSON that + // mirrorEvent stored in events.raw (byte-identical string), matched per + // subscription with NIP-01 OR-across-filters semantics. The sender's own + // matching subscriptions are included — protocol-legal double delivery. + const clean = pickEventFields(ev); + const rawJson = JSON.stringify(clean); + for (const row of this.subs.all()) { + let filters: SanitizedFilter[]; + try { + filters = JSON.parse(row.filters) as SanitizedFilter[]; + } catch { + continue; // unreadable row: skip, never throw mid-fan-out + } + if (matchesAnyFilter(filters, clean)) { + out.fanout.push({ + connId: row.conn_id, + frame: eventFrame(row.sub_id, rawJson), + }); + } + } + return out; + } + + /** REQ: sanitize → sub cap → stored events (D1) → EOSE → persist the sub. */ + private async handleReq( + conn: ConnState, + subId: string, + rawFilters: unknown[], + out: HandleOutcome, + ): Promise { + const sanitized = sanitizeFilters(rawFilters); + if ("error" in sanitized) { + out.frames.push(closedFrame(subId, `invalid: ${sanitized.error}`)); + return out; + } + + // ≤8 open subs per connection; a REQ reusing an existing subId REPLACES + // that subscription (NIP-01), so it never counts against itself. + if (this.subs.countOther(conn.connId, subId) >= MAX_SUBSCRIPTIONS_PER_CONN) { + out.frames.push(closedFrame(subId, "restricted: too many subscriptions")); + return out; + } + + let rows: QueryRow[]; + try { + rows = await queryEvents(this.env, sanitized); + } catch { + out.frames.push(closedFrame(subId, UNAVAILABLE)); + return out; + } + for (const row of rows) { + out.frames.push(eventFrame(subId, row.raw)); + } + out.frames.push(eoseFrame(subId)); + + this.subs.put(conn.connId, subId, JSON.stringify(sanitized)); + return out; + } + + /** NIP-42 AUTH: bounded attempts, then validateAuthEvent vs the attachment. */ + private async handleAuth( + conn: ConnState, + ev: NostrEvent, + out: HandleOutcome, + ): Promise { + const attempts = (this.authAttempts.get(conn.connId) ?? 0) + 1; + this.authAttempts.set(conn.connId, attempts); + if (attempts > MAX_AUTH_ATTEMPTS) { + out.frames.push(noticeFrame("restricted: too many auth attempts")); + out.close = { code: 1008, reason: "too many auth attempts" }; + return out; + } + + const result = await validateAuthEvent( + ev, + conn.challenge, + this.env, + this.now(), + ); + if (!result.ok) { + out.frames.push(okFrame(ev.id, false, `invalid: ${result.reason}`)); + return out; + } + + // allowedUntil resets so a re-auth under a DIFFERENT key can never ride + // the previous key's cached allowlist verdict. + out.updatedConn = { ...conn, authedPubkey: result.pubkey, allowedUntil: 0 }; + out.frames.push(okFrame(ev.id, true, "")); + return out; + } + + /** In-memory fixed-window message-rate check (120/min per connection). */ + private allowMessage(connId: string): boolean { + const now = this.now(); + const w = this.msgWindows.get(connId); + if (w === undefined || now - w.windowStart >= MESSAGE_WINDOW_SECONDS) { + this.msgWindows.set(connId, { windowStart: now, count: 1 }); + return true; + } + w.count += 1; + return w.count <= MAX_MESSAGES_PER_MINUTE; + } +} + +// --- Durable Object shell ----------------------------------------------------------- + +/** Send that tolerates a concurrently-closed peer (send() throws then). */ +function trySend(ws: WebSocket, frame: string): void { + try { + ws.send(frame); + } catch { + // Peer already closing/closed — nothing to do; webSocketClose cleans up. + } +} + +export class RelayDO implements DurableObject { + private readonly subs: SqlSubsStore; + private readonly core: RelayCore; + + constructor( + private readonly ctx: DurableObjectState, + env: Env, + ) { + // D1/KV bindings arrive in the DO's env; mirrorEvent / getUserByPubkey / + // rateLimitAllows / queryEvents run here unchanged. + this.subs = new SqlSubsStore(ctx.storage.sql); + this.core = new RelayCore(env, this.subs); + } + + /** + * Upgrade-only entry point. NIP-11 and the plain info page are the + * WORKER's job (src/relay/http.ts) — an information fetch must never spend + * a DO request, so anything that reaches the DO without an Upgrade header + * is a routing mistake and gets a 426. + */ + fetch(request: Request): Response { + if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") { + return new Response("Expected a WebSocket upgrade request", { + status: 426, + headers: { Upgrade: "websocket" }, + }); + } + + const live = this.ctx.getWebSockets(); + if (live.length >= MAX_CONNECTIONS) { + return new Response("relay at capacity, try again later", { + status: 503, + headers: { "Retry-After": "60" }, + }); + } + + // Opportunistic sweep: drop subs rows orphaned by connections that died + // without a webSocketClose (eviction, crash). Best-effort — never blocks + // an upgrade. + try { + const liveIds: string[] = []; + for (const ws of live) { + const tag = this.ctx.getTags(ws)[0]; + if (tag !== undefined) liveIds.push(tag); + } + this.subs.sweep(liveIds); + } catch { + // sweep failure is harmless (rows retry on the next upgrade) + } + + const connId = crypto.randomUUID(); + const pair = new WebSocketPair(); + const client = pair[0]; + const server = pair[1]; + + // Hibernation API: the runtime owns the socket; the connId tag addresses + // it for fan-out after wake-ups (ctx.getWebSockets(connId)). + this.ctx.acceptWebSocket(server, [connId]); + + const challenge = bytesToHex(crypto.getRandomValues(new Uint8Array(32))); + const conn: ConnState = { + connId, + challenge, + authedPubkey: null, + allowedUntil: 0, + }; + server.serializeAttachment(conn); + + // NIP-42: challenge goes out immediately so clients can pre-auth before + // their first EVENT. + trySend(server, authFrame(challenge)); + + return new Response(null, { status: 101, webSocket: client }); + } + + async webSocketMessage( + ws: WebSocket, + message: string | ArrayBuffer, + ): Promise { + if (typeof message !== "string") { + trySend(ws, noticeFrame("invalid: binary frames are not supported")); + return; + } + const conn = ws.deserializeAttachment() as ConnState | null; + if (conn === null) { + // Should be unreachable (attachment is set before accept returns) — + // fail closed rather than process an unattributable frame. + ws.close(1011, "missing connection state"); + return; + } + + const out = await this.core.handleMessage(conn, message); + + // Persist attachment changes BEFORE any send: if the isolate dies mid- + // flush, auth state must not be lost while the client believes it holds. + if (out.updatedConn !== undefined) { + ws.serializeAttachment(out.updatedConn); + } + for (const frame of out.frames) { + trySend(ws, frame); + } + for (const { connId, frame } of out.fanout) { + for (const peer of this.ctx.getWebSockets(connId)) { + trySend(peer, frame); + } + } + if (out.close !== undefined) { + this.core.dropConn(conn.connId); + ws.close(out.close.code, out.close.reason); + } + } + + webSocketClose(ws: WebSocket): void { + this.cleanup(ws); + } + + webSocketError(ws: WebSocket): void { + this.cleanup(ws); + } + + /** Drop this connection's subs rows + in-memory counters. Idempotent. */ + private cleanup(ws: WebSocket): void { + const connId = this.ctx.getTags(ws)[0]; + if (connId !== undefined) { + try { + this.core.dropConn(connId); + } catch { + // best-effort — the upgrade-time sweep catches leftovers + } + } + } +} diff --git a/src/relay/filters.ts b/src/relay/filters.ts new file mode 100644 index 0000000..a57d583 --- /dev/null +++ b/src/relay/filters.ts @@ -0,0 +1,224 @@ +/** + * REQ filter sanitation + in-memory event matching (packet 1). Pure — no I/O. + * + * sanitizeFilters is the single choke point between untrusted REQ payloads + * and everything downstream (SQL translation in the query engine, JSON + * persistence in DO SQLite subs rows, live fan-out matching): every array is + * capped, every hex string validated, and `limit` is always materialized, so + * a SanitizedFilter can be trusted blindly. Cap violations are ERRORS (the + * REQ gets a CLOSED), not silent truncation — a silently narrowed filter + * would return misleading results. EMPTY lists are also errors: NIP-01's + * "matches nothing" reading and the SQL engine's "no constraint" reading + * would otherwise disagree, so the ambiguity is rejected at the choke point + * and neither engine ever sees an empty list. + */ +import { getDTag, MAX_TAG_ITEM_LENGTH, type NostrEvent } from "../nostr/event"; +import type { SanitizedFilter } from "./types"; + +/** Plan caps (docs: plan §B REQ engine; NIP-11 limitation mirrors these). */ +export const MAX_REQ_FILTERS = 4; +export const MAX_FILTER_IDS = 50; +export const MAX_FILTER_AUTHORS = 20; +export const MAX_FILTER_KINDS = 10; +export const MAX_TAG_FILTER_VALUES = 20; +export const MIN_LIMIT = 1; +export const MAX_LIMIT = 500; +export const DEFAULT_LIMIT = 100; + +const HEX_64 = /^[0-9a-f]{64}$/; +/** NIP-01 tag filters are `#` (a–z, A–Z) only. */ +const TAG_FILTER_KEY = /^#([a-zA-Z])$/; + +type ErrorResult = { error: string }; + +/** + * Sanitize the raw filter list of one REQ (`msg.slice(2)`), enforcing the + * plan caps: ≤4 filters; ids ≤50 / authors ≤20 (64-hex lowercase); kinds ≤10 + * (integers 0–65535); since/until non-negative integers; limit clamped to + * [1, 500] (default 100); `#d` and other single-letter tag filters ≤20 + * string values each. Unknown non-tag keys (e.g. `search`) and non-single- + * letter `#…` keys are ignored per NIP-01. Never throws. + */ +export function sanitizeFilters(raw: unknown): SanitizedFilter[] | ErrorResult { + if (!Array.isArray(raw)) { + return { error: "filters must be objects" }; + } + if (raw.length === 0) { + return { error: "REQ needs at least one filter" }; + } + if (raw.length > MAX_REQ_FILTERS) { + return { error: `too many filters (max ${MAX_REQ_FILTERS})` }; + } + const out: SanitizedFilter[] = []; + for (const item of raw) { + const result = sanitizeFilter(item); + if ("error" in result) return result; + out.push(result); + } + return out; +} + +/** Sanitize one filter object. */ +function sanitizeFilter(item: unknown): SanitizedFilter | ErrorResult { + if (typeof item !== "object" || item === null || Array.isArray(item)) { + return { error: "filter must be an object" }; + } + const rec = item as Record; + const f: SanitizedFilter = { limit: DEFAULT_LIMIT }; + + if (rec.ids !== undefined) { + const ids = hexList(rec.ids, MAX_FILTER_IDS, "ids"); + if ("error" in ids) return ids; + f.ids = ids.values; + } + if (rec.authors !== undefined) { + const authors = hexList(rec.authors, MAX_FILTER_AUTHORS, "authors"); + if ("error" in authors) return authors; + f.authors = authors.values; + } + if (rec.kinds !== undefined) { + const kinds = rec.kinds; + if ( + !Array.isArray(kinds) || + kinds.length === 0 || + kinds.length > MAX_FILTER_KINDS + ) { + return { error: `kinds must be a non-empty list of at most ${MAX_FILTER_KINDS} kinds` }; + } + for (const k of kinds) { + if (typeof k !== "number" || !Number.isInteger(k) || k < 0 || k > 65535) { + return { error: "kinds must be integers in 0-65535" }; + } + } + f.kinds = kinds as number[]; + } + if (rec.since !== undefined) { + if (!isTimestamp(rec.since)) return { error: "since must be a unix timestamp" }; + f.since = rec.since; + } + if (rec.until !== undefined) { + if (!isTimestamp(rec.until)) return { error: "until must be a unix timestamp" }; + f.until = rec.until; + } + if (rec.limit !== undefined) { + if (typeof rec.limit !== "number" || !Number.isInteger(rec.limit)) { + return { error: "limit must be an integer" }; + } + f.limit = Math.min(MAX_LIMIT, Math.max(MIN_LIMIT, rec.limit)); + } + + // Tag filters: `#d` → dTags (SQL-translated downstream); other single- + // letter keys → tagFilters (JS-post-filtered). Anything else is ignored. + for (const key of Object.keys(rec)) { + const m = TAG_FILTER_KEY.exec(key); + if (m === null) continue; + const letter = m[1] as string; // regex has exactly one capture group + const values = stringList(rec[key], MAX_TAG_FILTER_VALUES, key); + if ("error" in values) return values; + if (letter === "d") { + f.dTags = values.values; + } else { + (f.tagFilters ??= {})[letter] = values.values; + } + } + + return f; +} + +function isTimestamp(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isInteger(value) && + value >= 0 && + value <= Number.MAX_SAFE_INTEGER + ); +} + +function hexList( + value: unknown, + max: number, + name: string, +): { values: string[] } | ErrorResult { + if (!Array.isArray(value) || value.length === 0 || value.length > max) { + return { error: `${name} must be a non-empty list of at most ${max} values` }; + } + for (const v of value) { + if (typeof v !== "string" || !HEX_64.test(v)) { + return { error: `${name} must contain 64-char lowercase hex values` }; + } + } + return { values: value as string[] }; +} + +function stringList( + value: unknown, + max: number, + name: string, +): { values: string[] } | ErrorResult { + if (!Array.isArray(value) || value.length === 0 || value.length > max) { + return { error: `${name} must be a non-empty list of at most ${max} values` }; + } + for (const v of value) { + // Values longer than an event tag item can ever be (MAX_TAG_ITEM_LENGTH) + // can never match anything — reject instead of persisting dead weight in + // DO SQLite subs rows. + if (typeof v !== "string" || v.length > MAX_TAG_ITEM_LENGTH) { + return { error: `${name} values must be strings of at most ${MAX_TAG_ITEM_LENGTH} chars` }; + } + } + return { values: value as string[] }; +} + +// --- Matching (live fan-out) ----------------------------------------------------- + +/** + * The `d` value the store slots this event under: parameterized-replaceable + * kinds (30000-39999) key on their FIRST `d` tag, every other kind (0/5/…) + * occupies the empty-string slot even when a stray `d` tag is present. Mirrors + * mirror.ts `slotDTag` so live `#d` matching and the indexed `d_tag` column + * (query.ts) can never disagree — otherwise a kind-5-with-stray-d or a + * multi-`d` 30023 could be live-fanned to a `#d` subscriber while a fresh REQ + * returns nothing. + */ +function slottedDTag(ev: NostrEvent): string { + return ev.kind >= 30_000 && ev.kind < 40_000 ? getDTag(ev) : ""; +} + +/** + * Does one sanitized filter match an event? All present conditions must hold + * (NIP-01 AND semantics within a filter); `limit` is a query cap, not a + * match condition. `#d` matches the event's SLOTTED `d` value (see + * slottedDTag — mirrors the SQL `d_tag` column); generic tag conditions match + * when ANY tag of that name carries one of the requested values. + */ +export function matchEvent(f: SanitizedFilter, ev: NostrEvent): boolean { + if (f.ids !== undefined && !f.ids.includes(ev.id)) return false; + if (f.authors !== undefined && !f.authors.includes(ev.pubkey)) return false; + if (f.kinds !== undefined && !f.kinds.includes(ev.kind)) return false; + if (f.since !== undefined && ev.created_at < f.since) return false; + if (f.until !== undefined && ev.created_at > f.until) return false; + if (f.dTags !== undefined && !f.dTags.includes(slottedDTag(ev))) return false; + if (f.tagFilters !== undefined) { + for (const [letter, values] of Object.entries(f.tagFilters)) { + if (!hasTagValue(ev, letter, values)) return false; + } + } + return true; +} + +/** An event matches a REQ when ANY of its filters match (NIP-01 OR). */ +export function matchesAnyFilter( + filters: SanitizedFilter[], + ev: NostrEvent, +): boolean { + return filters.some((f) => matchEvent(f, ev)); +} + +/** Does the event carry a tag `[name, v]` with v in `values`? */ +function hasTagValue(ev: NostrEvent, name: string, values: string[]): boolean { + return ev.tags.some((t) => { + if (t[0] !== name) return false; + const v = t[1]; + return v !== undefined && values.includes(v); + }); +} diff --git a/src/relay/http.ts b/src/relay/http.ts new file mode 100644 index 0000000..90fd963 --- /dev/null +++ b/src/relay/http.ts @@ -0,0 +1,109 @@ +/** + * Worker-side /relay endpoint (packet 3). + * + * Registered on the OUTER Hono app BEFORE securityHeaders/guard/tenant + * (src/app.ts): a successful upgrade returns an immutable 101 response — + * securityHeaders' headers.set() would throw on it — and the guard/tenant/ + * csrf/session stack has no business running per-upgrade. That means this + * handler must do its own host self-check and set its own response headers. + * + * Split of duties (plan §B): the DO serves ONLY WebSocket traffic; NIP-11 + * and the plain info page are answered here so an information fetch never + * spends a DO request. Denied upgrades (per-IP rate limit) are also decided + * here for the same reason. + */ +import type { Context } from "hono"; +import { normalizeHostname } from "../middleware/guard"; +import { rateLimitAllows } from "../services/ratelimit"; +import type { AppEnv } from "../types"; +import { nip11Document } from "./nip11"; +import { selfRelayUrl } from "./url"; + +/** Per-IP upgrade budget: 30 per 10 minutes (D1 rate_limits, fail-closed). */ +export const UPGRADE_IP_MAX = 30; +export const UPGRADE_IP_WINDOW_SECONDS = 600; + +/** + * Loopback hosts wrangler dev listens on — same set the guard treats as the + * apex (src/middleware/guard.ts LOOPBACK_HOSTS). + */ +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); + +/** + * Plain-text response carrying the same nosniff/Referrer-Policy hardening the + * securityHeaders middleware adds to every OTHER Worker response. /relay is + * registered BEFORE that middleware (immutable 101s), so these short bodies + * (404 wrong-host, 429 rate-limited, info page) must set the headers + * themselves — matching the "headers land on every response" invariant. + */ +function textResponse(body: string, status = 200): Response { + return new Response(body, { + status, + headers: { + "Content-Type": "text/plain; charset=utf-8", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "strict-origin-when-cross-origin", + }, + }); +} + +/** GET /relay | WS upgrade — see module docs. */ +export async function relayEndpoint(c: Context): Promise { + // Host self-check FIRST (mirrors guard semantics: normalized hostname must + // be the apex, or a loopback dev host). The route pattern is path-only, so + // without this check alice.nbread.lol/relay and hostile Host headers would + // reach the relay. + const rawHost = c.req.header("host") ?? new URL(c.req.url).host; + const hostname = normalizeHostname(rawHost); + const main = c.env.MAIN_HOST.toLowerCase(); + if (hostname === null || (hostname !== main && !LOOPBACK_HOSTS.has(hostname))) { + return textResponse("Not found", 404); + } + + // WebSocket upgrade → per-IP rate limit, then hand the RAW request to the + // single global DO and return its response UNTOUCHED (101s are immutable). + if (c.req.header("Upgrade")?.toLowerCase() === "websocket") { + const ip = c.req.header("CF-Connecting-IP") ?? "unknown"; + const allowed = await rateLimitAllows( + c.env, + `relay:ip:${ip}`, + UPGRADE_IP_MAX, + UPGRADE_IP_WINDOW_SECONDS, + ); + if (!allowed) { + // Fail-closed (D1 error denies too); a denied upgrade never spends a + // DO request. + return textResponse("rate limited, try again later", 429); + } + const stub = c.env.RELAY_DO.get(c.env.RELAY_DO.idFromName("relay:v1")); + return stub.fetch(c.req.raw); + } + + // NIP-11 relay information document. Headers are set manually — this route + // bypasses securityHeaders by design. + if ((c.req.header("Accept") ?? "").includes("application/nostr+json")) { + return Response.json(nip11Document(c.env), { + headers: { + "Content-Type": "application/nostr+json", + "Access-Control-Allow-Origin": "*", + "X-Content-Type-Options": "nosniff", + "Cache-Control": "public, max-age=3600", + }, + }); + } + + // Plain browser hit: a tiny text info page. + const body = [ + "nbread relay", + "", + ` ${selfRelayUrl(c.env)}`, + "", + "Reads are open; writes are restricted to claimed nbread.lol handles", + "(NIP-42 auth; kinds 30023, 5, and 0 — own events only).", + "", + "Relay info: request this URL with Accept: application/nostr+json (NIP-11).", + `Docs: https://${main}/docs`, + "", + ].join("\n"); + return textResponse(body); +} diff --git a/src/relay/nip11.ts b/src/relay/nip11.ts new file mode 100644 index 0000000..2e934a0 --- /dev/null +++ b/src/relay/nip11.ts @@ -0,0 +1,80 @@ +/** + * NIP-11 relay information document (packet 1). Pure — no I/O. + * + * Served by the WORKER (not the DO — an information fetch must never spend a + * DO request) when GET /relay carries `Accept: application/nostr+json`. The + * limitation block mirrors the enforced caps exactly: values are imported + * from the modules that enforce them so the advertisement can never drift + * from the implementation. + */ +import { MAX_CONTENT_LENGTH, MAX_TAGS } from "../nostr/event"; +import { adminPubkeyOf } from "../routes/admin"; +import { MAX_LIMIT } from "./filters"; +import { + MAX_MESSAGE_LENGTH, + MAX_SUBID_LENGTH, + MAX_SUBSCRIPTIONS_PER_CONN, +} from "./protocol"; + +/** Public source repository (NIP-11 `software`). */ +export const RELAY_SOFTWARE = "https://github.com/sovITxyz/nbread"; +/** Relay implementation version (NIP-11 `version`). */ +export const RELAY_VERSION = "0.1.0"; +/** + * Max seconds an event's created_at may sit in the future (NIP-11 + * `created_at_upper_limit`). Plan §B literal. + */ +export const CREATED_AT_UPPER_LIMIT_SECONDS = 900; + +export type Nip11Document = { + name: string; + description: string; + supported_nips: number[]; + software: string; + version: string; + /** Admin contact pubkey (lowercase hex) — present iff ADMIN_PUBKEY is set. */ + pubkey?: string; + limitation: { + auth_required: boolean; + restricted_writes: boolean; + max_message_length: number; + max_subscriptions: number; + max_limit: number; + max_subid_length: number; + max_event_tags: number; + max_content_length: number; + created_at_upper_limit: number; + }; +}; + +/** + * Build the NIP-11 document. `pubkey` is included IFF ADMIN_PUBKEY resolves + * (hex or npub1…, via the admin surface's own parser — same normalization, + * same fail-closed posture on malformed values). + */ +export function nip11Document(env: Env): Nip11Document { + const doc: Nip11Document = { + name: "nbread relay", + description: + "First-party relay for nbread.lol blogs. Reads are open; writes are " + + "restricted to claimed nbread authors (NIP-42 auth; kinds 30023, 5, " + + "and 0 — own events only).", + supported_nips: [1, 9, 11, 42], + software: RELAY_SOFTWARE, + version: RELAY_VERSION, + limitation: { + auth_required: false, // reads are open; writes demand AUTH (restricted_writes) + restricted_writes: true, + max_message_length: MAX_MESSAGE_LENGTH, + max_subscriptions: MAX_SUBSCRIPTIONS_PER_CONN, + max_limit: MAX_LIMIT, + max_subid_length: MAX_SUBID_LENGTH, + max_event_tags: MAX_TAGS, + max_content_length: MAX_CONTENT_LENGTH, + created_at_upper_limit: CREATED_AT_UPPER_LIMIT_SECONDS, + }, + }; + const pubkey = adminPubkeyOf(env); + if (pubkey !== null) doc.pubkey = pubkey; + return doc; +} diff --git a/src/relay/protocol.ts b/src/relay/protocol.ts new file mode 100644 index 0000000..1770661 --- /dev/null +++ b/src/relay/protocol.ts @@ -0,0 +1,207 @@ +/** + * Relay wire protocol (packet 1): strict inbound message parsing, outbound + * frame builders, and NIP-42 AUTH event validation. Pure functions — no I/O, + * nothing here throws on untrusted input. + */ +import { isNostrEvent, verifyEvent, type NostrEvent } from "../nostr/event"; +import { + LOGIN_EVENT_KIND, + MAX_LOGIN_SKEW_SECONDS, + relayTagBindsHost, +} from "../routes/auth"; +import type { AuthValidation, ClientMessage } from "./types"; + +/** + * Max inbound frame size in UTF-16 code units (`string.length`, which + * lower-bounds bytes). Matches NIP-11 `max_message_length` (1 MiB). The DO + * passes this to parseClientMessage BEFORE JSON.parse so oversized frames + * never spend parse CPU. + */ +export const MAX_MESSAGE_LENGTH = 1_048_576; + +/** Max subscription-id length (NIP-01 caps it at 64; NIP-11 max_subid_length). */ +export const MAX_SUBID_LENGTH = 64; + +/** Max concurrent subscriptions per connection (NIP-11 max_subscriptions). */ +export const MAX_SUBSCRIPTIONS_PER_CONN = 8; + +const HEX_64 = /^[0-9a-f]{64}$/; + +/** + * Parse one raw WebSocket text frame into a typed client message. Strict + * NIP-01 JSON-array parsing; never throws — every failure mode (oversized, + * junk JSON, deep-nested stack blowout inside JSON.parse, non-array, unknown + * verb, wrong arity, structurally invalid event, bad subId) collapses to + * `{type: "invalid", reason}`. EVENT/AUTH payloads are isNostrEvent-checked + * (structure + size caps only — schnorr stays with the caller); REQ filters + * are returned raw for sanitizeFilters. + */ +export function parseClientMessage(raw: string, maxLen: number): ClientMessage { + if (raw.length > maxLen) { + return { type: "invalid", reason: "invalid: message too large" }; + } + let msg: unknown; + try { + msg = JSON.parse(raw); + } catch { + return { type: "invalid", reason: "invalid: not valid JSON" }; + } + if (!Array.isArray(msg) || msg.length === 0) { + return { type: "invalid", reason: "invalid: message must be a JSON array" }; + } + const verb: unknown = msg[0]; + if (typeof verb !== "string") { + return { type: "invalid", reason: "invalid: message type must be a string" }; + } + + switch (verb) { + case "EVENT": { + if (msg.length !== 2) { + return { type: "invalid", reason: 'invalid: EVENT must be ["EVENT", event]' }; + } + const ev: unknown = msg[1]; + if (!isNostrEvent(ev)) { + const id = plausibleEventId(ev); + return { + type: "invalid", + reason: "invalid: event failed structural validation", + ...(id !== undefined ? { id } : {}), + }; + } + return { type: "event", event: ev }; + } + case "REQ": { + if (msg.length < 3) { + return { + type: "invalid", + reason: 'invalid: REQ must be ["REQ", subId, filter, ...]', + }; + } + const subId = validSubId(msg[1]); + if (subId === null) { + return { type: "invalid", reason: "invalid: bad subscription id" }; + } + return { type: "req", subId, filters: msg.slice(2) as unknown[] }; + } + case "CLOSE": { + if (msg.length !== 2) { + return { type: "invalid", reason: 'invalid: CLOSE must be ["CLOSE", subId]' }; + } + const subId = validSubId(msg[1]); + if (subId === null) { + return { type: "invalid", reason: "invalid: bad subscription id" }; + } + return { type: "close", subId }; + } + case "AUTH": { + if (msg.length !== 2) { + return { type: "invalid", reason: 'invalid: AUTH must be ["AUTH", event]' }; + } + const ev: unknown = msg[1]; + if (!isNostrEvent(ev)) { + return { type: "invalid", reason: "invalid: auth event failed structural validation" }; + } + return { type: "auth", event: ev }; + } + default: + return { type: "invalid", reason: `invalid: unknown message type "${verb.slice(0, 16)}"` }; + } +} + +/** A valid NIP-01 subscription id: non-empty string, ≤64 chars. */ +function validSubId(value: unknown): string | null { + if (typeof value !== "string") return null; + if (value.length === 0 || value.length > MAX_SUBID_LENGTH) return null; + return value; +} + +/** Best-effort event id from a payload that FAILED isNostrEvent (for OK-false). */ +function plausibleEventId(value: unknown): string | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + const id = (value as Record).id; + return typeof id === "string" && HEX_64.test(id) ? id : undefined; +} + +// --- Outbound frame builders (all return serialized JSON strings) -------------- + +/** `["OK", id, ok, message]` (NIP-01/20). */ +export function okFrame(id: string, ok: boolean, message: string): string { + return JSON.stringify(["OK", id, ok, message]); +} + +/** `["NOTICE", msg]`. */ +export function noticeFrame(msg: string): string { + return JSON.stringify(["NOTICE", msg]); +} + +/** `["CLOSED", subId, msg]` (NIP-01 server-side subscription termination). */ +export function closedFrame(subId: string, msg: string): string { + return JSON.stringify(["CLOSED", subId, msg]); +} + +/** `["EOSE", subId]`. */ +export function eoseFrame(subId: string): string { + return JSON.stringify(["EOSE", subId]); +} + +/** `["AUTH", challenge]` (NIP-42 challenge frame). */ +export function authFrame(challenge: string): string { + return JSON.stringify(["AUTH", challenge]); +} + +/** + * `["EVENT", subId, event]` — built by STRING CONCATENATION around the + * canonical stored JSON (`events.raw`). The raw text is never reparsed or + * re-serialized, so the bytes a client receives are exactly the bytes that + * were verified and stored (id recomputation on the client side stays valid, + * and we spend zero parse CPU per fan-out). + */ +export function eventFrame(subId: string, rawEventJson: string): string { + return '["EVENT",' + JSON.stringify(subId) + "," + rawEventJson + "]"; +} + +// --- NIP-42 AUTH validation ----------------------------------------------------- + +/** + * Validate a client's `["AUTH", event]` response against THIS connection's + * challenge. Reuses the login flow's building blocks (same kind 22242, same + * ±600s skew window, same relay-tag host binding via relayTagBindsHost) — + * the one intentional difference is that the challenge is the connection's + * in-memory attachment value, NOT a D1 login_nonces row. Cheap structural + * checks run first; schnorr (verifyEvent) runs last. Never throws. + */ +export async function validateAuthEvent( + ev: NostrEvent, + challenge: string, + env: Env, + nowSec: number, +): Promise { + // Defense in depth: parse already ran isNostrEvent, but this function must + // never throw even if handed a forged object directly (e.g. from a future + // caller), so re-check before touching ev.tags. + if (!isNostrEvent(ev)) { + return { ok: false, reason: "malformed auth event" }; + } + if (ev.kind !== LOGIN_EVENT_KIND) { + return { ok: false, reason: "wrong event kind" }; + } + if (Math.abs(ev.created_at - nowSec) > MAX_LOGIN_SKEW_SECONDS) { + return { ok: false, reason: "created_at outside the acceptance window" }; + } + // An empty connection challenge must never validate (no AUTH frame was + // issued yet, so there is nothing to prove possession of). + const evChallenge = ev.tags.find((t) => t[0] === "challenge")?.[1]; + if (challenge === "" || evChallenge === undefined || evChallenge !== challenge) { + return { ok: false, reason: "missing or wrong challenge tag" }; + } + const relayTag = ev.tags.find((t) => t[0] === "relay")?.[1]; + if (relayTag === undefined || !relayTagBindsHost(relayTag, env)) { + return { ok: false, reason: "missing or wrong relay binding tag" }; + } + if (!(await verifyEvent(ev))) { + return { ok: false, reason: "invalid event signature" }; + } + return { ok: true, pubkey: ev.pubkey }; +} diff --git a/src/relay/query.ts b/src/relay/query.ts new file mode 100644 index 0000000..2e3d35d --- /dev/null +++ b/src/relay/query.ts @@ -0,0 +1,133 @@ +// Relay REQ query engine: translates sanitized NIP-01 filters into SQL over +// the existing D1 `events` table (the same store mirrorEvent writes — relay +// and blog can never disagree). One parameterized statement per filter; +// generic tag filters (#e/#t/…) are applied as a JS post-filter on the SQL +// candidates, bounded by the per-filter LIMIT already clamped upstream by +// sanitizeFilters. +import type { SanitizedFilter } from "./types"; + +/** Row shape served back to REQ subscribers (raw = canonical stored JSON). */ +export type QueryRow = { + id: string; + kind: number; + created_at: number; + raw: string; +}; + +/** `?, ?, ?` placeholder list for a dynamic IN clause (values stay bound). */ +function placeholders(n: number): string { + return new Array(n).fill("?").join(", "); +} + +/** + * Does the event raw JSON satisfy every generic tag filter? NIP-01: for each + * `#x` filter the event needs at least one `x` tag whose value is in the + * filter's list; multiple tag filters AND together. Keys are accepted with or + * without the leading `#` (tolerant of either sanitizer representation). + * Unparseable raw rows are excluded (fail closed — never serve garbage). + */ +function passesTagFilters( + raw: string, + tagFilters: Record, +): boolean { + let tags: unknown; + try { + const ev = JSON.parse(raw) as { tags?: unknown }; + tags = ev.tags; + } catch { + return false; + } + if (!Array.isArray(tags)) return false; + for (const [key, values] of Object.entries(tagFilters)) { + const letter = key.startsWith("#") ? key.slice(1) : key; + const hit = tags.some( + (tag) => + Array.isArray(tag) && + tag[0] === letter && + typeof tag[1] === "string" && + values.includes(tag[1]), + ); + if (!hit) return false; + } + return true; +} + +/** + * Execute sanitized REQ filters against D1. + * + * Per filter: WHERE deleted = 0 plus the SQL-translatable keys (ids, authors, + * kinds, #d, since, until), ORDER BY created_at DESC, id ASC, LIMIT the + * filter's clamped limit. Kind-5 delete markers are stored with deleted = 0, + * so deletes stay servable while tombstoned posts never are. + * + * Results are merged across filters by id (an event matching several filters + * is served once), re-sorted globally (created_at DESC, id ASC), and capped + * at the max of the filters' limits. + */ +export async function queryEvents( + env: Env, + filters: SanitizedFilter[], +): Promise { + const byId = new Map(); + let globalCap = 0; + + for (const f of filters) { + globalCap = Math.max(globalCap, f.limit); + + const where: string[] = ["deleted = 0"]; + const params: (string | number)[] = []; + + if (f.ids && f.ids.length > 0) { + where.push(`id IN (${placeholders(f.ids.length)})`); + params.push(...f.ids); + } + if (f.authors && f.authors.length > 0) { + where.push(`pubkey IN (${placeholders(f.authors.length)})`); + params.push(...f.authors); + } + if (f.kinds && f.kinds.length > 0) { + where.push(`kind IN (${placeholders(f.kinds.length)})`); + params.push(...f.kinds); + } + if (f.dTags && f.dTags.length > 0) { + where.push(`d_tag IN (${placeholders(f.dTags.length)})`); + params.push(...f.dTags); + } + if (f.since !== undefined) { + where.push("created_at >= ?"); + params.push(f.since); + } + if (f.until !== undefined) { + where.push("created_at <= ?"); + params.push(f.until); + } + + const stmt = env.DB.prepare( + `SELECT id, kind, created_at, raw FROM events + WHERE ${where.join(" AND ")} + ORDER BY created_at DESC, id ASC LIMIT ?`, + ).bind(...params, f.limit); + + const { results } = await stmt.all(); + const tagFilters = f.tagFilters; + const hasTagFilters = + tagFilters !== undefined && Object.keys(tagFilters).length > 0; + + for (const row of results) { + if (byId.has(row.id)) continue; + if (hasTagFilters && !passesTagFilters(row.raw, tagFilters)) continue; + byId.set(row.id, row); + } + } + + const merged = [...byId.values()].sort((a, b) => + a.created_at !== b.created_at + ? b.created_at - a.created_at + : a.id < b.id + ? -1 + : a.id > b.id + ? 1 + : 0, + ); + return merged.slice(0, globalCap); +} diff --git a/src/relay/types.ts b/src/relay/types.ts new file mode 100644 index 0000000..2555404 --- /dev/null +++ b/src/relay/types.ts @@ -0,0 +1,98 @@ +/** + * First-party relay protocol types (packet 1): sanitized REQ filters, parsed + * inbound client messages, outbound frame shapes, and the NIP-42 AUTH + * validation result. Pure data contracts — no I/O. The RelayDO (packet 3) + * consumes these; changing a shape here is a cross-packet contract change. + */ +import type { NostrEvent } from "../nostr/event"; + +/** + * A REQ filter after sanitizeFilters (src/relay/filters.ts) has enforced the + * plan caps. Every array is bounded, every hex string validated, and `limit` + * is always present (clamped to [1, 500], default 100), so the query engine + * and fan-out matcher can trust the shape blindly. + */ +export type SanitizedFilter = { + /** Exact event ids, 64-hex lowercase, ≤50. */ + ids?: string[]; + /** Author pubkeys, 64-hex lowercase, ≤20. */ + authors?: string[]; + /** Event kinds, integers 0–65535, ≤10. */ + kinds?: number[]; + /** Inclusive lower bound on created_at (NIP-01: since ≤ created_at). */ + since?: number; + /** Inclusive upper bound on created_at (NIP-01: created_at ≤ until). */ + until?: number; + /** Max events for the stored-events query; ALWAYS set. */ + limit: number; + /** `#d` filter values (matched against any `d` tag of the event), ≤20. */ + dTags?: string[]; + /** + * Other single-letter tag filters keyed by the BARE letter (`#t` → `"t"`), + * ≤20 values each. `#d` never appears here — it lives in `dTags` because + * the query engine translates it to SQL while these are JS-post-filtered. + */ + tagFilters?: Record; +}; + +// --- Inbound client messages (output of parseClientMessage) ------------------- + +/** `["EVENT", event]` — event already passed isNostrEvent (structure only). */ +export type ClientEventMessage = { type: "event"; event: NostrEvent }; + +/** + * `["REQ", subId, ...filters]` — subId validated (string, 1–64 chars); + * filters are the RAW third-onward elements, still unknown: the caller feeds + * them to sanitizeFilters (kept separate so a filter error maps to CLOSED + * while a frame error maps to NOTICE). + */ +export type ClientReqMessage = { type: "req"; subId: string; filters: unknown[] }; + +/** `["CLOSE", subId]` — subId validated (string, 1–64 chars). */ +export type ClientCloseMessage = { type: "close"; subId: string }; + +/** `["AUTH", event]` — structurally valid; validateAuthEvent does the rest. */ +export type ClientAuthMessage = { type: "auth"; event: NostrEvent }; + +/** + * Anything unusable: junk JSON, non-array, unknown verb, wrong arity, + * oversized frame, malformed event/subId. `id` is set when an EVENT frame + * carried a plausible (64-hex) event id despite failing structural + * validation, so the DO can answer `["OK", id, false, …]` instead of a bare + * NOTICE. + */ +export type ClientInvalidMessage = { type: "invalid"; reason: string; id?: string }; + +/** Union of every parseClientMessage result. */ +export type ClientMessage = + | ClientEventMessage + | ClientReqMessage + | ClientCloseMessage + | ClientAuthMessage + | ClientInvalidMessage; + +// --- Outbound frames (relay → client, NIP-01/42 shapes) ------------------------ +// The builders in protocol.ts return SERIALIZED strings; these tuple types +// document the wire shape (and type the JSON.parse side in tests). + +export type OkFrame = ["OK", string, boolean, string]; +export type NoticeFrame = ["NOTICE", string]; +export type ClosedFrame = ["CLOSED", string, string]; +export type EoseFrame = ["EOSE", string]; +export type AuthChallengeFrame = ["AUTH", string]; +export type EventFrame = ["EVENT", string, NostrEvent]; + +export type RelayFrame = + | OkFrame + | NoticeFrame + | ClosedFrame + | EoseFrame + | AuthChallengeFrame + | EventFrame; + +// --- NIP-42 AUTH validation result --------------------------------------------- + +/** Result of validateAuthEvent (src/relay/protocol.ts). */ +export type AuthValidation = + | { ok: true; pubkey: string } + | { ok: false; reason: string }; diff --git a/src/relay/url.ts b/src/relay/url.ts new file mode 100644 index 0000000..a5fccc9 --- /dev/null +++ b/src/relay/url.ts @@ -0,0 +1,30 @@ +/** + * Relay endpoint URL helpers (packet 1). Pure — no I/O. + * + * The first-party relay lives at wss:///relay by design (no new + * env var; plan §B): selfRelayUrl derives it, and isSelfRelayHost lets the + * cron self-filter merged relay lists — a same-zone Worker ws subrequest + * won't reliably re-enter the Worker, and the store is shared D1 anyway, so + * the cron reading its own relay would be a wasted (or hanging) connection. + */ + +/** The first-party relay endpoint for this deployment. */ +export function selfRelayUrl(env: Env): string { + return "wss://" + env.MAIN_HOST.toLowerCase() + "/relay"; +} + +/** + * Does this relay URL point at OUR host? Hostname comparison only (any + * scheme/path/port form a client might have recorded still names the same + * zone). Invalid/unparseable URLs → false — a junk entry in a merged relay + * list must never be mistaken for self. + */ +export function isSelfRelayHost(url: string, env: Env): boolean { + let hostname: string; + try { + hostname = new URL(url).hostname.toLowerCase(); + } catch { + return false; + } + return hostname === env.MAIN_HOST.toLowerCase(); +} diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index cbdd6d1..543b9d6 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -18,6 +18,7 @@ import { renderPost } from "../markdown"; import { sanitizeCss, MAX_THEME_CSS_LENGTH } from "../markdown/css-sanitize"; import { firstTagValue, isoDate, postMeta } from "../markdown/nip23"; import { relayList } from "../cron/refresh"; +import { selfRelayUrl } from "../relay/url"; import { DashboardPage, type DashboardPost } from "../views/main/dashboard"; import { EditorPage } from "../views/main/editor"; @@ -137,12 +138,19 @@ function clientIp(c: Context): string { } /** - * Relays the editor should broadcast to: the user's configured list, falling - * back to the service defaults (RELAYS env var) when none are set. + * Relays the editor should broadcast to: the first-party nbread relay first + * (every publish lands on wss://MAIN_HOST/relay), then the user's configured + * list, falling back to the service defaults (RELAYS env var) when none are + * set. Deduped in case the user configured the nbread relay themselves. */ function editorRelays(env: Env, user: User | null): string[] { const configured = readBlogSettings(user?.settings ?? "{}").relays; - return configured.length > 0 ? configured : relayList(env); + return [ + ...new Set([ + selfRelayUrl(env), + ...(configured.length > 0 ? configured : relayList(env)), + ]), + ]; } /** diff --git a/src/routes/wellknown.ts b/src/routes/wellknown.ts index d52e936..cfb4b80 100644 --- a/src/routes/wellknown.ts +++ b/src/routes/wellknown.ts @@ -1,5 +1,6 @@ import { Hono } from "hono"; import type { DispatchEnv } from "../types"; +import { selfRelayUrl } from "../relay/url"; /** * NIP-05: GET /.well-known/nostr.json?name= → {names:{: @@ -39,9 +40,16 @@ wellknownRoutes.get("/nostr.json", async (c) => { if (!row || row.blocked) { return c.json({ names: {} }, 200, CORS_HEADERS); } - const relays = c.env.RELAYS.split(",") - .map((r) => r.trim()) - .filter((r) => r.length > 0); + // Relay hints: the first-party nbread relay first (it always carries this + // user's mirrored events), then the service defaults from env.RELAYS. + const relays = [ + ...new Set([ + selfRelayUrl(c.env), + ...c.env.RELAYS.split(",") + .map((r) => r.trim()) + .filter((r) => r.length > 0), + ]), + ]; return c.json( { names: { [name]: row.pubkey }, diff --git a/src/views/main/docs.tsx b/src/views/main/docs.tsx index 32bd298..51adcef 100644 --- a/src/views/main/docs.tsx +++ b/src/views/main/docs.tsx @@ -11,7 +11,7 @@ export function DocsPage() {

Docs

-

Last updated: 2026-07-14

+

Last updated: 2026-07-16

What is nbread.lol

@@ -93,6 +93,13 @@ body { font-family: Georgia, serif; } Defaults: relay.damus.io, nos.lol, and relay.nostr.band. Set your own in the dashboard.

+

+ nbread also runs a first-party relay at{" "} + wss://nbread.lol/relay. Anyone can read from it with + any Nostr client; writes are restricted to claimed nbread handles + (NIP-42 auth). Posts published in the editor are broadcast to it + automatically. +

Unclaimed blogs

diff --git a/test/integration/editor.spec.ts b/test/integration/editor.spec.ts index 5f39c50..67f6696 100644 --- a/test/integration/editor.spec.ts +++ b/test/integration/editor.spec.ts @@ -281,6 +281,8 @@ describe("editor pages", () => { expect(html).toContain('id="editor-form"'); expect(html).toContain("/js/editor.js"); expect(html).toContain('id="editor-config"'); + // Broadcast list leads with the first-party relay (then defaults). + expect(html).toContain('"relays":["wss://nbread.lol/relay"'); }); it("loads an existing post into the editor by slug", async () => { diff --git a/test/integration/info.spec.ts b/test/integration/info.spec.ts index 7685032..900279e 100644 --- a/test/integration/info.spec.ts +++ b/test/integration/info.spec.ts @@ -40,6 +40,13 @@ describe("info pages (/privacy /terms /docs)", () => { const docs = await SELF.fetch("https://nbread.lol/docs"); expect(await docs.text()).toContain("AGPL"); }); + + it("/docs documents the first-party relay", async () => { + const res = await SELF.fetch("https://nbread.lol/docs"); + const html = await res.text(); + expect(html).toContain("wss://nbread.lol/relay"); + expect(html).toContain("NIP-42"); + }); }); describe("apex 404", () => { diff --git a/test/integration/migrations.spec.ts b/test/integration/migrations.spec.ts index afedf73..cbf7f2c 100644 --- a/test/integration/migrations.spec.ts +++ b/test/integration/migrations.spec.ts @@ -46,6 +46,7 @@ describe("migrations/0001_init.sql", () => { "about", "root", "_dmarc", + "relay", // seeded by 0005_relay.sql (first-party relay endpoint) ].sort(), ); }); diff --git a/test/integration/refresh-relays.spec.ts b/test/integration/refresh-relays.spec.ts index 0c0e8de..e27f101 100644 --- a/test/integration/refresh-relays.spec.ts +++ b/test/integration/refresh-relays.spec.ts @@ -70,6 +70,29 @@ describe("cron refresh — user-configured relays participate in sync", () => { expect(row?.id).toBe(aliceHello.id); }); + it("never dials the first-party nbread relay, even when configured", async () => { + // Users may paste wss://nbread.lol/relay into their settings. Cron must + // filter it out: a Worker-to-own-zone ws subrequest won't reliably + // re-enter the Worker, and the relay shares the same D1 store anyway. + const SELF_RELAY = "wss://nbread.lol/relay"; + await env.DB.prepare("UPDATE users SET settings = ? WHERE pubkey = ?") + .bind(JSON.stringify({ relays: [SELF_RELAY, USER_RELAY] }), ALICE_PK) + .run(); + const dialed: string[] = []; + serveEventsByUrl({ [USER_RELAY]: [aliceHello] }, dialed); + + await runScheduled(); + + expect(dialed).not.toContain(SELF_RELAY); + expect(dialed).toContain(USER_RELAY); // other configured relays survive + const row = await env.DB.prepare( + "SELECT id FROM events WHERE pubkey = ? AND d_tag = 'hello-world'", + ) + .bind(ALICE_PK) + .first<{ id: string }>(); + expect(row?.id).toBe(aliceHello.id); + }); + it("still syncs from the defaults when the user configured nothing", async () => { const dialed: string[] = []; serveEventsByUrl( diff --git a/test/integration/relay-query.spec.ts b/test/integration/relay-query.spec.ts new file mode 100644 index 0000000..7d4f803 --- /dev/null +++ b/test/integration/relay-query.spec.ts @@ -0,0 +1,240 @@ +// Relay REQ query engine against the real (miniflare) D1: events seeded +// through mirrorEvent — replaceable semantics, NIP-09 tombstones and the raw +// column are exactly what production writes — then read back via queryEvents. +import { env } from "cloudflare:test"; +import { beforeEach, describe, expect, it } from "vitest"; +import { finalizeEvent } from "nostr-tools/pure"; +import { hexToBytes } from "@noble/hashes/utils.js"; +import { + ALICE_PK, + ALICE_SK, + BOB_PK, + BOB_SK, + resetMirrorState, +} from "../helpers"; +import { mirrorEvent } from "../../src/services/mirror"; +import { queryEvents } from "../../src/relay/query"; +import type { SanitizedFilter } from "../../src/relay/types"; +import type { NostrEvent } from "../../src/nostr/event"; + +/** Sign an arbitrary event with a committed throwaway fixture key. */ +function sign( + sk: string, + opts: { + kind: number; + created_at: number; + tags?: string[][]; + content?: string; + }, +): NostrEvent { + return finalizeEvent( + { + kind: opts.kind, + created_at: opts.created_at, + tags: opts.tags ?? [], + content: opts.content ?? "", + }, + hexToBytes(sk), + ) as NostrEvent; +} + +/** 30023 with the editor's tag shape plus optional extra tags (#t etc.). */ +function post( + sk: string, + d: string, + created_at: number, + extraTags: string[][] = [], + content = `body of ${d}`, +): NostrEvent { + return sign(sk, { + kind: 30023, + created_at, + tags: [["d", d], ["title", d], ...extraTags], + content, + }); +} + +/** SanitizedFilter with the sanitizer's default limit applied. */ +function filter(partial: Partial = {}): SanitizedFilter { + return { limit: 100, ...partial }; +} + +// Base corpus (mirrored fresh in beforeEach): +// alice a1 @1000, a2 @2000 (#t nostr), a3 @3000 +// bob b1 @2500 (#t bread) +// alice profile (kind 0) @1500 +const aliceA1 = post(ALICE_SK, "a1", 1000); +const aliceA2 = post(ALICE_SK, "a2", 2000, [["t", "nostr"]]); +const aliceA3 = post(ALICE_SK, "a3", 3000); +const bobB1 = post(BOB_SK, "b1", 2500, [["t", "bread"]]); +const aliceProfile = sign(ALICE_SK, { + kind: 0, + created_at: 1500, + content: JSON.stringify({ name: "alice-relay-test" }), +}); +const CORPUS = [aliceA1, aliceA2, aliceA3, bobB1, aliceProfile]; + +describe("relay query engine (queryEvents over mirrored D1 events)", () => { + beforeEach(async () => { + await resetMirrorState(); + for (const ev of CORPUS) { + expect(await mirrorEvent(env, ev)).toBe("stored"); + } + }); + + it("migration 0005 created idx_events_author_time on events", async () => { + const row = await env.DB.prepare( + `SELECT name, tbl_name FROM sqlite_master + WHERE type = 'index' AND name = 'idx_events_author_time'`, + ).first<{ name: string; tbl_name: string }>(); + expect(row).not.toBeNull(); + expect(row!.tbl_name).toBe("events"); + }); + + it("orders created_at DESC and serves the canonical stored raw JSON", async () => { + const rows = await queryEvents(env, [filter({ kinds: [30023] })]); + expect(rows.map((r) => r.id)).toEqual([ + aliceA3.id, + bobB1.id, + aliceA2.id, + aliceA1.id, + ]); + // raw is servable as-is: parses back to the signed event. + const parsed = JSON.parse(rows[0]!.raw) as NostrEvent; + expect(parsed.id).toBe(aliceA3.id); + expect(parsed.sig).toBe(aliceA3.sig); + expect(parsed.content).toBe(aliceA3.content); + }); + + it("breaks created_at ties by id ASC", async () => { + const twinA = post(ALICE_SK, "twin-a", 5000); + const twinB = post(ALICE_SK, "twin-b", 5000); + for (const ev of [twinA, twinB]) { + expect(await mirrorEvent(env, ev)).toBe("stored"); + } + const rows = await queryEvents(env, [ + filter({ authors: [ALICE_PK], since: 5000 }), + ]); + const expected = [twinA.id, twinB.id].sort(); + expect(rows.map((r) => r.id)).toEqual(expected); + }); + + it("filters by ids", async () => { + const rows = await queryEvents(env, [ + filter({ ids: [aliceA1.id, bobB1.id] }), + ]); + expect(rows.map((r) => r.id)).toEqual([bobB1.id, aliceA1.id]); + }); + + it("filters by authors", async () => { + const rows = await queryEvents(env, [ + filter({ authors: [BOB_PK], kinds: [30023] }), + ]); + expect(rows.map((r) => r.id)).toEqual([bobB1.id]); + }); + + it("filters by kinds (kind 0 profile only via kinds:[0])", async () => { + const rows = await queryEvents(env, [filter({ kinds: [0] })]); + expect(rows.map((r) => r.id)).toEqual([aliceProfile.id]); + // and kinds:[30023] never leaks the profile + const posts = await queryEvents(env, [filter({ kinds: [30023] })]); + expect(posts.map((r) => r.id)).not.toContain(aliceProfile.id); + }); + + it("applies since/until inclusively", async () => { + const rows = await queryEvents(env, [ + filter({ kinds: [30023], since: 2000, until: 2500 }), + ]); + expect(rows.map((r) => r.id)).toEqual([bobB1.id, aliceA2.id]); + }); + + it("filters by #d via the indexed d_tag column", async () => { + const rows = await queryEvents(env, [ + filter({ kinds: [30023], dTags: ["a2", "b1"] }), + ]); + expect(rows.map((r) => r.id)).toEqual([bobB1.id, aliceA2.id]); + }); + + it("post-filters generic tag filters (#t) in JS from row.raw", async () => { + const rows = await queryEvents(env, [ + filter({ kinds: [30023], tagFilters: { t: ["nostr"] } }), + ]); + expect(rows.map((r) => r.id)).toEqual([aliceA2.id]); + // tolerant of the "#t" key spelling too + const hashRows = await queryEvents(env, [ + filter({ kinds: [30023], tagFilters: { "#t": ["bread"] } }), + ]); + expect(hashRows.map((r) => r.id)).toEqual([bobB1.id]); + // no match -> empty + const none = await queryEvents(env, [ + filter({ kinds: [30023], tagFilters: { t: ["absent-topic"] } }), + ]); + expect(none).toEqual([]); + }); + + it("enforces the per-filter limit (newest first)", async () => { + const rows = await queryEvents(env, [filter({ kinds: [30023], limit: 2 })]); + expect(rows.map((r) => r.id)).toEqual([aliceA3.id, bobB1.id]); + }); + + it("dedupes across filters and caps at the max of the filters' limits", async () => { + const rows = await queryEvents(env, [ + filter({ kinds: [30023], limit: 1 }), + filter({ authors: [ALICE_PK], kinds: [30023], limit: 2 }), + ]); + // Filter 1 yields a3; filter 2 yields a3+a2. a3 deduped; global cap = + // max(1, 2) = 2. + expect(rows.map((r) => r.id)).toEqual([aliceA3.id, aliceA2.id]); + expect(new Set(rows.map((r) => r.id)).size).toBe(rows.length); + }); + + it("returns [] for an empty filter list", async () => { + expect(await queryEvents(env, [])).toEqual([]); + }); + + it("serves only the newest version of a replaceable slot", async () => { + const v1 = post(ALICE_SK, "evolving", 4000, [], "first draft"); + const v2 = post(ALICE_SK, "evolving", 4500, [], "second draft"); + expect(await mirrorEvent(env, v1)).toBe("stored"); + expect(await mirrorEvent(env, v2)).toBe("stored"); + + const rows = await queryEvents(env, [filter({ dTags: ["evolving"] })]); + expect(rows.map((r) => r.id)).toEqual([v2.id]); + // the losing version is gone entirely, even when asked for by id + expect(await queryEvents(env, [filter({ ids: [v1.id] })])).toEqual([]); + }); + + it("excludes tombstoned posts but still serves the kind-5 delete", async () => { + const doomed = post(ALICE_SK, "doomed", 4000); + expect(await mirrorEvent(env, doomed)).toBe("stored"); + // visible before the delete + let rows = await queryEvents(env, [filter({ dTags: ["doomed"] })]); + expect(rows.map((r) => r.id)).toEqual([doomed.id]); + + const del = sign(ALICE_SK, { + kind: 5, + created_at: 4100, + tags: [ + ["e", doomed.id], + ["a", `30023:${ALICE_PK}:doomed`], + ], + content: "Deleted via nbread.lol", + }); + expect(await mirrorEvent(env, del)).toBe("stored"); + + // the post is tombstoned everywhere: by #d, by id, by author scan + expect(await queryEvents(env, [filter({ dTags: ["doomed"] })])).toEqual([]); + expect(await queryEvents(env, [filter({ ids: [doomed.id] })])).toEqual([]); + const authorRows = await queryEvents(env, [ + filter({ authors: [ALICE_PK], kinds: [30023] }), + ]); + expect(authorRows.map((r) => r.id)).not.toContain(doomed.id); + + // …but the kind-5 delete itself stays servable (deleted = 0 on kind 5) + const deletes = await queryEvents(env, [ + filter({ authors: [ALICE_PK], kinds: [5] }), + ]); + expect(deletes.map((r) => r.id)).toEqual([del.id]); + expect(await queryEvents(env, [filter({ ids: [del.id] })])).toHaveLength(1); + }); +}); diff --git a/test/integration/relay.spec.ts b/test/integration/relay.spec.ts new file mode 100644 index 0000000..59ec7af --- /dev/null +++ b/test/integration/relay.spec.ts @@ -0,0 +1,352 @@ +// PR2 packet 5: the first-party relay end-to-end over a REAL WebSocket. +// +// Every case drives the live upgrade path: SELF.fetch("https://nbread.lol/ +// relay", { Upgrade: "websocket" }) returns a 101 whose client socket the +// TEST accepts (resp.webSocket.accept()) and exchanges frames with. Accepting +// on the TEST side — not inside a nested request/scheduled context — is what +// dodges workerd's cross-context WebSocketPair hang-detection (see +// test/mock-relay.ts). Frames are awaited one at a time through a small +// promise queue so ordering assertions are deterministic. +// +// The Worker (src/relay/http.ts) answers NIP-11 and the plain info page +// itself; the DO (src/relay/do.ts) serves only ws traffic. Storage is the +// shared D1 `events` table via mirrorEvent, so a published post is asserted +// BOTH back through the relay (REQ → EVENT/EOSE) and through the normal blog +// path (https://alice.nbread.lol/) — relay and blog can never disagree. +import { env, SELF } from "cloudflare:test"; +import { beforeEach, describe, expect, it } from "vitest"; +import { finalizeEvent } from "nostr-tools/pure"; +import { hexToBytes } from "@noble/hashes/utils.js"; +import { + ALICE_PK, + ALICE_SK, + BOB_PK, + BOB_SK, + resetMirrorState, + resetRateLimits, + resetUsers, + seedAlice, + signDeleteEvent, + signLoginEvent, + signPostEvent, +} from "../helpers"; +import type { NostrEvent } from "../../src/nostr/event"; +import { nip11Document } from "../../src/relay/nip11"; + +// --- WebSocket harness ---------------------------------------------------------- + +type Frame = unknown[]; + +/** + * A test-side wrapper around an accepted client socket: buffers inbound text + * frames and hands them out one at a time via next() (awaited promise if none + * are queued yet). send() serializes a NIP-01 tuple. + */ +type Harness = { + raw: WebSocket; + send: (msg: unknown[]) => void; + next: () => Promise; + close: () => void; +}; + +function harness(ws: WebSocket): Harness { + const queue: string[] = []; + const waiters: ((v: string) => void)[] = []; + ws.addEventListener("message", (e: MessageEvent) => { + const data = typeof e.data === "string" ? e.data : ""; + const w = waiters.shift(); + if (w !== undefined) w(data); + else queue.push(data); + }); + return { + raw: ws, + send: (msg) => ws.send(JSON.stringify(msg)), + next: () => + new Promise((resolve) => { + const deliver = (s: string) => resolve(JSON.parse(s) as Frame); + const q = queue.shift(); + if (q !== undefined) deliver(q); + else waiters.push(deliver); + }), + close: () => { + try { + ws.close(); + } catch { + // already closing/closed + } + }, + }; +} + +let ipCounter = 0; + +/** + * Open a ws connection to the relay and consume the immediate NIP-42 AUTH + * challenge frame, returning the harness plus that challenge string. Each call + * uses a fresh CF-Connecting-IP so the per-IP upgrade window never trips + * across the many connections a single test opens. + */ +async function connect(): Promise<{ ws: Harness; challenge: string }> { + ipCounter += 1; + const resp = await SELF.fetch("https://nbread.lol/relay", { + headers: { + Upgrade: "websocket", + "CF-Connecting-IP": `203.0.113.${ipCounter}`, + }, + }); + expect(resp.status).toBe(101); + const ws = resp.webSocket; + expect(ws).not.toBeNull(); + ws!.accept(); + const h = harness(ws!); + const first = await h.next(); + expect(first[0]).toBe("AUTH"); + return { ws: h, challenge: first[1] as string }; +} + +/** AUTH a connection with a fixture key; asserts OK true and returns nothing. */ +async function authenticate( + ws: Harness, + challenge: string, + sk: string, +): Promise { + ws.send(["AUTH", signLoginEvent(challenge, { sk })]); + const ok = await ws.next(); + expect(ok[0]).toBe("OK"); + expect(ok[2]).toBe(true); +} + +/** Sign an arbitrary-kind event with a fixture key (for the disallowed kinds). */ +function signEvent( + sk: string, + opts: { kind: number; created_at?: number; tags?: string[][]; content?: string }, +): NostrEvent { + return finalizeEvent( + { + kind: opts.kind, + created_at: opts.created_at ?? Math.floor(Date.now() / 1000), + tags: opts.tags ?? [], + content: opts.content ?? "", + }, + hexToBytes(sk), + ) as NostrEvent; +} + +const NOW = () => Math.floor(Date.now() / 1000); + +beforeEach(async () => { + await resetMirrorState(); + await resetRateLimits(); + await resetUsers(); + await seedAlice(); +}); + +// --- NIP-11 + info page (Worker-served, no DO cost) -------------------------------- + +describe("relay HTTP surface (NIP-11 + info page)", () => { + it("serves the NIP-11 document matching nip11Document(env) with CORS", async () => { + const resp = await SELF.fetch("https://nbread.lol/relay", { + headers: { Accept: "application/nostr+json" }, + }); + expect(resp.status).toBe(200); + expect(resp.headers.get("Content-Type")).toContain("application/nostr+json"); + expect(resp.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(resp.headers.get("Cache-Control")).toContain("max-age=3600"); + const doc = await resp.json(); + expect(doc).toEqual(nip11Document(env)); + // sanity on the load-bearing advertised facts + expect((doc as { supported_nips: number[] }).supported_nips).toContain(42); + expect((doc as { limitation: { restricted_writes: boolean } }).limitation.restricted_writes).toBe(true); + }); + + it("serves a plain-text info page on a bare GET (no upgrade, no Accept)", async () => { + const resp = await SELF.fetch("https://nbread.lol/relay"); + expect(resp.status).toBe(200); + expect(resp.headers.get("Content-Type")).toContain("text/plain"); + const body = await resp.text(); + expect(body).toContain("wss://nbread.lol/relay"); + expect(body).toContain("Reads are open"); + }); + + it("404s the relay path on a non-apex host", async () => { + const resp = await SELF.fetch("https://alice.nbread.lol/relay"); + expect(resp.status).toBe(404); + }); +}); + +// --- AUTH + write path ------------------------------------------------------------- + +describe("relay ws — AUTH and the EVENT write path", () => { + it("sends a NIP-42 AUTH challenge immediately on connect", async () => { + const { ws, challenge } = await connect(); + expect(challenge).toMatch(/^[0-9a-f]{64}$/); + ws.close(); + }); + + it("rejects an unauthenticated EVENT with auth-required + a fresh challenge", async () => { + const { ws } = await connect(); + const ev = signPostEvent({ d: "no-auth", title: "T", content: "x", created_at: NOW() }); + ws.send(["EVENT", ev]); + const ok = await ws.next(); + expect(ok).toEqual(["OK", ev.id, false, "auth-required: authenticate with your nbread key first"]); + const reauth = await ws.next(); + expect(reauth[0]).toBe("AUTH"); + ws.close(); + }); + + it("accepts a claimed key's 30023 — readable via REQ AND through the blog path", async () => { + const { ws, challenge } = await connect(); + await authenticate(ws, challenge, ALICE_SK); + + const post = signPostEvent({ + d: "relay-post", + title: "Published via the relay", + content: "hello from the first-party relay", + created_at: NOW(), + }); + ws.send(["EVENT", post]); + const ok = await ws.next(); + expect(ok).toEqual(["OK", post.id, true, ""]); + + // Readable back through a REQ on the same connection: EVENT then EOSE. + ws.send(["REQ", "read", { kinds: [30023], authors: [ALICE_PK] }]); + const evFrame = await ws.next(); + expect(evFrame[0]).toBe("EVENT"); + expect(evFrame[1]).toBe("read"); + expect((evFrame[2] as NostrEvent).id).toBe(post.id); + expect((evFrame[2] as NostrEvent).sig).toBe(post.sig); + const eose = await ws.next(); + expect(eose).toEqual(["EOSE", "read"]); + + // Same event is live on the blog (shared D1 events table via mirrorEvent). + const blog = await SELF.fetch("https://alice.nbread.lol/relay-post"); + expect(blog.status).toBe(200); + const html = await blog.text(); + expect(html).toContain("hello from the first-party relay"); + ws.close(); + }); + + it("restricts a claimed-but-mismatched pubkey and an UNclaimed key", async () => { + // authed as alice, but the event is bob's key → pubkey mismatch + const a = await connect(); + await authenticate(a.ws, a.challenge, ALICE_SK); + const bobPost = signPostEvent({ sk: BOB_SK, d: "x", title: "X", content: "y", created_at: NOW() }); + a.ws.send(["EVENT", bobPost]); + const mism = await a.ws.next(); + expect(mism[2]).toBe(false); + expect(mism[3]).toMatch(/does not match the authenticated key/); + a.ws.close(); + + // authed as bob (a valid signer with NO claimed handle) → restricted write + const b = await connect(); + await authenticate(b.ws, b.challenge, BOB_SK); + const own = signPostEvent({ sk: BOB_SK, d: "z", title: "Z", content: "w", created_at: NOW() }); + b.ws.send(["EVENT", own]); + const restricted = await b.ws.next(); + expect(restricted[2]).toBe(false); + expect(restricted[3]).toMatch(/^restricted: writes are limited/); + b.ws.close(); + }); + + it("rejects a disallowed kind (kind 1) as restricted", async () => { + const { ws, challenge } = await connect(); + await authenticate(ws, challenge, ALICE_SK); + const note = signEvent(ALICE_SK, { kind: 1, content: "a short note" }); + ws.send(["EVENT", note]); + const ok = await ws.next(); + expect(ok).toEqual([ + "OK", + note.id, + false, + "restricted: only kinds 30023, 5, and 0 are accepted", + ]); + ws.close(); + }); +}); + +// --- REQ engine + deletes ---------------------------------------------------------- + +describe("relay ws — REQ filtering and NIP-09 deletes", () => { + it("honors filter correctness including the limit (newest first)", async () => { + const { ws, challenge } = await connect(); + await authenticate(ws, challenge, ALICE_SK); + + const t = NOW(); + const p1 = signPostEvent({ d: "p1", title: "P1", content: "one", created_at: t - 30 }); + const p2 = signPostEvent({ d: "p2", title: "P2", content: "two", created_at: t - 20 }); + const p3 = signPostEvent({ d: "p3", title: "P3", content: "three", created_at: t - 10 }); + for (const ev of [p1, p2, p3]) { + ws.send(["EVENT", ev]); + expect((await ws.next())[2]).toBe(true); + } + + // limit 2 → the two newest, p3 then p2, then EOSE + ws.send(["REQ", "lim", { kinds: [30023], authors: [ALICE_PK], limit: 2 }]); + const f1 = await ws.next(); + const f2 = await ws.next(); + const f3 = await ws.next(); + expect((f1[2] as NostrEvent).id).toBe(p3.id); + expect((f2[2] as NostrEvent).id).toBe(p2.id); + expect(f3).toEqual(["EOSE", "lim"]); + ws.close(); + }); + + it("stops serving a post once a kind-5 delete tombstones it", async () => { + const { ws, challenge } = await connect(); + await authenticate(ws, challenge, ALICE_SK); + + const doomed = signPostEvent({ d: "doomed", title: "Doomed", content: "goodbye", created_at: NOW() }); + ws.send(["EVENT", doomed]); + expect((await ws.next())[2]).toBe(true); + + // visible pre-delete + ws.send(["REQ", "pre", { kinds: [30023], authors: [ALICE_PK] }]); + expect((await ws.next())[0]).toBe("EVENT"); + expect(await ws.next()).toEqual(["EOSE", "pre"]); + + const del = signDeleteEvent({ + eventId: doomed.id, + address: `30023:${ALICE_PK}:doomed`, + created_at: NOW() + 1, + }); + ws.send(["EVENT", del]); + expect((await ws.next())[2]).toBe(true); + + // the post is gone from REQ; the delete marker itself is still servable + ws.send(["REQ", "post", { kinds: [30023], authors: [ALICE_PK] }]); + expect(await ws.next()).toEqual(["EOSE", "post"]); + ws.send(["REQ", "dels", { kinds: [5], authors: [ALICE_PK] }]); + const delFrame = await ws.next(); + expect(delFrame[0]).toBe("EVENT"); + expect((delFrame[2] as NostrEvent).id).toBe(del.id); + expect(await ws.next()).toEqual(["EOSE", "dels"]); + ws.close(); + }); +}); + +// --- Live fan-out ------------------------------------------------------------------ + +describe("relay ws — live fan-out across connections", () => { + it("delivers a freshly published EVENT to a second connection's matching REQ", async () => { + const author = await connect(); + await authenticate(author.ws, author.challenge, ALICE_SK); + + // Reader subscribes and drains to EOSE (nothing stored yet). + const reader = await connect(); + reader.ws.send(["REQ", "live", { kinds: [30023], authors: [ALICE_PK] }]); + expect(await reader.ws.next()).toEqual(["EOSE", "live"]); + + // Author publishes; reader must receive the live EVENT frame. + const post = signPostEvent({ d: "fanout", title: "Fanout", content: "broadcast body", created_at: NOW() }); + author.ws.send(["EVENT", post]); + expect((await author.ws.next())[2]).toBe(true); + + const pushed = await reader.ws.next(); + expect(pushed[0]).toBe("EVENT"); + expect(pushed[1]).toBe("live"); + expect((pushed[2] as NostrEvent).id).toBe(post.id); + + author.ws.close(); + reader.ws.close(); + }); +}); diff --git a/test/integration/wellknown.spec.ts b/test/integration/wellknown.spec.ts index 5d9a522..a3c696d 100644 --- a/test/integration/wellknown.spec.ts +++ b/test/integration/wellknown.spec.ts @@ -29,7 +29,9 @@ describe("/.well-known/nostr.json (NIP-05)", () => { expect(res.headers.get("access-control-allow-origin")).toBe("*"); expect(res.headers.get("content-type")).toContain("application/json"); expect(body.names).toEqual({ alice: ALICE_PK }); - // Optional relays object: pubkey → relay list from env.RELAYS. + // Optional relays object: the first-party relay hint leads, then the + // env.RELAYS defaults. + expect(body.relays?.[ALICE_PK]?.[0]).toBe("wss://nbread.lol/relay"); expect(body.relays?.[ALICE_PK]).toContain("wss://relay.damus.io"); }); diff --git a/test/unit/relay-do.spec.ts b/test/unit/relay-do.spec.ts new file mode 100644 index 0000000..79b3045 --- /dev/null +++ b/test/unit/relay-do.spec.ts @@ -0,0 +1,576 @@ +// PR2 packet 3: RelayCore — the RelayDO's testable protocol engine, driven +// directly (no sockets, no DO instance; the workerd cross-context +// WebSocketPair hang-detection gotcha never applies). env comes from the +// workers pool (real miniflare D1/KV with migrations applied), the SubsStore +// is an in-memory stub, and events are signed with the committed throwaway +// fixture keys. +import { env } from "cloudflare:test"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + ALICE_PK, + ALICE_SK, + BOB_PK, + BOB_SK, + MALLORY_PK, + MALLORY_SK, + resetMirrorState, + resetRateLimits, + resetUsers, + seedAlice, + seedBlockedMallory, + signDeleteEvent, + signLoginEvent, + signPostEvent, +} from "../helpers"; +import { mirrorEvent } from "../../src/services/mirror"; +import type { NostrEvent } from "../../src/nostr/event"; +import { + ALLOWLIST_CACHE_SECONDS, + EVENT_PK_MAX, + EVENT_PK_WINDOW_SECONDS, + GLOBAL_STORE_MAX, + GLOBAL_STORE_WINDOW_SECONDS, + MAX_AUTH_ATTEMPTS, + MAX_MESSAGES_PER_MINUTE, + RelayCore, + type ConnState, + type SubRow, + type SubsStore, +} from "../../src/relay/do"; + +// --- Test doubles --------------------------------------------------------------- + +/** In-memory SubsStore (the DO uses its SQLite; the core doesn't care). */ +class MemStore implements SubsStore { + private rows = new Map>(); + + countOther(connId: string, subId: string): number { + const conn = this.rows.get(connId); + if (conn === undefined) return 0; + let n = 0; + for (const k of conn.keys()) if (k !== subId) n += 1; + return n; + } + put(connId: string, subId: string, filtersJson: string): void { + let conn = this.rows.get(connId); + if (conn === undefined) { + conn = new Map(); + this.rows.set(connId, conn); + } + conn.set(subId, filtersJson); + } + delete(connId: string, subId: string): void { + this.rows.get(connId)?.delete(subId); + } + deleteConn(connId: string): void { + this.rows.delete(connId); + } + all(): SubRow[] { + const out: SubRow[] = []; + for (const [conn_id, subs] of this.rows) { + for (const [sub_id, filters] of subs) out.push({ conn_id, sub_id, filters }); + } + return out; + } + count(connId: string): number { + return this.rows.get(connId)?.size ?? 0; + } + get(connId: string, subId: string): string | undefined { + return this.rows.get(connId)?.get(subId); + } +} + +function makeConn(over: Partial = {}): ConnState { + return { + connId: crypto.randomUUID(), + challenge: "ab".repeat(32), + authedPubkey: null, + allowedUntil: 0, + ...over, + }; +} + +function makeCore(store = new MemStore(), now?: () => number) { + return { core: new RelayCore(env, store, now), store }; +} + +/** Parse an outbound frame back into its tuple for assertions. */ +const parse = (frame: string): unknown[] => JSON.parse(frame) as unknown[]; + +const send = ( + core: RelayCore, + conn: ConnState, + msg: unknown[], +): ReturnType => + core.handleMessage(conn, JSON.stringify(msg)); + +/** AUTH a connection with a fixture key and return the updated ConnState. */ +async function authed( + core: RelayCore, + conn: ConnState, + sk = ALICE_SK, +): Promise { + const out = await send(core, conn, ["AUTH", signLoginEvent(conn.challenge, { sk })]); + expect(out.updatedConn).toBeDefined(); + return out.updatedConn as ConnState; +} + +/** Seed a D1 rate_limits counter at the current window. */ +async function seedLimit( + key: string, + count: number, + windowSeconds: number, +): Promise { + const now = Math.floor(Date.now() / 1000); + await env.DB.prepare( + "INSERT OR REPLACE INTO rate_limits (key, count, window_start) VALUES (?, ?, ?)", + ) + .bind(key, count, now - (now % windowSeconds)) + .run(); +} + +const NOW = () => Math.floor(Date.now() / 1000); + +function post(over: Partial[0]> = {}): NostrEvent { + return signPostEvent({ + d: "hello", + title: "Hello", + content: "hello world", + created_at: NOW(), + ...over, + }); +} + +beforeEach(async () => { + await resetMirrorState(); + await resetRateLimits(); + await resetUsers(); + await seedAlice(); +}); + +// --- AUTH flow -------------------------------------------------------------------- + +describe("RelayCore — NIP-42 AUTH flow", () => { + it("flips authedPubkey and answers OK true on a valid AUTH", async () => { + const { core } = makeCore(); + const conn = makeConn(); + const ev = signLoginEvent(conn.challenge); + const out = await send(core, conn, ["AUTH", ev]); + expect(out.frames.map(parse)).toEqual([["OK", ev.id, true, ""]]); + expect(out.updatedConn).toMatchObject({ + authedPubkey: ALICE_PK, + allowedUntil: 0, + challenge: conn.challenge, + }); + expect(out.close).toBeUndefined(); + }); + + it("rejects a wrong-challenge AUTH with OK false and no state change", async () => { + const { core } = makeCore(); + const conn = makeConn(); + const ev = signLoginEvent("cd".repeat(32)); + const out = await send(core, conn, ["AUTH", ev]); + const frame = parse(out.frames[0] as string); + expect(frame[0]).toBe("OK"); + expect(frame[1]).toBe(ev.id); + expect(frame[2]).toBe(false); + expect(frame[3]).toMatch(/^invalid: /); + expect(out.updatedConn).toBeUndefined(); + }); + + it("closes 1008 after MAX_AUTH_ATTEMPTS failed attempts", async () => { + const { core } = makeCore(); + const conn = makeConn(); + const bad = signLoginEvent("cd".repeat(32)); + for (let i = 0; i < MAX_AUTH_ATTEMPTS; i++) { + const out = await send(core, conn, ["AUTH", bad]); + expect(out.close).toBeUndefined(); + } + const out = await send(core, conn, ["AUTH", signLoginEvent(conn.challenge)]); + expect(out.close).toEqual({ code: 1008, reason: "too many auth attempts" }); + // The over-limit attempt must not have authenticated the connection. + expect(out.updatedConn).toBeUndefined(); + }); + + it("re-auth under a different key resets the cached allowlist verdict", async () => { + const { core } = makeCore(); + let conn = makeConn(); + conn = await authed(core, conn, ALICE_SK); + // Simulate a warm allowlist cache, then re-auth as bob. + conn = { ...conn, allowedUntil: NOW() + ALLOWLIST_CACHE_SECONDS }; + const out = await send(core, conn, ["AUTH", signLoginEvent(conn.challenge, { sk: BOB_SK })]); + expect(out.updatedConn).toMatchObject({ authedPubkey: BOB_PK, allowedUntil: 0 }); + }); +}); + +// --- EVENT rejection ladder --------------------------------------------------------- + +describe("RelayCore — EVENT rejection ladder", () => { + it("rejects disallowed kinds before anything else (even unauthenticated)", async () => { + const { core } = makeCore(); + const conn = makeConn(); // unauthenticated on purpose + const ev = signLoginEvent(conn.challenge); // kind 22242 — not in the allowlist + const out = await send(core, conn, ["EVENT", ev]); + expect(out.frames.map(parse)).toEqual([ + ["OK", ev.id, false, "restricted: only kinds 30023, 5, and 0 are accepted"], + ]); + }); + + it("answers auth-required + a fresh AUTH frame when unauthenticated", async () => { + const { core } = makeCore(); + const conn = makeConn(); + const ev = post(); + const out = await send(core, conn, ["EVENT", ev]); + expect(out.frames.map(parse)).toEqual([ + ["OK", ev.id, false, "auth-required: authenticate with your nbread key first"], + ["AUTH", conn.challenge], + ]); + }); + + it("checks auth BEFORE the allowlist: an unclaimed key still gets auth-required", async () => { + const { core } = makeCore(); + const conn = makeConn(); + const ev = post({ sk: BOB_SK }); // bob has no user row at all + const out = await send(core, conn, ["EVENT", ev]); + expect((parse(out.frames[0] as string) as unknown[])[3]).toMatch(/^auth-required: /); + }); + + it("rejects an event whose pubkey is not the authenticated key", async () => { + const { core } = makeCore(); + const aliceConn = await authed(core, makeConn(), ALICE_SK); + const ev = post({ sk: BOB_SK }); + const out = await send(core, aliceConn, ["EVENT", ev]); + expect(out.frames.map(parse)).toEqual([ + ["OK", ev.id, false, "restricted: event pubkey does not match the authenticated key"], + ]); + }); + + it("rejects an authed key with no claimed handle", async () => { + const { core } = makeCore(); + const conn = await authed(core, makeConn(), BOB_SK); // no users row + const ev = post({ sk: BOB_SK }); + const out = await send(core, conn, ["EVENT", ev]); + expect(out.frames.map(parse)).toEqual([ + ["OK", ev.id, false, "restricted: writes are limited to claimed nbread.lol handles"], + ]); + }); + + it("rejects a blocked user", async () => { + await seedBlockedMallory(); + const { core } = makeCore(); + const conn = await authed(core, makeConn(), MALLORY_SK); + const ev = post({ sk: MALLORY_SK }); + const out = await send(core, conn, ["EVENT", ev]); + expect((parse(out.frames[0] as string) as unknown[])[3]).toMatch(/^restricted: writes/); + }); + + it("checks the allowlist BEFORE rate limits: exhausted window still reads restricted", async () => { + await seedLimit(`relay:ev:pk:${BOB_PK}`, EVENT_PK_MAX, EVENT_PK_WINDOW_SECONDS); + const { core } = makeCore(); + const conn = await authed(core, makeConn(), BOB_SK); // unclaimed + const out = await send(core, conn, ["EVENT", post({ sk: BOB_SK })]); + expect((parse(out.frames[0] as string) as unknown[])[3]).toMatch(/^restricted: writes/); + }); + + it("rate-limits per pubkey (30/5min) without storing the event", async () => { + await seedLimit(`relay:ev:pk:${ALICE_PK}`, EVENT_PK_MAX, EVENT_PK_WINDOW_SECONDS); + const { core } = makeCore(); + const conn = await authed(core, makeConn(), ALICE_SK); + const ev = post(); + const out = await send(core, conn, ["EVENT", ev]); + expect(out.frames.map(parse)).toEqual([["OK", ev.id, false, "rate-limited: slow down"]]); + const row = await env.DB.prepare("SELECT 1 FROM events WHERE id = ?").bind(ev.id).first(); + expect(row).toBeNull(); + }); + + it("rate-limits on the global daily store budget", async () => { + await seedLimit("relay:global:store", GLOBAL_STORE_MAX, GLOBAL_STORE_WINDOW_SECONDS); + const { core } = makeCore(); + const conn = await authed(core, makeConn(), ALICE_SK); + const out = await send(core, conn, ["EVENT", post()]); + expect((parse(out.frames[0] as string) as unknown[])[3]).toBe("rate-limited: slow down"); + }); + + it("accepts, stores, and caches the allowlist verdict on success", async () => { + const { core } = makeCore(); + const conn = await authed(core, makeConn(), ALICE_SK); + const ev = post(); + const before = NOW(); + const out = await send(core, conn, ["EVENT", ev]); + expect(out.frames.map(parse)).toEqual([["OK", ev.id, true, ""]]); + expect(out.updatedConn?.allowedUntil).toBeGreaterThanOrEqual( + before + ALLOWLIST_CACHE_SECONDS, + ); + const row = await env.DB.prepare("SELECT raw FROM events WHERE id = ?") + .bind(ev.id) + .first<{ raw: string }>(); + expect(row).not.toBeNull(); + }); + + it("honors the 5-minute allowlist cache (no D1 re-check while warm)", async () => { + const { core } = makeCore(); + let conn = await authed(core, makeConn(), ALICE_SK); + conn = { ...conn, allowedUntil: NOW() + ALLOWLIST_CACHE_SECONDS }; + await resetUsers(); // alice's claim disappears — the warm cache must carry + const ev = post(); + const out = await send(core, conn, ["EVENT", ev]); + expect(out.frames.map(parse)).toEqual([["OK", ev.id, true, ""]]); + }); + + it("re-checks once the cache expires", async () => { + const { core } = makeCore(); + let conn = await authed(core, makeConn(), ALICE_SK); + conn = { ...conn, allowedUntil: 0 }; + await resetUsers(); + const out = await send(core, conn, ["EVENT", post()]); + expect((parse(out.frames[0] as string) as unknown[])[3]).toMatch(/^restricted: writes/); + }); + + it("answers duplicate for a stale replaceable version", async () => { + const { core } = makeCore(); + const conn = await authed(core, makeConn(), ALICE_SK); + const t = NOW(); + const newer = post({ created_at: t }); + const older = post({ created_at: t - 100, content: "old body" }); + expect((parse((await send(core, conn, ["EVENT", newer])).frames[0] as string) as unknown[])[2]).toBe(true); + const out = await send(core, conn, ["EVENT", older]); + expect(out.frames.map(parse)).toEqual([ + ["OK", older.id, false, "duplicate: a newer version of this replaceable event is already stored"], + ]); + }); + + it("answers invalid when id/signature verification fails", async () => { + const { core } = makeCore(); + const conn = await authed(core, makeConn(), ALICE_SK); + const forged = { ...post(), content: "tampered after signing" }; + const out = await send(core, conn, ["EVENT", forged]); + expect(out.frames.map(parse)).toEqual([ + ["OK", forged.id, false, "invalid: id or signature verification failed"], + ]); + }); +}); + +// --- REQ / CLOSE bookkeeping --------------------------------------------------------- + +describe("RelayCore — REQ / CLOSE subscription bookkeeping", () => { + it("CLOSEDs a malformed filter and stores nothing", async () => { + const { core, store } = makeCore(); + const conn = makeConn(); + const out = await send(core, conn, ["REQ", "s1", { ids: [] }]); + const frame = parse(out.frames[0] as string); + expect(frame[0]).toBe("CLOSED"); + expect(frame[1]).toBe("s1"); + expect(frame[2]).toMatch(/^invalid: /); + expect(store.count(conn.connId)).toBe(0); + }); + + it("EOSEs an empty result and persists the SANITIZED filters", async () => { + const { core, store } = makeCore(); + const conn = makeConn(); + const out = await send(core, conn, ["REQ", "s1", { kinds: [30023], junk: "ignored" }]); + expect(out.frames.map(parse)).toEqual([["EOSE", "s1"]]); + const stored = JSON.parse(store.get(conn.connId, "s1") as string) as unknown[]; + expect(stored).toEqual([{ kinds: [30023], limit: 100 }]); + }); + + it("serves stored events newest-first with the verbatim stored raw, then EOSE", async () => { + const t = NOW(); + const ev1 = post({ d: "a", created_at: t - 10 }); + const ev2 = post({ d: "b", created_at: t }); + await mirrorEvent(env, ev1); + await mirrorEvent(env, ev2); + const { core } = makeCore(); + const conn = makeConn(); + const out = await send(core, conn, ["REQ", "s1", { kinds: [30023] }]); + const frames = out.frames.map(parse); + expect(frames).toHaveLength(3); + expect(frames[0]?.slice(0, 2)).toEqual(["EVENT", "s1"]); + expect((frames[0]?.[2] as NostrEvent).id).toBe(ev2.id); + expect((frames[1]?.[2] as NostrEvent).id).toBe(ev1.id); + expect(frames[2]).toEqual(["EOSE", "s1"]); + // Verbatim raw: the frame embeds exactly the D1-stored JSON text. + const row = await env.DB.prepare("SELECT raw FROM events WHERE id = ?") + .bind(ev2.id) + .first<{ raw: string }>(); + expect(out.frames[0]).toBe(`["EVENT","s1",${row?.raw}]`); + }); + + it("REPLACES a subscription reusing the same subId (NIP-01)", async () => { + const { core, store } = makeCore(); + const conn = makeConn(); + await send(core, conn, ["REQ", "s1", { kinds: [30023] }]); + await send(core, conn, ["REQ", "s1", { kinds: [0] }]); + expect(store.count(conn.connId)).toBe(1); + const stored = JSON.parse(store.get(conn.connId, "s1") as string) as unknown[]; + expect(stored).toEqual([{ kinds: [0], limit: 100 }]); + }); + + it("caps open subscriptions at 8 per connection", async () => { + const { core, store } = makeCore(); + const conn = makeConn(); + for (let i = 0; i < 8; i++) { + const out = await send(core, conn, ["REQ", `s${i}`, { kinds: [30023] }]); + expect(parse(out.frames.at(-1) as string)[0]).toBe("EOSE"); + } + const out = await send(core, conn, ["REQ", "s8", { kinds: [30023] }]); + expect(out.frames.map(parse)).toEqual([ + ["CLOSED", "s8", "restricted: too many subscriptions"], + ]); + expect(store.count(conn.connId)).toBe(8); + // …but an existing subId can still be replaced at the cap. + const replace = await send(core, conn, ["REQ", "s0", { kinds: [5] }]); + expect(parse(replace.frames.at(-1) as string)[0]).toBe("EOSE"); + }); + + it("CLOSE deletes the subscription silently; unknown subIds are a no-op", async () => { + const { core, store } = makeCore(); + const conn = makeConn(); + await send(core, conn, ["REQ", "s1", { kinds: [30023] }]); + const out = await send(core, conn, ["CLOSE", "s1"]); + expect(out.frames).toEqual([]); + expect(store.count(conn.connId)).toBe(0); + const noop = await send(core, conn, ["CLOSE", "never-existed"]); + expect(noop.frames).toEqual([]); + }); + + it("dropConn wipes a connection's subscriptions", async () => { + const { core, store } = makeCore(); + const conn = makeConn(); + await send(core, conn, ["REQ", "s1", { kinds: [30023] }]); + core.dropConn(conn.connId); + expect(store.count(conn.connId)).toBe(0); + }); +}); + +// --- Live fan-out --------------------------------------------------------------------- + +describe("RelayCore — live fan-out on stored EVENTs", () => { + it("fans out to matching subs (sender included), skips non-matching", async () => { + const { core } = makeCore(); + const alice = await authed(core, makeConn(), ALICE_SK); + const reader = makeConn(); + const other = makeConn(); + await send(core, reader, ["REQ", "watch", { kinds: [30023], authors: [ALICE_PK] }]); + await send(core, other, ["REQ", "misses", { kinds: [30023], authors: [BOB_PK] }]); + await send(core, alice, ["REQ", "own", { kinds: [30023] }]); + + const ev = post(); + const out = await send(core, alice, ["EVENT", ev]); + expect((parse(out.frames[0] as string) as unknown[])[2]).toBe(true); + + const targets = out.fanout.map((f) => { + const frame = parse(f.frame); + return { connId: f.connId, subId: frame[1], id: (frame[2] as NostrEvent).id }; + }); + expect(targets).toEqual( + expect.arrayContaining([ + { connId: reader.connId, subId: "watch", id: ev.id }, + { connId: alice.connId, subId: "own", id: ev.id }, + ]), + ); + expect(targets).toHaveLength(2); + }); + + it("matches generic tag filters (#t) on fan-out", async () => { + const { core } = makeCore(); + const alice = await authed(core, makeConn(), ALICE_SK); + const reader = makeConn(); + await send(core, reader, ["REQ", "tags", { "#t": ["nostr"] }]); + await send(core, reader, ["REQ", "othertag", { "#t": ["bitcoin"] }]); + + const ev = signPostEvent({ d: "t", title: "T", content: "x", created_at: NOW() }); + ev.tags.push(["t", "nostr"]); + // re-sign with the mutated tags + const { finalizeEvent } = await import("nostr-tools/pure"); + const { hexToBytes } = await import("@noble/hashes/utils.js"); + const signed = finalizeEvent( + { kind: 30023, created_at: ev.created_at, tags: ev.tags, content: ev.content }, + hexToBytes(ALICE_SK), + ) as NostrEvent; + + const out = await send(core, alice, ["EVENT", signed]); + expect(out.fanout.map((f) => parse(f.frame)[1])).toEqual(["tags"]); + }); + + it("fans out kind-5 deletes too", async () => { + const { core } = makeCore(); + const alice = await authed(core, makeConn(), ALICE_SK); + const reader = makeConn(); + await send(core, reader, ["REQ", "dels", { kinds: [5] }]); + const del = signDeleteEvent({ address: `30023:${ALICE_PK}:gone`, created_at: NOW() }); + const out = await send(core, alice, ["EVENT", del]); + expect((parse(out.frames[0] as string) as unknown[])[2]).toBe(true); + expect(out.fanout.map((f) => parse(f.frame)[1])).toEqual(["dels"]); + }); + + it("does NOT fan out rejected events", async () => { + const { core } = makeCore(); + const reader = makeConn(); + await send(core, reader, ["REQ", "watch", { kinds: [30023] }]); + const unauthedConn = makeConn(); + const out = await send(core, unauthedConn, ["EVENT", post()]); + expect(out.fanout).toEqual([]); + }); +}); + +// --- Frame hygiene + message rate -------------------------------------------------------- + +describe("RelayCore — frame hygiene and message rate", () => { + it("NOTICEs junk frames", async () => { + const { core } = makeCore(); + const out = await core.handleMessage(makeConn(), "not json at all"); + const frame = parse(out.frames[0] as string); + expect(frame[0]).toBe("NOTICE"); + expect(out.close).toBeUndefined(); + }); + + it("NOTICEs oversized frames without parsing them", async () => { + const { core } = makeCore(); + const out = await core.handleMessage(makeConn(), "x".repeat(1_048_577)); + const frame = parse(out.frames[0] as string); + expect(frame[0]).toBe("NOTICE"); + expect(frame[1]).toMatch(/too large/); + }); + + it("answers OK false (not NOTICE) for a broken EVENT with a plausible id", async () => { + const { core } = makeCore(); + const id = "cd".repeat(32); + const out = await send(core, makeConn(), ["EVENT", { id, kind: "nope" }]); + const frame = parse(out.frames[0] as string); + expect(frame[0]).toBe("OK"); + expect(frame[1]).toBe(id); + expect(frame[2]).toBe(false); + }); + + it("closes 1008 when a connection exceeds 120 messages/minute", async () => { + let t = 1_700_000_000; + const { core } = makeCore(new MemStore(), () => t); + const conn = makeConn(); + for (let i = 0; i < MAX_MESSAGES_PER_MINUTE; i++) { + const out = await send(core, conn, ["CLOSE", "s1"]); + expect(out.close).toBeUndefined(); + } + const out = await send(core, conn, ["CLOSE", "s1"]); + expect(parse(out.frames[0] as string)).toEqual([ + "NOTICE", + "rate-limited: too many messages", + ]); + expect(out.close).toEqual({ code: 1008, reason: "message rate exceeded" }); + // A fresh window admits the connection again. + t += 60; + const later = await send(core, conn, ["CLOSE", "s1"]); + expect(later.close).toBeUndefined(); + }); + + it("message-rate windows are per connection", async () => { + let t = 1_700_000_000; + const { core } = makeCore(new MemStore(), () => t); + const a = makeConn(); + const b = makeConn(); + for (let i = 0; i < MAX_MESSAGES_PER_MINUTE; i++) { + await send(core, a, ["CLOSE", "s1"]); + } + expect((await send(core, a, ["CLOSE", "s1"])).close).toBeDefined(); + expect((await send(core, b, ["CLOSE", "s1"])).close).toBeUndefined(); + }); +}); diff --git a/test/unit/relay-filters.spec.ts b/test/unit/relay-filters.spec.ts new file mode 100644 index 0000000..f5dca84 --- /dev/null +++ b/test/unit/relay-filters.spec.ts @@ -0,0 +1,327 @@ +// PR2 packet 1: REQ filter sanitation cap matrix + in-memory event matching +// (live fan-out semantics). Pure — no fixtures, no I/O; matching needs no +// signatures, so events are plain structural objects. +import { describe, expect, it } from "vitest"; +import type { NostrEvent } from "../../src/nostr/event"; +import { + DEFAULT_LIMIT, + matchesAnyFilter, + matchEvent, + MAX_FILTER_AUTHORS, + MAX_FILTER_IDS, + MAX_FILTER_KINDS, + MAX_LIMIT, + MAX_REQ_FILTERS, + MAX_TAG_FILTER_VALUES, + sanitizeFilters, +} from "../../src/relay/filters"; +import type { SanitizedFilter } from "../../src/relay/types"; + +const ID_1 = "1".repeat(64); +const PK_A = "a".repeat(64); +const PK_B = "b".repeat(64); + +/** n distinct 64-hex strings. */ +function hexes(n: number): string[] { + return Array.from({ length: n }, (_, i) => + i.toString(16).padStart(64, "0"), + ); +} + +/** Sanitize and assert success. */ +function ok(raw: unknown): SanitizedFilter[] { + const res = sanitizeFilters(raw); + if (!Array.isArray(res)) { + throw new Error(`expected filters, got error: ${res.error}`); + } + return res; +} + +/** Sanitize and assert failure, returning the error string. */ +function err(raw: unknown): string { + const res = sanitizeFilters(raw); + if (Array.isArray(res)) throw new Error("expected an error"); + expect(res.error.length).toBeGreaterThan(0); + return res.error; +} + +function ev(over: Partial = {}): NostrEvent { + return { + id: ID_1, + pubkey: PK_A, + kind: 30023, + created_at: 1000, + tags: [ + ["d", "post-1"], + ["t", "nostr"], + ], + content: "", + sig: "f".repeat(128), + ...over, + }; +} + +describe("sanitizeFilters — accepted shapes", () => { + it("empty filter → limit default only", () => { + expect(ok([{}])).toEqual([{ limit: DEFAULT_LIMIT }]); + }); + + it("full filter maps every supported key", () => { + const [f] = ok([ + { + ids: [ID_1], + authors: [PK_A, PK_B], + kinds: [30023, 5, 0], + since: 100, + until: 200, + limit: 25, + "#d": ["post-1"], + "#t": ["nostr", "blog"], + "#e": [ID_1], + }, + ]); + expect(f).toEqual({ + ids: [ID_1], + authors: [PK_A, PK_B], + kinds: [30023, 5, 0], + since: 100, + until: 200, + limit: 25, + dTags: ["post-1"], + tagFilters: { t: ["nostr", "blog"], e: [ID_1] }, + }); + }); + + it("accepts up to MAX_REQ_FILTERS filters", () => { + expect(ok(Array.from({ length: MAX_REQ_FILTERS }, () => ({})))).toHaveLength( + MAX_REQ_FILTERS, + ); + }); + + it("accepts exactly-at-cap list sizes", () => { + const [f] = ok([ + { + ids: hexes(MAX_FILTER_IDS), + authors: hexes(MAX_FILTER_AUTHORS), + kinds: Array.from({ length: MAX_FILTER_KINDS }, (_, i) => i), + "#t": Array.from({ length: MAX_TAG_FILTER_VALUES }, (_, i) => `t${i}`), + }, + ]); + expect(f?.ids).toHaveLength(MAX_FILTER_IDS); + expect(f?.tagFilters?.t).toHaveLength(MAX_TAG_FILTER_VALUES); + }); + + it("clamps limit into [1, MAX_LIMIT] and defaults to DEFAULT_LIMIT", () => { + expect(ok([{ limit: 0 }])[0]?.limit).toBe(1); + expect(ok([{ limit: -5 }])[0]?.limit).toBe(1); + expect(ok([{ limit: 9999 }])[0]?.limit).toBe(MAX_LIMIT); + expect(ok([{ limit: MAX_LIMIT }])[0]?.limit).toBe(MAX_LIMIT); + expect(ok([{ limit: 7 }])[0]?.limit).toBe(7); + expect(ok([{}])[0]?.limit).toBe(DEFAULT_LIMIT); + }); + + it("ignores unknown non-tag keys and non-single-letter #keys (NIP-01)", () => { + const [f] = ok([ + { search: "hello", foo: 1, "#dd": ["x"], "#": ["y"], "#1": ["z"] }, + ]); + expect(f).toEqual({ limit: DEFAULT_LIMIT }); + }); +}); + +describe("sanitizeFilters — rejection matrix", () => { + it("rejects non-array input and an empty filter list", () => { + err({}); + err("nope"); + err(null); + expect(err([])).toMatch(/at least one/); + }); + + it("rejects more than MAX_REQ_FILTERS filters", () => { + expect( + err(Array.from({ length: MAX_REQ_FILTERS + 1 }, () => ({}))), + ).toMatch(/too many filters/); + }); + + it("rejects non-object filters", () => { + err([null]); + err([[]]); + err(["x"]); + err([42]); + // one bad filter poisons the whole REQ + err([{}, null]); + }); + + it("rejects oversized id/author/kind/tag lists", () => { + err([{ ids: hexes(MAX_FILTER_IDS + 1) }]); + err([{ authors: hexes(MAX_FILTER_AUTHORS + 1) }]); + err([{ kinds: Array.from({ length: MAX_FILTER_KINDS + 1 }, (_, i) => i) }]); + err([ + { + "#d": Array.from({ length: MAX_TAG_FILTER_VALUES + 1 }, (_, i) => `${i}`), + }, + ]); + err([ + { + "#t": Array.from({ length: MAX_TAG_FILTER_VALUES + 1 }, (_, i) => `${i}`), + }, + ]); + }); + + it("rejects malformed hex in ids/authors", () => { + err([{ ids: ["zz".repeat(32)] }]); // non-hex + err([{ ids: ["a".repeat(63)] }]); // short + err([{ ids: ["A".repeat(64)] }]); // uppercase is non-canonical + err([{ authors: [PK_A.slice(0, 8)] }]); // NIP-01 prefixes unsupported + err([{ authors: [42] }]); + err([{ ids: "not-a-list" }]); + }); + + it("rejects empty lists (ambiguous between engines — fail closed)", () => { + err([{ ids: [] }]); + err([{ authors: [] }]); + err([{ kinds: [] }]); + err([{ "#d": [] }]); + err([{ "#t": [] }]); + }); + + it("rejects malformed kinds", () => { + err([{ kinds: [-1] }]); + err([{ kinds: [65536] }]); + err([{ kinds: [1.5] }]); + err([{ kinds: ["1"] }]); + err([{ kinds: 30023 }]); + }); + + it("rejects malformed since/until", () => { + err([{ since: "100" }]); + err([{ since: -1 }]); + err([{ since: 1.5 }]); + err([{ until: null }]); + err([{ until: Number.NaN }]); + }); + + it("rejects malformed limit (clamp only applies to integers)", () => { + err([{ limit: "5" }]); + err([{ limit: 1.5 }]); + err([{ limit: null }]); + }); + + it("rejects non-string and oversized tag filter values", () => { + err([{ "#t": [42] }]); + err([{ "#d": [null] }]); + err([{ "#t": ["x".repeat(8193)] }]); // > MAX_TAG_ITEM_LENGTH can never match + err([{ "#t": "nostr" }]); + }); +}); + +describe("matchEvent", () => { + const only = (f: Partial): SanitizedFilter => ({ + limit: DEFAULT_LIMIT, + ...f, + }); + + it("empty filter matches everything", () => { + expect(matchEvent(only({}), ev())).toBe(true); + expect(matchEvent(only({}), ev({ kind: 5, tags: [] }))).toBe(true); + }); + + it("ids", () => { + expect(matchEvent(only({ ids: [ID_1] }), ev())).toBe(true); + expect(matchEvent(only({ ids: ["2".repeat(64)] }), ev())).toBe(false); + }); + + it("authors", () => { + expect(matchEvent(only({ authors: [PK_A, PK_B] }), ev())).toBe(true); + expect(matchEvent(only({ authors: [PK_B] }), ev())).toBe(false); + }); + + it("kinds", () => { + expect(matchEvent(only({ kinds: [30023, 5] }), ev())).toBe(true); + expect(matchEvent(only({ kinds: [0] }), ev())).toBe(false); + }); + + it("since/until are inclusive bounds on created_at", () => { + expect(matchEvent(only({ since: 1000 }), ev())).toBe(true); + expect(matchEvent(only({ since: 1001 }), ev())).toBe(false); + expect(matchEvent(only({ until: 1000 }), ev())).toBe(true); + expect(matchEvent(only({ until: 999 }), ev())).toBe(false); + expect(matchEvent(only({ since: 900, until: 1100 }), ev())).toBe(true); + }); + + it("#d matches the SLOTTED d value (mirrors the SQL d_tag column)", () => { + expect(matchEvent(only({ dTags: ["post-1"] }), ev())).toBe(true); + expect(matchEvent(only({ dTags: ["other"] }), ev())).toBe(false); + // A 30023 with two d tags slots under its FIRST d tag only — matching a + // later d value must miss, exactly as `d_tag IN (...)` would. + const twoD = ev({ + tags: [ + ["d", "x"], + ["d", "y"], + ], + }); + expect(matchEvent(only({ dTags: ["x"] }), twoD)).toBe(true); + expect(matchEvent(only({ dTags: ["y"] }), twoD)).toBe(false); + // Non-parameterized kinds slot under "" even with a stray d tag, so a #d + // filter for that stray value must miss while "" hits. + const strayD = ev({ kind: 5, tags: [["d", "foo"]] }); + expect(matchEvent(only({ dTags: ["foo"] }), strayD)).toBe(false); + expect(matchEvent(only({ dTags: [""] }), strayD)).toBe(true); + }); + + it("generic tag filters match any-tag, AND across letters", () => { + expect(matchEvent(only({ tagFilters: { t: ["nostr"] } }), ev())).toBe(true); + expect(matchEvent(only({ tagFilters: { t: ["bitcoin"] } }), ev())).toBe( + false, + ); + // both letters must hit + expect( + matchEvent(only({ tagFilters: { t: ["nostr"], e: [ID_1] } }), ev()), + ).toBe(false); + const withE = ev({ + tags: [ + ["t", "nostr"], + ["e", ID_1], + ], + }); + expect( + matchEvent(only({ tagFilters: { t: ["nostr"], e: [ID_1] } }), withE), + ).toBe(true); + }); + + it("valueless tags never match and never throw", () => { + const bare = ev({ tags: [["t"]] }); + expect(matchEvent(only({ tagFilters: { t: ["nostr"] } }), bare)).toBe( + false, + ); + }); + + it("all present conditions AND together within one filter", () => { + const f = only({ kinds: [30023], authors: [PK_B] }); + expect(matchEvent(f, ev())).toBe(false); // kind hits, author misses + expect(matchEvent(f, ev({ pubkey: PK_B }))).toBe(true); + }); + + it("sanitizeFilters output feeds matchEvent directly (round trip)", () => { + const filters = ok([{ kinds: [30023], "#t": ["nostr"], "#d": ["post-1"] }]); + const f = filters[0]; + expect(f).toBeDefined(); + if (f === undefined) return; + expect(matchEvent(f, ev())).toBe(true); + expect(matchEvent(f, ev({ kind: 1 }))).toBe(false); + }); +}); + +describe("matchesAnyFilter (REQ = OR of filters)", () => { + const miss: SanitizedFilter = { limit: 100, kinds: [0] }; + const hit: SanitizedFilter = { limit: 100, kinds: [30023] }; + + it("true when any filter matches", () => { + expect(matchesAnyFilter([miss, hit], ev())).toBe(true); + expect(matchesAnyFilter([hit, miss], ev())).toBe(true); + }); + + it("false when no filter matches, or the list is empty", () => { + expect(matchesAnyFilter([miss, miss], ev())).toBe(false); + expect(matchesAnyFilter([], ev())).toBe(false); + }); +}); diff --git a/test/unit/relay-protocol.spec.ts b/test/unit/relay-protocol.spec.ts new file mode 100644 index 0000000..e81f1d4 --- /dev/null +++ b/test/unit/relay-protocol.spec.ts @@ -0,0 +1,372 @@ +// PR2 packet 1: relay wire protocol — parseClientMessage matrix, frame +// builder exactness, NIP-42 validateAuthEvent, plus the pure url/nip11 +// helpers. Committed throwaway fixture keys only; env is a plain cast (the +// helpers only read MAIN_HOST / ENVIRONMENT / ADMIN_PUBKEY). +import { describe, expect, it } from "vitest"; +import { finalizeEvent } from "nostr-tools/pure"; +import { hexToBytes } from "@noble/hashes/utils.js"; +import keys from "../fixtures/keys.json"; +import type { NostrEvent } from "../../src/nostr/event"; +import { + authFrame, + closedFrame, + eoseFrame, + eventFrame, + MAX_MESSAGE_LENGTH, + MAX_SUBID_LENGTH, + noticeFrame, + okFrame, + parseClientMessage, + validateAuthEvent, +} from "../../src/relay/protocol"; +import { nip11Document } from "../../src/relay/nip11"; +import { isSelfRelayHost, selfRelayUrl } from "../../src/relay/url"; + +const fakeEnv = (over: Record = {}): Env => + ({ + MAIN_HOST: "nbread.lol", + ENVIRONMENT: "production", + ...over, + }) as unknown as Env; + +/** Fixed "now" so skew cases are deterministic. */ +const NOW = 1_700_000_000; +const CHALLENGE = "ab".repeat(32); + +/** Sign a kind-22242 AUTH event like test/helpers.ts signLoginEvent does. */ +function signAuth( + opts: { + sk?: string; + kind?: number; + created_at?: number; + /** `null` omits the tag entirely. */ + relay?: string | null; + /** `null` omits the tag entirely. */ + challenge?: string | null; + } = {}, +): NostrEvent { + const tags: string[][] = []; + if (opts.relay !== null) { + tags.push(["relay", opts.relay ?? "wss://nbread.lol"]); + } + if (opts.challenge !== null) { + tags.push(["challenge", opts.challenge ?? CHALLENGE]); + } + return finalizeEvent( + { + kind: opts.kind ?? 22242, + created_at: opts.created_at ?? NOW, + tags, + content: "", + }, + hexToBytes(opts.sk ?? keys.alice.sk), + ) as NostrEvent; +} + +const parse = (raw: string) => parseClientMessage(raw, MAX_MESSAGE_LENGTH); + +describe("parseClientMessage — valid frames", () => { + it("parses a valid EVENT frame", () => { + const ev = signAuth(); + const msg = parse(JSON.stringify(["EVENT", ev])); + expect(msg.type).toBe("event"); + if (msg.type !== "event") return; + expect(msg.event.id).toBe(ev.id); + expect(msg.event.pubkey).toBe(keys.alice.pk); + }); + + it("parses a valid REQ frame, filters returned raw and in order", () => { + const msg = parse( + JSON.stringify(["REQ", "sub1", { kinds: [30023] }, { ids: [] }]), + ); + expect(msg.type).toBe("req"); + if (msg.type !== "req") return; + expect(msg.subId).toBe("sub1"); + expect(msg.filters).toEqual([{ kinds: [30023] }, { ids: [] }]); + }); + + it("parses a valid CLOSE frame", () => { + const msg = parse(JSON.stringify(["CLOSE", "sub1"])); + expect(msg).toEqual({ type: "close", subId: "sub1" }); + }); + + it("parses a valid AUTH frame", () => { + const ev = signAuth(); + const msg = parse(JSON.stringify(["AUTH", ev])); + expect(msg.type).toBe("auth"); + if (msg.type !== "auth") return; + expect(msg.event.kind).toBe(22242); + }); + + it("accepts a subId of exactly MAX_SUBID_LENGTH chars", () => { + const subId = "s".repeat(MAX_SUBID_LENGTH); + const msg = parse(JSON.stringify(["CLOSE", subId])); + expect(msg.type).toBe("close"); + }); +}); + +describe("parseClientMessage — rejection matrix (never throws)", () => { + const invalidCases: [string, string][] = [ + ["junk text", "not json at all"], + ["empty string", ""], + ["JSON object", "{}"], + ["empty array", "[]"], + ["non-string verb", "[42]"], + ["JSON scalar", '"EVENT"'], + ["unknown verb", '["COUNT","sub1",{}]'], + ["EVENT missing payload", '["EVENT"]'], + ["EVENT extra element", '["EVENT",{},{}]'], + ["EVENT non-event payload", '["EVENT",{"hello":"world"}]'], + ["EVENT array payload", '["EVENT",[1,2,3]]'], + ["AUTH missing payload", '["AUTH"]'], + ["AUTH non-event payload", '["AUTH",{"kind":22242}]'], + ["REQ without filters", '["REQ","sub1"]'], + ["REQ non-string subId", '["REQ",42,{}]'], + ["REQ empty subId", '["REQ","",{}]'], + ["CLOSE missing subId", '["CLOSE"]'], + ["CLOSE extra element", '["CLOSE","sub1","x"]'], + ]; + for (const [label, raw] of invalidCases) { + it(`rejects ${label}`, () => { + const msg = parse(raw); + expect(msg.type).toBe("invalid"); + if (msg.type !== "invalid") return; + expect(msg.reason.length).toBeGreaterThan(0); + }); + } + + it("rejects an oversized frame BEFORE parsing", () => { + const msg = parseClientMessage("x".repeat(11), 10); + expect(msg.type).toBe("invalid"); + if (msg.type !== "invalid") return; + expect(msg.reason).toMatch(/too large/); + }); + + it("accepts a frame of exactly maxLen chars", () => { + const raw = JSON.stringify(["CLOSE", "sub1"]); + expect(parseClientMessage(raw, raw.length).type).toBe("close"); + }); + + it("survives deeply nested junk without throwing", () => { + const depth = 100_000; + const raw = "[".repeat(depth) + "]".repeat(depth); + // Either JSON.parse blows the stack (caught) or the verb is non-string; + // both collapse to invalid. + expect(parse(raw).type).toBe("invalid"); + }); + + it("rejects a subId over MAX_SUBID_LENGTH on REQ and CLOSE", () => { + const subId = "s".repeat(MAX_SUBID_LENGTH + 1); + expect(parse(JSON.stringify(["REQ", subId, {}])).type).toBe("invalid"); + expect(parse(JSON.stringify(["CLOSE", subId])).type).toBe("invalid"); + }); + + it("surfaces a plausible event id from a structurally invalid EVENT", () => { + const id = "ab".repeat(32); + const msg = parse(JSON.stringify(["EVENT", { id, kind: "nope" }])); + expect(msg.type).toBe("invalid"); + if (msg.type !== "invalid") return; + expect(msg.id).toBe(id); + }); + + it("omits id when the invalid EVENT payload has no plausible id", () => { + const msg = parse(JSON.stringify(["EVENT", { id: "short", kind: 1 }])); + expect(msg.type).toBe("invalid"); + if (msg.type !== "invalid") return; + expect(msg.id).toBeUndefined(); + }); +}); + +describe("frame builders — exact wire output", () => { + it("okFrame", () => { + expect(okFrame("id1", true, "")).toBe('["OK","id1",true,""]'); + expect(okFrame("id2", false, "auth-required: publish")).toBe( + '["OK","id2",false,"auth-required: publish"]', + ); + }); + + it("noticeFrame escapes embedded quotes", () => { + expect(noticeFrame('bad "frame"')).toBe('["NOTICE","bad \\"frame\\""]'); + }); + + it("closedFrame / eoseFrame / authFrame", () => { + expect(closedFrame("sub1", "error: temporarily unavailable")).toBe( + '["CLOSED","sub1","error: temporarily unavailable"]', + ); + expect(eoseFrame("sub1")).toBe('["EOSE","sub1"]'); + expect(authFrame(CHALLENGE)).toBe(`["AUTH","${CHALLENGE}"]`); + }); + + it("eventFrame embeds the raw JSON verbatim (no reserialize)", () => { + // Non-canonical spacing survives ONLY if the raw text is concatenated, + // never reparsed/re-serialized. + const raw = '{"id": "abc", "content": "café\\n"}'; + const frame = eventFrame("sub1", raw); + expect(frame).toBe('["EVENT","sub1",' + raw + "]"); + }); + + it("eventFrame escapes hostile subIds and stays parseable", () => { + const ev = signAuth(); + const raw = JSON.stringify(ev); + const subId = 'we"ird\\sub'; + const frame = eventFrame(subId, raw); + // Compare against JSON.parse(raw), not `ev`: nostr-tools tags finalized + // events with a Symbol(verified) property that toEqual would see. + expect(JSON.parse(frame)).toEqual(["EVENT", subId, JSON.parse(raw)]); + }); +}); + +describe("validateAuthEvent (NIP-42)", () => { + const env = fakeEnv(); + + it("accepts a correctly signed 22242 for this challenge and host", async () => { + const res = await validateAuthEvent(signAuth(), CHALLENGE, env, NOW); + expect(res).toEqual({ ok: true, pubkey: keys.alice.pk }); + }); + + it("accepts created_at at the exact skew boundary", async () => { + const ev = signAuth({ created_at: NOW - 600 }); + const res = await validateAuthEvent(ev, CHALLENGE, env, NOW); + expect(res.ok).toBe(true); + }); + + it("rejects the wrong challenge", async () => { + const ev = signAuth({ challenge: "cd".repeat(32) }); + const res = await validateAuthEvent(ev, CHALLENGE, env, NOW); + expect(res).toMatchObject({ ok: false }); + if (res.ok) return; + expect(res.reason).toMatch(/challenge/); + }); + + it("rejects a missing challenge tag", async () => { + const ev = signAuth({ challenge: null }); + expect((await validateAuthEvent(ev, CHALLENGE, env, NOW)).ok).toBe(false); + }); + + it("rejects an empty connection challenge even if the tags 'match'", async () => { + const ev = signAuth({ challenge: "" }); + expect((await validateAuthEvent(ev, "", env, NOW)).ok).toBe(false); + }); + + it("rejects a relay tag bound to another host", async () => { + const ev = signAuth({ relay: "wss://evil.example" }); + const res = await validateAuthEvent(ev, CHALLENGE, env, NOW); + expect(res).toMatchObject({ ok: false }); + if (res.ok) return; + expect(res.reason).toMatch(/relay/); + }); + + it("rejects a missing relay tag", async () => { + const ev = signAuth({ relay: null }); + expect((await validateAuthEvent(ev, CHALLENGE, env, NOW)).ok).toBe(false); + }); + + it("accepts loopback relay tags in development only (relayTagBindsHost reuse)", async () => { + const ev = signAuth({ relay: "ws://localhost:8787" }); + expect((await validateAuthEvent(ev, CHALLENGE, env, NOW)).ok).toBe(false); + const dev = fakeEnv({ ENVIRONMENT: "development" }); + expect((await validateAuthEvent(ev, CHALLENGE, dev, NOW)).ok).toBe(true); + }); + + it("rejects stale and future created_at beyond the skew window", async () => { + for (const created_at of [NOW - 601, NOW + 601]) { + const res = await validateAuthEvent( + signAuth({ created_at }), + CHALLENGE, + env, + NOW, + ); + expect(res).toMatchObject({ ok: false }); + if (res.ok) continue; + expect(res.reason).toMatch(/created_at/); + } + }); + + it("rejects the wrong event kind", async () => { + const ev = signAuth({ kind: 1 }); + const res = await validateAuthEvent(ev, CHALLENGE, env, NOW); + expect(res).toMatchObject({ ok: false }); + if (res.ok) return; + expect(res.reason).toMatch(/kind/); + }); + + it("rejects a tampered event (bad signature)", async () => { + const ev = { ...signAuth(), content: "tampered" }; + const res = await validateAuthEvent(ev, CHALLENGE, env, NOW); + expect(res).toMatchObject({ ok: false }); + if (res.ok) return; + expect(res.reason).toMatch(/signature/); + }); + + it("rejects a swapped pubkey (id no longer matches)", async () => { + const ev = { ...signAuth(), pubkey: keys.bob.pk }; + expect((await validateAuthEvent(ev, CHALLENGE, env, NOW)).ok).toBe(false); + }); + + it("never throws on structural garbage", async () => { + const res = await validateAuthEvent( + {} as NostrEvent, + CHALLENGE, + env, + NOW, + ); + expect(res.ok).toBe(false); + }); +}); + +describe("selfRelayUrl / isSelfRelayHost", () => { + it("derives wss:///relay, lowercased", () => { + expect(selfRelayUrl(fakeEnv())).toBe("wss://nbread.lol/relay"); + expect(selfRelayUrl(fakeEnv({ MAIN_HOST: "NBREAD.LOL" }))).toBe( + "wss://nbread.lol/relay", + ); + }); + + it("matches self by hostname regardless of scheme/path/case", () => { + const env = fakeEnv(); + expect(isSelfRelayHost("wss://nbread.lol/relay", env)).toBe(true); + expect(isSelfRelayHost("wss://NBREAD.LOL", env)).toBe(true); + expect(isSelfRelayHost("https://nbread.lol/relay", env)).toBe(true); + }); + + it("rejects other hosts, lookalikes, and junk URLs", () => { + const env = fakeEnv(); + expect(isSelfRelayHost("wss://relay.damus.io", env)).toBe(false); + expect(isSelfRelayHost("wss://alice.nbread.lol/relay", env)).toBe(false); + expect(isSelfRelayHost("wss://nbread.lol.evil.com", env)).toBe(false); + expect(isSelfRelayHost("not a url", env)).toBe(false); + expect(isSelfRelayHost("", env)).toBe(false); + }); +}); + +describe("nip11Document", () => { + it("advertises the plan's exact limitation caps and NIPs", () => { + const doc = nip11Document(fakeEnv()); + expect(doc.name).toBe("nbread relay"); + expect(doc.supported_nips).toEqual([1, 9, 11, 42]); + expect(doc.software).toBe("https://github.com/sovITxyz/nbread"); + expect(typeof doc.version).toBe("string"); + expect(doc.limitation).toEqual({ + auth_required: false, + restricted_writes: true, + max_message_length: 1048576, + max_subscriptions: 8, + max_limit: 500, + max_subid_length: 64, + max_event_tags: 2000, + max_content_length: 262144, + created_at_upper_limit: 900, + }); + }); + + it("includes pubkey IFF ADMIN_PUBKEY resolves", () => { + expect(nip11Document(fakeEnv())).not.toHaveProperty("pubkey"); + expect( + nip11Document(fakeEnv({ ADMIN_PUBKEY: keys.bob.pk })).pubkey, + ).toBe(keys.bob.pk); + // Malformed values fail closed (same posture as the admin surface). + expect( + nip11Document(fakeEnv({ ADMIN_PUBKEY: "not-a-key" })), + ).not.toHaveProperty("pubkey"); + }); +}); diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index c1ab9b3..81aadf8 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,19 +1,21 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 3d63818717005fdab6ba2d5dc2ddb507) +// Generated by Wrangler by running `wrangler types` (hash: 6d4e84722c4f9285413c2686ba2ad75a) // Runtime types generated with workerd@1.20260708.1 2026-01-01 interface __BaseEnv_Env { KV: KVNamespace; DB: D1Database; ASSETS: Fetcher; MAIN_HOST: "nbread.lol"; - TURNSTILE_SITE_KEY: "0x4AAAAAAD1JzzXDBykpiavq"; + TURNSTILE_SITE_KEY: "0x4AAAAAAD1_vRxPvgk56oO-"; RELAYS: "wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band,wss://relay.primal.net,wss://nostr.wine,wss://purplepag.es,wss://relay.snort.social,wss://nostr.mom"; ENVIRONMENT: string; TURNSTILE_SECRET_KEY: string; + RELAY_DO: DurableObjectNamespace; } declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./src/index"); + durableNamespaces: "RelayDO"; } interface Env extends __BaseEnv_Env {} } diff --git a/wrangler.jsonc b/wrangler.jsonc index 4af4563..347a744 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -29,6 +29,25 @@ "id": "d01e2e69d35549bb8ee153ac4007dbe2" } ], + // First-party relay (#5): single global DO (idFromName("relay:v1")) behind + // wss://nbread.lol/relay. WebSocket Hibernation API, no alarms/timers — + // hibernating objects accrue zero duration charges. + "durable_objects": { + "bindings": [ + { + "name": "RELAY_DO", + "class_name": "RelayDO" + } + ] + }, + "migrations": [ + { + "tag": "v1", + // Free plan REQUIRES the SQLite storage backend for Durable Objects + // (new_sqlite_classes, NOT new_classes). + "new_sqlite_classes": ["RelayDO"] + } + ], "vars": { "MAIN_HOST": "nbread.lol", // Fail closed: the committed config is production-safe. Local dev gets