clone

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

test: the write gate and the moderation tools

Dami committed 5764f5f
commit
5764f5fe3a03f52fa1b03c7a95aa7056dd011e41
parent
0c7ffc987c10a893c4ee59e4826cfeab3c40032a
author
Dami <dami.etoile@gmail.com>
1 file changed, +293 -0
Atest/gate.test.ts293++++++++++++++++++++++++++++++
A test/gate.test.ts +293 -0
@@ -0,0 +1,293 @@
1+// The write gate and the moderation tools: guests past a limited write2+// rule, the web of trust, blocked words, a ban that erases, and reports3+// that hide an event until a moderator looks.4+import { SELF } from "cloudflare:test";5+import { describe, it, expect } from "vitest";6+import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";7+import { getToken } from "nostr-tools/nip98";8+import { sha256 } from "@noble/hashes/sha2.js";9+import { bytesToHex } from "../src/negentropy.ts";10+11+const now = () => Math.floor(Date.now() / 1000);12+const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()) => finalizeEvent({ kind, content, tags, created_at }, sk);13+const pk = (sk: Uint8Array) => getPublicKey(sk);14+15+async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {16+ const url = `http://${host}/`;17+ const payload = { method, params };18+ const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);19+ const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });20+ return { status: resp.status, ...(await resp.json<any>()) };21+}22+23+class WS {24+ private queue: any[][] = [];25+ private waiters: ((m: any[]) => void)[] = [];26+ constructor(public ws: WebSocket) {27+ ws.accept();28+ ws.addEventListener("message", (e) => {29+ const m = JSON.parse(e.data as string);30+ const w = this.waiters.shift();31+ if (w) w(m);32+ else this.queue.push(m);33+ });34+ }35+ static async connect(host: string) {36+ const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });37+ const c = new WS(resp.webSocket!);38+ await c.expect("AUTH");39+ return c;40+ }41+ send(...m: unknown[]) {42+ this.ws.send(JSON.stringify(m));43+ }44+ recv(): Promise<any[]> {45+ const m = this.queue.shift();46+ if (m) return Promise.resolve(m);47+ return new Promise((res) => this.waiters.push(res));48+ }49+ async expect(type: string) {50+ const m = await this.recv();51+ expect(m[0], JSON.stringify(m)).toBe(type);52+ return m;53+ }54+ async ok(e: Event) {55+ this.send("EVENT", e);56+ const m = await this.expect("OK");57+ return { ok: m[2] as boolean, msg: m[3] as string };58+ }59+ private n = 0;60+ async req(filter: unknown) {61+ const id = "q" + ++this.n;62+ this.send("REQ", id, filter);63+ const events: Event[] = [];64+ for (;;) {65+ const m = await this.recv();66+ if (m[0] === "EVENT" && m[1] === id) events.push(m[2]);67+ else if (m[0] === "EOSE" && m[1] === id) {68+ this.send("CLOSE", id);69+ return events;70+ } else if (m[0] === "CLOSED" && m[1] === id) throw new Error(m[2]);71+ }72+ }73+ async count(filter: unknown) {74+ const id = "c" + ++this.n;75+ this.send("COUNT", id, filter);76+ const m = await this.expect("COUNT");77+ return m[2].count as number;78+ }79+}80+81+async function upload(host: string, sk: Uint8Array, text: string) {82+ const body = new TextEncoder().encode(text);83+ const sha = bytesToHex(sha256(body));84+ const token = "Nostr " + btoa(JSON.stringify(ev(sk, 24242, "upload", [["t", "upload"], ["x", sha], ["expiration", String(now() + 300)]])));85+ const resp = await SELF.fetch(`http://${host}/upload`, { method: "PUT", headers: { authorization: token, "content-type": "text/plain" }, body });86+ return { status: resp.status, sha };87+}88+89+describe("guests", () => {90+ it("lets a stranger through a members-only rule for open kinds and for replies to members", async () => {91+ const host = "guests.bind.ws";92+ const owner = generateSecretKey();93+ const member = generateSecretKey();94+ const stranger = generateSecretKey();95+ await rpc(host, owner, "claim");96+ await rpc(host, owner, "setmember", pk(member), {});97+ let r = await rpc(host, owner, "setpolicy", { writes: "allowlist", openKinds: [7, 7, 70000, -1], guestReplies: true });98+ expect(r.result.openKinds).toEqual([7]);99+ expect(r.result.guestReplies).toBe(true);100+101+ const c = await WS.connect(host);102+ const note = ev(member, 1, "a member's note");103+ expect((await c.ok(note))).toEqual({ ok: true, msg: "" });104+ const outsiderNote = ev(stranger, 1, "hello?");105+ expect((await c.ok(outsiderNote)).msg).toMatch(/only accepts events from its members/);106+ // An open kind passes the rule; a reply to a member passes; a reply to nothing here does not.107+ expect((await c.ok(ev(stranger, 7, "+", [["e", note.id]]))).ok).toBe(true);108+ expect((await c.ok(ev(stranger, 1, "nice", [["e", note.id, "", "reply"]]))).ok).toBe(true);109+ expect((await c.ok(ev(stranger, 1111, "a comment", [["E", note.id]]))).ok).toBe(true);110+ expect((await c.ok(ev(stranger, 1, "into the void", [["e", "ab".repeat(32)]]))).ok).toBe(false);111+ // A reply to another stranger's event is not a reply to a member.112+ const guestReply = (await c.req({ kinds: [1], authors: [pk(stranger)] }))[0];113+ expect((await c.ok(ev(generateSecretKey(), 1, "reply to a guest", [["e", guestReply.id]]))).ok).toBe(false);114+ // Bans still win, and so do kind rules.115+ await rpc(host, owner, "disallowkind", 7);116+ expect((await c.ok(ev(stranger, 7, "-", [["e", note.id]]))).msg).toMatch(/does not accept kind 7/);117+ await rpc(host, owner, "setpolicy", { guestReplies: false });118+ expect((await c.ok(ev(stranger, 1, "again", [["e", note.id]]))).ok).toBe(false);119+ // Only me plus open kinds: the owner's rule, guests' kinds.120+ await rpc(host, owner, "unrulekind", 7);121+ await rpc(host, owner, "setpolicy", { writes: "owner" });122+ expect((await c.ok(ev(member, 1, "member note"))).msg).toMatch(/only the relay owner/);123+ expect((await c.ok(ev(member, 7, "+", [["e", note.id]]))).ok).toBe(true);124+ });125+});126+127+describe("web of trust", () => {128+ it("admits the people members follow, and follows the lists as they change", async () => {129+ const host = "wot.bind.ws";130+ const owner = generateSecretKey();131+ const member = generateSecretKey();132+ const friend = generateSecretKey();133+ const friendOfMember = generateSecretKey();134+ const stranger = generateSecretKey();135+ await rpc(host, owner, "claim");136+ const c = await WS.connect(host);137+ expect((await c.ok(ev(owner, 3, "", [["p", pk(friend)]]))).ok).toBe(true);138+ const r = await rpc(host, owner, "setpolicy", { writes: "wot" });139+ expect(r.result.writes).toBe("wot");140+ expect((await c.ok(ev(friend, 1, "a friend of the owner"))).ok).toBe(true);141+ expect((await c.ok(ev(stranger, 1, "nobody follows me"))).msg).toMatch(/members and the people they follow/);142+ expect((await upload(host, stranger, "a stranger's file")).status).toBe(403);143+ expect((await upload(host, friend, "a friend's file")).status).toBe(200);144+145+ // A member's list arriving extends the web; the member leaving shrinks it.146+ await rpc(host, owner, "setmember", pk(member), {});147+ expect((await c.ok(ev(member, 3, "", [["p", pk(friendOfMember)]]))).ok).toBe(true);148+ expect((await c.ok(ev(friendOfMember, 1, "followed by a member"))).ok).toBe(true);149+ await rpc(host, owner, "removemember", pk(member));150+ expect((await c.ok(ev(friendOfMember, 1, "the member left"))).ok).toBe(false);151+ // The owner's newer list without the friend drops them.152+ expect((await c.ok(ev(owner, 3, "", [["p", pk(stranger)]], now() + 1))).ok).toBe(true);153+ expect((await c.ok(ev(friend, 1, "unfollowed"))).ok).toBe(false);154+ expect((await c.ok(ev(stranger, 1, "now followed"))).ok).toBe(true);155+ // The group flags read the rule as restricted and closed.156+ const info: any = await (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json();157+ expect(info.limitation.restricted_writes).toBe(true);158+ const meta = (await c.req({ kinds: [39000] }))[0];159+ expect(meta.tags.some((t: string[]) => t[0] === "restricted")).toBe(true);160+ expect(meta.tags.some((t: string[]) => t[0] === "closed")).toBe(true);161+ });162+});163+164+describe("blocked words", () => {165+ it("refuses content with a blocked word from anyone but the owner and moderators", async () => {166+ const host = "words.bind.ws";167+ const owner = generateSecretKey();168+ const mod = generateSecretKey();169+ const member = generateSecretKey();170+ await rpc(host, owner, "claim");171+ await rpc(host, owner, "setmember", pk(mod), { role: "moderator" });172+ await rpc(host, owner, "setmember", pk(member), {});173+ let r = await rpc(host, owner, "setblockedwords", [" Casino ", "free money", "x", "a".repeat(65), "casino"]);174+ expect(r.result).toEqual(["casino", "free money"]);175+ expect((await rpc(host, owner, "getpolicy")).result.blockedWords).toEqual(["casino", "free money"]);176+ // A moderator may set the list too.177+ r = await rpc(host, mod, "setblockedwords", ["casino"]);178+ expect(r.status).toBe(200);179+ expect((await rpc(host, member, "setblockedwords", ["casino"])).status).toBe(403);180+181+ const c = await WS.connect(host);182+ expect((await c.ok(ev(member, 1, "Big CASINO night"))).msg).toBe("blocked: content contains a blocked word");183+ expect((await c.ok(ev(generateSecretKey(), 1, "casino"))).ok).toBe(false);184+ expect((await c.ok(ev(member, 1, "a quiet evening"))).ok).toBe(true);185+ expect((await c.ok(ev(owner, 1, "the casino is closed"))).ok).toBe(true);186+ expect((await c.ok(ev(mod, 1, "no casino talk please"))).ok).toBe(true);187+ // The list travels with the configuration.188+ const cfg = (await rpc(host, owner, "exportconfig")).result;189+ expect(cfg.policy.blockedWords).toEqual(["casino"]);190+ });191+});192+193+describe("ban and erase", () => {194+ it("removes everything a banned pubkey wrote and uploaded when asked", async () => {195+ const host = "erase.bind.ws";196+ const owner = generateSecretKey();197+ const member = generateSecretKey();198+ const other = generateSecretKey();199+ await rpc(host, owner, "claim");200+ await rpc(host, owner, "setmember", pk(member), {});201+ await rpc(host, owner, "setmember", pk(other), {});202+ const c = await WS.connect(host);203+ for (const e of [ev(member, 0, JSON.stringify({ name: "soon gone" })), ev(member, 1, "one"), ev(member, 1, "two"), ev(other, 1, "stays")]) expect((await c.ok(e)).ok).toBe(true);204+ const { sha } = await upload(host, member, "a file to erase");205+ expect((await SELF.fetch(`http://${host}/${sha}`)).status).toBe(200);206+ // A plain ban keeps the history.207+ expect((await rpc(host, owner, "banpubkey", pk(other), "plain")).result).toBe(true);208+ expect((await c.req({ authors: [pk(other)] })).length).toBe(1);209+ // A ban with erase does not.210+ expect((await rpc(host, owner, "banpubkey", pk(member), "gone", true)).result).toBe(true);211+ expect(await c.req({ authors: [pk(member)] })).toEqual([]);212+ expect((await SELF.fetch(`http://${host}/${sha}`)).status).toBe(404);213+ expect((await rpc(host, owner, "listblobs")).result).toEqual([]);214+ expect((await rpc(host, owner, "listmembers")).result.members.some((m: any) => m.pubkey === pk(member))).toBe(false);215+ expect((await c.ok(ev(member, 1, "back?"))).msg).toMatch(/banned/);216+ // The same through a report.217+ const reporter = generateSecretKey();218+ await rpc(host, owner, "setpolicy", { writes: "open" });219+ const bad = ev(generateSecretKey(), 1, "report me");220+ expect((await c.ok(bad)).ok).toBe(true);221+ expect((await c.ok(ev(reporter, 1984, "spam", [["e", bad.id, "spam"], ["p", bad.pubkey]]))).ok).toBe(true);222+ const report = (await rpc(host, owner, "listreports")).result[0];223+ expect((await rpc(host, owner, "resolvereport", report.id, "ban", true)).result).toBe(true);224+ expect(await c.req({ authors: [bad.pubkey] })).toEqual([]);225+ });226+});227+228+describe("report thresholds", () => {229+ it("hides an event once enough people report it, until a moderator resolves the reports", async () => {230+ const host = "threshold.bind.ws";231+ const owner = generateSecretKey();232+ const author = generateSecretKey();233+ const r1 = generateSecretKey();234+ const r2 = generateSecretKey();235+ await rpc(host, owner, "claim");236+ expect((await rpc(host, owner, "setpolicy", { reportThreshold: 2 })).result.reportThreshold).toBe(2);237+ const c = await WS.connect(host);238+ const note = ev(author, 1, "contested");239+ expect((await c.ok(note)).ok).toBe(true);240+ const report = (sk: Uint8Array) => ev(sk, 1984, "nope", [["e", note.id, "spam"], ["p", pk(author)]]);241+ expect((await c.ok(report(r1))).ok).toBe(true);242+ expect((await c.ok(report(r1))).ok).toBe(true); // the same reporter twice counts once243+ expect((await c.req({ ids: [note.id] })).length).toBe(1);244+ expect((await c.ok(report(r2))).ok).toBe(true);245+ // Hidden everywhere stored events are read.246+ expect(await c.req({ ids: [note.id] })).toEqual([]);247+ expect(await c.count({ authors: [pk(author)] })).toBe(0);248+ expect((await SELF.fetch(`http://${host}/e/${note.id}`)).status).toBe(404);249+ const feed = await (await SELF.fetch(`http://${host}/feed.xml`)).text();250+ expect(feed.includes(note.id)).toBe(false);251+ const reports = (await rpc(host, owner, "listreports")).result;252+ expect(reports.length).toBe(2);253+ expect(reports.every((r: any) => r.hidden === 1)).toBe(true);254+ expect((await rpc(host, owner, "listeventsneedingmoderation")).result.map((r: any) => r.id)).toEqual([note.id]);255+ // Dismissing one report leaves the hold; dismissing the last lifts it.256+ await rpc(host, owner, "resolvereport", reports[0].id, "dismiss");257+ expect(await c.req({ ids: [note.id] })).toEqual([]);258+ await rpc(host, owner, "resolvereport", reports[1].id, "dismiss");259+ expect((await c.req({ ids: [note.id] })).length).toBe(1);260+ // Reports on a pubkey alone never hide anything.261+ expect((await c.ok(ev(r1, 1984, "person", [["p", pk(author), "impersonation"]]))).ok).toBe(true);262+ expect((await c.ok(ev(r2, 1984, "person", [["p", pk(author), "impersonation"]]))).ok).toBe(true);263+ expect((await c.req({ ids: [note.id] })).length).toBe(1);264+ // Delete makes it permanent.265+ const again = ev(author, 1, "contested again");266+ expect((await c.ok(again)).ok).toBe(true);267+ for (const sk of [r1, r2]) expect((await c.ok(ev(sk, 1984, "nope", [["e", again.id, "spam"], ["p", pk(author)]]))).ok).toBe(true);268+ expect(await c.req({ ids: [again.id] })).toEqual([]);269+ const open = (await rpc(host, owner, "listreports")).result.filter((r: any) => r.target_event === again.id);270+ await rpc(host, owner, "resolvereport", open[0].id, "delete");271+ expect((await c.ok(again)).msg).toMatch(/banned/);272+ });273+});274+275+describe("configuration", () => {276+ it("carries the new rules through export and import", async () => {277+ const a = "gate-a.bind.ws", b = "gate-b.bind.ws";278+ const owner = generateSecretKey();279+ await rpc(a, owner, "claim");280+ await rpc(b, owner, "claim");281+ await rpc(a, owner, "setpolicy", { writes: "wot", openKinds: [7, 9735], guestReplies: true, reportThreshold: 3 });282+ await rpc(a, owner, "setblockedwords", ["casino"]);283+ const cfg = (await rpc(a, owner, "exportconfig")).result;284+ expect(cfg.policy).toMatchObject({ writes: "wot", openKinds: [7, 9735], guestReplies: true, reportThreshold: 3, blockedWords: ["casino"] });285+ expect((await rpc(b, owner, "importconfig", cfg)).status).toBe(200);286+ const p = (await rpc(b, owner, "getpolicy")).result;287+ expect(p).toMatchObject({ writes: "wot", openKinds: [7, 9735], guestReplies: true, reportThreshold: 3, blockedWords: ["casino"] });288+ // resetrules puts the guest rules back but keeps the moderation ones.289+ await rpc(b, owner, "resetrules");290+ const q = (await rpc(b, owner, "getpolicy")).result;291+ expect(q).toMatchObject({ writes: "open", openKinds: [], guestReplies: false, reportThreshold: 3, blockedWords: ["casino"] });292+ });293+});