- commit
- 3f4dacf2da56d04bb2ec918e0eeea982367b038e
- parent
- 557b36f00744ef2351ff963090d5f7e5711df770
- author
- Dami <dami.etoile@gmail.com>
| M | package.json | 3 | +- |
| A | scripts/ | 167 | +++++++++++++++ |
| A | scripts/ | 335 | ++++++++++++++++++++++++++++++ |
| A | test/ | 75 | +++++++ |
| A | test/ | 85 | ++++++++ |
@@ -20,7 +20,8 @@
2020 "dev:stage": "node scripts/dev/stage.mjs",2121 "dev:junk": "node scripts/dev/junk.mjs",2222 "dev:shot": "node scripts/dev/shot.mjs",23- "dev:zaptest": "node scripts/dev/zaptest.mjs"23+ "dev:zaptest": "node scripts/dev/zaptest.mjs",24+ "test:network": "node scripts/ops/network.mjs"2425 },2526 "dependencies": {2627 "@noble/hashes": "^2.4.0",@@ -0,0 +1,167 @@
1+// The manually invoked network test client: signed management calls and a2+// bounded, read-only WebSocket sample. It keeps retries narrow because a3+// mutation whose result is unclear must remain visible to the harness.4+import WebSocket from "ws";5+import { finalizeEvent, verifyEvent } from "nostr-tools/pure";6+import { getToken } from "nostr-tools/nip98";7+8+const REQUEST_TIMEOUT = 20_000;9+const SAMPLE_TIMEOUT = 15_000;10+const SAMPLE_BYTES = 256 * 1024;11+const RETRIES = 30;12+const RETRY_DELAY = 500;13+const RETRY_MAX_WAIT = 2_000;14+const RETRY_WINDOW = 60_000;15+16+export const clientMetrics = { requests: 0, operationRetries: 0, operationWaitMs: 0 };17+18+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));19+20+export class NetworkError extends Error {21+ constructor(message, status, body) {22+ super(message);23+ this.name = "NetworkError";24+ this.status = status;25+ this.body = body;26+ }27+}28+29+const parseBody = async (response) => {30+ const text = await response.text();31+ if (!text) return null;32+ try {33+ return JSON.parse(text);34+ } catch {35+ return text;36+ }37+};38+39+const retryable = (status, body) => {40+ if (status !== 429) return false;41+ const reason = body && typeof body === "object"42+ ? `${body.error ?? ""} ${body.message ?? ""}`43+ : String(body ?? "");44+ return /operation in progress/i.test(reason);45+};46+47+const errorText = (body) => {48+ if (body && typeof body === "object") return String(body.error ?? body.message ?? "request failed").slice(0, 300);49+ return String(body ?? "request failed").slice(0, 300);50+};51+52+// request sends one signed JSON POST and returns its parsed response. Only a53+// relay's explicit transient operation refusal is retried; timeout, network54+// and other HTTP failures remain errors because their mutation outcome is55+// unknown.56+export async function request(node, sk, body, rpc = false) {57+ const url = typeof node === "string" ? node : node.url;58+ const payload = body ?? {};59+ const retryUntil = Date.now() + RETRY_WINDOW;60+ for (let attempt = 0; attempt <= RETRIES; attempt++) {61+ const controller = new AbortController();62+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);63+ try {64+ clientMetrics.requests++;65+ const authorization = await getToken(url, "POST", (event) => finalizeEvent(event, sk), true, payload);66+ const response = await fetch(url, {67+ method: "POST",68+ redirect: "error",69+ signal: controller.signal,70+ headers: { "content-type": rpc ? "application/nostr+json+rpc" : "application/json", authorization },71+ body: JSON.stringify(payload),72+ });73+ const result = await parseBody(response);74+ if (response.ok) return result;75+ if (retryable(response.status, result) && attempt < RETRIES && Date.now() < retryUntil) {76+ const delay = Math.min(RETRY_MAX_WAIT, RETRY_DELAY * (attempt + 1), retryUntil - Date.now());77+ if (delay <= 0) throw new NetworkError(errorText(result), response.status, result);78+ clientMetrics.operationRetries++;79+ clientMetrics.operationWaitMs += delay;80+ await sleep(delay);81+ continue;82+ }83+ throw new NetworkError(errorText(result), response.status, result);84+ } catch (error) {85+ if (error instanceof NetworkError) throw error;86+ throw new NetworkError(error instanceof Error ? error.message : String(error), 0, null);87+ } finally {88+ clearTimeout(timer);89+ }90+ }91+ throw new NetworkError("request retries exhausted", 429, null);92+}93+94+// rpc calls one NIP-86 method and returns its result. A relay error remains a95+// NetworkError so the harness can stop before issuing the next mutation.96+export async function rpc(node, sk, method, ...params) {97+ const response = await request(node, sk, { method, params }, true);98+ if (response && typeof response === "object" && "error" in response) {99+ throw new NetworkError(errorText(response), 200, response);100+ }101+ return response && typeof response === "object" && "result" in response ? response.result : response;102+}103+104+const validSample = (event, filter) => {105+ if (!event || typeof event !== "object" || !verifyEvent(event)) return false;106+ if (filter.kinds?.length && !filter.kinds.includes(event.kind)) return false;107+ if (filter.since !== undefined && event.created_at < filter.since) return false;108+ if (filter.until !== undefined && event.created_at > filter.until) return false;109+ if (filter.authors?.length && !filter.authors.includes(event.pubkey)) return false;110+ return true;111+};112+113+// sample reads at most limit valid signed events from a relay. It never114+// answers AUTH or sends EVENT. An authenticated source is allowed to complete115+// its read or refuse it. Incoming data and time are bounded for endpoints.116+export function sample(url, filter = {}, limit = 3) {117+ const requested = { ...filter, kinds: filter.kinds?.length ? [...filter.kinds] : [1], limit: Math.min(Math.max(limit, 1), 3) };118+ const wsURL = url.replace(/^https:/, "wss:").replace(/^http:/, "ws:");119+ return new Promise((resolve) => {120+ const events = [];121+ let settled = false;122+ let timer;123+ let socket;124+ const finish = (status, error = "") => {125+ if (settled) return;126+ settled = true;127+ clearTimeout(timer);128+ if (socket) {129+ socket.removeAllListeners("open");130+ socket.removeAllListeners("message");131+ try {132+ if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CLOSING) socket.close();133+ else socket.terminate();134+ } catch {135+ try { socket.terminate(); } catch { /* already closed */ }136+ }137+ }138+ resolve({ events, status, error });139+ };140+ try {141+ socket = new WebSocket(wsURL, { maxPayload: SAMPLE_BYTES, followRedirects: false });142+ socket.on("open", () => socket.send(JSON.stringify(["REQ", "network-sample", requested])));143+ socket.on("message", (data) => {144+ if (settled) return;145+ const raw = Buffer.isBuffer(data) ? data : Buffer.from(String(data));146+ if (raw.length > SAMPLE_BYTES) return finish("failed", "sample response exceeded 256 KiB");147+ let message;148+ try { message = JSON.parse(raw.toString("utf8")); } catch { return; }149+ if (!Array.isArray(message)) return;150+ if (message[0] === "AUTH") return;151+ if (message[0] === "CLOSED") return finish("refused", String(message[2] ?? "source refused query").slice(0, 300));152+ if (message[0] === "EOSE" && message[1] === "network-sample") return finish("complete");153+ if (message[0] !== "EVENT" || message[1] !== "network-sample") return;154+ let valid = false;155+ try { valid = validSample(message[2], requested); } catch { return; }156+ if (!valid) return;157+ if (!events.some((event) => event.id === message[2].id)) events.push(message[2]);158+ if (events.length >= requested.limit) finish("limit");159+ });160+ socket.on("error", (error) => finish("failed", error.message.slice(0, 300)));161+ socket.on("close", () => finish(events.length ? "partial" : "failed", events.length ? "source closed before EOSE" : "connection closed"));162+ timer = setTimeout(() => finish("timeout", "source did not answer within 15 seconds"), SAMPLE_TIMEOUT);163+ } catch (error) {164+ finish("failed", error instanceof Error ? error.message.slice(0, 300) : String(error).slice(0, 300));165+ }166+ });167+}@@ -0,0 +1,335 @@
1+// The finite relay-network exercise, invoked with npm run test:network.2+// A private manifest precedes every claim so cleanup also covers lost replies.3+// Only run-created relays receive writes; external sources only receive reads.4+import { mkdir, readFile, writeFile, rename } from "node:fs/promises";5+import { homedir } from "node:os";6+import { join, resolve } from "node:path";7+import { pathToFileURL } from "node:url";8+import { randomBytes } from "node:crypto";9+import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure";10+import { encrypt } from "nostr-tools/nip04";11+import { rpc, request, sample, clientMetrics } from "./network-client.mjs";12+13+export const ROLES = ["peer-a", "peer-b", "satellite", "personal", "hub"];14+export const EXTERNAL = ["wss://relay.damus.io", "wss://nos.lol"];15+const now = () => Math.floor(Date.now() / 1000);16+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));17+const secret = (actor) => Uint8Array.from(Buffer.from(actor.secret, "hex"));18+const key = (m, node) => secret(m.actors[node.owner]);19+const log = (s) => console.log(new Date().toISOString() + " " + s);20+21+export function manifest(domain = "bind.ws") {22+ if (!/^[a-z0-9]+(?:[.-][a-z0-9]+)*\.[a-z]{2,}$/.test(domain)) throw new Error("invalid: domain must be a DNS hostname");23+ const id = randomBytes(6).toString("hex");24+ const actors = Object.fromEntries(["alice", "bob", "carole", "agent", "larry"].map((name) => {25+ const sk = generateSecretKey();26+ return [name, { secret: Buffer.from(sk).toString("hex"), pubkey: getPublicKey(sk) }];27+ }));28+ return { version: 1, id, domain, createdAt: new Date().toISOString(), actors,29+ nodes: ROLES.map((role, i) => ({ role, owner: ["alice", "bob", "carole", "alice", "carole"][i],30+ slug: `net-${id}-${role}`, url: `https://net-${id}-${role}.${domain}/`, state: "planned" })) };31+}32+33+// validateManifest prevents recovery from following arbitrary addresses in a file.34+export function validateManifest(m) {35+ if (m.version !== 1 || !/^[a-f0-9]{12}$/.test(m.id) || !/^[a-z0-9]+(?:[.-][a-z0-9]+)*\.[a-z]{2,}$/.test(m.domain)) throw new Error("invalid: network manifest identity");36+ if (!Array.isArray(m.nodes) || m.nodes.length !== ROLES.length) throw new Error("invalid: network manifest nodes");37+ for (const [i, node] of m.nodes.entries()) {38+ const slug = `net-${m.id}-${ROLES[i]}`;39+ if (node.role !== ROLES[i] || node.slug !== slug || node.url !== `https://${slug}.${m.domain}/`) throw new Error("invalid: network manifest target");40+ const actor = m.actors[node.owner];41+ if (!actor || !/^[a-f0-9]{64}$/.test(actor.secret) || getPublicKey(secret(actor)) !== actor.pubkey) throw new Error("invalid: network manifest owner");42+ if (!["planned", "claiming", "claimed", "deleted"].includes(node.state)) throw new Error("invalid: network manifest state");43+ }44+ return m;45+}46+47+export async function save(path, value) {48+ await writeFile(path + ".tmp", JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });49+ await rename(path + ".tmp", path);50+}51+52+// cleanup verifies ownership through an authenticated uncached call before deletion.53+// An unclaimed response also resolves a claim or delete whose reply was lost.54+export async function cleanup(m, persist, call = rpc) {55+ validateManifest(m);56+ const results = [];57+ for (const node of [...m.nodes].reverse()) {58+ if (node.state === "planned" || node.state === "deleted") continue;59+ try {60+ let p;61+ let unclaimed = false;62+ try { p = await call(node, key(m, node), "getpolicy"); }63+ catch (e) {64+ if (e.status !== 403 || e.message !== "restricted: this relay is unclaimed") throw e;65+ unclaimed = true;66+ }67+ if (!unclaimed) {68+ if (!p || p.owner !== m.actors[node.owner].pubkey) throw new Error("restricted: cleanup owner mismatch");69+ const r = await call(node, key(m, node), "deleterelay", node.slug);70+ if (r?.deleted !== true || r.name !== node.slug) throw new Error("error: deletion was not acknowledged");71+ }72+ node.state = "deleted";73+ delete node.cleanupError;74+ results.push({ role: node.role, deleted: true });75+ } catch (e) {76+ node.cleanupError = e.message;77+ results.push({ role: node.role, deleted: false, error: e.message });78+ }79+ await persist();80+ }81+ return results;82+}83+84+export function usageDelta(before, after) {85+ const delta = {};86+ for (const k of Object.keys(after ?? {})) {87+ if (typeof after[k] === "number" && typeof before?.[k] === "number") delta[k] = after[k] - before[k];88+ else if (after[k] && typeof after[k] === "object" && !Array.isArray(after[k])) delta[k] = usageDelta(before?.[k], after[k]);89+ }90+ return delta;91+}92+93+async function run(dir, domain) {94+ await mkdir(dir, { recursive: false, mode: 0o700 });95+ const m = manifest(domain);96+ const manifestPath = join(dir, "manifest.json");97+ const reportPath = join(dir, "report.json");98+ const persist = () => save(manifestPath, m);99+ const report = { version: 1, id: m.id, startedAt: m.createdAt, domain, nodes: [], checks: [], jobs: [], external: [], fixtures: [], actors: Object.fromEntries(Object.entries(m.actors).map(([name, actor]) => [name, actor.pubkey])),100+ limits: { relays: 5, externalSources: 2, externalAuthorsPerSource: 3, jobTimeoutSeconds: 120, runTimeoutSeconds: 1200 },101+ assumptions: { size: "five small relays; external sources treated as much larger, their size is not measured", activity: "short bursts, idle gaps, catch-up and repeat sync; no load or capacity claim", privacy: "public tasks and results, encrypted kind-4 message held only at personal relay" } };102+ const checkpoint = async () => { await persist(); await save(reportPath, report); };103+ await checkpoint();104+ log(`Manifest: ${manifestPath}`);105+ let interrupted = false;106+ const stop = () => { interrupted = true; };107+ process.on("SIGINT", stop);108+ process.on("SIGTERM", stop);109+ const deadline = Date.now() + 1200000;110+ const active = () => { if (interrupted || Date.now() > deadline) throw new Error("error: network exercise interrupted or timed out"); };111+ const node = (role) => m.nodes.find((n) => n.role === role);112+ const call = (n, method, ...args) => rpc(n, key(m, n), method, ...args);113+ const ws = (n) => n.url.replace("https:", "wss:").replace(/\/$/, "");114+ const authors = [m.actors.alice.pubkey, m.actors.bob.pubkey];115+ const event = (actor, text, kind = 1, tags = []) => {116+ const e = finalizeEvent({ kind, content: text, created_at: now(), tags: [["t", `bindws-network-${m.id}`], ["expiration", String(now() + 3600)], ...tags] }, secret(m.actors[actor]));117+ report.fixtures.push(e);118+ return e;119+ };120+ const publish = async (n, e, actor) => {121+ active();122+ const r = await request(n.url + "events", secret(m.actors[actor]), e);123+ if (!r.accepted) throw new Error(r.message ?? "error: event refused");124+ return e;125+ };126+ const query = (n, ids, actor = n.owner) => request(n.url + "query", secret(m.actors[actor]), [{ ids, limit: 100 }]);127+ const check = async (name, fn) => {128+ active();129+ log(name);130+ const start = Date.now();131+ try { const detail = await fn(); report.checks.push({ name, passed: true, milliseconds: Date.now() - start, detail }); }132+ catch (e) { report.checks.push({ name, passed: false, milliseconds: Date.now() - start, error: e.message }); log(`FAILED: ${e.message}`); }133+ await checkpoint();134+ };135+ const expectIds = async (n, ids, actor) => {136+ const found = (await query(n, ids, actor)).map((e) => e.id);137+ if (found.length !== ids.length || ids.some((id) => !found.includes(id))) throw new Error(`error: ${n.role} has ${found.length}/${ids.length} expected events`);138+ return { expected: ids, found };139+ };140+ const absent = async (n, ids, actor) => {141+ const found = await query(n, ids, actor);142+ if (found.length) throw new Error(`error: ${n.role} exposed ${found.length} excluded events`);143+ return { excluded: ids };144+ };145+ const job = async (n, kind, urls, filter, label, strict = true) => {146+ active();147+ const start = Date.now();148+ const j = await call(n, "addjob", { kind, relays: urls, filter, every: 0 });149+ const entry = { role: n.role, label, id: j.id, kind, relays: urls, filter };150+ report.jobs.push(entry);151+ await checkpoint();152+ try {153+ while (Date.now() - start < 120000) {154+ active();155+ const state = (await call(n, "listjobs")).find((x) => x.id === j.id);156+ if (!state) throw new Error("error: job disappeared");157+ if (!state.running && state.nextRun === 0 && state.last) {158+ entry.result = state.last;159+ entry.targetStatus = state.targetStatus;160+ entry.milliseconds = Date.now() - start;161+ if (strict && (state.last.error || state.last.refused || state.last.sources?.some((s) => s.status === "failed" || s.status === "refused" || s.partial))) throw new Error(`error: ${label}: ${state.last.error || "partial or refused sync"}`);162+ return entry;163+ }164+ await sleep(1500);165+ }166+ throw new Error(`error: ${label} exceeded 120 seconds`);167+ } catch (e) { entry.error = e.message; throw e; }168+ finally { await call(n, "removejob", j.id); await checkpoint(); }169+ };170+ try {171+ for (const n of m.nodes) {172+ active();173+ n.state = "claiming";174+ await persist();175+ const r = await call(n, "claim");176+ if (!r.claimed || r.owner !== m.actors[n.owner].pubkey) throw new Error("restricted: generated relay was not claimed by this run");177+ n.state = "claimed";178+ await persist();179+ // Quiet disables unsolicited delivery, notifications and unrelated features.180+ await call(n, "applypreset", "quiet");181+ await call(n, "setpolicy", { writes: "allowlist", reads: "open", directoryPublic: false, guestReplies: false, openKinds: [], features: { sync: true, count: true } });182+ for (const actor of ({ "peer-a": ["bob"], "peer-b": ["alice"], satellite: [], personal: ["agent"], hub: ["alice", "agent"] })[n.role]) if (actor !== n.owner) await call(n, "setmember", m.actors[actor].pubkey, { name: actor });183+ const baseline = await call(n, "stats");184+ report.nodes.push({ role: n.role, url: n.url, owner: m.actors[n.owner].pubkey, baseline, policy: await call(n, "getpolicy") });185+ log(`Created ${n.role}: ${n.url}`);186+ await checkpoint();187+ }188+ const a = node("peer-a"), b = node("peer-b"), personal = node("personal"), hub = node("hub"), satellite = node("satellite");189+ const filter = { authors, kinds: [1], since: now() - 60 };190+ const peerEvents = [];191+ await check("equal peers converge in both directions", async () => {192+ for (let i = 0; i < 4; i++) {193+ peerEvents.push(await publish(a, event("alice", `peer a note ${i}`), "alice"));194+ peerEvents.push(await publish(b, event("bob", `peer b note ${i}`), "bob"));195+ }196+ await job(a, "pull", [ws(b)], filter, "b to a");197+ await job(b, "pull", [ws(a)], filter, "a to b");198+ const ids = peerEvents.map((e) => e.id);199+ return [await expectIds(a, ids), await expectIds(b, ids)];200+ });201+ await check("repeat sync has no duplicate events", async () => {202+ await job(a, "pull", [ws(b)], filter, "repeat b to a");203+ const ids = peerEvents.map((e) => e.id);204+ await publish(a, peerEvents[0], "alice");205+ return expectIds(a, ids);206+ });207+ await check("an idle peer catches up with the next burst", async () => {208+ await sleep(2500);209+ const events = [];210+ for (let i = 0; i < 3; i++) events.push(await publish(a, event("alice", `catch-up ${i}`), "alice"));211+ await absent(b, events.map((e) => e.id));212+ await job(b, "pull", [ws(a)], filter, "catch-up a to b");213+ return expectIds(b, [...peerEvents, ...events].map((e) => e.id));214+ });215+ await check("the personal relay exchanges selected notes with the peer network", async () => {216+ const note = await publish(personal, event("alice", "public personal note for the small peer network"), "alice");217+ await job(personal, "push", [ws(a)], { authors: [m.actors.alice.pubkey], kinds: [1] }, "personal to peer a");218+ await job(b, "pull", [ws(a)], filter, "personal note through peer a to b");219+ await expectIds(b, [note.id]);220+ await job(personal, "pull", [ws(b)], { authors: [m.actors.bob.pubkey], kinds: [1] }, "bob from peer network to personal");221+ return expectIds(personal, peerEvents.filter((e) => e.pubkey === m.actors.bob.pubkey).map((e) => e.id));222+ });223+ let privateEvent;224+ await check("personal tasks reach the hub and agent results return", async () => {225+ const task = await publish(personal, event("alice", "public task: count the words in this synthetic note"), "alice");226+ privateEvent = await publish(personal, event("alice", await encrypt(secret(m.actors.alice), m.actors.bob.pubkey, "synthetic private note"), 4, [["p", m.actors.bob.pubkey]]), "alice");227+ await job(personal, "push", [ws(hub)], { authors: [m.actors.alice.pubkey], kinds: [1] }, "personal tasks to hub");228+ await expectIds(hub, [task.id], "agent");229+ const result = await publish(hub, event("agent", "public result: synthetic task complete", 1, [["e", task.id], ["p", m.actors.alice.pubkey]]), "agent");230+ await job(personal, "pull", [ws(hub)], { authors: [m.actors.agent.pubkey], kinds: [1] }, "agent results to personal");231+ return { task: task.id, result: result.id, received: await expectIds(personal, [result.id]), privateExcluded: await absent(hub, [privateEvent.id], "bob") };232+ });233+ await check("private messages stay visible only to their parties", async () => {234+ if (!privateEvent) throw new Error("error: private fixture was not published");235+ return { recipient: await expectIds(personal, [privateEvent.id], "bob"), stranger: await absent(personal, [privateEvent.id], "larry") };236+ });237+ await check("non-member writes and management are refused", async () => {238+ for (const n of [a, personal, hub]) {239+ try { await publish(n, event("larry", "unauthorized synthetic write"), "larry"); throw new Error("error: unauthorized write accepted"); }240+ catch (e) { if (e.status !== 400 || !/restricted:|blocked:|auth-required:/.test(e.message)) throw e; }241+ try { await rpc(n, secret(m.actors.larry), "setpolicy", { writes: "open" }); throw new Error("error: unauthorized management accepted"); }242+ catch (e) { if (e.status !== 403 || !e.message.startsWith("restricted:")) throw e; }243+ }244+ });245+ await check("author and kind filters exclude unrelated hub data", async () => {246+ const unrelated = await publish(hub, event("carole", "hub local note"), "carole");247+ const metadata = await publish(hub, event("agent", JSON.stringify({ name: "synthetic agent" }), 0), "agent");248+ await job(personal, "pull", [ws(hub)], { authors: [m.actors.agent.pubkey], kinds: [1] }, "filtered hub repeat");249+ return absent(personal, [unrelated.id, metadata.id]);250+ });251+ await check("members-only sources report a refused unauthenticated pull", async () => {252+ await call(hub, "setpolicy", { reads: "members" });253+ try {254+ const r = await job(personal, "pull", [ws(hub)], { authors: [m.actors.agent.pubkey], kinds: [1] }, "restricted source", false);255+ if (!r.result.sources?.some((s) => s.status === "refused")) throw new Error("error: restricted source was not classified as refused");256+ return r.result;257+ } finally { await call(hub, "setpolicy", { reads: "open" }); }258+ });259+ for (const source of EXTERNAL) {260+ active();261+ log(`Read-only external sample: ${source}`);262+ const selection = await sample(source, { kinds: [0], since: now() - 86400, limit: 3 }, 3);263+ const entry = { source, sampling: { status: selection.status, error: selection.error }, sampledIds: selection.events.map((e) => e.id), authors: [...new Set(selection.events.map((e) => e.pubkey))] };264+ report.external.push(entry);265+ if (!entry.authors.length) { entry.outcome = "inconclusive: no public sample"; await checkpoint(); continue; }266+ try {267+ const j = await job(satellite, "pull", [source], { authors: entry.authors, kinds: [0], since: now() - 86400 }, `external ${source}`, false);268+ const received = await request(satellite.url + "query", key(m, satellite), [{ authors: entry.authors, kinds: [0], limit: 10 }]);269+ entry.receivedIds = received.map((e) => e.id);270+ entry.job = j.result;271+ entry.outcome = received.length ? "observed public profile import" : "inconclusive: no matching profiles imported";272+ } catch (e) { entry.outcome = "inconclusive: " + e.message; }273+ if (entry.receivedIds?.length) {274+ await check("selected satellite profiles reach the agent hub", async () => {275+ const context = await job(hub, "pull", [ws(satellite)], { authors: entry.authors, kinds: [0] }, "satellite profile context to hub");276+ entry.hubContext = { job: context.result, received: await expectIds(hub, entry.receivedIds) };277+ return entry.hubContext;278+ });279+ }280+ await checkpoint();281+ }282+ await check("the external satellite stays separate from synthetic peer traffic", async () => absent(satellite, peerEvents.map((e) => e.id)));283+ } catch (e) {284+ report.error = e.message;285+ log(e.message);286+ } finally {287+ log("Extracting final usage and tearing down run-created relays");288+ for (const n of m.nodes.filter((n) => n.state === "claimed")) {289+ const entry = report.nodes.find((x) => x.role === n.role);290+ try {291+ const stats = await call(n, "stats");292+ if (entry) { entry.storage = await call(n, "storagestats"); entry.final = stats; entry.usageDelta = usageDelta(entry.baseline.fuel, stats.fuel); }293+ } catch (e) { if (entry) entry.extractionError = e.message; }294+ }295+ report.cleanup = await cleanup(m, persist);296+ report.client = { ...clientMetrics };297+ report.finishedAt = new Date().toISOString();298+ report.passed = !report.error && report.checks.length > 0 && report.checks.every((c) => c.passed) && report.cleanup.every((c) => c.deleted) && !report.nodes.some((n) => n.extractionError);299+ await checkpoint();300+ process.off("SIGINT", stop);301+ process.off("SIGTERM", stop);302+ }303+ log(`${report.passed ? "PASS" : "FAIL"}: ${report.checks.filter((c) => c.passed).length}/${report.checks.length} checks; report ${reportPath}`);304+ if (!report.passed) process.exitCode = 1;305+}306+307+export async function main(args) {308+ const [command, target, domain, ...extra] = args;309+ if (extra.length || !["run", "cleanup", "plan"].includes(command)) {310+ console.log("npm run test:network -- plan\nnpm run test:network -- run [new-output-directory] [domain]\nnpm run test:network -- cleanup <output-directory>");311+ if (command && command !== "--help") process.exitCode = 1;312+ return;313+ }314+ if (command === "plan") {315+ console.log(JSON.stringify({ roles: ROLES, externalReadOnly: EXTERNAL, run: "finite one-shot jobs, no schedule", cleanup: "finally and recoverable manifest", assertions: "peer convergence, dedup, catch-up, task/result filters, privacy and access refusal" }, null, 2));316+ return;317+ }318+ if (command === "cleanup") {319+ if (!target || domain) throw new Error("invalid: cleanup needs one output directory");320+ const path = join(resolve(target), "manifest.json");321+ const m = validateManifest(JSON.parse(await readFile(path, "utf8")));322+ const results = await cleanup(m, () => save(path, m));323+ await save(join(resolve(target), "cleanup.json"), results);324+ console.log(JSON.stringify(results, null, 2));325+ if (results.some((r) => !r.deleted)) process.exitCode = 1;326+ return;327+ }328+ const root = join(homedir(), ".local", "share", "bindws", "network-tests");329+ if (!target) await mkdir(root, { recursive: true, mode: 0o700 });330+ await run(target ? resolve(target) : join(root, new Date().toISOString().replace(/[:.]/g, "-") + "-" + randomBytes(3).toString("hex")), domain ?? "bind.ws");331+}332+333+if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {334+ main(process.argv.slice(2)).catch((e) => { console.error(e.message); process.exitCode = 1; });335+}@@ -0,0 +1,75 @@
1+import { createServer } from "node:http";2+import { describe, expect, it } from "vitest";3+import { WebSocketServer } from "ws";4+import { finalizeEvent, generateSecretKey } from "nostr-tools/pure";5+// @ts-expect-error the production-only helper is JavaScript by design.6+import { request, sample } from "../../scripts/ops/network-client.mjs";7+8+const key = generateSecretKey();9+10+describe("network test client", () => {11+ it("does not retry an ambiguous mutation response", async () => {12+ let calls = 0;13+ const server = createServer((_req, res) => {14+ calls++;15+ res.writeHead(503, { "content-type": "application/json" });16+ res.end(JSON.stringify({ error: "error: upstream unavailable" }));17+ });18+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));19+ const address = server.address();20+ const url = `http://127.0.0.1:${typeof address === "object" && address ? address.port : 0}/`;21+ await expect(request({ url }, key, { mutation: true })).rejects.toMatchObject({ status: 503, message: "error: upstream unavailable" });22+ expect(calls).toBe(1);23+ await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));24+ });25+26+ it("retries an explicit operation-in-progress refusal and returns the response", async () => {27+ let calls = 0;28+ const server = createServer((_req, res) => {29+ calls++;30+ res.writeHead(calls === 1 ? 429 : 200, { "content-type": "application/json" });31+ res.end(JSON.stringify(calls === 1 ? { error: "restricted: relay operation in progress; retry" } : { result: { ok: true } }));32+ });33+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));34+ const address = server.address();35+ const url = `http://127.0.0.1:${typeof address === "object" && address ? address.port : 0}/`;36+ await expect(request({ url }, key, { method: "stats", params: [] }, true)).resolves.toEqual({ result: { ok: true } });37+ expect(calls).toBe(2);38+ await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));39+ });40+41+ it("samples signed events after an unsolicited auth challenge without publishing", async () => {42+ const server = new WebSocketServer({ port: 0 });43+ let clientMessages: string[] = [];44+ const event = finalizeEvent({ kind: 0, content: JSON.stringify({ name: "sample" }), tags: [], created_at: Math.floor(Date.now() / 1000) }, key);45+ server.on("connection", (socket) => {46+ socket.on("message", (data) => {47+ clientMessages.push(data.toString());48+ socket.send(JSON.stringify(["AUTH", "challenge"]));49+ socket.send(JSON.stringify(["EVENT", "network-sample", { id: "bad" }]));50+ socket.send(JSON.stringify(["EVENT", "network-sample", event]));51+ socket.send(JSON.stringify(["EOSE", "network-sample"]));52+ });53+ });54+ await new Promise<void>((resolve) => server.once("listening", () => resolve()));55+ const address = server.address();56+ const result = await sample(`ws://127.0.0.1:${typeof address === "object" && address ? address.port : 0}`, { kinds: [0] });57+ expect(result.status).toBe("complete");58+ expect(result.events.map((x: { id: string }) => x.id)).toEqual([event.id]);59+ expect(clientMessages).toHaveLength(1);60+ expect(clientMessages[0]).not.toContain("AUTH");61+ expect(clientMessages[0]).not.toContain("EVENT");62+ server.close();63+ });64+65+ it("reports a source that closes before completing its sample", async () => {66+ const server = new WebSocketServer({ port: 0 });67+ server.on("connection", (socket) => socket.close());68+ await new Promise<void>((resolve) => server.once("listening", () => resolve()));69+ const address = server.address();70+ const result = await sample(`ws://127.0.0.1:${typeof address === "object" && address ? address.port : 0}`);71+ expect(result.status).toBe("failed");72+ expect(result.error).toContain("closed");73+ server.close();74+ });75+});@@ -0,0 +1,85 @@
1+import { describe, it, expect, vi } from "vitest";2+const { manifest, validateManifest, cleanup, usageDelta } = await import(new URL("../../scripts/ops/network.mjs", import.meta.url).href);3+4+describe("network cleanup", () => {5+ it("rejects a recovery manifest that points outside its run before making a request", async () => {6+ const m = manifest();7+ m.nodes[0].state = "claimed";8+ m.nodes[0].url = "https://someone-else.bind.ws/";9+ const call = vi.fn();10+ await expect(cleanup(m, vi.fn(), call)).rejects.toThrow("manifest target");11+ expect(call).not.toHaveBeenCalled();12+ });13+14+ it("recovers a lost claim reply and persists each deletion independently", async () => {15+ const m = manifest();16+ m.nodes[0].state = "claiming";17+ m.nodes[1].state = "claimed";18+ const saved: string[][] = [];19+ const call = vi.fn(async (n: { owner: string; slug: string }, _key: unknown, method: string, slug?: string) => {20+ if (method === "getpolicy") return { owner: m.actors[n.owner].pubkey };21+ expect(method).toBe("deleterelay");22+ expect(slug).toBe(n.slug);23+ return { deleted: true, name: slug };24+ });25+ const results = await cleanup(m, async () => saved.push(m.nodes.map((n: { state: string }) => n.state)), call);26+ expect(results).toHaveLength(2);27+ expect(results.every((r: { deleted: boolean }) => r.deleted)).toBe(true);28+ expect(saved).toHaveLength(2);29+ expect(saved[0][0]).toBe("claiming");30+ expect(saved[1][0]).toBe("deleted");31+ expect(call).toHaveBeenCalledTimes(4);32+ });33+34+ it("continues after a failed deletion and never deletes a mismatched owner", async () => {35+ const m = manifest();36+ m.nodes[0].state = "claimed";37+ m.nodes[1].state = "claimed";38+ const call = vi.fn(async (n: { role: string; owner: string; slug: string }, _key: unknown, method: string) => {39+ if (method === "getpolicy") return { owner: n.role === "peer-b" ? "ff".repeat(32) : m.actors[n.owner].pubkey };40+ return { deleted: true, name: n.slug };41+ });42+ const r = await cleanup(m, vi.fn(), call);43+ expect(r[0].deleted).toBe(false);44+ expect(r[1].deleted).toBe(true);45+ expect(call.mock.calls.filter((c) => c[2] === "deleterelay")).toHaveLength(1);46+ expect(m.nodes[1].state).toBe("claimed");47+ });48+49+ it("treats only the authenticated unclaimed response as an already completed deletion", async () => {50+ const m = manifest();51+ m.nodes[0].state = "claiming";52+ const call = vi.fn(async () => { throw Object.assign(new Error("restricted: this relay is unclaimed"), { status: 403 }); });53+ expect((await cleanup(m, vi.fn(), call))[0].deleted).toBe(true);54+ expect(call).toHaveBeenCalledTimes(1);55+ m.nodes[0].state = "claiming";56+ call.mockImplementation(async () => { throw Object.assign(new Error("restricted: not the relay owner"), { status: 403 }); });57+ expect((await cleanup(m, vi.fn(), call))[0].deleted).toBe(false);58+ expect(m.nodes[0].state).toBe("claiming");59+ });60+61+ it("keeps malformed successful ownership responses pending for recovery", async () => {62+ for (const policy of [null, undefined, false, {}, { owner: "" }]) {63+ const m = manifest();64+ m.nodes[0].state = "claimed";65+ const call = vi.fn(async () => policy);66+ const results = await cleanup(m, vi.fn(), call);67+ expect(results[0].deleted).toBe(false);68+ expect(m.nodes[0].state).toBe("claimed");69+ expect(call).toHaveBeenCalledTimes(1);70+ }71+ });72+73+ it("rejects duplicate nodes and a secret that does not match the recorded owner", () => {74+ const m = manifest();75+ m.nodes[1] = { ...m.nodes[0] };76+ expect(() => validateManifest(m)).toThrow("manifest target");77+ const second = manifest();78+ second.actors.alice.pubkey = second.actors.bob.pubkey;79+ expect(() => validateManifest(second)).toThrow("manifest owner");80+ });81+});82+83+it("reports counter deltas without confusing flags or absent samples with zero usage", () => {84+ expect(usageDelta({ activeMs: 2, outOfFuel: false }, { activeMs: 9, outOfFuel: true, rowsRead: 10 })).toEqual({ activeMs: 7 });85+});