clone

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

test: the object tests are named after the feature or module they exercise, so Blossom, moderation, members, identity and the configuration each live in one file

Dami committed 180c587
commit
180c5873740bf1005e35d2ca117e0dcc7087cd20
parent
ee65de55396160acadb6f5cbe37ea472fb7c78ce
author
Dami <dami.etoile@gmail.com>
16 files changed, +1236 -1240
Mdocs/12-develop-extend.md12+-
Dtest/object/adopt.test.ts451------------------------------
Dtest/object/audit.test.ts85------
Dtest/object/blossom.test.ts127--------
Rtest/object/nip46.test.ts → test/object/bridge.test.ts57+++-
Atest/object/config.test.ts198+++++++++++++
Atest/object/dumps.test.ts78+++++
Dtest/object/gate.test.ts215--------------
Rtest/object/nip43.test.ts → test/object/identity.test.ts47+++-
Dtest/object/manage.test.ts134---------
Mtest/object/media.test.ts181++++++++++++-
Rtest/object/data.test.ts → test/object/members.test.ts157++++++----
Atest/object/moderation.test.ts225+++++++++++++++
Atest/object/nip05.test.ts42+++
Mtest/object/policy.test.ts380++++++++++++++++++++++----
Mtest/object/relay.test.ts87+------
M docs/12-develop-extend.md +7 -5
@@ -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
D test/object/adopt.test.ts +0 -451
@@ -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-});
D test/object/audit.test.ts +0 -85
@@ -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-});
D test/object/blossom.test.ts +0 -127
@@ -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-});
R test/object/nip46.test.ts → test/object/bridge.test.ts +52 -5
@@ -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";
A test/object/config.test.ts +198 -0
@@ -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+});
A test/object/dumps.test.ts +78 -0
@@ -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+});
D test/object/gate.test.ts +0 -215
@@ -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-});
R test/object/nip43.test.ts → test/object/identity.test.ts +41 -6
@@ -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";
D test/object/manage.test.ts +0 -134
@@ -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-});
M test/object/media.test.ts +175 -6
@@ -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";
R test/object/data.test.ts → test/object/members.test.ts +92 -65
@@ -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+});
A test/object/moderation.test.ts +225 -0
@@ -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+});
A test/object/nip05.test.ts +42 -0
@@ -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+});
M test/object/policy.test.ts +324 -56
@@ -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 });
M test/object/relay.test.ts +2 -85
@@ -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");