clone

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

relay: the relay's own profile, NIP-43 role definitions and requests, one publish path for members

Dami committed f3e65c8
The identity signs a kind 0 for the self key from the relay's name,
description and icon, re-signed only when they change, and the 33534
role definitions for owner and moderator. Roster member tags carry a role
only when there is one. NIP-43 join (28934, with a claim tag) and leave
(28936) requests are answered like their NIP-29 counterparts and never
stored.

publishRoster and publishGroup become publishMembership, one function
that signs everything derived from the member table: the roster and its
deltas, the put-user and remove-user records, the group state, the role
definitions and the profile. A role change used to refresh the group
without the roster; an import used to refresh the roster without any
deltas. Both paths now go through the same door, and a test walks every
membership path asserting 13534, 39001 and 39002 agree with the table.
commit
f3e65c87f65e24dbc2fff257a1612366a6b668a0
parent
5a898d6f9b671926f1981571b5b0a5b1ef245691
author
Dami <dami.etoile@gmail.com>
8 files changed, +344 -63
Msrc/groups.ts33++++-
Msrc/identity.ts17++-
Msrc/manage.ts29++-
Msrc/relay.ts64+++----
Msrc/settings.ts2+-
Mtest/adopt.test.ts12+-
Atest/nip43.test.ts248++++++++++++++++++++++++++++++
Mtest/relay.test.ts2+-
M src/groups.ts +29 -4
@@ -9,7 +9,7 @@
99 // a moderator moderates whatever the write rule says.1010 import { now, tagValues, type Event } from "./event.ts";1111 import { claimInvite, mintInvite } from "./invites.ts";12-import { KIND_PUT_USER, KIND_REMOVE_USER, type GroupFacts } from "./identity.ts";12+import { KIND_PROFILE, KIND_PUT_USER, KIND_REMOVE_USER, type GroupFacts } from "./identity.ts";1313 import { can, ROLES, type Action } from "./roles.ts";1414 import type { Relay } from "./relay.ts";1515
@@ -21,9 +21,14 @@ export const KIND_CREATE_INVITE = 9009;
2121 export const KIND_PINS = 9010;2222 export const KIND_JOIN = 9021;2323 export const KIND_LEAVE = 9022;24+// NIP-43's own join and leave requests: ephemeral, no h tag, a claim tag25+// carries the invite code.26+export const KIND_NIP43_JOIN = 28934;27+export const KIND_NIP43_LEAVE = 28936;2428 2529 export const isModeration = (kind: number) => kind >= 9000 && kind <= 9020;26-export const isGroupManagement = (kind: number) => isModeration(kind) || kind === KIND_JOIN || kind === KIND_LEAVE;30+export const isNIP43Request = (kind: number) => kind === KIND_NIP43_JOIN || kind === KIND_NIP43_LEAVE;31+export const isGroupManagement = (kind: number) => isModeration(kind) || kind === KIND_JOIN || kind === KIND_LEAVE || isNIP43Request(kind);2732 // The addressable state kinds only the relay writes.2833 export const isGroupState = (kind: number) => kind >= 39000 && kind <= 39003;2934
@@ -33,6 +38,10 @@ const HEX64 = /^[0-9a-f]{64}$/;
3338 export function groupFacts(relay: Relay): GroupFacts {3439 const p = relay.settings.policy;3540 const members = relay.settings.members();41+ // The relay's profile: re-signed only when what it says would change.42+ const profile = JSON.stringify({ name: p.name || relay.slug, about: p.description, ...(p.icon ? { picture: p.icon } : {}) });43+ const row = relay.identity.pubkey ? relay.sql.exec<{ raw: string }>(`SELECT raw FROM events WHERE kind=? AND pubkey=? ORDER BY created_at DESC LIMIT 1`, KIND_PROFILE, relay.identity.pubkey).toArray()[0] : undefined;44+ const current = row ? (JSON.parse(row.raw) as { content: string }).content : undefined;3645 return {3746 id: relay.slug,3847 name: p.name || relay.slug,
@@ -44,6 +53,7 @@ export function groupFacts(relay: Relay): GroupFacts {
4453 admins: members.filter((m) => m.role !== "member").map((m) => ({ pubkey: m.pubkey, role: m.role })),4554 members: p.directoryPublic ? members.map((m) => m.pubkey) : null,4655 roles: ROLES,56+ profile: current === profile ? null : profile,4757 };4858 }4959
@@ -86,6 +96,21 @@ export async function handleGroupEvent(relay: Relay, e: Event): Promise<Result>
8696 if (!(await relay.removeMember(e.pubkey))) return no("invalid: not a member");8797 return OK;8898 }99+ case KIND_NIP43_JOIN: {100+ // Ephemeral, so never stored or fanned out: the answer is the OK.101+ if (s.isAllowed(e.pubkey)) return { ok: true, msg: "duplicate: you are already a member of this relay.", stored: false };102+ const claim = tagValues(e, "claim")[0] ?? "";103+ if (!claim) return no("restricted: a join request needs a claim tag with an invite code.");104+ const r = claimInvite(relay.sql, claim, t);105+ if (r !== "ok") return no({ invite_invalid: "restricted: that is an invalid invite code.", invite_expired: "restricted: that invite code is expired.", invite_exhausted: "restricted: that invite code has been used up." }[r]);106+ const err = await relay.setMember(e.pubkey, { via: "invite " + claim.slice(0, 8) });107+ return err ? no(err) : { ok: true, msg: "info: welcome to " + relay.slug + "!", stored: false };108+ }109+ case KIND_NIP43_LEAVE: {110+ if (s.isOwner(e.pubkey)) return no("restricted: the owner cannot leave; transfer ownership first");111+ if (!(await relay.removeMember(e.pubkey))) return { ok: true, msg: "duplicate: you are not a member of this relay.", stored: false };112+ return { ok: true, msg: "info: access revoked.", stored: false };113+ }89114 case KIND_PUT_USER: {90115 const gate = need("members");91116 if (gate) return no(gate);
@@ -101,7 +126,7 @@ export async function handleGroupEvent(relay: Relay, e: Event): Promise<Result>
101126 const next = wantsModerator ? "moderator" : "member";102127 if ((cur ?? "member") !== next) {103128 s.setRole(target, next);104- await relay.publishGroup(target);129+ await relay.publishMembership({ pubkey: target });105130 }106131 return OK;107132 }
@@ -125,7 +150,7 @@ export async function handleGroupEvent(relay: Relay, e: Event): Promise<Result>
125150 if (about !== undefined) patch.description = about.slice(0, 2000);126151 if (picture !== undefined) patch.icon = picture.slice(0, 2000);127152 s.update(patch);128- await relay.publishGroup();153+ await relay.publishMembership();129154 return OK;130155 }131156 case KIND_DELETE_EVENT: {
M src/identity.ts +13 -4
@@ -3,7 +3,8 @@
33 // for: the NIP-43 membership roster (kind 13534) and its added/removed44 // deltas (kinds 8000/8001), the NIP-29 put-user and remove-user records55 // (9000/9001) and the group's metadata, admins, members and roles6-// (39000-39003). All NIP-70 protected.6+// (39000-39003), the NIP-43 role definitions (33534), and its own profile7+// (kind 0). All but the profile NIP-70 protected.78 import { finalizeEvent, getPublicKey, generateSecretKey, type Event } from "nostr-tools/pure";89 import { bytesToHex, hexToBytes } from "./negentropy.ts";910
@@ -16,6 +17,8 @@ export const KIND_GROUP_METADATA = 39000;
1617 export const KIND_GROUP_ADMINS = 39001;1718 export const KIND_GROUP_MEMBERS = 39002;1819 export const KIND_GROUP_ROLES = 39003;20+export const KIND_ROLE_DEF = 33534;21+export const KIND_PROFILE = 0;1922 2023 export interface GroupFacts {2124 id: string;
@@ -28,6 +31,7 @@ export interface GroupFacts {
2831 admins: { pubkey: string; role: string }[];2932 members: string[] | null; // null: not published3033 roles: { role: string; about: string }[];34+ profile: string | null; // kind 0 content to sign, null when the stored one is current3135 }3236 3337 export class Identity {
@@ -69,7 +73,9 @@ export class Identity {
6973 const at = Math.max(now, this.lastRoster + 1);7074 this.lastRoster = at;7175 this.storage.put("relay-roster-at", at);72- return this.sign(KIND_ROSTER, [["-"], ...members.map((m) => ["member", m.pubkey, m.role])], "", at);76+ // A role is named only when it is one (NIP-43 roles are the 3353477+ // definitions); a plain member has none.78+ return this.sign(KIND_ROSTER, [["-"], ...members.map((m) => (m.role === "member" ? ["member", m.pubkey] : ["member", m.pubkey, m.role]))], "", at);7379 }7480 7581 delta(added: boolean, pubkey: string): Event {
@@ -86,8 +92,9 @@ export class Identity {
8692 }8793 8894 // group signs the addressable NIP-29 state: metadata, admins, members89- // (when published) and roles. Same strictly increasing clock as the roster,90- // so a newer set always replaces an older one.95+ // (when published) and roles, plus the NIP-43 role definitions and, when96+ // it changed, the relay's own profile. Same strictly increasing clock as97+ // the roster, so a newer set always replaces an older one.9198 group(f: GroupFacts, now = Math.floor(Date.now() / 1000)): Event[] {9299 const at = Math.max(now, this.lastGroup + 1);93100 this.lastGroup = at;
@@ -104,6 +111,8 @@ export class Identity {
104111 this.sign(KIND_GROUP_ROLES, [["-"], d, ...f.roles.map((r) => ["role", r.role, r.about])], "", at),105112 ];106113 if (f.members) out.push(this.sign(KIND_GROUP_MEMBERS, [["-"], d, ...f.members.map((p) => ["p", p])], "", at));114+ f.roles.forEach((r, i) => out.push(this.sign(KIND_ROLE_DEF, [["-"], ["d", r.role], ["label", r.role], ["description", r.about], ["order", String(i + 1)]], "", at)));115+ if (f.profile !== null) out.push(this.sign(KIND_PROFILE, [], f.profile, at));107116 return out;108117 }109118 }
M src/manage.ts +19 -10
@@ -94,7 +94,7 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
9494 if (lease && lease.until <= t) return reply({ error: "restricted: this temporary relay has expired" }, 403);9595 s.update(lease ? { owner: caller, lease: null, name: "", description: "" } : { owner: caller });9696 s.upsertMember(caller, { via: "claimed" }, t);97- await relay.publishRoster();97+ await relay.publishMembership();9898 return reply({ result: { owner: caller, claimed: true, ...(lease ? { converted: true } : {}) } });9999 }100100 return reply({ result: { owner: p.owner, claimed: s.isOwner(caller) } }, s.isOwner(caller) ? 200 : 403);
@@ -138,7 +138,7 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
138138 if (typeof clean.maxLimit === "number") clean.maxLimit = Math.min(Math.max(clean.maxLimit as number, 1), 5000);139139 if (typeof clean.maxSubs === "number") clean.maxSubs = Math.min(Math.max(clean.maxSubs as number, 1), 200);140140 s.update(clean);141- await relay.publishGroup();141+ await relay.publishMembership();142142 return reply({ result: s.policy });143143 }144144 case "banpubkey": {
@@ -164,7 +164,7 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
164164 if (err) return reply({ error: err }, 400);165165 if (wantsRole && s.roleOf(pk) !== wantsRole) {166166 s.setRole(pk, wantsRole);167- await relay.publishGroup(pk);167+ await relay.publishMembership({ pubkey: pk });168168 }169169 return reply({ result: s.member(pk) });170170 }
@@ -257,9 +257,19 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
257257 case "exportconfig":258258 return reply({ result: s.exportConfig(relay.slug) });259259 case "importconfig": {260+ // The import replaces the member list wholesale; the records say who261+ // came, who went and who changed role, one delta each.262+ const before = new Map(s.members().map((x) => [x.pubkey, x.role]));260263 const err = s.importConfig(params[0], t);261264 if (err) return reply({ error: err }, 400);262- await relay.publishRoster();265+ const after = new Map(s.members().map((x) => [x.pubkey, x.role]));266+ const changes: { pubkey: string; added?: boolean }[] = [];267+ for (const [pk, role] of after) {268+ if (!before.has(pk)) changes.push({ pubkey: pk, added: true });269+ else if (before.get(pk) !== role) changes.push({ pubkey: pk });270+ }271+ for (const pk of before.keys()) if (!after.has(pk)) changes.push({ pubkey: pk, added: false });272+ await relay.publishMembership(...changes);263273 return reply({ result: s.exportConfig(relay.slug) });264274 }265275 case "deleterelay": {
@@ -327,7 +337,7 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
327337 return reply({ result: s.listKinds("block") });328338 case "resetrules":329339 s.resetRules();330- await relay.publishGroup();340+ await relay.publishMembership();331341 return reply({ result: s.policy });332342 case "pullfrom": {333343 // (url): copy what another relay has that this one lacks. Runs in
@@ -347,21 +357,20 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
347357 if (!hex64(pk)) return reply({ error: "invalid: pubkey must be 64 hex chars" }, 400);348358 const err = s.transferOwner(pk);349359 if (err) return reply({ error: err }, 400);350- await relay.publishRoster();351- await relay.publishGroup(caller);360+ await relay.publishMembership({ pubkey: pk }, { pubkey: caller });352361 return reply({ result: { owner: pk, previous: caller } });353362 }354363 case "changerelayname":355364 s.update({ name: str(0).slice(0, 200) });356- await relay.publishGroup();365+ await relay.publishMembership();357366 return reply({ result: true });358367 case "changerelaydescription":359368 s.update({ description: str(0).slice(0, 2000) });360- await relay.publishGroup();369+ await relay.publishMembership();361370 return reply({ result: true });362371 case "changerelayicon":363372 s.update({ icon: str(0).slice(0, 2000) });364- await relay.publishGroup();373+ await relay.publishMembership();365374 return reply({ result: true });366375 }367376 return reply({ error: "unsupported: " + method }, 400);
M src/relay.ts +27 -37
@@ -21,7 +21,7 @@ import { checkInvite, claimInvite, invitePage, termsPage } from "./invites.ts";
2121 import { verifyNIP98 } from "./manage.ts";2222 import { FAVICON_SVG } from "./ui.ts";2323 import { runPullRound, type PullJob, type PullResult } from "./pull.ts";24-import { groupFacts, handleGroupEvent, isGroupManagement, isGroupState } from "./groups.ts";24+import { groupFacts, handleGroupEvent, isGroupManagement, isGroupState, isNIP43Request } from "./groups.ts";2525 import { KIND_GROUP_MEMBERS } from "./identity.ts";2626 import { SIGNER_JS } from "./gen/signer.ts";2727 import type { Blob } from "./blossom.ts";
@@ -158,30 +158,31 @@ export class Relay extends DurableObject<Env> {
158158 159159 // ---- relay identity and the NIP-43 roster ----160160 161- // publishRoster signs the current member list as kind 13534 and, when a162- // single change is given, the matching 8000/8001 delta, and fans both out.163- async publishRoster(change?: { added: boolean; pubkey: string }) {161+ // publishMembership signs everything the relay vouches for from its member162+ // list and settings, in one place so the NIP-43 roster and the NIP-29 group163+ // can never disagree: the roster (13534) and, for an add or a removal, its164+ // 8000/8001 delta; the NIP-29 put-user or remove-user record for each165+ // change given (a change without `added` is a role change); the group's166+ // metadata, admins, roles and, when the directory is public, members167+ // (39000-39003); the NIP-43 role definitions (33534); and the relay's own168+ // profile (kind 0) when its name, description or icon changed.169+ async publishMembership(...changes: { pubkey: string; added?: boolean }[]) {164170 if (this.settings.policy.owner === "") return;165171 await this.identity.ensure();166172 const t = now();167173 const events: Event[] = [];168- if (change) {169- events.push(this.identity.delta(change.added, change.pubkey));170- events.push(change.added ? this.identity.putUser(this.slug, change.pubkey, this.rolesOf(change.pubkey)) : this.identity.removeUser(this.slug, change.pubkey));174+ for (const c of changes) {175+ if (c.added !== undefined) events.push(this.identity.delta(c.added, c.pubkey));176+ if (c.added === false) events.push(this.identity.removeUser(this.slug, c.pubkey));177+ else events.push(this.identity.putUser(this.slug, c.pubkey, this.rolesOf(c.pubkey)));178+ }179+ const f = groupFacts(this);180+ // A members list that is no longer public is taken down.181+ if (f.members === null) {182+ for (const r of this.sql.exec<{ id: string }>(`SELECT id FROM events WHERE kind=? AND pubkey=?`, KIND_GROUP_MEMBERS, this.identity.pubkey).toArray()) this.store.deleteEvent(r.id);171183 }172184 events.push(this.identity.roster(this.settings.members(), t));173- events.push(...this.groupState(t));174- this.emit(events, t);175- }176-177- // publishGroup refreshes the NIP-29 state after something other than a178- // membership change: a rule, the name, or, with a pubkey, that person's role.179- async publishGroup(roleChanged?: string) {180- if (this.settings.policy.owner === "") return;181- await this.identity.ensure();182- const t = now();183- const events: Event[] = roleChanged ? [this.identity.putUser(this.slug, roleChanged, this.rolesOf(roleChanged))] : [];184- events.push(...this.groupState(t));185+ events.push(...this.identity.group(f, t));185186 this.emit(events, t);186187 }187188
@@ -190,17 +191,6 @@ export class Relay extends DurableObject<Env> {
190191 return r && r !== "member" ? [r] : [];191192 }192193 193- // groupState signs the group's metadata, admins, roles and, when the194- // directory is public, members. A members list that is no longer public is195- // taken down.196- private groupState(t: number): Event[] {197- const f = groupFacts(this);198- if (f.members === null) {199- for (const r of this.sql.exec<{ id: string }>(`SELECT id FROM events WHERE kind=? AND pubkey=?`, KIND_GROUP_MEMBERS, this.identity.pubkey).toArray()) this.store.deleteEvent(r.id);200- }201- return this.identity.group(f, t);202- }203-204194 private emit(events: Event[], t: number) {205195 for (const e of events) {206196 const err = this.store.save(e, t);
@@ -213,14 +203,14 @@ export class Relay extends DurableObject<Env> {
213203 const was = this.settings.isAllowed(pubkey);214204 const err = this.settings.upsertMember(pubkey, patch, now(), force);215205 if (err) return err;216- if (!was) await this.publishRoster({ added: true, pubkey });206+ if (!was) await this.publishMembership({ pubkey, added: true });217207 return "";218208 }219209 220210 async removeMember(pubkey: string): Promise<boolean> {221211 if (!this.settings.removeMember(pubkey)) return false;222212 if (this.settings.policy.reads === "members") this.evict(pubkey, "restricted: this relay only serves its members", false);223- await this.publishRoster({ added: false, pubkey });213+ await this.publishMembership({ pubkey, added: false });224214 return true;225215 }226216
@@ -228,7 +218,7 @@ export class Relay extends DurableObject<Env> {
228218 const was = this.settings.isAllowed(pubkey);229219 this.settings.setBan(pubkey, true, reason, now());230220 this.evict(pubkey, "blocked: you are banned from this relay", true);231- if (was) await this.publishRoster({ added: false, pubkey });221+ if (was) await this.publishMembership({ pubkey, added: false });232222 }233223 234224 // evict closes the door on a pubkey: subscriptions are ended and, for a
@@ -507,7 +497,7 @@ export class Relay extends DurableObject<Env> {
507497 const r = claimInvite(this.sql, code, now());508498 if (r !== "ok") return json({ error: r }, 403);509499 this.settings.upsertMember(auth.pubkey, { via: "invite " + code.slice(0, 8) }, now());510- await this.publishRoster({ added: true, pubkey: auth.pubkey });500+ await this.publishMembership({ pubkey: auth.pubkey, added: true });511501 return json({ status: "joined", role: "member" });512502 }513503
@@ -551,7 +541,7 @@ export class Relay extends DurableObject<Env> {
551541 }552542 const url = new URL(req.url);553543 // Relays claimed before identities existed get theirs on first contact.554- if (this.settings.policy.owner && !this.identity.pubkey) await this.publishRoster();544+ if (this.settings.policy.owner && !this.identity.pubkey) await this.publishMembership();555545 // A blocked address gets no socket and no door that writes or reads556546 // events or files. The page, NIP-11 and management stay reachable, so557547 // an owner who blocked their own address can undo it.
@@ -825,7 +815,7 @@ export class Relay extends DurableObject<Env> {
825815 if (r) return r;826816 }827817 if (e.kind === KIND_REPORT) return this.acceptReport(e);828- if (isGroupManagement(e.kind) && hasTag(e, "h")) return this.acceptGroup(e, conn);818+ if (isGroupManagement(e.kind) && (hasTag(e, "h") || isNIP43Request(e.kind))) return this.acceptGroup(e, conn);829819 return this.accept(e, conn);830820 }831821
@@ -837,7 +827,7 @@ export class Relay extends DurableObject<Env> {
837827 if (reason) return { ok: false, msg: reason, stored: false };838828 if (this.sql.exec(`SELECT 1 FROM events WHERE id=?`, e.id).toArray().length) return { ok: true, msg: ERR_DUPLICATE, stored: false };839829 const r = await handleGroupEvent(this, e);840- if (!r.ok) return r;830+ if (!r.ok || !r.stored) return r;841831 const err = this.store.save(e, t);842832 if (err) return { ok: false, msg: err, stored: false };843833 return { ok: true, msg: "", stored: true };
M src/settings.ts +1 -1
M test/adopt.test.ts +6 -6
@@ -251,7 +251,7 @@ describe("relay identity and NIP-43 roster", () => {
251251 expect(added[2].kind).toBe(8000);252252 expect(added[2].tags).toContainEqual(["p", getPublicKey(bob)]);253253 r = await c.req({ kinds: [13534] });254- expect(r.events[0].tags).toContainEqual(["member", getPublicKey(bob), "member"]);254+ expect(r.events[0].tags).toContainEqual(["member", getPublicKey(bob)]);255255 await rpc(host, owner, "unrulepubkey", getPublicKey(bob));256256 expect((await c.expect("EVENT"))[2].kind).toBe(8001);257257 // Nobody can forge relay-signed kinds: a client-signed 13534 is just another replaceable event by another author.
@@ -485,7 +485,7 @@ describe("configuration export and import", () => {
485485 const rc = await WS.connect(b);486486 await rc.auth(ownerB, b);487487 const r = await rc.req({ kinds: [13534] });488- expect(r.events[0].tags).toContainEqual(["member", m1, "member"]);488+ expect(r.events[0].tags).toContainEqual(["member", m1]);489489 });490490 });491491
@@ -518,8 +518,8 @@ describe("storage: retention and purge", () => {
518518 await runInDurableObject(stub, async (r: Relay) => expect(r.sweepRetention(now())).toBe(1));519519 let st = (await rpc(host, owner, "storagestats")).result;520520 const byKind = Object.fromEntries(st.kinds.map((k: any) => [k.kind, k.n]));521- expect(byKind).toEqual({ 1: 2, 7: 1, 0: 1, 13534: 1, 39000: 1, 39001: 1, 39002: 1, 39003: 1 }); // the roster and group state are the relay's own522- expect(st.events).toBe(9);521+ expect(byKind).toEqual({ 1: 2, 7: 1, 0: 2, 13534: 1, 33534: 2, 39000: 1, 39001: 1, 39002: 1, 39003: 1 }); // the roster, profile, role definitions and group state are the relay's own522+ expect(st.events).toBe(12);523523 expect(st.eventBytes).toBeGreaterThan(0);524524 expect(st.retention).toEqual([{ kind: 7, days: 30 }, { kind: null, days: 100 }]);525525
@@ -527,7 +527,7 @@ describe("storage: retention and purge", () => {
527527 expect((await rpc(host, owner, "purgekind", 1, 10)).result).toEqual({ deleted: 1 });528528 expect((await rpc(host, owner, "purgekind", 7, 0)).result).toEqual({ deleted: 1 });529529 st = (await rpc(host, owner, "storagestats")).result;530- expect(st.events).toBe(7);530+ expect(st.events).toBe(10);531531 532532 // Rules travel with the configuration; removing one is days 0.533533 const cfg = (await rpc(host, owner, "exportconfig")).result;
@@ -540,6 +540,6 @@ describe("storage: retention and purge", () => {
540540 expect((await rpc(host, owner, "purgekind", 13534, 0)).status).toBe(400);541541 await rpc(host, owner, "setretention", null, 1);542542 expect((await rpc(host, owner, "purgekind", null, 0)).result.deleted).toBe(1); // the fresh note; profile, roster and group state stay543- expect((await rpc(host, owner, "storagestats")).result.kinds.map((k: any) => k.kind).sort((a: number, b: number) => a - b)).toEqual([0, 13534, 39000, 39001, 39002, 39003]);543+ expect((await rpc(host, owner, "storagestats")).result.kinds.map((k: any) => k.kind).sort((a: number, b: number) => a - b)).toEqual([0, 13534, 33534, 39000, 39001, 39002, 39003]);544544 });545545 });
A test/nip43.test.ts +248 -0
@@ -0,0 +1,248 @@
1+// NIP-43, the rest of it: the relay's own profile, role definitions, roles2+// in the roster, the NIP-43 join and leave requests, and the promise that3+// the roster and the NIP-29 group never disagree, whichever path changed4+// the members.5+import { SELF } from "cloudflare:test";6+import { describe, it, expect } from "vitest";7+import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";8+import { getToken } from "nostr-tools/nip98";9+10+const now = () => Math.floor(Date.now() / 1000);11+const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()) => finalizeEvent({ kind, content, tags, created_at }, sk);12+const pk = (sk: Uint8Array) => getPublicKey(sk);13+14+async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {15+ const url = `http://${host}/`;16+ const payload = { method, params };17+ const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);18+ const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });19+ return { status: resp.status, ...(await resp.json<any>()) };20+}21+22+const info = async (host: string) => (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();23+24+class WS {25+ private queue: any[][] = [];26+ private waiters: ((m: any[]) => void)[] = [];27+ challenge = "";28+ constructor(public ws: WebSocket) {29+ ws.accept();30+ ws.addEventListener("message", (e) => {31+ const m = JSON.parse(e.data as string);32+ const w = this.waiters.shift();33+ if (w) w(m);34+ else this.queue.push(m);35+ });36+ }37+ static async connect(host: string) {38+ const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });39+ const c = new WS(resp.webSocket!);40+ c.challenge = (await c.expect("AUTH"))[1];41+ return c;42+ }43+ send(...m: unknown[]) {44+ this.ws.send(JSON.stringify(m));45+ }46+ recv(): Promise<any[]> {47+ const m = this.queue.shift();48+ if (m) return Promise.resolve(m);49+ return new Promise((res) => this.waiters.push(res));50+ }51+ async expect(type: string) {52+ const m = await this.recv();53+ expect(m[0], JSON.stringify(m)).toBe(type);54+ return m;55+ }56+ async ok(e: Event) {57+ this.send("EVENT", e);58+ const m = await this.expect("OK");59+ return { ok: m[2] as boolean, msg: m[3] as string };60+ }61+ async auth(sk: Uint8Array, host: string) {62+ this.send("AUTH", ev(sk, 22242, "", [["relay", "ws://" + host], ["challenge", this.challenge]]));63+ const m = await this.expect("OK");64+ expect(m[2], m[3]).toBe(true);65+ }66+ private n = 0;67+ async req(filter: unknown) {68+ const id = "q" + ++this.n;69+ this.send("REQ", id, filter);70+ const events: Event[] = [];71+ for (;;) {72+ const m = await this.recv();73+ if (m[0] === "EVENT" && m[1] === id) events.push(m[2]);74+ else if (m[0] === "EOSE" && m[1] === id) {75+ this.send("CLOSE", id);76+ return events;77+ } else if (m[0] === "CLOSED" && m[1] === id) throw new Error(m[2]);78+ }79+ }80+}81+82+const tagsOf = (e: Event, name: string) => e.tags.filter((t) => t[0] === name);83+84+describe("relay profile and roles", () => {85+ it("signs a kind 0 for the self key, role definitions, and roles in the roster", async () => {86+ const host = "profile.bind.ws";87+ const owner = generateSecretKey();88+ await rpc(host, owner, "claim");89+ const self = (await info(host)).self as string;90+ const c = await WS.connect(host);91+92+ let profile = await c.req({ kinds: [0], authors: [self] });93+ expect(profile.length).toBe(1);94+ expect(JSON.parse(profile[0].content)).toEqual({ name: "profile", about: "" });95+ await rpc(host, owner, "changerelayname", "Pizza");96+ await rpc(host, owner, "setpolicy", { description: "pies", icon: "https://img.example/pizza.png" });97+ profile = await c.req({ kinds: [0], authors: [self] });98+ expect(profile.length).toBe(1);99+ expect(JSON.parse(profile[0].content)).toEqual({ name: "Pizza", about: "pies", picture: "https://img.example/pizza.png" });100+101+ const roles = await c.req({ kinds: [33534], authors: [self] });102+ expect(roles.map((e) => tagsOf(e, "d")[0][1]).sort()).toEqual(["moderator", "owner"]);103+ for (const r of roles) {104+ expect(tagsOf(r, "-").length).toBe(1);105+ expect(tagsOf(r, "label")[0][1]).toBe(tagsOf(r, "d")[0][1]);106+ expect(tagsOf(r, "description")[0][1]).not.toBe("");107+ }108+109+ const alice = generateSecretKey();110+ await rpc(host, owner, "setmember", pk(alice), {});111+ let roster = (await c.req({ kinds: [13534], authors: [self] }))[0];112+ expect(roster.tags).toContainEqual(["member", pk(owner), "owner"]);113+ expect(roster.tags).toContainEqual(["member", pk(alice)]);114+115+ // A role change must show in the roster and the admins list alike.116+ await rpc(host, owner, "setmember", pk(alice), { role: "moderator" });117+ roster = (await c.req({ kinds: [13534], authors: [self] }))[0];118+ expect(roster.tags).toContainEqual(["member", pk(alice), "moderator"]);119+ const admins = (await c.req({ kinds: [39001], authors: [self] }))[0];120+ expect(admins.tags).toContainEqual(["p", pk(alice), "moderator"]);121+ });122+});123+124+describe("roster and group agreement", () => {125+ it("keeps 13534, 39001 and 39002 in step with the member table through every path", async () => {126+ const host = "instep.bind.ws";127+ const owner = generateSecretKey();128+ const alice = generateSecretKey();129+ const bob = generateSecretKey();130+ const carol = generateSecretKey();131+ const dave = generateSecretKey();132+ const erin = generateSecretKey();133+ const h = ["h", "instep"];134+ await rpc(host, owner, "claim");135+ const self = (await info(host)).self as string;136+ const c = await WS.connect(host);137+138+ // expected: pubkey -> role, maintained by the test as it walks the paths139+ const want = new Map<string, string>([[pk(owner), "owner"]]);140+ let step = 0;141+ async function check() {142+ step++;143+ const [roster] = await c.req({ kinds: [13534], authors: [self] });144+ const [admins] = await c.req({ kinds: [39001], authors: [self] });145+ const [members] = await c.req({ kinds: [39002], authors: [self] });146+ const fromRoster = new Map(tagsOf(roster, "member").map((t) => [t[1], t[2] ?? "member"]));147+ expect(fromRoster, `roster at step ${step}`).toEqual(want);148+ expect(new Set(tagsOf(members, "p").map((t) => t[1])), `members at step ${step}`).toEqual(new Set(want.keys()));149+ const wantAdmins = new Map([...want].filter(([, r]) => r !== "member"));150+ expect(new Map(tagsOf(admins, "p").map((t) => [t[1], t[2]])), `admins at step ${step}`).toEqual(wantAdmins);151+ const listed = (await rpc(host, sk(want), "listallowedpubkeys")).result.map((m: any) => m.pubkey).sort();152+ expect(listed, `listmembers at step ${step}`).toEqual([...want.keys()].filter((p) => want.get(p) !== "owner").sort());153+ }154+ const keys = { [pk(owner)]: owner, [pk(alice)]: alice, [pk(bob)]: bob, [pk(carol)]: carol, [pk(dave)]: dave, [pk(erin)]: erin };155+ const sk = (m: Map<string, string>) => keys[[...m].find(([, r]) => r === "owner")![0]];156+ await check();157+158+ await rpc(host, owner, "setmember", pk(alice), {});159+ want.set(pk(alice), "member");160+ await check();161+162+ expect((await c.ok(ev(bob, 9021, "", [h]))).ok).toBe(true);163+ want.set(pk(bob), "member");164+ await check();165+166+ await rpc(host, owner, "setmember", pk(alice), { role: "moderator" });167+ want.set(pk(alice), "moderator");168+ await check();169+170+ expect((await c.ok(ev(bob, 9022, "", [h]))).ok).toBe(true);171+ want.delete(pk(bob));172+ await check();173+174+ await rpc(host, owner, "setmember", pk(carol), {});175+ await rpc(host, owner, "banpubkey", pk(carol), "spam");176+ await check();177+178+ await rpc(host, owner, "setmember", pk(dave), {});179+ await rpc(host, owner, "removemember", pk(dave));180+ await check();181+182+ expect((await c.ok(ev(owner, 9000, "", [h, ["p", pk(dave), "moderator"]]))).ok).toBe(true);183+ want.set(pk(dave), "moderator");184+ await check();185+ expect((await c.ok(ev(owner, 9001, "", [h, ["p", pk(dave)]]))).ok).toBe(true);186+ want.delete(pk(dave));187+ await check();188+189+ expect((await rpc(host, owner, "transferowner", pk(alice))).status).toBe(200);190+ want.set(pk(alice), "owner");191+ want.set(pk(owner), "moderator");192+ await check();193+194+ const cfg = (await rpc(host, alice, "exportconfig")).result;195+ cfg.members = [{ pubkey: pk(erin), name: null, note: "" }];196+ expect((await rpc(host, alice, "importconfig", cfg)).status).toBe(200);197+ want.delete(pk(owner));198+ want.set(pk(erin), "member");199+ await check();200+ // The import announced who came and who went.201+ expect((await c.req({ kinds: [8000], authors: [self], "#p": [pk(erin)] })).length).toBe(1);202+ expect((await c.req({ kinds: [8001], authors: [self], "#p": [pk(owner)] })).length).toBe(1);203+ expect((await c.req({ kinds: [9001], authors: [self], "#p": [pk(owner)] })).length).toBe(1);204+ });205+});206+207+describe("NIP-43 join and leave requests", () => {208+ it("admits with a claim, refuses bad codes, and revokes on request", async () => {209+ const host = "fortythree.bind.ws";210+ const owner = generateSecretKey();211+ await rpc(host, owner, "claim");212+ await rpc(host, owner, "setpolicy", { writes: "allowlist" });213+ const code = (await rpc(host, owner, "createinvite", 3600, 0, "friends")).result.code as string;214+ const sam = generateSecretKey();215+ const c = await WS.connect(host);216+217+ // NIP-70: a "-" tagged request needs the socket authenticated.218+ expect((await c.ok(ev(sam, 28934, "", [["-"], ["claim", code]]))).msg).toMatch(/^auth-required/);219+ await c.auth(sam, host);220+ let r = await c.ok(ev(sam, 28934, "", [["-"], ["claim", "nope"]]));221+ expect(r.ok).toBe(false);222+ expect(r.msg).toBe("restricted: that is an invalid invite code.");223+ r = await c.ok(ev(sam, 28934, "", [["-"]]));224+ expect(r.ok).toBe(false);225+ expect(r.msg).toMatch(/^restricted: a join request needs a claim/);226+ r = await c.ok(ev(sam, 28934, "", [["-"], ["claim", code]]));227+ expect(r.ok).toBe(true);228+ expect(r.msg).toBe("info: welcome to fortythree!");229+ expect((await rpc(host, owner, "listallowedpubkeys")).result.map((m: any) => m.pubkey)).toContain(pk(sam));230+ r = await c.ok(ev(sam, 28934, "", [["-"], ["claim", code]]));231+ expect(r.ok).toBe(true);232+ expect(r.msg).toMatch(/^duplicate: you are already a member/);233+ // Requests are ephemeral: nothing was stored.234+ expect((await c.req({ kinds: [28934] })).length).toBe(0);235+236+ r = await c.ok(ev(sam, 28936, "", [["-"]]));237+ expect(r.ok).toBe(true);238+ expect(r.msg).toBe("info: access revoked.");239+ expect((await rpc(host, owner, "listallowedpubkeys")).result.map((m: any) => m.pubkey)).not.toContain(pk(sam));240+ r = await c.ok(ev(sam, 28936, "", [["-"]]));241+ expect(r.ok).toBe(true);242+ expect(r.msg).toMatch(/^duplicate: you are not a member/);243+ // The owner cannot leave.244+ const o = await WS.connect(host);245+ await o.auth(owner, host);246+ expect((await o.ok(ev(owner, 28936, "", [["-"]]))).msg).toMatch(/^restricted: the owner cannot leave/);247+ });248+});
M test/relay.test.ts +1 -1