diff --git a/README.md b/README.md index 98c036d..54ee98b 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ Site hosting and mirroring are on by default; Marmot and GRASP are off until ena - [NIP-5A static websites](docs/20-nip-5a-static-websites.md): publish and serve static sites from signed Nostr manifests. - [GRASP-01 Git hosting](docs/22-grasp-01-git-hosting.md): host bounded NIP-34 repositories through Git Smart HTTP. +- [NIP-86 membership claims](docs/24-nip86-claims.md): create, list and revoke invitation codes through the standard management methods. ### Protocol guides diff --git a/docs/03-people-and-groups.md b/docs/03-people-and-groups.md index a6c3543..abb6bf1 100644 --- a/docs/03-people-and-groups.md +++ b/docs/03-people-and-groups.md @@ -27,6 +27,8 @@ A members-only relay does not have to be silent to everyone else. On the Rules t **New invite link** mints a link that lives one hour to 30 days, three days by default, and takes as many people as you allow, or any number if you leave the count at zero. The person opens it, reads the join terms, signs once and becomes a member. +Clients and scripts can choose a code through `createclaim`, list usable codes through `listclaims`, and revoke one through `deleteclaim`. These use the same invites and member tree as the page. A chosen code lasts three days and permits unlimited joins until expiry or revocation; use `createinvite` for a generated code with a different lifetime or use limit. See [NIP-86 membership claims](24-nip86-claims.md). These membership codes are separate from claiming ownership of a relay. + The **Joining** block holds the join terms, shown before someone accepts an invite and published at `/terms`. Its switch decides whether visitors see the people directory at `/people`, in the group's member list, in the card's member count and in the NIP-05 listing at `/.well-known/nostr.json` without a name. A lookup by name still answers, since the member put that address in their own profile. ### Members invite members diff --git a/docs/13-scripts-and-agents.md b/docs/13-scripts-and-agents.md index 2ee67ac..a12cd70 100644 --- a/docs/13-scripts-and-agents.md +++ b/docs/13-scripts-and-agents.md @@ -87,7 +87,8 @@ The bridge takes the same header. `POST /events` answers `{ event_id, accepted, - `listpresets`: preset names, descriptions and whether one needs a source. - `listmembers`, `listpeople`, `listallowedpubkeys`: the member list in three shapes. - `listbannedpubkeys`, `listbannedevents`, `listblockedips`: the bans. -- `listinvites`: live invites; a member under the invite rule sees only their own. +- `listinvites`: detailed invite records, including expired and exhausted entries; a member under the invite rule sees only their own. +- `listclaims`: up to 200 usable invite code strings; a member under the invite rule sees only their own. See [NIP-86 membership claims](24-nip86-claims.md) for the pinned proposal and constraints. - `listrecentevents [limit]`: the newest events. - `listallowedkinds`, `listblockedkinds`, `listretention`: the kind and keep-for rules. - `listblobs`, `listreports`: files and the reports queue. @@ -115,6 +116,8 @@ The bridge takes the same header. `POST /events` answers `{ event_id, accepted, - `createinvite ttlSeconds maxUses note`: mint a code. A member under the invite rule may mint within their quota. - `revokeinvite code`. +- `createclaim code`: mint a chosen code with the default three-day lifetime and unlimited uses; returns `true`. The same member quota and depth apply. +- `deleteclaim code`: revoke a code from the shared invite store; returns `true`. Members may only delete their own codes. **Reports** diff --git a/docs/14-http-reference.md b/docs/14-http-reference.md index 3539f2e..f9ceae8 100644 --- a/docs/14-http-reference.md +++ b/docs/14-http-reference.md @@ -182,6 +182,8 @@ Only while reads are open. Otherwise every path here answers 404, and an unclaim ## People, invites and names +The NIP-86 `createclaim`, `listclaims` and `deleteclaim` methods use `POST /` to manage the same codes redeemed below. Their parameters, results and limits are in [NIP-86 membership claims](24-nip86-claims.md); the bind.ws `claim` method still concerns relay ownership. + | Path | Method | Auth | Answers | Status | |---|---|---|---|---| | `/.well-known/nostr.json?name=` | GET | none for a name; the directory switch for the listing without one, else NIP-98 by a member | NIP-05 for the member with that name, with this relay as their relay; without a name, every named member when the directory is public | 200; 401 bad signature | diff --git a/docs/24-nip86-claims.md b/docs/24-nip86-claims.md new file mode 100644 index 0000000..4332901 --- /dev/null +++ b/docs/24-nip86-claims.md @@ -0,0 +1,52 @@ +--- +title: NIP-86 membership claims +audience: developer +--- + +# NIP-86 membership claims + +bind.ws implements the claim management methods in [NIPs proposal #2408](https://github.com/nostr-protocol/nips/pull/2408), pinned to [dcf5af03aacd5ca9c70c51448e32f60477f6ac34](https://github.com/nostr-protocol/nips/commit/dcf5af03aacd5ca9c70c51448e32f60477f6ac34), checked on 2026-09-04. The proposal is open. Its [NIP-86 text](https://github.com/nostr-protocol/nips/blob/dcf5af03aacd5ca9c70c51448e32f60477f6ac34/86.md) defines caller-chosen NIP-43 invitation codes, not relay ownership. + +## Calls + +These methods use the existing signed HTTP management endpoint: `POST /`, content type `application/nostr+json+rpc`, with NIP-98 authentication. [Scripts and agents](13-scripts-and-agents.md#signing-a-request-nip-98) shows signing and RPC helpers. `supportedmethods` includes all three names. + +| Method | Parameters | Result | +|---|---|---| +| `listclaims` | `[]` | An array of usable code strings, such as `["friends-2026"]` | +| `createclaim` | `["friends-2026"]` | `true` | +| `deleteclaim` | `["friends-2026"]` | `true` | + +The result is wrapped in `{ "result": ... }`. Invalid calls return HTTP 400 with `{ "error": "invalid: ..." }`; an existing code returns a `duplicate:` error. A missing or bad signature returns 401, and insufficient permission returns 403. + +For the `rpc` helper in Scripts and agents: + +```js +await rpc("createclaim", "friends-2026"); // { result: true } +await rpc("listclaims"); // { result: ["friends-2026"] } +await rpc("deleteclaim", "friends-2026"); // { result: true } +``` + +## Existing invitation rules + +The proposal does not specify expiry, use limits or a code alphabet. bind.ws keeps its invitation constraints: + +| Rule | Behavior | +|---|---| +| Chosen code | Exactly one string, 4 to 64 ASCII letters, digits, dashes or underscores; case-sensitive. An existing record, even expired or exhausted, prevents creation until revoked or cleaned up. | +| Lifetime | Three days from creation, using the existing default. The expiry second itself is still valid. | +| Uses | Unlimited until expiry or revocation, using the existing default. | +| Listing | At most 200 usable codes, newest first with code as the tie breaker. Expired, exhausted and revoked codes are absent. Creator and liveness filters run before the cap. There is no pagination in this proposal. | +| Permission | Owners and moderators manage all codes. Plain members manage only their own codes when `memberInvites.depth` is positive. Creation also requires room in the shared live-invite quota and tree depth. | +| Deletion | Revokes the shared invite record. Owners and moderators receive `true` even if it is absent. Members receive 403 for another person's or an unknown code. | +| Audit | Successful creation and deletion use the normal moderation log, with the method name and code. Listing is not logged. | + +`createinvite(ttlSeconds, maxUses, note)` remains the API for a generated code, custom lifetime and use count. `listinvites` keeps its existing detailed records, including expired and exhausted entries, and `revokeinvite` keeps its boolean indicating whether a row existed. Codes created by these methods or NIP-29 kind 9009 appear in `listclaims` while usable; either revocation API invalidates them. All issuance paths count toward the same member quota. + +## Joining and compatibility + +There is one `invites` table and one member tree. A code created through `createclaim` works with a NIP-43 kind-28934 join request using `["claim", "friends-2026"]`, a NIP-29 join using a `code` tag, or the HTTP invite link `https:///invite/friends-2026`. Joining records the issuer as `invitedBy`. Existing bans, signature checks and join behavior apply. Revoking a code prevents future joins; it does not remove existing members. + +The pinned proposal replaces NIP-43's old kind-28935 invite-request section with NIP-86 `createclaim`. bind.ws did not generate those kind-28935 replies. No legacy issuer is removed or added, and existing invite management and join clients continue to work. + +The bind.ws `claim` method still assigns ownership of an unclaimed relay. `createclaim` requires invitation permission on an owned relay and never assigns ownership. No role assignment or broader NIP-29 permission changes are part of these methods. diff --git a/src/invites.ts b/src/invites.ts index e80d641..33b7410 100644 --- a/src/invites.ts +++ b/src/invites.ts @@ -23,7 +23,7 @@ const DAY = 86400; export const CODE_RE = /^[a-zA-Z0-9_-]{4,64}$/; // mintInvite creates an invite. A code may be chosen by the caller (NIP-29 -// create-invite does); it must be unused. Returns the invite or a reason. +// create-invite and NIP-86 createclaim do); it must be unused. Returns the invite or a reason. export function mintInvite(sql: SqlStorage, by: string, ttlSecs: number, maxUses: number, note: string, now: number, chosen = ""): Invite | string { if (chosen && !CODE_RE.test(chosen)) return "invalid: an invite code is 4 to 64 letters, digits, dash or underscore"; if (chosen && sql.exec(`SELECT 1 FROM invites WHERE code=?`, chosen).toArray().length) return "duplicate: that invite code exists"; @@ -39,6 +39,12 @@ export function listInvites(sql: SqlStorage, now: number): Invite[] { return sql.exec(`SELECT * FROM invites ORDER BY created_at DESC LIMIT 200`).toArray(); } +// listClaims returns usable NIP-43 codes, scoped before the result cap so +// another creator's invites and dead codes cannot hide a member's claims. +export function listClaims(sql: SqlStorage, now: number, by = ""): string[] { + return sql.exec<{ code: string }>(`SELECT code FROM invites WHERE expires_at>=? AND (max_uses=0 OR uses i.code); +} + export function revokeInvite(sql: SqlStorage, code: string): boolean { return sql.exec(`DELETE FROM invites WHERE code=?`, code).rowsWritten > 0; } diff --git a/src/manage.ts b/src/manage.ts index f6ed79b..a42eed5 100644 --- a/src/manage.ts +++ b/src/manage.ts @@ -8,7 +8,7 @@ // the table, so a method is registered in one place. import { now } from "./event.ts"; import type { Relay } from "./relay.ts"; -import { inviteCreator, listInvites, memberInviteGate, mintInvite, revokeInvite } from "./invites.ts"; +import { CODE_RE, inviteCreator, listClaims, listInvites, memberInviteGate, mintInvite, revokeInvite } from "./invites.ts"; import { descriptor, type Blob } from "./blossom.ts"; import { badBlockedWord, blockedWords, policyPatch, type Policy, type Settings } from "./settings.ts"; import { applyConfig, exportConfig, parseConfig, planConfig } from "./config.ts"; @@ -54,6 +54,8 @@ interface Method { action: Action | "open"; // Changes nothing, so it is not written to the moderation log. reads?: true; + // Plain members may manage their own invitations when the tree is open. + ownInvites?: true; run: (c: Call) => Response | Promise; } @@ -221,7 +223,7 @@ export const METHODS: Record = { listmembers: { action: "read", reads: true, run: listMembers }, listpeople: { action: "read", reads: true, run: listMembers }, createinvite: { - action: "invites", + action: "invites", ownInvites: true, run: ({ relay, s, t, caller, role, str, num, reply }) => { if (role === "member") { const gate = memberInviteGate(s, relay.sql, caller, t); @@ -232,19 +234,49 @@ export const METHODS: Record = { }, }, listinvites: { - action: "read", reads: true, + action: "read", reads: true, ownInvites: true, run: ({ relay, t, caller, role, reply }) => { const all = listInvites(relay.sql, t); return reply({ result: role === "member" ? all.filter((i) => i.created_by === caller) : all }); }, }, revokeinvite: { - action: "invites", + action: "invites", ownInvites: true, run: ({ relay, caller, role, str, reply }) => { if (role === "member" && inviteCreator(relay.sql, str(0)) !== caller) return reply({ error: "restricted: not your invite" }, 403); return reply({ result: revokeInvite(relay.sql, str(0)) }); }, }, + // NIP-86 proposal #2408, dcf5af03aacd5ca9c70c51448e32f60477f6ac34. + // These codes join members; the open claim method above assigns ownership. + listclaims: { + action: "invites", reads: true, ownInvites: true, + run: ({ relay, t, caller, role, params, reply }) => { + if (params.length !== 0) return reply({ error: "invalid: listclaims takes no parameters" }, 400); + return reply({ result: listClaims(relay.sql, t, role === "member" ? caller : "") }); + }, + }, + createclaim: { + action: "invites", ownInvites: true, + run: ({ relay, s, t, caller, role, params, str, reply }) => { + if (params.length !== 1 || !CODE_RE.test(str(0))) return reply({ error: "invalid: give one invite code of 4 to 64 letters, digits, dash or underscore" }, 400); + if (role === "member") { + const gate = memberInviteGate(s, relay.sql, caller, t); + if (gate) return reply({ error: gate }, 403); + } + const inv = mintInvite(relay.sql, caller, 0, 0, "", t, str(0)); + return typeof inv === "string" ? reply({ error: inv }, 400) : reply({ result: true }); + }, + }, + deleteclaim: { + action: "invites", ownInvites: true, + run: ({ relay, caller, role, params, str, reply }) => { + if (params.length !== 1 || !CODE_RE.test(str(0))) return reply({ error: "invalid: give one invite code of 4 to 64 letters, digits, dash or underscore" }, 400); + if (role === "member" && inviteCreator(relay.sql, str(0)) !== caller) return reply({ error: "restricted: not your invite" }, 403); + revokeInvite(relay.sql, str(0)); + return reply({ result: true }); + }, + }, removesubtree: { action: "members", run: async ({ relay, s, str, hex64, reply, outranks }) => { @@ -761,7 +793,7 @@ export async function manage(relay: Relay, req: Request): Promise { if (method === "successionstatus" && p.succession && p.succession.heir === caller) return reply({ result: await relay.succession.status() }); // A plain member reaches their own invites when the owner opened the // invite tree (memberInvites); the invite methods keep them to their own. - const ownInvites = role === "member" && p.memberInvites.depth > 0 && (method === "createinvite" || method === "listinvites" || method === "revokeinvite"); + const ownInvites = role === "member" && p.memberInvites.depth > 0 && m.ownInvites; if (role === "owner") void relay.succession.seen(caller); if (!ownInvites && !can(role, m.action)) { const why = role === "moderator" ? "restricted: moderators cannot do that" : p.owner !== "" ? "restricted: not the relay owner" : s.isLeased() ? "restricted: this is a temporary relay; claim it first" : "restricted: this relay is unclaimed"; diff --git a/test/object/exposure.test.ts b/test/object/exposure.test.ts index b3c565c..4ae10aa 100644 --- a/test/object/exposure.test.ts +++ b/test/object/exposure.test.ts @@ -81,6 +81,22 @@ function doors(f: Fixture): { path: string; host?: string; method?: string; gate const leaks = (text: string, secrets: string[]) => secrets.filter((s) => text.includes(s)); describe("the read rule at every door", () => { + it("keeps membership claim management behind authentication and invitation permission", async () => { + const host = "exposure-claims.bind.ws"; + const owner = generateSecretKey(); + const outsider = generateSecretKey(); + await rpc(host, owner, "claim"); + await rpc(host, owner, "createclaim", "private-invite"); + for (const method of ["listclaims", "createclaim", "deleteclaim"]) { + const params = method === "listclaims" ? [] : ["private-invite"]; + expect((await rpc(host, null, method, ...params)).status).toBe(401); + const denied = await rpc(host, outsider, method, ...params); + expect(denied.status).toBe(403); + expect(JSON.stringify(denied)).not.toContain("private-invite"); + } + expect((await rpc(host, owner, "listclaims")).result).toEqual(["private-invite"]); + }); + it("keeps Git storage inventory owner-only at the management door", async () => { const host = "exposure-git-storage.bind.ws"; const owner = generateSecretKey(); diff --git a/test/object/invites.test.ts b/test/object/invites.test.ts new file mode 100644 index 0000000..efcbbe3 --- /dev/null +++ b/test/object/invites.test.ts @@ -0,0 +1,201 @@ +// NIP-86 membership invite claims: management, member invite-tree policy, +// and the existing HTTP/NIP-43 admission paths. +import { env, runInDurableObject } from "cloudflare:test"; +import { describe, it, expect } from "vitest"; +import { generateSecretKey } from "nostr-tools/pure"; +import type { Relay } from "../../src/relay.ts"; +import { checkInvite, listClaims } from "../../src/invites.ts"; +import { now, ev, rpc, post, pk } from "../helpers/relay.ts"; +import { WS } from "../helpers/ws.ts"; + +describe("NIP-86 membership claim management", () => { + it("creates, lists, deletes, and audits membership claims while keeping ownership separate", async () => { + const host = "claims.bind.ws"; + const owner = generateSecretKey(); + const mod = generateSecretKey(); + const stranger = generateSecretKey(); + expect((await rpc(host, stranger, "createclaim", "not-ownership")).status).toBe(403); + await rpc(host, owner, "claim"); + + for (const code of ["abc", "bad space", "\u00fcmlaut", "x".repeat(65), "ok_code"]) { + const r = await rpc(host, owner, "createclaim", code); + expect(r.status, code).toBe(code === "ok_code" ? 200 : 400); + } + expect((await rpc(host, owner, "createclaim", "ok_code")).status).toBe(400); + expect((await rpc(host, owner, "createclaim", "another_code")).result).toBe(true); + expect((await rpc(host, owner, "listclaims", "unexpected")).status).toBe(400); + expect((await rpc(host, owner, "createclaim")).status).toBe(400); + expect((await rpc(host, owner, "createclaim", null)).status).toBe(400); + expect((await rpc(host, owner, "createclaim", { code: "object_code" })).status).toBe(400); + for (const params of [[], [null], [1234], ["abc"], ["ok_code", "extra"]]) { + expect((await rpc(host, owner, "deleteclaim", ...params)).status).toBe(400); + } + expect(((await rpc(host, owner, "listclaims")).result as string[]).sort()).toEqual(["another_code", "ok_code"]); + const invite = (await rpc(host, owner, "listinvites")).result.find((i: any) => i.code === "ok_code"); + expect(invite.expires_at - invite.created_at).toBe(3 * 86400); + expect(invite).toMatchObject({ max_uses: 0, uses: 0, created_by: pk(owner), note: "" }); + expect((await rpc(host, owner, "supportedmethods")).result).toEqual(expect.arrayContaining(["claim", "listclaims", "createclaim", "deleteclaim"])); + + // The claim is usable through the public HTTP admission door and is not + // the relay ownership claim method. + expect((await post(host, stranger, "/api/invites/claim", { code: "ok_code" })).body.status).toBe("joined"); + expect((await rpc(host, owner, "listclaims")).result).toContain("ok_code"); + // The ownership claim remains its own, idempotent management method. + expect((await rpc(host, owner, "claim")).result).toMatchObject({ claimed: true, owner: pk(owner) }); + expect((await rpc(host, stranger, "claim")).status).toBe(403); + + await rpc(host, owner, "setmember", pk(mod), { role: "moderator" }); + expect((await rpc(host, mod, "listclaims")).result).toEqual(expect.arrayContaining(["another_code", "ok_code"])); + expect((await rpc(host, mod, "deleteclaim", "missing_code")).result).toBe(true); + expect((await rpc(host, mod, "deleteclaim", "ok_code")).result).toBe(true); + expect((await rpc(host, owner, "listclaims")).result).not.toContain("ok_code"); + expect((await rpc(host, mod, "createclaim", "moderator-code")).result).toBe(true); + expect((await rpc(host, owner, "revokeinvite", "moderator-code")).result).toBe(true); + + const deleted = generateSecretKey(); + const ws = await WS.connect(host); + expect((await rpc(host, owner, "createclaim", "deleted_code")).result).toBe(true); + expect((await rpc(host, owner, "deleteclaim", "deleted_code")).result).toBe(true); + expect((await post(host, deleted, "/api/invites/claim", { code: "deleted_code" })).body.error).toBe("invite_invalid"); + await ws.auth(deleted, host); + expect((await ws.ok(ev(deleted, 28934, "", [["-"], ["claim", "deleted_code"]]))).msg).toMatch(/invalid invite code/); + + const audit = (await rpc(host, owner, "listaudit")).result as any[]; + expect(audit.some((x) => x.action === "createclaim" && x.target === "another_code")).toBe(true); + expect(audit.some((x) => x.action === "deleteclaim" && x.target === "ok_code")).toBe(true); + const beforeRead = audit.length; + expect((await rpc(host, owner, "listclaims")).status).toBe(200); + expect(((await rpc(host, owner, "listaudit")).result as any[]).length).toBe(beforeRead); + }); + + it("filters expired and exhausted claims before applying the 200-row limit", async () => { + const host = "claimfilter.bind.ws"; + const owner = generateSecretKey(); + await rpc(host, owner, "claim"); + const t = now(); + await runInDurableObject(env.RELAY.getByName("claimfilter"), (r: Relay) => { + // Recent expired rows remain in the invite table for normal invite + // cleanup, so this also exercises listclaims' live predicate directly. + 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(?,?,?,?,?,?,?)`, + `expired_${i}`, pk(owner), t - i, t - 1, 0, 0, "", + ); + } + 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(?,?,?,?,?,?,?)`, + `used_${i}`, pk(owner), t - i, t + 86400, 1, 1, "", + ); + } + r.sql.exec( + `INSERT INTO invites(code,created_by,created_at,expires_at,max_uses,uses,note) VALUES(?,?,?,?,?,?,?)`, + "boundary_code", pk(owner), t, t, 0, 0, "", + ); + 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, ""); + // An explicit clock makes the expiry-second boundary deterministic. + expect(listClaims(r.sql, t)).toEqual(["boundary_code", "live_code"]); + expect(checkInvite(r.sql, "boundary_code", t)).toBe("ok"); + expect(listClaims(r.sql, t + 1)).toEqual(["live_code"]); + expect(checkInvite(r.sql, "boundary_code", t + 1)).toBe("invite_expired"); + r.sql.exec(`DELETE FROM invites WHERE code=?`, "boundary_code"); + }); + const claims = (await rpc(host, owner, "listclaims")).result as string[]; + expect(claims).toEqual(["live_code"]); + const guest = generateSecretKey(); + expect((await post(host, guest, "/api/invites/claim", { code: "expired_0" })).body.error).toBe("invite_expired"); + expect((await post(host, guest, "/api/invites/claim", { code: "used_0" })).body.error).toBe("invite_exhausted"); + expect((await rpc(host, owner, "createclaim", "expired_0")).error).toMatch(/^duplicate:/); + }); + + it("isolates member claims and enforces shared quota and depth", async () => { + const host = "memberclaims.bind.ws"; + const owner = generateSecretKey(); + const alice = generateSecretKey(); + const bob = generateSecretKey(); + const stranger = generateSecretKey(); + await rpc(host, owner, "claim"); + await rpc(host, owner, "setpolicy", { writes: "allowlist", memberInvites: { depth: 2, quota: 2 } }); + await rpc(host, owner, "setmember", pk(alice), {}); + await rpc(host, owner, "setmember", pk(bob), {}); + + expect((await rpc(host, alice, "listclaims", "unexpected")).status).toBe(400); + expect((await rpc(host, alice, "createclaim", "four_code", "extra")).status).toBe(400); + const aliceInvite = (await rpc(host, alice, "createinvite")).result; + expect(aliceInvite.max_uses).toBe(0); + expect(aliceInvite.expires_at - aliceInvite.created_at).toBe(3 * 86400); + expect((await rpc(host, alice, "createclaim", "alice_one")).result).toBe(true); + expect((await rpc(host, alice, "createinvite")).status).toBe(403); + expect((await rpc(host, alice, "createclaim", "alice_three")).status).toBe(403); + const aliceClaims = (await rpc(host, alice, "listclaims")).result as string[]; + expect(aliceClaims.sort()).toEqual([aliceInvite.code, "alice_one"].sort()); + expect((await rpc(host, bob, "listclaims")).result).toEqual([]); + expect((await rpc(host, bob, "deleteclaim", "alice_one")).status).toBe(403); + expect((await rpc(host, stranger, "listclaims")).status).toBe(403); + + // A child may claim over NIP-43; the invitation creator remains Alice. + const ws = await WS.connect(host); + expect((await ws.ok(ev(stranger, 28934, "", [["-"], ["claim", "alice_one"]]))).msg).toMatch(/^auth-required/); + await ws.auth(stranger, host); + expect((await ws.ok(ev(stranger, 28934, "", [["-"], ["claim", "alice_one"]]))).ok).toBe(true); + const member = (await rpc(host, owner, "listmembers")).result.members.find((m: any) => m.pubkey === pk(stranger)); + expect(member.invited_by).toBe(pk(alice)); + expect((await rpc(host, stranger, "createclaim", "stranger_code")).status).toBe(403); + expect((await rpc(host, alice, "deleteclaim", "alice_one")).result).toBe(true); + expect((await rpc(host, alice, "deleteclaim", "unknown_code")).status).toBe(403); + // Deletion through either API frees exactly one slot in the same quota. + expect((await rpc(host, alice, "createclaim", "alice_two")).result).toBe(true); + expect((await rpc(host, alice, "createclaim", "alice_three")).status).toBe(403); + expect((await rpc(host, alice, "revokeinvite", aliceInvite.code)).result).toBe(true); + expect((await rpc(host, alice, "createclaim", "alice_three")).result).toBe(true); + await rpc(host, owner, "setpolicy", { memberInvites: { depth: 0, quota: 0 } }); + for (const method of ["createclaim", "listclaims", "deleteclaim"]) expect((await rpc(host, alice, method, ...(method === "listclaims" ? [] : ["alice_three"]))).status).toBe(403); + }); + + it("scopes live claims before the cap and limits the administrative listing", async () => { + const host = "claimscope.bind.ws"; + const owner = generateSecretKey(); + const member = generateSecretKey(); + await rpc(host, owner, "claim"); + await rpc(host, owner, "setmember", pk(member)); + await rpc(host, owner, "setpolicy", { memberInvites: { depth: 2, quota: 2 } }); + await rpc(host, member, "createclaim", "member-code"); + await runInDurableObject(env.RELAY.getByName("claimscope"), (r: Relay) => { + const t = now(); + r.sql.exec(`UPDATE invites SET created_at=? WHERE code=?`, t - 1000, "member-code"); + 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, ""); + }); + expect((await rpc(host, member, "listclaims")).result).toEqual(["member-code"]); + const all = (await rpc(host, owner, "listclaims")).result as string[]; + expect(all).toHaveLength(200); + expect(all.every((code) => code.startsWith("owner_"))).toBe(true); + }); + + it("shares limited invites with NIP-43 and NIP-29 joins, exhaustion, and bans", async () => { + const host = "claimjoins.bind.ws"; + const owner = generateSecretKey(); + const first = generateSecretKey(); + const second = generateSecretKey(); + await rpc(host, owner, "claim"); + await rpc(host, owner, "setpolicy", { writes: "allowlist" }); + const limited = (await rpc(host, owner, "createinvite", 3600, 1, "single-use")).result; + expect((await rpc(host, owner, "listclaims")).result).toEqual([limited.code]); + const ws = await WS.connect(host); + await ws.auth(first, host); + expect((await ws.ok(ev(first, 28934, "", [["-"], ["claim", limited.code]]))).ok).toBe(true); + expect((await rpc(host, owner, "listclaims")).result).toEqual([]); + expect((await rpc(host, owner, "listinvites")).result[0]).toMatchObject({ code: limited.code, uses: 1 }); + expect((await post(host, second, "/api/invites/claim", { code: limited.code })).body.error).toBe("invite_exhausted"); + await ws.auth(second, host); + expect((await ws.ok(ev(second, 28934, "", [["-"], ["claim", limited.code]]))).msg).toMatch(/used up/); + expect((await rpc(host, owner, "deleteclaim", limited.code)).result).toBe(true); + expect((await rpc(host, owner, "revokeinvite", limited.code)).result).toBe(false); + await rpc(host, owner, "createclaim", "group-code"); + expect((await ws.ok(ev(second, 9021, "", [["h", "claimjoins"], ["code", "group-code"]]))).ok).toBe(true); + const banned = generateSecretKey(); + await rpc(host, owner, "banpubkey", pk(banned)); + expect((await post(host, banned, "/api/invites/claim", { code: "group-code" })).status).toBe(403); + expect((await rpc(host, banned, "createclaim", "banned-code")).status).toBe(403); + expect((await rpc(host, owner, "getpolicy")).result.owner).toBe(pk(owner)); + }); +});