diff --git a/dev/policy-relay.test.mjs b/dev/policy-relay.test.mjs new file mode 100644 index 00000000..bcce7cca --- /dev/null +++ b/dev/policy-relay.test.mjs @@ -0,0 +1,110 @@ +import { test, expect } from "vitest"; +import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools"; +import { createHash } from "node:crypto"; +import { policyRelay } from "../tests/browser/policy-relay.mjs"; + +// Positive controls for the modeled upstream used by the browser measurements. +// No actual app or deployed relay is involved in these enforcement checks. +test("modeled presence quota enforcement rejects overload across sockets and HTTP purposes", async () => { + const key = generateSecretKey(), + viewer = getPublicKey(key); + const report = { queries: [], unexpected: [] }; + let clock = 1000; + const sign = (kind, tags = [], content = "online") => + finalizeEvent( + { kind, tags, content, created_at: Math.floor(clock / 1000) }, + key, + ); + const relay = policyRelay({ + viewer, + report, + pending: [], + answer: () => [], + presenceSnapshot: () => {}, + acceptPublication: () => {}, + enforceQuotas: true, + now: () => clock, + }); + async function post(body, path = "/query", community = "primary") { + const url = `https://${community}.example${path}`; + const payload = JSON.stringify(body); + const auth = sign(27235, [ + ["u", url], + ["payload", createHash("sha256").update(payload).digest("hex")], + ]); + return relay.fetch(url, { + body: payload, + headers: { + Authorization: `Nostr ${Buffer.from(JSON.stringify(auth)).toString("base64")}`, + }, + signal: new AbortController().signal, + }); + } + for (let i = 0; i < 300; i++) { + const filters = + i % 2 + ? [{ kinds: [20001], authors: [viewer], limit: 1 }] + : [{ kinds: [9], "#h": ["alpha"], limit: 1 }]; + expect((await post(filters)).status).toBe(200); + } + const denied = await post(sign(9, [["h", "alpha"]], "over quota"), "/events"); + expect(denied.status).toBe(429); + expect((await denied.json()).error).toContain("rate-limited:"); + expect(report.quotaRefusals.at(-1).category).toBe("ApiCalls"); + expect( + (await post([{ kinds: [9], limit: 1 }], "/query", "secondary")).status, + ).toBe(200); + clock += 60000; + expect((await post([{ kinds: [9], limit: 1 }])).status).toBe(200); + + const sockets = Array.from({ length: 8 }, () => + relay.socket("wss://primary.example"), + ); + for (const socket of sockets) + socket.send( + JSON.stringify(["AUTH", sign(22242, [["challenge", "policy-fixture"]])]), + ); + const frames = []; + for (const socket of sockets) + socket.onmessage = ({ data }) => frames.push(JSON.parse(data)); + for (let i = 0; i < 51; i++) + sockets[i % 8].send( + JSON.stringify([ + "REQ", + `request-${i}`, + i % 2 + ? { kinds: [20001], authors: [viewer], limit: 0 } + : { kinds: [9], "#h": ["alpha"], limit: 1 }, + ]), + ); + await Promise.resolve(); + expect(relay.requests).toHaveLength(50); + expect(frames).toContainEqual([ + "CLOSED", + "request-50", + expect.stringContaining("rate-limited:"), + ]); + expect(report.quotaRefusals.at(-1).category).toBe("WsEvents"); + // Separate message quota: stay below each 50/5s WS burst but send 61 EVENTs/min. + clock += 5000; + for (let i = 0; i < 61; i++) { + if (i === 30) clock += 5000; + sockets[i % 8].send(JSON.stringify(["EVENT", sign(20001)])); + } + await Promise.resolve(); + expect(report.presencePublications).toHaveLength(60); + expect(report.quotaRefusals.at(-1).category).toBe("Messages"); + expect(frames).toContainEqual([ + "OK", + expect.any(String), + false, + expect.stringContaining("rate-limited:"), + ]); + expect(report.quotaRefusals.map(({ category }) => category)).toEqual([ + "ApiCalls", + "WsEvents", + "Messages", + ]); + expect(report.unexpected).toEqual([]); + for (const socket of sockets) socket.close(); +}); diff --git a/dev/relay-broker-api.test.mjs b/dev/relay-broker-api.test.mjs index f594723b..0c9a06b3 100644 --- a/dev/relay-broker-api.test.mjs +++ b/dev/relay-broker-api.test.mjs @@ -407,3 +407,120 @@ test("both real sign and publish routes admit direct replies but reject arbitrar await h.close(); } }); + +const presenceSnapshot = [ + { kinds: [20001], authors: ["a".repeat(64)], limit: 1 }, +]; +test("a held presence snapshot is additive to all six ordinary broker slots, not freed by browser abort", async () => { + let release; + const held = new Promise((resolve) => { + release = resolve; + }); + const h = await harness(async (call, count, event) => { + await held; + return success(call, count, event); + }); + const controller = new AbortController(); + const pending = []; + try { + const p = h.post("query", presenceSnapshot, controller.signal); + const stopped = expect(p).rejects.toThrow(); + await vi.waitFor(() => expect(h.calls).toHaveLength(1)); + // Six ordinary requests truly reach the held upstream, not merely the admission queue. + for (let i = 0; i < 6; i++) pending.push(h.post("query", filters)); + await vi.waitFor(() => expect(h.calls).toHaveLength(7), { timeout: 4000 }); + expect(h.calls[1].at - h.calls[0].at).toBeLessThan(200); + const seventh = await h.post("publish", h.event); + expect(seventh.status).toBe(429); + expect(await seventh.json()).toEqual({ + error: "Query concurrency limit", + sent: false, + }); + controller.abort(); + await stopped; + await delay(30); + // A different community still shares the process-wide additive snapshot cap. + const second = await fetch(`${h.base}/api/relay/secondary/query`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(presenceSnapshot), + }); + expect(second.status).toBe(429); + expect(h.calls).toHaveLength(7); + release(); + await Promise.all(pending.map(async (response) => (await response).text())); + const recovered = await h.post("query", filters); + expect(recovered.status).toBe(200); + await recovered.text(); + } finally { + release(); + controller.abort(); + await h.close(); + } +}, 10000); + +test("broker optional classification ignores spoofed priority and rejects mixed/extra-key capacity bypass", async () => { + let release; + const held = new Promise((resolve) => { + release = resolve; + }); + const h = await harness(async () => { + await held; + return Response.json([]); + }); + const pending = []; + try { + for (let i = 0; i < 6; i++) pending.push(h.post("query", filters)); + await vi.waitFor(() => expect(h.calls).toHaveLength(1)); + await delay(30); + for (const value of [ + filters, + [{ ...presenceSnapshot[0], kinds: [20001, 9] }], + [{ ...presenceSnapshot[0], kinds: [20001, 20001] }], + [{ ...presenceSnapshot[0], since: 0 }], + [ + { + ...presenceSnapshot[0], + authors: ["a".repeat(64), "a".repeat(64)], + limit: 2, + }, + ], + ]) { + const response = await h.post("query", value, undefined, "presence"); + expect(response.status).toBe(429); + expect(await response.json()).toEqual({ + error: "Query concurrency limit", + sent: false, + }); + } + pending.push(h.post("query", presenceSnapshot, undefined, "foreground")); + await vi.waitFor(() => + expect(h.calls.some((c) => c.body[0].kinds[0] === 20001)).toBe(true), + ); + release(); + await Promise.all(pending.map(async (response) => (await response).text())); + } finally { + release(); + await h.close(); + } +}, 10000); + +test("broker presence quota evidence shares API cooldown with ordinary publication, not local capacity", async () => { + const h = await harness(() => + Response.json( + { error: "rate-limited: quota exceeded; retry in 3s" }, + { status: 429 }, + ), + ); + try { + const p = await h.post("query", presenceSnapshot); + expect(p.status).toBe(429); + expect(await p.json()).toMatchObject({ quota: "api", retryAfterMs: 4000 }); + const f = await h.post("publish", h.event); + expect(f.status).toBe(429); + expect(await f.json()).toMatchObject({ paused: true, sent: false }); + expect(h.calls).toHaveLength(1); + } finally { + await h.close(); + } +}); diff --git a/dev/relay-broker-fixture.test.mjs b/dev/relay-broker-fixture.test.mjs new file mode 100644 index 00000000..044703b8 --- /dev/null +++ b/dev/relay-broker-fixture.test.mjs @@ -0,0 +1,504 @@ +import { test, expect, vi } from "vitest"; +import { connectBrokerTransport } from "../src/features/relay/transport.ts"; +import { EventEmitter } from "node:events"; +import { createServer } from "node:http"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { browserFixtures } from "../tests/browser/fixture.mjs"; +import { brokerEvidence } from "../tests/browser/broker-evidence.mjs"; + +async function until(check) { + for (let i = 0; i < 200; i++) { + if (check()) return; + await delay(10); + } + throw new Error("Broker fixture did not reach expected state"); +} + +// Execute the actual browser fixture's server, instrumentation, teardown and +// final assertions. Only the unused browser shell is inert; HTTP/broker/WS policy +// and the 503 are real. No browser engine, live identity, or frontend build needed. +async function fixture(use, overrides = {}) { + const directory = await mkdtemp(join(tmpdir(), "presence-fixture-check-")); + await writeFile(join(directory, "index.html"), ""); + const page = new EventEmitter(); + page.addInitScript = async () => {}; + page.close = async () => {}; + const options = Object.fromEntries( + Object.entries(browserFixtures) + .filter(([, value]) => Array.isArray(value)) + .map(([key, value]) => [key, value[0]]), + ); + try { + await browserFixtures.app( + { + ...options, + ...overrides, + page, + context: { route: async () => {}, routeWebSocket: async () => {} }, + browserName: "chromium", + browser: { version: () => "HTTP-only fixture wiring check" }, + productionBroker: true, + // HTTP-only assertions need distinct retained records, not 1,440 signed rows. + compactHistory: true, + compiledApp: { + durationMs: 0, + config: { + configFile: false, + envFile: false, + logLevel: "silent", + build: { outDir: directory }, + }, + }, + }, + (app) => use(app, page), + { + workerIndex: 0, + project: { use: {} }, + outputPath: (file) => join(directory, file), + attach: async () => {}, + }, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +async function publication(app, dispose) { + const { origin, relay, report } = app; + relay.holdEose("profiles"); + relay.holdEose("membership"); + const controller = new AbortController(); + const request = new AbortController(); + let pending; + try { + const headers = { Origin: origin, "Content-Type": "application/json" }; + const response = await fetch(`${origin}/api/relay/primary/stream`, { + method: "POST", + headers, + body: JSON.stringify({ channels: [] }), + signal: controller.signal, + }); + const streamId = response.headers.get("x-buzz-live-id"); + const socket = relay.sockets.at(-1); + await until(() => socket.authenticated); + const endpoint = `${origin}/api/relay/primary/stream-presence-publish`; + pending = fetch(endpoint, { + method: "POST", + headers, + body: JSON.stringify({ streamId, status: "online" }), + signal: AbortSignal.any([request.signal, AbortSignal.timeout(3000)]), + }); + // Observe rejection immediately, even if setup fails before the awaited receipt. + void pending.catch(() => {}); + await until(() => + report.brokerRequests.some((record) => record.streamId === streamId), + ); + // Allow the real middleware's body continuation to install the publication. + await new Promise((resolve) => setImmediate(resolve)); + if (dispose) controller.abort(); + else { + // Reset is an actual failure; later retirement must not classify it. + socket.onclose(); + } + const failed = await pending; + expect(failed.status).toBe(503); + expect(await failed.json()).toEqual({ + error: "Presence publication unconfirmed", + ...(dispose ? { code: "presence_owner_disposed" } : {}), + }); + return endpoint; + } finally { + controller.abort(); + request.abort(); + await pending?.catch(() => {}); + } +} + +const console503 = (page, url) => + page.emit("console", { + type: () => "error", + text: () => + "Failed to load resource: the server responded with a status of 503 (Service Unavailable)", + location: () => ({ url }), + }); + +test.each(["stream", "stream-presence-publish"])( + "%s failure is observed and closes its stream before fixture teardown", + async (route) => { + await fixture(async (app) => { + const realFetch = globalThis.fetch; + const failure = new Error("Fixture publication dispatch failed"); + let wire; + vi.stubGlobal("fetch", (url, init) => { + if (!String(url).endsWith(`/${route}`)) return realFetch(url, init); + if (route === "stream") + return realFetch(url, init).then(() => { + throw failure; + }); + // Dispatch through the real host so until() still waits for real evidence. + // Reject before that boundary to exercise immediate rejection ownership. + wire = realFetch(url, init).catch(() => {}); + return Promise.reject(failure); + }); + try { + await expect(publication(app, true)).rejects.toBe(failure); + await wire; + await until(() => + app.relay.sockets.every((socket) => socket.readyState === 3), + ); + } finally { + vi.unstubAllGlobals(); + } + }); + }, +); + +test("actual fixture accounts disposal 503 without relying on browser response or console", async () => { + await fixture(async (app) => { + await publication(app, true); + expect(app.report.presencePublicationResponses).toHaveLength(1); + const record = app.report.brokerRequests.find((item) => item.streamId); + await until(() => record.close); + expect(record.finish).toMatchObject({ + status: 503, + finished: true, + serverTiming: null, + }); + expect(record.close).toMatchObject({ status: 503, finished: true }); + expect(record.close.at).toBeGreaterThanOrEqual(record.finish.at); + }); +}); + +test("actual fixture fails an unclassified 503 even with no console event", async () => { + await expect( + fixture(async (app) => { + await publication(app, false); + }), + ).rejects.toThrow("Unclassified presence publication 503"); +}); + +test("same endpoint disposal console cannot hide a separate unclassified response", async () => { + await expect( + fixture(async (app, page) => { + console503(page, await publication(app, true)); + await publication(app, false); + }), + ).rejects.toThrow("Unclassified presence publication 503"); +}); + +test("one classified response permits one console diagnostic", async () => { + await fixture(async (app, page) => + console503(page, await publication(app, true)), + ); +}); + +test("classified response is not a blanket endpoint exemption", async () => { + await expect( + fixture(async (app, page) => { + const url = await publication(app, true); + console503(page, url); + console503(page, url); + }), + ).rejects.toThrow(); +}); + +test.each([ + undefined, + "not-json", + JSON.stringify({ error: "Presence publication unconfirmed" }), + JSON.stringify({ + error: "Presence publication unconfirmed", + code: "unknown", + }), + JSON.stringify({ + error: "Presence publication unconfirmed", + code: "presence_owner_disposed", + accepted: true, + }), +])("missing/malformed classification fails closed (%s)", (body) => { + const report = { brokerRequests: [] }; + const evidence = brokerEvidence(report, new Set()); + const req = new EventEmitter(); + req.url = "/api/relay/primary/stream-presence-publish"; + req.headers = { host: "127.0.0.1:1234" }; + const res = new EventEmitter(); + res.statusCode = 503; + res.end = () => {}; + evidence.middleware(req, res, () => {}); + req.emit("data", JSON.stringify({ streamId: "a".repeat(32) })); + req.emit("end"); + res.end(body); + expect(() => evidence.assertPublications()).toThrow( + "Unclassified presence publication 503", + ); +}); + +test("passive completion evidence preserves Server-Timing and distinguishes an unfinished close", async () => { + const report = { brokerRequests: [] }; + const evidence = brokerEvidence(report, new Set()); + const server = createServer((req, res) => + evidence.middleware(req, res, () => { + res.setHeader("Server-Timing", "admission;dur=12, upstream;dur=3"); + res.writeHead(200, { "Content-Type": "text/plain" }); + if (req.url.endsWith("/complete")) res.end("accepted"); + else res.write("pending"); + }), + ); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}/api/relay`; + try { + expect(await (await fetch(`${base}/complete`)).text()).toBe("accepted"); + const controller = new AbortController(); + await fetch(`${base}/pending`, { signal: controller.signal }); + controller.abort(); + await until(() => report.brokerRequests.every((record) => record.close)); + const [complete, pending] = report.brokerRequests; + expect(complete.finish).toMatchObject({ + status: 200, + finished: true, + serverTiming: "admission;dur=12, upstream;dur=3", + }); + expect(complete.close.finished).toBe(true); + expect(pending.finish).toBeUndefined(); + expect(pending.close).toMatchObject({ + status: 200, + finished: false, + serverTiming: "admission;dur=12, upstream;dur=3", + }); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + } +}); + +test("composer reconciliation returns only retained events from the queried community", async () => { + await fixture( + async (app) => { + const records = [...app.histories.values()]; + expect(records.map((rows) => rows.length)).toEqual([1, 1, 1, 1]); + expect(new Set(records.flat().map((event) => event.id)).size).toBe(4); + const retained = app.histories.get("primary/alpha")[0]; + const lookup = async (community, id) => { + const response = await fetch( + `${app.origin}/api/relay/${community}/query`, + { + method: "POST", + headers: { Origin: app.origin, "Content-Type": "application/json" }, + body: JSON.stringify([{ ids: [id], limit: 1 }]), + }, + ); + expect(response.status).toBe(200); + return response.json(); + }; + expect(await lookup("primary", retained.id)).toEqual([ + JSON.parse(JSON.stringify(retained)), + ]); + expect(await lookup("secondary", retained.id)).toEqual([]); + expect(await lookup("primary", "f".repeat(64))).toEqual([]); + }, + { composerPublication: true }, + ); +}); + +// No alternate adapter or clock injection: real HTTP -> broker -> policy relay. +// Keep demand queued for a full reference quota window; measure actual upstream +// starts across all eight callers, not independent per-socket budgets. +test("eight broker callers share combined API/REQ/EVENT budgets and correlated WS cooldown", async () => { + await fixture( + async ({ origin, report, relay }) => { + const realFetch = globalThis.fetch; + // Node has no browser Origin header. Supply only that browser boundary; + // request bodies, streaming, signing and upstream admission remain real. + vi.stubGlobal("fetch", (url, init) => + realFetch(url, { + ...init, + headers: { ...init?.headers, Origin: origin }, + }), + ); + const stop = new AbortController(); + const streams = []; + const states = new Map(); + const pending = []; + const failures = []; + let running = true; + try { + const transports = await Promise.all( + Array.from({ length: 8 }, () => connectBrokerTransport(origin)), + ); + expect(transports.every((t) => t.presence === true)).toBe(true); + for (const t of transports) { + const stream = t.subscribe({ + receive() {}, + established() {}, + state(snapshot) { + states.set(t, snapshot); + }, + denied() {}, + }); + streams.push(stream); + } + for (const stream of streams) { + // Enough real channel work to keep the ordinary shared setup lane busy + // throughout measurement, without reconnecting/replacing a socket. + stream.update(Array.from({ length: 40 }, (_, i) => `load-${i}`)); + } + await until( + () => + relay.sockets.filter((s) => s.authenticated && s.readyState === 1) + .length === 8, + ); + const start = performance.now(); + const loop = (operation) => + (async () => { + while (running) { + try { + await operation(); + } catch (error) { + if (running) throw error; + } + } + })().catch((error) => { + failures.push(error); + }); + // Four ordinary readers keep demand ready, below the broker's six-slot + // bound. Rotate across all eight transport objects on completion. + let next = 0; + for (let i = 0; i < 4; i++) + pending.push( + loop(() => + transports[next++ % 8].query( + [{ kinds: [0], limit: 1 }], + stop.signal, + ), + ), + ); + pending.push( + loop(() => + transports[next++ % 8].query( + [{ kinds: [20001], authors: ["a".repeat(64)], limit: 1 }], + stop.signal, + ), + ), + ); + // One outstanding publication keeps the shared optional lane saturated + // without manufacturing publication-deadline failures in the fixture. + const publishing = loop(async () => { + // Budget measurement is steady-state, not a cold-publication deadline + // test. Drive EOSE-established owners rather than AUTH-only owners + // whose foreground globals are still waiting behind shared traffic. + const ready = transports + .map((t, i) => ({ state: states.get(t), stream: streams[i] })) + .filter(({ state }) => { + const globals = + state?.routes.filter((route) => !route.channelId) ?? []; + return ( + globals.length === 2 && + globals.every((route) => route.status === "live") + ); + }); + if (!ready.length) { + await delay(20); + return; + } + await ready[next++ % ready.length].stream.presence.publish( + "online", + new AbortController().signal, + ); + }); + pending.push(publishing); + for (let second = 0; second < 60; second++) { + for (const stream of streams) + stream.presence.update([ + (second + 1).toString(16).padStart(64, "0"), + ]); + await delay(1000); + if (failures.length) { + throw new Error("Broker load driver failed", { + cause: { + errors: failures.map(String), + responses: report.presencePublicationResponses, + }, + }); + } + } + const end = start + 60000; + running = false; + stop.abort(); + await Promise.all(pending); + expect(failures).toEqual([]); + const during = (rows) => + rows.filter((row) => row.at >= start && row.at < end); + const ordinary = during(report.liveRequests).filter( + (r) => r.route !== "presence", + ); + const presence = during(report.liveRequests).filter( + (r) => r.route === "presence", + ); + const events = during(report.presencePublications); + const api = during(report.queries); + // Real scheduling can delay starts. Lower bounds prove this is a loaded + // run, not a vacuous "no quota rejection" pass. Exact admission-clock + // spacing is covered with fake time in live/http-admission tests; this + // fixture measures actual aggregate arrivals after signing/HTTP overhead. + expect(ordinary.length).toBeGreaterThanOrEqual(200); + expect(presence.length).toBeGreaterThanOrEqual(50); + expect(events.length).toBeGreaterThanOrEqual(11); + expect(api.length).toBeGreaterThanOrEqual(120); + expect( + api.filter((r) => r.filter.kinds?.[0] === 20001).length, + ).toBeGreaterThanOrEqual(11); + // The existing first-call-anchored relay counters charge combined callers + // before acceptance, including rejected work. Do not count only successes. + for (const [category, maximum] of [ + ["ApiCalls", 134], + ["WsEvents", 27], + ["Messages", 13], + ]) { + const charges = report.quotaCharges.filter( + (r) => r.category === category, + ); + expect(charges.length).toBeGreaterThan(0); + expect(Math.max(...charges.map((r) => r.count))).toBeLessThanOrEqual( + maximum, + ); + expect(charges.every((r) => r.accepted)).toBe(true); + } + expect(report.quotaRefusals).toEqual([]); + // A correlated presence CLOSED must stop every WS caller, while ordinary + // API reads remain independent. Recovery occurs only after the margin. + relay.failRoute( + "primary", + "presence", + "rate-limited: quota exceeded; retry in 3s", + ); + const before = report.quotaCharges.filter( + (r) => r.category === "WsEvents", + ).length; + for (const stream of streams) stream.presence.update(["b".repeat(64)]); + const recovery = streams[0].presence.publish( + "away", + new AbortController().signal, + ); + await transports[0].query([{ kinds: [0], limit: 1 }]); + await delay(3200); + expect( + report.quotaCharges.filter((r) => r.category === "WsEvents"), + ).toHaveLength(before); + await recovery; + expect( + report.quotaCharges.filter((r) => r.category === "WsEvents").length, + ).toBeGreaterThan(before); + } finally { + running = false; + stop.abort(); + for (const stream of streams) stream.dispose(); + await Promise.allSettled(pending); + vi.unstubAllGlobals(); + } + }, + { enforceQuotas: true }, + ); +}, 90000); diff --git a/dev/relay-broker-live.test.mjs b/dev/relay-broker-live.test.mjs index 68c311ea..caf2e2da 100644 --- a/dev/relay-broker-live.test.mjs +++ b/dev/relay-broker-live.test.mjs @@ -17,13 +17,58 @@ import { connectBrokerTransport } from "../src/features/relay/transport.ts"; async function harness( refuseAt = 0, reason = "rate-limited: quota exceeded; retry in 0s", + holdSetup = false, ) { const key = new Uint8Array(32); key[31] = 1; const requests = []; const sockets = []; + const frames = []; + const setupReplies = []; + const publications = []; + const lifecycle = []; + let onStreamClose; let handler; - const server = createServer((req, res) => handler?.(req, res)); + const server = createServer((req, res) => { + if (req.url?.endsWith("/stream")) { + let streamId; + const writeHead = res.writeHead; + res.writeHead = function (...args) { + // writeHead's direct headers are not retained by getHeader(). + streamId = args.at(-1)["X-Buzz-Live-ID"]; + return writeHead.apply(this, args); + }; + res.once("close", () => { + lifecycle.push({ event: "stream-close", streamId }); + onStreamClose?.(); + }); + } + if (req.url?.endsWith("/stream-presence-publish")) { + const publication = { closed: false, status: undefined }; + let body = ""; + req.on("data", (part) => { + body += part; + }); + req.once("end", () => { + publication.streamId = JSON.parse(body).streamId; + // Observe after the real middleware consumes the request body; do not + // substitute its publication capability or prepare its pending state. + setImmediate(() => publications.push(publication)); + }); + res.once("finish", () => { + publication.status = res.statusCode; + lifecycle.push({ + event: "publication-finish", + streamId: publication.streamId, + status: res.statusCode, + }); + }); + res.once("close", () => { + publication.closed = true; + }); + } + handler?.(req, res); + }); const plugin = relayBrokerPlugin({ relayUrl: fixtureRelayUrl, communityAliases: fixtureAliases, @@ -35,14 +80,16 @@ async function harness( readyState: 1, send(text) { const [kind, id, filter] = JSON.parse(text); + frames.push({ kind, id, filter, socket, at: performance.now() }); if (kind === "AUTH") queueMicrotask(() => this.receive(["OK", id.id, true])); if (kind !== "REQ") return; requests.push({ at: performance.now(), id, filter, socket }); const refused = requests.length === refuseAt; - queueMicrotask(() => - this.receive(refused ? ["CLOSED", id, reason] : ["EOSE", id]), - ); + const reply = () => + this.receive(refused ? ["CLOSED", id, reason] : ["EOSE", id]); + if (holdSetup) setupReplies.push(reply); + else queueMicrotask(reply); }, receive(frame) { if (this.readyState === 1) @@ -73,15 +120,33 @@ async function harness( return { key, sockets, + frames, requests, + publications, + lifecycle, + duringStreamClose(callback) { + onStreamClose = callback; + }, + releaseSetup() { + holdSetup = false; + for (const reply of setupReplies.splice(0)) reply(); + }, base, - async post(channels, origin = base) { + publishPresence(streamId) { + return fetch(`${base}/api/relay/stream-presence-publish`, { + method: "POST", + headers: { Origin: base, "Content-Type": "application/json" }, + body: JSON.stringify({ streamId, status: "online" }), + signal: AbortSignal.timeout(3000), + }); + }, + async post(channels, origin = base, extra = {}) { const controller = new AbortController(); controllers.push(controller); const response = await fetch(`${base}/api/relay/stream`, { method: "POST", headers: { Origin: origin, "Content-Type": "application/json" }, - body: JSON.stringify({ channels }), + body: JSON.stringify({ channels, ...extra }), signal: controller.signal, }); return { response, abort: () => controller.abort() }; @@ -93,8 +158,8 @@ async function harness( }, }; } -async function until(check) { - for (let i = 0; i < 200; i++) { +async function until(check, timeoutMs = 2000) { + for (let i = 0; i < timeoutMs / 10; i++) { if (check()) return; await delay(10); } @@ -148,9 +213,24 @@ test("real HTTP accepts the 1022-channel body and rejects invalid/oversized/orig large.abort(); await until(() => h.sockets.every((s) => s.readyState === 3)); const count = h.sockets.length; - for (const invalid of [[""], Array(1025).fill("a"), ["x".repeat(150001)]]) { + for (const [invalid, status] of [ + [[""], 400], + [Array(1025).fill("a"), 400], + [["x".repeat(129)], 400], + [["x".repeat(160001)], 413], + ]) { const { response } = await h.post(invalid); - expect([400, 413]).toContain(response.status); + expect(response.status).toBe(status); + await response.text(); + } + for (const extra of [ + { authors: ["bad"] }, + { authors: Array(257).fill("a".repeat(64)) }, + { priority: Array(65).fill("a") }, + { observer: Number.MAX_SAFE_INTEGER + 1 }, + ]) { + const { response } = await h.post(["a"], h.base, extra); + expect(response.status).toBe(400); await response.text(); } const denied = await h.post(["a"], "https://other.invalid"); @@ -161,22 +241,26 @@ test("real HTTP accepts the 1022-channel body and rejects invalid/oversized/orig } }); -test.each([null, 1])( - "maximum channel interests survive observer startup and toggles (initial %s) through the real broker/browser stream", +test.each([null, Number.MAX_SAFE_INTEGER])( + "combined maximum-length channel, priority and presence interests survive observer startup and toggles (initial %s) through the real broker/browser stream", async (initialObserver) => { const h = await harness(); const nativeFetch = globalThis.fetch; let traffic; + const streamResponses = []; try { - const fetcher = vi.fn((input, init) => - nativeFetch(input, { + const fetcher = vi.fn(async (input, init) => { + const response = await nativeFetch(input, { ...init, headers: { ...init?.headers, ...(init?.method === "POST" ? { Origin: h.base } : {}), }, - }), - ); + }); + if (String(input).endsWith("/stream")) + streamResponses.push(response.status); + return response; + }); vi.stubGlobal("fetch", fetcher); const transport = await connectBrokerTransport(h.base); const states = [], @@ -195,10 +279,29 @@ test.each([null, 1])( }); const ids = Array.from( { length: 1024 }, - (_, i) => `channel-${String(i).padStart(4, "0")}`, + (_, i) => `channel-${String(i).padStart(120, "0")}`, + ); + const authors = Array.from({ length: 256 }, (_, i) => + i.toString(16).padStart(64, "0"), ); traffic.observe(initialObserver); + traffic.prioritize(ids.slice(0, 64)); + traffic.presence.update(authors); traffic.update(ids); + const body = fetcher.mock.calls.findLast(([url]) => + String(url).endsWith("/stream"), + )[1].body; + expect(Buffer.byteLength(body)).toBe( + initialObserver === null ? 159735 : 159747, + ); + expect(JSON.parse(body)).toEqual({ + channels: ids, + priority: ids.slice(0, 64), + authors, + observer: initialObserver, + }); + await until(() => streamResponses.length > 0); + expect(streamResponses).toEqual([200]); const streamPosts = () => fetcher.mock.calls.filter(([url]) => String(url).endsWith("/stream")) .length; @@ -232,11 +335,11 @@ test.each([null, 1])( snapshot.routes .filter((r) => r.status === "limited") .map((r) => r.channelId), - ).toEqual(ids.slice(enabled ? 1021 : 1022)); - // Status retains every interest, but only 1024 routes may have a wire. + ).toEqual(ids.slice(enabled ? 1019 : 1020)); + // Presence reserves two more wires outside this ordinary-route snapshot. expect( snapshot.routes.filter((r) => r.status !== "limited"), - ).toHaveLength(1024); + ).toHaveLength(1022); expect(snapshot.routes.some((r) => r.id === "observer")).toBe(enabled); sockets ??= h.sockets.length; posts ??= streamPosts(); @@ -498,6 +601,404 @@ test("priority control cannot allocate interests or bypass owner, origin, commun } }); +test("actual browser/broker presence controls preserve socket and healthy routes; only a correlated WS receipt resolves publication", async () => { + const h = await harness(); + const nativeFetch = globalThis.fetch; + let traffic; + try { + const fetcher = vi.fn((input, init) => + nativeFetch(input, { + ...init, + headers: { + ...init?.headers, + ...(init?.method === "POST" ? { Origin: h.base } : {}), + }, + }), + ); + vi.stubGlobal("fetch", fetcher); + const callbacks = { + receive: vi.fn(), + state: vi.fn(), + established: vi.fn(), + denied: vi.fn(), + presence: vi.fn(), + presenceState: vi.fn(), + }; + const transport = await connectBrokerTransport(h.base); + traffic = transport.subscribe(callbacks); + traffic.update(["a"]); + await until(() => callbacks.established.mock.calls.length === 3); + const sockets = h.sockets.length; + const streams = fetcher.mock.calls.filter(([url]) => + String(url).endsWith("/stream"), + ).length; + const author = getPublicKey(h.key); + for (let i = 0; i < 1000; i++) traffic.presence.update([author]); + await until( + () => callbacks.presenceState.mock.lastCall?.[0].status === "ready", + ); + const route = h.frames.find( + (f) => f.kind === "REQ" && f.filter.kinds[0] === 20001, + ); + expect(route.filter).toEqual({ + kinds: [20001], + authors: [author], + limit: 0, + }); + const event = finalizeEvent( + { + kind: 20001, + content: "online", + created_at: Math.floor(Date.now() / 1000), + tags: [], + }, + h.key, + ); + await route.socket.receive(["EVENT", route.id, event]); + await until(() => callbacks.presence.mock.calls.length === 1); + expect(callbacks.receive).not.toHaveBeenCalled(); + expect(callbacks.established).toHaveBeenCalledTimes(3); + // Coalesced A -> B -> A must return to Ready even though the server union never changed. + traffic.presence.update(["f".repeat(64)]); + traffic.presence.update([author]); + expect(callbacks.presenceState.mock.lastCall?.[0].status).toBe("pending"); + await until( + () => callbacks.presenceState.mock.lastCall?.[0].status === "ready", + ); + const completed = vi.fn(); + const operation = traffic.presence + .publish("away", new AbortController().signal) + .then(completed); + await until(() => h.frames.some((f) => f.kind === "EVENT")); + const frame = h.frames.find((f) => f.kind === "EVENT"); + expect(frame.id).toMatchObject({ + kind: 20001, + content: "away", + tags: [], + pubkey: author, + }); + await frame.socket.receive(["OK", "wrong-id", true]); + await delay(20); + expect(completed).not.toHaveBeenCalled(); + await frame.socket.receive(["OK", frame.id.id, true]); + await operation; + expect(completed).toHaveBeenCalledOnce(); + traffic.presence.update([]); + await until(() => + h.frames.some((f) => f.kind === "CLOSE" && f.id === route.id), + ); + expect(h.sockets).toHaveLength(sockets); + expect( + fetcher.mock.calls.filter(([url]) => String(url).endsWith("/stream")), + ).toHaveLength(streams); + expect(h.requests.filter((r) => r.filter.kinds[0] !== 20001)).toHaveLength( + 3, + ); + expect( + fetcher.mock.calls.filter(([url]) => + String(url).endsWith("/stream-presence"), + ), + ).toHaveLength(3); + expect( + fetcher.mock.calls.some(([url]) => + /\/(events|publish|sign)$/.test(String(url)), + ), + ).toBe(false); + } finally { + traffic?.dispose(); + vi.unstubAllGlobals(); + await h.close(); + } +}); + +test("presence controls enforce origin, owner, community, shape and body bounds without extra sockets/signing", async () => { + const h = await harness(); + const post = (path, body, origin = h.base) => + fetch(`${h.base}${path}`, { + method: "POST", + headers: { Origin: origin, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + try { + const stream = await h.post([]); + const streamId = stream.response.headers.get("x-buzz-live-id"); + const author = getPublicKey(h.key); + await until(() => h.requests.length === 2); + for (const [path, body, code] of [ + ["stream-presence", { streamId, authors: ["bad"] }, 400], + ["stream-presence", { streamId, authors: Array(257).fill(author) }, 400], + ["stream-presence", { streamId, authors: ["x".repeat(18001)] }, 413], + ["stream-presence-publish", { streamId, status: "offline" }, 400], + [ + "stream-presence-publish", + { streamId, status: { status: "online" } }, + 400, + ], + ["stream-presence-publish", { streamId, status: "x".repeat(300) }, 413], + [ + "stream-presence-publish", + { streamId: "f".repeat(32), status: "online" }, + 404, + ], + ]) + expect((await post(`/api/relay/${path}`, body)).status).toBe(code); + for (const path of ["stream-presence", "stream-presence-publish"]) { + const body = { streamId, authors: [author], status: "online" }; + expect( + (await post(`/api/relay/${path}`, body, "https://wrong.invalid")) + .status, + ).toBe(403); + expect((await post(`/api/relay/secondary/${path}`, body)).status).toBe( + 404, + ); + } + expect(h.sockets).toHaveLength(1); + expect(h.frames.some((f) => f.kind === "EVENT")).toBe(false); + expect(h.requests).toHaveLength(2); + stream.abort(); + await until(() => h.sockets[0].readyState === 3); + expect( + ( + await post("/api/relay/stream-presence", { + streamId, + authors: [author], + }) + ).status, + ).toBe(404); + expect( + ( + await post("/api/relay/stream-presence-publish", { + streamId, + status: "online", + }) + ).status, + ).toBe(404); + } finally { + await h.close(); + } +}); + +test("SSE close before publication POST close remains unconfirmed and replacement needs its matching OK", async () => { + const h = await harness(0, undefined, true); + try { + const first = await h.post([]); + const streamId = first.response.headers.get("x-buzz-live-id"); + await until(() => h.requests.length === 2); // Authenticated, globals await EOSE. + const pending = h.publishPresence(streamId); + await until(() => h.publications.length === 1); + expect(h.publications[0]).toEqual({ + streamId, + closed: false, + status: undefined, + }); + expect(h.frames.filter((frame) => frame.kind === "EVENT")).toEqual([]); + + // Close only the SSE; the independent publication HTTP response stays open. + first.abort(); + const response = await pending; + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + error: "Presence publication unconfirmed", + code: "presence_owner_disposed", + }); + expect(h.lifecycle).toEqual([ + { event: "stream-close", streamId }, + { event: "publication-finish", streamId, status: 503 }, + ]); + expect(h.sockets[0].readyState).toBe(3); + h.releaseSetup(); // Late old EOSE must not send the retired publication. + await delay(10); + expect(h.frames.filter((frame) => frame.kind === "EVENT")).toEqual([]); + + const replacement = await h.post([]); + const replacementId = replacement.response.headers.get("x-buzz-live-id"); + await until(() => h.requests.length === 4); + const retry = h.publishPresence(replacementId); + const settled = vi.fn(); + void retry.then(settled); + await until(() => h.frames.some((frame) => frame.kind === "EVENT")); + const frame = h.frames.find((frame) => frame.kind === "EVENT"); + expect(frame.socket).toBe(h.sockets[1]); + await frame.socket.receive(["OK", "f".repeat(64), true]); + await delay(10); + expect(settled).not.toHaveBeenCalled(); + await frame.socket.receive(["OK", frame.id.id, true]); + const accepted = await retry; + expect(accepted.status).toBe(200); + expect(await accepted.json()).toEqual({ accepted: true }); + expect(h.frames.filter((entry) => entry.kind === "EVENT")).toHaveLength(1); + replacement.abort(); + } finally { + await h.close(); + } +}); + +test.each([ + "restricted: publication refused", + "Presence owner disposed", + "presence_owner_disposed", +])("live rejection %s stays unclassified after retirement", async (reason) => { + const h = await harness(); + try { + const stream = await h.post([]); + const streamId = stream.response.headers.get("x-buzz-live-id"); + await until(() => h.requests.length === 2); + const pending = h.publishPresence(streamId); + await until(() => h.frames.some((frame) => frame.kind === "EVENT")); + const frame = h.frames.find((entry) => entry.kind === "EVENT"); + await frame.socket.receive(["OK", frame.id.id, false, reason]); + const response = await pending; + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + error: "Presence publication unconfirmed", + }); + expect(h.sockets[0].readyState).toBe(1); + expect(h.lifecycle).toEqual([ + { event: "publication-finish", streamId, status: 503 }, + ]); + // Later retirement cannot retroactively turn a genuine failure into cancellation. + stream.abort(); + await until(() => h.sockets[0].readyState === 3); + expect(h.lifecycle).toEqual([ + { event: "publication-finish", streamId, status: 503 }, + { event: "stream-close", streamId }, + ]); + } finally { + await h.close(); + } +}); + +test("disposal after EVENT but before matching OK stays unconfirmed, not unsent or accepted", async () => { + const h = await harness(); + try { + const stream = await h.post([]); + const streamId = stream.response.headers.get("x-buzz-live-id"); + await until(() => h.requests.length === 2); + const pending = h.publishPresence(streamId); + await until(() => h.frames.some((entry) => entry.kind === "EVENT")); + const frame = h.frames.find((entry) => entry.kind === "EVENT"); + stream.abort(); + const response = await pending; + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + error: "Presence publication unconfirmed", + code: "presence_owner_disposed", + }); + await frame.socket.receive(["OK", frame.id.id, true]); + await delay(10); + expect(h.frames.filter((entry) => entry.kind === "EVENT")).toHaveLength(1); + } finally { + await h.close(); + } +}); + +test("a relay rejection during SSE retirement defeats close-before-503 accounting", async () => { + const h = await harness(); + try { + const stream = await h.post([]); + const streamId = stream.response.headers.get("x-buzz-live-id"); + await until(() => h.requests.length === 2); + const pending = h.publishPresence(streamId); + await until(() => h.frames.some((entry) => entry.kind === "EVENT")); + const frame = h.frames.find((entry) => entry.kind === "EVENT"); + const reason = "rate-limited: quota exceeded; retry in 1s"; + let rejectedWhileLive = false; + // Deliver the real negative OK after an outer observer sees HTTP close but + // before the broker's close listener disposes the still-live socket owner. + h.duringStreamClose(() => { + rejectedWhileLive = frame.socket.readyState === 1; + frame.socket.receive(["OK", frame.id.id, false, reason]); + }); + stream.abort(); + const response = await pending; + expect(rejectedWhileLive).toBe(true); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + error: "Presence publication unconfirmed", + }); + expect(h.lifecycle).toEqual([ + { event: "stream-close", streamId }, + { event: "publication-finish", streamId, status: 503 }, + ]); + // This is a real rejection, not merely a discarded negative OK after + // disposal: its host-wide quota pause suppresses the replacement's REQ. + const count = h.requests.length; + const replacement = await h.post([]); + await until(() => + h.frames.some( + (entry) => entry.kind === "AUTH" && entry.socket === h.sockets[1], + ), + ); + await delay(300); + expect(h.requests).toHaveLength(count); + replacement.abort(); + } finally { + await h.close(); + } +}); + +test("broker publication cancellation frees its owner and quota rejection remains unconfirmed with no retry", async () => { + const h = await harness(); + const nativeFetch = globalThis.fetch; + let traffic; + try { + vi.stubGlobal("fetch", (input, init) => + nativeFetch(input, { + ...init, + headers: { + ...init?.headers, + ...(init?.method === "POST" ? { Origin: h.base } : {}), + }, + }), + ); + const transport = await connectBrokerTransport(h.base); + let ready = 0; + traffic = transport.subscribe({ + receive() {}, + state() {}, + established() { + ready++; + }, + denied() {}, + }); + await until(() => ready === 2); + const controller = new AbortController(); + const operation = traffic.presence.publish("online", controller.signal); + const failure = expect(operation).rejects.toThrow(); + await until(() => h.frames.some((f) => f.kind === "EVENT")); + controller.abort(); + await failure; + await delay(50); // Let HTTP close cancellation reach the server owner. + const second = traffic.presence.publish( + "away", + new AbortController().signal, + ); + const rejected = expect(second).rejects.toThrow("unconfirmed"); + await until( + () => h.frames.filter((f) => f.kind === "EVENT").length === 2, + 6000, + ); + const frame = h.frames.filter((f) => f.kind === "EVENT")[1]; + expect( + frame.at - h.frames.find((f) => f.kind === "EVENT").at, + ).toBeGreaterThanOrEqual(4990); + await frame.socket.receive([ + "OK", + frame.id.id, + false, + "rate-limited: quota exceeded; retry in 0s", + ]); + await rejected; + await delay(1100); + expect(h.frames.filter((f) => f.kind === "EVENT")).toHaveLength(2); + expect(h.sockets).toHaveLength(1); + } finally { + traffic?.dispose(); + vi.unstubAllGlobals(); + await h.close(); + } +}, 10000); + test("real signed/encrypted WS → host decode → SSE → session activity; demand and clear fence without replacing chat", async () => { const h = await harness(); const nativeFetch = globalThis.fetch; diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs index 2027d4a1..d063ec24 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -1,3 +1,5 @@ +import { PresenceOwnerDisposed } from "../src/features/relay/presence-live.ts"; +import { isPresenceSnapshot } from "../src/features/relay/presence-contract.ts"; import { decodeAgentObserver } from "./agent-observer.mjs"; import { observerGeneration } from "../src/features/agents/observer.ts"; import { @@ -18,6 +20,10 @@ import { SIDEBAR_UPLOAD_MS, SIDEBAR_UPLOAD_SLOTS, } from "./sidebar-preferences.mjs"; +import { + presenceAuthors, + presenceStatus, +} from "../src/features/relay/presence-contract.ts"; import { createHostAdmission } from "../src/features/relay/host-admission.ts"; import { relayKlipySearchPath } from "../src/features/relay/gifs.ts"; // Dev-only relay broker. Holds the local Buzz identity in this Node process and signs NIP-98 reads @@ -319,6 +325,7 @@ export function relayBrokerPlugin({ }; const stats = { queries: 0, errors: 0, media: 0, connects: 0 }; let inflight = 0; + let presenceInflight = 0; let sidebarUploads = 0; let libraryRead; const streams = new Map(); @@ -514,28 +521,39 @@ export function relayBrokerPlugin({ readState: true, agentLibrary: true, live: true, + presence: true, agentActivity: true, }); if ( [ "/api/relay/stream-retry", "/api/relay/stream-priority", + "/api/relay/stream-presence", + "/api/relay/stream-presence-publish", "/api/relay/stream-observer", ].includes(route) && req.method === "POST" ) { const prioritizing = route === "/api/relay/stream-priority"; + const observingPresence = route === "/api/relay/stream-presence"; + const publishingPresence = + route === "/api/relay/stream-presence-publish"; const observing = route === "/api/relay/stream-observer"; let raw = ""; for await (const part of req) { raw += part; - if (Buffer.byteLength(raw) > (prioritizing ? 9000 : 256)) + if ( + Buffer.byteLength(raw) > + (observingPresence ? 18000 : prioritizing ? 9000 : 256) + ) return json(res, 413, { error: "Live control too large" }); } - let streamId, priority, observer; + let streamId, priority, authors, status, observer; try { const body = JSON.parse(raw); streamId = body.streamId; + if (observingPresence) authors = presenceAuthors(body.authors); + if (publishingPresence) status = presenceStatus(body.status); if (observing) observer = observerGeneration(body.observer); if (prioritizing) { liveChannels(body.channels); @@ -556,7 +574,35 @@ export function relayBrokerPlugin({ return json(res, 404, { error: "Live stream no longer available", }); - if (prioritizing) stream.traffic.prioritize(priority); + if (publishingPresence) { + const controller = new AbortController(); + const abort = () => controller.abort(); + res.once("close", abort); + try { + await stream.traffic.presence.publish( + status, + controller.signal, + ); + if (!res.destroyed) return json(res, 200, { accepted: true }); + } catch (error) { + if (!res.destroyed) + return json(res, 503, { + error: "Presence publication unconfirmed", + ...(error instanceof PresenceOwnerDisposed + ? { code: "presence_owner_disposed" } + : {}), + }); + } finally { + res.off("close", abort); + } + return; + } + if (observingPresence) { + stream.traffic.presence.update(authors); + // Reassert state on the ordered SSE lane even when the union is unchanged + // (e.g. A -> B -> A coalesced in the browser, or retry after a lost response). + stream.presence(); + } else if (prioritizing) stream.traffic.prioritize(priority); else if (observing) stream.traffic.observe(observer); else stream.traffic.retry(); return json(res, 200, { accepted: true }); @@ -565,13 +611,17 @@ export function relayBrokerPlugin({ let raw = ""; for await (const part of req) { raw += part; - if (Buffer.byteLength(raw) > 150000) + // Combined maxima serialize to 159,747 bytes: 1,024 × 128-char + // channels, 64 priorities, 256 full authors and a safe-int observer. + // Keep a bounded envelope with room for field names/JSON punctuation. + if (Buffer.byteLength(raw) > 160000) return json(res, 413, { error: "Live interests too large" }); } - let channels, priority, observer; + let channels, priority, authors, observer; try { const body = JSON.parse(raw); channels = liveChannels(body.channels); + authors = presenceAuthors(body.authors ?? []); observer = observerGeneration(body.observer ?? null); liveChannels(body.priority ?? []); if (body.priority?.length > 64) @@ -608,6 +658,7 @@ export function relayBrokerPlugin({ `${kind ? `event: ${kind}\n` : ""}data: ${JSON.stringify(value)}\n\n`, ); }; + let presenceState = { status: "idle", authors: [] }; const traffic = subscribeRelayTraffic( relay.replace(/^http/, "ws"), async (event) => finalizeEvent(event, key), @@ -617,6 +668,13 @@ export function relayBrokerPlugin({ for (const event of events) write("traffic", { event, provenance }); }, + presence: (events) => { + for (const event of events) write("presence", event); + }, + presenceState: (state) => { + presenceState = state; + write("presence-state", state); + }, telemetry: (event, generation) => { if (res.destroyed) return; try { @@ -640,6 +698,7 @@ export function relayBrokerPlugin({ traffic.observe(observer); traffic.prioritize(priority); traffic.update(channels); + traffic.presence.update(authors); const keepAlive = setInterval( () => res.write(": keepalive\n\n"), 15000, @@ -654,7 +713,12 @@ export function relayBrokerPlugin({ streams.delete(streamId); res.destroy(); }; - streams.set(streamId, { relay, traffic, close }); + streams.set(streamId, { + relay, + traffic, + close, + presence: () => write("presence-state", presenceState), + }); res.once("close", close); if (res.destroyed) close(); return; @@ -877,131 +941,146 @@ export function relayBrokerPlugin({ : policy ? "/api/invites/accept-policy" : "/query"; - if (inflight >= MAX_INFLIGHT) + const presenceSnapshot = + route === "/api/relay/query" && isPresenceSnapshot(filters); + if ( + presenceSnapshot ? presenceInflight >= 1 : inflight >= MAX_INFLIGHT + ) return json(res, 429, { error: "Query concurrency limit", sent: false, }); - inflight++; + if (presenceSnapshot) presenceInflight++; + else inflight++; try { const lane = admissions(relay, viewer).api; - const body = JSON.stringify(filters); - // A browser that gave up (the client's ten-second deadline) must also release - // this upstream request, or hung requests exhaust the inflight budget. - const cancel = new AbortController(); - const release = () => cancel.abort(); - res.once("close", release); - const admissionStart = performance.now(); - let connectsBefore, upstreamStart; - let response; - try { - const requestSignal = AbortSignal.any([ - cancel.signal, - AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), - ]); - response = await admittedApiRequest( - lane, - () => { - // Auth freshness and network timings begin at dispatch, not queue entry. - requestSignal.throwIfAborted(); - timings.push( - `admission;dur=${(performance.now() - admissionStart).toFixed(2)}`, - ); - const authStart = performance.now(); - const auth = finalizeEvent( - { - kind: 27235, - created_at: Math.floor(Date.now() / 1000), - content: "", - tags: [ - ["u", `${relay}${upstreamPath}`], - ["method", "POST"], - [ - "payload", - createHash("sha256").update(body).digest("hex"), - ], - ["nonce", randomBytes(16).toString("hex")], - ], + await lane.prepare( + async () => { + const body = JSON.stringify(filters); + // A browser that gave up (the client's ten-second deadline) must also release + // this upstream request, or hung requests exhaust the inflight budget. + const cancel = new AbortController(); + const release = () => cancel.abort(); + res.once("close", release); + const admissionStart = performance.now(); + let connectsBefore, upstreamStart; + let response; + try { + const requestSignal = AbortSignal.any([ + cancel.signal, + AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + ]); + response = await admittedApiRequest( + lane, + () => { + // Auth freshness and network timings begin at dispatch, not queue entry. + requestSignal.throwIfAborted(); + timings.push( + `admission;dur=${(performance.now() - admissionStart).toFixed(2)}`, + ); + const authStart = performance.now(); + const auth = finalizeEvent( + { + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + content: "", + tags: [ + ["u", `${relay}${upstreamPath}`], + ["method", "POST"], + [ + "payload", + createHash("sha256").update(body).digest("hex"), + ], + ["nonce", randomBytes(16).toString("hex")], + ], + }, + key, + ); + timings.push( + `auth;dur=${(performance.now() - authStart).toFixed(2)}`, + ); + connectsBefore = upstream.connects(); + upstreamStart = performance.now(); + return fetchUpstream(`${relay}${upstreamPath}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: + "Nostr " + + Buffer.from(JSON.stringify(auth)).toString( + "base64", + ), + }, + body, + redirect: "error", + signal: requestSignal, + }).then((response) => { + timings.push( + `ttfb;dur=${(performance.now() - upstreamStart).toFixed(2)}`, + ); + return response; + }); }, - key, + requestSignal, + presenceSnapshot + ? "presence" + : route === "/api/relay/query" && + req.headers["x-buzz-read-priority"] === "background" + ? "background" + : "foreground", + ); + const text = + snapshot && response.ok + ? await readSnapshotText(response) + : await response.text(); + // The relay's own service time separates server work from network time. + const relayMs = Number( + response.headers.get("x-envoy-upstream-service-time"), ); timings.push( - `auth;dur=${(performance.now() - authStart).toFixed(2)}`, + ...upstream.connectTiming(connectsBefore), + ...(Number.isFinite(relayMs) && + response.headers.has("x-envoy-upstream-service-time") + ? [`relay;dur=${relayMs}`] + : []), + `upstream;dur=${(performance.now() - upstreamStart).toFixed(2)}`, ); - connectsBefore = upstream.connects(); - upstreamStart = performance.now(); - return fetchUpstream(`${relay}${upstreamPath}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: - "Nostr " + - Buffer.from(JSON.stringify(auth)).toString("base64"), - }, - body, - redirect: "error", - signal: requestSignal, - }).then((response) => { - timings.push( - `ttfb;dur=${(performance.now() - upstreamStart).toFixed(2)}`, - ); - return response; + res.setHeader("Server-Timing", timings.join(", ")); + stats.queries++; + if (!response.ok) { + stats.errors++; + let failure; + try { + failure = apiFailure(response.status, JSON.parse(text)); + } catch { + failure = apiFailure(response.status, undefined); + } + return json(res, response.status, failure); + } + if (profile) { + const receipt = JSON.parse(text); + if ( + receipt.event_id !== filters.id || + typeof receipt.accepted !== "boolean" + ) + return json(res, 502, { + error: "Profile publication could not be confirmed", + }); + } + res.writeHead(200, { + "Content-Type": "application/json", + "Cache-Control": "no-store", }); - }, - requestSignal, - route === "/api/relay/query" && - req.headers["x-buzz-read-priority"] === "background" - ? "background" - : "foreground", - ); - const text = - snapshot && response.ok - ? await readSnapshotText(response) - : await response.text(); - // The relay's own service time separates server work from network time. - const relayMs = Number( - response.headers.get("x-envoy-upstream-service-time"), - ); - timings.push( - ...upstream.connectTiming(connectsBefore), - ...(Number.isFinite(relayMs) && - response.headers.has("x-envoy-upstream-service-time") - ? [`relay;dur=${relayMs}`] - : []), - `upstream;dur=${(performance.now() - upstreamStart).toFixed(2)}`, - ); - res.setHeader("Server-Timing", timings.join(", ")); - stats.queries++; - if (!response.ok) { - stats.errors++; - let failure; - try { - failure = apiFailure(response.status, JSON.parse(text)); - } catch { - failure = apiFailure(response.status, undefined); + res.end(text); + } finally { + res.off("close", release); } - return json(res, response.status, failure); - } - if (profile) { - const receipt = JSON.parse(text); - if ( - receipt.event_id !== filters.id || - typeof receipt.accepted !== "boolean" - ) - return json(res, 502, { - error: "Profile publication could not be confirmed", - }); - } - res.writeHead(200, { - "Content-Type": "application/json", - "Cache-Control": "no-store", - }); - res.end(text); - } finally { - res.off("close", release); - } + }, + presenceSnapshot ? "presence" : "foreground", + ); } finally { - inflight--; + if (presenceSnapshot) presenceInflight--; + else inflight--; } } catch (error) { if (res.destroyed) return; // The browser gave up first; nothing to answer. diff --git a/docs/relay-queries.md b/docs/relay-queries.md index 62fac93a..8dce9f23 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -203,9 +203,12 @@ The timeline, channel-list preview and filtered plugin views share that operatio ## Bounds and lifecycle -- Reads: three active slots, at most one background request, 128 pending distinct - requests, ten-second deadlines including queue time, four filters per request, - 500 per-filter limit, 64 KiB keys, and an 8 MiB result budget. +- Ordinary reads: three active slots, at most one background request, 128 pending + distinct requests. One strictly classified presence snapshot has separate additive + capacity; it does not reduce those ordinary budgets. All reads retain ten-second + deadlines including queue time, four filters per request, 500 per-filter limit, + 64 KiB keys, and an 8 MiB result budget. Optional consumer cancellation retains + its slot until underlying work settles. See [optional presence transport](#optional-presence-transport). - Outbox: at most 256 operations / 2 MiB persisted, 32 KiB per submitted payload, three concurrent deliveries, ten-second delivery deadlines including queue time. Only outstanding operations count toward capacity; completed events move to a separate 2,048-event / @@ -426,8 +429,9 @@ for outstanding work. These manual fixtures are not part of `pnpm test`; see The session's `live` capability exposes connection/route state independently from finite-read readiness. A connected socket is not proof that every route is live. Global profile and self-scoped membership-hint routes are separate from explicit -channel subscriptions. One socket supports at most 1,022 channels plus those two -globals; omitted routes and partial roster coverage remain visible. +channel subscriptions. One socket supports at most 1,020 channels (1,019 with the +agent observer), reserving two globals and two presence handover slots within the +1,024-route ceiling. Omitted routes and partial roster coverage remain visible. EOSE establishes streaming, **not complete historical replay**. Retained channel windows get finite, signed-bounds head catch-up after establishment/reconnect; @@ -461,6 +465,46 @@ HTTP/1 streaming uses a close-delimited response to avoid WebKit stranding trail chunked frames until later traffic. This is not a new replay-completeness guarantee. +### Optional presence transport + +The live subscription exposes a separate, explicit `presence` capability: update +at most 256 full author keys or publish bare `online`/`away` kind-20001 events on +the same authenticated socket. Confirmed and candidate routes overlap during +handover. EOSE establishes the route, not a presence snapshot. Publication resolves +only for a matching WebSocket OK; timeout, cancellation or owner disposal is not +proof of Offline or non-delivery. There is no durable outbox entry, heartbeat +schedule or automatic publication replay in this transport owner. + +Presence has separate bounded capacity and start clocks, so it does not consume +ordinary reader slots or advance ordinary HTTP/WS pacing. Ordinary ready HTTP/reader +work wins; WS presence yields to foreground route setup. Actual relay cooldowns +remain shared. Presence snapshots/publications have a +five-second start interval; presence route setup has a one-second interval. +Ownership survives cancellation until outstanding signing/body work settles. +Only one finite filter containing exactly `kinds: [20001]`, unique full `authors` +and `limit: authors.length` qualifies for optional HTTP capacity. A caller-supplied +priority cannot grant that classification. The broker implements optional HTTP +admission and explicitly advertises `presence: true` with live support. The +application composes that adapter. The alternate direct-signed adapter keeps +ordinary reads/writes and the shared socket owner but does not advertise complete +presence support or classify optional HTTP reads. Feature composition must require +the explicit transport flag rather than infer support from `subscribe`. + +These transport capabilities alone do not enable session observation, renewal or +UI indicators. With no presence caller, explicit live Retry can send an empty-author +`/stream-presence` control but creates no presence REQ or publication. The reserved +route capacity above applies even before feature activation. + +Owning coverage lives in `presence-live.test.ts`, `reader-presence.test.ts`, +`transport.test.ts` and the broker/admission suites. +`dev/relay-broker-fixture.test.mjs` drives eight production broker transports for a +full reference quota window through real local HTTP and the existing policy relay. +It checks combined upstream quota charges (including rejected attempts), loaded +throughput and correlated WS cooldown with independent API progress. Exact start +clocks remain covered by deterministic admission/live tests. `dev/policy-relay.test.mjs` exercises modeled quota overload and expiry once +under Vitest, not once per browser engine. Controlled local budgets do not reserve +account-wide relay quota or establish deployed latency. + ### User attention during recovery The warning banner is for failures needing attention. Routine setup and bounded diff --git a/src/features/relay/broker-live.test.ts b/src/features/relay/broker-live.test.ts index 3522651b..1e609778 100644 --- a/src/features/relay/broker-live.test.ts +++ b/src/features/relay/broker-live.test.ts @@ -1,7 +1,7 @@ // Regression controls contributed by Brain; see WS_RETRY_REVIEW_2026_09_09. import { assert, afterEach, expect, it, vi } from "vitest"; -import { keypair, message } from "./testing"; import { connectBrokerTransport } from "./transport"; +import { keypair, message, signed } from "./testing"; function required(value: T | undefined): T { assert.exists(value); return value; @@ -39,7 +39,11 @@ function fixture() { live: true, }), ); - if (url.endsWith("/stream-retry") || url.endsWith("/stream-observer")) { + if ( + url.endsWith("/stream-retry") || + url.endsWith("/stream-presence") || + url.endsWith("/stream-observer") + ) { const d = deferred(); controls.push(d); signals.push(init.signal as AbortSignal); @@ -85,10 +89,23 @@ function fixture() { snapshots, accept, publish, - frame(kind: string, value: unknown) { - required(bodyControllers[0]).enqueue( + end(index: number) { + required(bodyControllers[index]).close(); + }, + fail(index: number) { + required(bodyControllers[index]).error(new Error("injected stream loss")); + }, + presence(index: number, state: unknown) { + required(bodyControllers[index]).enqueue( + new TextEncoder().encode( + `event: presence-state\ndata: ${JSON.stringify(state)}\n\n`, + ), + ); + }, + frame(index: number, kind: string, data: unknown) { + required(bodyControllers[index]).enqueue( new TextEncoder().encode( - `event: ${kind}\ndata: ${JSON.stringify(value)}\n\n`, + `event: ${kind}\ndata: ${JSON.stringify(data)}\n\n`, ), ); }, @@ -200,6 +217,83 @@ for (const finish of ["replacement", "dispose"] as const) } }); +it("presence controls coalesce continuous demand with a fixed deadline and stale author state cannot replace current demand", async () => { + vi.useFakeTimers(); + const f = fixture(); + const states = vi.fn(); + const t = await connectBrokerTransport(); + const owner = required(t.subscribe)({ + ...f.callbacks, + presenceState: states, + }); + try { + f.accept(0); + await tick(); + for (let i = 1; i <= 10; i++) { + owner.presence?.update([i.toString(16).padStart(64, "0")]); + await vi.advanceTimersByTimeAsync(50); + } + expect(f.controls).toHaveLength(1); + const latest = "a".padStart(64, "0"); + f.presence(0, { status: "ready", authors: ["1".padStart(64, "0")] }); + await tick(); + expect(states.mock.lastCall?.[0]).toEqual({ + status: "pending", + authors: [latest], + }); + required(f.controls[0]).resolve(new Response(null, { status: 200 })); + await tick(); + await vi.advanceTimersByTimeAsync(600); + expect(f.controls).toHaveLength(2); + f.presence(0, { status: "ready", authors: [latest] }); + await tick(); + expect(states.mock.lastCall?.[0]).toEqual({ + status: "ready", + authors: [latest], + }); + required(f.controls[1]).resolve(new Response(null, { status: 200 })); + await tick(); + } finally { + owner.dispose(); + } +}); + +it("stream replacement clears a queued presence timer without poisoning later controls; late failed controls are fenced", async () => { + vi.useFakeTimers(); + const f = fixture(); + const states = vi.fn(); + const t = await connectBrokerTransport(); + const owner = required(t.subscribe)({ + ...f.callbacks, + presenceState: states, + }); + try { + f.accept(0); + await tick(); + owner.presence?.update(["a".repeat(64)]); + owner.update(["a"]); // replacement while the first control is still queued + f.accept(1); + await tick(); + owner.presence?.update(["b".repeat(64)]); + await vi.advanceTimersByTimeAsync(100); + expect(f.controls).toHaveLength(1); + owner.update(["b"]); + f.accept(2); + await tick(); + const before = states.mock.calls.length; + required(f.controls[0]).resolve(new Response(null, { status: 503 })); + await tick(); + expect(states).toHaveBeenCalledTimes(before); + owner.presence?.update(["c".repeat(64)]); + await vi.advanceTimersByTimeAsync(1000); + expect(f.controls).toHaveLength(2); + required(f.controls[1]).resolve(new Response(null, { status: 200 })); + await tick(); + } finally { + owner.dispose(); + } +}); + it("late observer 404 from a retired stream cannot interrupt its replacement", async () => { vi.useFakeTimers(); const f = fixture(); @@ -236,6 +330,91 @@ it("late observer 404 from a retired stream cannot interrupt its replacement", a } }); +it("keeps simultaneous presence and observer startup controls and SSE deliveries isolated", async () => { + vi.useFakeTimers(); + const f = fixture(); + const callbacks = { + ...f.callbacks, + receive: vi.fn(), + presence: vi.fn(), + observer: vi.fn(), + }; + const t = await connectBrokerTransport(); + const owner = required(t.subscribe)(callbacks); + const key = keypair(); + try { + const startup = vi + .mocked(fetch) + .mock.calls.find(([url]) => String(url).endsWith("/stream")); + expect(JSON.parse(String(startup?.[1]?.body))).toMatchObject({ + authors: [], + observer: null, + }); + required(owner.presence).update([key.pubkey]); + required(owner.observe)(1); + f.accept(0); + await tick(); + await vi.advanceTimersByTimeAsync(100); + const controls = () => + vi + .mocked(fetch) + .mock.calls.filter(([url]) => + /\/stream-(presence|observer)$/.test(String(url)), + ); + expect( + controls().map(([url, init]) => [ + String(url).split("/").at(-1), + JSON.parse(String(init?.body)), + ]), + ).toEqual([ + ["stream-observer", { streamId: "1".padStart(32, "0"), observer: 1 }], + [ + "stream-presence", + { streamId: "1".padStart(32, "0"), authors: [key.pubkey] }, + ], + ]); + expect(f.headers).toHaveLength(1); + for (const control of f.controls) + control.resolve(new Response(null, { status: 200 })); + await tick(); + + const event = signed(key, { kind: 20001, content: "online", tags: [] }); + const telemetry = signed(key, { kind: 24200, content: "opaque", tags: [] }); + const frame = { + id: telemetry.id, + agent: key.pubkey, + createdAt: telemetry.created_at, + plaintext: "{}", + }; + f.frame(0, "message", event); + f.frame(0, "message", telemetry); + for (const special of [event, telemetry]) + f.frame(0, "traffic", { + event: special, + provenance: { phase: "live", channelId: "a" }, + }); + f.frame(0, "presence", event); + f.frame(0, "observer", { frame, generation: 1 }); + await tick(); + expect(callbacks.receive).not.toHaveBeenCalled(); + expect(callbacks.presence).toHaveBeenCalledWith([event]); + expect(callbacks.observer).toHaveBeenCalledWith(frame, 1); + + required(owner.observe)(2); + required(owner.presence).update([]); + f.frame(0, "presence", event); + f.frame(0, "observer", { frame, generation: 1 }); + f.frame(0, "observer", { frame, generation: 2 }); + await tick(); + expect(callbacks.presence).toHaveBeenCalledTimes(1); + expect(callbacks.observer).toHaveBeenCalledTimes(2); + expect(callbacks.observer).toHaveBeenLastCalledWith(frame, 2); + expect(f.headers).toHaveLength(1); + } finally { + owner.dispose(); + } +}); + it("preserves validated replay/live provenance through production broker transport; legacy traffic stays unknown", async () => { vi.useFakeTimers(); const f = fixture(); @@ -245,11 +424,11 @@ it("preserves validated replay/live provenance through production broker transpo f.accept(0); await tick(); const event = message(keypair(), "a", "incoming", 1700000000); - f.frame("message", event); + f.frame(0, "message", event); await tick(); expect(f.callbacks.receive).toHaveBeenLastCalledWith([event]); for (const phase of ["replay", "live"]) { - f.frame("traffic", { event, provenance: { phase, channelId: "a" } }); + f.frame(0, "traffic", { event, provenance: { phase, channelId: "a" } }); await tick(); expect(f.callbacks.receive).toHaveBeenLastCalledWith([event], { phase, @@ -271,7 +450,7 @@ it.each([undefined, { phase: "fresh" }, { phase: "live", channelId: ["a"] }])( try { f.accept(0); await tick(); - f.frame("traffic", { + f.frame(0, "traffic", { event: message(keypair(), "a", "incoming", 1700000000), provenance, }); @@ -283,3 +462,122 @@ it.each([undefined, { phase: "fresh" }, { phase: "live", channelId: ["a"] }])( } }, ); + +it.each([200, 400, 401, 403, 413])( + "channel replacement invalidates ready presence synchronously and fences retired frames (HTTP %s)", + async (status) => { + vi.useFakeTimers(); + const f = fixture(); + const states = vi.fn(); + const t = await connectBrokerTransport(); + const owner = required(t.subscribe)({ + ...f.callbacks, + presenceState: states, + }); + const authors = ["a".repeat(64)]; + try { + required(owner.presence).update(authors); + f.accept(0); + await tick(); + f.presence(0, { status: "ready", authors }); + await tick(); + expect(states).toHaveBeenLastCalledWith({ status: "ready", authors }); + owner.update(["replacement"]); + // Headers are deliberately held. Neither a replacement nor its old EOSE is readiness. + expect(states).toHaveBeenLastCalledWith({ status: "pending", authors }); + const count = states.mock.calls.length; + f.presence(0, { status: "ready", authors }); + await tick(); + expect(states).toHaveBeenCalledTimes(count); + if (status === 200) { + f.accept(1); + await tick(); + f.publish(1); + await tick(); + expect(states).toHaveBeenLastCalledWith({ status: "pending", authors }); + f.presence(1, { status: "ready", authors }); + await tick(); + expect(states).toHaveBeenLastCalledWith({ status: "ready", authors }); + } else { + required(f.headers[1]).resolve(new Response(null, { status })); + await tick(); + expect(states).toHaveBeenLastCalledWith({ + status: "error", + authors, + error: `Live broker rejected subscription (${status})`, + }); + await vi.advanceTimersByTimeAsync(60000); + expect(f.headers).toHaveLength(2); + } + } finally { + owner.dispose(); + } + }, +); + +it.each(["end", "fail"] as const)( + "stream %s invalidates presence through reconnect exhaustion; explicit retry requires fresh readiness", + async (loss) => { + vi.useFakeTimers(); + const f = fixture(); + const states = vi.fn(); + const t = await connectBrokerTransport(); + const owner = required(t.subscribe)({ + ...f.callbacks, + presenceState: states, + }); + const authors = ["a".repeat(64)]; + try { + required(owner.presence).update(authors); + for (let i = 0; i <= 5; i++) { + f.accept(i); + await tick(); + // Establish every generation, so losing any one must invalidate its ready state. + f.presence(i, { status: "ready", authors }); + await tick(); + expect(states).toHaveBeenLastCalledWith({ status: "ready", authors }); + f[loss](i); + await tick(); + expect(states.mock.lastCall?.[0]).toMatchObject({ + status: i === 5 ? "error" : "pending", + authors, + }); + if (i < 5) await vi.advanceTimersByTimeAsync(500 * 2 ** i); + } + expect(states.mock.lastCall?.[0].error).toMatch(/attempts exhausted/); + await vi.advanceTimersByTimeAsync(60000); + expect(f.headers).toHaveLength(6); + owner.retry(); + expect(states).toHaveBeenLastCalledWith({ status: "pending", authors }); + f.accept(6); + await tick(); + f.publish(6); + await tick(); + expect(states).toHaveBeenLastCalledWith({ status: "pending", authors }); + f.presence(6, { status: "ready", authors }); + await tick(); + expect(states).toHaveBeenLastCalledWith({ status: "ready", authors }); + } finally { + owner.dispose(); + } + }, +); + +it("no presence demand stays idle during stream replacement and terminal rejection", async () => { + const f = fixture(); + const states = vi.fn(); + const t = await connectBrokerTransport(); + const owner = required(t.subscribe)({ + ...f.callbacks, + presenceState: states, + }); + try { + owner.update(["replacement"]); + expect(states).toHaveBeenLastCalledWith({ status: "idle", authors: [] }); + required(f.headers[1]).resolve(new Response(null, { status: 403 })); + await tick(); + expect(states).toHaveBeenLastCalledWith({ status: "idle", authors: [] }); + } finally { + owner.dispose(); + } +}); diff --git a/src/features/relay/broker-live.ts b/src/features/relay/broker-live.ts index 6cb4ca69..a5b6b191 100644 --- a/src/features/relay/broker-live.ts +++ b/src/features/relay/broker-live.ts @@ -1,5 +1,15 @@ -import { observerFrame, observerGeneration } from "../agents/observer"; +import { + OBSERVER_KIND, + observerFrame, + observerGeneration, +} from "../agents/observer"; import { eventDto } from "./events"; +import { + presenceAuthors, + presenceStatus, + presenceState, + type PresenceStatus, +} from "./presence-contract"; import { liveChannels, liveProvenance, @@ -20,6 +30,11 @@ export function subscribeBrokerTraffic( attempts = 0; let channels: string[] = []; let priority: string[] = []; + let authors: string[] = []; + let presencePending = false; + let presenceTimer: ReturnType | undefined; + let presenceDue = 0; + let publishing = false; let priorityPending = false; let observer: number | null = null; let observerPending = false; @@ -34,14 +49,29 @@ export function subscribeBrokerTraffic( }; let retryTimer: ReturnType | undefined; let heartbeat: ReturnType | undefined; - const state = (status: LiveSnapshot["status"], error?: string) => + const state = (status: LiveSnapshot["status"], error?: string) => { + // Local stream transitions retire its EOSE evidence. Generic connected + // frames cannot restore it; only a matching presence-state in this generation can. + callbacks.presenceState?.({ + status: authors.length + ? status === "error" + ? "error" + : "pending" + : "idle", + authors: [...authors], + ...(authors.length && error ? { error } : {}), + }); publish({ status, routes: [], ...(error ? { error } : {}) }); + }; function start() { if (closed) return; const current = ++generation; streamId = undefined; controlPending = false; priorityPending = false; + presencePending = false; + clearTimeout(presenceTimer); + presenceTimer = undefined; observerPending = false; receiving = true; controller?.abort(); @@ -61,6 +91,7 @@ export function subscribeBrokerTraffic( }; pulse(); const startingPriority = JSON.stringify(priority); + const startingPresence = JSON.stringify(authors); const startingObserver = observer; void (async () => { try { @@ -68,7 +99,7 @@ export function subscribeBrokerTraffic( method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ channels, priority, observer }), + body: JSON.stringify({ channels, priority, authors, observer }), signal: owned.signal, }); if (!valid()) return; @@ -91,6 +122,7 @@ export function subscribeBrokerTraffic( throw new Error("Invalid live broker control identity"); streamId = identity ?? undefined; if (startingPriority !== JSON.stringify(priority)) sendPriority(); + if (startingPresence !== JSON.stringify(authors)) schedulePresence(); if (startingObserver !== observer) sendObserver(); const reader = response.body.getReader(); const decoder = new TextDecoder(); @@ -120,8 +152,20 @@ export function subscribeBrokerTraffic( if (!lines.length) continue; // Keepalives carry no data. const data: unknown = JSON.parse(lines.join("\n")); if (!valid()) return; - if (kind === "message") callbacks.receive([eventDto(data)]); - else if (kind === "traffic") { + if (kind === "message") { + const event = eventDto(data); + if (event.kind !== 20001 && event.kind !== OBSERVER_KIND) + callbacks.receive([event]); + } else if (kind === "presence") { + const event = eventDto(data); + if (event.kind === 20001 && authors.includes(event.pubkey)) + callbacks.presence?.([event]); + } else if (kind === "presence-state") { + const state = presenceState(data); + // SSE already in transit can describe an older control's author set. + if (JSON.stringify(state.authors) === JSON.stringify(authors)) + callbacks.presenceState?.(state); + } else if (kind === "traffic") { if ( !data || typeof data !== "object" || @@ -129,10 +173,10 @@ export function subscribeBrokerTraffic( !("provenance" in data) ) throw new Error("Invalid live traffic envelope"); - callbacks.receive( - [eventDto(data.event)], - liveProvenance(data.provenance), - ); + const event = eventDto(data.event); + const provenance = liveProvenance(data.provenance); + if (event.kind !== 20001 && event.kind !== OBSERVER_KIND) + callbacks.receive([event], provenance); } else if (kind === "observer") { const record = data as { frame?: unknown; @@ -180,6 +224,7 @@ export function subscribeBrokerTraffic( retryTimer = setTimeout(start, 500 * 2 ** attempts++); } finally { if (current === generation) { + owned.abort(); streamId = undefined; receiving = false; clearTimeout(heartbeat); @@ -216,6 +261,51 @@ export function subscribeBrokerTraffic( if (sent !== JSON.stringify(priority)) sendPriority(); }); } + function schedulePresence() { + if (closed || presencePending || !streamId || presenceTimer) return; + // First dirty update fixes the deadline; scrolling only replaces authors. + presenceTimer = setTimeout( + () => { + presenceTimer = undefined; + sendPresence(); + }, + Math.max(100, presenceDue - performance.now()), + ); + } + function sendPresence() { + if (closed || !streamId || presencePending) return; + const current = generation; + const sent = JSON.stringify(authors); + presencePending = true; + presenceDue = performance.now() + 1000; + void fetch(`${endpoint}/stream-presence`, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ streamId, authors }), + signal: AbortSignal.any([ + controller?.signal ?? new AbortController().signal, + AbortSignal.timeout(5000), + ]), + }) + .then((response) => { + if (!closed && current === generation && !response.ok) + throw new Error(`Presence control failed (${response.status})`); + }) + .catch((error) => { + if (!closed && current === generation) + callbacks.presenceState?.({ + status: authors.length ? "error" : "idle", + authors: [...authors], + error: String(error), + }); + }) + .finally(() => { + if (current !== generation) return; + presencePending = false; + if (sent !== JSON.stringify(authors)) schedulePresence(); + }); + } function sendObserver() { if (closed || !streamId || observerPending) return; const current = generation; @@ -250,6 +340,59 @@ export function subscribeBrokerTraffic( } start(); return { + presence: { + update(input) { + const next = presenceAuthors(input); + if (closed || JSON.stringify(next) === JSON.stringify(authors)) return; + authors = next; + callbacks.presenceState?.({ + status: authors.length ? "pending" : "idle", + authors: [...authors], + }); + schedulePresence(); + }, + async publish(status: PresenceStatus, signal: AbortSignal) { + presenceStatus(status); + signal.throwIfAborted(); + if (closed || !streamId) throw new Error("Presence stream unavailable"); + if (publishing) + throw new Error("Presence publication already in flight"); + const current = generation; + publishing = true; + try { + const response = await fetch(`${endpoint}/stream-presence-publish`, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ streamId, status }), + signal: AbortSignal.any([ + signal, + controller?.signal ?? new AbortController().signal, + AbortSignal.timeout(11000), + ]), + }); + signal.throwIfAborted(); + if (closed || current !== generation) + throw new Error("Presence stream replaced; outcome unknown"); + if (!response.ok) + throw new Error( + `Presence publication unconfirmed (${response.status})`, + ); + const receipt: unknown = await response.json(); + signal.throwIfAborted(); + if ( + closed || + current !== generation || + !receipt || + typeof receipt !== "object" || + (receipt as { accepted?: unknown }).accepted !== true + ) + throw new Error("Invalid presence receipt or replaced stream"); + } finally { + publishing = false; + } + }, + }, observe(value) { const next = observerGeneration(value); if (closed || observer === next) return; @@ -278,6 +421,7 @@ export function subscribeBrokerTraffic( start(); return; } + schedulePresence(); // Retry a failed author control too, not just server-side routes. const current = generation; controlPending = true; void fetch(`${endpoint}/stream-retry`, { @@ -314,6 +458,7 @@ export function subscribeBrokerTraffic( controller?.abort(); clearTimeout(retryTimer); clearTimeout(heartbeat); + clearTimeout(presenceTimer); }, }; } diff --git a/src/features/relay/host-admission.test.ts b/src/features/relay/host-admission.test.ts index ebc31a8b..1eeb9525 100644 --- a/src/features/relay/host-admission.test.ts +++ b/src/features/relay/host-admission.test.ts @@ -38,3 +38,21 @@ it("asynchronous preparation pins its principal until dispatch ownership is rele await preparing; expect(owner.api.idle()).toBe(true); }); + +it("retains optional signing leases and optional clocks after the last stream closes", async () => { + vi.useFakeTimers(); + const get = createHostAdmission(); + const owner = get("relay", "viewer"); + const release = owner.live.acquirePublication(); + for (let i = 0; i < 80; i++) get(`idle-${i}`, "viewer"); + expect(get("relay", "viewer")).toBe(owner); + expect(() => owner.live.acquirePublication()).toThrow("capacity"); + owner.live.takePublish(); + release(); + release(); + get("next", "viewer"); + expect(get("relay", "viewer")).toBe(owner); + await vi.advanceTimersByTimeAsync(5000); + get("expired", "viewer"); + expect(get("relay", "viewer")).not.toBe(owner); +}); diff --git a/src/features/relay/host-admission.ts b/src/features/relay/host-admission.ts index 04d7bf6e..5ba68f94 100644 --- a/src/features/relay/host-admission.ts +++ b/src/features/relay/host-admission.ts @@ -17,7 +17,7 @@ export function createHostAdmission() { let principal = principals.get(key); if (!principal) { for (const [id, entry] of principals) - if (!entry.streams && entry.api.idle() && entry.live.delay() === 0) + if (!entry.streams && entry.api.idle() && entry.live.idle()) principals.delete(id); if (principals.size >= 64) throw new Error("Relay admission owner capacity reached"); diff --git a/src/features/relay/http-admission.test.ts b/src/features/relay/http-admission.test.ts index a3ccc27a..29863577 100644 --- a/src/features/relay/http-admission.test.ts +++ b/src/features/relay/http-admission.test.ts @@ -177,3 +177,47 @@ it("a partial quota body retains its host owner until normalization installs coo await vi.advanceTimersByTimeAsync(3000); expect(lane.idle()).toBe(true); }); + +it("optional starts neither consume nor reset ordinary pacing; both lanes retain shared cooldown", async () => { + vi.useFakeTimers(); + const lane = createApiAdmission(); + const starts: [string, number][] = []; + const run = (name: string, priority: "foreground" | "presence") => + lane.run( + async () => { + starts.push([name, performance.now()]); + }, + undefined, + priority, + ); + await run("ordinary-1", "foreground"); + await vi.advanceTimersByTimeAsync(100); + await run("presence-1", "presence"); + const ordinary = run("ordinary-2", "foreground"); + const presence = run("presence-2", "presence"); + await vi.advanceTimersByTimeAsync(400); + await ordinary; + expect(starts).toEqual([ + ["ordinary-1", 0], + ["presence-1", 100], + ["ordinary-2", 500], + ]); + const rejected = expect(presence).rejects.toBeInstanceOf(ApiPaused); + lane.pause(4000); + await rejected; + await expect(run("blocked ordinary", "foreground")).rejects.toBeInstanceOf( + ApiPaused, + ); + await expect(run("blocked presence", "presence")).rejects.toBeInstanceOf( + ApiPaused, + ); + await vi.advanceTimersByTimeAsync(4000); + await run("ordinary-3", "foreground"); + const resumed = run("presence-3", "presence"); + await vi.advanceTimersByTimeAsync(600); + await resumed; + expect(starts.slice(-2)).toEqual([ + ["ordinary-3", 4500], + ["presence-3", 5100], + ]); +}); diff --git a/src/features/relay/http-admission.ts b/src/features/relay/http-admission.ts index aa5cff5a..83dad3de 100644 --- a/src/features/relay/http-admission.ts +++ b/src/features/relay/http-admission.ts @@ -1,4 +1,5 @@ import { ReadError } from "./errors.ts"; +import { PRESENCE_WORK_INTERVAL_MS } from "./presence-contract.ts"; /** Admission owns starts, not retries or delivery semantics. An admitted write is * still sent once; a lost response is never permission to repeat it. */ @@ -103,8 +104,9 @@ export async function readApiFailure(response: Response): Promise { } } +export type ApiPriority = "foreground" | "background" | "presence"; type Ticket = { - priority: "foreground" | "background"; + priority: ApiPriority; signal?: AbortSignal | undefined; start(): void; reject(error: Error): void; @@ -115,9 +117,12 @@ type Ticket = { * queued work explicitly instead of silently spending a read's 10s deadline. */ export function createApiAdmission() { let next = 0, + nextPresence = 0, pausedUntil = 0, active = 0, - preparing = 0; + activePresence = 0, + preparing = 0, + preparingPresence = 0; let timer: ReturnType | undefined; const queue: Ticket[] = []; const pauseError = () => @@ -138,15 +143,34 @@ export function createApiAdmission() { } return; } - const wait = next - performance.now(); - if (wait > 0) { - timer = setTimeout(pump, wait); + const ordinary = + queue.find((t) => t.priority === "foreground") ?? + queue.find((t) => t.priority === "background"); + const presence = queue.find((t) => t.priority === "presence"); + const now = performance.now(); + const ticket = + ordinary && next <= now + ? ordinary + : presence && nextPresence <= now + ? presence + : undefined; + if (!ticket) { + timer = setTimeout( + pump, + Math.max( + 0, + Math.min( + ordinary ? next : Infinity, + presence ? nextPresence : Infinity, + ) - now, + ), + ); return; } - const ticket = queue.find((t) => t.priority === "foreground") ?? queue[0]; - if (!ticket) return; remove(ticket); - next = performance.now() + 500; + if (ticket.priority === "presence") + nextPresence = now + PRESENCE_WORK_INTERVAL_MS; + else next = now + 500; ticket.start(); pump(); } @@ -154,21 +178,29 @@ export function createApiAdmission() { /** Retain bounded ownership across asynchronous auth and its final dispatch. * A cancelled unabortable signer keeps its slot until it settles: repeatedly * cancelling must not admit an unbounded number of outstanding sign prompts. */ - async prepare(work: () => Promise): Promise { + async prepare( + work: () => Promise, + priority: ApiPriority = "foreground", + ): Promise { if (performance.now() < pausedUntil) throw pauseError(); - if (preparing >= 128) throw new ApiCapacity(); - preparing++; + const presence = priority === "presence"; + if (presence ? preparingPresence >= 1 : preparing >= 128) + throw new ApiCapacity(); + if (presence) preparingPresence++; + else preparing++; try { return await work(); } finally { - preparing--; + if (presence) preparingPresence--; + else preparing--; } }, idle: () => !active && !preparing && + !preparingPresence && !queue.length && - performance.now() >= Math.max(next, pausedUntil), + performance.now() >= Math.max(next, nextPresence, pausedUntil), pause(milliseconds: number) { if (!Number.isFinite(milliseconds) || milliseconds < 0) throw new Error("Invalid API pause"); @@ -185,19 +217,38 @@ export function createApiAdmission() { ): Promise { if (signal?.aborted) return Promise.reject(signal.reason); if (performance.now() < pausedUntil) return Promise.reject(pauseError()); - if (queue.length >= 128) return Promise.reject(new ApiCapacity()); + const presence = priority === "presence"; + if ( + presence + ? activePresence + + queue.filter((t) => t.priority === "presence").length >= + 1 + : queue.filter((t) => t.priority !== "presence").length >= 128 + ) + return Promise.reject(new ApiCapacity()); return new Promise((resolve, reject) => { const ticket: Ticket = { priority, signal, start() { active++; + if (presence) activePresence++; const finish = () => { active--; + if (presence) activePresence--; }; try { signal?.throwIfAborted(); - Promise.resolve(work()).then(resolve, reject).finally(finish); + Promise.resolve(work()).then( + (value) => { + finish(); + resolve(value); + }, + (error) => { + finish(); + reject(error); + }, + ); } catch (error) { finish(); reject(error); diff --git a/src/features/relay/live.test.ts b/src/features/relay/live.test.ts index 72d484ac..17496b46 100644 --- a/src/features/relay/live.test.ts +++ b/src/features/relay/live.test.ts @@ -1,5 +1,6 @@ import { assert, afterEach, expect, it, vi } from "vitest"; import { + LIVE_CHANNEL_CAPACITY, createLiveAdmission, liveChannels, subscribeRelayTraffic, @@ -193,19 +194,19 @@ it("bounds interests and exposes every omitted ID without exceeding 1024 subscri await h.first.auth(); await vi.advanceTimersByTimeAsync(750); let index = 0; - while (index < 1024) { + while (index < LIVE_CHANNEL_CAPACITY + 2) { if (index >= h.first.requests().length) await vi.advanceTimersByTimeAsync(250); const request = h.first.requests()[index++]; assert.exists(request); await h.first.receive(["EOSE", request[1]]); } - expect(h.first.requests()).toHaveLength(1024); + expect(h.first.requests()).toHaveLength(LIVE_CHANNEL_CAPACITY + 2); expect( h.callbacks.state.mock.lastCall?.[0].routes .filter((r) => r.status === "limited") .map((r) => r.channelId), - ).toEqual(ids.slice(1022)); + ).toEqual(ids.slice(LIVE_CHANNEL_CAPACITY)); expect(() => liveChannels([...ids, "excess"])).toThrow(); for (const invalid of [[""], ["a b"], ["x".repeat(129)], [9], {}]) expect(() => liveChannels(invalid)).toThrow(); @@ -631,6 +632,13 @@ it("live channel provenance excludes observer telemetry while preserving members }); await h.first.receive(["EVENT", route[1], telemetry]); expect(h.callbacks.receive).not.toHaveBeenCalled(); + const presence = signed(h.key, { + kind: 20001, + content: "online", + tags: [], + }); + await h.first.receive(["EVENT", route[1], presence]); + expect(h.callbacks.receive).not.toHaveBeenCalled(); const membership = signed(h.key, { kind: 40099, content: "{}", @@ -696,3 +704,73 @@ it("observer route is optional, live-only at dispatch/retry, separately fenced a h.owner.dispose(); expect(vi.getTimerCount()).toBe(0); }); + +it("keeps the combined 1024-wire ceiling during presence handover and observer toggles at full channel demand", async () => { + vi.useFakeTimers(); + const ids = Array.from( + { length: 1024 }, + (_, i) => `channel-${String(i).padStart(4, "0")}`, + ); + const h = setup(ids); + const active = new Set(); + const send = h.first.send.bind(h.first); + vi.spyOn(h.first, "send").mockImplementation((text) => { + const [kind, wire] = JSON.parse(text); + if (kind === "REQ") active.add(wire); + if (kind === "CLOSE") active.delete(wire); + expect(active.size).toBeLessThanOrEqual(1024); // Every intermediate wire state. + send(text); + }); + try { + await h.first.auth(); + for (let i = 0; i < 1022; i++) { + const request = h.first.requests()[i]; + assert.exists(request); + await h.first.receive(["EOSE", request[1]]); + await vi.advanceTimersByTimeAsync(250); + } + expect(active.size).toBe(1022); // 1020 channels + two globals. + const globals = h.first + .requests() + .slice(0, 2) + .map((r) => r[1]); + const presence = h.owner.presence; + assert.exists(presence); + presence.update(["a".repeat(64)]); + await vi.advanceTimersByTimeAsync(1000); + const confirmed = h.first.requests().at(-1); + assert.exists(confirmed); + expect(confirmed[2].kinds).toEqual([20001]); + await h.first.receive(["EOSE", confirmed[1]]); + presence.update(["b".repeat(64)]); + await vi.advanceTimersByTimeAsync(1000); + const candidate = h.first.requests().at(-1); + assert.exists(candidate); + expect(candidate[2].kinds).toEqual([20001]); + expect(candidate[1]).not.toBe(confirmed[1]); + expect(active.size).toBe(1024); + + for (const observer of [1, null, 2]) { + h.owner.observe?.(observer); + await vi.advanceTimersByTimeAsync(250); + expect(active.size).toBe(1024); + const snapshot = h.callbacks.state.mock.lastCall?.[0]; + assert.exists(snapshot); + expect( + snapshot.routes.filter((r) => r.channelId && r.status !== "limited"), + ).toHaveLength(observer === null ? 1020 : 1019); + expect(active.has(confirmed[1]) && active.has(candidate[1])).toBe(true); + expect(globals.every((wire) => active.has(wire))).toBe(true); + } + await h.first.receive(["EOSE", candidate[1]]); + expect(active.has(confirmed[1])).toBe(false); + expect(active.has(candidate[1])).toBe(true); + expect(active.size).toBe(1023); + expect(h.sockets).toHaveLength(1); + presence.update([]); + expect(active.size).toBe(1022); + } finally { + h.owner.dispose(); + } + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/src/features/relay/live.ts b/src/features/relay/live.ts index 8febfae2..f7babc50 100644 --- a/src/features/relay/live.ts +++ b/src/features/relay/live.ts @@ -7,7 +7,14 @@ import type { EventTemplate, VerifiedEvent } from "nostr-tools"; import { eventDto } from "./events.ts"; import { EMOJI_SET } from "./emoji.ts"; -export const LIVE_CHANNEL_CAPACITY = 1022; // Reserve two of the relay's 1024 slots. +import { createPresenceLive } from "./presence-live.ts"; +import { + PRESENCE_WORK_INTERVAL_MS, + type PresenceCapability, + type PresenceState, +} from "./presence-contract.ts"; + +export const LIVE_CHANNEL_CAPACITY = 1020; // Two globals + two presence slots; observer subtracts one more when enabled. export const LIVE_REPLAY_LIMIT = 500; const SETUP_CONCURRENCY = 4; const REQUEST_INTERVAL_MS = 250; // 4 starts/s leaves room below the reference 10/s quota. @@ -17,9 +24,44 @@ const MAX_QUOTA_RETRIES = 3; export function createLiveAdmission() { let next = 0; let cooldown = 0; + let nextPresence = 0, + nextPublish = 0; + let publishing = false; return { + idle: () => + !publishing && + performance.now() >= Math.max(next, nextPresence, nextPublish, cooldown), + /** Pins the host principal across signing, dispatch and receipt, even after cancellation. */ + acquirePublication() { + if (publishing) throw new Error("Presence publication capacity reached"); + publishing = true; + let released = false; + return () => { + if (released) return; + released = true; + publishing = false; + }; + }, delay: () => Math.max(0, next - performance.now(), cooldown - performance.now()), + presenceDelay: () => + Math.max( + 0, + nextPresence - performance.now(), + cooldown - performance.now(), + ), + publishDelay: () => + Math.max( + 0, + nextPublish - performance.now(), + cooldown - performance.now(), + ), + takePresence() { + nextPresence = performance.now() + 1000; + }, + takePublish() { + nextPublish = performance.now() + PRESENCE_WORK_INTERVAL_MS; + }, take() { next = performance.now() + REQUEST_INTERVAL_MS; }, @@ -70,6 +112,8 @@ export function liveProvenance(value: unknown): LiveProvenance { export type LiveCallbacks = { /** Legacy/missing provenance reconciles quietly; it is never implicitly fresh. */ receive(events: readonly VerifiedEvent[], provenance?: LiveProvenance): void; + presence?(events: readonly VerifiedEvent[]): void; + presenceState?(state: PresenceState): void; /** Host-only encrypted telemetry route; never ordinary history reconciliation. */ telemetry?(event: VerifiedEvent, generation: number): void; /** Decoded host DTO on the browser transport. */ @@ -79,6 +123,7 @@ export type LiveCallbacks = { denied(channelId: string, reason: string): void; }; export type LiveSubscription = { + presence?: PresenceCapability; update(channels: readonly string[]): void; /** Host demand only: reorder existing pending routes, never grant new interests. */ prioritize?(channels: readonly string[]): void; @@ -163,6 +208,34 @@ export function subscribeRelayTraffic( const send = (value: unknown) => { if (!closed && socket?.readyState === 1) socket.send(JSON.stringify(value)); }; + function cooldown(reason: string): boolean | undefined { + if (!reason.startsWith("rate-limited:")) return undefined; + const hint = /^rate-limited: quota exceeded; retry in (\d+)s$/.exec(reason); + const seconds = hint ? Number(hint[1]) : 5; + const supported = Number.isSafeInteger(seconds) && seconds <= 60; + admission.pause( + Number.isSafeInteger(seconds) && seconds <= 86400 ? seconds : 86400, + ); + if (!supported) { + for (const queued of routes.values()) + if (queued.status === "pending" && !queued.wire) { + queued.status = "error"; + queued.error = "Unsupported live cooldown; automatic setup stopped"; + } + notify(); + } + return supported; + } + const presence = createPresenceLive({ + sign, + viewer, + callbacks, + admission, + send, + connected: () => !closed && authenticated, + wake: pump, + cooldown, + }); function remove(route: Route) { clearTimeout(route.deadline); if (route.wire) { @@ -230,33 +303,15 @@ export function subscribeRelayTraffic( delete route.wire; route.status = "error"; route.error = reason; - if (reason.startsWith("rate-limited:")) { - const hint = /^rate-limited: quota exceeded; retry in (\d+)s$/.exec( - reason, - ); - const seconds = hint ? Number(hint[1]) : 5; - if (!Number.isSafeInteger(seconds) || seconds > 60) { + if (cooldown(reason) === true) { + if (++route.quotaRetries <= MAX_QUOTA_RETRIES) route.status = "pending"; + else { for (const queued of routes.values()) if (queued.status === "pending" && !queued.wire) { queued.status = "error"; - queued.error = "Unsupported live cooldown; automatic setup stopped"; + queued.error = + "Live request cooldown retries exhausted; retry available"; } - // Conservative shared pause survives replacement; never overflow a timer. - admission.pause( - Number.isSafeInteger(seconds) && seconds <= 86400 ? seconds : 86400, - ); - } else { - admission.pause(seconds); - if (++route.quotaRetries <= MAX_QUOTA_RETRIES) route.status = "pending"; - else { - // Stop the unsent queue too: rejection must never drain it into an exhausted budget. - for (const queued of routes.values()) - if (queued.status === "pending" && !queued.wire) { - queued.status = "error"; - queued.error = - "Live request cooldown retries exhausted; retry available"; - } - } } } notify(); @@ -270,6 +325,12 @@ export function subscribeRelayTraffic( let active = [...routes.values()].filter( (route) => route.wire && route.status === "pending", ).length; + const foreground = [...routes.values()].some( + (route) => + route.status === "pending" && + (!route.channelId || priority.includes(route.channelId)), + ); + presence.dispatch(foreground || active >= SETUP_CONCURRENCY); const rank = (route: Route) => !route.channelId ? -2 @@ -340,6 +401,7 @@ export function subscribeRelayTraffic( function clearSocket() { generation++; authenticated = false; + presence.reset("Presence socket disconnected; outcome unknown"); clearTimeout(dispatchTimer); clearTimeout(deadline); for (const route of routes.values()) clearTimeout(route.deadline); @@ -447,6 +509,7 @@ export function subscribeRelayTraffic( notify(); return; } + presence.message(data); const route = typeof data[1] === "string" ? wires.get(data[1]) : undefined; if (!authenticated || !route) return; @@ -466,7 +529,7 @@ export function subscribeRelayTraffic( incoming.created_at >= route.since ) callbacks.telemetry?.(incoming, observer); - } else if (incoming.kind !== OBSERVER_KIND) + } else if (incoming.kind !== OBSERVER_KIND && incoming.kind !== 20001) callbacks.receive( [incoming], Object.freeze({ @@ -497,6 +560,7 @@ export function subscribeRelayTraffic( } connect(); return { + presence: presence.capability, observe(value) { const next = observerGeneration(value); if (closed || observer === next) return; @@ -520,6 +584,7 @@ export function subscribeRelayTraffic( if (closed) return; clearTimeout(retryTimer); attempts = 0; + presence.retry(); // Explicit Retry resets exhaustion even before reauthentication. if (authenticated) { for (const route of routes.values()) { if (route.status !== "error") continue; @@ -533,6 +598,7 @@ export function subscribeRelayTraffic( }, dispose() { if (closed) return; + presence.dispose(); closed = true; clearTimeout(retryTimer); clearSocket(); diff --git a/src/features/relay/presence-contract.test.ts b/src/features/relay/presence-contract.test.ts new file mode 100644 index 00000000..8df4135c --- /dev/null +++ b/src/features/relay/presence-contract.test.ts @@ -0,0 +1,36 @@ +import { expect, it } from "vitest"; +import { isPresenceSnapshot } from "./presence-contract"; +const author = "a".repeat(64); +const valid = { kinds: [20001], authors: [author], limit: 1 }; +it("classifies only the exact finite, unique full-author presence snapshot", () => { + expect(isPresenceSnapshot([valid])).toBe(true); + const authors = Array.from({ length: 256 }, (_, i) => + i.toString(16).padStart(64, "0"), + ); + expect(isPresenceSnapshot([{ kinds: [20001], authors, limit: 256 }])).toBe( + true, + ); + for (const value of [ + null, + {}, + [], + [valid, valid], + [null], + [[valid]], + [{ ...valid, kinds: [20001, 9] }], + [{ ...valid, kinds: [20001, 20001] }], + [{ ...valid, authors: [] }], + [{ ...valid, authors: [author, author], limit: 2 }], + [{ ...valid, authors: ["a"] }], + [{ ...valid, authors: [author.toUpperCase()] }], + [{ ...valid, authors: Array(1) }], + [{ ...valid, authors: [...authors, "f".repeat(64)], limit: 257 }], + [{ ...valid, limit: 0 }], + [{ ...valid, limit: 2 }], + [{ ...valid, limit: "1" }], + [{ ...valid, since: 0 }], + [{ ...valid, since: undefined }], + [Object.assign(Object.create(valid), { a: 1, b: 2, c: 3 })], + ]) + expect(isPresenceSnapshot(value), JSON.stringify(value)).toBe(false); +}); diff --git a/src/features/relay/presence-contract.ts b/src/features/relay/presence-contract.ts new file mode 100644 index 00000000..2042369c --- /dev/null +++ b/src/features/relay/presence-contract.ts @@ -0,0 +1,77 @@ +/** Ephemeral kind 20001, never a durable outbox operation. + * Publish bare online/away content and no tags. Reads also recognize offline and + * legacy JSON {"status":"online"|"away"|"offline"}; other values are unknown. + * Live subject = verified author. Only HTTP snapshots use relay-signed p subjects. + */ +export type PresenceStatus = "online" | "away"; +export type PresenceState = Readonly<{ + status: "idle" | "pending" | "ready" | "error"; + /** Confirmed authors when ready; desired authors otherwise. EOSE is not a seed. */ + authors: readonly string[]; + error?: string; +}>; +export type PresenceCapability = { + update(authors: readonly string[]): void; + /** Resolves only for matching WS OK. Failure/abort is not evidence of Offline. + * One in flight; callers coalesce status changes. No transport heartbeat replay. */ + publish(status: PresenceStatus, signal: AbortSignal): Promise; +}; +export const PRESENCE_AUTHOR_CAPACITY = 256; +export const PRESENCE_WORK_INTERVAL_MS = 5000; +/** Only this finite snapshot can use optional capacity. Priority is not a bypass. */ +export function isPresenceSnapshot(input: unknown): boolean { + if (!Array.isArray(input) || input.length !== 1) return false; + const filter = input[0]; + if (!filter || typeof filter !== "object" || Array.isArray(filter)) + return false; + const { kinds, authors, limit } = filter; + return ( + Object.keys(filter).length === 3 && + Object.keys(filter).every((key) => + ["kinds", "authors", "limit"].includes(key), + ) && + Array.isArray(kinds) && + kinds.length === 1 && + kinds[0] === 20001 && + Array.isArray(authors) && + authors.length > 0 && + authors.length <= PRESENCE_AUTHOR_CAPACITY && + Array.from(authors).every( + (id: unknown) => typeof id === "string" && /^[0-9a-f]{64}$/.test(id), + ) && + new Set(authors).size === authors.length && + limit === authors.length + ); +} +export function presenceAuthors(input: unknown): string[] { + if ( + !Array.isArray(input) || + input.length > PRESENCE_AUTHOR_CAPACITY || + input.some((id) => typeof id !== "string" || !/^[0-9a-f]{64}$/.test(id)) + ) + throw new Error("Invalid presence authors (maximum 256 full public keys)"); + return [...new Set(input as string[])].sort(); +} +export function presenceStatus(input: unknown): PresenceStatus { + if (input !== "online" && input !== "away") + throw new Error("Invalid presence publication status"); + return input; +} +export function presenceState(input: unknown): PresenceState { + if (!input || typeof input !== "object") + throw new Error("Invalid presence state"); + const value = input as PresenceState; + if ( + !["idle", "pending", "ready", "error"].includes(value.status) || + (value.error !== undefined && typeof value.error !== "string") + ) + throw new Error("Invalid presence state"); + const authors = presenceAuthors(value.authors); + if ((value.status === "idle") !== (authors.length === 0)) + throw new Error("Invalid presence state authors"); + return Object.freeze({ + status: value.status, + authors: Object.freeze(authors), + ...(value.error ? { error: value.error } : {}), + }); +} diff --git a/src/features/relay/presence-live.test.ts b/src/features/relay/presence-live.test.ts new file mode 100644 index 00000000..ad7845f5 --- /dev/null +++ b/src/features/relay/presence-live.test.ts @@ -0,0 +1,695 @@ +import { afterEach, assert, expect, it, vi } from "vitest"; +import type { EventTemplate, VerifiedEvent } from "nostr-tools"; +import { + createLiveAdmission, + LIVE_CHANNEL_CAPACITY, + subscribeRelayTraffic, + type LiveCallbacks, +} from "./live"; +import { connectSignedTransport } from "./transport"; +import { presenceAuthors } from "./presence-contract"; +import { PresenceOwnerDisposed } from "./presence-live"; +import { keypair, signed } from "./testing"; + +class Socket { + readyState = 1; + sent: unknown[][] = []; + times: number[] = []; + onmessage?: (event: { data: string }) => Promise; + onclose?: () => void; + send(text: string) { + this.sent.push(JSON.parse(text)); + this.times.push(performance.now()); + } + close() { + this.readyState = 3; + this.onclose?.(); + } + async receive(frame: unknown[]) { + await this.onmessage?.({ data: JSON.stringify(frame) }); + } + requests(presence = false) { + return this.sent.filter( + (f) => + f[0] === "REQ" && + ((f[2] as { kinds: number[] }).kinds[0] === 20001) === presence, + ) as [ + string, + string, + { kinds: number[]; authors?: string[]; limit: number; "#h"?: string[] }, + ][]; + } + events() { + return this.sent + .filter((f) => f[0] === "EVENT") + .map((f) => f[1] as VerifiedEvent); + } + async auth() { + await this.receive(["AUTH", "test"]); + const event = this.sent.find((f) => f[0] === "AUTH")?.[1] as VerifiedEvent; + await this.receive(["OK", event.id, true]); + } + async globals() { + await this.auth(); + for (let i = 0; i < 2; i++) { + const request = this.requests()[i]; + assert.exists(request); + await this.receive(["EOSE", request[1]]); + await vi.advanceTimersByTimeAsync(250); + } + } +} +const author = (n: number) => n.toString(16).padStart(64, "0"); +const signal = () => new AbortController().signal; +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); +function setup( + signOverride?: (event: EventTemplate) => Promise, + admission = createLiveAdmission(), +) { + vi.useFakeTimers(); + const key = keypair(); + const sockets: Socket[] = []; + const callbacks = { + receive: vi.fn(), + established: vi.fn(), + denied: vi.fn(), + state: vi.fn(), + presence: vi.fn(), + presenceState: vi.fn(), + } satisfies LiveCallbacks; + const sign = vi.fn( + signOverride ?? (async (event: EventTemplate) => signed(key, event)), + ); + const owner = subscribeRelayTraffic( + "wss://test.invalid", + sign, + key.pubkey, + callbacks, + () => { + const socket = new Socket(); + sockets.push(socket); + return socket as unknown as WebSocket; + }, + admission, + ); + const socket = sockets[0]; + assert.exists(socket); + const presence = owner.presence; + assert.exists(presence); + return { key, sockets, socket, owner, presence, callbacks, sign, admission }; +} + +it("zero demand opens no presence route; production update is bounded, explicit and separate from ordinary establishment/receive", async () => { + const h = setup(); + try { + await h.socket.globals(); + await vi.advanceTimersByTimeAsync(60000); + expect(h.socket.requests(true)).toHaveLength(0); + expect(h.sign).toHaveBeenCalledTimes(1); // AUTH, no implicit heartbeat. + const person = keypair(); + for (let i = 0; i < 1000; i++) h.presence.update([person.pubkey]); + await vi.advanceTimersByTimeAsync(100); + const route = h.socket.requests(true)[0]; + assert.exists(route); + expect(route[2]).toEqual({ + kinds: [20001], + authors: [person.pubkey], + limit: 0, + }); + const event = signed(person, { kind: 20001, content: "online", tags: [] }); + await h.socket.receive(["EVENT", route[1], event]); + await h.socket.receive(["EOSE", route[1]]); + expect(h.callbacks.presence).toHaveBeenCalledWith([event]); + expect(h.callbacks.presenceState).toHaveBeenLastCalledWith({ + status: "ready", + authors: [person.pubkey], + }); + expect(h.callbacks.established).toHaveBeenCalledTimes(2); + expect(h.callbacks.receive).not.toHaveBeenCalled(); + // Misrouted ephemeral events can never enter generic acceptance either. + await h.socket.receive(["EVENT", h.socket.requests()[0]?.[1], event]); + expect(h.callbacks.receive).not.toHaveBeenCalled(); + h.presence.update([]); + await h.socket.receive(["EVENT", route[1], event]); + expect(h.callbacks.presence).toHaveBeenCalledTimes(1); + expect(h.callbacks.presenceState).toHaveBeenLastCalledWith({ + status: "idle", + authors: [], + }); + for (const value of [ + [""], + ["a"], + [author(1).toUpperCase().replace("1", "A")], + Array(257).fill(author(1)), + {}, + ]) + expect(() => presenceAuthors(value)).toThrow(); + } finally { + h.owner.dispose(); + } + expect(vi.getTimerCount()).toBe(0); +}); + +it("continuous scrolling makes progress at <=1 start/sec, retains only confirmed+candidate and fences obsolete EOSE", async () => { + const h = setup(); + try { + await h.socket.globals(); + h.presence.update([author(1)]); + await vi.advanceTimersByTimeAsync(100); + const first = h.socket.requests(true)[0]; + assert.exists(first); + await h.socket.receive(["EOSE", first[1]]); + for (let i = 2; i <= 50; i++) { + h.presence.update([author(i)]); + await vi.advanceTimersByTimeAsync(50); + } + expect(h.socket.requests(true)).toHaveLength(2); // One candidate, not 49 cancellations. + const candidate = h.socket.requests(true)[1]; + assert.exists(candidate); + expect(h.socket.sent).not.toContainEqual(["CLOSE", first[1]]); + await h.socket.receive(["EOSE", candidate[1]]); + expect(h.socket.sent).toContainEqual(["CLOSE", candidate[1]]); + expect(h.socket.sent).not.toContainEqual(["CLOSE", first[1]]); + await vi.advanceTimersByTimeAsync(100); + const latest = h.socket.requests(true)[2]; + assert.exists(latest); + expect(latest[2].authors).toEqual([author(50)]); + await h.socket.receive(["EOSE", latest[1]]); + expect(h.socket.sent).toContainEqual(["CLOSE", first[1]]); + const count = h.callbacks.presenceState.mock.calls.length; + await h.socket.receive(["EOSE", candidate[1]]); + expect(h.callbacks.presenceState).toHaveBeenCalledTimes(count); + let active = 0, + max = 0; + for (const frame of h.socket.sent) { + if (frame[0] === "REQ" && String(frame[1]).startsWith("presence-")) + max = Math.max(max, ++active); + if (frame[0] === "CLOSE" && String(frame[1]).startsWith("presence-")) + active--; + } + const starts = h.socket.sent.flatMap((frame, i) => + frame[0] === "REQ" && String(frame[1]).startsWith("presence-") + ? [h.socket.times[i] as number] + : [], + ); + for (let i = 1; i < starts.length; i++) + expect( + (starts[i] as number) - (starts[i - 1] as number), + ).toBeGreaterThanOrEqual(1000); + expect(max).toBe(2); + expect(active).toBe(1); + } finally { + h.owner.dispose(); + } +}); + +it("failed candidates preserve confirmed routes and retries stay capped even as desired authors change", async () => { + const h = setup(); + try { + await h.socket.globals(); + h.presence.update([author(1)]); + await vi.advanceTimersByTimeAsync(100); + const first = h.socket.requests(true)[0]; + assert.exists(first); + await h.socket.receive(["EOSE", first[1]]); + h.presence.update([author(2)]); + for (let i = 0; i < 4; i++) { + await vi.advanceTimersByTimeAsync(8000); + const route = h.socket.requests(true).at(-1); + assert.exists(route); + expect(route[1]).not.toBe(first[1]); + await h.socket.receive([ + "CLOSED", + route[1], + "temporary: presence unavailable", + ]); + h.presence.update([author(i + 3)]); + } + const count = h.socket.requests(true).length; + await vi.advanceTimersByTimeAsync(120000); + expect(h.socket.requests(true)).toHaveLength(count); + expect(count).toBe(5); + expect(h.socket.sent).not.toContainEqual(["CLOSE", first[1]]); + h.owner.retry(); + await vi.advanceTimersByTimeAsync(100); + expect(h.socket.requests(true)).toHaveLength(count + 1); + } finally { + h.owner.dispose(); + } +}); + +it("channel opening wins over presence and all routes fit the reserved 1024 slots", async () => { + const h = setup(); + try { + const ids = Array.from({ length: 1024 }, (_, i) => `c-${i}`); + h.owner.update(ids); + h.owner.prioritize?.(["c-999"]); + h.presence.update([author(1)]); + await h.socket.auth(); + for (let i = 0; i < 3; i++) { + const request = h.socket.requests()[i]; + assert.exists(request); + await h.socket.receive(["EOSE", request[1]]); + await vi.advanceTimersByTimeAsync(250); + } + expect(h.socket.requests()[2]?.[2]["#h"]).toEqual(["c-999"]); + const p = h.socket.requests(true)[0]; + assert.exists(p); + await h.socket.receive(["EOSE", p[1]]); + let index = 3; + while (index < LIVE_CHANNEL_CAPACITY + 2) { + if (index >= h.socket.requests().length) + await vi.advanceTimersByTimeAsync(250); + const request = h.socket.requests()[index++]; + assert.exists(request); + await h.socket.receive(["EOSE", request[1]]); + } + h.presence.update([author(2)]); + await vi.advanceTimersByTimeAsync(1000); + expect(h.socket.requests().length + h.socket.requests(true).length).toBe( + 1024, + ); + expect( + h.callbacks.state.mock.lastCall?.[0].routes.filter( + (r: { status: string }) => r.status === "limited", + ), + ).toHaveLength(4); + } finally { + h.owner.dispose(); + } +}); + +it("publication signs only after authenticated foreground admission; matching OK alone resolves and no echo/read is needed", async () => { + const h = setup(); + try { + await expect(h.presence.publish("online", signal())).rejects.toThrow( + "authenticated", + ); + await h.socket.auth(); + const operation = h.presence.publish("away", signal()); + const done = vi.fn(); + void operation.then(done); + await vi.advanceTimersByTimeAsync(250); + expect(h.sign).toHaveBeenCalledTimes(1); // globals await EOSE + for (const route of h.socket.requests()) + await h.socket.receive(["EOSE", route[1]]); + await vi.advanceTimersByTimeAsync(250); + const event = h.socket.events()[0]; + assert.exists(event); + expect(event).toMatchObject({ + kind: 20001, + content: "away", + tags: [], + pubkey: h.key.pubkey, + }); + await h.socket.receive(["OK", "wrong", true]); + expect(done).not.toHaveBeenCalled(); + await expect(h.presence.publish("online", signal())).rejects.toThrow( + "in flight", + ); + await h.socket.receive(["OK", event.id, true]); + await operation; + expect(done).toHaveBeenCalledOnce(); + expect(h.socket.events()).toHaveLength(1); + await vi.advanceTimersByTimeAsync(180000); + expect(h.socket.events()).toHaveLength(1); + } finally { + h.owner.dispose(); + } +}); + +it("publication rejection shares cooldown with channel setup and cannot create automatic retries", async () => { + const h = setup(); + try { + await h.socket.globals(); + const promise = h.presence.publish("online", signal()); + const failure = expect(promise).rejects.toThrow("rate-limited"); + await vi.advanceTimersByTimeAsync(0); + const event = h.socket.events()[0]; + assert.exists(event); + await h.socket.receive([ + "OK", + event.id, + false, + "rate-limited: quota exceeded; retry in 2s", + ]); + await failure; + h.owner.update(["a"]); + await vi.advanceTimersByTimeAsync(2999); + expect(h.socket.requests()).toHaveLength(2); + await vi.advanceTimersByTimeAsync(1); + expect(h.socket.requests()).toHaveLength(3); + expect(h.socket.events()).toHaveLength(1); + } finally { + h.owner.dispose(); + } +}); + +it.each(["abort", "disconnect", "dispose", "timeout"])( + "%s fences a pending signer and leaves no heartbeat replay", + async (finish) => { + let release!: (value: VerifiedEvent) => void; + const key = keypair(); + let template!: EventTemplate; + // Match the viewer returned by setup by replacing only the publication signing phase. + const h = setup(); + h.sign.mockImplementation(async (event) => { + if (event.kind !== 20001) return signed(h.key, event); + template = event; + return new Promise((resolve) => { + release = resolve; + }); + }); + try { + await h.socket.globals(); + const controller = new AbortController(); + const promise = h.presence.publish("online", controller.signal); + const outcome = promise.catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(0); + if (finish === "abort") controller.abort(); + if (finish === "disconnect") h.socket.close(); + if (finish === "dispose") h.owner.dispose(); + if (finish === "timeout") await vi.advanceTimersByTimeAsync(10000); + const error = await outcome; + expect(error).toBeInstanceOf(Error); + expect(error instanceof PresenceOwnerDisposed).toBe(finish === "dispose"); + release(signed(key, template)); + await vi.advanceTimersByTimeAsync(0); + expect(h.socket.events()).toHaveLength(0); + } finally { + h.owner.dispose(); + } + expect(vi.getTimerCount()).toBe(0); + }, +); + +it.each(["rejection", "deadline", "reset", "local", "abort"])( + "%s winning settlement cannot become owner disposal later", + async (first) => { + const h = setup(); + try { + await h.socket.globals(); + const local = new Error("Presence owner disposed"); + if (first === "local") h.sign.mockRejectedValueOnce(local); + const controller = new AbortController(); + const outcome = h.presence + .publish("online", controller.signal) + .catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(0); + if (first === "rejection") { + const event = h.socket.events()[0]; + assert.exists(event); + await h.socket.receive([ + "OK", + event.id, + false, + "Presence owner disposed", + ]); + } else if (first === "deadline") await vi.advanceTimersByTimeAsync(10000); + else if (first === "reset") h.socket.close(); + else if (first === "abort") controller.abort(); + h.owner.dispose(); + const error = await outcome; + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(PresenceOwnerDisposed); + if (first === "local") expect(error).toBe(local); + } finally { + h.owner.dispose(); + } + expect(vi.getTimerCount()).toBe(0); + }, +); + +it("a signing delay cannot bypass a newly learned shared cooldown", async () => { + const h = setup(); + let release!: (value: VerifiedEvent) => void; + let template!: EventTemplate; + try { + await h.socket.globals(); + h.sign.mockImplementation((event) => { + template = event; + return new Promise((resolve) => { + release = resolve; + }); + }); + const promise = h.presence.publish("online", signal()); + h.admission.pause(1); + release(signed(h.key, template)); + await vi.advanceTimersByTimeAsync(1999); + expect(h.socket.events()).toHaveLength(0); + await vi.advanceTimersByTimeAsync(1); + const event = h.socket.events()[0]; + assert.exists(event); + await h.socket.receive(["OK", event.id, true]); + await promise; + } finally { + h.owner.dispose(); + } +}); + +it("the signed transport exposes the actual same-socket presence capability, never HTTP publication", async () => { + vi.useFakeTimers(); + const key = keypair(); + const sockets: Socket[] = []; + vi.stubGlobal( + "WebSocket", + class extends Socket { + constructor() { + super(); + sockets.push(this); + } + }, + ); + const fetcher = vi.fn(); + vi.stubGlobal("fetch", fetcher); + const transport = await connectSignedTransport( + { + getPublicKey: async () => key.pubkey, + signEvent: async (event) => signed(key, event), + }, + "https://presence-fixture.invalid", + key.pubkey, + ); + const owner = transport.subscribe?.({ + receive() {}, + established() {}, + state() {}, + denied() {}, + }); + assert.exists(owner); + try { + const socket = sockets[0]; + assert.exists(socket); + await socket.globals(); + const operation = owner.presence?.publish("online", signal()); + await vi.advanceTimersByTimeAsync(0); + const event = socket.events()[0]; + assert.exists(event); + await socket.receive(["OK", event.id, true]); + await operation; + expect(sockets).toHaveLength(1); + expect(fetcher).not.toHaveBeenCalled(); + } finally { + owner.dispose(); + } +}); + +it("presence CLOSED honors shared cooldown and setup timeout advances only the latest candidate", async () => { + const h = setup(); + try { + await h.socket.globals(); + h.presence.update([author(1)]); + await vi.advanceTimersByTimeAsync(100); + const first = h.socket.requests(true)[0]; + assert.exists(first); + await h.socket.receive([ + "CLOSED", + first[1], + "rate-limited: quota exceeded; retry in 2s", + ]); + h.owner.update(["a"]); + h.owner.prioritize?.(["a"]); + h.presence.update([author(2)]); + await vi.advanceTimersByTimeAsync(2999); + expect(h.socket.requests()).toHaveLength(2); + expect(h.socket.requests(true)).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + const channel = h.socket.requests()[2]; + assert.exists(channel); + await h.socket.receive(["EOSE", channel[1]]); + await vi.advanceTimersByTimeAsync(250); + const candidate = h.socket.requests(true)[1]; + assert.exists(candidate); + expect(candidate[2].authors).toEqual([author(2)]); + h.presence.update([author(3)]); + await vi.advanceTimersByTimeAsync(12000); + const replacement = h.socket.requests(true)[2]; + assert.exists(replacement); + expect(replacement[2].authors).toEqual([author(3)]); + expect(h.socket.sent).toContainEqual(["CLOSE", candidate[1]]); + expect(h.callbacks.established).toHaveBeenCalledTimes(3); + } finally { + h.owner.dispose(); + } +}); + +it("lost WS OK is an unknown outcome, never an automatic resend or acceptance of late receipts", async () => { + const h = setup(); + try { + await h.socket.globals(); + const operation = h.presence.publish("online", signal()); + const failed = expect(operation).rejects.toThrow("unknown"); + await vi.advanceTimersByTimeAsync(0); + const event = h.socket.events()[0]; + assert.exists(event); + await vi.advanceTimersByTimeAsync(10000); + await failed; + await h.socket.receive(["OK", event.id, true]); + await vi.advanceTimersByTimeAsync(120000); + expect(h.socket.events()).toHaveLength(1); + expect(h.callbacks.receive).not.toHaveBeenCalled(); + } finally { + h.owner.dispose(); + } +}); + +it("a held presence candidate leaves four ordinary setups available with untouched 250ms pacing", async () => { + const h = setup(); + try { + await h.socket.globals(); + h.presence.update([author(1)]); + await vi.advanceTimersByTimeAsync(100); + expect(h.socket.requests(true)).toHaveLength(1); // Keep EOSE held throughout. + await vi.advanceTimersByTimeAsync(1); + const started = performance.now(); + h.owner.update(["a", "b", "c", "d", "e"]); + h.owner.prioritize?.(["a", "b", "c", "d", "e"]); + await vi.advanceTimersByTimeAsync(750); + const channels = h.socket.sent.flatMap((f, i) => + f[0] === "REQ" && (f[2] as { "#h"?: string[] })["#h"] + ? [(h.socket.times[i] as number) - started] + : [], + ); + expect(channels).toEqual([0, 250, 500, 750]); + await vi.advanceTimersByTimeAsync(250); + expect(h.socket.requests()).toHaveLength(6); // 2 globals + 4 held channels. + await h.socket.receive(["EOSE", h.socket.requests()[2]?.[1]]); + expect(h.socket.requests()).toHaveLength(7); + } finally { + h.owner.dispose(); + } +}); + +it("presence publication does not charge or reset the ordinary dispatch clock", async () => { + const h = setup(); + try { + await h.socket.globals(); + h.owner.update(["a"]); + await h.socket.receive(["EOSE", h.socket.requests()[2]?.[1]]); + const started = performance.now(); + await vi.advanceTimersByTimeAsync(100); + const p = h.presence.publish("online", signal()); + await vi.advanceTimersByTimeAsync(0); + const event = h.socket.events()[0]; + assert.exists(event); + h.owner.update(["a", "b"]); + await vi.advanceTimersByTimeAsync(149); + expect(h.socket.requests()).toHaveLength(3); + await vi.advanceTimersByTimeAsync(1); + expect(h.socket.requests()).toHaveLength(4); + expect((h.socket.times.at(-1) as number) - started).toBe(250); + await h.socket.receive(["OK", event.id, true]); + await p; + } finally { + h.owner.dispose(); + } +}); + +it("cancelled unabortable publication signing stays capped across shared streams without blocking ordinary AUTH/setup", async () => { + const h = setup(); + const other = setup(undefined, h.admission); + let release!: (value: VerifiedEvent) => void; + let template!: EventTemplate; + try { + await h.socket.globals(); + h.sign.mockImplementation(async (event) => { + template = event; + return new Promise((resolve) => { + release = resolve; + }); + }); + const c = new AbortController(); + const p = h.presence.publish("online", c.signal); + const failed = expect(p).rejects.toThrow(); + c.abort(); + await failed; + await other.socket.globals(); // The optional lease cannot serialize ordinary signing. + for (let i = 0; i < 5; i++) + await expect(other.presence.publish("online", signal())).rejects.toThrow( + "capacity", + ); + expect(h.sign).toHaveBeenCalledTimes(2); // AUTH + exactly one unresolved heartbeat. + expect(other.sign).toHaveBeenCalledTimes(1); + other.owner.update(["foreground"]); + expect(other.socket.requests()).toHaveLength(3); + release(signed(h.key, template)); + await vi.advanceTimersByTimeAsync(0); + expect(h.socket.events()).toHaveLength(0); + await other.socket.receive(["EOSE", other.socket.requests()[2]?.[1]]); + const retry = other.presence.publish("online", signal()); + await vi.advanceTimersByTimeAsync(0); + const event = other.socket.events()[0]; + assert.exists(event); + await other.socket.receive(["OK", event.id, true]); + await retry; + } finally { + h.owner.dispose(); + other.owner.dispose(); + } +}); + +it.each([false, true])( + "exhausted presence survives automatic reconnect, but one disconnected explicit Retry recovers (explicit %s)", + async (explicit) => { + const h = setup(); + try { + await h.socket.globals(); + h.presence.update([author(1)]); + for (let i = 0; i < 4; i++) { + await vi.advanceTimersByTimeAsync(8000); + const route = h.socket.requests(true).at(-1); + assert.exists(route); + await h.socket.receive(["CLOSED", route[1], "temporary: unavailable"]); + } + expect(h.socket.requests(true)).toHaveLength(4); + await vi.advanceTimersByTimeAsync(120000); + expect(h.socket.requests(true)).toHaveLength(4); + h.socket.close(); + if (explicit) h.owner.retry(); + else await vi.advanceTimersByTimeAsync(500); + expect(h.sockets).toHaveLength(2); + const replacement = h.sockets[1]; + assert.exists(replacement); + await replacement.globals(); + await vi.advanceTimersByTimeAsync(100); + if (explicit) { + expect(replacement.requests(true)).toHaveLength(1); + const route = replacement.requests(true)[0]; + assert.exists(route); + await replacement.receive(["EOSE", route[1]]); + expect(h.callbacks.presenceState).toHaveBeenLastCalledWith({ + status: "ready", + authors: [author(1)], + }); + } else { + await vi.advanceTimersByTimeAsync(120000); + expect(replacement.requests(true)).toHaveLength(0); + } + } finally { + h.owner.dispose(); + } + }, +); diff --git a/src/features/relay/presence-live.ts b/src/features/relay/presence-live.ts new file mode 100644 index 00000000..af17b01c --- /dev/null +++ b/src/features/relay/presence-live.ts @@ -0,0 +1,322 @@ +import type { EventTemplate, VerifiedEvent } from "nostr-tools"; +import { eventDto } from "./events.ts"; +import type { LiveAdmission, LiveCallbacks } from "./live.ts"; +import { + presenceAuthors, + presenceStatus, + type PresenceState, + type PresenceStatus, +} from "./presence-contract.ts"; + +export class PresenceOwnerDisposed extends Error { + constructor() { + super("Presence owner disposed"); + this.name = "PresenceOwnerDisposed"; + } +} + +type Route = { + wire: string; + authors: string[]; + deadline?: ReturnType; +}; +type Publication = { + status: PresenceStatus; + event?: VerifiedEvent; + signing: boolean; + sent: boolean; + release?: () => void; + finish(error?: unknown): void; +}; +const same = (a: readonly string[], b: readonly string[]) => + a.length === b.length && a.every((id, i) => id === b[i]); +/** Socket-owned ephemeral lane. No reconnect, snapshots, renewal clock or event history. + * The ordinary route pump calls dispatch only after foreground setup has yielded. */ +export function createPresenceLive(host: { + sign(event: EventTemplate): Promise; + viewer: string; + callbacks: LiveCallbacks; + admission: LiveAdmission; + connected(): boolean; + send(frame: unknown): void; + wake(): void; + cooldown(reason: string): boolean | undefined; +}) { + let closed = false, + serial = 0, + failures = 0; + let desired: string[] = []; + let confirmed: Route | undefined, candidate: Route | undefined; + let timer: ReturnType | undefined; + let due = 0; + let error: string | undefined; + let lastState = ""; + let publication: Publication | undefined; + function notify() { + if (closed) return; + const ready = confirmed && same(confirmed.authors, desired); + const state: PresenceState = Object.freeze({ + status: !desired.length + ? "idle" + : ready + ? "ready" + : error + ? "error" + : "pending", + authors: Object.freeze([...desired]), + ...(!ready && desired.length && error ? { error } : {}), + }); + const key = JSON.stringify(state); + if (key === lastState) return; + lastState = key; + host.callbacks.presenceState?.(state); + } + function remove(route: Route | undefined) { + if (!route) return; + // Fence first: even a reentrant transport cannot deliver after CLOSE. + if (confirmed === route) confirmed = undefined; + if (candidate === route) candidate = undefined; + clearTimeout(route.deadline); + host.send(["CLOSE", route.wire]); + } + function schedule() { + if (!due) due = performance.now() + 100; + notify(); + host.wake(); + } + function failed(route: Route, reason: string) { + remove(route); + error = reason; + const retryable = host.cooldown(reason); + failures = retryable === false ? 4 : failures + 1; + due = performance.now() + 1000 * 2 ** Math.min(failures - 1, 3); + notify(); + host.wake(); + } + function dispatch(blocked: boolean) { + clearTimeout(timer); + if (closed || !host.connected() || blocked) return; + const needsRoute = + desired.length > 0 && + !candidate && + failures <= 3 && + (!confirmed || !same(confirmed.authors, desired)); + const pendingWrite = + publication && !publication.sent && !publication.signing; + if (!needsRoute && !pendingWrite) return; + const routeDelay = needsRoute + ? Math.max(0, due - performance.now(), host.admission.presenceDelay()) + : Infinity; + const writeDelay = pendingWrite ? host.admission.publishDelay() : Infinity; + const delay = Math.min(routeDelay, writeDelay); + if (delay > 0) { + timer = setTimeout(host.wake, delay); + return; + } + if (needsRoute && routeDelay === 0) { + const route: Route = { + wire: `presence-${++serial}`, + authors: [...desired], + }; + candidate = route; + due = 0; + host.admission.takePresence(); + route.deadline = setTimeout(() => { + if (candidate === route) + failed(route, "Presence setup timed out; retry available"); + }, 10000); + host.send([ + "REQ", + route.wire, + { kinds: [20001], authors: route.authors, limit: 0 }, + ]); + host.wake(); + return; + } + const operation = publication; + if (!operation || operation.sent || operation.signing) return; + if (!operation.event) { + try { + operation.release = host.admission.acquirePublication(); + } catch (error) { + operation.finish(error); + return; + } + operation.signing = true; + const template = { + kind: 20001, + content: operation.status, + tags: [], + created_at: Math.floor(Date.now() / 1000), + }; + void (async () => { + const raw = await host.sign(template); + if (publication !== operation || closed) return; + const event = eventDto(raw); + if ( + event.pubkey !== host.viewer || + event.kind !== template.kind || + event.content !== template.content || + event.created_at !== template.created_at || + event.tags.length + ) + throw new Error("Presence signer changed the publication"); + operation.event = event; + })() + .catch((error) => operation.finish(error)) + .finally(() => { + operation.signing = false; + if (publication !== operation) operation.release?.(); + else host.wake(); // Recheck foreground work and cooldown learned during signing. + }); + return; + } + operation.sent = true; + host.admission.takePublish(); + host.send(["EVENT", operation.event]); + } + return { + capability: { + update(input: readonly string[]) { + const next = presenceAuthors(input); + if (closed || same(next, desired)) return; + desired = next; + if (failures <= 3) error = undefined; + if (!desired.length) { + remove(candidate); + remove(confirmed); + failures = 0; + due = 0; + } else if (confirmed && same(confirmed.authors, desired)) + remove(candidate); + schedule(); + }, + publish(status: PresenceStatus, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + presenceStatus(status); + signal.throwIfAborted(); + if (closed || !host.connected()) + throw new Error("Presence socket is not authenticated"); + if (publication) + throw new Error("Presence publication already in flight"); + const deadline = setTimeout( + () => + operation.finish( + new Error( + "Presence publication outcome unknown (deadline exceeded)", + ), + ), + 10000, + ); + const abort = () => + operation.finish( + signal.reason ?? + new DOMException("Presence cancelled", "AbortError"), + ); + const operation: Publication = { + status, + signing: false, + sent: false, + finish(error) { + if (publication !== operation) return; + publication = undefined; + clearTimeout(deadline); + signal.removeEventListener("abort", abort); + if (!operation.signing) operation.release?.(); + if (error === undefined) resolve(); + else reject(error); + }, + }; + publication = operation; + signal.addEventListener("abort", abort, { once: true }); + host.wake(); + }); + }, + }, + dispatch, + message(data: unknown[]) { + if (closed || !host.connected()) return; + if ( + data[0] === "OK" && + publication?.sent && + data[1] === publication.event?.id && + typeof data[2] === "boolean" + ) { + const reason = + typeof data[3] === "string" + ? data[3].slice(0, 512) + : "Presence publication rejected"; + if (!data[2]) host.cooldown(reason); + publication.finish(data[2] ? undefined : new Error(reason)); + host.wake(); + return; + } + const route = + candidate?.wire === data[1] + ? candidate + : confirmed?.wire === data[1] + ? confirmed + : undefined; + if (!route) return; + if (data[0] === "EVENT") { + let event: VerifiedEvent; + try { + event = eventDto(data[2]); + } catch { + failed(route, "Invalid presence signature"); + return; + } + if ( + event.kind === 20001 && + route.authors.includes(event.pubkey) && + desired.includes(event.pubkey) + ) + host.callbacks.presence?.([event]); + } else if (data[0] === "EOSE" && candidate === route) { + clearTimeout(route.deadline); + if (!same(route.authors, desired)) { + remove(route); + schedule(); + return; + } + remove(confirmed); + confirmed = route; + candidate = undefined; + failures = 0; + error = undefined; + notify(); + host.wake(); + } else if (data[0] === "CLOSED") { + failed( + route, + typeof data[2] === "string" + ? data[2].slice(0, 512) + : "Presence subscription closed", + ); + } + }, + retry() { + failures = 0; + error = undefined; + schedule(); + }, + reset(reason: string) { + clearTimeout(timer); + // Socket generation owns the wires. No CLOSE on a dead/replaced socket. + clearTimeout(candidate?.deadline); + clearTimeout(confirmed?.deadline); + candidate = confirmed = undefined; + publication?.finish(new Error(reason)); + error = reason; + notify(); + }, + dispose() { + closed = true; + clearTimeout(timer); + remove(candidate); + remove(confirmed); + // finish owns the winner; later disposal cannot relabel an earlier failure. + publication?.finish(new PresenceOwnerDisposed()); + }, + }; +} diff --git a/src/features/relay/reader-presence.test.ts b/src/features/relay/reader-presence.test.ts new file mode 100644 index 00000000..08f7603b --- /dev/null +++ b/src/features/relay/reader-presence.test.ts @@ -0,0 +1,108 @@ +import { afterEach, assert, expect, it, vi } from "vitest"; +import { createRelayReader } from "./reader"; +import type { ReadFilter, RelayEvent } from "./events"; +import { isPresenceSnapshot } from "./presence-contract"; + +const filter = { kinds: [20001], authors: ["a".repeat(64)], limit: 1 }; +const snapshot = [filter]; +const ordinary = (n: number) => [{ kinds: [9], limit: n }]; +afterEach(() => vi.useRealTimers()); +function setup() { + vi.useFakeTimers(); + const calls: { + filters: readonly ReadFilter[]; + resolve(value: RelayEvent[]): void; + }[] = []; + const owner = createRelayReader( + { + viewer: "viewer", + relayAuthor: "relay", + media: () => undefined, + // Deliberately unabortable: consumer completion is not underlying settlement. + query: (filters) => + new Promise((resolve) => calls.push({ filters, resolve })), + }, + { maxPending: 3 }, + ); + return { + ...owner, + calls, + read: owner.reader.read, + call(index: number) { + const call = calls[index]; + assert.exists(call); + return call; + }, + }; +} +it("a held optional snapshot leaves all three ordinary reader slots and pending budget intact", async () => { + const h = setup(); + try { + const p = h.read(snapshot, { priority: "background" }); + const reads = [1, 2, 3].map((n) => h.read(ordinary(n))); + expect(h.calls).toHaveLength(4); + await expect(h.read(ordinary(4))).rejects.toThrow("Too many"); + await expect( + h.read([{ ...filter, authors: ["b".repeat(64)] }]), + ).rejects.toThrow("Too many"); + h.calls.slice(1).forEach((call) => { + call.resolve([]); + }); + await Promise.all(reads); + const fourth = h.read(ordinary(4)); + expect(h.calls).toHaveLength(5); + h.call(4).resolve([]); + await fourth; + h.call(0).resolve([]); + await p; + } finally { + h.dispose(); + } +}); +it("abort keeps the single optional slot until transport settlement; ordinary work and recovery still proceed", async () => { + const h = setup(); + try { + const c = new AbortController(); + const p = h.read(snapshot, { signal: c.signal }); + const rejected = expect(p).rejects.toThrow(); + c.abort(); + await rejected; + for (let i = 0; i < 5; i++) + await expect(h.read(snapshot)).rejects.toThrow("Too many"); + const f = h.read(ordinary(1)); + expect(h.calls).toHaveLength(2); + h.call(1).resolve([]); + await f; + h.call(0).resolve([]); + await vi.advanceTimersByTimeAsync(0); + const retry = h.read(snapshot); + expect(h.calls).toHaveLength(3); + h.call(2).resolve([]); + await retry; + } finally { + h.dispose(); + } +}); +it("normalization cannot upgrade malformed filters or join them to an optional job", async () => { + const h = setup(); + try { + const p = h.read(snapshot); + for (const malformed of [ + [{ ...filter, since: undefined }], + [{ ...filter, kinds: [20001, 20001] }], + [{ ...filter, authors: ["a".repeat(64), "a".repeat(64)] }], + ]) + await expect( + h.read(malformed as unknown as readonly ReadFilter[]), + ).rejects.toThrow("Ambiguous presence"); + const mixed = h.read([{ ...filter, kinds: [20001, 9] }]); + expect(h.calls).toHaveLength(2); + expect(isPresenceSnapshot(h.call(1).filters)).toBe(false); + h.calls.forEach((call) => { + call.resolve([]); + }); + await Promise.all([p, mixed]); + } finally { + h.dispose(); + } +}); diff --git a/src/features/relay/reader.ts b/src/features/relay/reader.ts index 27a0698e..da69622a 100644 --- a/src/features/relay/reader.ts +++ b/src/features/relay/reader.ts @@ -1,3 +1,4 @@ +import { isPresenceSnapshot } from "./presence-contract"; import { yieldToHost } from "./yield"; import { createRelayProfiler, type RelayProfiler } from "./profiling"; import type { ReadFilter, RelayEvent } from "./events"; @@ -36,6 +37,7 @@ type Job = { timer: ReturnType; running: boolean; snapshot: boolean; + presence: boolean; }; const cancelled = () => new DOMException("Relay read cancelled", "AbortError"); @@ -63,6 +65,8 @@ export function createRelayReader( let sequence = 0; const jobs = new Map(); let closed = false; + // A cancelled consumer cannot release an unresolved optional transport. + let presenceRunning = false; let recovering = false; // Finite reads keep their existing deadlines during navigation. beforeunload // is reversible, so pause admission rather than disposing the session. Fetch @@ -125,15 +129,24 @@ export function createRelayReader( function pump() { if (closed || suspended || recovering || !transport) return; while (!closed && !suspended && !recovering) { - const active = [...jobs.values()].filter((job) => job.running); - if (active.length >= 3) return; + const active = [...jobs.values()].filter( + (job) => job.running && !job.presence, + ); const background = active.some((job) => job.priority === "background"); - const queued = [...jobs.values()].filter((job) => !job.running); + const queued = [...jobs.values()].filter( + (job) => !job.running && !job.presence, + ); + const optional = [...jobs.values()].find( + (job) => !job.running && job.presence, + ); const job = - queued.find((job) => job.priority === "foreground") ?? - (!background ? queued[0] : undefined); + (active.length < 3 + ? (queued.find((job) => job.priority === "foreground") ?? + (!background ? queued[0] : undefined)) + : undefined) ?? (!presenceRunning ? optional : undefined); if (!job) return; job.running = true; + if (job.presence) presenceRunning = true; job.queued(); job.fetched = profiling.start("read.fetch", job.id); // Catch synchronous adapter failures as well as rejected promises. @@ -153,22 +166,32 @@ export function createRelayReader( job.id, job.priority, ); - void query.then( - (events) => { - if (byteSize(events) > 8 * 1024 * 1024) - finish( - job, - undefined, - new ReadError( - "invalid-response", - "Relay response exceeds the read budget", - ), - ); - else finish(job, Object.freeze([...events])); - }, - (error) => failed(job, error), - ); + const settled = () => { + if (job.presence) presenceRunning = false; + }; + void query + .then( + (events) => { + settled(); + if (byteSize(events) > 8 * 1024 * 1024) + finish( + job, + undefined, + new ReadError( + "invalid-response", + "Relay response exceeds the read budget", + ), + ); + else finish(job, Object.freeze([...events])); + }, + (error) => { + settled(); + failed(job, error); + }, + ) + .finally(pump); } catch (error) { + if (job.presence) presenceRunning = false; failed(job, error); } } @@ -236,10 +259,19 @@ export function createRelayReader( ); key = `read-state-snapshot:${key}`; } + // Classify the original request too: canonicalization must not erase unknown keys. + const presence = !snapshot && isPresenceSnapshot(filters); + if (!snapshot && !presence && isPresenceSnapshot(requestFilters)) + return Promise.reject(new Error("Ambiguous presence snapshot filters")); if (fresh) key = `${key}:fresh:${++sequence}`; let job = jobs.get(key); if (!job) { - if (jobs.size >= maxPending) + if ( + presence + ? presenceRunning || [...jobs.values()].some((job) => job.presence) + : [...jobs.values()].filter((job) => !job.presence).length >= + maxPending + ) return Promise.reject( new ReadError("unavailable", "Too many pending relay reads"), ); @@ -254,6 +286,7 @@ export function createRelayReader( consumers: new Set(), running: false, snapshot, + presence, timer: setTimeout( () => finish( diff --git a/src/features/relay/transport.test.ts b/src/features/relay/transport.test.ts index d4e54f3e..3831e22f 100644 --- a/src/features/relay/transport.test.ts +++ b/src/features/relay/transport.test.ts @@ -1,9 +1,15 @@ +import { createHash } from "node:crypto"; +import type { EventTemplate } from "nostr-tools"; import { assert, afterEach, expect, it, vi } from "vitest"; import { connectBrokerTransport, connectSignedTransport } from "./transport"; import { PublishRejected } from "./outbox"; import { keypair, signed } from "./testing"; const key = keypair(); -afterEach(() => vi.unstubAllGlobals()); +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); it("publishes the unchanged signed event to /events with request-bound NIP-98 auth", async () => { const event = signed(key, { kind: 9, content: "hello", tags: [["h", "c"]] }); const fetcher = vi.fn(async () => @@ -184,3 +190,71 @@ it("uses each signed transport's own origin for protected media, never a deploym ); expect(a.media("http://images.example/insecure.png")).toBeUndefined(); }); + +it.each([ + [true, true, true], + [true, false, false], + [false, true, false], + [undefined, true, false], + ["true", true, false], +])( + "presence requires explicit host support (%s) and live support (%s)", + async (presence, live, supported) => { + vi.stubGlobal("fetch", async () => + Response.json({ + viewer: key.pubkey, + relayAuthor: key.pubkey, + presence, + live, + }), + ); + const transport = await connectBrokerTransport(); + expect(transport.presence).toBe(supported); + }, +); + +async function setup() { + vi.useFakeTimers(); + // Keep hashing real but immediate: no native-worker scheduling in a fake-time admission model. + vi.spyOn(crypto.subtle, "digest").mockImplementation( + async (_algorithm, data) => + Uint8Array.from( + createHash("sha256") + .update(new Uint8Array(data as ArrayBuffer)) + .digest(), + ).buffer, + ); + const key = keypair(); + const signer = { + getPublicKey: async () => key.pubkey, + signEvent: vi.fn(async (event: EventTemplate) => signed(key, event)), + }; + const transport = await connectSignedTransport( + signer, + "https://presence-admission.test", + key.pubkey, + ); + expect(transport.presence).toBeUndefined(); + return { key, signer, transport }; +} +const ordinary = [{ kinds: [9], limit: 1 }]; +it("runtime priority spoofing cannot give ordinary signed queries optional admission", async () => { + const h = await setup(); + const starts: number[] = []; + vi.stubGlobal("fetch", async () => { + starts.push(performance.now()); + return Response.json([]); + }); + await h.transport.query(ordinary); + const spoofed = h.transport.query( + ordinary, + undefined, + "spoof", + "presence" as "foreground", + ); + await vi.advanceTimersByTimeAsync(499); + expect(starts).toEqual([0]); + await vi.advanceTimersByTimeAsync(1); + await spoofed; + expect(starts).toEqual([0, 500]); +}); diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts index fb80d9f1..1c05b494 100644 --- a/src/features/relay/transport.ts +++ b/src/features/relay/transport.ts @@ -36,6 +36,8 @@ export interface RelayWriter { export interface ReadTransport { /** Purpose-bound observer decoding on the shared host live stream. */ readonly agentActivity?: boolean; + /** Snapshot admission and same-socket presence controls are both supported. */ + readonly presence?: boolean; /** Host-projected local library; display only, never relay authority. */ readonly readAgentLibrary?: AgentLibraryReader; /** Host-only decoder of the viewer's two signed sidebar preference coordinates. */ @@ -145,6 +147,7 @@ export async function connectBrokerTransport( writeKinds?: number[]; relayUrl?: string; live?: boolean; + presence?: boolean; sidebarPreferences?: boolean; agentLibrary?: boolean; agentActivity?: boolean; @@ -166,6 +169,7 @@ export async function connectBrokerTransport( return { profiling, agentActivity: session.agentActivity === true && session.live === true, + presence: session.presence === true && session.live === true, ...(session.live ? { subscribe: (callbacks: LiveCallbacks) => @@ -436,7 +440,7 @@ export async function connectSignedTransport( profiling, requestId, principal().api, - priority, + priority === "background" ? "background" : "foreground", ); if (!result.ok) { const failure = await readApiFailure(result); diff --git a/tests/browser/broker-evidence.mjs b/tests/browser/broker-evidence.mjs new file mode 100644 index 00000000..33c8f341 --- /dev/null +++ b/tests/browser/broker-evidence.mjs @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; + +// Passive host evidence survives a browser abort/no-console response. No response +// is delayed, substituted or interpreted as delivery by this observer. +export function brokerEvidence(report, retiredStreams) { + const publications = []; + report.presencePublicationResponses = publications; + return { + middleware(req, res, next) { + if (!req.url?.startsWith("/api/relay/")) return next(); + const record = { + url: req.url, + at: performance.now(), + priority: req.headers["x-buzz-read-priority"], + }; + report.brokerRequests.push(record); + const url = new URL(req.url, `http://${req.headers.host}`); + const publishing = url.pathname.endsWith("/stream-presence-publish"); + const streaming = url.pathname.endsWith("/stream"); + let streamId; + if (streaming) { + const writeHead = res.writeHead; + res.writeHead = function (...args) { + // Direct writeHead headers are not retained by getHeader(). + streamId = args.at(-1)?.["X-Buzz-Live-ID"]; + return writeHead.apply(this, args); + }; + } + let body = ""; + if (publishing) { + req.on("data", (chunk) => { + if (body.length <= 256) body += chunk; + }); + req.once("end", () => { + try { + record.streamId = JSON.parse(body).streamId; + } catch { + // Invalid/unreadable request evidence cannot classify a 503. + } + }); + const end = res.end; + res.end = function (chunk, ...args) { + if (this.statusCode === 503) { + let value; + try { + value = JSON.parse(String(chunk)); + } catch { + // Missing/malformed evidence remains unclassified and test-failing. + } + publications.push({ + url: url.href, + streamId: record.streamId, + at: performance.now(), + status: 503, + body: value ?? null, + disposed: + /^[0-9a-f]{32}$/.test(record.streamId ?? "") && + value?.error === "Presence publication unconfirmed" && + value?.code === "presence_owner_disposed" && + Object.keys(value).length === 2, + }); + } + return end.call(this, chunk, ...args); + }; + } + const snapshot = () => ({ + at: performance.now(), + status: res.statusCode, + serverTiming: res.getHeader("Server-Timing") ?? null, + finished: res.writableFinished, + }); + res.once("finish", () => { + record.finish = snapshot(); + }); + res.once("close", () => { + record.close = snapshot(); + if (streaming && streamId) retiredStreams.add(streamId); + // A destroyed/truncated 503 without end() is still an unclassified failure. + if (publishing && res.statusCode === 503 && !res.writableEnded) + publications.push({ + url: url.href, + streamId: record.streamId, + status: 503, + disposed: false, + }); + }); + next(); + }, + assertPublications() { + assert.deepEqual( + publications.filter((item) => !item.disposed), + [], + "Unclassified presence publication 503", + ); + }, + consoleFilter() { + // Each classified response permits at most one endpoint-qualified console + // diagnostic. Independent publication validation prevents same-URL masking. + const remaining = publications.filter((item) => item.disposed); + return (message, location) => { + if ( + !/^Failed to load resource: the server responded with a status of 503/.test( + message, + ) + ) + return false; + const index = remaining.findIndex((item) => item.url === location); + if (index < 0) return false; + remaining.splice(index, 1); + return true; + }; + }, + }; +} diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index b4ecb916..79cfe95d 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -15,6 +15,7 @@ import { execFileSync } from "node:child_process"; import { relayBrokerPlugin } from "../../dev/relay-broker.mjs"; import { policyRelay } from "./policy-relay.mjs"; import { buildApp } from "./build.mjs"; +import { brokerEvidence } from "./broker-evidence.mjs"; const root = fileURLToPath(new URL("../../", import.meta.url)); export const channels = ["alpha", "beta"]; @@ -23,8 +24,12 @@ export const historySize = 640; // The built app, React, services, verification, IndexedDB and Virtua stay real. // Layout journeys use synthetic broker HTTP; live journeys retain the production // broker/subscriber and model only the upstream relay policy with ephemeral keys. -export const test = base.extend({ +export const browserFixtures = { productionBroker: [false, { option: true }], + composerPublication: [false, { option: true }], + compactHistory: [false, { option: true }], + enforceQuotas: [false, { option: true }], + withoutPresence: [false, { option: true, scope: "worker" }], readState: [false, { option: true }], threadUnread: [false, { option: true }], exactMessages: [false, { option: true }], @@ -45,6 +50,10 @@ export const test = base.extend({ browserName, browser, productionBroker, + composerPublication, + compactHistory, + enforceQuotas, + withoutPresence, readState, threadUnread, exactMessages, @@ -161,7 +170,13 @@ export const test = base.extend({ histories.set( `${community}/${channel}`, Array.from( - { length: channel === "alpha" ? historySize : 80 }, + { + length: compactHistory + ? 1 + : channel === "alpha" + ? historySize + : 80, + }, (_, i) => sign( 9, @@ -331,6 +346,8 @@ export const test = base.extend({ encoding: "utf8", }), browserName, + withoutPresence, + enforceQuotas, developmentReact, pluginFixtures, compiledBuild: { @@ -362,6 +379,7 @@ export const test = base.extend({ }; const pending = []; const retiredStreams = new Set(); + const brokerResponses = brokerEvidence(report, retiredStreams); const observerFailures = []; const consoleLocations = new Map(); const send = (response, body, status = 200) => { @@ -439,6 +457,11 @@ export const test = base.extend({ ] : []), ]; + if (composerPublication && filter.ids) + expect(filter).toEqual({ + ids: [expect.stringMatching(/^[0-9a-f]{64}$/)], + limit: 1, + }); if (filter.ids) return [...histories.entries()] .filter(([key]) => key.startsWith(`${community}/`)) @@ -582,6 +605,9 @@ export const test = base.extend({ const relay = productionBroker ? policyRelay({ viewer, + enforceQuotas, + presenceSnapshot: (author, status) => + sign(20001, [["p", author]], status), answer, report, pending, @@ -596,9 +622,28 @@ export const test = base.extend({ max_bytes: 8388608, }, }), + } + : {}), + ...(readState || composerPublication + ? { acceptPublication: (community, event) => { expect(verifyEvent(event)).toBe(true); expect(event.pubkey).toBe(viewer); + if (composerPublication && event.kind === 9) { + const channel = event.tags.find( + ([name]) => name === "h", + )?.[1]; + expect(channels).toContain(channel); + histories.get(`${community}/${channel}`).push(event); + report.publications.push({ + community, + event, + at: performance.now(), + }); + relay.publish(community, event); + return; + } + expect(readState).toBe(true); expect(event.kind).toBe(30078); expect(event.tags).toContainEqual(["t", "read-state"]); const blob = JSON.parse( @@ -726,18 +771,7 @@ export const test = base.extend({ async configurePreviewServer(server) { if (relay) { report.brokerRequests = []; - server.middlewares.use((req, res, next) => { - if (req.url?.startsWith("/api/relay/")) - report.brokerRequests.push({ - url: req.url, - at: performance.now(), - }); - if (req.url?.endsWith("/stream")) - res.once("close", () => { - retiredStreams.add(res.getHeader("x-buzz-live-id")); - }); - next(); - }); + server.middlewares.use(brokerResponses.middleware); const broker = relayBrokerPlugin({ relayUrl: fixtureRelayUrl, communityAliases: fixtureAliases, @@ -845,6 +879,19 @@ export const test = base.extend({ participants, viewer, relay, + presence(status, updateSnapshot = true) { + relay.presence( + "primary", + sign( + 20001, + [], + status, + readState ? peerKey : userKey, + Math.floor(Date.now() / 1000), + ), + updateSnapshot, + ); + }, observer(raw, agentKey, community = "primary") { const agent = getPublicKey(agentKey); const plaintext = JSON.stringify(raw); @@ -951,54 +998,15 @@ export const test = base.extend({ return event; }, }); - expect(report.unexpected).toEqual([]); - // Aborted startup streams can race an already-dispatched observer control. - // Permit only 404s whose exact stream was already closed by the real host; - // a current/unknown stream failure still fails, and all errors stay recorded. - report.retiredObserverControls = [...observerFailures]; - expect(observerFailures.every((failure) => failure.retired)).toBe(true); - const retiredConsole = (message, index) => { - if ( - !/^Failed to load resource: the server responded with a status of 404/.test( - message, - ) - ) - return false; - const match = observerFailures.findIndex( - (failure) => failure.url === consoleLocations.get(index), - ); - if (match < 0) return false; - observerFailures.splice(match, 1); - return true; - }; - expect( - report.consoleErrors.filter( - (message, index) => - !retiredConsole(message, index) && - !( - expectedPageFailure && - message.includes("Fixture page render failure") - ) && - !( - relay?.expectedHttpErrors() && - /^Failed to load resource: the server responded with a status of 429/.test( - message, - ) - ), - ), - ).toEqual([]); - // Existing WebKit observer warning is recorded, never silently swallowed. - expect( - report.errors.filter( - (message) => - !( - browserName === "webkit" && - message === - "ResizeObserver loop completed with undelivered notifications." - ), - ), - ).toEqual([]); } finally { + await page.close(); + for (const clients of streams.values()) + for (const response of clients) response.end(); + if (server) { + server.httpServer.closeAllConnections(); + await new Promise((resolve) => server.httpServer.close(resolve)); + } + report.retiredObserverControls = [...observerFailures]; await writeFile( testInfo.outputPath("evidence.json"), JSON.stringify(report, null, 2), @@ -1007,14 +1015,57 @@ export const test = base.extend({ body: JSON.stringify(report, null, 2), contentType: "application/json", }); - await page.close(); - for (const clients of streams.values()) - for (const response of clients) response.end(); - if (server) { - server.httpServer.closeAllConnections(); - await new Promise((resolve) => server.httpServer.close(resolve)); - } } + expect(report.unexpected).toEqual([]); + // Aborted startup streams can race an already-dispatched observer control. + // Permit only 404s whose exact stream was already closed by the real host; + // a current/unknown stream failure still fails, and all errors stay recorded. + expect(observerFailures.every((failure) => failure.retired)).toBe(true); + brokerResponses.assertPublications(); + const disposedConsole = brokerResponses.consoleFilter(); + const retiredConsole = (message, index) => { + if ( + !/^Failed to load resource: the server responded with a status of 404/.test( + message, + ) + ) + return false; + const match = observerFailures.findIndex( + (failure) => failure.url === consoleLocations.get(index), + ); + if (match < 0) return false; + observerFailures.splice(match, 1); + return true; + }; + expect( + report.consoleErrors.filter( + (message, index) => + !retiredConsole(message, index) && + !disposedConsole(message, consoleLocations.get(index)) && + !( + expectedPageFailure && + message.includes("Fixture page render failure") + ) && + !( + relay?.expectedHttpErrors() && + /^Failed to load resource: the server responded with a status of 429/.test( + message, + ) + ), + ), + ).toEqual([]); + // Existing WebKit observer warning is recorded, never silently swallowed. + expect( + report.errors.filter( + (message) => + !( + browserName === "webkit" && + message === + "ResizeObserver loop completed with undelivered notifications." + ), + ), + ).toEqual([]); }, -}); +}; +export const test = base.extend(browserFixtures); export { expect }; diff --git a/tests/browser/policy-relay.mjs b/tests/browser/policy-relay.mjs index 537f25b6..9784c3ac 100644 --- a/tests/browser/policy-relay.mjs +++ b/tests/browser/policy-relay.mjs @@ -12,13 +12,67 @@ export function policyRelay({ pending, discovery, acceptPublication, + presenceSnapshot, + enforceQuotas = false, + now = () => performance.now(), }) { + const presence = new Map(); + report.presencePublications = []; const sockets = []; const requests = []; const rejected = []; report.liveRequests = requests; report.quotaRefusals = rejected; const quotas = new Map(); + // Audited buzz 78618804: admission.rs, rejection.rs and RedisRateLimiter. + // Windows start on the first charged operation; rejected attempts still count. + // This is opt-in modeled policy, not discovery of the deployed configuration. + const limits = { + ApiCalls: [300, 60000], + WsEvents: [50, 5000], + Messages: [60, 60000], + }; + const counters = new Map(); + report.quotaCharges = []; + function charge(community, category, operation) { + if (!enforceQuotas) return; + const at = now(), + [limit, windowMs] = limits[category]; + const key = `${community}:${viewer}:${category}`; + let counter = counters.get(key); + if (!counter || at >= counter.until) { + counter = { count: 0, until: at + windowMs }; + counters.set(key, counter); + } + counter.count++; + const accepted = counter.count <= limit; + report.quotaCharges.push({ + community, + category, + operation, + at, + count: counter.count, + limit, + until: counter.until, + accepted, + }); + if (accepted) return; + const seconds = Math.ceil((counter.until - at) / 1000); + const reason = `rate-limited: quota exceeded; retry in ${seconds}s`; + rejected.push({ + community, + category, + operation, + at, + until: counter.until, + reason, + }); + return reason; + } + let heldPresence = false; + let presenceStarted; + const pendingPresence = []; + report.presenceHolds = []; const heldEose = new Set(); const pendingEose = []; const pendingProfiles = []; @@ -38,7 +92,9 @@ export function policyRelay({ ? "membership" : filter.kinds.includes(24200) ? "observer" - : undefined); + : filter.kinds.includes(20001) + ? "presence" + : undefined); report.wireFrames = []; let emptyRoster = false; let heldContent = false; @@ -54,6 +110,38 @@ export function policyRelay({ socket.onmessage?.({ data: JSON.stringify(frame) }); } return { + presence(community, event, updateSnapshot = true) { + expect(event.kind).toBe(20001); + expect(verifyEvent(event)).toBe(true); + if (updateSnapshot) + presence.set(`${community}:${event.pubkey}`, { + status: event.content, + expires: Date.now() + 180000, + }); + let deliveries = 0; + for (const socket of sockets) { + if (socket.readyState !== 1 || socket.community !== community) continue; + for (const [id, filter] of socket.routes) { + if ( + !filter.kinds.includes(20001) || + !filter.authors?.includes(event.pubkey) + ) + continue; + emit(socket, ["EVENT", id, event]); + deliveries++; + } + } + expect(deliveries).toBeGreaterThan(0); + }, + holdPresence(onStart) { + heldPresence = true; + presenceStarted = onStart; + }, + releasePresence() { + heldPresence = false; + presenceStarted = undefined; + for (const release of pendingPresence.splice(0)) release(); + }, holdContent() { heldContent = true; }, @@ -117,6 +205,12 @@ export function policyRelay({ createHash("sha256").update(init.body).digest("hex"), ]); const filters = JSON.parse(init.body); + const reason = charge( + communityOf(url), + "ApiCalls", + new URL(url).pathname, + ); + if (reason) return Response.json({ error: reason }, { status: 429 }); if (new URL(url).pathname === "/events") { acceptPublication(communityOf(url), filters); return Response.json({ accepted: true, event_id: filters.id }); @@ -163,6 +257,46 @@ export function policyRelay({ const filter = filters[0], community = communityOf(url); report.queries.push({ community, filter, at: performance.now() }); + if (filter.kinds?.includes(20001)) { + expect(filter.kinds).toEqual([20001]); + expect(filter.authors.length).toBeGreaterThan(0); + expect(filter.authors.length).toBeLessThanOrEqual(256); + expect(filter.limit).toBe(filter.authors.length); + const result = filter.authors.flatMap((author) => { + const value = presence.get(`${community}:${author}`); + return value && value.expires > Date.now() + ? [presenceSnapshot(author, value.status)] + : []; + }); + if (heldPresence) + return new Promise((resolve, reject) => { + const held = { + at: performance.now(), + pending: true, + aborted: false, + }; + report.presenceHolds.push(held); + const abort = () => { + held.pending = false; + held.aborted = true; + held.completedAt = performance.now(); + reject(init.signal.reason); + }; + if (init.signal.aborted) return abort(); + init.signal.addEventListener("abort", abort, { once: true }); + pendingPresence.push(() => { + init.signal.removeEventListener("abort", abort); + if (!held.pending) return; + held.pending = false; + held.completedAt = performance.now(); + resolve(Response.json(result)); + }); + const started = presenceStarted; + presenceStarted = undefined; + started?.(held); + }); + return Response.json(result); + } if (heldContent && filter.kinds?.includes(9)) return new Promise((_resolve, reject) => { if (init.signal.aborted) reject(init.signal.reason); @@ -285,6 +419,42 @@ export function policyRelay({ this.routes.delete(id); return; } + if (kind === "REQ" || kind === "EVENT") { + expect(this.authenticated).toBe(true); + const reason = + charge(this.community, "WsEvents", kind) || + (kind === "EVENT" && charge(this.community, "Messages", kind)); + if (reason) { + queueMicrotask(() => + emit( + this, + kind === "REQ" + ? ["CLOSED", id, reason] + : ["OK", id.id, false, reason], + ), + ); + return; + } + } + if (kind === "EVENT") { + expect(this.authenticated).toBe(true); + expect(verifyEvent(id)).toBe(true); + expect(id.pubkey).toBe(viewer); + expect(id.kind).toBe(20001); + expect(id.tags).toEqual([]); + expect(["online", "away"]).toContain(id.content); + presence.set(`${this.community}:${id.pubkey}`, { + status: id.content, + expires: Date.now() + 180000, + }); + report.presencePublications.push({ + community: this.community, + event: id, + at: performance.now(), + }); + queueMicrotask(() => emit(this, ["OK", id.id, true])); + return; + } expect(kind).toBe("REQ"); expect(this.authenticated).toBe(true); requests.push({ @@ -310,6 +480,16 @@ export function policyRelay({ ); return; } + if (filter.kinds.includes(20001)) { + expect(filter.kinds).toEqual([20001]); + expect(filter.authors.length).toBeGreaterThan(0); + expect(filter.authors.length).toBeLessThanOrEqual(256); + expect( + filter.authors.every((author) => /^[0-9a-f]{64}$/.test(author)), + ).toBe(true); + expect(filter.limit).toBe(0); + expect(filter["#h"]).toBeUndefined(); + } if (filter.kinds.includes(44100)) expect(filter["#p"]).toEqual([viewer]); if (filter.kinds.includes(24200)) {