clone

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

dependabot/github_actions/actions-9b61906d8b
bindws / src / manage.ts
manage.ts 46.5K
1// NIP-86 relay management over HTTP, authenticated with NIP-98. The owner is2// whoever claimed the relay; "claim" is the one method anyone may call, and3// only while the relay is unclaimed.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.9import { now } from "./event.ts";10import type { Relay } from "./relay.ts";11import { CODE_RE, inviteCreator, listClaims, listInvites, memberInviteGate, mintInvite, revokeInvite } from "./invites.ts";12import { descriptor, type Blob } from "./blossom.ts";13import { featureOn, badBlockedWord, blockedWords, policyPatch, type Policy, type Settings } from "./settings.ts";14import { deliveryStatus } from "./delivery.ts";15import { applyConfig, exportConfig, parseConfig, planConfig } from "./config.ts";16import { isReplaceable, isProtected, validIP } from "./settings.ts";17import { checkPullURL } from "./pull.ts";18import { VIEWS, publishView, viewsSummary } from "./views.ts";19import { MAX_PINS } from "./groups.ts";20import { relaysFromList } from "./jobs.ts";21import { notify } from "./notify.ts";22import { DUMP_NAME_RE, deleteDump, dumpBytes, listDumps, writeDump } from "./dumps.ts";23import { can, type Action, type Role } from "./roles.ts";24import { detailOf } from "./audit.ts";25import { PRESETS, applyPreset, findPreset } from "./presets.ts";26import { CONNECTION_TEMPLATES, parseConnections } from "./connections.ts";27import { addDomain, checkDomain, listDomains, removeDomain, setDomainSite } from "./domains.ts";28import { verifyNIP98 } from "./auth.ts";29import { SITE_KINDS, checkSite, siteLabel, sitePaths } from "./sites.ts";30import { gitStorage } from "./git-storage.ts";31import { createBackup, deleteBackup, listBackups, DEFAULT_BACKUP_LIMITS } from "./backups.ts";3233// A call: the relay and the request, who is calling and as what, the34// parameters with their readers, and how to answer.35export interface Call {36 relay: Relay;37 req: Request;38 s: Settings;39 p: Policy;40 t: number;41 caller: string;42 role: Role | null;43 method: string;44 params: unknown[];45 str: (i: number) => string;46 num: (i: number) => number;47 hex64: (v: string) => boolean;48 reply: (body: unknown, status?: number) => Response;49 // outranks says the caller may not touch this pubkey: a moderator keeps50 // their hands off the owner and other moderators.51 outranks: (pk: string) => boolean;52}5354interface Method {55 // The action the caller's role must allow (roles.ts); "open" for the two56 // methods anyone may call.57 action: Action | "open";58 // Changes nothing, so it is not written to the moderation log.59 reads?: true;60 // Plain members may manage their own invitations when the tree is open.61 ownInvites?: true;62 ownListHistory?: true;63 run: (c: Call) => Response | Promise<Response>;64}6566// putMember answers allowpubkey and setmember.67const putMember = async ({ relay, s, role, method, params, str, hex64, reply, outranks }: Call) => {68 // NIP-86 allowpubkey(pubkey, reason) and the richer setmember(pubkey, {name, note}).69 const pk = str(0);70 if (!hex64(pk)) return reply({ error: "invalid: pubkey must be 64 hex chars" }, 400);71 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 }) : {});72 if (outranks(pk)) return reply({ error: "restricted: moderators cannot edit the owner or other moderators" }, 403);73 // Per-member keep-for and cap: the owner's call, and never on the owner.74 const keepDays = Number.isInteger(patch.keepDays) && (patch.keepDays as number) >= 0 ? (patch.keepDays as number) : undefined;75 const maxBytes = Number.isInteger(patch.maxBytes) && (patch.maxBytes as number) >= 0 ? (patch.maxBytes as number) : undefined;76 if ((keepDays !== undefined || maxBytes !== undefined) && role !== "owner") return reply({ error: "restricted: only the owner sets limits" }, 403);77 const wantsRole = patch.role === "moderator" || patch.role === "member" ? patch.role : undefined;78 if (wantsRole && role !== "owner") return reply({ error: "restricted: only the owner sets roles" }, 403);79 if (wantsRole && s.isOwner(pk)) return reply({ error: "invalid: the owner's role changes by transferowner" }, 400);80 if (s.isBanned(pk)) s.setBan(pk, false);81 const err = await relay.setMember(pk, { name: patch.name, note: typeof patch.note === "string" ? patch.note : undefined, ...(s.isOwner(pk) ? {} : { keepDays, maxBytes }) }, true);82 if (err) return reply({ error: err }, 400);83 if (wantsRole && s.roleOf(pk) !== wantsRole) {84 s.setRole(pk, wantsRole);85 await relay.publishMembership({ pubkey: pk });86 }87 return reply({ result: s.member(pk) });88};8990// dropMember answers unrulepubkey and removemember.91const dropMember = async ({ relay, s, str, hex64, reply, outranks }: Call) => {92 // Removes a ban or a membership, whichever the pubkey has; never the owner.93 const pk = str(0);94 if (!hex64(pk)) return reply({ error: "invalid: pubkey must be 64 hex chars" }, 400);95 if (outranks(pk)) return reply({ error: "restricted: moderators cannot remove the owner or other moderators" }, 403);96 if (s.isBanned(pk)) s.setBan(pk, false);97 await relay.removeMember(pk);98 return reply({ result: true });99};100101// listMembers answers listmembers and listpeople.102const listMembers = ({ relay, s, t, reply }: Call) => {103 // Each member carries how many live invites they hold, for the tree.104 const minted = new Map<string, number>();105 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);106 return reply({ result: { self: relay.identity.pubkey, members: s.members().map((m) => ({ ...m, invites: minted.get(m.pubkey) ?? 0 })) } });107};108109// pin answers pinevent and unpinevent.110const pin = async ({ relay, s, method, str, hex64, reply }: Call) => {111 // (id or address): the group's pin list, at most MAX_PINS, in order.112 const ref = str(0).trim().toLowerCase();113 const tag = hex64(ref) ? ["e", ref] : /^\d{1,5}:[0-9a-f]{64}:/.test(ref) ? ["a", ref] : null;114 if (!tag) return reply({ error: "invalid: give an event id or an address kind:pubkey:d" }, 400);115 const cur = s.pins().filter((x) => x[1] !== ref);116 const next = method === "pinevent" ? [...cur, tag] : cur;117 if (next.length > MAX_PINS) return reply({ error: `invalid: at most ${MAX_PINS} pins` }, 400);118 s.setPins(next);119 await relay.publishPins();120 return reply({ result: next });121};122123// ruleKind answers allowkind, disallowkind and unrulekind.124const ruleKind = async ({ relay, s, method, num, reply }: Call) => {125 const k = num(0);126 if (!Number.isInteger(k) || k < 0 || k > 65535) return reply({ error: "invalid: kind out of range" }, 400);127 s.setKind(k, method === "allowkind" ? "allow" : method === "disallowkind" ? "block" : null);128 await relay.publishDiscovery();129 return reply({ result: true });130};131132export const METHODS: Record<string, Method> = {133 // claim: the one method anyone may call, while the relay is unclaimed.134 claim: {135 action: "open", reads: true,136 run: async ({ relay, s, p, t, caller, reply }) => {137 if (p.owner === "") {138 // A leased relay converts in place, keeping its events and files. A139 // lease taken with a signature is reserved for that key.140 const lease = p.lease;141 if (lease && lease.holder && lease.holder !== caller) return reply({ error: "restricted: this temporary relay is reserved for another key" }, 403);142 if (lease && lease.until <= t) return reply({ error: "restricted: this temporary relay has expired" }, 403);143 s.update(lease ? { owner: caller, lease: null, name: "", description: "" } : { owner: caller });144 s.upsertMember(caller, { via: "claimed" }, t);145 await relay.succession.seenNow();146 await relay.publishMembership();147 return reply({ result: { owner: caller, claimed: true, ...(lease ? { converted: true } : {}) } });148 }149 return reply({ result: { owner: p.owner, claimed: s.isOwner(caller) } }, s.isOwner(caller) ? 200 : 403);150 },151 },152 supportedmethods: { action: "open", reads: true, run: ({ reply }) => reply({ result: Object.keys(METHODS) }) },153 listaudit: {154 action: "read", reads: true,155 run: ({ relay, num, reply }) => {156 // (before): rows older than that seq, newest first; 0 or nothing for the newest page.157 return reply({ result: relay.audit.list(Math.max(num(0) || 0, 0)) });158 },159 },160 stats: {161 action: "read", reads: true,162 run: ({ relay, p, reply }) => {163 const d = new Date(now() * 1000);164 const monthStart = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1) / 1000;165 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() } });166 },167 },168 getpolicy: { action: "read", reads: true, run: ({ p, reply }) => reply({ result: p }) },169 setpolicy: {170 action: "rules",171 run: async ({ relay, s, p, params, reply }) => {172 const patch = params[0] && typeof params[0] === "object" ? (params[0] as Record<string, unknown>) : {};173 const clean = policyPatch(patch, p);174 if (patch.git !== undefined && !clean.git) return reply({ error: "invalid: Git limits must be positive integers within the supported ranges" }, 400);175 s.update(clean);176 // A read rule that tightened ends the subscriptions it no longer admits.177 relay.enforceReads();178 await relay.publishMembership();179 // A view switched off is taken down now; one switched on is published180 // now, and every view is republished when the rules changed, since181 // the read rule and the directory decide each one's audience.182 const names = patch.views && typeof patch.views === "object" ? Object.keys(patch.views as object) : [];183 if (clean.reads !== undefined || clean.directoryPublic !== undefined) for (const v of VIEWS) if (!names.includes(v.name)) names.push(v.name);184 for (const name of names) await publishView(relay, name);185 return reply({ result: s.policy });186 },187 },188 listviews: { action: "read", reads: true, run: async ({ relay, reply }) => reply({ result: await viewsSummary(relay) }) },189 banpubkey: {190 action: "ban",191 run: async ({ relay, s, params, str, hex64, reply, outranks }) => {192 const pk = str(0);193 if (!hex64(pk)) return reply({ error: "invalid: pubkey must be 64 hex chars" }, 400);194 if (s.isOwner(pk)) return reply({ error: "invalid: cannot ban the owner" }, 400);195 if (outranks(pk)) return reply({ error: "restricted: moderators cannot ban other moderators" }, 403);196 // (pubkey, reason, erase): erase deletes everything they wrote and uploaded.197 await relay.ban(pk, str(1), params[2] === true);198 return reply({ result: true });199 },200 },201 setblockedwords: {202 action: "ban",203 run: ({ s, params, reply }) => {204 // (words[]): content containing one is refused; the owner and moderators205 // are exempt. A /pattern/ that does not compile fails the whole call, so206 // the mistake is seen rather than dropped.207 if (!Array.isArray(params[0])) return reply({ error: "invalid: give a list of words" }, 400);208 for (const x of params[0]) {209 const why = badBlockedWord(x);210 if (why) return reply({ error: why }, 400);211 }212 const words = blockedWords(params[0])!;213 s.update({ blockedWords: words });214 return reply({ result: words });215 },216 },217 allowpubkey: { action: "members", run: putMember },218 setmember: { action: "members", run: putMember },219 unrulepubkey: { action: "members", run: dropMember },220 removemember: { action: "members", run: dropMember },221 listbannedpubkeys: { action: "read", reads: true, run: ({ s, reply }) => reply({ result: s.listBans() }) },222 listallowedpubkeys: {223 action: "read", reads: true,224 run: ({ s, reply }) => {225 return reply({ result: s.members().filter((m) => m.role !== "owner").map((m) => ({ pubkey: m.pubkey, reason: m.note })) });226 },227 },228 listmembers: { action: "read", reads: true, run: listMembers },229 listpeople: { action: "read", reads: true, run: listMembers },230 createinvite: {231 action: "invites", ownInvites: true,232 run: ({ relay, s, t, caller, role, str, num, reply }) => {233 if (role === "member") {234 const gate = memberInviteGate(s, relay.sql, caller, t);235 if (gate) return reply({ error: gate }, 403);236 }237 const inv = mintInvite(relay.sql, caller, num(0) || 0, num(1) || 0, str(2), t);238 return typeof inv === "string" ? reply({ error: inv }, 400) : reply({ result: inv });239 },240 },241 listinvites: {242 action: "read", reads: true, ownInvites: true,243 run: ({ relay, t, caller, role, reply }) => {244 const all = listInvites(relay.sql, t);245 return reply({ result: role === "member" ? all.filter((i) => i.created_by === caller) : all });246 },247 },248 revokeinvite: {249 action: "invites", ownInvites: true,250 run: ({ relay, caller, role, str, reply }) => {251 if (role === "member" && inviteCreator(relay.sql, str(0)) !== caller) return reply({ error: "restricted: not your invite" }, 403);252 return reply({ result: revokeInvite(relay.sql, str(0)) });253 },254 },255 // NIP-86 proposal #2408, dcf5af03aacd5ca9c70c51448e32f60477f6ac34.256 // These codes join members; the open claim method above assigns ownership.257 listclaims: {258 action: "invites", reads: true, ownInvites: true,259 run: ({ relay, t, caller, role, params, reply }) => {260 if (params.length !== 0) return reply({ error: "invalid: listclaims takes no parameters" }, 400);261 return reply({ result: listClaims(relay.sql, t, role === "member" ? caller : "") });262 },263 },264 listlisthistory: {265 action: "read", reads: true, ownListHistory: true,266 run: ({ relay, caller, t, reply }) => reply({ result: relay.store.listHistory(caller, t) }),267 },268 restorelist: {269 action: "read", reads: true, ownListHistory: true,270 run: ({ relay, caller, params, str, hex64, reply }) => {271 if (params.length !== 1 || !hex64(str(0))) return reply({ error: "invalid: give one saved list event id" }, 400);272 const restored = relay.store.restoreHistory(caller, str(0), now());273 return typeof restored === "string" ? reply({ error: restored }, 404) : reply({ result: restored });274 },275 },276 createclaim: {277 action: "invites", ownInvites: true,278 run: ({ relay, s, t, caller, role, params, str, reply }) => {279 if (params.length !== 1 || !CODE_RE.test(str(0))) return reply({ error: "invalid: give one invite code of 4 to 64 letters, digits, dash or underscore" }, 400);280 if (role === "member") {281 const gate = memberInviteGate(s, relay.sql, caller, t);282 if (gate) return reply({ error: gate }, 403);283 }284 const inv = mintInvite(relay.sql, caller, 0, 0, "", t, str(0));285 return typeof inv === "string" ? reply({ error: inv }, 400) : reply({ result: true });286 },287 },288 deleteclaim: {289 action: "invites", ownInvites: true,290 run: ({ relay, caller, role, params, str, reply }) => {291 if (params.length !== 1 || !CODE_RE.test(str(0))) return reply({ error: "invalid: give one invite code of 4 to 64 letters, digits, dash or underscore" }, 400);292 if (role === "member" && inviteCreator(relay.sql, str(0)) !== caller) return reply({ error: "restricted: not your invite" }, 403);293 revokeInvite(relay.sql, str(0));294 return reply({ result: true });295 },296 },297 removesubtree: {298 action: "members",299 run: async ({ relay, s, str, hex64, reply, outranks }) => {300 // (pubkey): the member and everyone they invited, plain members only.301 const pk = str(0);302 if (!hex64(pk)) return reply({ error: "invalid: pubkey must be 64 hex chars" }, 400);303 if (s.isOwner(pk)) return reply({ error: "invalid: cannot remove the owner" }, 400);304 if (outranks(pk)) return reply({ error: "restricted: moderators cannot remove the owner or other moderators" }, 403);305 const removed = await relay.removeSubtree(pk);306 return reply({ result: { removed } });307 },308 },309 banevent: {310 action: "ban",311 run: ({ relay, s, t, str, hex64, reply }) => {312 const id = str(0);313 if (!hex64(id)) return reply({ error: "invalid: id must be 64 hex chars" }, 400);314 s.setEvent(id, "ban", str(1), t);315 relay.store.deleteEvent(id);316 return reply({ result: true });317 },318 },319 allowevent: {320 action: "ban",321 run: ({ s, str, reply }) => {322 s.setEvent(str(0), null);323 return reply({ result: true });324 },325 },326 listeventsneedingmoderation: {327 action: "reports", reads: true,328 run: ({ relay, s, reply }) => {329 // NIP-86's view of the reports queue: one entry per reported thing,330 // event id or blob hash, with the report's type and words as the reason.331 const seen = new Set<string>();332 const out: { id: string; reason: string }[] = [];333 for (const r of relay.sql.exec<{ target_event: string; type: string; content: string }>(`SELECT target_event, type, content FROM reports WHERE status='open' AND target_event<>'' ORDER BY at DESC LIMIT 500`)) {334 if (seen.has(r.target_event)) continue;335 seen.add(r.target_event);336 out.push({ id: r.target_event, reason: [r.type, r.content.slice(0, 200)].filter(Boolean).join(": ") });337 }338 return reply({ result: out });339 },340 },341 blockip: {342 action: "ban",343 run: ({ relay, str, reply }) => {344 const ip = str(0).trim().toLowerCase();345 if (!validIP(ip)) return reply({ error: "invalid: not an IP address" }, 400);346 relay.blockIP(ip, str(1).slice(0, 200));347 return reply({ result: true });348 },349 },350 unblockip: {351 action: "ban",352 run: ({ s, str, reply }) => {353 const ip = str(0).trim().toLowerCase();354 if (!validIP(ip)) return reply({ error: "invalid: not an IP address" }, 400);355 s.setIPBlock(ip, false);356 return reply({ result: true });357 },358 },359 listblockedips: { action: "read", reads: true, run: ({ s, reply }) => reply({ result: s.listIPBlocks() }) },360 listreports: {361 action: "read", reads: true,362 run: ({ relay, str, reply }) => {363 const status = str(0) || "open";364 // blob says whether target_event is a file this relay still holds (BUD-09).365 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() });366 },367 },368 resolvereport: {369 action: "reports",370 run: async ({ relay, s, t, caller, params, str, reply, outranks }) => {371 const id = str(0);372 const action = str(1);373 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];374 if (!row) return reply({ error: "invalid: no such report" }, 400);375 if (!["ban", "delete", "dismiss"].includes(action)) return reply({ error: "invalid: action must be ban, delete or dismiss" }, 400);376 // target_event is an event id or, for a blob report (BUD-09), a sha256.377 // Either way the id goes on the banned list, so the thing cannot return.378 const remove = async () => {379 if (!row.target_event) return;380 s.setEvent(row.target_event, "ban", "report " + id.slice(0, 8), t);381 relay.store.deleteEvent(row.target_event);382 if (relay.sql.exec(`SELECT 1 FROM blobs WHERE sha256=?`, row.target_event).toArray().length) await relay.deleteBlob(row.target_event);383 };384 if (action === "ban") {385 if (s.isOwner(row.target_pubkey)) return reply({ error: "invalid: cannot ban the owner" }, 400);386 if (outranks(row.target_pubkey)) return reply({ error: "restricted: moderators cannot ban other moderators" }, 403);387 await relay.ban(row.target_pubkey, "report " + id.slice(0, 8), params[2] === true);388 await remove();389 } else if (action === "delete") await remove();390 relay.sql.exec(`UPDATE reports SET status='resolved', resolved_by=?, resolved_at=?, action=? WHERE id=?`, caller, t, action, id);391 // A dismissal that clears the last open report lifts the hold.392 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);393 return reply({ result: true });394 },395 },396 exportconfig: { action: "config", reads: true, run: ({ relay, s, reply }) => reply({ result: exportConfig(s, relay.slug) }) },397 importconfig: {398 action: "config",399 run: async ({ relay, s, p, t, params, reply }) => {400 // (document, {dryRun?}): a dry run answers with what applying would401 // change and touches nothing. Applied, the import replaces each402 // section the document has; the membership records say who came, who403 // went and who changed role, one delta each.404 const cfg = parseConfig(params[0], p);405 if (typeof cfg === "string") return reply({ error: cfg }, 400);406 const plan = planConfig(s, cfg);407 const opts = params[1] && typeof params[1] === "object" ? (params[1] as { dryRun?: unknown }) : {};408 if (opts.dryRun === true) return reply({ result: { dryRun: true, changes: plan, warnings: cfg.warnings } });409 const before = new Map(s.members().map((x) => [x.pubkey, x.role]));410 applyConfig(s, cfg, t);411 // Imported address blocks drop the sockets they now refuse.412 for (const b of s.listIPBlocks()) relay.blockIP(b.ip, b.reason);413 const after = new Map(s.members().map((x) => [x.pubkey, x.role]));414 const changes: { pubkey: string; added?: boolean }[] = [];415 for (const [pk, role] of after) {416 if (!before.has(pk)) changes.push({ pubkey: pk, added: true });417 else if (before.get(pk) !== role) changes.push({ pubkey: pk });418 }419 for (const pk of before.keys()) if (!after.has(pk)) changes.push({ pubkey: pk, added: false });420 await relay.publishMembership(...changes);421 return reply({ result: exportConfig(s, relay.slug) });422 },423 },424 deleterelay: {425 action: "deleteRelay",426 run: async ({ relay, s, str, reply }) => {427 // The confirmation is the relay's name, typed by hand, GitHub-style.428 if (str(0) !== relay.slug) return reply({ error: "invalid: type the relay name to confirm" }, 400);429 await relay.teardown();430 return reply({ result: { deleted: true, name: relay.slug } });431 },432 },433 listblobs: {434 action: "read", reads: true,435 run: ({ relay, req, num, reply }) => {436 const host = new URL(req.url).host;437 const rows = relay.sql.exec<Blob>(`SELECT * FROM blobs ORDER BY uploaded DESC LIMIT ?`, Math.min(Math.max(num(0) || 100, 1), 500)).toArray();438 return reply({ result: rows.map((b) => ({ ...descriptor(host, b), uploader: b.uploader })) });439 },440 },441 listsites: {442 action: "read", reads: true,443 run: ({ relay, t, reply }) => {444 // Site manifests are regular events, so the paths live in their raw445 // JSON rather than the single-character tag index. Keep this query446 // bounded like the other console data lists. Expired manifests should not appear even before the index sweep.447 const rows = relay.sql.exec<{ raw: string }>(`SELECT raw FROM events WHERE kind IN (${SITE_KINDS.join(",")}) AND (expires=0 OR expires>?) ORDER BY created_at DESC LIMIT 500`, t).toArray();448 const host = relay.domain;449 const result = rows.flatMap(({ raw }) => {450 try {451 const e = JSON.parse(raw) as Parameters<typeof siteLabel>[0];452 if (checkSite(e)) return [];453 const label = siteLabel(e);454 if (!label) return [];455 const paths = sitePaths(e);456 const hashes = [...new Set(paths.map((p) => p[2]).filter((x): x is string => !!x))];457 const blobs = hashes.length ? relay.sql.exec<{ sha256: string; size: number }>(`SELECT sha256,size FROM blobs WHERE sha256 IN (SELECT value FROM json_each(?))`, JSON.stringify(hashes)).toArray() : [];458 const present = new Set(blobs.map((b) => b.sha256));459 const size = blobs.reduce((total, b) => total + b.size, 0);460 const d = e.kind === 35128 ? e.tags.find((t) => t[0] === "d")?.[1] || "" : "";461 return [{ id: e.id, kind: e.kind, author: e.pubkey, d, label, url: `https://${label}.${host}`, size, missing: hashes.filter((h) => !present.has(h)).length, expires_at: e.tags.find((t) => t[0] === "expiration")?.[1] ? Number(e.tags.find((t) => t[0] === "expiration")![1]) : 0, paths: paths.length }];462 } catch { return []; }463 });464 return reply({ result });465 },466 },467 deleteblob: {468 action: "storage",469 run: async ({ relay, str, hex64, reply }) => {470 const sha = str(0);471 if (!hex64(sha)) return reply({ error: "invalid: sha256 must be 64 hex chars" }, 400);472 await relay.deleteBlob(sha);473 return reply({ result: true });474 },475 },476 listbannedevents: { action: "read", reads: true, run: ({ s, reply }) => reply({ result: s.listEvents("ban") }) },477 deleteevent: { action: "deleteEvent", run: ({ relay, str, reply }) => reply({ result: relay.store.deleteEvent(str(0)) }) },478 listrecentevents: {479 action: "read", reads: true,480 run: ({ relay, t, num, reply }) => {481 return reply({ result: relay.store.recent(Math.min(Math.max(num(0) || 50, 1), 500), t).map((r) => JSON.parse(r)) });482 },483 },484 searchevents: {485 action: "read", reads: true,486 run: ({ relay, t, str, num, reply }) => {487 // (query, limit): the NIP-50 index, in the shape of listrecentevents.488 const q = str(0).trim().slice(0, 200);489 const limit = Math.min(Math.max(num(1) || 50, 1), 200);490 if (!q) return reply({ result: relay.store.recent(limit, t).map((r) => JSON.parse(r)) });491 return reply({ result: relay.store.query({ tags: {}, search: q, limit }, { pubkeys: [], all: true }, limit, t).rows.map((r) => JSON.parse(r)) });492 },493 },494 pinevent: { action: "deleteEvent", run: pin },495 unpinevent: { action: "deleteEvent", run: pin },496 listpins: { action: "read", reads: true, run: ({ s, reply }) => reply({ result: s.pins() }) },497 allowkind: { action: "rules", run: ruleKind },498 disallowkind: { action: "rules", run: ruleKind },499 unrulekind: { action: "rules", run: ruleKind },500 storagestats: {501 action: "storage", reads: true,502 run: ({ relay, s, reply }) => {503 const kinds = relay.store.kindStats().map((k) => ({ ...k, days: s.retentionDays(k.kind), replaceable: isReplaceable(k.kind), protected: isProtected(k.kind) }));504 const blobs = relay.sql.exec<{ n: number | null; bytes: number | null }>(`SELECT count(*) AS n, sum(size) AS bytes FROM blobs`).one();505 const eventSync = relay.sql.exec<{ jobs: number; failed: number; history: number; due: number | null }>(`SELECT count(*) AS jobs, coalesce(sum(error!=''),0) AS failed, coalesce(sum(windows!='[]'),0) AS history, min(due) AS due FROM grasp_event_sync`).one();506 const gitSync = relay.sql.exec<{ jobs: number; failed: number; due: number | null }>(`SELECT count(*) AS jobs, coalesce(sum(error!=''),0) AS failed, min(due) AS due FROM grasp_git_sync`).one();507 const partial = relay.sql.exec<{ partial: number }>(`SELECT partial FROM grasp_sync_status WHERE id=1`).toArray()[0]?.partial === 1;508 return reply({ result: { graspSync: { enabled: featureOn(s.policy, "grasp02"), partial, events: eventSync, git: gitSync }, 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() } });509 },510 },511 gitstorage: {512 action: "storage", reads: true,513 run: async ({ relay, params, str, hex64, reply }) => {514 if (params.length !== 2 || !hex64(str(0)) || typeof params[1] !== "string" || !str(1) || new TextEncoder().encode(str(1)).length > 256) return reply({ error: "invalid: expected repository owner hex pubkey and identifier" }, 400);515 const result = await gitStorage(relay, str(0), str(1));516 return reply(result.body, result.status);517 },518 },519 setretention: {520 action: "rules",521 run: ({ s, params, num, reply }) => {522 // (kind | null, days): days 0 removes the rule.523 const kind = params[0] === null ? null : num(0);524 const days = num(1);525 if (kind !== null && (!Number.isInteger(kind) || kind < 0 || kind > 65535)) return reply({ error: "invalid: kind out of range" }, 400);526 if (!Number.isInteger(days) || days < 0 || days > 36500) return reply({ error: "invalid: days out of range" }, 400);527 const err = s.setRetention(kind, days);528 if (err) return reply({ error: err }, 400);529 return reply({ result: s.listRetention() });530 },531 },532 listretention: { action: "read", reads: true, run: ({ s, reply }) => reply({ result: s.listRetention() }) },533 purgekind: {534 action: "rules",535 run: ({ relay, t, params, num, reply }) => {536 // (kind | null, olderThanDays): 0 days means everything of that kind.537 const kind = params[0] === null ? null : num(0);538 const days = num(1) || 0;539 if (kind !== null && (!Number.isInteger(kind) || kind < 0 || kind > 65535)) return reply({ error: "invalid: kind out of range" }, 400);540 if (!Number.isInteger(days) || days < 0) return reply({ error: "invalid: days out of range" }, 400);541 if (kind !== null && isProtected(kind)) return reply({ error: `invalid: kind ${kind} is part of how the relay works and cannot be purged` }, 400);542 const before = days > 0 ? t - days * 86400 : Number.MAX_SAFE_INTEGER;543 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);544 return reply({ result: { deleted: gone } });545 },546 },547 listallowedkinds: { action: "read", reads: true, run: ({ s, reply }) => reply({ result: s.listKinds("allow") }) },548 listblockedkinds: { action: "read", reads: true, run: ({ s, reply }) => reply({ result: s.listKinds("block") }) },549 notifytest: {550 action: "identity", reads: true,551 run: async ({ relay, reply }) => {552 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);553 return reply({ result: { sent } });554 },555 },556 resetrules: {557 action: "rules",558 run: async ({ relay, s, reply }) => {559 s.resetRules();560 await relay.publishMembership();561 return reply({ result: s.policy });562 },563 },564 listpresets: {565 action: "read", reads: true,566 run: ({ reply }) => {567 return reply({ result: PRESETS.map((x) => ({ name: x.name, title: x.title, about: x.about, source: x.source, every: x.every, connections: x.connections })) });568 },569 },570 // The Connect fold's app shortcuts (connections.ts): the library the571 // owner picks from, with whether each template's feature is on, the572 // owner's list, and the list replaced whole, in the order given.573 listconnectiontemplates: {574 action: "read", reads: true,575 run: ({ p, reply }) => reply({ result: CONNECTION_TEMPLATES.map((t) => ({ ...t, available: !t.feature || featureOn(p, t.feature) })) }),576 },577 listconnections: { action: "read", reads: true, run: ({ s, reply }) => reply({ result: s.connections() }) },578 setconnections: {579 action: "rules",580 run: ({ s, params, reply }) => {581 // (list): every entry must fit; the console builds the list from the582 // library, so a bad one is a mistake to report, not to drop.583 const parsed = parseConnections(params[0]);584 if (typeof parsed === "string") return reply({ error: parsed }, 400);585 if (parsed.warnings.length) return reply({ error: "invalid: " + parsed.warnings.join("; ") }, 400);586 s.setConnections(parsed.list);587 return reply({ result: s.connections() });588 },589 },590 applypreset: {591 action: "rules",592 run: async ({ relay, s, t, params, str, reply }) => {593 // (name, {source?}): writes, reads, directory, kind rules and keep-for594 // rules in one go. A replica preset also gets a standing pull of its595 // kinds from the source; an earlier replica job is replaced.596 const preset = findPreset(str(0));597 if (!preset) return reply({ error: "invalid: no preset named " + str(0) }, 400);598 const opts = params[1] && typeof params[1] === "object" ? (params[1] as { source?: unknown }) : {};599 const source = typeof opts.source === "string" ? opts.source.trim() : "";600 if (preset.source === "required" && !source) return reply({ error: "invalid: this preset needs a source relay to mirror" }, 400);601 if (source && !preset.source) return reply({ error: "invalid: this preset does not mirror a source" }, 400);602 if (source) {603 const bad = checkPullURL(source, relay.slug, relay.domain);604 if (bad) return reply({ error: bad }, 400);605 }606 const err = applyPreset(s, preset.name, t);607 if (err) return reply({ error: err }, 400);608 relay.enforceReads();609 for (const j of await relay.jobs()) if (j.label === "replica") await relay.removeJob(j.id);610 let job = null;611 if (source) {612 const r = await relay.addJob({ kind: "pull", label: "replica", relays: [source], filter: { kinds: preset.allow }, every: preset.every ?? 24 });613 if (typeof r === "string") return reply({ error: r }, r.startsWith("invalid") ? 400 : 409);614 job = r;615 }616 await relay.publishMembership();617 // The read rule and the directory moved, so every view's audience may have.618 for (const v of VIEWS) await publishView(relay, v.name);619 return reply({ result: { ...s.policy, job } });620 },621 },622 forkrelay: {623 action: "fork",624 run: async ({ relay, req, caller, params, hex64, reply }) => {625 // ({name?, holder?, filter?, people?}): lease a new name reserved for626 // holder (the caller by default), copy this relay into it, hand over.627 const o = params[0] && typeof params[0] === "object" ? (params[0] as Record<string, unknown>) : {};628 const name = typeof o.name === "string" ? o.name.trim().toLowerCase() : "";629 const holder = typeof o.holder === "string" && o.holder !== "" ? o.holder : caller;630 if (!hex64(holder)) return reply({ error: "invalid: holder must be a 64 hex pubkey" }, 400);631 const f = o.filter && typeof o.filter === "object" ? (o.filter as Record<string, unknown>) : {};632 const filter: { authors?: string[]; kinds?: number[] } = {};633 if (Array.isArray(f.authors) && f.authors.length) {634 if (!f.authors.every((a) => hex64(String(a))) || f.authors.length > 50) return reply({ error: "invalid: authors must be up to fifty hex pubkeys" }, 400);635 filter.authors = [...new Set(f.authors as string[])];636 }637 if (Array.isArray(f.kinds) && f.kinds.length) {638 if (!f.kinds.every((k) => Number.isInteger(k) && (k as number) >= 0 && (k as number) <= 65535) || f.kinds.length > 50) return reply({ error: "invalid: kinds must be up to fifty integers" }, 400);639 filter.kinds = [...new Set(f.kinds as number[])];640 }641 const r = await relay.forkRelay(new URL(req.url).host, { name: name || undefined, holder, filter, people: o.people === true });642 if (typeof r === "string") return reply({ error: r }, r.startsWith("invalid") ? 400 : r.startsWith("error") ? 503 : 409);643 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.` } });644 },645 },646 pullfrom: {647 action: "jobs",648 run: async ({ relay, str, reply }) => {649 // (url): copy what another relay has that this one lacks. Runs in650 // the background; pullstatus reports on it.651 const url = str(0).trim();652 const bad = checkPullURL(url, relay.slug, relay.domain);653 if (bad) return reply({ error: bad }, 400);654 const err = await relay.pullStart(url);655 if (err) return reply({ error: err }, 409);656 return reply({ result: { started: true, url } });657 },658 },659 pullstatus: { action: "jobs", reads: true, run: async ({ relay, reply }) => reply({ result: await relay.pullStatus() }) },660 listjobs: { action: "jobs", reads: true, run: async ({ relay, reply }) => reply({ result: await relay.jobs() }) },661 deliverystatus: { action: "jobs", reads: true, run: ({ relay, reply }) => reply({ result: deliveryStatus(relay) }) },662 addjob: {663 action: "jobs",664 run: async ({ relay, params, reply }) => {665 // ({kind, relays, filter?, every?, label?}): a pull or push, once or standing.666 const r = await relay.addJob(params[0]);667 if (typeof r === "string") return reply({ error: r }, r.startsWith("invalid") ? 400 : 409);668 return reply({ result: r });669 },670 },671 removejob: { action: "jobs", run: async ({ relay, str, reply }) => reply({ result: await relay.removeJob(str(0)) }) },672 runjob: { action: "jobs", run: async ({ relay, str, reply }) => reply({ result: await relay.runJob(str(0)) }) },673 backfill: {674 action: "jobs",675 run: async ({ relay, s, p, params, reply }) => {676 // (relays?): fetch the owner's own history from the relays in their677 // kind 10002 stored here, or from the given list.678 const given = Array.isArray(params[0]) ? (params[0] as unknown[]).filter((u): u is string => typeof u === "string" && u.trim() !== "") : [];679 const relays = given.length ? given : relaysFromList(relay, p.owner);680 if (relays.length === 0) return reply({ error: "invalid: no relay list (kind 10002) is stored here; give relays to fetch from" }, 400);681 const r = await relay.addJob({ kind: "pull", label: "backfill", relays, filter: { authors: [p.owner] }, every: 0 });682 if (typeof r === "string") return reply({ error: r }, r.startsWith("invalid") ? 400 : 409);683 return reply({ result: r });684 },685 },686 transferowner: {687 action: "transfer",688 run: async ({ relay, s, caller, str, hex64, reply }) => {689 // (pubkey): hands the relay to a member; the old owner stays as a moderator.690 const pk = str(0);691 if (!hex64(pk)) return reply({ error: "invalid: pubkey must be 64 hex chars" }, 400);692 const err = s.transferOwner(pk);693 if (err) return reply({ error: err }, 400);694 await relay.succession.seenNow();695 await relay.publishMembership({ pubkey: pk }, { pubkey: caller });696 return reply({ result: { owner: pk, previous: caller } });697 },698 },699 listdumps: { action: "storage", reads: true, run: ({ relay, reply }) => reply({ result: listDumps(relay.sql).map((d) => ({ ...d, url: "/dumps/" + d.name })) }) },700 deletedump: {701 action: "storage",702 run: async ({ relay, str, reply }) => {703 const name = str(0);704 if (!DUMP_NAME_RE.test(name)) return reply({ error: "invalid: not a dump name" }, 400);705 return reply({ result: await deleteDump(relay, name) });706 },707 },708 dumpnow: { action: "storage", run: async ({ relay, t, reply }) => reply({ result: await writeDump(relay, t) }) },709 backupnow: {710 action: "storage",711 run: async ({ relay, params, reply }) => {712 const options = params[0] && typeof params[0] === "object" ? params[0] as Record<string, unknown> : {};713 const id = typeof params[0] === "string" && params[0] ? params[0] : typeof options.id === "string" && options.id ? options.id : `backup-${now()}`;714 const result = await createBackup(relay, id, { ...DEFAULT_BACKUP_LIMITS, includeGit: options.includeGit !== false });715 return typeof result === "string" ? reply({ error: result }, result.startsWith("invalid:") ? 400 : 403) : reply({ result: { ...result.manifest, url: "/backups/" + id } });716 },717 },718 listbackups: { action: "storage", reads: true, run: async ({ relay, reply }) => reply({ result: await listBackups(relay) }) },719 deletebackup: { action: "storage", run: async ({ relay, str, reply }) => reply({ result: await deleteBackup(relay, str(0)) }) },720 setsuccession: {721 action: "transfer",722 run: async ({ relay, s, params, str, num, hex64, reply }) => {723 // ({heir, afterDays}) or (heir, afterDays): the heir must be a member.724 const o = params[0] && typeof params[0] === "object" ? (params[0] as Record<string, unknown>) : { heir: str(0), afterDays: num(1) };725 const heir = typeof o.heir === "string" ? o.heir : "";726 if (!hex64(heir)) return reply({ error: "invalid: heir must be a 64 hex pubkey" }, 400);727 const err = s.setSuccession(heir, Number(o.afterDays));728 if (err) return reply({ error: err }, 400);729 await relay.succession.seenNow();730 return reply({ result: await relay.succession.status() });731 },732 },733 clearsuccession: {734 action: "transfer",735 run: async ({ relay, s, reply }) => {736 s.update({ succession: null });737 await relay.succession.seenNow();738 return reply({ result: true });739 },740 },741 successionstatus: {742 action: "read", reads: true,743 run: async ({ relay, role, reply }) => {744 if (role !== "owner") return reply({ error: "restricted: only the owner and the heir may see this" }, 403);745 return reply({ result: await relay.succession.status() });746 },747 },748 changerelayname: {749 action: "identity",750 run: async ({ relay, s, str, reply }) => {751 s.update({ name: str(0).slice(0, 200) });752 await relay.publishMembership();753 return reply({ result: true });754 },755 },756 changerelaydescription: {757 action: "identity",758 run: async ({ relay, s, str, reply }) => {759 s.update({ description: str(0).slice(0, 2000) });760 await relay.publishMembership();761 return reply({ result: true });762 },763 },764 changerelayicon: {765 action: "identity",766 run: async ({ relay, s, str, reply }) => {767 s.update({ icon: str(0).slice(0, 2000) });768 await relay.publishMembership();769 return reply({ result: true });770 },771 },772 // Custom domains: the relay under a hostname the owner controls.773 adddomain: {774 action: "identity",775 run: async ({ relay, str, reply }) => {776 const r = await addDomain(relay, str(0), str(1) || undefined);777 if (typeof r === "string") return reply({ error: r }, r.startsWith("error:") ? 502 : 400);778 return reply({ result: r });779 },780 },781 setdomainsite: {782 action: "identity",783 run: async ({ relay, str, reply }) => {784 const r = await setDomainSite(relay, str(0), str(1) || undefined);785 return typeof r === "string" ? reply({ error: r }, 400) : reply({ result: r });786 },787 },788 checkdomain: {789 action: "identity", reads: true,790 run: async ({ relay, str, reply }) => {791 const r = await checkDomain(relay, str(0));792 if (typeof r === "string") return reply({ error: r }, r.startsWith("error:") ? 502 : 400);793 return reply({ result: r });794 },795 },796 removedomain: {797 action: "identity",798 run: async ({ relay, str, reply }) => {799 const err = await removeDomain(relay, str(0));800 if (err) return reply({ error: err }, err.startsWith("error:") ? 502 : 400);801 return reply({ result: true });802 },803 },804 listdomains: {805 action: "identity", reads: true,806 run: ({ relay, reply }) => {807 const r = listDomains(relay);808 if (typeof r === "string") return reply({ error: r }, 400);809 return reply({ result: r });810 },811 },812};813814export async function manage(relay: Relay, req: Request): Promise<Response> {815 const cors = { "access-control-allow-origin": "*", "content-type": "application/json" };816 const reply = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: cors });817 const bodyText = await req.text();818 let body: { method?: unknown; params?: unknown };819 try {820 body = JSON.parse(bodyText);821 } catch {822 return reply({ error: "invalid: body is not JSON" }, 400);823 }824 const method = typeof body.method === "string" ? body.method : "";825 const params = Array.isArray(body.params) ? body.params : [];826 const m = METHODS[method];827 if (!m) return reply({ error: "unsupported: unknown method " + method }, 400);828829 const auth = verifyNIP98(req.headers.get("authorization") ?? "", req.url, req.method, bodyText);830 if (typeof auth === "string") return reply({ error: auth }, 401);831 return relay.repositoryAccess.run("control", async () => {832 const s = relay.settings;833 const p = s.policy;834 const t = now();835 const caller = auth.pubkey;836837 const role = s.roleOf(caller);838 const outranks = (pk: string) => role !== "owner" && (s.isOwner(pk) || s.roleOf(pk) === "moderator");839 const str = (i: number) => (typeof params[i] === "string" ? (params[i] as string) : "");840 const num = (i: number) => (Number.isInteger(params[i]) ? (params[i] as number) : parseInt(str(i), 10));841 const hex64 = (v: string) => /^[0-9a-f]{64}$/.test(v);842 const call: Call = { relay, req, s, p, t, caller, role, method, params, str, num, hex64, reply, outranks };843 if (m.action === "open") return m.run(call);844 // The heir may read the succession status: a member, with no console otherwise.845 if (method === "successionstatus" && p.succession && p.succession.heir === caller) return reply({ result: await relay.succession.status() });846 // A plain member reaches their own invites when the owner opened the847 // invite tree (memberInvites); the invite methods keep them to their own.848 const ownInvites = role === "member" && p.memberInvites.depth > 0 && m.ownInvites;849 const ownListHistory = role !== null && m.ownListHistory;850 if (role === "owner") void relay.succession.seen(caller);851 if (!ownInvites && !ownListHistory && !can(role, m.action)) {852 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";853 return reply({ error: why }, 403);854 }855 // Every method that changed something and answered 200 goes in the856 // moderation log with who called it (audit.ts). deleterelay took the857 // table with it.858 const resp = await m.run(call);859 const dryRun = method === "importconfig" && (params[1] as { dryRun?: unknown } | undefined)?.dryRun === true;860 if (resp.status === 200 && !m.reads && !dryRun && method !== "deleterelay") {861 const { target, detail } = detailOf(method, params);862 relay.audit.record(t, caller, method, target, detail);863 if (["setpolicy", "importconfig", "applypreset"].includes(method) && (featureOn(s.policy, "grasp02") || featureOn(s.policy, "grasp06"))) await relay.ensureAlarm(now() + 1);864 }865 return resp;866 }, () => new Response(JSON.stringify({ error: "restricted: relay operation in progress; retry" }), { status: 429, headers: { ...cors, "retry-after": "1" } }));867}