clone

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

main
bindws / src / identity.ts
identity.ts 6.2K
1// A relay has its own nostr identity: a keypair kept in the object's storage,2// advertised as NIP-11 "self", used to sign facts only the relay can vouch3// for: the NIP-43 membership roster (kind 13534) and its added/removed4// deltas (kinds 8000/8001), the NIP-29 put-user and remove-user records5// (9000/9001) and the group's metadata, admins, members and roles6// (39000-39003), the NIP-43 role definitions (33534), and its own profile7// (kind 0). All but the profile NIP-70 protected.8import { finalizeEvent, getPublicKey, generateSecretKey, type Event } from "nostr-tools/pure";9import { wrapEvent } from "nostr-tools/nip59";10import { bytesToHex, hexToBytes } from "./negentropy.ts";11import { KIND_PROFILE, KIND_VIEW, KIND_MEMBER_ADDED, KIND_MEMBER_REMOVED, KIND_ROSTER, KIND_ROLE_DEF, KIND_PUT_USER, KIND_REMOVE_USER, KIND_GROUP_METADATA, KIND_GROUP_ADMINS, KIND_GROUP_MEMBERS, KIND_GROUP_ROLES, KIND_GROUP_PINS } from "./kinds.ts";1213export interface GroupFacts {14 id: string;15 name: string;16 about: string;17 picture: string;18 private: boolean; // members-only reads19 restricted: boolean; // members-only writes20 closed: boolean; // join requests need an invite21 admins: { pubkey: string; role: string }[];22 members: string[] | null; // null: not published23 roles: { role: string; about: string }[];24 profile: string | null; // kind 0 content to sign, null when the stored one is current25}2627export class Identity {28 private sk: Uint8Array | null = null;29 private lastRoster = 0;30 private lastGroup = 0;3132 constructor(private storage: DurableObjectStorage) {}3334 async load() {35 const hex = await this.storage.get<string>("relay-key");36 if (hex) this.sk = hexToBytes(hex);37 this.lastRoster = (await this.storage.get<number>("relay-roster-at")) ?? 0;38 this.lastGroup = (await this.storage.get<number>("relay-group-at")) ?? 0;39 }4041 // ensure creates the key on first use, so unclaimed relays have none.42 async ensure(): Promise<string> {43 if (!this.sk) {44 this.sk = generateSecretKey();45 await this.storage.put("relay-key", bytesToHex(this.sk));46 }47 return this.pubkey;48 }4950 get pubkey(): string {51 return this.sk ? getPublicKey(this.sk) : "";52 }5354 sign(kind: number, tags: string[][], content = "", created_at = Math.floor(Date.now() / 1000)): Event {55 if (!this.sk) throw new Error("relay has no identity yet");56 return finalizeEvent({ kind, tags, content, created_at }, this.sk);57 }5859 // wrap seals and gift wraps a rumour for one recipient with the relay's60 // key (NIP-59 with NIP-44), for the notifications the relay sends its owner.61 wrap(rumor: { kind: number; content: string; tags: string[][]; created_at: number }, recipient: string): Event {62 if (!this.sk) throw new Error("relay has no identity yet");63 return wrapEvent(rumor, this.sk, recipient) as Event;64 }6566 // roster timestamps are strictly increasing: two snapshots in one second67 // would tie on created_at and NIP-01's lowest-id tie-break could keep the68 // stale one.69 roster(members: { pubkey: string; role: string }[], now = Math.floor(Date.now() / 1000)): Event {70 const at = Math.max(now, this.lastRoster + 1);71 this.lastRoster = at;72 this.storage.put("relay-roster-at", at);73 // A role is named only when it is one (NIP-43 roles are the 3353474 // definitions); a plain member has none.75 return this.sign(KIND_ROSTER, [["-"], ...members.map((m) => (m.role === "member" ? ["member", m.pubkey] : ["member", m.pubkey, m.role]))], "", at);76 }7778 delta(added: boolean, pubkey: string): Event {79 return this.sign(added ? KIND_MEMBER_ADDED : KIND_MEMBER_REMOVED, [["-"], ["p", pubkey]]);80 }8182 // NIP-29 records of a membership change, signed by the relay as the83 // group's authority. roles are the ones the person holds now.84 putUser(group: string, pubkey: string, roles: string[]): Event {85 return this.sign(KIND_PUT_USER, [["-"], ["h", group], ["p", pubkey, ...roles]]);86 }87 removeUser(group: string, pubkey: string): Event {88 return this.sign(KIND_REMOVE_USER, [["-"], ["h", group], ["p", pubkey]]);89 }9091 // view signs a fold as a kind 30078 record on the group clock, so a newer92 // record always replaces the older one. NIP-70 protected like the rest.93 view(tags: string[][], content: string, now = Math.floor(Date.now() / 1000)): Event {94 const at = Math.max(now, this.lastGroup + 1);95 this.lastGroup = at;96 this.storage.put("relay-group-at", at);97 return this.sign(KIND_VIEW, [["-"], ...tags], content, at);98 }99100 // pins signs the group's pinned list (NIP-29 kind 39005): e and a tags in101 // the order they should show.102 pins(group: string, tags: string[][], now = Math.floor(Date.now() / 1000)): Event {103 const at = Math.max(now, this.lastGroup + 1);104 this.lastGroup = at;105 this.storage.put("relay-group-at", at);106 return this.sign(KIND_GROUP_PINS, [["-"], ["d", group], ...tags], "", at);107 }108109 // group signs the addressable NIP-29 state: metadata, admins, members110 // (when published) and roles, plus the NIP-43 role definitions and, when111 // it changed, the relay's own profile. Same strictly increasing clock as112 // the roster, so a newer set always replaces an older one.113 group(f: GroupFacts, now = Math.floor(Date.now() / 1000)): Event[] {114 const at = Math.max(now, this.lastGroup + 1);115 this.lastGroup = at;116 this.storage.put("relay-group-at", at);117 const d = ["d", f.id];118 const meta: string[][] = [["-"], d, ["name", f.name], ["about", f.about]];119 if (f.picture) meta.push(["picture", f.picture]);120 if (f.private) meta.push(["private"]);121 if (f.restricted) meta.push(["restricted"]);122 if (f.closed) meta.push(["closed"]);123 const out = [124 this.sign(KIND_GROUP_METADATA, meta, "", at),125 this.sign(KIND_GROUP_ADMINS, [["-"], d, ...f.admins.map((a) => ["p", a.pubkey, a.role])], "", at),126 this.sign(KIND_GROUP_ROLES, [["-"], d, ...f.roles.map((r) => ["role", r.role, r.about])], "", at),127 ];128 if (f.members) out.push(this.sign(KIND_GROUP_MEMBERS, [["-"], d, ...f.members.map((p) => ["p", p])], "", at));129 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)));130 if (f.profile !== null) out.push(this.sign(KIND_PROFILE, [], f.profile, at));131 return out;132 }133}