clone

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

invites: NIP-86 membership claims share existing invitations (#21)

FelineStateMachine committed 6d98cb7
Standard listclaims, createclaim and deleteclaim reuse the invite store,
member permissions, quotas, expiry and audit. Existing ownership and invite
APIs retain their behavior. Pinned proposal documentation and integration
tests cover management, visibility and joining through every admission path.
commit
6d98cb7a360e46460718a8fe2ffdae72db0fe959
parent
03f39d463314656de34e898bda89e36157b41ed1
author
FelineStateMachine <dami.etoile@gmail.com>
9 files changed, +322 -7
MREADME.md1+
Mdocs/03-people-and-groups.md2+
Mdocs/13-scripts-and-agents.md5+-
Mdocs/14-http-reference.md2+
Adocs/24-nip86-claims.md52++++++++
Msrc/invites.ts8+-
Msrc/manage.ts42++++++-
Mtest/object/exposure.test.ts16++
Atest/object/invites.test.ts201++++++++++++++++++++++++++++++
M README.md +1 -0
M docs/03-people-and-groups.md +2 -0
M docs/13-scripts-and-agents.md +4 -1
@@ -87,7 +87,8 @@ The bridge takes the same header. `POST /events` answers `{ event_id, accepted,
8787 - `listpresets`: preset names, descriptions and whether one needs a source.8888 - `listmembers`, `listpeople`, `listallowedpubkeys`: the member list in three shapes.8989 - `listbannedpubkeys`, `listbannedevents`, `listblockedips`: the bans.90-- `listinvites`: live invites; a member under the invite rule sees only their own.90+- `listinvites`: detailed invite records, including expired and exhausted entries; a member under the invite rule sees only their own.91+- `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.9192 - `listrecentevents [limit]`: the newest events.9293 - `listallowedkinds`, `listblockedkinds`, `listretention`: the kind and keep-for rules.9394 - `listblobs`, `listreports`: files and the reports queue.
@@ -115,6 +116,8 @@ The bridge takes the same header. `POST /events` answers `{ event_id, accepted,
115116 116117 - `createinvite ttlSeconds maxUses note`: mint a code. A member under the invite rule may mint within their quota.117118 - `revokeinvite code`.119+- `createclaim code`: mint a chosen code with the default three-day lifetime and unlimited uses; returns `true`. The same member quota and depth apply.120+- `deleteclaim code`: revoke a code from the shared invite store; returns `true`. Members may only delete their own codes.118121 119122 **Reports**120123
M docs/14-http-reference.md +2 -0
A docs/24-nip86-claims.md +52 -0
@@ -0,0 +1,52 @@
1+---2+title: NIP-86 membership claims3+audience: developer4+---5+6+# NIP-86 membership claims7+8+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.9+10+## Calls11+12+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.13+14+| Method | Parameters | Result |15+|---|---|---|16+| `listclaims` | `[]` | An array of usable code strings, such as `["friends-2026"]` |17+| `createclaim` | `["friends-2026"]` | `true` |18+| `deleteclaim` | `["friends-2026"]` | `true` |19+20+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.21+22+For the `rpc` helper in Scripts and agents:23+24+```js25+await rpc("createclaim", "friends-2026"); // { result: true }26+await rpc("listclaims"); // { result: ["friends-2026"] }27+await rpc("deleteclaim", "friends-2026"); // { result: true }28+```29+30+## Existing invitation rules31+32+The proposal does not specify expiry, use limits or a code alphabet. bind.ws keeps its invitation constraints:33+34+| Rule | Behavior |35+|---|---|36+| 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. |37+| Lifetime | Three days from creation, using the existing default. The expiry second itself is still valid. |38+| Uses | Unlimited until expiry or revocation, using the existing default. |39+| 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. |40+| 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. |41+| 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. |42+| Audit | Successful creation and deletion use the normal moderation log, with the method name and code. Listing is not logged. |43+44+`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.45+46+## Joining and compatibility47+48+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://<relay>/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.49+50+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.51+52+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.
M src/invites.ts +7 -1
@@ -23,7 +23,7 @@ const DAY = 86400;
2323 export const CODE_RE = /^[a-zA-Z0-9_-]{4,64}$/;2424 2525 // mintInvite creates an invite. A code may be chosen by the caller (NIP-2926-// create-invite does); it must be unused. Returns the invite or a reason.26+// create-invite and NIP-86 createclaim do); it must be unused. Returns the invite or a reason.2727 export function mintInvite(sql: SqlStorage, by: string, ttlSecs: number, maxUses: number, note: string, now: number, chosen = ""): Invite | string {2828 if (chosen && !CODE_RE.test(chosen)) return "invalid: an invite code is 4 to 64 letters, digits, dash or underscore";2929 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[] {
3939 return sql.exec<Invite>(`SELECT * FROM invites ORDER BY created_at DESC LIMIT 200`).toArray();4040 }4141 42+// listClaims returns usable NIP-43 codes, scoped before the result cap so43+// another creator's invites and dead codes cannot hide a member's claims.44+export function listClaims(sql: SqlStorage, now: number, by = ""): string[] {45+ return sql.exec<{ code: string }>(`SELECT code FROM invites WHERE expires_at>=? AND (max_uses=0 OR uses<max_uses)${by ? " AND created_by=?" : ""} ORDER BY created_at DESC, code LIMIT 200`, now, ...(by ? [by] : [])).toArray().map((i) => i.code);46+}47+4248 export function revokeInvite(sql: SqlStorage, code: string): boolean {4349 return sql.exec(`DELETE FROM invites WHERE code=?`, code).rowsWritten > 0;4450 }
M src/manage.ts +37 -5
@@ -8,7 +8,7 @@
88 // the table, so a method is registered in one place.99 import { now } from "./event.ts";1010 import type { Relay } from "./relay.ts";11-import { inviteCreator, listInvites, memberInviteGate, mintInvite, revokeInvite } from "./invites.ts";11+import { CODE_RE, inviteCreator, listClaims, listInvites, memberInviteGate, mintInvite, revokeInvite } from "./invites.ts";1212 import { descriptor, type Blob } from "./blossom.ts";1313 import { badBlockedWord, blockedWords, policyPatch, type Policy, type Settings } from "./settings.ts";1414 import { applyConfig, exportConfig, parseConfig, planConfig } from "./config.ts";
@@ -54,6 +54,8 @@ interface Method {
5454 action: Action | "open";5555 // Changes nothing, so it is not written to the moderation log.5656 reads?: true;57+ // Plain members may manage their own invitations when the tree is open.58+ ownInvites?: true;5759 run: (c: Call) => Response | Promise<Response>;5860 }5961
@@ -221,7 +223,7 @@ export const METHODS: Record<string, Method> = {
221223 listmembers: { action: "read", reads: true, run: listMembers },222224 listpeople: { action: "read", reads: true, run: listMembers },223225 createinvite: {224- action: "invites",226+ action: "invites", ownInvites: true,225227 run: ({ relay, s, t, caller, role, str, num, reply }) => {226228 if (role === "member") {227229 const gate = memberInviteGate(s, relay.sql, caller, t);
@@ -232,19 +234,49 @@ export const METHODS: Record<string, Method> = {
232234 },233235 },234236 listinvites: {235- action: "read", reads: true,237+ action: "read", reads: true, ownInvites: true,236238 run: ({ relay, t, caller, role, reply }) => {237239 const all = listInvites(relay.sql, t);238240 return reply({ result: role === "member" ? all.filter((i) => i.created_by === caller) : all });239241 },240242 },241243 revokeinvite: {242- action: "invites",244+ action: "invites", ownInvites: true,243245 run: ({ relay, caller, role, str, reply }) => {244246 if (role === "member" && inviteCreator(relay.sql, str(0)) !== caller) return reply({ error: "restricted: not your invite" }, 403);245247 return reply({ result: revokeInvite(relay.sql, str(0)) });246248 },247249 },250+ // NIP-86 proposal #2408, dcf5af03aacd5ca9c70c51448e32f60477f6ac34.251+ // These codes join members; the open claim method above assigns ownership.252+ listclaims: {253+ action: "invites", reads: true, ownInvites: true,254+ run: ({ relay, t, caller, role, params, reply }) => {255+ if (params.length !== 0) return reply({ error: "invalid: listclaims takes no parameters" }, 400);256+ return reply({ result: listClaims(relay.sql, t, role === "member" ? caller : "") });257+ },258+ },259+ createclaim: {260+ action: "invites", ownInvites: true,261+ run: ({ relay, s, t, caller, role, params, str, reply }) => {262+ 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);263+ if (role === "member") {264+ const gate = memberInviteGate(s, relay.sql, caller, t);265+ if (gate) return reply({ error: gate }, 403);266+ }267+ const inv = mintInvite(relay.sql, caller, 0, 0, "", t, str(0));268+ return typeof inv === "string" ? reply({ error: inv }, 400) : reply({ result: true });269+ },270+ },271+ deleteclaim: {272+ action: "invites", ownInvites: true,273+ run: ({ relay, caller, role, params, str, reply }) => {274+ 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);275+ if (role === "member" && inviteCreator(relay.sql, str(0)) !== caller) return reply({ error: "restricted: not your invite" }, 403);276+ revokeInvite(relay.sql, str(0));277+ return reply({ result: true });278+ },279+ },248280 removesubtree: {249281 action: "members",250282 run: async ({ relay, s, str, hex64, reply, outranks }) => {
@@ -761,7 +793,7 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
761793 if (method === "successionstatus" && p.succession && p.succession.heir === caller) return reply({ result: await relay.succession.status() });762794 // A plain member reaches their own invites when the owner opened the763795 // invite tree (memberInvites); the invite methods keep them to their own.764- const ownInvites = role === "member" && p.memberInvites.depth > 0 && (method === "createinvite" || method === "listinvites" || method === "revokeinvite");796+ const ownInvites = role === "member" && p.memberInvites.depth > 0 && m.ownInvites;765797 if (role === "owner") void relay.succession.seen(caller);766798 if (!ownInvites && !can(role, m.action)) {767799 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";
M test/object/exposure.test.ts +16 -0
A test/object/invites.test.ts +201 -0
@@ -0,0 +1,201 @@
1+// NIP-86 membership invite claims: management, member invite-tree policy,2+// and the existing HTTP/NIP-43 admission paths.3+import { env, runInDurableObject } from "cloudflare:test";4+import { describe, it, expect } from "vitest";5+import { generateSecretKey } from "nostr-tools/pure";6+import type { Relay } from "../../src/relay.ts";7+import { checkInvite, listClaims } from "../../src/invites.ts";8+import { now, ev, rpc, post, pk } from "../helpers/relay.ts";9+import { WS } from "../helpers/ws.ts";10+11+describe("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");19+20+ 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"]));38+39+ // 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);46+47+ 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);54+55+ 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/);62+63+ 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+ });70+71+ 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+ });110+111+ 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), {});121+122+ 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);135+136+ // 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+ });154+155+ 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+ });173+174+ 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+});