Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions docs/03-people-and-groups.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion docs/13-scripts-and-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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**

Expand Down
2 changes: 2 additions & 0 deletions docs/14-http-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
52 changes: 52 additions & 0 deletions docs/24-nip86-claims.md
Original file line number Diff line number Diff line change
@@ -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://<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.

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.
8 changes: 7 additions & 1 deletion src/invites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -39,6 +39,12 @@ export function listInvites(sql: SqlStorage, now: number): Invite[] {
return sql.exec<Invite>(`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<max_uses)${by ? " AND created_by=?" : ""} ORDER BY created_at DESC, code LIMIT 200`, now, ...(by ? [by] : [])).toArray().map((i) => i.code);
}

export function revokeInvite(sql: SqlStorage, code: string): boolean {
return sql.exec(`DELETE FROM invites WHERE code=?`, code).rowsWritten > 0;
}
Expand Down
42 changes: 37 additions & 5 deletions src/manage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Response>;
}

Expand Down Expand Up @@ -221,7 +223,7 @@ export const METHODS: Record<string, Method> = {
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);
Expand All @@ -232,19 +234,49 @@ export const METHODS: Record<string, Method> = {
},
},
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 }) => {
Expand Down Expand Up @@ -761,7 +793,7 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
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";
Expand Down
16 changes: 16 additions & 0 deletions test/object/exposure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading