clone

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

dependabot/github_actions/actions-9b61906d8b
bindws / test / object / groups.test.ts
groups.test.ts 14.3K
1// NIP-29: one group per relay. Joins and leaves, moderation by owner and2// moderator with the permission boundaries, the relay-signed group state,3// and ownership transfer.4import { SELF } from "cloudflare:test";5import { describe, it, expect } from "vitest";6import { generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";7import { ev, tagsOf, rpc, info } from "../helpers/relay.ts";8import { WS } from "../helpers/ws.ts";910const has = (e: Event, name: string) => e.tags.some((t) => t[0] === name);1112// state fetches the four relay-signed group events, keyed by kind.13async function state(c: WS, self: string) {14 const list = await c.req({ kinds: [39000, 39001, 39002, 39003], authors: [self] });15 return Object.fromEntries(list.map((e) => [e.kind, e])) as Record<number, Event | undefined>;16}1718describe("NIP-29 group state", () => {19 it("is signed by the relay after the claim and follows the rules", async () => {20 const host = "grp.bind.ws";21 const name = "grp";22 const owner = generateSecretKey();23 await rpc(host, owner, "claim");24 const doc = await info(host);25 expect(doc.supported_nips).toContain(29);26 const c = await WS.connect(host);27 let st = await state(c, doc.self);28 expect(st[39000]!.tags).toEqual(expect.arrayContaining([["d", name], ["name", name], ["about", ""]]));29 expect(has(st[39000]!, "private") || has(st[39000]!, "restricted") || has(st[39000]!, "closed")).toBe(false);30 expect(tagsOf(st[39001]!, "p")).toEqual([["p", getPublicKey(owner), "owner"]]);31 expect(tagsOf(st[39002]!, "p")).toEqual([["p", getPublicKey(owner)]]);32 expect(tagsOf(st[39003]!, "role").map((t) => t[1])).toEqual(["owner", "moderator"]);3334 await rpc(host, owner, "setpolicy", { reads: "members", writes: "allowlist", name: "Pizza", icon: "https://x/p.png" });35 // Members-only reads now: the owner authenticates to keep reading.36 const m = await WS.connect(host);37 // (the AUTH challenge was consumed by connect; fetch it again through a fresh socket's state)38 const chal = await (async () => {39 const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });40 const w = new WS(resp.webSocket!);41 const a = await w.expect("AUTH");42 return { w, challenge: a[1] as string };43 })();44 chal.w.send("AUTH", ev(owner, 22242, "", [["relay", "ws://" + host], ["challenge", chal.challenge]]));45 await chal.w.expect("OK");46 st = await state(chal.w, doc.self);47 expect(has(st[39000]!, "private") && has(st[39000]!, "restricted") && has(st[39000]!, "closed")).toBe(true);48 expect(st[39000]!.tags).toEqual(expect.arrayContaining([["name", "Pizza"], ["picture", "https://x/p.png"]]));49 void m;5051 await rpc(host, owner, "setpolicy", { reads: "open", directoryPublic: false });52 st = await state(c, doc.self);53 expect(st[39002]).toBeUndefined();54 expect(st[39001]).toBeDefined();55 });56});5758describe("NIP-29 joins and leaves", () => {59 it("lets strangers join an open group, refuses duplicates and foreign groups, and records leaves", async () => {60 const host = "open.bind.ws";61 const owner = generateSecretKey();62 await rpc(host, owner, "claim");63 const doc = await info(host);64 const alice = generateSecretKey();65 const c = await WS.connect(host);66 c.send("REQ", "live", { kinds: [9000, 9001], authors: [doc.self] });67 await c.expect("EOSE");6869 expect((await c.ok(ev(alice, 9021, "hi", [["h", "other"]]))).msg).toMatch(/^blocked: this relay hosts one group: open/);70 expect((await c.ok(ev(alice, 9021, "hi", [["h", "open"]]))).ok).toBe(true);71 const put = await c.expect("EVENT");72 expect(put[2].kind).toBe(9000);73 expect(put[2].tags).toEqual(expect.arrayContaining([["h", "open"], ["p", getPublicKey(alice)]]));74 expect((await rpc(host, owner, "listmembers")).result.members.find((m: any) => m.pubkey === getPublicKey(alice)).via).toBe("join");75 expect((await c.ok(ev(alice, 9021, "again", [["h", "open"]]))).msg).toMatch(/^duplicate: already a member/);76 // The join request itself is kept.77 expect((await c.req({ kinds: [9021], authors: [getPublicKey(alice)] })).length).toBe(1);7879 expect((await c.ok(ev(owner, 9022, "", [["h", "open"]]))).msg).toMatch(/transfer ownership first/);80 expect((await c.ok(ev(alice, 9022, "bye", [["h", "open"]]))).ok).toBe(true);81 const rem = await c.expect("EVENT");82 expect(rem[2].kind).toBe(9001);83 expect(tagsOf(rem[2], "p")[0][1]).toBe(getPublicKey(alice));84 expect((await c.ok(ev(alice, 9022, "bye again", [["h", "open"]]))).msg).toMatch(/^invalid: not a member/);85 });8687 it("needs an invite code in a closed group", async () => {88 const host = "closed.bind.ws";89 const owner = generateSecretKey();90 await rpc(host, owner, "claim");91 await rpc(host, owner, "setpolicy", { writes: "allowlist" });92 const bob = generateSecretKey();93 const c = await WS.connect(host);94 expect((await c.ok(ev(bob, 9021, "", [["h", "closed"]]))).msg).toMatch(/^restricted: this group is closed/);95 expect((await c.ok(ev(bob, 9021, "", [["h", "closed"], ["code", "nope"]]))).msg).toMatch(/not valid/);96 const inv = (await rpc(host, owner, "createinvite", 3600, 1, "friends")).result;97 expect((await c.ok(ev(bob, 9021, "", [["h", "closed"], ["code", inv.code]]))).ok).toBe(true);98 expect((await rpc(host, owner, "listmembers")).result.members.find((m: any) => m.pubkey === getPublicKey(bob)).via).toMatch(/^invite /);99 const carol = generateSecretKey();100 expect((await c.ok(ev(carol, 9021, "", [["h", "closed"], ["code", inv.code]]))).msg).toMatch(/used up/);101 });102});103104describe("NIP-29 moderation", () => {105 it("applies put-user, remove-user, edit-metadata, delete-event and create-invite by role", async () => {106 const host = "mod.bind.ws";107 const h = ["h", "mod"];108 const owner = generateSecretKey();109 const mod = generateSecretKey();110 const mod2 = generateSecretKey();111 const alice = generateSecretKey();112 const stranger = generateSecretKey();113 await rpc(host, owner, "claim");114 const doc = await info(host);115 const c = await WS.connect(host);116 const pk = getPublicKey;117118 expect((await c.ok(ev(stranger, 9000, "", [h, ["p", pk(alice)]]))).msg).toMatch(/^restricted: not a group admin/);119 expect((await c.ok(ev(owner, 9000, "", [h, ["p", pk(mod), "moderator"]]))).ok).toBe(true);120 expect((await c.ok(ev(owner, 9000, "", [h, ["p", pk(mod2), "moderator", "gardener"]]))).ok).toBe(true);121 let members = (await rpc(host, owner, "listmembers")).result.members;122 expect(members.find((m: any) => m.pubkey === pk(mod)).role).toBe("moderator");123 let st = await state(c, doc.self);124 expect(tagsOf(st[39001]!, "p")).toEqual(expect.arrayContaining([["p", pk(mod), "moderator"], ["p", pk(mod2), "moderator"]]));125 // The relay's own put-user carries the roles held now.126 const records = await c.req({ kinds: [9000], authors: [doc.self], "#p": [pk(mod)] });127 expect(records.some((e) => tagsOf(e, "p")[0].includes("moderator"))).toBe(true);128129 expect((await c.ok(ev(mod, 9000, "", [h, ["p", pk(alice)]]))).ok).toBe(true);130 expect((await c.ok(ev(mod, 9000, "", [h, ["p", pk(stranger), "moderator"]]))).msg).toMatch(/only the owner appoints/);131 expect((await c.ok(ev(mod, 9000, "", [h, ["p", pk(mod2)]]))).msg).toMatch(/only the owner changes moderators/);132 expect((await c.ok(ev(mod, 9000, "", [h, ["p", pk(owner)]]))).msg).toMatch(/changes by transfer/);133 expect((await c.ok(ev(mod, 9001, "", [h, ["p", pk(owner)]]))).msg).toMatch(/owner cannot be removed/);134 expect((await c.ok(ev(mod, 9001, "", [h, ["p", pk(mod2)]]))).msg).toMatch(/only the owner removes moderators/);135 expect((await c.ok(ev(mod, 9001, "", [h, ["p", pk(alice)]]))).ok).toBe(true);136 expect((await rpc(host, owner, "listmembers")).result.members.some((m: any) => m.pubkey === pk(alice))).toBe(false);137 // Demotion by the owner.138 expect((await c.ok(ev(owner, 9000, "", [h, ["p", pk(mod2)]]))).ok).toBe(true);139 members = (await rpc(host, owner, "listmembers")).result.members;140 expect(members.find((m: any) => m.pubkey === pk(mod2)).role).toBe("member");141142 expect((await c.ok(ev(mod, 9002, "", [h, ["name", "Nope"]]))).msg).toMatch(/moderators cannot do that/);143 expect((await c.ok(ev(owner, 9002, "", [h, ["name", "Mods"], ["about", "a tidy place"], ["picture", "https://x/i.png"]]))).ok).toBe(true);144 const d2 = await info(host);145 expect([d2.name, d2.description, d2.icon]).toEqual(["Mods", "a tidy place", "https://x/i.png"]);146 st = await state(c, doc.self);147 expect(st[39000]!.tags).toEqual(expect.arrayContaining([["name", "Mods"], ["about", "a tidy place"]]));148149 // Alice was removed; re-add so she can post, then a moderator deletes her note.150 expect((await c.ok(ev(owner, 9000, "", [h, ["p", pk(alice)]]))).ok).toBe(true);151 const note = ev(alice, 1, "rude", [h]);152 expect((await c.ok(note)).ok).toBe(true);153 expect((await c.ok(ev(stranger, 9005, "", [h, ["e", note.id]]))).ok).toBe(false);154 expect((await c.ok(ev(mod, 9005, "", [h, ["e", note.id]]))).ok).toBe(true);155 expect((await c.req({ ids: [note.id] })).length).toBe(0);156 const roster = (await c.req({ kinds: [13534], authors: [doc.self] }))[0];157 expect((await c.ok(ev(mod, 9005, "", [h, ["e", roster.id]]))).msg).toMatch(/relay's own records/);158159 expect((await c.ok(ev(mod, 9009, "come along", [h, ["code", "party-2026"]]))).ok).toBe(true);160 expect((await rpc(host, owner, "listinvites")).result.some((i: any) => i.code === "party-2026" && i.note === "come along")).toBe(true);161 expect((await c.ok(ev(mod, 9009, "", [h, ["code", "party-2026"]]))).msg).toMatch(/^duplicate:/);162 expect((await c.ok(ev(mod, 9009, "", [h, ["code", "x"]]))).msg).toMatch(/^invalid:/);163 await rpc(host, owner, "setpolicy", { writes: "allowlist" });164 expect((await c.ok(ev(stranger, 9021, "", [h, ["code", "party-2026"]]))).ok).toBe(true);165166 expect((await c.ok(ev(owner, 9007, "", [h]))).msg).toMatch(/^unsupported:/);167 expect((await c.ok(ev(owner, 9008, "", [h]))).msg).toMatch(/^unsupported:/);168 expect((await c.ok(ev(owner, 9010, "", [h]))).ok).toBe(true); // an empty pin list clears the pins169 expect((await c.ok(ev(owner, 39000, "", [["d", "mod"], ["name", "forged"]]))).msg).toMatch(/written by the relay/);170 });171172 it("gives a moderator the NIP-86 methods for keeping the peace and nothing else", async () => {173 const host = "team.bind.ws";174 const owner = generateSecretKey();175 const mod = generateSecretKey();176 const mod2 = generateSecretKey();177 const alice = generateSecretKey();178 const pk = getPublicKey;179 await rpc(host, owner, "claim");180 await rpc(host, owner, "setmember", pk(alice), { name: "alice" });181 expect((await rpc(host, mod, "stats")).status).toBe(403);182 await rpc(host, owner, "setmember", pk(mod), { role: "moderator" });183 await rpc(host, owner, "setmember", pk(mod2), { role: "moderator" });184 expect((await rpc(host, owner, "listmembers")).result.members.map((m: any) => m.role)).toEqual(["owner", "moderator", "moderator", "member"]);185186 expect((await rpc(host, mod, "stats")).status).toBe(200);187 expect((await rpc(host, mod, "getpolicy")).status).toBe(200);188 expect((await rpc(host, mod, "listreports")).status).toBe(200);189 expect((await rpc(host, mod, "setmember", pk(alice), { note: "ok" })).status).toBe(200);190 expect((await rpc(host, mod, "setmember", pk(alice), { role: "moderator" })).error).toMatch(/only the owner sets roles/);191 expect((await rpc(host, mod, "setmember", pk(mod2), { note: "x" })).status).toBe(403);192 expect((await rpc(host, mod, "removemember", pk(mod2))).status).toBe(403);193 expect((await rpc(host, mod, "banpubkey", pk(mod2), "grr")).status).toBe(403);194 expect((await rpc(host, mod, "banpubkey", pk(owner), "grr")).status).toBe(400);195 for (const m of ["setpolicy", "storagestats", "exportconfig", "pullstatus", "deleterelay", "changerelayname", "resetrules"]) {196 const r = await rpc(host, mod, m, "x");197 expect(r.status, m).toBe(403);198 expect(r.error, m).toMatch(/moderators cannot do that/);199 }200 expect((await rpc(host, mod, "transferowner", pk(mod))).status).toBe(403);201 expect((await rpc(host, mod, "banpubkey", pk(alice), "rude")).status).toBe(200);202 expect((await rpc(host, mod, "listbannedpubkeys")).result.map((b: any) => b.pubkey)).toEqual([pk(alice)]);203 const inv = await rpc(host, mod, "createinvite", 3600, 0, "");204 expect(inv.status).toBe(200);205 expect((await rpc(host, mod, "revokeinvite", inv.result.code)).result).toBe(true);206 // Demoted, the door closes again.207 await rpc(host, owner, "setmember", pk(mod), { role: "member" });208 expect((await rpc(host, mod, "stats")).status).toBe(403);209 });210});211212describe("ownership transfer", () => {213 it("hands the relay to a member and keeps the old owner on as a moderator", async () => {214 const host = "handover.bind.ws";215 const owner = generateSecretKey();216 const bob = generateSecretKey();217 const pk = getPublicKey;218 await rpc(host, owner, "claim");219 const before = await info(host);220 expect((await rpc(host, owner, "transferowner", pk(bob))).error).toMatch(/must be a member first/);221 expect((await rpc(host, owner, "transferowner", pk(owner))).status).toBe(400);222 await rpc(host, owner, "setmember", pk(bob), { name: "bob" });223 const r = await rpc(host, owner, "transferowner", pk(bob));224 expect(r.result).toEqual({ owner: pk(bob), previous: pk(owner) });225226 const after = await info(host);227 expect(after.pubkey).toBe(pk(bob));228 expect(after.self).toBe(before.self);229 const members = (await rpc(host, bob, "listmembers")).result.members;230 expect(members.map((m: any) => [m.pubkey, m.role])).toEqual([[pk(bob), "owner"], [pk(owner), "moderator"]]);231 const c = await WS.connect(host);232 const st = await state(c, after.self);233 expect(tagsOf(st[39001]!, "p")).toEqual([["p", pk(bob), "owner"], ["p", pk(owner), "moderator"]]);234 const roster = (await c.req({ kinds: [13534], authors: [after.self] }))[0];235 expect(tagsOf(roster, "member")).toEqual(expect.arrayContaining([["member", pk(bob), "owner"], ["member", pk(owner), "moderator"]]));236237 expect((await rpc(host, owner, "setpolicy", { writes: "owner" })).status).toBe(403);238 expect((await rpc(host, bob, "setpolicy", { writes: "owner" })).status).toBe(200);239 expect((await rpc(host, owner, "banpubkey", pk(bob), "")).status).toBe(400);240 expect((await rpc(host, bob, "banpubkey", pk(owner), "")).status).toBe(200);241 expect((await rpc(host, owner, "stats")).status).toBe(403);242 });243});