1// NIP-29, one group per relay. The group id is the relay's name; events that2// carry an h tag for any other id are out of context and refused. The group's3// rules are the relay's rules: private is reads set to members, restricted4// and closed are writes not open. Membership and roles are the member list;5// the relay signs the group's metadata, admins, members and roles itself.6//7// Joins, leaves and moderation events are gated by role (roles.ts), not by8// the write policy: a join request comes from a stranger by definition, and9// a moderator moderates whatever the write rule says.10import { now, tagValues, type Event } from "./event.ts";11import { claimInvite, inviteCreator, memberInviteGate, mintInvite } from "./invites.ts";12import { type GroupFacts } from "./identity.ts";13import { can, ROLES, type Action } from "./roles.ts";14import type { Relay } from "./relay.ts";15import { KIND_PROFILE, KIND_PUT_USER, KIND_REMOVE_USER, KIND_EDIT_METADATA, KIND_DELETE_EVENT, KIND_CREATE_GROUP, KIND_DELETE_GROUP, KIND_CREATE_INVITE, KIND_PINS, KIND_JOIN, KIND_LEAVE, KIND_NIP43_JOIN, KIND_NIP43_LEAVE } from "./kinds.ts";1617export const isModeration = (kind: number) => kind >= 9000 && kind <= 9020;18export const isNIP43Request = (kind: number) => kind === KIND_NIP43_JOIN || kind === KIND_NIP43_LEAVE;19export const isGroupManagement = (kind: number) => isModeration(kind) || kind === KIND_JOIN || kind === KIND_LEAVE || isNIP43Request(kind);20// The addressable state kinds only the relay writes.21export const isGroupState = (kind: number) => kind >= 39000 && kind <= 39005;2223const HEX64 = /^[0-9a-f]{64}$/;2425// groupFacts derives the group's NIP-29 state from the relay's settings.26export function groupFacts(relay: Relay): GroupFacts {27 const p = relay.settings.policy;28 const members = relay.settings.members();29 // The relay's profile: re-signed only when what it says would change.30 const profile = JSON.stringify({ name: p.name || relay.slug, about: p.description, ...(p.icon ? { picture: p.icon } : {}) });31 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;32 const current = row ? (JSON.parse(row.raw) as { content: string }).content : undefined;33 return {34 id: relay.slug,35 name: p.name || relay.slug,36 about: p.description,37 picture: p.icon,38 private: p.reads === "members",39 restricted: p.writes !== "open",40 closed: p.writes !== "open",41 admins: members.filter((m) => m.role !== "member").map((m) => ({ pubkey: m.pubkey, role: m.role })),42 members: p.directoryPublic ? members.map((m) => m.pubkey) : null,43 roles: ROLES,44 profile: current === profile ? null : profile,45 };46}4748type Result = { ok: boolean; msg: string; stored: boolean };49const OK: Result = { ok: true, msg: "", stored: true };50const no = (msg: string): Result => ({ ok: false, msg, stored: false });5152const MODERATION_NAMES: Record<number, string> = { [KIND_PUT_USER]: "put-user", [KIND_REMOVE_USER]: "remove-user", [KIND_EDIT_METADATA]: "edit-metadata", [KIND_DELETE_EVENT]: "delete-event", [KIND_CREATE_INVITE]: "create-invite", [KIND_PINS]: "update-pins" };5354// handleGroupEvent applies a join, leave or moderation event that has passed55// the common gate. It does not store the event; stored: true asks the caller56// to keep and broadcast it. A moderation event that took effect goes in the57// moderation log under the kind's NIP-29 name, like the management method58// that does the same thing.59export async function handleGroupEvent(relay: Relay, e: Event): Promise<Result> {60 const r = await applyGroupEvent(relay, e);61 if (r.ok && isModeration(e.kind)) {62 const target = e.tags.find((x) => (x[0] === "p" || x[0] === "e") && HEX64.test(x[1] ?? ""))?.[1] ?? "";63 relay.audit.record(now(), e.pubkey, MODERATION_NAMES[e.kind] ?? "kind " + e.kind, target, e.kind === KIND_EDIT_METADATA || e.kind === KIND_PINS ? JSON.stringify(e.tags.filter((x) => x[0] !== "h")).slice(0, 500) : "");64 }65 return r;66}6768async function applyGroupEvent(relay: Relay, e: Event): Promise<Result> {69 const s = relay.settings;70 const t = now();71 const role = s.roleOf(e.pubkey);72 const need = (action: Action) => (can(role, action) ? "" : role === "moderator" ? "restricted: moderators cannot do that" : "restricted: not a group admin");73 const pTag = e.tags.find((x) => x[0] === "p" && HEX64.test(x[1] ?? ""));74 const target = pTag?.[1] ?? "";7576 switch (e.kind) {77 case KIND_JOIN: {78 if (s.isAllowed(e.pubkey)) return no("duplicate: already a member");79 const code = tagValues(e, "code")[0] ?? "";80 let via = "";81 let invitedBy = "";82 if (code) {83 const r = claimInvite(relay.sql, code, t);84 if (r === "ok") {85 via = "invite " + code.slice(0, 8);86 invitedBy = inviteCreator(relay.sql, code);87 }88 else if (s.policy.writes !== "open") {89 return no("restricted: " + { invite_invalid: "that invite code is not valid", invite_expired: "that invite has expired", invite_exhausted: "that invite has been used up" }[r]);90 }91 }92 if (!via) {93 if (s.policy.writes !== "open") return no("restricted: this group is closed; join with an invite");94 via = "join";95 }96 const err = await relay.setMember(e.pubkey, { via, invitedBy });97 return err ? no(err) : OK;98 }99 case KIND_LEAVE: {100 if (s.isOwner(e.pubkey)) return no("restricted: the owner cannot leave; transfer ownership first");101 if (!(await relay.removeMember(e.pubkey))) return no("invalid: not a member");102 return OK;103 }104 case KIND_NIP43_JOIN: {105 // Ephemeral, so never stored or fanned out: the answer is the OK.106 if (s.isAllowed(e.pubkey)) return { ok: true, msg: "duplicate: you are already a member of this relay.", stored: false };107 const claim = tagValues(e, "claim")[0] ?? "";108 if (!claim) return no("restricted: a join request needs a claim tag with an invite code.");109 const r = claimInvite(relay.sql, claim, t);110 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]);111 const err = await relay.setMember(e.pubkey, { via: "invite " + claim.slice(0, 8), invitedBy: inviteCreator(relay.sql, claim) });112 return err ? no(err) : { ok: true, msg: "info: welcome to " + relay.slug + "!", stored: false };113 }114 case KIND_NIP43_LEAVE: {115 if (s.isOwner(e.pubkey)) return no("restricted: the owner cannot leave; transfer ownership first");116 if (!(await relay.removeMember(e.pubkey))) return { ok: true, msg: "duplicate: you are not a member of this relay.", stored: false };117 return { ok: true, msg: "info: access revoked.", stored: false };118 }119 case KIND_PUT_USER: {120 const gate = need("members");121 if (gate) return no(gate);122 if (!target) return no("invalid: put-user needs a p tag");123 if (s.isOwner(target)) return no("invalid: the owner's role changes by transfer");124 const wantsModerator = (pTag as string[]).slice(2).includes("moderator");125 const cur = s.roleOf(target);126 if (cur === "moderator" && role !== "owner") return no("restricted: only the owner changes moderators");127 if (wantsModerator && role !== "owner") return no("restricted: only the owner appoints moderators");128 if (s.isBanned(target)) s.setBan(target, false);129 const err = await relay.setMember(target, { via: "put-user" });130 if (err) return no(err);131 const next = wantsModerator ? "moderator" : "member";132 if ((cur ?? "member") !== next) {133 s.setRole(target, next);134 await relay.publishMembership({ pubkey: target });135 }136 return OK;137 }138 case KIND_REMOVE_USER: {139 const gate = need("members");140 if (gate) return no(gate);141 if (!target) return no("invalid: remove-user needs a p tag");142 if (s.isOwner(target)) return no("invalid: the owner cannot be removed");143 if (s.roleOf(target) === "moderator" && role !== "owner") return no("restricted: only the owner removes moderators");144 if (!(await relay.removeMember(target))) return no("invalid: not a member");145 return OK;146 }147 case KIND_EDIT_METADATA: {148 const gate = need("identity");149 if (gate) return no(gate);150 const patch: { name?: string; description?: string; icon?: string } = {};151 const name = tagValues(e, "name")[0];152 const about = tagValues(e, "about")[0];153 const picture = tagValues(e, "picture")[0];154 if (name !== undefined) patch.name = name.slice(0, 200);155 if (about !== undefined) patch.description = about.slice(0, 2000);156 if (picture !== undefined) patch.icon = picture.slice(0, 2000);157 s.update(patch);158 await relay.publishMembership();159 return OK;160 }161 case KIND_DELETE_EVENT: {162 const gate = need("deleteEvent");163 if (gate) return no(gate);164 const ids = tagValues(e, "e").filter((id) => HEX64.test(id));165 if (!ids.length) return no("invalid: delete-event needs an e tag");166 for (const id of ids) {167 const row = relay.sql.exec<{ pubkey: string }>(`SELECT pubkey FROM events WHERE id=?`, id).toArray()[0];168 if (row && row.pubkey === relay.identity.pubkey) return no("blocked: the relay's own records cannot be deleted");169 }170 for (const id of ids) relay.store.deleteEvent(id);171 return OK;172 }173 case KIND_CREATE_INVITE: {174 // Plain members may mint too when the owner opened the invite tree.175 const gate = role === "member" ? memberInviteGate(s, relay.sql, e.pubkey, t) : need("invites");176 if (gate) return no(gate);177 const r = mintInvite(relay.sql, e.pubkey, 0, 0, e.content, t, tagValues(e, "code")[0] ?? "");178 return typeof r === "string" ? no(r) : OK;179 }180 case KIND_CREATE_GROUP:181 return no("unsupported: this relay is one group; there is nothing to create");182 case KIND_DELETE_GROUP:183 return no("unsupported: delete the relay from its page");184 case KIND_PINS: {185 // update-pin-list replaces the whole list: e tags and a tags, in order.186 const gate = need("deleteEvent");187 if (gate) return no(gate);188 const tags = pinTags(e.tags);189 if (typeof tags === "string") return no(tags);190 s.setPins(tags);191 await relay.publishPins();192 return OK;193 }194 default:195 return no("unsupported: unknown moderation kind " + e.kind);196 }197}198199export const MAX_PINS = 20;200const ADDR = /^\d{1,5}:[0-9a-f]{64}:.*$/;201202// pinTags keeps the e and a tags of a pin list, in order, or says why not.203export function pinTags(tags: string[][]): string[][] | string {204 const out: string[][] = [];205 for (const t of tags) {206 if (t[0] === "e" && HEX64.test(t[1] ?? "")) out.push(["e", t[1]]);207 else if (t[0] === "a" && ADDR.test(t[1] ?? "")) out.push(["a", t[1]]);208 }209 const seen = new Set<string>();210 const unique = out.filter((t) => !seen.has(t[1]) && seen.add(t[1]));211 if (unique.length > MAX_PINS) return `invalid: at most ${MAX_PINS} pins`;212 return unique;213}