- commit
- 6d800c1fcf5835e76aa7528d60d4dac0f3234d26
- parent
- ef4749ac9086a9b338aa3aecc53cafb427c5764a
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 2 | +- |
| M | docs/ | 4 | +- |
| M | src/ | 11 | +- |
| M | src/ | 692 | ++++++++++++++++++------------ |
| M | src/ | 31 | +- |
@@ -117,7 +117,7 @@ Members inviting members is `invited_by` on the member row, set from the invite'
117117 118118 ## Management and rate limits119119 120-`manage.ts` implements NIP-86 over NIP-98. The console is a client of it. `verifyNIP98` is a local implementation: kind 27235, 60-second window, `u` matches host and path, method matches, payload hash matches when there is a body. Every method except `claim` and `supportedmethods` names an action in `roles.ts`; a method without one is refused for everyone.120+`manage.ts` implements NIP-86 over NIP-98. The console is a client of it. `verifyNIP98` is a local implementation: kind 27235, 60-second window, `u` matches host and path, method matches, payload hash matches when there is a body. Every method is one entry in its `METHODS` table: the action it needs (`roles.ts`), whether it changes nothing, and its handler; `claim` and `supportedmethods` are the two open to anyone.121121 122122 `listeventsneedingmoderation` is a view over the reports queue: one entry per open reported thing, an event id or a blob hash, with the report's type and words as the reason. `blockip`, `unblockip` and `listblockedips` work on the address the worker stamped. A blocked address gets no socket and no write, read or upload door, while the page, NIP-11 and management stay reachable so an owner can undo a block on their own address.123123 @@ -74,8 +74,8 @@ CI runs typecheck and the object tests on every push; the conformance suite agai
7474 7575 ## Add a management method7676 77-1. Add the name to `METHODS` in `src/manage.ts` and the action it needs to `METHOD_ACTIONS` in `src/roles.ts`; a method without an action is refused for everyone.78-2. Add a `case` in the switch. Use `num(i)` and `str(i)` for parameters. Return `reply({ result })` or `reply({ error }, 400)`.77+1. Add an entry to `METHODS` in `src/manage.ts`: the action it needs (`roles.ts`), `reads: true` if it changes nothing, and `run`. The handler takes what it uses from the call: `str(i)` and `num(i)` for parameters, `s` for settings, `reply({ result })` or `reply({ error }, 400)` to answer. `supportedmethods`, the permission check and the moderation log read the same entry.78+2. A method a moderator may call names an action in the moderator's set in `roles.ts`; anything else is the owner's.7979 3. If it changes state that other code caches, update `Settings` and its in-memory sets.8080 4. If the console should call it, add the control to `src/console/console.html` and a handler in `console.js`.8181 5. Cover it in the `test/object` file for its feature, with `rpc` from `test/helpers/relay.ts`. If it can show a member, an event or a file, ask `Settings.mayRead` (or `mayList` for names and counts) with what `whoAsks` found in the header, and add the path to the door walk in `test/object/exposure.test.ts`, which knocks on every path as a stranger and as a signed-in non-member and fails on anything of a member's that comes back.@@ -1,5 +1,5 @@
11 // The moderation log: who did what, when, to whom. Every management method2-// that changes something (manage.ts) and every NIP-29 moderation event2+// that changes something (the ones without `reads` in manage.ts) and every NIP-29 moderation event33 // (groups.ts) leaves a row. The owner and moderators read it with listaudit44 // and on the console's Moderation tab. Each row also goes out as a JSON55 // line on the console, so an OTLP logs destination gets it as it happens@@ -17,15 +17,6 @@ const PAGE = 100;
1717 export type AuditRow = { seq: number; at: number; actor: string; action: string; target: string; detail: string };1818 type Row = Record<string, string | number>;1919 20-// The management methods that change nothing; the rest are logged when they succeed.21-export const READ_METHODS = new Set([22- "supportedmethods", "claim", "stats", "getpolicy", "listpresets", "listviews",23- "listbannedpubkeys", "listallowedpubkeys", "listmembers", "listpeople", "listinvites",24- "listbannedevents", "listrecentevents", "searchevents", "listallowedkinds", "listblockedkinds", "listblobs", "listretention", "listreports",25- "listblockedips", "listpins", "listeventsneedingmoderation", "storagestats", "listjobs", "pullstatus", "listdumps",26- "successionstatus", "listdomains", "checkdomain", "exportconfig", "notifytest", "listaudit",27-]);28-2920 // detailOf compresses a method's parameters into the detail column: the3021 // first string parameter is the target, the rest is JSON, and bulky3122 // payloads are described rather than copied.@@ -1,11 +1,16 @@
11 // NIP-86 relay management over HTTP, authenticated with NIP-98. The owner is22 // whoever claimed the relay; "claim" is the one method anyone may call, and33 // only while the relay is unclaimed.4-import { now, tagValues, validate, type Event } from "./event.ts";4+//5+// One table, METHODS, holds every method: the action it needs (roles.ts),6+// whether it changes nothing (then it is not logged), and what it does.7+// supportedmethods, the permission check and the moderation log all read8+// the table, so a method is registered in one place.9+import { now } from "./event.ts";510 import type { Relay } from "./relay.ts";611 import { inviteCreator, listInvites, memberInviteGate, mintInvite, revokeInvite } from "./invites.ts";712 import { descriptor, type Blob } from "./blossom.ts";8-import { badBlockedWord, blockedWords, gateFields, isWriteRule, limitFields, viewFields } from "./settings.ts";13+import { badBlockedWord, blockedWords, gateFields, isWriteRule, limitFields, viewFields, type Policy, type Settings } from "./settings.ts";914 import { isReplaceable, isProtected, publicFields, dumpFields, validIP } from "./settings.ts";1015 export { validIP };1116 import { checkPullURL } from "./pull.ts";@@ -14,115 +19,150 @@ import { MAX_PINS } from "./groups.ts";
1419 import { relaysFromList } from "./jobs.ts";1520 import { notify, notifySettings } from "./notify.ts";1621 import { DUMP_NAME_RE, deleteDump, dumpBytes, listDumps, writeDump } from "./dumps.ts";17-import { can, METHOD_ACTIONS } from "./roles.ts";18-import { READ_METHODS, detailOf } from "./audit.ts";22+import { can, type Action, type Role } from "./roles.ts";23+import { detailOf } from "./audit.ts";1924 import { PRESETS, applyPreset, findPreset } from "./presets.ts";2025 import { addDomain, checkDomain, listDomains, removeDomain } from "./domains.ts";2126 2227 import { verifyNIP98 } from "./auth.ts";2328 export { verifyNIP98 };2429 25-const METHODS = [26- "supportedmethods", "claim", "stats", "getpolicy", "setpolicy",27- "banpubkey", "allowpubkey", "unrulepubkey", "listbannedpubkeys", "listallowedpubkeys",28- "setmember", "removemember", "listpeople",29- "banevent", "allowevent", "listbannedevents", "deleteevent", "listrecentevents", "searchevents", "pinevent", "unpinevent", "listpins",30- "allowkind", "disallowkind", "unrulekind", "listallowedkinds", "listblockedkinds",31- "changerelayname", "changerelaydescription", "changerelayicon",32- "createinvite", "listinvites", "revokeinvite", "listmembers",33- "listreports", "resolvereport", "listeventsneedingmoderation", "listaudit",34- "blockip", "unblockip", "listblockedips", "setblockedwords",35- "listblobs", "deleteblob",36- "storagestats", "setretention", "listretention", "purgekind",37- "deleterelay", "exportconfig", "importconfig", "resetrules", "listpresets", "applypreset",38- "pullfrom", "pullstatus", "listjobs", "addjob", "removejob", "runjob", "backfill", "transferowner", "notifytest",39- "listdumps", "deletedump", "dumpnow", "listviews",40- "removesubtree",41- "forkrelay",42- "setsuccession", "clearsuccession", "successionstatus",43- "adddomain", "removedomain", "checkdomain", "listdomains",44-];30+// A call: the relay and the request, who is calling and as what, the31+// parameters with their readers, and how to answer.32+export interface Call {33+ relay: Relay;34+ req: Request;35+ s: Settings;36+ p: Policy;37+ t: number;38+ caller: string;39+ role: Role | null;40+ method: string;41+ params: unknown[];42+ str: (i: number) => string;43+ num: (i: number) => number;44+ hex64: (v: string) => boolean;45+ reply: (body: unknown, status?: number) => Response;46+ // outranks says the caller may not touch this pubkey: a moderator keeps47+ // their hands off the owner and other moderators.48+ outranks: (pk: string) => boolean;49+}4550 46-export async function manage(relay: Relay, req: Request): Promise<Response> {47- const cors = { "access-control-allow-origin": "*", "content-type": "application/json" };48- const reply = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: cors });49- const bodyText = await req.text();50- let body: { method?: unknown; params?: unknown };51- try {52- body = JSON.parse(bodyText);53- } catch {54- return reply({ error: "invalid: body is not JSON" }, 400);51+interface Method {52+ // The action the caller's role must allow (roles.ts); "open" for the two53+ // methods anyone may call.54+ action: Action | "open";55+ // Changes nothing, so it is not written to the moderation log.56+ reads?: true;57+ run: (c: Call) => Response | Promise<Response>;58+}59+60+// putMember answers allowpubkey and setmember.61+const putMember = async ({ relay, s, role, method, params, str, hex64, reply, outranks }: Call) => {62+ // NIP-86 allowpubkey(pubkey, reason) and the richer setmember(pubkey, {name, note}).63+ const pk = str(0);64+ if (!hex64(pk)) return reply({ error: "invalid: pubkey must be 64 hex chars" }, 400);65+ const patch = method === "allowpubkey" ? { note: str(1) } : (params[1] && typeof params[1] === "object" ? (params[1] as { name?: string | null; note?: string; role?: string; keepDays?: unknown; maxBytes?: unknown }) : {});66+ if (outranks(pk)) return reply({ error: "restricted: moderators cannot edit the owner or other moderators" }, 403);67+ // Per-member keep-for and cap: the owner's call, and never on the owner.68+ const keepDays = Number.isInteger(patch.keepDays) && (patch.keepDays as number) >= 0 ? (patch.keepDays as number) : undefined;69+ const maxBytes = Number.isInteger(patch.maxBytes) && (patch.maxBytes as number) >= 0 ? (patch.maxBytes as number) : undefined;70+ if ((keepDays !== undefined || maxBytes !== undefined) && role !== "owner") return reply({ error: "restricted: only the owner sets limits" }, 403);71+ const wantsRole = patch.role === "moderator" || patch.role === "member" ? patch.role : undefined;72+ if (wantsRole && role !== "owner") return reply({ error: "restricted: only the owner sets roles" }, 403);73+ if (wantsRole && s.isOwner(pk)) return reply({ error: "invalid: the owner's role changes by transferowner" }, 400);74+ if (s.isBanned(pk)) s.setBan(pk, false);75+ const err = await relay.setMember(pk, { name: patch.name, note: typeof patch.note === "string" ? patch.note : undefined, ...(s.isOwner(pk) ? {} : { keepDays, maxBytes }) }, true);76+ if (err) return reply({ error: err }, 400);77+ if (wantsRole && s.roleOf(pk) !== wantsRole) {78+ s.setRole(pk, wantsRole);79+ await relay.publishMembership({ pubkey: pk });5580 }56- const method = typeof body.method === "string" ? body.method : "";57- const params = Array.isArray(body.params) ? body.params : [];58- if (!METHODS.includes(method)) return reply({ error: "unsupported: unknown method " + method }, 400);81+ return reply({ result: s.member(pk) });82+};5983 60- const auth = verifyNIP98(req.headers.get("authorization") ?? "", req.url, req.method, bodyText);61- if (typeof auth === "string") return reply({ error: auth }, 401);62- const s = relay.settings;63- const p = s.policy;64- const t = now();65- const caller = auth.pubkey;84+// dropMember answers unrulepubkey and removemember.85+const dropMember = async ({ relay, s, str, hex64, reply, outranks }: Call) => {86+ // Removes a ban or a membership, whichever the pubkey has; never the owner.87+ const pk = str(0);88+ if (!hex64(pk)) return reply({ error: "invalid: pubkey must be 64 hex chars" }, 400);89+ if (outranks(pk)) return reply({ error: "restricted: moderators cannot remove the owner or other moderators" }, 403);90+ if (s.isBanned(pk)) s.setBan(pk, false);91+ await relay.removeMember(pk);92+ return reply({ result: true });93+};6694 67- if (method === "claim") {68- if (p.owner === "") {69- // A leased relay converts in place, keeping its events and files. A70- // lease taken with a signature is reserved for that key.71- const lease = p.lease;72- if (lease && lease.holder && lease.holder !== caller) return reply({ error: "restricted: this temporary relay is reserved for another key" }, 403);73- if (lease && lease.until <= t) return reply({ error: "restricted: this temporary relay has expired" }, 403);74- s.update(lease ? { owner: caller, lease: null, name: "", description: "" } : { owner: caller });75- s.upsertMember(caller, { via: "claimed" }, t);76- await relay.ownerSeenNow();77- await relay.publishMembership();78- return reply({ result: { owner: caller, claimed: true, ...(lease ? { converted: true } : {}) } });79- }80- return reply({ result: { owner: p.owner, claimed: s.isOwner(caller) } }, s.isOwner(caller) ? 200 : 403);81- }82- if (method === "supportedmethods") return reply({ result: METHODS });83- // The heir may read the succession status: a member, with no console otherwise.84- if (method === "successionstatus" && p.succession && p.succession.heir === caller) return reply({ result: await relay.successionStatus() });85- // One permission table for the console and for NIP-29 (roles.ts).86- const role = s.roleOf(caller);87- const action = METHOD_ACTIONS[method];88- // A plain member reaches their own invites when the owner opened the89- // invite tree (memberInvites); the cases below keep them to their own.90- const ownInvites = role === "member" && p.memberInvites.depth > 0 && (method === "createinvite" || method === "listinvites" || method === "revokeinvite");91- if (role === "owner") void relay.ownerSeen(caller);92- if (!ownInvites && (!action || !can(role, action))) {93- 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";94- return reply({ error: why }, 403);95- }96- // Moderators keep their hands off the owner and each other.97- const outranks = (pk: string) => role !== "owner" && (s.isOwner(pk) || s.roleOf(pk) === "moderator");95+// listMembers answers listmembers and listpeople.96+const listMembers = ({ relay, s, t, reply }: Call) => {97+ // Each member carries how many live invites they hold, for the tree.98+ const minted = new Map<string, number>();99+ for (const r of relay.sql.exec<{ created_by: string; n: number }>(`SELECT created_by, count(*) AS n FROM invites WHERE expires_at>=? AND (max_uses=0 OR uses<max_uses) GROUP BY created_by`, t)) minted.set(r.created_by, r.n);100+ return reply({ result: { self: relay.identity.pubkey, members: s.members().map((m) => ({ ...m, invites: minted.get(m.pubkey) ?? 0 })) } });101+};98102 99- const str = (i: number) => (typeof params[i] === "string" ? (params[i] as string) : "");100- const num = (i: number) => (Number.isInteger(params[i]) ? (params[i] as number) : parseInt(str(i), 10));101- const hex64 = (v: string) => /^[0-9a-f]{64}$/.test(v);103+// pin answers pinevent and unpinevent.104+const pin = async ({ relay, s, method, str, hex64, reply }: Call) => {105+ // (id or address): the group's pin list, at most MAX_PINS, in order.106+ const ref = str(0).trim().toLowerCase();107+ const tag = hex64(ref) ? ["e", ref] : /^\d{1,5}:[0-9a-f]{64}:/.test(ref) ? ["a", ref] : null;108+ if (!tag) return reply({ error: "invalid: give an event id or an address kind:pubkey:d" }, 400);109+ const cur = s.pins().filter((x) => x[1] !== ref);110+ const next = method === "pinevent" ? [...cur, tag] : cur;111+ if (next.length > MAX_PINS) return reply({ error: `invalid: at most ${MAX_PINS} pins` }, 400);112+ s.setPins(next);113+ await relay.publishPins();114+ return reply({ result: next });115+};102116 103- // Every method that changed something and answered 200 goes in the104- // moderation log with who called it (audit.ts). deleterelay took the105- // table with it.106- const resp = await run();107- if (resp.status === 200 && !READ_METHODS.has(method) && method !== "deleterelay") {108- const { target, detail } = detailOf(method, params);109- relay.audit.record(t, caller, method, target, detail);110- }111- return resp;117+// ruleKind answers allowkind, disallowkind and unrulekind.118+const ruleKind = async ({ relay, s, method, num, reply }: Call) => {119+ const k = num(0);120+ if (!Number.isInteger(k) || k < 0 || k > 65535) return reply({ error: "invalid: kind out of range" }, 400);121+ s.setKind(k, method === "allowkind" ? "allow" : method === "disallowkind" ? "block" : null);122+ await relay.publishDiscovery();123+ return reply({ result: true });124+};112125 113- async function run(): Promise<Response> {114- switch (method) {115- case "listaudit":126+export const METHODS: Record<string, Method> = {127+ // claim: the one method anyone may call, while the relay is unclaimed.128+ claim: {129+ action: "open", reads: true,130+ run: async ({ relay, s, p, t, caller, reply }) => {131+ if (p.owner === "") {132+ // A leased relay converts in place, keeping its events and files. A133+ // lease taken with a signature is reserved for that key.134+ const lease = p.lease;135+ if (lease && lease.holder && lease.holder !== caller) return reply({ error: "restricted: this temporary relay is reserved for another key" }, 403);136+ if (lease && lease.until <= t) return reply({ error: "restricted: this temporary relay has expired" }, 403);137+ s.update(lease ? { owner: caller, lease: null, name: "", description: "" } : { owner: caller });138+ s.upsertMember(caller, { via: "claimed" }, t);139+ await relay.ownerSeenNow();140+ await relay.publishMembership();141+ return reply({ result: { owner: caller, claimed: true, ...(lease ? { converted: true } : {}) } });142+ }143+ return reply({ result: { owner: p.owner, claimed: s.isOwner(caller) } }, s.isOwner(caller) ? 200 : 403);144+ },145+ },146+ supportedmethods: { action: "open", reads: true, run: ({ reply }) => reply({ result: Object.keys(METHODS) }) },147+ listaudit: {148+ action: "read", reads: true,149+ run: ({ relay, num, reply }) => {116150 // (before): rows older than that seq, newest first; 0 or nothing for the newest page.117151 return reply({ result: relay.audit.list(Math.max(num(0) || 0, 0)) });118- case "stats": {152+ },153+ },154+ stats: {155+ action: "read", reads: true,156+ run: ({ relay, p, reply }) => {119157 const d = new Date(now() * 1000);120158 const monthStart = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1) / 1000;121159 return reply({ result: { ...relay.store.stats(), kinds: relay.store.kinds(monthStart), connections: relay.connections(), name: relay.slug, owner: p.owner, fuel: relay.fuelStatus(), credits: relay.fuel.recentCredits() } });122- }123- case "getpolicy":124- return reply({ result: p });125- case "setpolicy": {160+ },161+ },162+ getpolicy: { action: "read", reads: true, run: ({ p, reply }) => reply({ result: p }) },163+ setpolicy: {164+ action: "rules",165+ run: async ({ relay, s, p, params, reply }) => {126166 const patch = params[0] && typeof params[0] === "object" ? (params[0] as Record<string, unknown>) : {};127167 const clean: Record<string, unknown> = {};128168 for (const k of ["name", "description", "icon", "contact"]) if (typeof patch[k] === "string") clean[k] = (patch[k] as string).slice(0, 2000);@@ -145,10 +185,12 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
145185 if (clean.reads !== undefined || clean.directoryPublic !== undefined) for (const v of VIEWS) if (!names.includes(v.name)) names.push(v.name);146186 for (const name of names) await relay.publishView(name);147187 return reply({ result: s.policy });148- }149- case "listviews":150- return reply({ result: await viewsSummary(relay) });151- case "banpubkey": {188+ },189+ },190+ listviews: { action: "read", reads: true, run: async ({ relay, reply }) => reply({ result: await viewsSummary(relay) }) },191+ banpubkey: {192+ action: "ban",193+ run: async ({ relay, s, params, str, hex64, reply, outranks }) => {152194 const pk = str(0);153195 if (!hex64(pk)) return reply({ error: "invalid: pubkey must be 64 hex chars" }, 400);154196 if (s.isOwner(pk)) return reply({ error: "invalid: cannot ban the owner" }, 400);@@ -156,8 +198,11 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
156198 // (pubkey, reason, erase): erase deletes everything they wrote and uploaded.157199 await relay.ban(pk, str(1), params[2] === true);158200 return reply({ result: true });159- }160- case "setblockedwords": {201+ },202+ },203+ setblockedwords: {204+ action: "ban",205+ run: ({ s, params, reply }) => {161206 // (words[]): content containing one is refused; the owner and moderators162207 // are exempt. A /pattern/ that does not compile fails the whole call, so163208 // the mistake is seen rather than dropped.@@ -169,68 +214,49 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
169214 const words = blockedWords(params[0])!;170215 s.update({ blockedWords: words });171216 return reply({ result: words });172- }173- case "allowpubkey":174- case "setmember": {175- // NIP-86 allowpubkey(pubkey, reason) and the richer setmember(pubkey, {name, note}).176- const pk = str(0);177- if (!hex64(pk)) return reply({ error: "invalid: pubkey must be 64 hex chars" }, 400);178- const patch = method === "allowpubkey" ? { note: str(1) } : (params[1] && typeof params[1] === "object" ? (params[1] as { name?: string | null; note?: string; role?: string; keepDays?: unknown; maxBytes?: unknown }) : {});179- if (outranks(pk)) return reply({ error: "restricted: moderators cannot edit the owner or other moderators" }, 403);180- // Per-member keep-for and cap: the owner's call, and never on the owner.181- const keepDays = Number.isInteger(patch.keepDays) && (patch.keepDays as number) >= 0 ? (patch.keepDays as number) : undefined;182- const maxBytes = Number.isInteger(patch.maxBytes) && (patch.maxBytes as number) >= 0 ? (patch.maxBytes as number) : undefined;183- if ((keepDays !== undefined || maxBytes !== undefined) && role !== "owner") return reply({ error: "restricted: only the owner sets limits" }, 403);184- const wantsRole = patch.role === "moderator" || patch.role === "member" ? patch.role : undefined;185- if (wantsRole && role !== "owner") return reply({ error: "restricted: only the owner sets roles" }, 403);186- if (wantsRole && s.isOwner(pk)) return reply({ error: "invalid: the owner's role changes by transferowner" }, 400);187- if (s.isBanned(pk)) s.setBan(pk, false);188- const err = await relay.setMember(pk, { name: patch.name, note: typeof patch.note === "string" ? patch.note : undefined, ...(s.isOwner(pk) ? {} : { keepDays, maxBytes }) }, true);189- if (err) return reply({ error: err }, 400);190- if (wantsRole && s.roleOf(pk) !== wantsRole) {191- s.setRole(pk, wantsRole);192- await relay.publishMembership({ pubkey: pk });193- }194- return reply({ result: s.member(pk) });195- }196- case "unrulepubkey":197- case "removemember": {198- // Removes a ban or a membership, whichever the pubkey has; never the owner.199- const pk = str(0);200- if (!hex64(pk)) return reply({ error: "invalid: pubkey must be 64 hex chars" }, 400);201- if (outranks(pk)) return reply({ error: "restricted: moderators cannot remove the owner or other moderators" }, 403);202- if (s.isBanned(pk)) s.setBan(pk, false);203- await relay.removeMember(pk);204- return reply({ result: true });205- }206- case "listbannedpubkeys":207- return reply({ result: s.listBans() });208- case "listallowedpubkeys":217+ },218+ },219+ allowpubkey: { action: "members", run: putMember },220+ setmember: { action: "members", run: putMember },221+ unrulepubkey: { action: "members", run: dropMember },222+ removemember: { action: "members", run: dropMember },223+ listbannedpubkeys: { action: "read", reads: true, run: ({ s, reply }) => reply({ result: s.listBans() }) },224+ listallowedpubkeys: {225+ action: "read", reads: true,226+ run: ({ s, reply }) => {209227 return reply({ result: s.members().filter((m) => m.role !== "owner").map((m) => ({ pubkey: m.pubkey, reason: m.note })) });210- case "listmembers":211- case "listpeople": {212- // Each member carries how many live invites they hold, for the tree.213- const minted = new Map<string, number>();214- for (const r of relay.sql.exec<{ created_by: string; n: number }>(`SELECT created_by, count(*) AS n FROM invites WHERE expires_at>=? AND (max_uses=0 OR uses<max_uses) GROUP BY created_by`, t)) minted.set(r.created_by, r.n);215- return reply({ result: { self: relay.identity.pubkey, members: s.members().map((m) => ({ ...m, invites: minted.get(m.pubkey) ?? 0 })) } });216- }217- case "createinvite": {228+ },229+ },230+ listmembers: { action: "read", reads: true, run: listMembers },231+ listpeople: { action: "read", reads: true, run: listMembers },232+ createinvite: {233+ action: "invites",234+ run: ({ relay, s, t, caller, role, str, num, reply }) => {218235 if (role === "member") {219236 const gate = memberInviteGate(s, relay.sql, caller, t);220237 if (gate) return reply({ error: gate }, 403);221238 }222239 const inv = mintInvite(relay.sql, caller, num(0) || 0, num(1) || 0, str(2), t);223240 return typeof inv === "string" ? reply({ error: inv }, 400) : reply({ result: inv });224- }225- case "listinvites": {241+ },242+ },243+ listinvites: {244+ action: "read", reads: true,245+ run: ({ relay, t, caller, role, reply }) => {226246 const all = listInvites(relay.sql, t);227247 return reply({ result: role === "member" ? all.filter((i) => i.created_by === caller) : all });228- }229- case "revokeinvite": {248+ },249+ },250+ revokeinvite: {251+ action: "invites",252+ run: ({ relay, caller, role, str, reply }) => {230253 if (role === "member" && inviteCreator(relay.sql, str(0)) !== caller) return reply({ error: "restricted: not your invite" }, 403);231254 return reply({ result: revokeInvite(relay.sql, str(0)) });232- }233- case "removesubtree": {255+ },256+ },257+ removesubtree: {258+ action: "members",259+ run: async ({ relay, s, str, hex64, reply, outranks }) => {234260 // (pubkey): the member and everyone they invited, plain members only.235261 const pk = str(0);236262 if (!hex64(pk)) return reply({ error: "invalid: pubkey must be 64 hex chars" }, 400);@@ -238,18 +264,28 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
238264 if (outranks(pk)) return reply({ error: "restricted: moderators cannot remove the owner or other moderators" }, 403);239265 const removed = await relay.removeSubtree(pk);240266 return reply({ result: { removed } });241- }242- case "banevent": {267+ },268+ },269+ banevent: {270+ action: "ban",271+ run: ({ relay, s, t, str, hex64, reply }) => {243272 const id = str(0);244273 if (!hex64(id)) return reply({ error: "invalid: id must be 64 hex chars" }, 400);245274 s.setEvent(id, "ban", str(1), t);246275 relay.store.deleteEvent(id);247276 return reply({ result: true });248- }249- case "allowevent":277+ },278+ },279+ allowevent: {280+ action: "ban",281+ run: ({ s, str, reply }) => {250282 s.setEvent(str(0), null);251283 return reply({ result: true });252- case "listeventsneedingmoderation": {284+ },285+ },286+ listeventsneedingmoderation: {287+ action: "reports", reads: true,288+ run: ({ relay, s, reply }) => {253289 // NIP-86's view of the reports queue: one entry per reported thing,254290 // event id or blob hash, with the report's type and words as the reason.255291 const seen = new Set<string>();@@ -260,27 +296,38 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
260296 out.push({ id: r.target_event, reason: [r.type, r.content.slice(0, 200)].filter(Boolean).join(": ") });261297 }262298 return reply({ result: out });263- }264- case "blockip": {299+ },300+ },301+ blockip: {302+ action: "ban",303+ run: ({ relay, str, reply }) => {265304 const ip = str(0).trim().toLowerCase();266305 if (!validIP(ip)) return reply({ error: "invalid: not an IP address" }, 400);267306 relay.blockIP(ip, str(1).slice(0, 200));268307 return reply({ result: true });269- }270- case "unblockip": {308+ },309+ },310+ unblockip: {311+ action: "ban",312+ run: ({ s, str, reply }) => {271313 const ip = str(0).trim().toLowerCase();272314 if (!validIP(ip)) return reply({ error: "invalid: not an IP address" }, 400);273315 s.setIPBlock(ip, false);274316 return reply({ result: true });275- }276- case "listblockedips":277- return reply({ result: s.listIPBlocks() });278- case "listreports": {317+ },318+ },319+ listblockedips: { action: "read", reads: true, run: ({ s, reply }) => reply({ result: s.listIPBlocks() }) },320+ listreports: {321+ action: "read", reads: true,322+ run: ({ relay, str, reply }) => {279323 const status = str(0) || "open";280324 // blob says whether target_event is a file this relay still holds (BUD-09).281325 return reply({ result: relay.sql.exec(`SELECT r.*, EXISTS(SELECT 1 FROM blobs b WHERE b.sha256=r.target_event) AS blob, EXISTS(SELECT 1 FROM event_rules e WHERE e.id=r.target_event AND e.rule='hide') AS hidden FROM reports r WHERE status=? ORDER BY at DESC LIMIT 200`, status).toArray() });282- }283- case "resolvereport": {326+ },327+ },328+ resolvereport: {329+ action: "reports",330+ run: async ({ relay, s, t, caller, params, str, reply, outranks }) => {284331 const id = str(0);285332 const action = str(1);286333 const row = relay.sql.exec<{ id: string; target_pubkey: string; target_event: string; status: string }>(`SELECT id, target_pubkey, target_event, status FROM reports WHERE id=?`, id).toArray()[0];@@ -304,10 +351,12 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
304351 // A dismissal that clears the last open report lifts the hold.305352 if (action === "dismiss" && row.target_event && s.isEventHidden(row.target_event) && relay.sql.exec(`SELECT 1 FROM reports WHERE target_event=? AND status='open'`, row.target_event).toArray().length === 0) s.setEvent(row.target_event, null);306353 return reply({ result: true });307- }308- case "exportconfig":309- return reply({ result: s.exportConfig(relay.slug) });310- case "importconfig": {354+ },355+ },356+ exportconfig: { action: "config", reads: true, run: ({ relay, s, reply }) => reply({ result: s.exportConfig(relay.slug) }) },357+ importconfig: {358+ action: "config",359+ run: async ({ relay, s, t, role, params, reply }) => {311360 // The import replaces the member list wholesale; the records say who312361 // came, who went and who changed role, one delta each.313362 const before = new Map(s.members().map((x) => [x.pubkey, x.role]));@@ -324,67 +373,69 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
324373 for (const pk of before.keys()) if (!after.has(pk)) changes.push({ pubkey: pk, added: false });325374 await relay.publishMembership(...changes);326375 return reply({ result: s.exportConfig(relay.slug) });327- }328- case "deleterelay": {376+ },377+ },378+ deleterelay: {379+ action: "deleteRelay",380+ run: async ({ relay, s, str, reply }) => {329381 // The confirmation is the relay's name, typed by hand, GitHub-style.330382 if (str(0) !== relay.slug) return reply({ error: "invalid: type the relay name to confirm" }, 400);331383 await relay.teardown();332384 return reply({ result: { deleted: true, name: relay.slug } });333- }334- case "listblobs": {385+ },386+ },387+ listblobs: {388+ action: "read", reads: true,389+ run: ({ relay, req, num, reply }) => {335390 const host = new URL(req.url).host;336391 const rows = relay.sql.exec<Blob>(`SELECT * FROM blobs ORDER BY uploaded DESC LIMIT ?`, Math.min(Math.max(num(0) || 100, 1), 500)).toArray();337392 return reply({ result: rows.map((b) => ({ ...descriptor(host, b), uploader: b.uploader })) });338- }339- case "deleteblob": {393+ },394+ },395+ deleteblob: {396+ action: "storage",397+ run: async ({ relay, str, hex64, reply }) => {340398 const sha = str(0);341399 if (!hex64(sha)) return reply({ error: "invalid: sha256 must be 64 hex chars" }, 400);342400 await relay.deleteBlob(sha);343401 return reply({ result: true });344- }345- case "listbannedevents":346- return reply({ result: s.listEvents("ban") });347- case "deleteevent":348- return reply({ result: relay.store.deleteEvent(str(0)) });349- case "listrecentevents":402+ },403+ },404+ listbannedevents: { action: "read", reads: true, run: ({ s, reply }) => reply({ result: s.listEvents("ban") }) },405+ deleteevent: { action: "deleteEvent", run: ({ relay, str, reply }) => reply({ result: relay.store.deleteEvent(str(0)) }) },406+ listrecentevents: {407+ action: "read", reads: true,408+ run: ({ relay, t, num, reply }) => {350409 return reply({ result: relay.store.recent(Math.min(Math.max(num(0) || 50, 1), 500), t).map((r) => JSON.parse(r)) });351- case "searchevents": {410+ },411+ },412+ searchevents: {413+ action: "read", reads: true,414+ run: ({ relay, t, str, num, reply }) => {352415 // (query, limit): the NIP-50 index, in the shape of listrecentevents.353416 const q = str(0).trim().slice(0, 200);354417 const limit = Math.min(Math.max(num(1) || 50, 1), 200);355418 if (!q) return reply({ result: relay.store.recent(limit, t).map((r) => JSON.parse(r)) });356419 return reply({ result: relay.store.query({ tags: {}, search: q, limit }, { pubkeys: [], all: true }, limit, t).rows.map((r) => JSON.parse(r)) });357- }358- case "pinevent":359- case "unpinevent": {360- // (id or address): the group's pin list, at most MAX_PINS, in order.361- const ref = str(0).trim().toLowerCase();362- const tag = hex64(ref) ? ["e", ref] : /^\d{1,5}:[0-9a-f]{64}:/.test(ref) ? ["a", ref] : null;363- if (!tag) return reply({ error: "invalid: give an event id or an address kind:pubkey:d" }, 400);364- const cur = s.pins().filter((x) => x[1] !== ref);365- const next = method === "pinevent" ? [...cur, tag] : cur;366- if (next.length > MAX_PINS) return reply({ error: `invalid: at most ${MAX_PINS} pins` }, 400);367- s.setPins(next);368- await relay.publishPins();369- return reply({ result: next });370- }371- case "listpins":372- return reply({ result: s.pins() });373- case "allowkind":374- case "disallowkind":375- case "unrulekind": {376- const k = num(0);377- if (!Number.isInteger(k) || k < 0 || k > 65535) return reply({ error: "invalid: kind out of range" }, 400);378- s.setKind(k, method === "allowkind" ? "allow" : method === "disallowkind" ? "block" : null);379- await relay.publishDiscovery();380- return reply({ result: true });381- }382- case "storagestats": {420+ },421+ },422+ pinevent: { action: "deleteEvent", run: pin },423+ unpinevent: { action: "deleteEvent", run: pin },424+ listpins: { action: "read", reads: true, run: ({ s, reply }) => reply({ result: s.pins() }) },425+ allowkind: { action: "rules", run: ruleKind },426+ disallowkind: { action: "rules", run: ruleKind },427+ unrulekind: { action: "rules", run: ruleKind },428+ storagestats: {429+ action: "storage", reads: true,430+ run: ({ relay, s, reply }) => {383431 const kinds = relay.store.kindStats().map((k) => ({ ...k, days: s.retentionDays(k.kind), replaceable: isReplaceable(k.kind), protected: isProtected(k.kind) }));384432 const blobs = relay.sql.exec<{ n: number | null; bytes: number | null }>(`SELECT count(*) AS n, sum(size) AS bytes FROM blobs`).one();385433 return reply({ result: { kinds, events: kinds.reduce((a, k) => a + k.n, 0), eventBytes: kinds.reduce((a, k) => a + k.bytes, 0), databaseBytes: relay.eventBytes(), blobs: blobs.n ?? 0, mediaBytes: blobs.bytes ?? 0, dumps: listDumps(relay.sql).length, dumpBytes: dumpBytes(relay.sql), retention: s.listRetention() } });386- }387- case "setretention": {434+ },435+ },436+ setretention: {437+ action: "rules",438+ run: ({ s, params, num, reply }) => {388439 // (kind | null, days): days 0 removes the rule.389440 const kind = params[0] === null ? null : num(0);390441 const days = num(1);@@ -393,10 +444,12 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
393444 const err = s.setRetention(kind, days);394445 if (err) return reply({ error: err }, 400);395446 return reply({ result: s.listRetention() });396- }397- case "listretention":398- return reply({ result: s.listRetention() });399- case "purgekind": {447+ },448+ },449+ listretention: { action: "read", reads: true, run: ({ s, reply }) => reply({ result: s.listRetention() }) },450+ purgekind: {451+ action: "rules",452+ run: ({ relay, t, params, num, reply }) => {400453 // (kind | null, olderThanDays): 0 days means everything of that kind.401454 const kind = params[0] === null ? null : num(0);402455 const days = num(1) || 0;@@ -406,22 +459,34 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
406459 const before = days > 0 ? t - days * 86400 : Number.MAX_SAFE_INTEGER;407460 const gone = kind === null ? relay.store.purge(null, before, relay.store.kindStats().map((k) => k.kind).filter((k) => isReplaceable(k) || isProtected(k)), relay.identity.pubkey) : relay.store.purge(kind, before, [], relay.identity.pubkey);408461 return reply({ result: { deleted: gone } });409- }410- case "listallowedkinds":411- return reply({ result: s.listKinds("allow") });412- case "listblockedkinds":413- return reply({ result: s.listKinds("block") });414- case "notifytest": {462+ },463+ },464+ listallowedkinds: { action: "read", reads: true, run: ({ s, reply }) => reply({ result: s.listKinds("allow") }) },465+ listblockedkinds: { action: "read", reads: true, run: ({ s, reply }) => reply({ result: s.listKinds("block") }) },466+ notifytest: {467+ action: "identity", reads: true,468+ run: async ({ relay, reply }) => {415469 const sent = await notify(relay, "test", `This is a test from ${relay.slug}. Notifications work: you will hear from the relay here about the things you switched on.`, "a test from " + relay.slug);416470 return reply({ result: { sent } });417- }418- case "resetrules":471+ },472+ },473+ resetrules: {474+ action: "rules",475+ run: async ({ relay, s, reply }) => {419476 s.resetRules();420477 await relay.publishMembership();421478 return reply({ result: s.policy });422- case "listpresets":479+ },480+ },481+ listpresets: {482+ action: "read", reads: true,483+ run: ({ reply }) => {423484 return reply({ result: PRESETS.map((x) => ({ name: x.name, title: x.title, about: x.about, source: x.source, every: x.every })) });424- case "applypreset": {485+ },486+ },487+ applypreset: {488+ action: "rules",489+ run: async ({ relay, s, params, str, reply }) => {425490 // (name, {source?}): writes, reads, directory, kind rules and keep-for426491 // rules in one go. A replica preset also gets a standing pull of its427492 // kinds from the source; an earlier replica job is replaced.@@ -449,8 +514,11 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
449514 // The read rule and the directory moved, so every view's audience may have.450515 for (const v of VIEWS) await relay.publishView(v.name);451516 return reply({ result: { ...s.policy, job } });452- }453- case "forkrelay": {517+ },518+ },519+ forkrelay: {520+ action: "fork",521+ run: async ({ relay, req, caller, params, hex64, reply }) => {454522 // ({name?, holder?, filter?, people?}): lease a new name reserved for455523 // holder (the caller by default), copy this relay into it, hand over.456524 const o = params[0] && typeof params[0] === "object" ? (params[0] as Record<string, unknown>) : {};@@ -470,8 +538,11 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
470538 const r = await relay.forkRelay(new URL(req.url).host, { name: name || undefined, holder, filter, people: o.people === true });471539 if (typeof r === "string") return reply({ error: r }, r.startsWith("invalid") ? 400 : r.startsWith("error") ? 503 : 409);472540 return reply({ result: { ...r, handover: `Your relay is ready at ${r.console}. Open it and claim it with your key before it expires; the events are copying over now.` } });473- }474- case "pullfrom": {541+ },542+ },543+ pullfrom: {544+ action: "jobs",545+ run: async ({ relay, str, reply }) => {475546 // (url): copy what another relay has that this one lacks. Runs in476547 // the background; pullstatus reports on it.477548 const url = str(0).trim();@@ -480,22 +551,24 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
480551 const err = await relay.pullStart(url);481552 if (err) return reply({ error: err }, 409);482553 return reply({ result: { started: true, url } });483- }484- case "pullstatus":485- return reply({ result: await relay.pullStatus() });486- case "listjobs":487- return reply({ result: await relay.jobs() });488- case "addjob": {554+ },555+ },556+ pullstatus: { action: "jobs", reads: true, run: async ({ relay, reply }) => reply({ result: await relay.pullStatus() }) },557+ listjobs: { action: "jobs", reads: true, run: async ({ relay, reply }) => reply({ result: await relay.jobs() }) },558+ addjob: {559+ action: "jobs",560+ run: async ({ relay, params, reply }) => {489561 // ({kind, relays, filter?, every?, label?}): a pull or push, once or standing.490562 const r = await relay.addJob(params[0]);491563 if (typeof r === "string") return reply({ error: r }, r.startsWith("invalid") ? 400 : 409);492564 return reply({ result: r });493- }494- case "removejob":495- return reply({ result: await relay.removeJob(str(0)) });496- case "runjob":497- return reply({ result: await relay.runJob(str(0)) });498- case "backfill": {565+ },566+ },567+ removejob: { action: "jobs", run: async ({ relay, str, reply }) => reply({ result: await relay.removeJob(str(0)) }) },568+ runjob: { action: "jobs", run: async ({ relay, str, reply }) => reply({ result: await relay.runJob(str(0)) }) },569+ backfill: {570+ action: "jobs",571+ run: async ({ relay, s, p, params, reply }) => {499572 // (relays?): fetch the owner's own history from the relays in their500573 // kind 10002 stored here, or from the given list.501574 const given = Array.isArray(params[0]) ? (params[0] as unknown[]).filter((u): u is string => typeof u === "string" && u.trim() !== "") : [];@@ -504,8 +577,11 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
504577 const r = await relay.addJob({ kind: "pull", label: "backfill", relays, filter: { authors: [p.owner] }, every: 0 });505578 if (typeof r === "string") return reply({ error: r }, r.startsWith("invalid") ? 400 : 409);506579 return reply({ result: r });507- }508- case "transferowner": {580+ },581+ },582+ transferowner: {583+ action: "transfer",584+ run: async ({ relay, s, caller, str, hex64, reply }) => {509585 // (pubkey): hands the relay to a member; the old owner stays as a moderator.510586 const pk = str(0);511587 if (!hex64(pk)) return reply({ error: "invalid: pubkey must be 64 hex chars" }, 400);@@ -514,17 +590,21 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
514590 await relay.ownerSeenNow();515591 await relay.publishMembership({ pubkey: pk }, { pubkey: caller });516592 return reply({ result: { owner: pk, previous: caller } });517- }518- case "listdumps":519- return reply({ result: listDumps(relay.sql).map((d) => ({ ...d, url: "/dumps/" + d.name })) });520- case "deletedump": {593+ },594+ },595+ listdumps: { action: "storage", reads: true, run: ({ relay, reply }) => reply({ result: listDumps(relay.sql).map((d) => ({ ...d, url: "/dumps/" + d.name })) }) },596+ deletedump: {597+ action: "storage",598+ run: async ({ relay, str, reply }) => {521599 const name = str(0);522600 if (!DUMP_NAME_RE.test(name)) return reply({ error: "invalid: not a dump name" }, 400);523601 return reply({ result: await deleteDump(relay, name) });524- }525- case "dumpnow":526- return reply({ result: await writeDump(relay, t) });527- case "setsuccession": {602+ },603+ },604+ dumpnow: { action: "storage", run: async ({ relay, t, reply }) => reply({ result: await writeDump(relay, t) }) },605+ setsuccession: {606+ action: "transfer",607+ run: async ({ relay, s, params, str, num, hex64, reply }) => {528608 // ({heir, afterDays}) or (heir, afterDays): the heir must be a member.529609 const o = params[0] && typeof params[0] === "object" ? (params[0] as Record<string, unknown>) : { heir: str(0), afterDays: num(1) };530610 const heir = typeof o.heir === "string" ? o.heir : "";@@ -533,48 +613,128 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
533613 if (err) return reply({ error: err }, 400);534614 await relay.ownerSeenNow();535615 return reply({ result: await relay.successionStatus() });536- }537- case "clearsuccession":616+ },617+ },618+ clearsuccession: {619+ action: "transfer",620+ run: async ({ relay, s, reply }) => {538621 s.update({ succession: null });539622 await relay.ownerSeenNow();540623 return reply({ result: true });541- case "successionstatus":624+ },625+ },626+ successionstatus: {627+ action: "read", reads: true,628+ run: async ({ relay, role, reply }) => {542629 if (role !== "owner") return reply({ error: "restricted: only the owner and the heir may see this" }, 403);543630 return reply({ result: await relay.successionStatus() });544- case "changerelayname":631+ },632+ },633+ changerelayname: {634+ action: "identity",635+ run: async ({ relay, s, str, reply }) => {545636 s.update({ name: str(0).slice(0, 200) });546637 await relay.publishMembership();547638 return reply({ result: true });548- case "changerelaydescription":639+ },640+ },641+ changerelaydescription: {642+ action: "identity",643+ run: async ({ relay, s, str, reply }) => {549644 s.update({ description: str(0).slice(0, 2000) });550645 await relay.publishMembership();551646 return reply({ result: true });552- case "changerelayicon":647+ },648+ },649+ changerelayicon: {650+ action: "identity",651+ run: async ({ relay, s, str, reply }) => {553652 s.update({ icon: str(0).slice(0, 2000) });554653 await relay.publishMembership();555654 return reply({ result: true });556- // Custom domains: the relay under a hostname the owner controls.557- case "adddomain": {655+ },656+ },657+ // Custom domains: the relay under a hostname the owner controls.658+ adddomain: {659+ action: "identity",660+ run: async ({ relay, str, reply }) => {558661 const r = await addDomain(relay, str(0));559662 if (typeof r === "string") return reply({ error: r }, r.startsWith("error:") ? 502 : 400);560663 return reply({ result: r });561- }562- case "checkdomain": {664+ },665+ },666+ checkdomain: {667+ action: "identity", reads: true,668+ run: async ({ relay, str, reply }) => {563669 const r = await checkDomain(relay, str(0));564670 if (typeof r === "string") return reply({ error: r }, r.startsWith("error:") ? 502 : 400);565671 return reply({ result: r });566- }567- case "removedomain": {672+ },673+ },674+ removedomain: {675+ action: "identity",676+ run: async ({ relay, str, reply }) => {568677 const err = await removeDomain(relay, str(0));569678 if (err) return reply({ error: err }, err.startsWith("error:") ? 502 : 400);570679 return reply({ result: true });571- }572- case "listdomains": {680+ },681+ },682+ listdomains: {683+ action: "identity", reads: true,684+ run: ({ relay, reply }) => {573685 const r = listDomains(relay);574686 if (typeof r === "string") return reply({ error: r }, 400);575687 return reply({ result: r });576- }688+ },689+ },690+};691+692+export async function manage(relay: Relay, req: Request): Promise<Response> {693+ const cors = { "access-control-allow-origin": "*", "content-type": "application/json" };694+ const reply = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: cors });695+ const bodyText = await req.text();696+ let body: { method?: unknown; params?: unknown };697+ try {698+ body = JSON.parse(bodyText);699+ } catch {700+ return reply({ error: "invalid: body is not JSON" }, 400);577701 }578- return reply({ error: "unsupported: " + method }, 400);702+ const method = typeof body.method === "string" ? body.method : "";703+ const params = Array.isArray(body.params) ? body.params : [];704+ const m = METHODS[method];705+ if (!m) return reply({ error: "unsupported: unknown method " + method }, 400);706+707+ const auth = verifyNIP98(req.headers.get("authorization") ?? "", req.url, req.method, bodyText);708+ if (typeof auth === "string") return reply({ error: auth }, 401);709+ const s = relay.settings;710+ const p = s.policy;711+ const t = now();712+ const caller = auth.pubkey;713+714+ const role = s.roleOf(caller);715+ const outranks = (pk: string) => role !== "owner" && (s.isOwner(pk) || s.roleOf(pk) === "moderator");716+ const str = (i: number) => (typeof params[i] === "string" ? (params[i] as string) : "");717+ const num = (i: number) => (Number.isInteger(params[i]) ? (params[i] as number) : parseInt(str(i), 10));718+ const hex64 = (v: string) => /^[0-9a-f]{64}$/.test(v);719+ const call: Call = { relay, req, s, p, t, caller, role, method, params, str, num, hex64, reply, outranks };720+ if (m.action === "open") return m.run(call);721+ // The heir may read the succession status: a member, with no console otherwise.722+ if (method === "successionstatus" && p.succession && p.succession.heir === caller) return reply({ result: await relay.successionStatus() });723+ // A plain member reaches their own invites when the owner opened the724+ // invite tree (memberInvites); the invite methods keep them to their own.725+ const ownInvites = role === "member" && p.memberInvites.depth > 0 && (method === "createinvite" || method === "listinvites" || method === "revokeinvite");726+ if (role === "owner") void relay.ownerSeen(caller);727+ if (!ownInvites && !can(role, m.action)) {728+ 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";729+ return reply({ error: why }, 403);579730 }731+ // Every method that changed something and answered 200 goes in the732+ // moderation log with who called it (audit.ts). deleterelay took the733+ // table with it.734+ const resp = await m.run(call);735+ if (resp.status === 200 && !m.reads && method !== "deleterelay") {736+ const { target, detail } = detailOf(method, params);737+ relay.audit.record(t, caller, method, target, detail);738+ }739+ return resp;580740 }@@ -1,6 +1,7 @@
1-// Who may do what. One table for both doors: the NIP-86 methods the console2-// calls and the NIP-29 moderation events clients send. The owner may do3-// everything; a moderator keeps the peace and nothing more.1+// Who may do what. One permission table for both doors: the NIP-86 methods2+// the console calls (each names its action in manage.ts) and the NIP-293+// moderation events clients send. The owner may do everything; a moderator4+// keeps the peace and nothing more.45 56 export type Role = "owner" | "moderator" | "member";67 @@ -33,27 +34,3 @@ export const ROLES: { role: Role; about: string }[] = [
3334 { role: "owner", about: "everything: rules, identity, people, storage, fuel" },3435 { role: "moderator", about: "people, bans, deletions, invites, reports" },3536 ];36-37-// Every NIP-86 method and the action it needs. claim and supportedmethods38-// are open and not listed.39-export const METHOD_ACTIONS: Record<string, Action> = {40- stats: "read", getpolicy: "read", listpresets: "read",41- listbannedpubkeys: "read", listallowedpubkeys: "read", listmembers: "read", listpeople: "read", listinvites: "read",42- listbannedevents: "read", listrecentevents: "read", listallowedkinds: "read", listblockedkinds: "read", listblobs: "read", listretention: "read", listreports: "read",43- setmember: "members", allowpubkey: "members", removemember: "members", unrulepubkey: "members",44- banpubkey: "ban", banevent: "ban", allowevent: "ban", blockip: "ban", unblockip: "ban", listblockedips: "read", setblockedwords: "ban",45- deleteevent: "deleteEvent", pinevent: "deleteEvent", unpinevent: "deleteEvent", listpins: "read", searchevents: "read",46- createinvite: "invites", revokeinvite: "invites",47- resolvereport: "reports", listeventsneedingmoderation: "reports", listaudit: "read",48- setpolicy: "rules", allowkind: "rules", disallowkind: "rules", unrulekind: "rules", setretention: "rules", purgekind: "rules", resetrules: "rules", applypreset: "rules",49- changerelayname: "identity", changerelaydescription: "identity", changerelayicon: "identity",50- adddomain: "identity", removedomain: "identity", checkdomain: "identity", listdomains: "identity",51- listviews: "read", storagestats: "storage", deleteblob: "storage", listdumps: "storage", deletedump: "storage", dumpnow: "storage",52- removesubtree: "members",53- exportconfig: "config", importconfig: "config",54- pullfrom: "jobs", pullstatus: "jobs", listjobs: "jobs", addjob: "jobs", removejob: "jobs", runjob: "jobs", backfill: "jobs",55- transferowner: "transfer", setsuccession: "transfer", clearsuccession: "transfer", successionstatus: "read",56- notifytest: "identity",57- forkrelay: "fork",58- deleterelay: "deleteRelay",59-};