clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

dependabot/npm_and_yarn/npm-f01a459b0f
bindws / scripts / ops / network.mjs
network.mjs 19.6K
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.4import { mkdir, readFile, writeFile, rename } from "node:fs/promises";5import { homedir } from "node:os";6import { join, resolve } from "node:path";7import { pathToFileURL } from "node:url";8import { randomBytes } from "node:crypto";9import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure";10import { encrypt } from "nostr-tools/nip04";11import { rpc, request, sample, clientMetrics } from "./network-client.mjs";1213export const ROLES = ["peer-a", "peer-b", "satellite", "personal", "hub"];14export const EXTERNAL = ["wss://relay.damus.io", "wss://nos.lol"];15const now = () => Math.floor(Date.now() / 1000);16const sleep = (ms) => new Promise((r) => setTimeout(r, ms));17const secret = (actor) => Uint8Array.from(Buffer.from(actor.secret, "hex"));18const key = (m, node) => secret(m.actors[node.owner]);19const log = (s) => console.log(new Date().toISOString() + " " + s);2021export 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}3233// validateManifest prevents recovery from following arbitrary addresses in a file.34export 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}4647export 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}5152// cleanup verifies ownership through an authenticated uncached call before deletion.53// An unclaimed response also resolves a claim or delete whose reply was lost.54export 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}8384export 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}9293async 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}306307export 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}332333if (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}