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 / test / object / config.test.ts
config.test.ts 15.0K
1// The owner's configuration as a whole: export and import, with the rules2// and address blocks travelling along, retention and purge, and teardown.3import { SELF, env, runInDurableObject } from "cloudflare:test";4import { describe, it, expect } from "vitest";5import { generateSecretKey, getPublicKey } from "nostr-tools/pure";6import { sha256 } from "@noble/hashes/sha2.js";7import type { Relay } from "../../src/relay.ts";8import { bytesToHex } from "../../src/negentropy.ts";9import { now, ev, rpc } from "../helpers/relay.ts";10import { WS } from "../helpers/ws.ts";1112describe("configuration export and import", () => {13 it("round-trips policy, members, bans and kinds between relays without touching data or owner", async () => {14 const a = "cfg-a.bind.ws", b = "cfg-b.bind.ws";15 const ownerA = generateSecretKey(), ownerB = generateSecretKey();16 const m1 = getPublicKey(generateSecretKey()), bad = getPublicKey(generateSecretKey());17 await rpc(a, ownerA, "claim");18 await rpc(a, ownerA, "setpolicy", { writes: "allowlist", reads: "members", name: "Club", joinTerms: "be kind", minPow: 3, directoryPublic: false });19 await rpc(a, ownerA, "setmember", m1, { name: "alice", note: "friend" });20 await rpc(a, ownerA, "banpubkey", bad, "spam");21 await rpc(a, ownerA, "allowkind", 1);22 await rpc(a, ownerA, "disallowkind", 7);23 const cfg = (await rpc(a, ownerA, "exportconfig")).result;24 expect(cfg.format).toBe("bind.ws/relay-config/2");25 expect(cfg.policy.owner).toBeUndefined();26 expect(cfg.members).toEqual([{ pubkey: m1, name: "alice", note: "friend", role: "member" }]);27 expect(cfg.bans).toEqual([{ pubkey: bad, reason: "spam" }]);28 expect(cfg.kinds).toEqual({ allow: [1], block: [7] });2930 await rpc(b, ownerB, "claim");31 const c = await WS.connect(b);32 await c.ok(ev(ownerB, 1, "keep me"));33 expect((await rpc(b, ownerB, "importconfig", { format: "nope" })).status).toBe(400);34 const after = (await rpc(b, ownerB, "importconfig", cfg)).result;35 expect(after.members).toEqual(cfg.members);36 expect(after.bans).toEqual(cfg.bans);37 expect(after.kinds).toEqual(cfg.kinds);38 const p = (await rpc(b, ownerB, "getpolicy")).result;39 expect([p.writes, p.reads, p.name, p.joinTerms, p.minPow, p.directoryPublic, p.owner]).toEqual(["allowlist", "members", "Club", "be kind", 3, false, getPublicKey(ownerB)]);40 expect((await rpc(b, ownerB, "stats")).result.events).toBeGreaterThan(0);41 const doc: any = await (await SELF.fetch(`http://${b}/.well-known/nostr.json?name=alice`)).json();42 expect(doc.names).toEqual({ alice: m1 });43 // The roster on b now carries the imported member.44 const rc = await WS.connect(b);45 await rc.auth(ownerB, b);46 const r = await rc.req({ kinds: [13534] });47 expect(r[0].tags).toContainEqual(["member", m1]);48 });4950 it("dry-runs a document into a summary of changes and applies nothing; a section left out is left alone", async () => {51 const host = "cfg-dry.bind.ws";52 const owner = generateSecretKey();53 const m1 = getPublicKey(generateSecretKey()), m2 = getPublicKey(generateSecretKey());54 await rpc(host, owner, "claim");55 await rpc(host, owner, "setmember", m1, { name: "alice" });56 await rpc(host, owner, "allowkind", 1);57 const doc = {58 format: "bind.ws/relay-config/2",59 policy: { writes: "owner", minPow: 2, reads: "sideways", nonsense: 1, owner: "ab".repeat(32) },60 members: [{ pubkey: m1, name: "alice", note: "", role: "moderator" }, { pubkey: m2, name: "bob", note: "", role: "member" }, { pubkey: "nope" }],61 kinds: { allow: [1, 7], block: [99999] },62 retention: [{ kind: null, days: 30 }],63 };64 const dry = (await rpc(host, owner, "importconfig", doc, { dryRun: true })).result;65 expect(dry.dryRun).toBe(true);66 expect(dry.changes.policy.map((c: any) => c.field).sort()).toEqual(["minPow", "writes"]);67 expect(dry.changes.members.add.map((m: any) => m.pubkey)).toEqual([m2]);68 expect(dry.changes.members.change.map((m: any) => m.pubkey)).toEqual([m1]);69 expect(dry.changes.kinds.allow.add).toEqual([7]);70 expect(dry.changes.retention.add).toEqual([{ kind: null, days: 30 }]);71 expect(dry.changes.summary.length).toBeGreaterThanOrEqual(4);72 expect(dry.warnings).toEqual(["policy.reads: value not accepted", "policy.nonsense: not a setting", "policy.owner: not carried by a configuration", "members[2]: pubkey must be 64 hex chars", "kinds.block[0]: kind out of range"]);73 // Nothing moved.74 expect((await rpc(host, owner, "getpolicy")).result.writes).toBe("open");75 expect((await rpc(host, owner, "listmembers")).result.members.map((m: any) => m.pubkey)).toEqual([getPublicKey(owner), m1]);76 expect((await rpc(host, owner, "listaudit")).result.some((r: any) => r.action === "importconfig")).toBe(false);77 // Applied, and then a rules-only document leaves the people alone.78 expect((await rpc(host, owner, "importconfig", doc)).status).toBe(200);79 expect((await rpc(host, owner, "listmembers")).result.members.length).toBe(3);80 expect((await rpc(host, owner, "importconfig", { format: "bind.ws/relay-config/2", policy: { writes: "open" }, kinds: { allow: [], block: [] } })).status).toBe(200);81 expect((await rpc(host, owner, "getpolicy")).result.writes).toBe("open");82 expect((await rpc(host, owner, "listmembers")).result.members.length).toBe(3);83 expect((await rpc(host, owner, "listallowedkinds")).result).toEqual([]);84 expect((await rpc(host, owner, "listretention")).result).toEqual([{ kind: null, days: 30 }]);85 const again = (await rpc(host, owner, "importconfig", doc, { dryRun: true })).result;86 expect(again.changes.summary.filter((l: string) => !l.startsWith("writes")).length).toBe(1);87 });88});8990describe("rules in export and import", () => {91 it("carries the new rules through export and import", async () => {92 const a = "gate-a.bind.ws", b = "gate-b.bind.ws";93 const owner = generateSecretKey();94 await rpc(a, owner, "claim");95 await rpc(b, owner, "claim");96 await rpc(a, owner, "setpolicy", { writes: "wot", openKinds: [7, 9735], guestReplies: true, reportThreshold: 3 });97 await rpc(a, owner, "setblockedwords", ["casino"]);98 const cfg = (await rpc(a, owner, "exportconfig")).result;99 expect(cfg.policy).toMatchObject({ writes: "wot", openKinds: [7, 9735], guestReplies: true, reportThreshold: 3, blockedWords: ["casino"] });100 expect((await rpc(b, owner, "importconfig", cfg)).status).toBe(200);101 const p = (await rpc(b, owner, "getpolicy")).result;102 expect(p).toMatchObject({ writes: "wot", openKinds: [7, 9735], guestReplies: true, reportThreshold: 3, blockedWords: ["casino"] });103 // resetrules puts the guest rules back but keeps the moderation ones.104 await rpc(b, owner, "resetrules");105 const q = (await rpc(b, owner, "getpolicy")).result;106 expect(q).toMatchObject({ writes: "open", openKinds: [], guestReplies: false, reportThreshold: 3, blockedWords: ["casino"] });107 });108});109110describe("address blocks in the configuration", () => {111 it("export carries them and import applies them, dropping the sockets they refuse", async () => {112 const a = "addr-a.bind.ws";113 const b = "addr-b.bind.ws";114 const owner = generateSecretKey();115 const bad = "203.0.113.9";116 await rpc(a, owner, "claim");117 await rpc(b, owner, "claim");118 await rpc(a, owner, "blockip", bad, "scraper");119 await rpc(a, owner, "blockip", "2001:DB8::9", "");120 const cfg = (await rpc(a, owner, "exportconfig")).result;121 const byIP = (l: { ip: string; reason: string }[]) => [...l].sort((x, y) => x.ip.localeCompare(y.ip));122 expect(byIP(cfg.addresses)).toEqual([{ ip: "2001:db8::9", reason: "" }, { ip: bad, reason: "scraper" }]);123124 // The second relay has a stale block of its own and an open socket from the address about to be refused.125 await rpc(b, owner, "blockip", "198.51.100.200", "old");126 const open = (await WS.tryConnect(b, bad))!;127 expect((await open.ok(ev(owner, 1, "before"))).ok).toBe(true);128 cfg.addresses.push({ ip: "not an address", reason: "x" }, { reason: "no ip" });129 const imported = await rpc(b, owner, "importconfig", cfg);130 expect(imported.status).toBe(200);131 expect(byIP(imported.result.addresses)).toEqual([{ ip: "2001:db8::9", reason: "" }, { ip: bad, reason: "scraper" }]);132 expect((await rpc(b, owner, "listblockedips")).result.map((x: { ip: string }) => x.ip).sort()).toEqual(["2001:db8::9", bad]);133 for (let i = 0; i < 40 && !open.closed; i++) await new Promise((r) => setTimeout(r, 25));134 expect(open.closed?.code).toBe(4403);135 expect(await WS.tryConnect(b, bad)).toBeNull();136 expect(await WS.tryConnect(b, "198.51.100.200")).not.toBeNull();137 // A document without the list leaves the blocks alone; an empty list clears them.138 delete cfg.addresses;139 await rpc(b, owner, "importconfig", cfg);140 expect((await rpc(b, owner, "listblockedips")).result.length).toBe(2);141 cfg.addresses = [];142 await rpc(b, owner, "importconfig", cfg);143 expect((await rpc(b, owner, "listblockedips")).result).toEqual([]);144 expect(await WS.tryConnect(b, bad)).not.toBeNull();145 });146});147148describe("storage: retention and purge", () => {149 it("refuses events past a keep-for rule, sheds old ones daily, spares replaceable kinds from the catch-all, and purges on demand", async () => {150 const host = "keep.bind.ws";151 const owner = generateSecretKey();152 await rpc(host, owner, "claim");153 const c = await WS.connect(host);154 const day = 86400;155 // Before any rule: an old reaction, an old note, an old profile all land.156 expect((await c.ok(ev(owner, 7, "+", [], now() - 40 * day))).ok).toBe(true);157 expect((await c.ok(ev(owner, 1, "old note", [], now() - 40 * day))).ok).toBe(true);158 expect((await c.ok(ev(owner, 0, '{"name":"old me"}', [], now() - 400 * day))).ok).toBe(true);159 expect((await c.ok(ev(owner, 1, "fresh note"))).ok).toBe(true);160161 // Reactions kept 30 days, everything else 100 days.162 expect((await rpc(host, owner, "setretention", 7, 30)).result).toEqual([{ kind: 7, days: 30 }]);163 await rpc(host, owner, "setretention", null, 100);164 expect((await c.ok(ev(owner, 7, "+", [], now() - 31 * day))).msg).toMatch(/^blocked: this relay keeps kind 7 for 30 days/);165 expect((await c.ok(ev(owner, 7, "+", [], now() - 29 * day))).ok).toBe(true);166 expect((await c.ok(ev(owner, 1, "too old", [], now() - 101 * day))).msg).toMatch(/keeps kind 1 for 100 days/);167 // The catch-all spares replaceable kinds: a very old profile still lands.168 expect((await c.ok(ev(owner, 0, '{"name":"newer old me"}', [], now() - 300 * day))).ok).toBe(true);169 const info: any = await (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json();170 expect(info.retention).toEqual([{ kinds: [7], time: 30 * day }, { time: 100 * day }]);171172 // The daily sweep sheds the 40-day-old reaction and keeps the 40-day-old note (under 100).173 const stub = env.RELAY.getByName("keep");174 await runInDurableObject(stub, async (r: Relay) => expect(r.sweepRetention(now())).toBe(1));175 let st = (await rpc(host, owner, "storagestats")).result;176 const byKind = Object.fromEntries(st.kinds.map((k: any) => [k.kind, k.n]));177 expect(byKind).toEqual({ 1: 2, 7: 1, 0: 2, 13534: 1, 30166: 1, 33534: 2, 39000: 1, 39001: 1, 39002: 1, 39003: 1 }); // the roster, profile, discovery record, role definitions and group state are the relay's own178 expect(st.events).toBe(13);179 expect(st.eventBytes).toBeGreaterThan(0);180 expect(st.retention).toEqual([{ kind: 7, days: 30 }, { kind: null, days: 100 }]);181182 // Purge notes older than 10 days, then everything of kind 7.183 expect((await rpc(host, owner, "purgekind", 1, 10)).result).toEqual({ deleted: 1 });184 expect((await rpc(host, owner, "purgekind", 7, 0)).result).toEqual({ deleted: 1 });185 st = (await rpc(host, owner, "storagestats")).result;186 expect(st.events).toBe(11);187188 // Rules travel with the configuration; removing one is days 0.189 const cfg = (await rpc(host, owner, "exportconfig")).result;190 expect(cfg.retention).toEqual([{ kind: 7, days: 30 }, { kind: null, days: 100 }]);191 await rpc(host, owner, "setretention", null, 0);192 expect((await rpc(host, owner, "listretention")).result).toEqual([{ kind: 7, days: 30 }]);193 expect((await rpc(host, owner, "setretention", 70000, 5)).status).toBe(400);194 // Load-bearing kinds can be neither expired nor purged, and the catch-all skips them.195 expect((await rpc(host, owner, "setretention", 0, 5)).status).toBe(400);196 expect((await rpc(host, owner, "purgekind", 13534, 0)).status).toBe(400);197 await rpc(host, owner, "setretention", null, 1);198 expect((await rpc(host, owner, "purgekind", null, 0)).result.deleted).toBe(1); // the fresh note; profile, roster and group state stay199 expect((await rpc(host, owner, "storagestats")).result.kinds.map((k: any) => k.kind).sort((a: number, b: number) => a - b)).toEqual([0, 13534, 30166, 33534, 39000, 39001, 39002, 39003]);200 });201});202203describe("teardown", () => {204 it("requires the typed name, then wipes everything and returns the name to unclaimed", async () => {205 const host = "gone.bind.ws";206 const owner = generateSecretKey();207 const member = generateSecretKey();208 await rpc(host, owner, "claim");209 await rpc(host, owner, "setmember", getPublicKey(member), { name: "bob" });210 const c = await WS.connect(host);211 await c.ok(ev(owner, 1, "to be deleted"));212 await c.open("live", { kinds: [1] });213 const body = new TextEncoder().encode("bytes");214 const sha = bytesToHex(sha256(body));215 const token = "Nostr " + btoa(JSON.stringify(ev(owner, 24242, "upload", [["t", "upload"], ["x", sha], ["expiration", String(now() + 300)]])));216 expect((await SELF.fetch(`http://${host}/upload`, { method: "PUT", headers: { authorization: token, "content-type": "text/plain" }, body })).status).toBe(200);217218 expect((await rpc(host, member, "deleterelay", "gone")).status).toBe(403);219 const wrong = await rpc(host, owner, "deleterelay", "typo");220 expect(wrong.status).toBe(400);221 expect((await rpc(host, owner, "stats")).result.events).toBeGreaterThan(0);222223 expect((await rpc(host, owner, "deleterelay", "gone")).result).toEqual({ deleted: true, name: "gone" });224 for (let i = 0; i < 20 && !c.closed; i++) await new Promise((r) => setTimeout(r, 50));225 expect(c.closed?.code).toBe(4410);226 const info: any = await (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json();227 expect(info.pubkey).toBeUndefined();228 expect(info.self).toBeUndefined();229 expect((await SELF.fetch(`http://${host}/${sha}`)).status).toBe(404);230 expect((await env.MEDIA.list({ prefix: "gone/" })).objects).toEqual([]);231 const fresh = await WS.connect(host);232 expect(await fresh.req({ kinds: [1] })).toEqual([]);233 expect((await fresh.ok(ev(owner, 1, "again"))).msg).toMatch(/unclaimed/);234 const doc: any = await (await SELF.fetch(`http://${host}/.well-known/nostr.json?name=bob`)).json();235 expect(doc.names).toEqual({});236 // Anyone can claim the name again, including someone else.237 const other = generateSecretKey();238 expect((await rpc(host, other, "claim")).result.claimed).toBe(true);239 });240});