1// NIP-86 membership invite claims: management, member invite-tree policy,2// and the existing HTTP/NIP-43 admission paths.3import { env, runInDurableObject } from "cloudflare:test";4import { describe, it, expect } from "vitest";5import { generateSecretKey } from "nostr-tools/pure";6import type { Relay } from "../../src/relay.ts";7import { checkInvite, listClaims } from "../../src/invites.ts";8import { now, ev, rpc, post, pk } from "../helpers/relay.ts";9import { WS } from "../helpers/ws.ts";1011describe("NIP-86 membership claim management", () => {12 it("creates, lists, deletes, and audits membership claims while keeping ownership separate", async () => {13 const host = "claims.bind.ws";14 const owner = generateSecretKey();15 const mod = generateSecretKey();16 const stranger = generateSecretKey();17 expect((await rpc(host, stranger, "createclaim", "not-ownership")).status).toBe(403);18 await rpc(host, owner, "claim");1920 for (const code of ["abc", "bad space", "\u00fcmlaut", "x".repeat(65), "ok_code"]) {21 const r = await rpc(host, owner, "createclaim", code);22 expect(r.status, code).toBe(code === "ok_code" ? 200 : 400);23 }24 expect((await rpc(host, owner, "createclaim", "ok_code")).status).toBe(400);25 expect((await rpc(host, owner, "createclaim", "another_code")).result).toBe(true);26 expect((await rpc(host, owner, "listclaims", "unexpected")).status).toBe(400);27 expect((await rpc(host, owner, "createclaim")).status).toBe(400);28 expect((await rpc(host, owner, "createclaim", null)).status).toBe(400);29 expect((await rpc(host, owner, "createclaim", { code: "object_code" })).status).toBe(400);30 for (const params of [[], [null], [1234], ["abc"], ["ok_code", "extra"]]) {31 expect((await rpc(host, owner, "deleteclaim", ...params)).status).toBe(400);32 }33 expect(((await rpc(host, owner, "listclaims")).result as string[]).sort()).toEqual(["another_code", "ok_code"]);34 const invite = (await rpc(host, owner, "listinvites")).result.find((i: any) => i.code === "ok_code");35 expect(invite.expires_at - invite.created_at).toBe(3 * 86400);36 expect(invite).toMatchObject({ max_uses: 0, uses: 0, created_by: pk(owner), note: "" });37 expect((await rpc(host, owner, "supportedmethods")).result).toEqual(expect.arrayContaining(["claim", "listclaims", "createclaim", "deleteclaim"]));3839 // The claim is usable through the public HTTP admission door and is not40 // the relay ownership claim method.41 expect((await post(host, stranger, "/api/invites/claim", { code: "ok_code" })).body.status).toBe("joined");42 expect((await rpc(host, owner, "listclaims")).result).toContain("ok_code");43 // The ownership claim remains its own, idempotent management method.44 expect((await rpc(host, owner, "claim")).result).toMatchObject({ claimed: true, owner: pk(owner) });45 expect((await rpc(host, stranger, "claim")).status).toBe(403);4647 await rpc(host, owner, "setmember", pk(mod), { role: "moderator" });48 expect((await rpc(host, mod, "listclaims")).result).toEqual(expect.arrayContaining(["another_code", "ok_code"]));49 expect((await rpc(host, mod, "deleteclaim", "missing_code")).result).toBe(true);50 expect((await rpc(host, mod, "deleteclaim", "ok_code")).result).toBe(true);51 expect((await rpc(host, owner, "listclaims")).result).not.toContain("ok_code");52 expect((await rpc(host, mod, "createclaim", "moderator-code")).result).toBe(true);53 expect((await rpc(host, owner, "revokeinvite", "moderator-code")).result).toBe(true);5455 const deleted = generateSecretKey();56 const ws = await WS.connect(host);57 expect((await rpc(host, owner, "createclaim", "deleted_code")).result).toBe(true);58 expect((await rpc(host, owner, "deleteclaim", "deleted_code")).result).toBe(true);59 expect((await post(host, deleted, "/api/invites/claim", { code: "deleted_code" })).body.error).toBe("invite_invalid");60 await ws.auth(deleted, host);61 expect((await ws.ok(ev(deleted, 28934, "", [["-"], ["claim", "deleted_code"]]))).msg).toMatch(/invalid invite code/);6263 const audit = (await rpc(host, owner, "listaudit")).result as any[];64 expect(audit.some((x) => x.action === "createclaim" && x.target === "another_code")).toBe(true);65 expect(audit.some((x) => x.action === "deleteclaim" && x.target === "ok_code")).toBe(true);66 const beforeRead = audit.length;67 expect((await rpc(host, owner, "listclaims")).status).toBe(200);68 expect(((await rpc(host, owner, "listaudit")).result as any[]).length).toBe(beforeRead);69 });7071 it("filters expired and exhausted claims before applying the 200-row limit", async () => {72 const host = "claimfilter.bind.ws";73 const owner = generateSecretKey();74 await rpc(host, owner, "claim");75 const t = now();76 await runInDurableObject(env.RELAY.getByName("claimfilter"), (r: Relay) => {77 // Recent expired rows remain in the invite table for normal invite78 // cleanup, so this also exercises listclaims' live predicate directly.79 for (let i = 0; i < 205; i++) {80 r.sql.exec(81 `INSERT INTO invites(code,created_by,created_at,expires_at,max_uses,uses,note) VALUES(?,?,?,?,?,?,?)`,82 `expired_${i}`, pk(owner), t - i, t - 1, 0, 0, "",83 );84 }85 for (let i = 0; i < 205; i++) {86 r.sql.exec(87 `INSERT INTO invites(code,created_by,created_at,expires_at,max_uses,uses,note) VALUES(?,?,?,?,?,?,?)`,88 `used_${i}`, pk(owner), t - i, t + 86400, 1, 1, "",89 );90 }91 r.sql.exec(92 `INSERT INTO invites(code,created_by,created_at,expires_at,max_uses,uses,note) VALUES(?,?,?,?,?,?,?)`,93 "boundary_code", pk(owner), t, t, 0, 0, "",94 );95 r.sql.exec(`INSERT INTO invites(code,created_by,created_at,expires_at,max_uses,uses,note) VALUES(?,?,?,?,?,?,?)`, "live_code", pk(owner), t - 1000, t + 86400, 0, 0, "");96 // An explicit clock makes the expiry-second boundary deterministic.97 expect(listClaims(r.sql, t)).toEqual(["boundary_code", "live_code"]);98 expect(checkInvite(r.sql, "boundary_code", t)).toBe("ok");99 expect(listClaims(r.sql, t + 1)).toEqual(["live_code"]);100 expect(checkInvite(r.sql, "boundary_code", t + 1)).toBe("invite_expired");101 r.sql.exec(`DELETE FROM invites WHERE code=?`, "boundary_code");102 });103 const claims = (await rpc(host, owner, "listclaims")).result as string[];104 expect(claims).toEqual(["live_code"]);105 const guest = generateSecretKey();106 expect((await post(host, guest, "/api/invites/claim", { code: "expired_0" })).body.error).toBe("invite_expired");107 expect((await post(host, guest, "/api/invites/claim", { code: "used_0" })).body.error).toBe("invite_exhausted");108 expect((await rpc(host, owner, "createclaim", "expired_0")).error).toMatch(/^duplicate:/);109 });110111 it("isolates member claims and enforces shared quota and depth", async () => {112 const host = "memberclaims.bind.ws";113 const owner = generateSecretKey();114 const alice = generateSecretKey();115 const bob = generateSecretKey();116 const stranger = generateSecretKey();117 await rpc(host, owner, "claim");118 await rpc(host, owner, "setpolicy", { writes: "allowlist", memberInvites: { depth: 2, quota: 2 } });119 await rpc(host, owner, "setmember", pk(alice), {});120 await rpc(host, owner, "setmember", pk(bob), {});121122 expect((await rpc(host, alice, "listclaims", "unexpected")).status).toBe(400);123 expect((await rpc(host, alice, "createclaim", "four_code", "extra")).status).toBe(400);124 const aliceInvite = (await rpc(host, alice, "createinvite")).result;125 expect(aliceInvite.max_uses).toBe(0);126 expect(aliceInvite.expires_at - aliceInvite.created_at).toBe(3 * 86400);127 expect((await rpc(host, alice, "createclaim", "alice_one")).result).toBe(true);128 expect((await rpc(host, alice, "createinvite")).status).toBe(403);129 expect((await rpc(host, alice, "createclaim", "alice_three")).status).toBe(403);130 const aliceClaims = (await rpc(host, alice, "listclaims")).result as string[];131 expect(aliceClaims.sort()).toEqual([aliceInvite.code, "alice_one"].sort());132 expect((await rpc(host, bob, "listclaims")).result).toEqual([]);133 expect((await rpc(host, bob, "deleteclaim", "alice_one")).status).toBe(403);134 expect((await rpc(host, stranger, "listclaims")).status).toBe(403);135136 // A child may claim over NIP-43; the invitation creator remains Alice.137 const ws = await WS.connect(host);138 expect((await ws.ok(ev(stranger, 28934, "", [["-"], ["claim", "alice_one"]]))).msg).toMatch(/^auth-required/);139 await ws.auth(stranger, host);140 expect((await ws.ok(ev(stranger, 28934, "", [["-"], ["claim", "alice_one"]]))).ok).toBe(true);141 const member = (await rpc(host, owner, "listmembers")).result.members.find((m: any) => m.pubkey === pk(stranger));142 expect(member.invited_by).toBe(pk(alice));143 expect((await rpc(host, stranger, "createclaim", "stranger_code")).status).toBe(403);144 expect((await rpc(host, alice, "deleteclaim", "alice_one")).result).toBe(true);145 expect((await rpc(host, alice, "deleteclaim", "unknown_code")).status).toBe(403);146 // Deletion through either API frees exactly one slot in the same quota.147 expect((await rpc(host, alice, "createclaim", "alice_two")).result).toBe(true);148 expect((await rpc(host, alice, "createclaim", "alice_three")).status).toBe(403);149 expect((await rpc(host, alice, "revokeinvite", aliceInvite.code)).result).toBe(true);150 expect((await rpc(host, alice, "createclaim", "alice_three")).result).toBe(true);151 await rpc(host, owner, "setpolicy", { memberInvites: { depth: 0, quota: 0 } });152 for (const method of ["createclaim", "listclaims", "deleteclaim"]) expect((await rpc(host, alice, method, ...(method === "listclaims" ? [] : ["alice_three"]))).status).toBe(403);153 });154155 it("scopes live claims before the cap and limits the administrative listing", async () => {156 const host = "claimscope.bind.ws";157 const owner = generateSecretKey();158 const member = generateSecretKey();159 await rpc(host, owner, "claim");160 await rpc(host, owner, "setmember", pk(member));161 await rpc(host, owner, "setpolicy", { memberInvites: { depth: 2, quota: 2 } });162 await rpc(host, member, "createclaim", "member-code");163 await runInDurableObject(env.RELAY.getByName("claimscope"), (r: Relay) => {164 const t = now();165 r.sql.exec(`UPDATE invites SET created_at=? WHERE code=?`, t - 1000, "member-code");166 for (let i = 0; i < 205; i++) r.sql.exec(`INSERT INTO invites(code,created_by,created_at,expires_at,max_uses,uses,note) VALUES(?,?,?,?,?,?,?)`, `owner_${i}`, pk(owner), t, t + 86400, 0, 0, "");167 });168 expect((await rpc(host, member, "listclaims")).result).toEqual(["member-code"]);169 const all = (await rpc(host, owner, "listclaims")).result as string[];170 expect(all).toHaveLength(200);171 expect(all.every((code) => code.startsWith("owner_"))).toBe(true);172 });173174 it("shares limited invites with NIP-43 and NIP-29 joins, exhaustion, and bans", async () => {175 const host = "claimjoins.bind.ws";176 const owner = generateSecretKey();177 const first = generateSecretKey();178 const second = generateSecretKey();179 await rpc(host, owner, "claim");180 await rpc(host, owner, "setpolicy", { writes: "allowlist" });181 const limited = (await rpc(host, owner, "createinvite", 3600, 1, "single-use")).result;182 expect((await rpc(host, owner, "listclaims")).result).toEqual([limited.code]);183 const ws = await WS.connect(host);184 await ws.auth(first, host);185 expect((await ws.ok(ev(first, 28934, "", [["-"], ["claim", limited.code]]))).ok).toBe(true);186 expect((await rpc(host, owner, "listclaims")).result).toEqual([]);187 expect((await rpc(host, owner, "listinvites")).result[0]).toMatchObject({ code: limited.code, uses: 1 });188 expect((await post(host, second, "/api/invites/claim", { code: limited.code })).body.error).toBe("invite_exhausted");189 await ws.auth(second, host);190 expect((await ws.ok(ev(second, 28934, "", [["-"], ["claim", limited.code]]))).msg).toMatch(/used up/);191 expect((await rpc(host, owner, "deleteclaim", limited.code)).result).toBe(true);192 expect((await rpc(host, owner, "revokeinvite", limited.code)).result).toBe(false);193 await rpc(host, owner, "createclaim", "group-code");194 expect((await ws.ok(ev(second, 9021, "", [["h", "claimjoins"], ["code", "group-code"]]))).ok).toBe(true);195 const banned = generateSecretKey();196 await rpc(host, owner, "banpubkey", pk(banned));197 expect((await post(host, banned, "/api/invites/claim", { code: "group-code" })).status).toBe(403);198 expect((await rpc(host, banned, "createclaim", "banned-code")).status).toBe(403);199 expect((await rpc(host, owner, "getpolicy")).result.owner).toBe(pk(owner));200 });201});