1// Members: invites, members-only reads and eviction, per-member keep-for2// and caps, members inviting members, and the migration of the members table.3import { SELF, env, runInDurableObject } from "cloudflare:test";4import { describe, it, expect } from "vitest";5import { generateSecretKey, getPublicKey } from "nostr-tools/pure";6import type { Relay } from "../../src/relay.ts";7import { now, ev, rpc, post, pk } from "../helpers/relay.ts";8import { WS } from "../helpers/ws.ts";910describe("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";13 const owner = generateSecretKey();14 const guest = generateSecretKey();15 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);2122 // 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");2829 // Before joining: writes refused. After: accepted, and the roster changed.30 const c = await WS.connect(host);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 /);47 });48});4950describe("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";53 const owner = generateSecretKey();54 const member = generateSecretKey();55 const stranger = generateSecretKey();56 await rpc(host, owner, "claim");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");6869 // 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();7576 // 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);82 });83});8485describe("per-member keep-for and caps", () => {86 it("a member's keep-for refuses old events and sweeps theirs, while others follow the relay's rules", async () => {87 const host = "keepy.bind.ws";88 const owner = generateSecretKey();89 const a = generateSecretKey();90 const b = generateSecretKey();91 await rpc(host, owner, "claim");92 expect((await rpc(host, owner, "setmember", pk(a), { keepDays: 10 })).status).toBe(200);93 await rpc(host, owner, "setmember", pk(b), {});94 const members = (await rpc(host, owner, "listmembers")).result.members;95 expect(members.find((m: any) => m.pubkey === pk(a)).keep_days).toBe(10);9697 const c = await WS.connect(host);98 expect((await c.ok(ev(a, 1, "too old", [], now() - 20 * 86400))).msg).toMatch(/keeps your events for 10 days/);99 expect((await c.ok(ev(b, 1, "old but fine", [], now() - 20 * 86400))).ok).toBe(true);100 expect((await c.ok(ev(a, 1, "recent enough", [], now() - 5 * 86400))).ok).toBe(true);101 // Profiles are never swept by a keep-for.102 expect((await c.ok(ev(a, 0, JSON.stringify({ name: "a" }), [], now() - 30 * 86400))).ok).toBe(true);103104 await rpc(host, owner, "setmember", pk(a), { keepDays: 3 });105 const gone = await runInDurableObject(env.RELAY.getByName("keepy"), async (r: Relay) => r.sweepRetention(now()));106 expect(gone).toBe(1);107 expect((await c.req({ kinds: [1], authors: [pk(a)] })).length).toBe(0);108 expect((await c.req({ kinds: [0], authors: [pk(a)] })).length).toBe(1);109 expect((await c.req({ kinds: [1], authors: [pk(b)] })).length).toBe(1);110111 // Only the owner sets limits, and never on themselves.112 const mod = generateSecretKey();113 await rpc(host, owner, "setmember", pk(mod), { role: "moderator" });114 expect((await rpc(host, mod, "setmember", pk(b), { keepDays: 1 })).status).toBe(403);115 await rpc(host, owner, "setmember", pk(owner), { keepDays: 1 });116 expect((await c.ok(ev(owner, 1, "owner, old", [], now() - 20 * 86400))).ok).toBe(true);117 });118119 it("a cap refuses the event that would cross it, and the owner is never capped", async () => {120 const host = "cappy.bind.ws";121 const owner = generateSecretKey();122 const m = generateSecretKey();123 await rpc(host, owner, "claim");124 await rpc(host, owner, "setmember", pk(m), { maxBytes: 1200 });125 const c = await WS.connect(host);126 const big = "x".repeat(400);127 expect((await c.ok(ev(m, 1, big))).ok).toBe(true);128 const r = await c.ok(ev(m, 1, big + "2"));129 expect(r.ok).toBe(false);130 expect(r.msg).toMatch(/^restricted: you have reached your storage cap/);131 expect((await c.ok(ev(m, 7, "+"))).ok).toBe(true); // small ones still fit132 for (let i = 0; i < 3; i++) expect((await c.ok(ev(owner, 1, big + i))).ok).toBe(true);133 // Raising the cap lets it through; the cache follows the store.134 await rpc(host, owner, "setmember", pk(m), { maxBytes: 5000 });135 expect((await c.ok(ev(m, 1, big + "3"))).ok).toBe(true);136 const cfg = (await rpc(host, owner, "exportconfig")).result;137 expect(cfg.members.find((x: any) => x.pubkey === pk(m)).maxBytes).toBe(5000);138 });139});140141describe("members invite members", () => {142 it("depth and quota bound the tree, both join paths record the inviter, and a subtree goes in one publish", async () => {143 const host = "treey.bind.ws";144 const owner = generateSecretKey();145 const a = generateSecretKey();146 const b = generateSecretKey();147 const c2 = generateSecretKey();148 const e = generateSecretKey();149 await rpc(host, owner, "claim");150 await rpc(host, owner, "setpolicy", { writes: "allowlist", memberInvites: { depth: 2, quota: 1 } });151 expect((await rpc(host, owner, "getpolicy")).result.memberInvites).toEqual({ depth: 2, quota: 1 });152153 // The owner invites A through the HTTP door.154 const inv = (await rpc(host, owner, "createinvite", 86400, 0, "for a")).result;155 expect((await post(host, a, "/api/invites/claim", { code: inv.code })).body.status).toBe("joined");156 let members = (await rpc(host, owner, "listmembers")).result.members;157 expect(members.find((m: any) => m.pubkey === pk(a)).invited_by).toBe(pk(owner));158159 // A is one hop out and may hold one live invite.160 const invA = await rpc(host, a, "createinvite", 86400, 1, "for b");161 expect(invA.status, JSON.stringify(invA)).toBe(200);162 expect((await rpc(host, a, "createinvite")).error).toMatch(/already hold 1 live invite/);163 expect((await rpc(host, a, "listinvites")).result.map((i: any) => i.code)).toEqual([invA.result.code]);164 expect((await rpc(host, a, "revokeinvite", inv.code)).status).toBe(403);165 expect((await rpc(host, a, "listmembers")).status).toBe(403);166167 // B joins with A's code over the NIP-29 door; two hops out, B cannot invite.168 const ws = await WS.connect(host);169 expect((await ws.ok(ev(b, 9021, "", [["h", "treey"], ["code", invA.result.code]]))).ok).toBe(true);170 members = (await rpc(host, owner, "listmembers")).result.members;171 expect(members.find((m: any) => m.pubkey === pk(b)).invited_by).toBe(pk(a));172 expect((await rpc(host, b, "createinvite")).error).toMatch(/do not reach/);173 // A's invite is used up, so A may mint again; C joins, then E, whom the owner makes a moderator.174 const invA2 = (await rpc(host, a, "createinvite", 86400, 2, "more")).result;175 expect((await ws.ok(ev(c2, 9021, "", [["h", "treey"], ["code", invA2.code]]))).ok).toBe(true);176 expect((await ws.ok(ev(e, 9021, "", [["h", "treey"], ["code", invA2.code]]))).ok).toBe(true);177 await rpc(host, owner, "setmember", pk(e), { role: "moderator" });178 members = (await rpc(host, owner, "listmembers")).result.members;179 expect(members.find((m: any) => m.pubkey === pk(a)).invites).toBe(0);180181 // Switched off, members are back to asking the owner.182 await rpc(host, owner, "setpolicy", { memberInvites: { depth: 0, quota: 0 } });183 expect((await rpc(host, a, "createinvite")).status).toBe(403);184185 // Removing A takes B and C along, leaves the moderator E, and publishes once.186 const stub = env.RELAY.getByName("treey");187 await runInDurableObject(stub, async (r: Relay) => {188 const orig = r.publishMembership.bind(r);189 (r as any).__pub = 0;190 (r as any).publishMembership = async (...changes: { pubkey: string; added?: boolean }[]) => {191 (r as any).__pub++;192 return orig(...changes);193 };194 });195 // The moderator may do it: A is a plain member, and E's own branch stays.196 const removed = (await rpc(host, e, "removesubtree", pk(a))).result.removed;197 expect(removed.sort()).toEqual([pk(a), pk(b), pk(c2)].sort());198 members = (await rpc(host, owner, "listmembers")).result.members;199 expect(members.map((m: any) => m.pubkey).sort()).toEqual([pk(owner), pk(e)].sort());200 await runInDurableObject(stub, async (r: Relay) => expect((r as any).__pub).toBe(1));201 expect((await ws.req({ kinds: [9001] })).length).toBe(3);202 expect((await rpc(host, owner, "removesubtree", pk(owner))).status).toBe(400);203 });204});205206describe("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});