- commit
- 180c5873740bf1005e35d2ca117e0dcc7087cd20
- parent
- ee65de55396160acadb6f5cbe37ea472fb7c78ce
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 12 | +- |
| D | test/ | 451 | ------------------------------ |
| D | test/ | 85 | ------ |
| D | test/ | 127 | -------- |
| R | test/ | 57 | +++- |
| A | test/ | 198 | +++++++++++++ |
| A | test/ | 78 | +++++ |
| D | test/ | 215 | -------------- |
| R | test/ | 47 | +++- |
| D | test/ | 134 | --------- |
| M | test/ | 181 | ++++++++++++- |
| R | test/ | 157 | ++++++---- |
| A | test/ | 225 | +++++++++++++++ |
| A | test/ | 42 | +++ |
| M | test/ | 380 | ++++++++++++++++++++++---- |
| M | test/ | 87 | +------ |
@@ -43,8 +43,10 @@ src/
4343 landing.ts the apex, rendered from config4444 ui.ts the shared look4545 test/46- *.test.ts Durable Object tests in workerd, one file per feature47- conformance/ black-box suite for any relay URL, files included46+ unit/ pure functions on node: the QR encoder, bolt11 amounts, the edge47+ object/ Durable Object tests in workerd, one file per feature or module48+ helpers/ the socket client, the management call, media tokens, the QR reader49+ conformance/ black-box suite for any relay URL, one file per NIP, files included4850 scripts/4951 dev-signer.mjs seed.mjs junk.mjs zaptest.mjs shot.mjs5052 build-signer.mjs signer-entry.js bundle nostr-tools for the console's remote signing@@ -56,7 +58,7 @@ wrangler.celld.jsonc the same Worker on celld (docs/16)
5658 ## Run the tests5759 5860 ```59-npm test # Durable Object tests61+npm test # unit and Durable Object tests6062 npm run typecheck6163 npm run test:conformance # against RELAY_URL, default ws://127.0.0.1:74476264 ```@@ -75,7 +77,7 @@ CI runs typecheck and the object tests on every push; the conformance suite agai
7577 2. Add a `case` in the switch. Use `num(i)` and `str(i)` for parameters. Return `reply({ result })` or `reply({ error }, 400)`.7678 3. If it changes state that other code caches, update `Settings` and its in-memory sets.7779 4. If the console should call it, add the control to `dashboard.ts` and a handler in its script.78-5. Cover it in `test/adopt.test.ts` with the `rpc` helper. If it can show a member, an event or a file, ask `Settings.mayRead` (or `mayList` for names and counts) with what `whoAsks` found in the header, and add the path to the door walk in `test/exposure.test.ts`, which knocks on every path as a stranger and as a signed-in non-member and fails on anything of a member's that comes back.80+5. Cover it in the `test/object` file for its feature, with `rpc` from `test/helpers/relay.ts`. If it can show a member, an event or a file, ask `Settings.mayRead` (or `mayList` for names and counts) with what `whoAsks` found in the header, and add the path to the door walk in `test/object/exposure.test.ts`, which knocks on every path as a stranger and as a signed-in non-member and fails on anything of a member's that comes back.7981 8082 ## Add a NIP8183 @@ -91,7 +93,7 @@ Most NIPs land as their own module, wired in from `relay.ts` with one import and
9193 | 96 files | `nip96.ts` over `blossom.ts` | one path line in `fetch` |9294 | 11 extras | `settings.ts` fields, `info()` | `relay.ts` |9395 94-A NIP that changes the query surface touches `store.ts`. Add the number to `SUPPORTED_NIPS` only when the NIP says relays advertise it, with a word in the comment there for the less obvious ones. Add a conformance test in `test/conformance` so the behavior is checked from outside, and a Durable Object test next to the feature's file in `test/`.96+A NIP that changes the query surface touches `store.ts`. Add the number to `SUPPORTED_NIPS` only when the NIP says relays advertise it, with a word in the comment there for the less obvious ones. Add a file named after the NIP in `test/conformance` so the behavior is checked from outside, and a Durable Object test in `test/object`, named after the module it exercises; a NIP number names a test file only where the feature has no other name (`nip05`, `nip11`, `nip66`).9597 9698 ## The signer bundle9799 @@ -1,451 +0,0 @@
1-// The "learned from Buzz" set: HTTP bridge, invites, NIP-05, relay identity2-// and NIP-43 roster, members-only reads and eviction, Blossom media, rate3-// limits, and the NIP-56 moderation queue.4-import { SELF, env, runInDurableObject } 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 type { Relay } from "../../src/relay.ts";10-import { bytesToHex } from "../../src/negentropy.ts";11-import { now, ev, rpc, post } from "../helpers/relay.ts";12-import { WS } from "../helpers/ws.ts";13-14-describe("HTTP bridge", () => {15- it("accepts events, answers queries and counts with NIP-98, applying the same gates", async () => {16- const host = "bridge.bind.ws";17- const owner = generateSecretKey();18- const other = generateSecretKey();19- await rpc(host, owner, "claim");20- const e = ev(owner, 1, "over http");21- let r = await post(host, owner, "/events", e);22- expect(r.status).toBe(200);23- expect(r.body).toEqual({ event_id: e.id, accepted: true, message: "" });24- r = await post(host, owner, "/events", e);25- expect(r.body.accepted).toBe(true);26- expect(r.body.message).toMatch(/^duplicate:/);27-28- r = await post(host, other, "/query", [{ kinds: [1], authors: [getPublicKey(owner)] }]);29- expect(r.status).toBe(200);30- expect(r.body.map((x: Event) => x.id)).toEqual([e.id]);31- r = await post(host, other, "/count", [{ kinds: [1] }]);32- expect(r.body.count).toBe(1);33-34- // Unsigned, wrong-URL, and non-JSON requests are refused cleanly.35- expect((await post(host, null, "/query", [{}])).status).toBe(401);36- const badUrlToken = await getToken("http://elsewhere.bind.ws/query", "POST", (x) => finalizeEvent(x, other), true, [{}] as any);37- const bad = await SELF.fetch(`http://${host}/query`, { method: "POST", headers: { authorization: badUrlToken }, body: "[{}]" });38- expect(bad.status).toBe(401);39-40- // A subscriber on the socket sees bridge writes live.41- const c = await WS.connect(host);42- await c.open("live", { kinds: [1] });43- const e2 = ev(owner, 1, "pushed");44- await post(host, owner, "/events", e2);45- expect((await c.expect("EVENT"))[2].id).toBe(e2.id);46-47- // Private kinds via the bridge follow the recipient rule; the signer counts as authenticated.48- const recipient = generateSecretKey();49- const wrap = ev(generateSecretKey(), 1059, "dm", [["p", getPublicKey(recipient)]]);50- await post(host, owner, "/events", wrap);51- r = await post(host, other, "/query", [{ kinds: [1059] }]);52- expect(r.status).toBe(200);53- expect(r.body).toEqual([]); // authenticated but not a party: silently filtered54- expect((await post(host, null, "/query", [{ kinds: [1059] }])).status).toBe(401);55- r = await post(host, recipient, "/query", [{ kinds: [1059] }]);56- expect(r.body.map((x: Event) => x.id)).toEqual([wrap.id]);57- });58-});59-60-describe("invites", () => {61- it("lets the owner mint links that non-members can claim, with expiry and use limits", async () => {62- const host = "invite.bind.ws";63- const owner = generateSecretKey();64- const guest = generateSecretKey();65- await rpc(host, owner, "claim");66- await rpc(host, owner, "setpolicy", { writes: "allowlist", joinTerms: "Be kind." });67- expect((await rpc(host, guest, "createinvite")).status).toBe(403);68- const inv = (await rpc(host, owner, "createinvite", 3600, 1, "for guest")).result;69- expect(inv.code).toMatch(/^[0-9a-f]{32}$/);70- expect(inv.max_uses).toBe(1);71-72- // The page renders the terms for a valid code and a problem for a bad one.73- let page = await (await SELF.fetch(`http://${host}/invite/${inv.code}`)).text();74- expect(page).toContain("Be kind.");75- expect(page).toContain("Join with extension");76- page = await (await SELF.fetch(`http://${host}/invite/nope`)).text();77- expect(page).toContain("isn't valid");78-79- // Before joining: writes refused. After: accepted, and the roster changed.80- const c = await WS.connect(host);81- expect((await c.ok(ev(guest, 1, "hi"))).msg).toMatch(/^restricted:/);82- let r = await post(host, guest, "/api/invites/claim", { code: inv.code });83- expect(r.body).toEqual({ status: "joined", role: "member" });84- expect((await c.ok(ev(guest, 1, "hi again"))).ok).toBe(true);85- r = await post(host, guest, "/api/invites/claim", { code: inv.code });86- expect(r.body.status).toBe("already_member");87- r = await post(host, generateSecretKey(), "/api/invites/claim", { code: inv.code });88- expect(r.status).toBe(403);89- expect(r.body.error).toBe("invite_exhausted");90- expect((await post(host, generateSecretKey(), "/api/invites/claim", { code: "bogus" })).body.error).toBe("invite_invalid");91- const list = (await rpc(host, owner, "listinvites")).result;92- expect(list[0].uses).toBe(1);93- expect((await rpc(host, owner, "revokeinvite", inv.code)).result).toBe(true);94- expect((await rpc(host, owner, "listinvites")).result).toEqual([]);95- const members = (await rpc(host, owner, "listmembers")).result.members;96- expect(members.find((m: any) => m.pubkey === getPublicKey(guest)).via).toMatch(/^invite /);97- });98-});99-100-describe("NIP-05", () => {101- it("serves members' names for the relay's own host, claimed via kind 0 or assigned by the owner", async () => {102- const host = "names.bind.ws";103- const owner = generateSecretKey();104- const alice = generateSecretKey();105- const stranger = generateSecretKey();106- await rpc(host, owner, "claim");107- await rpc(host, owner, "allowpubkey", getPublicKey(alice));108- const c = await WS.connect(host);109- await c.ok(ev(alice, 0, JSON.stringify({ name: "alice", nip05: "Alice@names.bind.ws" })));110- await c.ok(ev(stranger, 0, JSON.stringify({ name: "mallory", nip05: "alice@names.bind.ws" }))); // not a member: ignored111- await c.ok(ev(owner, 0, JSON.stringify({ nip05: "owner@elsewhere.example" }))); // other domain: ignored112- let doc: any = await (await SELF.fetch(`http://${host}/.well-known/nostr.json?name=alice`)).json();113- expect(doc.names).toEqual({ alice: getPublicKey(alice) });114- expect(doc.relays[getPublicKey(alice)]).toEqual(["wss://" + host]);115- doc = await (await SELF.fetch(`http://${host}/.well-known/nostr.json?name=nobody`)).json();116- expect(doc.names).toEqual({});117- // Owner assigns and removes names; a taken name can't be claimed by another member.118- expect((await rpc(host, owner, "setmember", getPublicKey(owner), { name: "_" })).result.name).toBe("_");119- expect((await rpc(host, owner, "setmember", getPublicKey(owner), { name: "bad name!" })).status).toBe(400);120- doc = await (await SELF.fetch(`http://${host}/.well-known/nostr.json`)).json();121- expect(Object.keys(doc.names).sort()).toEqual(["_", "alice"]);122- // Assigning a taken name to someone else moves it; clearing frees it.123- expect((await rpc(host, owner, "setmember", getPublicKey(stranger), { name: "alice" })).result.name).toBe("alice");124- expect((await rpc(host, owner, "listmembers")).result.members.find((m: any) => m.pubkey === getPublicKey(alice)).name).toBeNull();125- expect((await rpc(host, owner, "setmember", getPublicKey(stranger), { name: "" })).result.name).toBeNull();126- // The public directory lists people with their names; the owner can hide it.127- let people: any = await (await SELF.fetch(`http://${host}/people`)).json();128- expect(people.people.map((m: any) => m.role)).toEqual(["owner", "member", "member"]);129- expect(people.people[0].name).toBe("_");130- await rpc(host, owner, "setpolicy", { directoryPublic: false });131- people = await (await SELF.fetch(`http://${host}/people`)).json();132- expect(people.people).toEqual([]);133- });134-});135-136-describe("relay identity and NIP-43 roster", () => {137- it("signs a roster after claiming and deltas on membership changes, advertised as self", async () => {138- const host = "roster.bind.ws";139- const owner = generateSecretKey();140- const bob = generateSecretKey();141- let info: any = await (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json();142- expect(info.self).toBeUndefined();143- await rpc(host, owner, "claim");144- info = await (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json();145- expect(info.self).toMatch(/^[0-9a-f]{64}$/);146- expect(info.supported_nips).toContain(43);147- const c = await WS.connect(host);148- c.send("REQ", "live", { kinds: [8000, 8001] });149- await c.expect("EOSE");150- let r = await c.req({ kinds: [13534] });151- expect(r.length).toBe(1);152- expect(r[0].pubkey).toBe(info.self);153- expect(r[0].tags).toEqual([["-"], ["member", getPublicKey(owner), "owner"]]);154-155- await rpc(host, owner, "allowpubkey", getPublicKey(bob), "friend");156- const added = await c.expect("EVENT");157- expect(added[2].kind).toBe(8000);158- expect(added[2].tags).toContainEqual(["p", getPublicKey(bob)]);159- r = await c.req({ kinds: [13534] });160- expect(r[0].tags).toContainEqual(["member", getPublicKey(bob)]);161- await rpc(host, owner, "unrulepubkey", getPublicKey(bob));162- expect((await c.expect("EVENT"))[2].kind).toBe(8001);163- // Nobody can forge relay-signed kinds: a client-signed 13534 is just another replaceable event by another author.164- const forged = await c.ok(ev(bob, 13534, "", [["member", getPublicKey(bob), "owner"]]));165- expect(forged.ok).toBe(true);166- r = await c.req({ kinds: [13534], authors: [info.self] });167- expect(r.map((x) => [x.pubkey, x.tags]), JSON.stringify(r)).toEqual([[info.self, [["-"], ["member", getPublicKey(owner), "owner"]]]]);168- });169-});170-171-describe("members-only reads and eviction", () => {172- it("serves only members when reads=members, and closes the door on bans and removals", async () => {173- const host = "closed.bind.ws";174- const owner = generateSecretKey();175- const member = generateSecretKey();176- const stranger = generateSecretKey();177- await rpc(host, owner, "claim");178- await rpc(host, owner, "allowpubkey", getPublicKey(member));179- await rpc(host, owner, "setpolicy", { reads: "members" });180- const s = await WS.connect(host);181- expect((await s.query({ kinds: [1] })).closed).toMatch(/^auth-required:/);182- await s.auth(stranger, host);183- expect((await s.query({ kinds: [1] })).closed).toMatch(/^restricted: .*members/);184- const m = await WS.connect(host);185- await m.auth(member, host);186- expect((await m.query({ kinds: [1] })).closed).toBe("");187- m.send("REQ", "live", { kinds: [1] });188- await m.expect("EOSE");189-190- // Removal ends every subscription with a reason; the socket stays.191- await rpc(host, owner, "unrulepubkey", getPublicKey(member));192- const closed = await m.expect("CLOSED");193- expect(closed[1]).toBe("live");194- expect(closed[2]).toMatch(/^restricted:/);195- expect(m.closed).toBeNull();196-197- // A ban closes the socket outright.198- const b = await WS.connect(host);199- await b.auth(stranger, host);200- await rpc(host, owner, "banpubkey", getPublicKey(stranger), "bye");201- for (let i = 0; i < 20 && !b.closed; i++) await new Promise((r) => setTimeout(r, 50));202- expect(b.closed?.code).toBe(4403);203- });204-});205-206-describe("rate limits", () => {207- it("throttles a chatty connection with a retry hint", async () => {208- const host = "limits.bind.ws";209- const owner = generateSecretKey();210- await rpc(host, owner, "claim");211- await rpc(host, owner, "setpolicy", { eventsPerMinute: 2, reqsPerMinute: 2 });212- const c = await WS.connect(host);213- expect((await c.ok(ev(owner, 1, "a"))).ok).toBe(true);214- expect((await c.ok(ev(owner, 1, "b"))).ok).toBe(true);215- const third = await c.ok(ev(owner, 1, "c"));216- expect(third.ok).toBe(false);217- expect(third.msg).toMatch(/^rate-limited: quota exceeded; retry in \d+s$/);218- await c.open("r1", { kinds: [1] });219- await c.open("r2", { kinds: [1] });220- expect((await c.open("r3", { kinds: [1] })).closed).toMatch(/^rate-limited:/);221- });222-});223-224-describe("moderation queue", () => {225- it("files NIP-56 reports for the owner and resolves them with ban, delete or dismiss", async () => {226- const host = "mods.bind.ws";227- const owner = generateSecretKey();228- const troll = generateSecretKey();229- const reporter = generateSecretKey();230- await rpc(host, owner, "claim");231- const c = await WS.connect(host);232- const bad = ev(troll, 1, "something awful");233- await c.ok(bad);234- const report = ev(reporter, 1984, "please look", [["e", bad.id, "spam"], ["p", getPublicKey(troll)]]);235- expect((await c.ok(report)).msg).toBe("info: report received");236- expect(await c.req({ kinds: [1984] })).toEqual([]); // never served237- let q = (await rpc(host, owner, "listreports")).result;238- expect(q.length).toBe(1);239- expect(q[0]).toMatchObject({ id: report.id, target_event: bad.id, target_pubkey: getPublicKey(troll), type: "spam", status: "open" });240- expect((await rpc(host, owner, "resolvereport", report.id, "ban")).result).toBe(true);241- expect(await c.req({ ids: [bad.id] })).toEqual([]);242- expect((await c.ok(ev(troll, 1, "back"))).msg).toMatch(/^blocked:/);243- expect((await rpc(host, owner, "listreports")).result).toEqual([]);244- expect((await rpc(host, owner, "listreports", "resolved")).result[0].action).toBe("ban");245- });246-});247-248-describe("Blossom media", () => {249- const blossomToken = (sk: Uint8Array, action: string, sha: string) =>250- "Nostr " + btoa(JSON.stringify(ev(sk, 24242, action, [["t", action], ["x", sha], ["expiration", String(now() + 300)]])));251-252- it("stores, serves, lists and deletes blobs under the relay's write policy, counting them as storage", async () => {253- const host = "media.bind.ws";254- const owner = generateSecretKey();255- const member = generateSecretKey();256- const stranger = generateSecretKey();257- await rpc(host, owner, "claim");258- await rpc(host, owner, "setpolicy", { writes: "allowlist", maxBlobMB: 1 });259- await rpc(host, owner, "allowpubkey", getPublicKey(member));260- const body = new TextEncoder().encode("hello blossom " + host);261- const sha = bytesToHex(sha256(body));262-263- const put = (sk: Uint8Array, data: Uint8Array, shaTag = sha, type = "text/plain") =>264- SELF.fetch(`http://${host}/upload`, { method: "PUT", headers: { authorization: blossomToken(sk, "upload", shaTag), "content-type": type }, body: data });265- expect((await put(stranger, body)).status).toBe(403);266- expect((await SELF.fetch(`http://${host}/upload`, { method: "PUT", body })).status).toBe(401);267- expect((await put(member, body, "00".repeat(32))).status).toBe(400);268- let resp = await put(member, body);269- expect(resp.status).toBe(200);270- const desc: any = await resp.json();271- expect(desc).toMatchObject({ sha256: sha, size: body.length, type: "text/plain", url: `https://${host}/${sha}.txt` });272- expect((await put(member, new Uint8Array(1024 * 1024 + 1), "ff".repeat(32))).status).toBe(413);273-274- resp = await SELF.fetch(`http://${host}/${sha}.txt`);275- expect(resp.status).toBe(200);276- expect(resp.headers.get("content-type")).toBe("text/plain");277- expect(await resp.text()).toBe("hello blossom " + host);278- resp = await SELF.fetch(`http://${host}/${sha}`, { method: "HEAD" });279- expect(resp.headers.get("content-length")).toBe(String(body.length));280- resp = await SELF.fetch(`http://${host}/${sha}`, { headers: { range: "bytes=0-4" } });281- expect(resp.status).toBe(206);282- expect(await resp.text()).toBe("hello");283- expect((await SELF.fetch(`http://${host}/${"ab".repeat(32)}`)).status).toBe(404);284-285- const list: any = await (await SELF.fetch(`http://${host}/list/${getPublicKey(member)}`)).json();286- expect(list.map((b: any) => b.sha256)).toEqual([sha]);287- const stub = env.RELAY.getByName("media");288- await runInDurableObject(stub, async (r: Relay) => expect(r.mediaBytes()).toBeGreaterThan(0));289-290- const del = (sk: Uint8Array) => SELF.fetch(`http://${host}/${sha}`, { method: "DELETE", headers: { authorization: blossomToken(sk, "delete", sha) } });291- expect((await del(stranger)).status).toBe(403);292- expect((await del(member)).status).toBe(204);293- expect((await SELF.fetch(`http://${host}/${sha}`)).status).toBe(404);294- expect((await rpc(host, owner, "listblobs")).result).toEqual([]);295- });296-});297-298-describe("members migration", () => {299- it("folds the earlier allow list and names table into members on load", async () => {300- const stub = env.RELAY.getByName("legacy");301- const owner = generateSecretKey();302- const a = getPublicKey(generateSecretKey());303- const b = getPublicKey(generateSecretKey());304- await runInDurableObject(stub, async (r: Relay) => {305- r.settings.update({ owner: getPublicKey(owner) });306- r.sql.exec(`INSERT INTO pubkey_rules(pubkey,rule,reason,at) VALUES(?,'allow','old friend',1000)`, a);307- r.sql.exec(`INSERT INTO nip05(name,pubkey,at) VALUES('bob',?,2000)`, b);308- r.sql.exec(`INSERT INTO nip05(name,pubkey,at) VALUES('alice',?,3000)`, a);309- r.settings.load();310- const m = r.settings.members();311- expect(m.map((x) => [x.role, x.name, x.via])).toEqual([["owner", null, "claimed"], ["member", "alice", "added"], ["member", "bob", "profile"]]);312- expect(r.settings.isAllowed(a) && r.settings.isAllowed(b)).toBe(true);313- expect(r.sql.exec(`SELECT count(*) AS n FROM pubkey_rules`).one().n).toBe(0);314- r.settings.load(); // idempotent315- expect(r.settings.members().length).toBe(3);316- });317- });318-});319-320-describe("teardown", () => {321- it("requires the typed name, then wipes everything and returns the name to unclaimed", async () => {322- const host = "gone.bind.ws";323- const owner = generateSecretKey();324- const member = generateSecretKey();325- await rpc(host, owner, "claim");326- await rpc(host, owner, "setmember", getPublicKey(member), { name: "bob" });327- const c = await WS.connect(host);328- await c.ok(ev(owner, 1, "to be deleted"));329- await c.open("live", { kinds: [1] });330- const body = new TextEncoder().encode("bytes");331- const sha = bytesToHex(sha256(body));332- const token = "Nostr " + btoa(JSON.stringify(ev(owner, 24242, "upload", [["t", "upload"], ["x", sha], ["expiration", String(now() + 300)]])));333- expect((await SELF.fetch(`http://${host}/upload`, { method: "PUT", headers: { authorization: token, "content-type": "text/plain" }, body })).status).toBe(200);334-335- expect((await rpc(host, member, "deleterelay", "gone")).status).toBe(403);336- const wrong = await rpc(host, owner, "deleterelay", "typo");337- expect(wrong.status).toBe(400);338- expect((await rpc(host, owner, "stats")).result.events).toBeGreaterThan(0);339-340- expect((await rpc(host, owner, "deleterelay", "gone")).result).toEqual({ deleted: true, name: "gone" });341- for (let i = 0; i < 20 && !c.closed; i++) await new Promise((r) => setTimeout(r, 50));342- expect(c.closed?.code).toBe(4410);343- const info: any = await (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json();344- expect(info.pubkey).toBeUndefined();345- expect(info.self).toBeUndefined();346- expect((await SELF.fetch(`http://${host}/${sha}`)).status).toBe(404);347- expect((await env.MEDIA.list({ prefix: "gone/" })).objects).toEqual([]);348- const fresh = await WS.connect(host);349- expect(await fresh.req({ kinds: [1] })).toEqual([]);350- expect((await fresh.ok(ev(owner, 1, "again"))).msg).toMatch(/unclaimed/);351- const doc: any = await (await SELF.fetch(`http://${host}/.well-known/nostr.json?name=bob`)).json();352- expect(doc.names).toEqual({});353- // Anyone can claim the name again, including someone else.354- const other = generateSecretKey();355- expect((await rpc(host, other, "claim")).result.claimed).toBe(true);356- });357-});358-359-describe("configuration export and import", () => {360- it("round-trips policy, members, bans and kinds between relays without touching data or owner", async () => {361- const a = "cfg-a.bind.ws", b = "cfg-b.bind.ws";362- const ownerA = generateSecretKey(), ownerB = generateSecretKey();363- const m1 = getPublicKey(generateSecretKey()), bad = getPublicKey(generateSecretKey());364- await rpc(a, ownerA, "claim");365- await rpc(a, ownerA, "setpolicy", { writes: "allowlist", reads: "members", name: "Club", joinTerms: "be kind", minPow: 3, directoryPublic: false });366- await rpc(a, ownerA, "setmember", m1, { name: "alice", note: "friend" });367- await rpc(a, ownerA, "banpubkey", bad, "spam");368- await rpc(a, ownerA, "allowkind", 1);369- await rpc(a, ownerA, "disallowkind", 7);370- const cfg = (await rpc(a, ownerA, "exportconfig")).result;371- expect(cfg.format).toBe("bind.ws/relay-config/1");372- expect(cfg.policy.owner).toBeUndefined();373- expect(cfg.members).toEqual([{ pubkey: m1, name: "alice", note: "friend" }]);374- expect(cfg.bans).toEqual([{ pubkey: bad, reason: "spam" }]);375- expect(cfg.kinds).toEqual({ allow: [1], block: [7] });376-377- await rpc(b, ownerB, "claim");378- const c = await WS.connect(b);379- await c.ok(ev(ownerB, 1, "keep me"));380- expect((await rpc(b, ownerB, "importconfig", { format: "nope" })).status).toBe(400);381- const after = (await rpc(b, ownerB, "importconfig", cfg)).result;382- expect(after.members).toEqual(cfg.members);383- expect(after.bans).toEqual(cfg.bans);384- expect(after.kinds).toEqual(cfg.kinds);385- const p = (await rpc(b, ownerB, "getpolicy")).result;386- 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)]);387- expect((await rpc(b, ownerB, "stats")).result.events).toBeGreaterThan(0);388- const doc: any = await (await SELF.fetch(`http://${b}/.well-known/nostr.json?name=alice`)).json();389- expect(doc.names).toEqual({ alice: m1 });390- // The roster on b now carries the imported member.391- const rc = await WS.connect(b);392- await rc.auth(ownerB, b);393- const r = await rc.req({ kinds: [13534] });394- expect(r[0].tags).toContainEqual(["member", m1]);395- });396-});397-398-describe("storage: retention and purge", () => {399- it("refuses events past a keep-for rule, sheds old ones daily, spares replaceable kinds from the catch-all, and purges on demand", async () => {400- const host = "keep.bind.ws";401- const owner = generateSecretKey();402- await rpc(host, owner, "claim");403- const c = await WS.connect(host);404- const day = 86400;405- // Before any rule: an old reaction, an old note, an old profile all land.406- expect((await c.ok(ev(owner, 7, "+", [], now() - 40 * day))).ok).toBe(true);407- expect((await c.ok(ev(owner, 1, "old note", [], now() - 40 * day))).ok).toBe(true);408- expect((await c.ok(ev(owner, 0, '{"name":"old me"}', [], now() - 400 * day))).ok).toBe(true);409- expect((await c.ok(ev(owner, 1, "fresh note"))).ok).toBe(true);410-411- // Reactions kept 30 days, everything else 100 days.412- expect((await rpc(host, owner, "setretention", 7, 30)).result).toEqual([{ kind: 7, days: 30 }]);413- await rpc(host, owner, "setretention", null, 100);414- expect((await c.ok(ev(owner, 7, "+", [], now() - 31 * day))).msg).toMatch(/^blocked: this relay keeps kind 7 for 30 days/);415- expect((await c.ok(ev(owner, 7, "+", [], now() - 29 * day))).ok).toBe(true);416- expect((await c.ok(ev(owner, 1, "too old", [], now() - 101 * day))).msg).toMatch(/keeps kind 1 for 100 days/);417- // The catch-all spares replaceable kinds: a very old profile still lands.418- expect((await c.ok(ev(owner, 0, '{"name":"newer old me"}', [], now() - 300 * day))).ok).toBe(true);419- const info: any = await (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json();420- expect(info.retention).toEqual([{ kinds: [7], time: 30 * day }, { time: 100 * day }]);421-422- // The daily sweep sheds the 40-day-old reaction and keeps the 40-day-old note (under 100).423- const stub = env.RELAY.getByName("keep");424- await runInDurableObject(stub, async (r: Relay) => expect(r.sweepRetention(now())).toBe(1));425- let st = (await rpc(host, owner, "storagestats")).result;426- const byKind = Object.fromEntries(st.kinds.map((k: any) => [k.kind, k.n]));427- 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 own428- expect(st.events).toBe(13);429- expect(st.eventBytes).toBeGreaterThan(0);430- expect(st.retention).toEqual([{ kind: 7, days: 30 }, { kind: null, days: 100 }]);431-432- // Purge notes older than 10 days, then everything of kind 7.433- expect((await rpc(host, owner, "purgekind", 1, 10)).result).toEqual({ deleted: 1 });434- expect((await rpc(host, owner, "purgekind", 7, 0)).result).toEqual({ deleted: 1 });435- st = (await rpc(host, owner, "storagestats")).result;436- expect(st.events).toBe(11);437-438- // Rules travel with the configuration; removing one is days 0.439- const cfg = (await rpc(host, owner, "exportconfig")).result;440- expect(cfg.retention).toEqual([{ kind: 7, days: 30 }, { kind: null, days: 100 }]);441- await rpc(host, owner, "setretention", null, 0);442- expect((await rpc(host, owner, "listretention")).result).toEqual([{ kind: 7, days: 30 }]);443- expect((await rpc(host, owner, "setretention", 70000, 5)).status).toBe(400);444- // Load-bearing kinds can be neither expired nor purged, and the catch-all skips them.445- expect((await rpc(host, owner, "setretention", 0, 5)).status).toBe(400);446- expect((await rpc(host, owner, "purgekind", 13534, 0)).status).toBe(400);447- await rpc(host, owner, "setretention", null, 1);448- expect((await rpc(host, owner, "purgekind", null, 0)).result.deleted).toBe(1); // the fresh note; profile, roster and group state stay449- 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]);450- });451-});@@ -1,85 +0,0 @@
1-// The moderation log: every management method that changes something and2-// every NIP-29 moderation event leaves a row that the owner and moderators3-// can read back, newest first, with paging; reads leave nothing.4-import { SELF } from "cloudflare:test";5-import { describe, it, expect } from "vitest";6-import { generateSecretKey, type Event } from "nostr-tools/pure";7-import { now, ev, pk, rpc } from "../helpers/relay.ts";8-9-async function publish(host: string, e: Event): Promise<{ ok: boolean; msg: string }> {10- const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });11- const ws = resp.webSocket!;12- ws.accept();13- return new Promise((res) => {14- ws.addEventListener("message", (m) => {15- const a = JSON.parse(m.data as string);16- if (a[0] === "OK") {17- ws.close();18- res({ ok: a[2], msg: a[3] });19- }20- });21- ws.send(JSON.stringify(["EVENT", e]));22- });23-}24-25-type Row = { seq: number; at: number; actor: string; action: string; target: string; detail: string };26-27-describe("moderation log", () => {28- it("records what changed, who did it and to whom, and reads back newest first", async () => {29- const host = "auditlog.bind.ws";30- const owner = generateSecretKey();31- const mod = generateSecretKey();32- const member = generateSecretKey();33- const spammer = generateSecretKey();34- await rpc(host, owner, "claim");35- await rpc(host, owner, "setmember", pk(mod), { role: "moderator" });36- await rpc(host, owner, "setmember", pk(member), {});37- expect((await rpc(host, owner, "getpolicy")).status).toBe(200);38- expect((await rpc(host, mod, "banpubkey", pk(spammer), "spam")).status).toBe(200);39- expect((await rpc(host, owner, "blockip", "203.0.113.9", "swarm")).status).toBe(200);40- expect((await rpc(host, owner, "setblockedwords", ["casino"])).status).toBe(200);41- expect((await rpc(host, owner, "setpolicy", { reads: "auth", maxSubs: 5 })).status).toBe(200);42- // A refused call leaves nothing.43- expect((await rpc(host, mod, "setpolicy", { reads: "open" })).status).toBe(403);44- expect((await rpc(host, owner, "banpubkey", "zz", "")).status).toBe(400);45-46- const rows = (await rpc(host, owner, "listaudit")).result as Row[];47- const actions = rows.map((r) => [r.actor.slice(0, 8), r.action, r.target]);48- expect(actions.slice(0, 4)).toEqual([49- [pk(owner).slice(0, 8), "setpolicy", ""],50- [pk(owner).slice(0, 8), "setblockedwords", ""],51- [pk(owner).slice(0, 8), "blockip", "203.0.113.9"],52- [pk(mod).slice(0, 8), "banpubkey", pk(spammer)],53- ]);54- expect(rows[0].detail).toBe('["reads","maxSubs"]');55- expect(rows[1].detail).toBe('["casino"]');56- expect(rows[2].detail).toBe('"swarm"');57- expect(rows.map((r) => r.action)).not.toContain("getpolicy");58- expect(rows.map((r) => r.action)).toContain("setmember");59- for (const r of rows) expect(r.at).toBeGreaterThan(now() - 60);60- // seq descends; paging with `before` continues from a row.61- expect(rows[0].seq).toBeGreaterThan(rows[1].seq);62- const older = (await rpc(host, owner, "listaudit", rows[1].seq)).result as Row[];63- expect(older[0].seq).toBe(rows[2].seq);64- // Moderators read it, members do not, and reading is not itself logged.65- expect((await rpc(host, mod, "listaudit")).status).toBe(200);66- expect((await rpc(host, member, "listaudit")).status).toBe(403);67- expect(((await rpc(host, owner, "listaudit")).result as Row[])[0].action).toBe("setpolicy");68- });69-70- it("records NIP-29 moderation events under their names", async () => {71- const host = "auditgroup.bind.ws";72- const owner = generateSecretKey();73- const someone = generateSecretKey();74- await rpc(host, owner, "claim");75- const put = ev(owner, 9000, "", [["h", host.split(".")[0]], ["p", pk(someone), "member"]]);76- expect((await publish(host, put)).ok).toBe(true);77- const remove = ev(owner, 9001, "", [["h", host.split(".")[0]], ["p", pk(someone)]]);78- expect((await publish(host, remove)).ok).toBe(true);79- const rows = (await rpc(host, owner, "listaudit")).result as Row[];80- expect(rows.slice(0, 2).map((r) => [r.action, r.target, r.actor])).toEqual([81- ["remove-user", pk(someone), pk(owner)],82- ["put-user", pk(someone), pk(owner)],83- ]);84- });85-});@@ -1,127 +0,0 @@
1-// Blossom beyond upload and fetch: BUD-06 asks whether an upload would be2-// accepted, BUD-04 copies a blob from a URL, ours or anyone's.3-import { SELF } from "cloudflare:test";4-import { describe, it, expect } from "vitest";5-import { generateSecretKey, getPublicKey } from "nostr-tools/pure";6-import { sha256 } from "@noble/hashes/sha2.js";7-import { bytesToHex } from "../../src/negentropy.ts";8-import { rpc } from "../helpers/relay.ts";9-import { blossomToken, upload } from "../helpers/media.ts";10-11-async function head(host: string, headers: Record<string, string>) {12- const resp = await SELF.fetch(`http://${host}/upload`, { method: "HEAD", headers });13- return { status: resp.status, reason: resp.headers.get("x-reason") ?? "", text: await resp.text() };14-}15-16-async function mirror(host: string, sk: Uint8Array, url: unknown, sha?: string, raw?: string) {17- const resp = await SELF.fetch(`http://${host}/mirror`, { method: "PUT", headers: { authorization: blossomToken(sk, "upload", sha), "content-type": "application/json" }, body: raw ?? JSON.stringify({ url }) });18- const text = await resp.text();19- return { status: resp.status, body: text ? JSON.parse(text) : null, reason: resp.headers.get("x-reason") ?? "" };20-}21-22-describe("BUD-06 upload requirements", () => {23- it("answers HEAD /upload from the headers and the policy, with no body", async () => {24- const host = "buds-head.bind.ws";25- const owner = generateSecretKey();26- const stranger = generateSecretKey();27- await rpc(host, owner, "claim");28- const bytes = new TextEncoder().encode("a small file");29- const sha = bytesToHex(sha256(bytes));30- const ok = { "x-sha-256": sha, "x-content-type": "text/plain", "x-content-length": String(bytes.length), authorization: blossomToken(owner, "upload", sha) };31-32- let r = await head(host, ok);33- expect([r.status, r.reason, r.text]).toEqual([200, "", ""]);34- r = await head(host, { ...ok, "x-sha-256": "nope" });35- expect([r.status, r.reason.split(":")[0]]).toEqual([400, "invalid"]);36- const { "x-content-length": _, ...noLength } = ok;37- r = await head(host, noLength);38- expect(r.status).toBe(411);39- r = await head(host, { ...ok, "x-content-length": "twelve" });40- expect(r.status).toBe(400);41- const { authorization: __, ...noAuth } = ok;42- r = await head(host, noAuth);43- expect(r.status).toBe(401);44- expect(r.reason).toMatch(/^auth-required/);45- r = await head(host, { ...ok, "x-content-length": String(26 * 1024 * 1024) });46- expect(r.status).toBe(413);47- expect(r.reason).toMatch(/25 MB/);48- r = await head(host, { ...ok, authorization: blossomToken(owner, "upload", "ab".repeat(32)) });49- expect(r.status).toBe(400);50-51- await rpc(host, owner, "setpolicy", { writes: "owner" });52- r = await head(host, { ...ok, authorization: blossomToken(stranger, "upload", sha) });53- expect(r.status).toBe(403);54- expect(r.reason).toMatch(/^restricted/);55-56- // Already stored is still a 200: the PUT would answer with the descriptor.57- expect((await upload(host, owner, "a small file")).status).toBe(200);58- expect((await head(host, ok)).status).toBe(200);59- });60-});61-62-describe("BUD-04 mirror", () => {63- it("copies a blob from another relay on this host, once", async () => {64- const a = "buds-src.bind.ws";65- const b = "buds-dst.bind.ws";66- const alice = generateSecretKey();67- const bob = generateSecretKey();68- await rpc(a, alice, "claim");69- await rpc(b, bob, "claim");70- const up = await upload(a, alice, "the same bytes, elsewhere");71- expect(up.status).toBe(200);72- expect(up.body.url).toBe(`https://${a}/${up.sha}.txt`);73-74- let r = await mirror(b, bob, up.body.url, up.sha);75- expect(r.status, r.reason).toBe(201);76- expect(r.body).toEqual({ url: `https://${b}/${up.sha}.txt`, sha256: up.sha, size: up.body.size, type: "text/plain", uploaded: expect.any(Number), nip94: expect.any(Array) });77- const got = await SELF.fetch(`http://${b}/${up.sha}`);78- expect(got.status).toBe(200);79- expect(await got.text()).toBe("the same bytes, elsewhere");80- const list = (await rpc(b, bob, "listblobs")).result;81- expect(list.map((x: any) => [x.sha256, x.uploader])).toEqual([[up.sha, getPublicKey(bob)]]);82-83- r = await mirror(b, bob, up.body.url, up.sha);84- expect(r.status).toBe(200);85- expect(r.body.sha256).toBe(up.sha);86- expect((await rpc(b, bob, "listblobs")).result.length).toBe(1);87- });88-89- it("refuses what it should: wrong hash, bad body, bad url, no auth, policy", async () => {90- const a = "buds-src2.bind.ws";91- const b = "buds-dst2.bind.ws";92- const alice = generateSecretKey();93- const bob = generateSecretKey();94- const stranger = generateSecretKey();95- await rpc(a, alice, "claim");96- await rpc(b, bob, "claim");97- const up = await upload(a, alice, "bytes to mirror");98-99- let r = await mirror(b, bob, up.body.url, "ab".repeat(32));100- expect([r.status, r.reason.split(":")[0]]).toEqual([409, "invalid"]);101- expect((await rpc(b, bob, "listblobs")).result).toEqual([]);102- r = await mirror(b, bob, undefined, undefined, "not json");103- expect(r.status).toBe(400);104- r = await mirror(b, bob, "");105- expect(r.status).toBe(400);106- r = await mirror(b, bob, "ftp:/nope");107- expect(r.status).toBe(400);108- r = await mirror(b, bob, "http://example.com/" + up.sha);109- expect([r.status, r.reason]).toEqual([400, "invalid: only https urls can be mirrored"]);110- r = await mirror(b, bob, `https://${a}/${"cd".repeat(32)}`);111- expect([r.status, r.reason]).toEqual([502, "error: the origin answered 404"]);112- const noAuth = await SELF.fetch(`http://${b}/mirror`, { method: "PUT", body: JSON.stringify({ url: up.body.url }) });113- expect(noAuth.status).toBe(401);114-115- await rpc(b, bob, "setpolicy", { writes: "owner" });116- r = await mirror(b, stranger, up.body.url, up.sha);117- expect(r.status).toBe(403);118- await rpc(b, bob, "setpolicy", { writes: "open", maxBlobMB: 1 });119- // The origin declares its size, so an oversized blob is refused before any bytes move.120- await rpc(a, alice, "setpolicy", { maxBlobMB: 2 });121- const big = await SELF.fetch(`http://${a}/upload`, { method: "PUT", headers: { authorization: blossomToken(alice, "upload"), "content-type": "application/octet-stream" }, body: new Uint8Array(1024 * 1024 + 1) });122- expect(big.status).toBe(200);123- r = await mirror(b, bob, (await big.json<any>()).url);124- expect(r.status).toBe(413);125- });126-127-});@@ -1,12 +1,59 @@
1-// NIP-46 transport: kind 24133 passes the ownership and write gates and a2-// subscription to it alone passes the read gate, so the relay can carry a3-// remote signer's session for anyone, including someone about to claim it.1+// Doors for a client without a socket: the HTTP bridge, and NIP-46 as2+// transport, where kind 24133 passes the ownership and write gates and a3+// subscription to it alone passes the read gate.44 import { SELF } from "cloudflare:test";55 import { describe, it, expect } from "vitest";6-import { generateSecretKey, getPublicKey } from "nostr-tools/pure";7-import { ev, rpc } from "../helpers/relay.ts";6+import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";7+import { getToken } from "nostr-tools/nip98";8+import { ev, rpc, post } from "../helpers/relay.ts";89 import { WS } from "../helpers/ws.ts";910 11+describe("HTTP bridge", () => {12+ it("accepts events, answers queries and counts with NIP-98, applying the same gates", async () => {13+ const host = "bridge.bind.ws";14+ const owner = generateSecretKey();15+ const other = generateSecretKey();16+ await rpc(host, owner, "claim");17+ const e = ev(owner, 1, "over http");18+ let r = await post(host, owner, "/events", e);19+ expect(r.status).toBe(200);20+ expect(r.body).toEqual({ event_id: e.id, accepted: true, message: "" });21+ r = await post(host, owner, "/events", e);22+ expect(r.body.accepted).toBe(true);23+ expect(r.body.message).toMatch(/^duplicate:/);24+25+ r = await post(host, other, "/query", [{ kinds: [1], authors: [getPublicKey(owner)] }]);26+ expect(r.status).toBe(200);27+ expect(r.body.map((x: Event) => x.id)).toEqual([e.id]);28+ r = await post(host, other, "/count", [{ kinds: [1] }]);29+ expect(r.body.count).toBe(1);30+31+ // Unsigned, wrong-URL, and non-JSON requests are refused cleanly.32+ expect((await post(host, null, "/query", [{}])).status).toBe(401);33+ const badUrlToken = await getToken("http://elsewhere.bind.ws/query", "POST", (x) => finalizeEvent(x, other), true, [{}] as any);34+ const bad = await SELF.fetch(`http://${host}/query`, { method: "POST", headers: { authorization: badUrlToken }, body: "[{}]" });35+ expect(bad.status).toBe(401);36+37+ // A subscriber on the socket sees bridge writes live.38+ const c = await WS.connect(host);39+ await c.open("live", { kinds: [1] });40+ const e2 = ev(owner, 1, "pushed");41+ await post(host, owner, "/events", e2);42+ expect((await c.expect("EVENT"))[2].id).toBe(e2.id);43+44+ // Private kinds via the bridge follow the recipient rule; the signer counts as authenticated.45+ const recipient = generateSecretKey();46+ const wrap = ev(generateSecretKey(), 1059, "dm", [["p", getPublicKey(recipient)]]);47+ await post(host, owner, "/events", wrap);48+ r = await post(host, other, "/query", [{ kinds: [1059] }]);49+ expect(r.status).toBe(200);50+ expect(r.body).toEqual([]); // authenticated but not a party: silently filtered51+ expect((await post(host, null, "/query", [{ kinds: [1059] }])).status).toBe(401);52+ r = await post(host, recipient, "/query", [{ kinds: [1059] }]);53+ expect(r.body.map((x: Event) => x.id)).toEqual([wrap.id]);54+ });55+});56+1057 describe("NIP-46 transport", () => {1158 it("carries kind 24133 on an unclaimed relay, live only, never stored", async () => {1259 const host = "phone.bind.ws";@@ -0,0 +1,198 @@
1+// The owner's configuration as a whole: export and import, with the rules2+// and address blocks travelling along, retention and purge, and teardown.3+import { SELF, env, runInDurableObject } from "cloudflare:test";4+import { describe, it, expect } from "vitest";5+import { generateSecretKey, getPublicKey } from "nostr-tools/pure";6+import { sha256 } from "@noble/hashes/sha2.js";7+import type { Relay } from "../../src/relay.ts";8+import { bytesToHex } from "../../src/negentropy.ts";9+import { now, ev, rpc } from "../helpers/relay.ts";10+import { WS } from "../helpers/ws.ts";11+12+describe("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/1");25+ expect(cfg.policy.owner).toBeUndefined();26+ expect(cfg.members).toEqual([{ pubkey: m1, name: "alice", note: "friend" }]);27+ expect(cfg.bans).toEqual([{ pubkey: bad, reason: "spam" }]);28+ expect(cfg.kinds).toEqual({ allow: [1], block: [7] });29+30+ 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+ });49+});50+51+describe("rules in export and import", () => {52+ it("carries the new rules through export and import", async () => {53+ const a = "gate-a.bind.ws", b = "gate-b.bind.ws";54+ const owner = generateSecretKey();55+ await rpc(a, owner, "claim");56+ await rpc(b, owner, "claim");57+ await rpc(a, owner, "setpolicy", { writes: "wot", openKinds: [7, 9735], guestReplies: true, reportThreshold: 3 });58+ await rpc(a, owner, "setblockedwords", ["casino"]);59+ const cfg = (await rpc(a, owner, "exportconfig")).result;60+ expect(cfg.policy).toMatchObject({ writes: "wot", openKinds: [7, 9735], guestReplies: true, reportThreshold: 3, blockedWords: ["casino"] });61+ expect((await rpc(b, owner, "importconfig", cfg)).status).toBe(200);62+ const p = (await rpc(b, owner, "getpolicy")).result;63+ expect(p).toMatchObject({ writes: "wot", openKinds: [7, 9735], guestReplies: true, reportThreshold: 3, blockedWords: ["casino"] });64+ // resetrules puts the guest rules back but keeps the moderation ones.65+ await rpc(b, owner, "resetrules");66+ const q = (await rpc(b, owner, "getpolicy")).result;67+ expect(q).toMatchObject({ writes: "open", openKinds: [], guestReplies: false, reportThreshold: 3, blockedWords: ["casino"] });68+ });69+});70+71+describe("address blocks in the configuration", () => {72+ it("export carries them and import applies them, dropping the sockets they refuse", async () => {73+ const a = "addr-a.bind.ws";74+ const b = "addr-b.bind.ws";75+ const owner = generateSecretKey();76+ const bad = "203.0.113.9";77+ await rpc(a, owner, "claim");78+ await rpc(b, owner, "claim");79+ await rpc(a, owner, "blockip", bad, "scraper");80+ await rpc(a, owner, "blockip", "2001:DB8::9", "");81+ const cfg = (await rpc(a, owner, "exportconfig")).result;82+ const byIP = (l: { ip: string; reason: string }[]) => [...l].sort((x, y) => x.ip.localeCompare(y.ip));83+ expect(byIP(cfg.addresses)).toEqual([{ ip: "2001:db8::9", reason: "" }, { ip: bad, reason: "scraper" }]);84+85+ // The second relay has a stale block of its own and an open socket from the address about to be refused.86+ await rpc(b, owner, "blockip", "198.51.100.200", "old");87+ const open = (await WS.tryConnect(b, bad))!;88+ expect((await open.ok(ev(owner, 1, "before"))).ok).toBe(true);89+ cfg.addresses.push({ ip: "not an address", reason: "x" }, { reason: "no ip" });90+ const imported = await rpc(b, owner, "importconfig", cfg);91+ expect(imported.status).toBe(200);92+ expect(byIP(imported.result.addresses)).toEqual([{ ip: "2001:db8::9", reason: "" }, { ip: bad, reason: "scraper" }]);93+ expect((await rpc(b, owner, "listblockedips")).result.map((x: { ip: string }) => x.ip).sort()).toEqual(["2001:db8::9", bad]);94+ for (let i = 0; i < 40 && !open.closed; i++) await new Promise((r) => setTimeout(r, 25));95+ expect(open.closed?.code).toBe(4403);96+ expect(await WS.tryConnect(b, bad)).toBeNull();97+ expect(await WS.tryConnect(b, "198.51.100.200")).not.toBeNull();98+ // A document without the list clears the blocks, like the other lists.99+ delete cfg.addresses;100+ await rpc(b, owner, "importconfig", cfg);101+ expect((await rpc(b, owner, "listblockedips")).result).toEqual([]);102+ expect(await WS.tryConnect(b, bad)).not.toBeNull();103+ });104+});105+106+describe("storage: retention and purge", () => {107+ it("refuses events past a keep-for rule, sheds old ones daily, spares replaceable kinds from the catch-all, and purges on demand", async () => {108+ const host = "keep.bind.ws";109+ const owner = generateSecretKey();110+ await rpc(host, owner, "claim");111+ const c = await WS.connect(host);112+ const day = 86400;113+ // Before any rule: an old reaction, an old note, an old profile all land.114+ expect((await c.ok(ev(owner, 7, "+", [], now() - 40 * day))).ok).toBe(true);115+ expect((await c.ok(ev(owner, 1, "old note", [], now() - 40 * day))).ok).toBe(true);116+ expect((await c.ok(ev(owner, 0, '{"name":"old me"}', [], now() - 400 * day))).ok).toBe(true);117+ expect((await c.ok(ev(owner, 1, "fresh note"))).ok).toBe(true);118+119+ // Reactions kept 30 days, everything else 100 days.120+ expect((await rpc(host, owner, "setretention", 7, 30)).result).toEqual([{ kind: 7, days: 30 }]);121+ await rpc(host, owner, "setretention", null, 100);122+ expect((await c.ok(ev(owner, 7, "+", [], now() - 31 * day))).msg).toMatch(/^blocked: this relay keeps kind 7 for 30 days/);123+ expect((await c.ok(ev(owner, 7, "+", [], now() - 29 * day))).ok).toBe(true);124+ expect((await c.ok(ev(owner, 1, "too old", [], now() - 101 * day))).msg).toMatch(/keeps kind 1 for 100 days/);125+ // The catch-all spares replaceable kinds: a very old profile still lands.126+ expect((await c.ok(ev(owner, 0, '{"name":"newer old me"}', [], now() - 300 * day))).ok).toBe(true);127+ const info: any = await (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json();128+ expect(info.retention).toEqual([{ kinds: [7], time: 30 * day }, { time: 100 * day }]);129+130+ // The daily sweep sheds the 40-day-old reaction and keeps the 40-day-old note (under 100).131+ const stub = env.RELAY.getByName("keep");132+ await runInDurableObject(stub, async (r: Relay) => expect(r.sweepRetention(now())).toBe(1));133+ let st = (await rpc(host, owner, "storagestats")).result;134+ const byKind = Object.fromEntries(st.kinds.map((k: any) => [k.kind, k.n]));135+ 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 own136+ expect(st.events).toBe(13);137+ expect(st.eventBytes).toBeGreaterThan(0);138+ expect(st.retention).toEqual([{ kind: 7, days: 30 }, { kind: null, days: 100 }]);139+140+ // Purge notes older than 10 days, then everything of kind 7.141+ expect((await rpc(host, owner, "purgekind", 1, 10)).result).toEqual({ deleted: 1 });142+ expect((await rpc(host, owner, "purgekind", 7, 0)).result).toEqual({ deleted: 1 });143+ st = (await rpc(host, owner, "storagestats")).result;144+ expect(st.events).toBe(11);145+146+ // Rules travel with the configuration; removing one is days 0.147+ const cfg = (await rpc(host, owner, "exportconfig")).result;148+ expect(cfg.retention).toEqual([{ kind: 7, days: 30 }, { kind: null, days: 100 }]);149+ await rpc(host, owner, "setretention", null, 0);150+ expect((await rpc(host, owner, "listretention")).result).toEqual([{ kind: 7, days: 30 }]);151+ expect((await rpc(host, owner, "setretention", 70000, 5)).status).toBe(400);152+ // Load-bearing kinds can be neither expired nor purged, and the catch-all skips them.153+ expect((await rpc(host, owner, "setretention", 0, 5)).status).toBe(400);154+ expect((await rpc(host, owner, "purgekind", 13534, 0)).status).toBe(400);155+ await rpc(host, owner, "setretention", null, 1);156+ expect((await rpc(host, owner, "purgekind", null, 0)).result.deleted).toBe(1); // the fresh note; profile, roster and group state stay157+ 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]);158+ });159+});160+161+describe("teardown", () => {162+ it("requires the typed name, then wipes everything and returns the name to unclaimed", async () => {163+ const host = "gone.bind.ws";164+ const owner = generateSecretKey();165+ const member = generateSecretKey();166+ await rpc(host, owner, "claim");167+ await rpc(host, owner, "setmember", getPublicKey(member), { name: "bob" });168+ const c = await WS.connect(host);169+ await c.ok(ev(owner, 1, "to be deleted"));170+ await c.open("live", { kinds: [1] });171+ const body = new TextEncoder().encode("bytes");172+ const sha = bytesToHex(sha256(body));173+ const token = "Nostr " + btoa(JSON.stringify(ev(owner, 24242, "upload", [["t", "upload"], ["x", sha], ["expiration", String(now() + 300)]])));174+ expect((await SELF.fetch(`http://${host}/upload`, { method: "PUT", headers: { authorization: token, "content-type": "text/plain" }, body })).status).toBe(200);175+176+ expect((await rpc(host, member, "deleterelay", "gone")).status).toBe(403);177+ const wrong = await rpc(host, owner, "deleterelay", "typo");178+ expect(wrong.status).toBe(400);179+ expect((await rpc(host, owner, "stats")).result.events).toBeGreaterThan(0);180+181+ expect((await rpc(host, owner, "deleterelay", "gone")).result).toEqual({ deleted: true, name: "gone" });182+ for (let i = 0; i < 20 && !c.closed; i++) await new Promise((r) => setTimeout(r, 50));183+ expect(c.closed?.code).toBe(4410);184+ const info: any = await (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json();185+ expect(info.pubkey).toBeUndefined();186+ expect(info.self).toBeUndefined();187+ expect((await SELF.fetch(`http://${host}/${sha}`)).status).toBe(404);188+ expect((await env.MEDIA.list({ prefix: "gone/" })).objects).toEqual([]);189+ const fresh = await WS.connect(host);190+ expect(await fresh.req({ kinds: [1] })).toEqual([]);191+ expect((await fresh.ok(ev(owner, 1, "again"))).msg).toMatch(/unclaimed/);192+ const doc: any = await (await SELF.fetch(`http://${host}/.well-known/nostr.json?name=bob`)).json();193+ expect(doc.names).toEqual({});194+ // Anyone can claim the name again, including someone else.195+ const other = generateSecretKey();196+ expect((await rpc(host, other, "claim")).result.claimed).toBe(true);197+ });198+});@@ -0,0 +1,78 @@
1+// Dumps to R2, and the signed door that serves them.2+import { env, runInDurableObject } from "cloudflare:test";3+import { describe, it, expect } from "vitest";4+import { generateSecretKey } from "nostr-tools/pure";5+import type { Relay } from "../../src/relay.ts";6+import { writeDump } from "../../src/dumps.ts";7+import { now, ev, rpc, get } from "../helpers/relay.ts";8+import { WS } from "../helpers/ws.ts";9+10+describe("dumps", () => {11+ it("writes a JSONL of every event to R2, lists it, serves it to a signature, rotates and counts as media", async () => {12+ const host = "dumpy.bind.ws";13+ const owner = generateSecretKey();14+ const writer = generateSecretKey();15+ await rpc(host, owner, "claim");16+ const c = await WS.connect(host);17+ for (let i = 0; i < 3; i++) expect((await c.ok(ev(writer, 1, "note " + i))).ok).toBe(true);18+ expect((await rpc(host, owner, "setpolicy", { dumps: "daily", dumpsKeep: 2 })).result.dumps).toBe("daily");19+20+ const d = (await rpc(host, owner, "dumpnow")).result;21+ expect(d.name).toMatch(/^\d{4}-\d{2}-\d{2}\.jsonl$/);22+ const total = (await rpc(host, owner, "stats")).result.events;23+ expect(d.events).toBe(total);24+ expect(total).toBeGreaterThanOrEqual(3);25+ const obj = await env.MEDIA.get(`dumpy/dumps/${d.name}`);26+ expect(obj).not.toBeNull();27+ const text = await obj!.text();28+ const lines = text.split("\n").filter(Boolean);29+ expect(lines.length).toBe(d.events);30+ for (const l of lines) expect(JSON.parse(l).id).toMatch(/^[0-9a-f]{64}$/);31+ expect(d.bytes).toBe(text.length);32+33+ const list = (await rpc(host, owner, "listdumps")).result;34+ expect(list.map((x: any) => x.name)).toEqual([d.name]);35+ expect(list[0].url).toBe("/dumps/" + d.name);36+37+ // Download needs a signature from someone with the storage action.38+ const signed = await get(host, "/dumps/" + d.name, owner);39+ expect(signed.status).toBe(200);40+ expect(signed.headers.get("content-disposition")).toContain(d.name);41+ expect(await signed.text()).toBe(text);42+ expect((await get(host, "/dumps/" + d.name, null)).status).toBe(401);43+ expect((await get(host, "/dumps/" + d.name, writer)).status).toBe(403);44+ expect((await get(host, "/dumps/nope.jsonl", owner)).status).toBe(400);45+ expect((await get(host, "/dumps/1999-01-01.jsonl", owner)).status).toBe(404);46+47+ const stub = env.RELAY.getByName("dumpy");48+ await runInDurableObject(stub, async (r: Relay) => {49+ expect(r.mediaBytes()).toBe(d.bytes);50+ // Two older dumps and a keep of two: the oldest goes.51+ await writeDump(r, now() - 2 * 86400);52+ await writeDump(r, now() - 86400);53+ });54+ const after = (await rpc(host, owner, "listdumps")).result.map((x: any) => x.name);55+ expect(after.length).toBe(2);56+ expect(after[0]).toBe(d.name);57+ expect((await env.MEDIA.list({ prefix: "dumpy/dumps/" })).objects.length).toBe(2);58+ expect((await rpc(host, owner, "storagestats")).result.dumps).toBe(2);59+60+ expect((await rpc(host, owner, "deletedump", after[1])).result).toBe(true);61+ expect((await env.MEDIA.list({ prefix: "dumpy/dumps/" })).objects.length).toBe(1);62+ expect((await rpc(host, writer, "listdumps")).status).toBe(403);63+ });64+65+ it("the alarm writes a scheduled dump once a day, not twice", async () => {66+ const host = "dumpz.bind.ws";67+ const owner = generateSecretKey();68+ await rpc(host, owner, "claim");69+ const stub = env.RELAY.getByName("dumpz");70+ await runInDurableObject(stub, async (r: Relay) => r.alarm());71+ expect((await rpc(host, owner, "listdumps")).result.length).toBe(0);72+ await rpc(host, owner, "setpolicy", { dumps: "daily" });73+ await runInDurableObject(stub, async (r: Relay) => r.alarm());74+ expect((await rpc(host, owner, "listdumps")).result.length).toBe(1);75+ await runInDurableObject(stub, async (r: Relay) => r.alarm());76+ expect((await rpc(host, owner, "listdumps")).result.length).toBe(1);77+ });78+});@@ -1,215 +0,0 @@
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 { generateSecretKey } from "nostr-tools/pure";7-import { now, ev, pk, rpc } from "../helpers/relay.ts";8-import { WS } from "../helpers/ws.ts";9-import { upload } from "../helpers/media.ts";10-11-describe("guests", () => {12- it("lets a stranger through a members-only rule for open kinds and for replies to members", async () => {13- const host = "guests.bind.ws";14- const owner = generateSecretKey();15- const member = generateSecretKey();16- const stranger = generateSecretKey();17- await rpc(host, owner, "claim");18- await rpc(host, owner, "setmember", pk(member), {});19- let r = await rpc(host, owner, "setpolicy", { writes: "allowlist", openKinds: [7, 7, 70000, -1], guestReplies: true });20- expect(r.result.openKinds).toEqual([7]);21- expect(r.result.guestReplies).toBe(true);22-23- const c = await WS.connect(host);24- const note = ev(member, 1, "a member's note");25- expect((await c.ok(note))).toEqual({ ok: true, msg: "" });26- const outsiderNote = ev(stranger, 1, "hello?");27- expect((await c.ok(outsiderNote)).msg).toMatch(/only accepts events from its members/);28- // An open kind passes the rule; a reply to a member passes; a reply to nothing here does not.29- expect((await c.ok(ev(stranger, 7, "+", [["e", note.id]]))).ok).toBe(true);30- expect((await c.ok(ev(stranger, 1, "nice", [["e", note.id, "", "reply"]]))).ok).toBe(true);31- expect((await c.ok(ev(stranger, 1111, "a comment", [["E", note.id]]))).ok).toBe(true);32- expect((await c.ok(ev(stranger, 1, "into the void", [["e", "ab".repeat(32)]]))).ok).toBe(false);33- // A reply to another stranger's event is not a reply to a member.34- const guestReply = (await c.req({ kinds: [1], authors: [pk(stranger)] }))[0];35- expect((await c.ok(ev(generateSecretKey(), 1, "reply to a guest", [["e", guestReply.id]]))).ok).toBe(false);36- // Bans still win, and so do kind rules.37- await rpc(host, owner, "disallowkind", 7);38- expect((await c.ok(ev(stranger, 7, "-", [["e", note.id]]))).msg).toMatch(/does not accept kind 7/);39- await rpc(host, owner, "setpolicy", { guestReplies: false });40- expect((await c.ok(ev(stranger, 1, "again", [["e", note.id]]))).ok).toBe(false);41- // Only me plus open kinds: the owner's rule, guests' kinds.42- await rpc(host, owner, "unrulekind", 7);43- await rpc(host, owner, "setpolicy", { writes: "owner" });44- expect((await c.ok(ev(member, 1, "member note"))).msg).toMatch(/only the relay owner/);45- expect((await c.ok(ev(member, 7, "+", [["e", note.id]]))).ok).toBe(true);46- });47-});48-49-describe("web of trust", () => {50- it("admits the people members follow, and follows the lists as they change", async () => {51- const host = "wot.bind.ws";52- const owner = generateSecretKey();53- const member = generateSecretKey();54- const friend = generateSecretKey();55- const friendOfMember = generateSecretKey();56- const stranger = generateSecretKey();57- await rpc(host, owner, "claim");58- const c = await WS.connect(host);59- expect((await c.ok(ev(owner, 3, "", [["p", pk(friend)]]))).ok).toBe(true);60- const r = await rpc(host, owner, "setpolicy", { writes: "wot" });61- expect(r.result.writes).toBe("wot");62- expect((await c.ok(ev(friend, 1, "a friend of the owner"))).ok).toBe(true);63- expect((await c.ok(ev(stranger, 1, "nobody follows me"))).msg).toMatch(/members and the people they follow/);64- expect((await upload(host, stranger, "a stranger's file")).status).toBe(403);65- expect((await upload(host, friend, "a friend's file")).status).toBe(200);66-67- // A member's list arriving extends the web; the member leaving shrinks it.68- await rpc(host, owner, "setmember", pk(member), {});69- expect((await c.ok(ev(member, 3, "", [["p", pk(friendOfMember)]]))).ok).toBe(true);70- expect((await c.ok(ev(friendOfMember, 1, "followed by a member"))).ok).toBe(true);71- await rpc(host, owner, "removemember", pk(member));72- expect((await c.ok(ev(friendOfMember, 1, "the member left"))).ok).toBe(false);73- // The owner's newer list without the friend drops them.74- expect((await c.ok(ev(owner, 3, "", [["p", pk(stranger)]], now() + 1))).ok).toBe(true);75- expect((await c.ok(ev(friend, 1, "unfollowed"))).ok).toBe(false);76- expect((await c.ok(ev(stranger, 1, "now followed"))).ok).toBe(true);77- // The group flags read the rule as restricted and closed.78- const info: any = await (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json();79- expect(info.limitation.restricted_writes).toBe(true);80- const meta = (await c.req({ kinds: [39000] }))[0];81- expect(meta.tags.some((t: string[]) => t[0] === "restricted")).toBe(true);82- expect(meta.tags.some((t: string[]) => t[0] === "closed")).toBe(true);83- });84-});85-86-describe("blocked words", () => {87- it("refuses content with a blocked word from anyone but the owner and moderators", async () => {88- const host = "words.bind.ws";89- const owner = generateSecretKey();90- const mod = generateSecretKey();91- const member = generateSecretKey();92- await rpc(host, owner, "claim");93- await rpc(host, owner, "setmember", pk(mod), { role: "moderator" });94- await rpc(host, owner, "setmember", pk(member), {});95- let r = await rpc(host, owner, "setblockedwords", [" Casino ", "free money", "x", "a".repeat(65), "casino"]);96- expect(r.result).toEqual(["casino", "free money"]);97- expect((await rpc(host, owner, "getpolicy")).result.blockedWords).toEqual(["casino", "free money"]);98- // A moderator may set the list too.99- r = await rpc(host, mod, "setblockedwords", ["casino"]);100- expect(r.status).toBe(200);101- expect((await rpc(host, member, "setblockedwords", ["casino"])).status).toBe(403);102-103- const c = await WS.connect(host);104- expect((await c.ok(ev(member, 1, "Big CASINO night"))).msg).toBe("blocked: content contains a blocked word");105- expect((await c.ok(ev(generateSecretKey(), 1, "casino"))).ok).toBe(false);106- expect((await c.ok(ev(member, 1, "a quiet evening"))).ok).toBe(true);107- expect((await c.ok(ev(owner, 1, "the casino is closed"))).ok).toBe(true);108- expect((await c.ok(ev(mod, 1, "no casino talk please"))).ok).toBe(true);109- // The list travels with the configuration.110- const cfg = (await rpc(host, owner, "exportconfig")).result;111- expect(cfg.policy.blockedWords).toEqual(["casino"]);112- });113-});114-115-describe("ban and erase", () => {116- it("removes everything a banned pubkey wrote and uploaded when asked", async () => {117- const host = "erase.bind.ws";118- const owner = generateSecretKey();119- const member = generateSecretKey();120- const other = generateSecretKey();121- await rpc(host, owner, "claim");122- await rpc(host, owner, "setmember", pk(member), {});123- await rpc(host, owner, "setmember", pk(other), {});124- const c = await WS.connect(host);125- 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);126- const { sha } = await upload(host, member, "a file to erase");127- expect((await SELF.fetch(`http://${host}/${sha}`)).status).toBe(200);128- // A plain ban keeps the history.129- expect((await rpc(host, owner, "banpubkey", pk(other), "plain")).result).toBe(true);130- expect((await c.req({ authors: [pk(other)] })).length).toBe(1);131- // A ban with erase does not.132- expect((await rpc(host, owner, "banpubkey", pk(member), "gone", true)).result).toBe(true);133- expect(await c.req({ authors: [pk(member)] })).toEqual([]);134- expect((await SELF.fetch(`http://${host}/${sha}`)).status).toBe(404);135- expect((await rpc(host, owner, "listblobs")).result).toEqual([]);136- expect((await rpc(host, owner, "listmembers")).result.members.some((m: any) => m.pubkey === pk(member))).toBe(false);137- expect((await c.ok(ev(member, 1, "back?"))).msg).toMatch(/banned/);138- // The same through a report.139- const reporter = generateSecretKey();140- await rpc(host, owner, "setpolicy", { writes: "open" });141- const bad = ev(generateSecretKey(), 1, "report me");142- expect((await c.ok(bad)).ok).toBe(true);143- expect((await c.ok(ev(reporter, 1984, "spam", [["e", bad.id, "spam"], ["p", bad.pubkey]]))).ok).toBe(true);144- const report = (await rpc(host, owner, "listreports")).result[0];145- expect((await rpc(host, owner, "resolvereport", report.id, "ban", true)).result).toBe(true);146- expect(await c.req({ authors: [bad.pubkey] })).toEqual([]);147- });148-});149-150-describe("report thresholds", () => {151- it("hides an event once enough people report it, until a moderator resolves the reports", async () => {152- const host = "threshold.bind.ws";153- const owner = generateSecretKey();154- const author = generateSecretKey();155- const r1 = generateSecretKey();156- const r2 = generateSecretKey();157- await rpc(host, owner, "claim");158- expect((await rpc(host, owner, "setpolicy", { reportThreshold: 2 })).result.reportThreshold).toBe(2);159- const c = await WS.connect(host);160- const note = ev(author, 1, "contested");161- expect((await c.ok(note)).ok).toBe(true);162- const report = (sk: Uint8Array) => ev(sk, 1984, "nope", [["e", note.id, "spam"], ["p", pk(author)]]);163- expect((await c.ok(report(r1))).ok).toBe(true);164- expect((await c.ok(report(r1))).ok).toBe(true); // the same reporter twice counts once165- expect((await c.req({ ids: [note.id] })).length).toBe(1);166- expect((await c.ok(report(r2))).ok).toBe(true);167- // Hidden everywhere stored events are read.168- expect(await c.req({ ids: [note.id] })).toEqual([]);169- expect((await c.count({ authors: [pk(author)] })).count).toBe(0);170- expect((await SELF.fetch(`http://${host}/e/${note.id}`)).status).toBe(404);171- const feed = await (await SELF.fetch(`http://${host}/feed.xml`)).text();172- expect(feed.includes(note.id)).toBe(false);173- const reports = (await rpc(host, owner, "listreports")).result;174- expect(reports.length).toBe(2);175- expect(reports.every((r: any) => r.hidden === 1)).toBe(true);176- expect((await rpc(host, owner, "listeventsneedingmoderation")).result.map((r: any) => r.id)).toEqual([note.id]);177- // Dismissing one report leaves the hold; dismissing the last lifts it.178- await rpc(host, owner, "resolvereport", reports[0].id, "dismiss");179- expect(await c.req({ ids: [note.id] })).toEqual([]);180- await rpc(host, owner, "resolvereport", reports[1].id, "dismiss");181- expect((await c.req({ ids: [note.id] })).length).toBe(1);182- // Reports on a pubkey alone never hide anything.183- expect((await c.ok(ev(r1, 1984, "person", [["p", pk(author), "impersonation"]]))).ok).toBe(true);184- expect((await c.ok(ev(r2, 1984, "person", [["p", pk(author), "impersonation"]]))).ok).toBe(true);185- expect((await c.req({ ids: [note.id] })).length).toBe(1);186- // Delete makes it permanent.187- const again = ev(author, 1, "contested again");188- expect((await c.ok(again)).ok).toBe(true);189- for (const sk of [r1, r2]) expect((await c.ok(ev(sk, 1984, "nope", [["e", again.id, "spam"], ["p", pk(author)]]))).ok).toBe(true);190- expect(await c.req({ ids: [again.id] })).toEqual([]);191- const open = (await rpc(host, owner, "listreports")).result.filter((r: any) => r.target_event === again.id);192- await rpc(host, owner, "resolvereport", open[0].id, "delete");193- expect((await c.ok(again)).msg).toMatch(/banned/);194- });195-});196-197-describe("configuration", () => {198- it("carries the new rules through export and import", async () => {199- const a = "gate-a.bind.ws", b = "gate-b.bind.ws";200- const owner = generateSecretKey();201- await rpc(a, owner, "claim");202- await rpc(b, owner, "claim");203- await rpc(a, owner, "setpolicy", { writes: "wot", openKinds: [7, 9735], guestReplies: true, reportThreshold: 3 });204- await rpc(a, owner, "setblockedwords", ["casino"]);205- const cfg = (await rpc(a, owner, "exportconfig")).result;206- expect(cfg.policy).toMatchObject({ writes: "wot", openKinds: [7, 9735], guestReplies: true, reportThreshold: 3, blockedWords: ["casino"] });207- expect((await rpc(b, owner, "importconfig", cfg)).status).toBe(200);208- const p = (await rpc(b, owner, "getpolicy")).result;209- expect(p).toMatchObject({ writes: "wot", openKinds: [7, 9735], guestReplies: true, reportThreshold: 3, blockedWords: ["casino"] });210- // resetrules puts the guest rules back but keeps the moderation ones.211- await rpc(b, owner, "resetrules");212- const q = (await rpc(b, owner, "getpolicy")).result;213- expect(q).toMatchObject({ writes: "open", openKinds: [], guestReplies: false, reportThreshold: 3, blockedWords: ["casino"] });214- });215-});@@ -1,12 +1,47 @@
1-// NIP-43, the rest of it: the relay's own profile, role definitions, roles2-// in the roster, the NIP-43 join and leave requests, and the promise that3-// the roster and the NIP-29 group never disagree, whichever path changed4-// the members.1+// The relay's identity under NIP-43: its profile, the role definitions, the2+// roster with roles, join and leave requests, and the promise that the roster3+// and the NIP-29 group never disagree, whichever path changed the members.4+import { SELF } from "cloudflare:test";55 import { describe, it, expect } from "vitest";6-import { generateSecretKey } from "nostr-tools/pure";7-import { ev, pk, tagsOf, rpc, info } from "../helpers/relay.ts";6+import { generateSecretKey, getPublicKey } from "nostr-tools/pure";7+import { ev, rpc, pk, tagsOf, info } from "../helpers/relay.ts";88 import { WS } from "../helpers/ws.ts";99 10+describe("relay identity and NIP-43 roster", () => {11+ it("signs a roster after claiming and deltas on membership changes, advertised as self", async () => {12+ const host = "roster.bind.ws";13+ const owner = generateSecretKey();14+ const bob = generateSecretKey();15+ let info: any = await (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json();16+ expect(info.self).toBeUndefined();17+ await rpc(host, owner, "claim");18+ info = await (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json();19+ expect(info.self).toMatch(/^[0-9a-f]{64}$/);20+ expect(info.supported_nips).toContain(43);21+ const c = await WS.connect(host);22+ c.send("REQ", "live", { kinds: [8000, 8001] });23+ await c.expect("EOSE");24+ let r = await c.req({ kinds: [13534] });25+ expect(r.length).toBe(1);26+ expect(r[0].pubkey).toBe(info.self);27+ expect(r[0].tags).toEqual([["-"], ["member", getPublicKey(owner), "owner"]]);28+29+ await rpc(host, owner, "allowpubkey", getPublicKey(bob), "friend");30+ const added = await c.expect("EVENT");31+ expect(added[2].kind).toBe(8000);32+ expect(added[2].tags).toContainEqual(["p", getPublicKey(bob)]);33+ r = await c.req({ kinds: [13534] });34+ expect(r[0].tags).toContainEqual(["member", getPublicKey(bob)]);35+ await rpc(host, owner, "unrulepubkey", getPublicKey(bob));36+ expect((await c.expect("EVENT"))[2].kind).toBe(8001);37+ // Nobody can forge relay-signed kinds: a client-signed 13534 is just another replaceable event by another author.38+ const forged = await c.ok(ev(bob, 13534, "", [["member", getPublicKey(bob), "owner"]]));39+ expect(forged.ok).toBe(true);40+ r = await c.req({ kinds: [13534], authors: [info.self] });41+ expect(r.map((x) => [x.pubkey, x.tags]), JSON.stringify(r)).toEqual([[info.self, [["-"], ["member", getPublicKey(owner), "owner"]]]]);42+ });43+});44+1045 describe("relay profile and roles", () => {1146 it("signs a kind 0 for the self key, role definitions, and roles in the roster", async () => {1247 const host = "profile.bind.ws";@@ -1,134 +0,0 @@
1-// NIP-86 gaps: the moderation list over the reports queue, address blocks2-// from the connecting address, and the per-address rate limit that stops a3-// swarm of sockets from multiplying a connection's allowance.4-import { SELF } from "cloudflare:test";5-import { describe, it, expect } from "vitest";6-import { generateSecretKey } from "nostr-tools/pure";7-import { ev, pk, sleep, rpc, post } from "../helpers/relay.ts";8-import { WS } from "../helpers/ws.ts";9-10-describe("listeventsneedingmoderation", () => {11- it("lists open reports by reported thing, deduplicated, for owner and moderator only", async () => {12- const host = "modqueue.bind.ws";13- const owner = generateSecretKey();14- const mod = generateSecretKey();15- const member = generateSecretKey();16- const author = generateSecretKey();17- const reporter = generateSecretKey();18- await rpc(host, owner, "claim");19- await rpc(host, owner, "setmember", pk(mod), { role: "moderator" });20- await rpc(host, owner, "setmember", pk(member), {});21- const c = (await WS.tryConnect(host, "10.9.0.1"))!;22- const note = ev(author, 1, "buy my coin");23- expect((await c.ok(note)).ok).toBe(true);24- // Two reports on the note, one on the author alone.25- expect((await c.ok(ev(reporter, 1984, "junk", [["e", note.id, "spam"], ["p", pk(author)]]))).ok).toBe(true);26- expect((await c.ok(ev(member, 1984, "", [["e", note.id, "spam"], ["p", pk(author)]]))).ok).toBe(true);27- expect((await c.ok(ev(reporter, 1984, "bot", [["p", pk(author), "impersonation"]]))).ok).toBe(true);28-29- const list = await rpc(host, owner, "listeventsneedingmoderation");30- expect(list.status).toBe(200);31- expect(list.result).toEqual([{ id: note.id, reason: "spam: junk" }]);32- expect((await rpc(host, mod, "listeventsneedingmoderation")).result).toEqual([{ id: note.id, reason: "spam: junk" }]);33- expect((await rpc(host, member, "listeventsneedingmoderation")).status).toBe(403);34- expect((await rpc(host, owner, "supportedmethods")).result).toEqual(expect.arrayContaining(["listeventsneedingmoderation", "blockip", "unblockip", "listblockedips"]));35-36- // Resolving the reports empties the list.37- const reports = (await rpc(host, owner, "listreports")).result as { id: string }[];38- for (const r of reports) await rpc(host, owner, "resolvereport", r.id, "dismiss");39- expect((await rpc(host, owner, "listeventsneedingmoderation")).result).toEqual([]);40- });41-});42-43-describe("address blocks", () => {44- it("refuses the socket and the doors, drops open sockets, and can be undone", async () => {45- const host = "blocks.bind.ws";46- const owner = generateSecretKey();47- const mod = generateSecretKey();48- const member = generateSecretKey();49- const bad = "203.0.113.5";50- await rpc(host, owner, "claim");51- await rpc(host, owner, "setmember", pk(mod), { role: "moderator" });52- await rpc(host, owner, "setmember", pk(member), {});53-54- const open = (await WS.tryConnect(host, bad))!;55- expect((await open.ok(ev(member, 1, "before"))).ok).toBe(true);56- expect((await rpc(host, member, "blockip", bad, "scraper")).status).toBe(403);57- expect((await rpc(host, mod, "blockip", "not an address", "x")).status).toBe(400);58- expect((await rpc(host, mod, "blockip", bad, "scraper")).result).toBe(true);59- expect((await rpc(host, owner, "listblockedips")).result).toEqual([{ ip: bad, reason: "scraper" }]);60-61- for (let i = 0; i < 40 && !open.closed; i++) await sleep(25);62- expect(open.closed?.code).toBe(4403);63- expect(await WS.tryConnect(host, bad)).toBeNull();64- const refused = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket", "cf-connecting-ip": bad } });65- expect(refused.status).toBe(403);66- const door = await post(host, member, "/events", ev(member, 1, "still here?"), { "cf-connecting-ip": bad });67- expect(door.status).toBe(403);68- expect(door.body.error).toMatch(/^blocked: this address/);69- expect((await post(host, member, "/count", [{ kinds: [1] }], { "cf-connecting-ip": bad })).status).toBe(403);70- // Another address is unaffected, and so is management from the blocked one.71- expect((await post(host, member, "/events", ev(member, 1, "elsewhere"), { "cf-connecting-ip": "203.0.113.6" })).status).toBe(200);72- expect(await WS.tryConnect(host, "203.0.113.6")).not.toBeNull();73- expect((await rpc(host, owner, "stats")).status).toBe(200);74- // Blocks are part of the portable configuration.75- expect((await rpc(host, owner, "exportconfig")).result.addresses).toEqual([{ ip: bad, reason: "scraper" }]);76-77- expect((await rpc(host, owner, "unblockip", bad)).result).toBe(true);78- expect((await rpc(host, owner, "listblockedips")).result).toEqual([]);79- const again = await WS.tryConnect(host, bad);80- expect(again).not.toBeNull();81- expect((await again!.ok(ev(member, 1, "back"))).ok).toBe(true);82- });83-84- it("accepts IPv6 and rejects ranges and names", async () => {85- const host = "blocks6.bind.ws";86- const owner = generateSecretKey();87- await rpc(host, owner, "claim");88- expect((await rpc(host, owner, "blockip", "2001:DB8::1", "")).result).toBe(true);89- expect((await rpc(host, owner, "listblockedips")).result).toEqual([{ ip: "2001:db8::1", reason: "" }]);90- expect(await WS.tryConnect(host, "2001:db8::1")).toBeNull();91- for (const bad of ["10.0.0.0/8", "relay.example", "1.2.3", "::::", "999.1.1.1"]) expect((await rpc(host, owner, "blockip", bad, "")).status).toBe(400);92- });93-});94-95-describe("per-address rate limit", () => {96- it("caps all sockets from one address at four times a connection's allowance", async () => {97- const host = "swarm.bind.ws";98- const owner = generateSecretKey();99- await rpc(host, owner, "claim");100- await rpc(host, owner, "setpolicy", { eventsPerMinute: 5 });101- const ip = "198.51.100.7";102- const socks: WS[] = [];103- for (let i = 0; i < 6; i++) socks.push((await WS.tryConnect(host, ip))!);104- // Four per socket stays under the per-connection five; the address105- // allowance is twenty, so the twenty-first event across them is refused.106- let accepted = 0;107- let refused = "";108- for (const c of socks) {109- for (let j = 0; j < 4 && !refused; j++) {110- const r = await c.ok(ev(owner, 1, `n${accepted}`));111- if (r.ok) accepted++;112- else refused = r.msg;113- }114- }115- expect(accepted).toBe(20);116- expect(refused).toMatch(/^rate-limited:/);117- // A lone socket from elsewhere is only bound by its own bucket.118- const alone = (await WS.tryConnect(host, "198.51.100.8"))!;119- for (let j = 0; j < 4; j++) expect((await alone.ok(ev(owner, 1, `alone${j}`))).ok).toBe(true);120- });121-122- it("is the bridge's rate limit too", async () => {123- const host = "bridgelimit.bind.ws";124- const owner = generateSecretKey();125- await rpc(host, owner, "claim");126- await rpc(host, owner, "setpolicy", { reqsPerMinute: 1 });127- const ip = "198.51.100.9";128- for (let i = 0; i < 4; i++) expect((await post(host, owner, "/count", [{ kinds: [1] }], { "cf-connecting-ip": ip })).status).toBe(200);129- const fifth = await post(host, owner, "/count", [{ kinds: [1] }], { "cf-connecting-ip": ip });130- expect(fifth.status).toBe(429);131- expect(fifth.body.error).toMatch(/^rate-limited:/);132- expect((await post(host, owner, "/count", [{ kinds: [1] }], { "cf-connecting-ip": "198.51.100.10" })).status).toBe(200);133- });134-});@@ -1,13 +1,25 @@
1-// Media beyond the Blossom basics: NIP-94 tags in descriptors (BUD-08), blob2-// reports in the moderation queue (BUD-09), and the NIP-96 door onto the3-// same bucket and table.4-import { SELF } from "cloudflare:test";1+// Files: Blossom upload, fetch, list and delete, BUD-06 upload requirements,2+// BUD-04 mirror, NIP-94 tags in descriptors (BUD-08), blob reports (BUD-09),3+// and the NIP-96 door onto the same bucket and table.4+import { SELF, env, runInDurableObject } from "cloudflare:test";55 import { describe, it, expect } from "vitest";66 import { generateSecretKey, getPublicKey } from "nostr-tools/pure";77 import { sha256 } from "@noble/hashes/sha2.js";8+import type { Relay } from "../../src/relay.ts";89 import { bytesToHex } from "../../src/negentropy.ts";9-import { ev, rpc } from "../helpers/relay.ts";10-import { upload } from "../helpers/media.ts";10+import { now, ev, rpc } from "../helpers/relay.ts";11+import { blossomToken, upload } from "../helpers/media.ts";12+13+async function head(host: string, headers: Record<string, string>) {14+ const resp = await SELF.fetch(`http://${host}/upload`, { method: "HEAD", headers });15+ return { status: resp.status, reason: resp.headers.get("x-reason") ?? "", text: await resp.text() };16+}17+18+async function mirror(host: string, sk: Uint8Array, url: unknown, sha?: string, raw?: string) {19+ const resp = await SELF.fetch(`http://${host}/mirror`, { method: "PUT", headers: { authorization: blossomToken(sk, "upload", sha), "content-type": "application/json" }, body: raw ?? JSON.stringify({ url }) });20+ const text = await resp.text();21+ return { status: resp.status, body: text ? JSON.parse(text) : null, reason: resp.headers.get("x-reason") ?? "" };22+}1123 1224 // nip98 signs a token by hand so the payload tag can be the file hash, as1325 // NIP-96 wants, rather than the hash of a JSON body.@@ -31,6 +43,163 @@ async function nip96Upload(host: string, sk: Uint8Array, bytes: Uint8Array, opts
3143 return { status: resp.status, body: await resp.json<any>(), reason: resp.headers.get("x-reason") ?? "" };3244 }3345 46+describe("Blossom media", () => {47+ const blossomToken = (sk: Uint8Array, action: string, sha: string) =>48+ "Nostr " + btoa(JSON.stringify(ev(sk, 24242, action, [["t", action], ["x", sha], ["expiration", String(now() + 300)]])));49+50+ it("stores, serves, lists and deletes blobs under the relay's write policy, counting them as storage", async () => {51+ const host = "media.bind.ws";52+ const owner = generateSecretKey();53+ const member = generateSecretKey();54+ const stranger = generateSecretKey();55+ await rpc(host, owner, "claim");56+ await rpc(host, owner, "setpolicy", { writes: "allowlist", maxBlobMB: 1 });57+ await rpc(host, owner, "allowpubkey", getPublicKey(member));58+ const body = new TextEncoder().encode("hello blossom " + host);59+ const sha = bytesToHex(sha256(body));60+61+ const put = (sk: Uint8Array, data: Uint8Array, shaTag = sha, type = "text/plain") =>62+ SELF.fetch(`http://${host}/upload`, { method: "PUT", headers: { authorization: blossomToken(sk, "upload", shaTag), "content-type": type }, body: data });63+ expect((await put(stranger, body)).status).toBe(403);64+ expect((await SELF.fetch(`http://${host}/upload`, { method: "PUT", body })).status).toBe(401);65+ expect((await put(member, body, "00".repeat(32))).status).toBe(400);66+ let resp = await put(member, body);67+ expect(resp.status).toBe(200);68+ const desc: any = await resp.json();69+ expect(desc).toMatchObject({ sha256: sha, size: body.length, type: "text/plain", url: `https://${host}/${sha}.txt` });70+ expect((await put(member, new Uint8Array(1024 * 1024 + 1), "ff".repeat(32))).status).toBe(413);71+72+ resp = await SELF.fetch(`http://${host}/${sha}.txt`);73+ expect(resp.status).toBe(200);74+ expect(resp.headers.get("content-type")).toBe("text/plain");75+ expect(await resp.text()).toBe("hello blossom " + host);76+ resp = await SELF.fetch(`http://${host}/${sha}`, { method: "HEAD" });77+ expect(resp.headers.get("content-length")).toBe(String(body.length));78+ resp = await SELF.fetch(`http://${host}/${sha}`, { headers: { range: "bytes=0-4" } });79+ expect(resp.status).toBe(206);80+ expect(await resp.text()).toBe("hello");81+ expect((await SELF.fetch(`http://${host}/${"ab".repeat(32)}`)).status).toBe(404);82+83+ const list: any = await (await SELF.fetch(`http://${host}/list/${getPublicKey(member)}`)).json();84+ expect(list.map((b: any) => b.sha256)).toEqual([sha]);85+ const stub = env.RELAY.getByName("media");86+ await runInDurableObject(stub, async (r: Relay) => expect(r.mediaBytes()).toBeGreaterThan(0));87+88+ const del = (sk: Uint8Array) => SELF.fetch(`http://${host}/${sha}`, { method: "DELETE", headers: { authorization: blossomToken(sk, "delete", sha) } });89+ expect((await del(stranger)).status).toBe(403);90+ expect((await del(member)).status).toBe(204);91+ expect((await SELF.fetch(`http://${host}/${sha}`)).status).toBe(404);92+ expect((await rpc(host, owner, "listblobs")).result).toEqual([]);93+ });94+});95+96+describe("BUD-06 upload requirements", () => {97+ it("answers HEAD /upload from the headers and the policy, with no body", async () => {98+ const host = "buds-head.bind.ws";99+ const owner = generateSecretKey();100+ const stranger = generateSecretKey();101+ await rpc(host, owner, "claim");102+ const bytes = new TextEncoder().encode("a small file");103+ const sha = bytesToHex(sha256(bytes));104+ const ok = { "x-sha-256": sha, "x-content-type": "text/plain", "x-content-length": String(bytes.length), authorization: blossomToken(owner, "upload", sha) };105+106+ let r = await head(host, ok);107+ expect([r.status, r.reason, r.text]).toEqual([200, "", ""]);108+ r = await head(host, { ...ok, "x-sha-256": "nope" });109+ expect([r.status, r.reason.split(":")[0]]).toEqual([400, "invalid"]);110+ const { "x-content-length": _, ...noLength } = ok;111+ r = await head(host, noLength);112+ expect(r.status).toBe(411);113+ r = await head(host, { ...ok, "x-content-length": "twelve" });114+ expect(r.status).toBe(400);115+ const { authorization: __, ...noAuth } = ok;116+ r = await head(host, noAuth);117+ expect(r.status).toBe(401);118+ expect(r.reason).toMatch(/^auth-required/);119+ r = await head(host, { ...ok, "x-content-length": String(26 * 1024 * 1024) });120+ expect(r.status).toBe(413);121+ expect(r.reason).toMatch(/25 MB/);122+ r = await head(host, { ...ok, authorization: blossomToken(owner, "upload", "ab".repeat(32)) });123+ expect(r.status).toBe(400);124+125+ await rpc(host, owner, "setpolicy", { writes: "owner" });126+ r = await head(host, { ...ok, authorization: blossomToken(stranger, "upload", sha) });127+ expect(r.status).toBe(403);128+ expect(r.reason).toMatch(/^restricted/);129+130+ // Already stored is still a 200: the PUT would answer with the descriptor.131+ expect((await upload(host, owner, "a small file")).status).toBe(200);132+ expect((await head(host, ok)).status).toBe(200);133+ });134+});135+136+describe("BUD-04 mirror", () => {137+ it("copies a blob from another relay on this host, once", async () => {138+ const a = "buds-src.bind.ws";139+ const b = "buds-dst.bind.ws";140+ const alice = generateSecretKey();141+ const bob = generateSecretKey();142+ await rpc(a, alice, "claim");143+ await rpc(b, bob, "claim");144+ const up = await upload(a, alice, "the same bytes, elsewhere");145+ expect(up.status).toBe(200);146+ expect(up.body.url).toBe(`https://${a}/${up.sha}.txt`);147+148+ let r = await mirror(b, bob, up.body.url, up.sha);149+ expect(r.status, r.reason).toBe(201);150+ expect(r.body).toEqual({ url: `https://${b}/${up.sha}.txt`, sha256: up.sha, size: up.body.size, type: "text/plain", uploaded: expect.any(Number), nip94: expect.any(Array) });151+ const got = await SELF.fetch(`http://${b}/${up.sha}`);152+ expect(got.status).toBe(200);153+ expect(await got.text()).toBe("the same bytes, elsewhere");154+ const list = (await rpc(b, bob, "listblobs")).result;155+ expect(list.map((x: any) => [x.sha256, x.uploader])).toEqual([[up.sha, getPublicKey(bob)]]);156+157+ r = await mirror(b, bob, up.body.url, up.sha);158+ expect(r.status).toBe(200);159+ expect(r.body.sha256).toBe(up.sha);160+ expect((await rpc(b, bob, "listblobs")).result.length).toBe(1);161+ });162+163+ it("refuses what it should: wrong hash, bad body, bad url, no auth, policy", async () => {164+ const a = "buds-src2.bind.ws";165+ const b = "buds-dst2.bind.ws";166+ const alice = generateSecretKey();167+ const bob = generateSecretKey();168+ const stranger = generateSecretKey();169+ await rpc(a, alice, "claim");170+ await rpc(b, bob, "claim");171+ const up = await upload(a, alice, "bytes to mirror");172+173+ let r = await mirror(b, bob, up.body.url, "ab".repeat(32));174+ expect([r.status, r.reason.split(":")[0]]).toEqual([409, "invalid"]);175+ expect((await rpc(b, bob, "listblobs")).result).toEqual([]);176+ r = await mirror(b, bob, undefined, undefined, "not json");177+ expect(r.status).toBe(400);178+ r = await mirror(b, bob, "");179+ expect(r.status).toBe(400);180+ r = await mirror(b, bob, "ftp:/nope");181+ expect(r.status).toBe(400);182+ r = await mirror(b, bob, "http://example.com/" + up.sha);183+ expect([r.status, r.reason]).toEqual([400, "invalid: only https urls can be mirrored"]);184+ r = await mirror(b, bob, `https://${a}/${"cd".repeat(32)}`);185+ expect([r.status, r.reason]).toEqual([502, "error: the origin answered 404"]);186+ const noAuth = await SELF.fetch(`http://${b}/mirror`, { method: "PUT", body: JSON.stringify({ url: up.body.url }) });187+ expect(noAuth.status).toBe(401);188+189+ await rpc(b, bob, "setpolicy", { writes: "owner" });190+ r = await mirror(b, stranger, up.body.url, up.sha);191+ expect(r.status).toBe(403);192+ await rpc(b, bob, "setpolicy", { writes: "open", maxBlobMB: 1 });193+ // The origin declares its size, so an oversized blob is refused before any bytes move.194+ await rpc(a, alice, "setpolicy", { maxBlobMB: 2 });195+ const big = await SELF.fetch(`http://${a}/upload`, { method: "PUT", headers: { authorization: blossomToken(alice, "upload"), "content-type": "application/octet-stream" }, body: new Uint8Array(1024 * 1024 + 1) });196+ expect(big.status).toBe(200);197+ r = await mirror(b, bob, (await big.json<any>()).url);198+ expect(r.status).toBe(413);199+ });200+201+});202+34203 describe("BUD-08 nip94 tags", () => {35204 it("descriptors from upload and list carry the tags the relay can vouch for", async () => {36205 const host = "nip94.bind.ws";@@ -1,79 +1,84 @@
1-// Dumps to R2, per-member keep-for and caps, and members inviting members.2-import { env, runInDurableObject } from "cloudflare:test";1+// Members: invites, members-only reads and eviction, per-member keep-for2+// and caps, members inviting members, and the migration of the members table.3+import { SELF, env, runInDurableObject } from "cloudflare:test";34 import { describe, it, expect } from "vitest";4-import { generateSecretKey } from "nostr-tools/pure";5+import { generateSecretKey, getPublicKey } from "nostr-tools/pure";56 import type { Relay } from "../../src/relay.ts";6-import { writeDump } from "../../src/dumps.ts";7-import { now, ev, pk, rpc, get, post } from "../helpers/relay.ts";7+import { now, ev, rpc, post, pk } from "../helpers/relay.ts";88 import { WS } from "../helpers/ws.ts";99 10-describe("dumps", () => {11- it("writes a JSONL of every event to R2, lists it, serves it to a signature, rotates and counts as media", async () => {12- const host = "dumpy.bind.ws";10+describe("invites", () => {11+ it("lets the owner mint links that non-members can claim, with expiry and use limits", async () => {12+ const host = "invite.bind.ws";1313 const owner = generateSecretKey();14- const writer = generateSecretKey();14+ const guest = generateSecretKey();1515 await rpc(host, owner, "claim");16+ await rpc(host, owner, "setpolicy", { writes: "allowlist", joinTerms: "Be kind." });17+ expect((await rpc(host, guest, "createinvite")).status).toBe(403);18+ const inv = (await rpc(host, owner, "createinvite", 3600, 1, "for guest")).result;19+ expect(inv.code).toMatch(/^[0-9a-f]{32}$/);20+ expect(inv.max_uses).toBe(1);21+22+ // The page renders the terms for a valid code and a problem for a bad one.23+ let page = await (await SELF.fetch(`http://${host}/invite/${inv.code}`)).text();24+ expect(page).toContain("Be kind.");25+ expect(page).toContain("Join with extension");26+ page = await (await SELF.fetch(`http://${host}/invite/nope`)).text();27+ expect(page).toContain("isn't valid");28+29+ // Before joining: writes refused. After: accepted, and the roster changed.1630 const c = await WS.connect(host);17- for (let i = 0; i < 3; i++) expect((await c.ok(ev(writer, 1, "note " + i))).ok).toBe(true);18- expect((await rpc(host, owner, "setpolicy", { dumps: "daily", dumpsKeep: 2 })).result.dumps).toBe("daily");19-20- const d = (await rpc(host, owner, "dumpnow")).result;21- expect(d.name).toMatch(/^\d{4}-\d{2}-\d{2}\.jsonl$/);22- const total = (await rpc(host, owner, "stats")).result.events;23- expect(d.events).toBe(total);24- expect(total).toBeGreaterThanOrEqual(3);25- const obj = await env.MEDIA.get(`dumpy/dumps/${d.name}`);26- expect(obj).not.toBeNull();27- const text = await obj!.text();28- const lines = text.split("\n").filter(Boolean);29- expect(lines.length).toBe(d.events);30- for (const l of lines) expect(JSON.parse(l).id).toMatch(/^[0-9a-f]{64}$/);31- expect(d.bytes).toBe(text.length);32-33- const list = (await rpc(host, owner, "listdumps")).result;34- expect(list.map((x: any) => x.name)).toEqual([d.name]);35- expect(list[0].url).toBe("/dumps/" + d.name);36-37- // Download needs a signature from someone with the storage action.38- const signed = await get(host, "/dumps/" + d.name, owner);39- expect(signed.status).toBe(200);40- expect(signed.headers.get("content-disposition")).toContain(d.name);41- expect(await signed.text()).toBe(text);42- expect((await get(host, "/dumps/" + d.name, null)).status).toBe(401);43- expect((await get(host, "/dumps/" + d.name, writer)).status).toBe(403);44- expect((await get(host, "/dumps/nope.jsonl", owner)).status).toBe(400);45- expect((await get(host, "/dumps/1999-01-01.jsonl", owner)).status).toBe(404);46-47- const stub = env.RELAY.getByName("dumpy");48- await runInDurableObject(stub, async (r: Relay) => {49- expect(r.mediaBytes()).toBe(d.bytes);50- // Two older dumps and a keep of two: the oldest goes.51- await writeDump(r, now() - 2 * 86400);52- await writeDump(r, now() - 86400);53- });54- const after = (await rpc(host, owner, "listdumps")).result.map((x: any) => x.name);55- expect(after.length).toBe(2);56- expect(after[0]).toBe(d.name);57- expect((await env.MEDIA.list({ prefix: "dumpy/dumps/" })).objects.length).toBe(2);58- expect((await rpc(host, owner, "storagestats")).result.dumps).toBe(2);59-60- expect((await rpc(host, owner, "deletedump", after[1])).result).toBe(true);61- expect((await env.MEDIA.list({ prefix: "dumpy/dumps/" })).objects.length).toBe(1);62- expect((await rpc(host, writer, "listdumps")).status).toBe(403);31+ expect((await c.ok(ev(guest, 1, "hi"))).msg).toMatch(/^restricted:/);32+ let r = await post(host, guest, "/api/invites/claim", { code: inv.code });33+ expect(r.body).toEqual({ status: "joined", role: "member" });34+ expect((await c.ok(ev(guest, 1, "hi again"))).ok).toBe(true);35+ r = await post(host, guest, "/api/invites/claim", { code: inv.code });36+ expect(r.body.status).toBe("already_member");37+ r = await post(host, generateSecretKey(), "/api/invites/claim", { code: inv.code });38+ expect(r.status).toBe(403);39+ expect(r.body.error).toBe("invite_exhausted");40+ expect((await post(host, generateSecretKey(), "/api/invites/claim", { code: "bogus" })).body.error).toBe("invite_invalid");41+ const list = (await rpc(host, owner, "listinvites")).result;42+ expect(list[0].uses).toBe(1);43+ expect((await rpc(host, owner, "revokeinvite", inv.code)).result).toBe(true);44+ expect((await rpc(host, owner, "listinvites")).result).toEqual([]);45+ const members = (await rpc(host, owner, "listmembers")).result.members;46+ expect(members.find((m: any) => m.pubkey === getPublicKey(guest)).via).toMatch(/^invite /);6347 });48+});6449 65- it("the alarm writes a scheduled dump once a day, not twice", async () => {66- const host = "dumpz.bind.ws";50+describe("members-only reads and eviction", () => {51+ it("serves only members when reads=members, and closes the door on bans and removals", async () => {52+ const host = "closed.bind.ws";6753 const owner = generateSecretKey();54+ const member = generateSecretKey();55+ const stranger = generateSecretKey();6856 await rpc(host, owner, "claim");69- const stub = env.RELAY.getByName("dumpz");70- await runInDurableObject(stub, async (r: Relay) => r.alarm());71- expect((await rpc(host, owner, "listdumps")).result.length).toBe(0);72- await rpc(host, owner, "setpolicy", { dumps: "daily" });73- await runInDurableObject(stub, async (r: Relay) => r.alarm());74- expect((await rpc(host, owner, "listdumps")).result.length).toBe(1);75- await runInDurableObject(stub, async (r: Relay) => r.alarm());76- expect((await rpc(host, owner, "listdumps")).result.length).toBe(1);57+ await rpc(host, owner, "allowpubkey", getPublicKey(member));58+ await rpc(host, owner, "setpolicy", { reads: "members" });59+ const s = await WS.connect(host);60+ expect((await s.query({ kinds: [1] })).closed).toMatch(/^auth-required:/);61+ await s.auth(stranger, host);62+ expect((await s.query({ kinds: [1] })).closed).toMatch(/^restricted: .*members/);63+ const m = await WS.connect(host);64+ await m.auth(member, host);65+ expect((await m.query({ kinds: [1] })).closed).toBe("");66+ m.send("REQ", "live", { kinds: [1] });67+ await m.expect("EOSE");68+69+ // Removal ends every subscription with a reason; the socket stays.70+ await rpc(host, owner, "unrulepubkey", getPublicKey(member));71+ const closed = await m.expect("CLOSED");72+ expect(closed[1]).toBe("live");73+ expect(closed[2]).toMatch(/^restricted:/);74+ expect(m.closed).toBeNull();75+76+ // A ban closes the socket outright.77+ const b = await WS.connect(host);78+ await b.auth(stranger, host);79+ await rpc(host, owner, "banpubkey", getPublicKey(stranger), "bye");80+ for (let i = 0; i < 20 && !b.closed; i++) await new Promise((r) => setTimeout(r, 50));81+ expect(b.closed?.code).toBe(4403);7782 });7883 });7984 @@ -197,3 +202,25 @@ describe("members invite members", () => {
197202 expect((await rpc(host, owner, "removesubtree", pk(owner))).status).toBe(400);198203 });199204 });205+206+describe("members migration", () => {207+ it("folds the earlier allow list and names table into members on load", async () => {208+ const stub = env.RELAY.getByName("legacy");209+ const owner = generateSecretKey();210+ const a = getPublicKey(generateSecretKey());211+ const b = getPublicKey(generateSecretKey());212+ await runInDurableObject(stub, async (r: Relay) => {213+ r.settings.update({ owner: getPublicKey(owner) });214+ r.sql.exec(`INSERT INTO pubkey_rules(pubkey,rule,reason,at) VALUES(?,'allow','old friend',1000)`, a);215+ r.sql.exec(`INSERT INTO nip05(name,pubkey,at) VALUES('bob',?,2000)`, b);216+ r.sql.exec(`INSERT INTO nip05(name,pubkey,at) VALUES('alice',?,3000)`, a);217+ r.settings.load();218+ const m = r.settings.members();219+ expect(m.map((x) => [x.role, x.name, x.via])).toEqual([["owner", null, "claimed"], ["member", "alice", "added"], ["member", "bob", "profile"]]);220+ expect(r.settings.isAllowed(a) && r.settings.isAllowed(b)).toBe(true);221+ expect(r.sql.exec(`SELECT count(*) AS n FROM pubkey_rules`).one().n).toBe(0);222+ r.settings.load(); // idempotent223+ expect(r.settings.members().length).toBe(3);224+ });225+ });226+});@@ -0,0 +1,225 @@
1+// Moderation: reports that hide an event until a moderator looks, the queue2+// over NIP-86, a ban that erases, and the log every change leaves.3+import { SELF } from "cloudflare:test";4+import { describe, it, expect } from "vitest";5+import { generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";6+import { now, ev, rpc, pk } from "../helpers/relay.ts";7+import { WS } from "../helpers/ws.ts";8+import { upload } from "../helpers/media.ts";9+10+async function publish(host: string, e: Event): Promise<{ ok: boolean; msg: string }> {11+ const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });12+ const ws = resp.webSocket!;13+ ws.accept();14+ return new Promise((res) => {15+ ws.addEventListener("message", (m) => {16+ const a = JSON.parse(m.data as string);17+ if (a[0] === "OK") {18+ ws.close();19+ res({ ok: a[2], msg: a[3] });20+ }21+ });22+ ws.send(JSON.stringify(["EVENT", e]));23+ });24+}25+26+type Row = { seq: number; at: number; actor: string; action: string; target: string; detail: string };27+28+describe("moderation queue", () => {29+ it("files NIP-56 reports for the owner and resolves them with ban, delete or dismiss", async () => {30+ const host = "mods.bind.ws";31+ const owner = generateSecretKey();32+ const troll = generateSecretKey();33+ const reporter = generateSecretKey();34+ await rpc(host, owner, "claim");35+ const c = await WS.connect(host);36+ const bad = ev(troll, 1, "something awful");37+ await c.ok(bad);38+ const report = ev(reporter, 1984, "please look", [["e", bad.id, "spam"], ["p", getPublicKey(troll)]]);39+ expect((await c.ok(report)).msg).toBe("info: report received");40+ expect(await c.req({ kinds: [1984] })).toEqual([]); // never served41+ let q = (await rpc(host, owner, "listreports")).result;42+ expect(q.length).toBe(1);43+ expect(q[0]).toMatchObject({ id: report.id, target_event: bad.id, target_pubkey: getPublicKey(troll), type: "spam", status: "open" });44+ expect((await rpc(host, owner, "resolvereport", report.id, "ban")).result).toBe(true);45+ expect(await c.req({ ids: [bad.id] })).toEqual([]);46+ expect((await c.ok(ev(troll, 1, "back"))).msg).toMatch(/^blocked:/);47+ expect((await rpc(host, owner, "listreports")).result).toEqual([]);48+ expect((await rpc(host, owner, "listreports", "resolved")).result[0].action).toBe("ban");49+ });50+});51+52+describe("report thresholds", () => {53+ it("hides an event once enough people report it, until a moderator resolves the reports", async () => {54+ const host = "threshold.bind.ws";55+ const owner = generateSecretKey();56+ const author = generateSecretKey();57+ const r1 = generateSecretKey();58+ const r2 = generateSecretKey();59+ await rpc(host, owner, "claim");60+ expect((await rpc(host, owner, "setpolicy", { reportThreshold: 2 })).result.reportThreshold).toBe(2);61+ const c = await WS.connect(host);62+ const note = ev(author, 1, "contested");63+ expect((await c.ok(note)).ok).toBe(true);64+ const report = (sk: Uint8Array) => ev(sk, 1984, "nope", [["e", note.id, "spam"], ["p", pk(author)]]);65+ expect((await c.ok(report(r1))).ok).toBe(true);66+ expect((await c.ok(report(r1))).ok).toBe(true); // the same reporter twice counts once67+ expect((await c.req({ ids: [note.id] })).length).toBe(1);68+ expect((await c.ok(report(r2))).ok).toBe(true);69+ // Hidden everywhere stored events are read.70+ expect(await c.req({ ids: [note.id] })).toEqual([]);71+ expect((await c.count({ authors: [pk(author)] })).count).toBe(0);72+ expect((await SELF.fetch(`http://${host}/e/${note.id}`)).status).toBe(404);73+ const feed = await (await SELF.fetch(`http://${host}/feed.xml`)).text();74+ expect(feed.includes(note.id)).toBe(false);75+ const reports = (await rpc(host, owner, "listreports")).result;76+ expect(reports.length).toBe(2);77+ expect(reports.every((r: any) => r.hidden === 1)).toBe(true);78+ expect((await rpc(host, owner, "listeventsneedingmoderation")).result.map((r: any) => r.id)).toEqual([note.id]);79+ // Dismissing one report leaves the hold; dismissing the last lifts it.80+ await rpc(host, owner, "resolvereport", reports[0].id, "dismiss");81+ expect(await c.req({ ids: [note.id] })).toEqual([]);82+ await rpc(host, owner, "resolvereport", reports[1].id, "dismiss");83+ expect((await c.req({ ids: [note.id] })).length).toBe(1);84+ // Reports on a pubkey alone never hide anything.85+ expect((await c.ok(ev(r1, 1984, "person", [["p", pk(author), "impersonation"]]))).ok).toBe(true);86+ expect((await c.ok(ev(r2, 1984, "person", [["p", pk(author), "impersonation"]]))).ok).toBe(true);87+ expect((await c.req({ ids: [note.id] })).length).toBe(1);88+ // Delete makes it permanent.89+ const again = ev(author, 1, "contested again");90+ expect((await c.ok(again)).ok).toBe(true);91+ for (const sk of [r1, r2]) expect((await c.ok(ev(sk, 1984, "nope", [["e", again.id, "spam"], ["p", pk(author)]]))).ok).toBe(true);92+ expect(await c.req({ ids: [again.id] })).toEqual([]);93+ const open = (await rpc(host, owner, "listreports")).result.filter((r: any) => r.target_event === again.id);94+ await rpc(host, owner, "resolvereport", open[0].id, "delete");95+ expect((await c.ok(again)).msg).toMatch(/banned/);96+ });97+});98+99+describe("listeventsneedingmoderation", () => {100+ it("lists open reports by reported thing, deduplicated, for owner and moderator only", async () => {101+ const host = "modqueue.bind.ws";102+ const owner = generateSecretKey();103+ const mod = generateSecretKey();104+ const member = generateSecretKey();105+ const author = generateSecretKey();106+ const reporter = generateSecretKey();107+ await rpc(host, owner, "claim");108+ await rpc(host, owner, "setmember", pk(mod), { role: "moderator" });109+ await rpc(host, owner, "setmember", pk(member), {});110+ const c = (await WS.tryConnect(host, "10.9.0.1"))!;111+ const note = ev(author, 1, "buy my coin");112+ expect((await c.ok(note)).ok).toBe(true);113+ // Two reports on the note, one on the author alone.114+ expect((await c.ok(ev(reporter, 1984, "junk", [["e", note.id, "spam"], ["p", pk(author)]]))).ok).toBe(true);115+ expect((await c.ok(ev(member, 1984, "", [["e", note.id, "spam"], ["p", pk(author)]]))).ok).toBe(true);116+ expect((await c.ok(ev(reporter, 1984, "bot", [["p", pk(author), "impersonation"]]))).ok).toBe(true);117+118+ const list = await rpc(host, owner, "listeventsneedingmoderation");119+ expect(list.status).toBe(200);120+ expect(list.result).toEqual([{ id: note.id, reason: "spam: junk" }]);121+ expect((await rpc(host, mod, "listeventsneedingmoderation")).result).toEqual([{ id: note.id, reason: "spam: junk" }]);122+ expect((await rpc(host, member, "listeventsneedingmoderation")).status).toBe(403);123+ expect((await rpc(host, owner, "supportedmethods")).result).toEqual(expect.arrayContaining(["listeventsneedingmoderation", "blockip", "unblockip", "listblockedips"]));124+125+ // Resolving the reports empties the list.126+ const reports = (await rpc(host, owner, "listreports")).result as { id: string }[];127+ for (const r of reports) await rpc(host, owner, "resolvereport", r.id, "dismiss");128+ expect((await rpc(host, owner, "listeventsneedingmoderation")).result).toEqual([]);129+ });130+});131+132+describe("ban and erase", () => {133+ it("removes everything a banned pubkey wrote and uploaded when asked", async () => {134+ const host = "erase.bind.ws";135+ const owner = generateSecretKey();136+ const member = generateSecretKey();137+ const other = generateSecretKey();138+ await rpc(host, owner, "claim");139+ await rpc(host, owner, "setmember", pk(member), {});140+ await rpc(host, owner, "setmember", pk(other), {});141+ const c = await WS.connect(host);142+ 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);143+ const { sha } = await upload(host, member, "a file to erase");144+ expect((await SELF.fetch(`http://${host}/${sha}`)).status).toBe(200);145+ // A plain ban keeps the history.146+ expect((await rpc(host, owner, "banpubkey", pk(other), "plain")).result).toBe(true);147+ expect((await c.req({ authors: [pk(other)] })).length).toBe(1);148+ // A ban with erase does not.149+ expect((await rpc(host, owner, "banpubkey", pk(member), "gone", true)).result).toBe(true);150+ expect(await c.req({ authors: [pk(member)] })).toEqual([]);151+ expect((await SELF.fetch(`http://${host}/${sha}`)).status).toBe(404);152+ expect((await rpc(host, owner, "listblobs")).result).toEqual([]);153+ expect((await rpc(host, owner, "listmembers")).result.members.some((m: any) => m.pubkey === pk(member))).toBe(false);154+ expect((await c.ok(ev(member, 1, "back?"))).msg).toMatch(/banned/);155+ // The same through a report.156+ const reporter = generateSecretKey();157+ await rpc(host, owner, "setpolicy", { writes: "open" });158+ const bad = ev(generateSecretKey(), 1, "report me");159+ expect((await c.ok(bad)).ok).toBe(true);160+ expect((await c.ok(ev(reporter, 1984, "spam", [["e", bad.id, "spam"], ["p", bad.pubkey]]))).ok).toBe(true);161+ const report = (await rpc(host, owner, "listreports")).result[0];162+ expect((await rpc(host, owner, "resolvereport", report.id, "ban", true)).result).toBe(true);163+ expect(await c.req({ authors: [bad.pubkey] })).toEqual([]);164+ });165+});166+167+describe("moderation log", () => {168+ it("records what changed, who did it and to whom, and reads back newest first", async () => {169+ const host = "auditlog.bind.ws";170+ const owner = generateSecretKey();171+ const mod = generateSecretKey();172+ const member = generateSecretKey();173+ const spammer = generateSecretKey();174+ await rpc(host, owner, "claim");175+ await rpc(host, owner, "setmember", pk(mod), { role: "moderator" });176+ await rpc(host, owner, "setmember", pk(member), {});177+ expect((await rpc(host, owner, "getpolicy")).status).toBe(200);178+ expect((await rpc(host, mod, "banpubkey", pk(spammer), "spam")).status).toBe(200);179+ expect((await rpc(host, owner, "blockip", "203.0.113.9", "swarm")).status).toBe(200);180+ expect((await rpc(host, owner, "setblockedwords", ["casino"])).status).toBe(200);181+ expect((await rpc(host, owner, "setpolicy", { reads: "auth", maxSubs: 5 })).status).toBe(200);182+ // A refused call leaves nothing.183+ expect((await rpc(host, mod, "setpolicy", { reads: "open" })).status).toBe(403);184+ expect((await rpc(host, owner, "banpubkey", "zz", "")).status).toBe(400);185+186+ const rows = (await rpc(host, owner, "listaudit")).result as Row[];187+ const actions = rows.map((r) => [r.actor.slice(0, 8), r.action, r.target]);188+ expect(actions.slice(0, 4)).toEqual([189+ [pk(owner).slice(0, 8), "setpolicy", ""],190+ [pk(owner).slice(0, 8), "setblockedwords", ""],191+ [pk(owner).slice(0, 8), "blockip", "203.0.113.9"],192+ [pk(mod).slice(0, 8), "banpubkey", pk(spammer)],193+ ]);194+ expect(rows[0].detail).toBe('["reads","maxSubs"]');195+ expect(rows[1].detail).toBe('["casino"]');196+ expect(rows[2].detail).toBe('"swarm"');197+ expect(rows.map((r) => r.action)).not.toContain("getpolicy");198+ expect(rows.map((r) => r.action)).toContain("setmember");199+ for (const r of rows) expect(r.at).toBeGreaterThan(now() - 60);200+ // seq descends; paging with `before` continues from a row.201+ expect(rows[0].seq).toBeGreaterThan(rows[1].seq);202+ const older = (await rpc(host, owner, "listaudit", rows[1].seq)).result as Row[];203+ expect(older[0].seq).toBe(rows[2].seq);204+ // Moderators read it, members do not, and reading is not itself logged.205+ expect((await rpc(host, mod, "listaudit")).status).toBe(200);206+ expect((await rpc(host, member, "listaudit")).status).toBe(403);207+ expect(((await rpc(host, owner, "listaudit")).result as Row[])[0].action).toBe("setpolicy");208+ });209+210+ it("records NIP-29 moderation events under their names", async () => {211+ const host = "auditgroup.bind.ws";212+ const owner = generateSecretKey();213+ const someone = generateSecretKey();214+ await rpc(host, owner, "claim");215+ const put = ev(owner, 9000, "", [["h", host.split(".")[0]], ["p", pk(someone), "member"]]);216+ expect((await publish(host, put)).ok).toBe(true);217+ const remove = ev(owner, 9001, "", [["h", host.split(".")[0]], ["p", pk(someone)]]);218+ expect((await publish(host, remove)).ok).toBe(true);219+ const rows = (await rpc(host, owner, "listaudit")).result as Row[];220+ expect(rows.slice(0, 2).map((r) => [r.action, r.target, r.actor])).toEqual([221+ ["remove-user", pk(someone), pk(owner)],222+ ["put-user", pk(someone), pk(owner)],223+ ]);224+ });225+});@@ -0,0 +1,42 @@
1+// NIP-05: names under the relay's domain for its members.2+import { SELF } from "cloudflare:test";3+import { describe, it, expect } from "vitest";4+import { generateSecretKey, getPublicKey } from "nostr-tools/pure";5+import { ev, rpc } from "../helpers/relay.ts";6+import { WS } from "../helpers/ws.ts";7+8+describe("NIP-05", () => {9+ it("serves members' names for the relay's own host, claimed via kind 0 or assigned by the owner", async () => {10+ const host = "names.bind.ws";11+ const owner = generateSecretKey();12+ const alice = generateSecretKey();13+ const stranger = generateSecretKey();14+ await rpc(host, owner, "claim");15+ await rpc(host, owner, "allowpubkey", getPublicKey(alice));16+ const c = await WS.connect(host);17+ await c.ok(ev(alice, 0, JSON.stringify({ name: "alice", nip05: "Alice@names.bind.ws" })));18+ await c.ok(ev(stranger, 0, JSON.stringify({ name: "mallory", nip05: "alice@names.bind.ws" }))); // not a member: ignored19+ await c.ok(ev(owner, 0, JSON.stringify({ nip05: "owner@elsewhere.example" }))); // other domain: ignored20+ let doc: any = await (await SELF.fetch(`http://${host}/.well-known/nostr.json?name=alice`)).json();21+ expect(doc.names).toEqual({ alice: getPublicKey(alice) });22+ expect(doc.relays[getPublicKey(alice)]).toEqual(["wss://" + host]);23+ doc = await (await SELF.fetch(`http://${host}/.well-known/nostr.json?name=nobody`)).json();24+ expect(doc.names).toEqual({});25+ // Owner assigns and removes names; a taken name can't be claimed by another member.26+ expect((await rpc(host, owner, "setmember", getPublicKey(owner), { name: "_" })).result.name).toBe("_");27+ expect((await rpc(host, owner, "setmember", getPublicKey(owner), { name: "bad name!" })).status).toBe(400);28+ doc = await (await SELF.fetch(`http://${host}/.well-known/nostr.json`)).json();29+ expect(Object.keys(doc.names).sort()).toEqual(["_", "alice"]);30+ // Assigning a taken name to someone else moves it; clearing frees it.31+ expect((await rpc(host, owner, "setmember", getPublicKey(stranger), { name: "alice" })).result.name).toBe("alice");32+ expect((await rpc(host, owner, "listmembers")).result.members.find((m: any) => m.pubkey === getPublicKey(alice)).name).toBeNull();33+ expect((await rpc(host, owner, "setmember", getPublicKey(stranger), { name: "" })).result.name).toBeNull();34+ // The public directory lists people with their names; the owner can hide it.35+ let people: any = await (await SELF.fetch(`http://${host}/people`)).json();36+ expect(people.people.map((m: any) => m.role)).toEqual(["owner", "member", "member"]);37+ expect(people.people[0].name).toBe("_");38+ await rpc(host, owner, "setpolicy", { directoryPublic: false });39+ people = await (await SELF.fetch(`http://${host}/people`)).json();40+ expect(people.people).toEqual([]);41+ });42+});@@ -1,43 +1,207 @@
1-// Rules an evaluator asked for: the socket message cap per relay, blocked2-// words that reach into tags and take a regular expression, and address3-// blocks that travel with the exported configuration.1+// The rules an owner sets and the relay enforces at the socket: who may2+// write and read, proof of work, kinds, guests past a members-only rule, the3+// web of trust, blocked words, message size, address blocks, and rate limits4+// per connection and per address.5+import { SELF } from "cloudflare:test";46 import { describe, it, expect } from "vitest";5-import { generateSecretKey } from "nostr-tools/pure";6-import { ev, rpc, info } from "../helpers/relay.ts";7+import { generateSecretKey, getPublicKey } from "nostr-tools/pure";8+import { difficulty } from "../../src/event.ts";9+import { now, ev, rpc, pk, info, post, sleep } from "../helpers/relay.ts";710 import { WS } from "../helpers/ws.ts";11+import { upload } from "../helpers/media.ts";812 9-describe("message size", () => {10- it("is the owner's rule, clamped, and NIP-11 says so", async () => {11- const host = "msgsize.bind.ws";13+describe("writes, reads, pow, bans and kinds", () => {14+ it("enforces writes, reads, pow, bans, and kind rules set by the owner", async () => {15+ const host = "gamma.bind.ws";1216 const owner = generateSecretKey();17+ const member = generateSecretKey();18+ const stranger = generateSecretKey();1319 await rpc(host, owner, "claim");14- expect((await info(host)).limitation.max_message_length).toBe(128 * 1024);20+ const c = await WS.connect(host);1521 16- // Below the floor and above the ceiling both clamp.17- expect((await rpc(host, owner, "setpolicy", { maxMessageKB: 1 })).result.maxMessageKB).toBe(16);18- expect((await rpc(host, owner, "setpolicy", { maxMessageKB: 5000 })).result.maxMessageKB).toBe(1024);19- expect((await rpc(host, owner, "setpolicy", { maxMessageKB: 16 })).result.maxMessageKB).toBe(16);20- expect((await info(host)).limitation.max_message_length).toBe(16 * 1024);22+ // owner-only writes23+ expect((await rpc(host, owner, "setpolicy", { writes: "owner" })).result.writes).toBe("owner");24+ expect((await c.ok(ev(stranger, 1, "x"))).msg).toMatch(/^restricted:/);25+ expect((await c.ok(ev(owner, 1, "x"))).ok).toBe(true);2126 22- const ws = await WS.connect(host);23- // A note of 20 KB is over the 16 KB cap: refused with a NOTICE, not stored.24- const big = ev(owner, 1, "x".repeat(20 * 1024));25- ws.raw(JSON.stringify(["EVENT", big]));26- const notice = await ws.expect("NOTICE");27- expect(notice[1]).toBe("error: message too large");28- // A small one still lands.29- expect((await ws.ok(ev(owner, 1, "small"))).ok).toBe(true);27+ // allowlist28+ await rpc(host, owner, "setpolicy", { writes: "allowlist" });29+ expect((await c.ok(ev(member, 1, "y"))).msg).toMatch(/^restricted:/);30+ await rpc(host, owner, "allowpubkey", getPublicKey(member), "friend");31+ expect((await c.ok(ev(member, 1, "y"))).ok).toBe(true);32+ expect((await rpc(host, owner, "listallowedpubkeys")).result).toEqual([{ pubkey: getPublicKey(member), reason: "friend" }]);33+ expect((await rpc(host, owner, "listmembers")).result.members.map((m: any) => m.role)).toEqual(["owner", "member"]);34+ await rpc(host, owner, "setpolicy", { writes: "open" });3035 31- // Raising the cap lets the same note through.32- await rpc(host, owner, "setpolicy", { maxMessageKB: 64 });33- expect((await ws.ok(big)).ok).toBe(true);36+ // bans win over everything, and the owner cannot ban themselves37+ await rpc(host, owner, "banpubkey", getPublicKey(stranger), "spam");38+ expect((await c.ok(ev(stranger, 1, "z"))).msg).toMatch(/^blocked:/);39+ expect((await rpc(host, owner, "banpubkey", getPublicKey(owner))).status).toBe(400);40+ await rpc(host, owner, "unrulepubkey", getPublicKey(stranger));41+ expect((await c.ok(ev(stranger, 1, "z"))).ok).toBe(true);42+43+ // ban an event: deleted now and refused later44+ const e = ev(stranger, 1, "offensive");45+ expect((await c.ok(e)).ok).toBe(true);46+ await rpc(host, owner, "banevent", e.id, "nope");47+ // Left open on purpose: the read rule below has to close it.48+ expect((await c.open("q", { ids: [e.id] })).events).toEqual([]);49+ expect((await c.ok(e)).msg).toMatch(/^blocked:/);50+ expect((await rpc(host, owner, "listbannedevents")).result).toEqual([{ id: e.id, reason: "nope" }]);51+52+ // kind rules53+ await rpc(host, owner, "disallowkind", 7);54+ expect((await c.ok(ev(stranger, 7, "+"))).msg).toMatch(/kind 7/);55+ await rpc(host, owner, "allowkind", 1);56+ expect((await c.ok(ev(stranger, 30023, "a", [["d", "x"]]))).msg).toMatch(/kind 30023/);57+ expect((await c.ok(ev(stranger, 1, "still fine"))).ok).toBe(true);58+ expect((await rpc(host, owner, "listallowedkinds")).result).toEqual([1]);59+ await rpc(host, owner, "unrulekind", 1);60+ await rpc(host, owner, "unrulekind", 7);61+62+ // proof of work63+ await rpc(host, owner, "setpolicy", { minPow: 4 });64+ // One random id in sixteen starts with a zero nibble and would pass; pick one that does not.65+ let weak = ev(stranger, 1, "weak");66+ while (difficulty(weak).difficulty >= 4) weak = ev(stranger, 1, "weak " + Math.random());67+ expect((await c.ok(weak)).msg).toMatch(/^pow:/);68+ await rpc(host, owner, "setpolicy", { minPow: 0 });69+70+ // reads need auth: tightening the rule closes the subscription still open71+ await rpc(host, owner, "setpolicy", { reads: "auth" });72+ expect((await c.expect("CLOSED"))[2]).toMatch(/^auth-required:/);73+ expect((await c.query({ kinds: [1] })).closed).toMatch(/^auth-required:/);74+ await c.auth(stranger, host);75+ expect((await c.req({ kinds: [1] })).length).toBeGreaterThan(0);76+77+ // NIP-11 reflects it all78+ await rpc(host, owner, "changerelayname", "Gamma Club");79+ await rpc(host, owner, "changerelaydescription", "members only");80+ const info = await (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();81+ expect(info.name).toBe("Gamma Club");82+ expect(info.limitation.auth_required).toBe(true);83+ expect(info.limitation.restricted_writes).toBe(false);84+ });85+86+ it("only the owner may manage", async () => {87+ const host = "delta.bind.ws";88+ const owner = generateSecretKey();89+ await rpc(host, owner, "claim");90+ const r = await rpc(host, generateSecretKey(), "setpolicy", { writes: "owner" });91+ expect(r.status).toBe(403);92+ expect(r.error).toMatch(/^restricted:/);93+ expect((await rpc(host, generateSecretKey(), "supportedmethods")).result).toContain("claim");94+ });95+});96+97+describe("guests", () => {98+ it("lets a stranger through a members-only rule for open kinds and for replies to members", async () => {99+ const host = "guests.bind.ws";100+ const owner = generateSecretKey();101+ const member = generateSecretKey();102+ const stranger = generateSecretKey();103+ await rpc(host, owner, "claim");104+ await rpc(host, owner, "setmember", pk(member), {});105+ let r = await rpc(host, owner, "setpolicy", { writes: "allowlist", openKinds: [7, 7, 70000, -1], guestReplies: true });106+ expect(r.result.openKinds).toEqual([7]);107+ expect(r.result.guestReplies).toBe(true);108+109+ const c = await WS.connect(host);110+ const note = ev(member, 1, "a member's note");111+ expect((await c.ok(note))).toEqual({ ok: true, msg: "" });112+ const outsiderNote = ev(stranger, 1, "hello?");113+ expect((await c.ok(outsiderNote)).msg).toMatch(/only accepts events from its members/);114+ // An open kind passes the rule; a reply to a member passes; a reply to nothing here does not.115+ expect((await c.ok(ev(stranger, 7, "+", [["e", note.id]]))).ok).toBe(true);116+ expect((await c.ok(ev(stranger, 1, "nice", [["e", note.id, "", "reply"]]))).ok).toBe(true);117+ expect((await c.ok(ev(stranger, 1111, "a comment", [["E", note.id]]))).ok).toBe(true);118+ expect((await c.ok(ev(stranger, 1, "into the void", [["e", "ab".repeat(32)]]))).ok).toBe(false);119+ // A reply to another stranger's event is not a reply to a member.120+ const guestReply = (await c.req({ kinds: [1], authors: [pk(stranger)] }))[0];121+ expect((await c.ok(ev(generateSecretKey(), 1, "reply to a guest", [["e", guestReply.id]]))).ok).toBe(false);122+ // Bans still win, and so do kind rules.123+ await rpc(host, owner, "disallowkind", 7);124+ expect((await c.ok(ev(stranger, 7, "-", [["e", note.id]]))).msg).toMatch(/does not accept kind 7/);125+ await rpc(host, owner, "setpolicy", { guestReplies: false });126+ expect((await c.ok(ev(stranger, 1, "again", [["e", note.id]]))).ok).toBe(false);127+ // Only me plus open kinds: the owner's rule, guests' kinds.128+ await rpc(host, owner, "unrulekind", 7);129+ await rpc(host, owner, "setpolicy", { writes: "owner" });130+ expect((await c.ok(ev(member, 1, "member note"))).msg).toMatch(/only the relay owner/);131+ expect((await c.ok(ev(member, 7, "+", [["e", note.id]]))).ok).toBe(true);132+ });133+});134+135+describe("web of trust", () => {136+ it("admits the people members follow, and follows the lists as they change", async () => {137+ const host = "wot.bind.ws";138+ const owner = generateSecretKey();139+ const member = generateSecretKey();140+ const friend = generateSecretKey();141+ const friendOfMember = generateSecretKey();142+ const stranger = generateSecretKey();143+ await rpc(host, owner, "claim");144+ const c = await WS.connect(host);145+ expect((await c.ok(ev(owner, 3, "", [["p", pk(friend)]]))).ok).toBe(true);146+ const r = await rpc(host, owner, "setpolicy", { writes: "wot" });147+ expect(r.result.writes).toBe("wot");148+ expect((await c.ok(ev(friend, 1, "a friend of the owner"))).ok).toBe(true);149+ expect((await c.ok(ev(stranger, 1, "nobody follows me"))).msg).toMatch(/members and the people they follow/);150+ expect((await upload(host, stranger, "a stranger's file")).status).toBe(403);151+ expect((await upload(host, friend, "a friend's file")).status).toBe(200);152+153+ // A member's list arriving extends the web; the member leaving shrinks it.154+ await rpc(host, owner, "setmember", pk(member), {});155+ expect((await c.ok(ev(member, 3, "", [["p", pk(friendOfMember)]]))).ok).toBe(true);156+ expect((await c.ok(ev(friendOfMember, 1, "followed by a member"))).ok).toBe(true);157+ await rpc(host, owner, "removemember", pk(member));158+ expect((await c.ok(ev(friendOfMember, 1, "the member left"))).ok).toBe(false);159+ // The owner's newer list without the friend drops them.160+ expect((await c.ok(ev(owner, 3, "", [["p", pk(stranger)]], now() + 1))).ok).toBe(true);161+ expect((await c.ok(ev(friend, 1, "unfollowed"))).ok).toBe(false);162+ expect((await c.ok(ev(stranger, 1, "now followed"))).ok).toBe(true);163+ // The group flags read the rule as restricted and closed.164+ const info: any = await (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json();165+ expect(info.limitation.restricted_writes).toBe(true);166+ const meta = (await c.req({ kinds: [39000] }))[0];167+ expect(meta.tags.some((t: string[]) => t[0] === "restricted")).toBe(true);168+ expect(meta.tags.some((t: string[]) => t[0] === "closed")).toBe(true);34169 });35170 });36171 37172 describe("blocked words", () => {38- it("takes /patterns/, refuses a bad one with the reason, and reaches into tags on request", async () => {173+ it("refuses content with a blocked word from anyone but the owner and moderators", async () => {39174 const host = "words.bind.ws";40175 const owner = generateSecretKey();176+ const mod = generateSecretKey();177+ const member = generateSecretKey();178+ await rpc(host, owner, "claim");179+ await rpc(host, owner, "setmember", pk(mod), { role: "moderator" });180+ await rpc(host, owner, "setmember", pk(member), {});181+ let r = await rpc(host, owner, "setblockedwords", [" Casino ", "free money", "x", "a".repeat(65), "casino"]);182+ expect(r.result).toEqual(["casino", "free money"]);183+ expect((await rpc(host, owner, "getpolicy")).result.blockedWords).toEqual(["casino", "free money"]);184+ // A moderator may set the list too.185+ r = await rpc(host, mod, "setblockedwords", ["casino"]);186+ expect(r.status).toBe(200);187+ expect((await rpc(host, member, "setblockedwords", ["casino"])).status).toBe(403);188+189+ const c = await WS.connect(host);190+ expect((await c.ok(ev(member, 1, "Big CASINO night"))).msg).toBe("blocked: content contains a blocked word");191+ expect((await c.ok(ev(generateSecretKey(), 1, "casino"))).ok).toBe(false);192+ expect((await c.ok(ev(member, 1, "a quiet evening"))).ok).toBe(true);193+ expect((await c.ok(ev(owner, 1, "the casino is closed"))).ok).toBe(true);194+ expect((await c.ok(ev(mod, 1, "no casino talk please"))).ok).toBe(true);195+ // The list travels with the configuration.196+ const cfg = (await rpc(host, owner, "exportconfig")).result;197+ expect(cfg.policy.blockedWords).toEqual(["casino"]);198+ });199+});200+201+describe("blocked words in tags and as patterns", () => {202+ it("takes /patterns/, refuses a bad one with the reason, and reaches into tags on request", async () => {203+ const host = "patterns.bind.ws";204+ const owner = generateSecretKey();41205 const guest = generateSecretKey();42206 await rpc(host, owner, "claim");43207 @@ -76,37 +240,141 @@ describe("blocked words", () => {
76240 });77241 });78242 79-describe("address blocks in the configuration", () => {80- it("export carries them and import applies them, dropping the sockets they refuse", async () => {81- const a = "cfg-a.bind.ws";82- const b = "cfg-b.bind.ws";243+describe("message size", () => {244+ it("is the owner's rule, clamped, and NIP-11 says so", async () => {245+ const host = "msgsize.bind.ws";83246 const owner = generateSecretKey();84- const bad = "203.0.113.9";85- await rpc(a, owner, "claim");86- await rpc(b, owner, "claim");87- await rpc(a, owner, "blockip", bad, "scraper");88- await rpc(a, owner, "blockip", "2001:DB8::9", "");89- const cfg = (await rpc(a, owner, "exportconfig")).result;90- const byIP = (l: { ip: string; reason: string }[]) => [...l].sort((x, y) => x.ip.localeCompare(y.ip));91- expect(byIP(cfg.addresses)).toEqual([{ ip: "2001:db8::9", reason: "" }, { ip: bad, reason: "scraper" }]);92-93- // The second relay has a stale block of its own and an open socket from the address about to be refused.94- await rpc(b, owner, "blockip", "198.51.100.200", "old");95- const open = (await WS.tryConnect(b, bad))!;96- expect((await open.ok(ev(owner, 1, "before"))).ok).toBe(true);97- cfg.addresses.push({ ip: "not an address", reason: "x" }, { reason: "no ip" });98- const imported = await rpc(b, owner, "importconfig", cfg);99- expect(imported.status).toBe(200);100- expect(byIP(imported.result.addresses)).toEqual([{ ip: "2001:db8::9", reason: "" }, { ip: bad, reason: "scraper" }]);101- expect((await rpc(b, owner, "listblockedips")).result.map((x: { ip: string }) => x.ip).sort()).toEqual(["2001:db8::9", bad]);102- for (let i = 0; i < 40 && !open.closed; i++) await new Promise((r) => setTimeout(r, 25));247+ await rpc(host, owner, "claim");248+ expect((await info(host)).limitation.max_message_length).toBe(128 * 1024);249+250+ // Below the floor and above the ceiling both clamp.251+ expect((await rpc(host, owner, "setpolicy", { maxMessageKB: 1 })).result.maxMessageKB).toBe(16);252+ expect((await rpc(host, owner, "setpolicy", { maxMessageKB: 5000 })).result.maxMessageKB).toBe(1024);253+ expect((await rpc(host, owner, "setpolicy", { maxMessageKB: 16 })).result.maxMessageKB).toBe(16);254+ expect((await info(host)).limitation.max_message_length).toBe(16 * 1024);255+256+ const ws = await WS.connect(host);257+ // A note of 20 KB is over the 16 KB cap: refused with a NOTICE, not stored.258+ const big = ev(owner, 1, "x".repeat(20 * 1024));259+ ws.raw(JSON.stringify(["EVENT", big]));260+ const notice = await ws.expect("NOTICE");261+ expect(notice[1]).toBe("error: message too large");262+ // A small one still lands.263+ expect((await ws.ok(ev(owner, 1, "small"))).ok).toBe(true);264+265+ // Raising the cap lets the same note through.266+ await rpc(host, owner, "setpolicy", { maxMessageKB: 64 });267+ expect((await ws.ok(big)).ok).toBe(true);268+ });269+});270+271+describe("rate limits", () => {272+ it("throttles a chatty connection with a retry hint", async () => {273+ const host = "limits.bind.ws";274+ const owner = generateSecretKey();275+ await rpc(host, owner, "claim");276+ await rpc(host, owner, "setpolicy", { eventsPerMinute: 2, reqsPerMinute: 2 });277+ const c = await WS.connect(host);278+ expect((await c.ok(ev(owner, 1, "a"))).ok).toBe(true);279+ expect((await c.ok(ev(owner, 1, "b"))).ok).toBe(true);280+ const third = await c.ok(ev(owner, 1, "c"));281+ expect(third.ok).toBe(false);282+ expect(third.msg).toMatch(/^rate-limited: quota exceeded; retry in \d+s$/);283+ await c.open("r1", { kinds: [1] });284+ await c.open("r2", { kinds: [1] });285+ expect((await c.open("r3", { kinds: [1] })).closed).toMatch(/^rate-limited:/);286+ });287+});288+289+describe("per-address rate limit", () => {290+ it("caps all sockets from one address at four times a connection's allowance", async () => {291+ const host = "swarm.bind.ws";292+ const owner = generateSecretKey();293+ await rpc(host, owner, "claim");294+ await rpc(host, owner, "setpolicy", { eventsPerMinute: 5 });295+ const ip = "198.51.100.7";296+ const socks: WS[] = [];297+ for (let i = 0; i < 6; i++) socks.push((await WS.tryConnect(host, ip))!);298+ // Four per socket stays under the per-connection five; the address299+ // allowance is twenty, so the twenty-first event across them is refused.300+ let accepted = 0;301+ let refused = "";302+ for (const c of socks) {303+ for (let j = 0; j < 4 && !refused; j++) {304+ const r = await c.ok(ev(owner, 1, `n${accepted}`));305+ if (r.ok) accepted++;306+ else refused = r.msg;307+ }308+ }309+ expect(accepted).toBe(20);310+ expect(refused).toMatch(/^rate-limited:/);311+ // A lone socket from elsewhere is only bound by its own bucket.312+ const alone = (await WS.tryConnect(host, "198.51.100.8"))!;313+ for (let j = 0; j < 4; j++) expect((await alone.ok(ev(owner, 1, `alone${j}`))).ok).toBe(true);314+ });315+316+ it("is the bridge's rate limit too", async () => {317+ const host = "bridgelimit.bind.ws";318+ const owner = generateSecretKey();319+ await rpc(host, owner, "claim");320+ await rpc(host, owner, "setpolicy", { reqsPerMinute: 1 });321+ const ip = "198.51.100.9";322+ for (let i = 0; i < 4; i++) expect((await post(host, owner, "/count", [{ kinds: [1] }], { "cf-connecting-ip": ip })).status).toBe(200);323+ const fifth = await post(host, owner, "/count", [{ kinds: [1] }], { "cf-connecting-ip": ip });324+ expect(fifth.status).toBe(429);325+ expect(fifth.body.error).toMatch(/^rate-limited:/);326+ expect((await post(host, owner, "/count", [{ kinds: [1] }], { "cf-connecting-ip": "198.51.100.10" })).status).toBe(200);327+ });328+});329+330+describe("address blocks", () => {331+ it("refuses the socket and the doors, drops open sockets, and can be undone", async () => {332+ const host = "blocks.bind.ws";333+ const owner = generateSecretKey();334+ const mod = generateSecretKey();335+ const member = generateSecretKey();336+ const bad = "203.0.113.5";337+ await rpc(host, owner, "claim");338+ await rpc(host, owner, "setmember", pk(mod), { role: "moderator" });339+ await rpc(host, owner, "setmember", pk(member), {});340+341+ const open = (await WS.tryConnect(host, bad))!;342+ expect((await open.ok(ev(member, 1, "before"))).ok).toBe(true);343+ expect((await rpc(host, member, "blockip", bad, "scraper")).status).toBe(403);344+ expect((await rpc(host, mod, "blockip", "not an address", "x")).status).toBe(400);345+ expect((await rpc(host, mod, "blockip", bad, "scraper")).result).toBe(true);346+ expect((await rpc(host, owner, "listblockedips")).result).toEqual([{ ip: bad, reason: "scraper" }]);347+348+ for (let i = 0; i < 40 && !open.closed; i++) await sleep(25);103349 expect(open.closed?.code).toBe(4403);104- expect(await WS.tryConnect(b, bad)).toBeNull();105- expect(await WS.tryConnect(b, "198.51.100.200")).not.toBeNull();106- // A document without the list clears the blocks, like the other lists.107- delete cfg.addresses;108- await rpc(b, owner, "importconfig", cfg);109- expect((await rpc(b, owner, "listblockedips")).result).toEqual([]);110- expect(await WS.tryConnect(b, bad)).not.toBeNull();350+ expect(await WS.tryConnect(host, bad)).toBeNull();351+ const refused = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket", "cf-connecting-ip": bad } });352+ expect(refused.status).toBe(403);353+ const door = await post(host, member, "/events", ev(member, 1, "still here?"), { "cf-connecting-ip": bad });354+ expect(door.status).toBe(403);355+ expect(door.body.error).toMatch(/^blocked: this address/);356+ expect((await post(host, member, "/count", [{ kinds: [1] }], { "cf-connecting-ip": bad })).status).toBe(403);357+ // Another address is unaffected, and so is management from the blocked one.358+ expect((await post(host, member, "/events", ev(member, 1, "elsewhere"), { "cf-connecting-ip": "203.0.113.6" })).status).toBe(200);359+ expect(await WS.tryConnect(host, "203.0.113.6")).not.toBeNull();360+ expect((await rpc(host, owner, "stats")).status).toBe(200);361+ // Blocks are part of the portable configuration.362+ expect((await rpc(host, owner, "exportconfig")).result.addresses).toEqual([{ ip: bad, reason: "scraper" }]);363+364+ expect((await rpc(host, owner, "unblockip", bad)).result).toBe(true);365+ expect((await rpc(host, owner, "listblockedips")).result).toEqual([]);366+ const again = await WS.tryConnect(host, bad);367+ expect(again).not.toBeNull();368+ expect((await again!.ok(ev(member, 1, "back"))).ok).toBe(true);369+ });370+371+ it("accepts IPv6 and rejects ranges and names", async () => {372+ const host = "blocks6.bind.ws";373+ const owner = generateSecretKey();374+ await rpc(host, owner, "claim");375+ expect((await rpc(host, owner, "blockip", "2001:DB8::1", "")).result).toBe(true);376+ expect((await rpc(host, owner, "listblockedips")).result).toEqual([{ ip: "2001:db8::1", reason: "" }]);377+ expect(await WS.tryConnect(host, "2001:db8::1")).toBeNull();378+ for (const bad of ["10.0.0.0/8", "relay.example", "1.2.3", "::::", "999.1.1.1"]) expect((await rpc(host, owner, "blockip", bad, "")).status).toBe(400);111379 });112380 });@@ -1,9 +1,10 @@
1+// The relay itself: claiming a name over NIP-86 with NIP-98 tokens, the2+// expiry alarm, and the Worker's routing by hostname.13 import { SELF, env, runInDurableObject } from "cloudflare:test";24 import { describe, it, expect } from "vitest";35 import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure";46 import { getToken } from "nostr-tools/nip98";57 import type { Relay } from "../../src/relay.ts";6-import { difficulty } from "../../src/event.ts";78 import { now, ev, rpc } from "../helpers/relay.ts";89 import { WS } from "../helpers/ws.ts";910 @@ -54,90 +55,6 @@ describe("claiming", () => {
5455 });5556 });5657 57-describe("policy", () => {58- it("enforces writes, reads, pow, bans, and kind rules set by the owner", async () => {59- const host = "gamma.bind.ws";60- const owner = generateSecretKey();61- const member = generateSecretKey();62- const stranger = generateSecretKey();63- await rpc(host, owner, "claim");64- const c = await WS.connect(host);65-66- // owner-only writes67- expect((await rpc(host, owner, "setpolicy", { writes: "owner" })).result.writes).toBe("owner");68- expect((await c.ok(ev(stranger, 1, "x"))).msg).toMatch(/^restricted:/);69- expect((await c.ok(ev(owner, 1, "x"))).ok).toBe(true);70-71- // allowlist72- await rpc(host, owner, "setpolicy", { writes: "allowlist" });73- expect((await c.ok(ev(member, 1, "y"))).msg).toMatch(/^restricted:/);74- await rpc(host, owner, "allowpubkey", getPublicKey(member), "friend");75- expect((await c.ok(ev(member, 1, "y"))).ok).toBe(true);76- expect((await rpc(host, owner, "listallowedpubkeys")).result).toEqual([{ pubkey: getPublicKey(member), reason: "friend" }]);77- expect((await rpc(host, owner, "listmembers")).result.members.map((m: any) => m.role)).toEqual(["owner", "member"]);78- await rpc(host, owner, "setpolicy", { writes: "open" });79-80- // bans win over everything, and the owner cannot ban themselves81- await rpc(host, owner, "banpubkey", getPublicKey(stranger), "spam");82- expect((await c.ok(ev(stranger, 1, "z"))).msg).toMatch(/^blocked:/);83- expect((await rpc(host, owner, "banpubkey", getPublicKey(owner))).status).toBe(400);84- await rpc(host, owner, "unrulepubkey", getPublicKey(stranger));85- expect((await c.ok(ev(stranger, 1, "z"))).ok).toBe(true);86-87- // ban an event: deleted now and refused later88- const e = ev(stranger, 1, "offensive");89- expect((await c.ok(e)).ok).toBe(true);90- await rpc(host, owner, "banevent", e.id, "nope");91- // Left open on purpose: the read rule below has to close it.92- expect((await c.open("q", { ids: [e.id] })).events).toEqual([]);93- expect((await c.ok(e)).msg).toMatch(/^blocked:/);94- expect((await rpc(host, owner, "listbannedevents")).result).toEqual([{ id: e.id, reason: "nope" }]);95-96- // kind rules97- await rpc(host, owner, "disallowkind", 7);98- expect((await c.ok(ev(stranger, 7, "+"))).msg).toMatch(/kind 7/);99- await rpc(host, owner, "allowkind", 1);100- expect((await c.ok(ev(stranger, 30023, "a", [["d", "x"]]))).msg).toMatch(/kind 30023/);101- expect((await c.ok(ev(stranger, 1, "still fine"))).ok).toBe(true);102- expect((await rpc(host, owner, "listallowedkinds")).result).toEqual([1]);103- await rpc(host, owner, "unrulekind", 1);104- await rpc(host, owner, "unrulekind", 7);105-106- // proof of work107- await rpc(host, owner, "setpolicy", { minPow: 4 });108- // One random id in sixteen starts with a zero nibble and would pass; pick one that does not.109- let weak = ev(stranger, 1, "weak");110- while (difficulty(weak).difficulty >= 4) weak = ev(stranger, 1, "weak " + Math.random());111- expect((await c.ok(weak)).msg).toMatch(/^pow:/);112- await rpc(host, owner, "setpolicy", { minPow: 0 });113-114- // reads need auth: tightening the rule closes the subscription still open115- await rpc(host, owner, "setpolicy", { reads: "auth" });116- expect((await c.expect("CLOSED"))[2]).toMatch(/^auth-required:/);117- expect((await c.query({ kinds: [1] })).closed).toMatch(/^auth-required:/);118- await c.auth(stranger, host);119- expect((await c.req({ kinds: [1] })).length).toBeGreaterThan(0);120-121- // NIP-11 reflects it all122- await rpc(host, owner, "changerelayname", "Gamma Club");123- await rpc(host, owner, "changerelaydescription", "members only");124- const info = await (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();125- expect(info.name).toBe("Gamma Club");126- expect(info.limitation.auth_required).toBe(true);127- expect(info.limitation.restricted_writes).toBe(false);128- });129-130- it("only the owner may manage", async () => {131- const host = "delta.bind.ws";132- const owner = generateSecretKey();133- await rpc(host, owner, "claim");134- const r = await rpc(host, generateSecretKey(), "setpolicy", { writes: "owner" });135- expect(r.status).toBe(403);136- expect(r.error).toMatch(/^restricted:/);137- expect((await rpc(host, generateSecretKey(), "supportedmethods")).result).toContain("claim");138- });139-});140-14158 describe("expiry alarm", () => {14259 it("sweeps expired events from storage", async () => {14360 const stub = env.RELAY.getByName("epsilon");