1// A relay's configuration as one document: what exportconfig writes,2// importconfig reads, a template in relay-templates/ declares and a file in3// an operator's repository holds. parseConfig checks the shape and says4// what it dropped; planConfig says what applying it would change; applyConfig5// does. A section that is absent is left alone, so a template carries the6// rules and nothing about people. The connections section is the Connect7// fold's app shortcuts, so a template can set the rules and the shortcuts8// that go with them in one click.9import { DEFAULT_POLICY, policyPatch, validIP, type Connection, type Policy, type Settings } from "./settings.ts";10import { parseConnections } from "./connections.ts";1112export const FORMAT = "bind.ws/relay-config/2";13const FORMATS = new Set(["bind.ws/relay-config/1", FORMAT]);14export const SECTIONS = ["policy", "members", "bans", "addresses", "banned_events", "kinds", "retention", "connections"] as const;15export type Section = (typeof SECTIONS)[number];16// What an export leaves out: the owner's own, and the platform's.17const NOT_IN_A_FILE = new Set(["owner", "lease", "succession", "customHosts"]);18const POLICY_KEYS = new Set(Object.keys(DEFAULT_POLICY).filter((k) => !NOT_IN_A_FILE.has(k)));1920export interface ConfigMember {21 pubkey: string;22 name: string | null;23 note: string;24 role: "member" | "moderator";25 keepDays?: number;26 maxBytes?: number;27}28export interface Template {29 title: string;30 about: string;31 source?: "required" | "optional"; // a replica needs a source relay to pull from32 every?: number; // hours between pulls33}34export interface Config {35 name: string;36 template?: Template;37 sections: Section[];38 policy: Partial<Policy>;39 members: ConfigMember[];40 bans: { pubkey: string; reason: string }[];41 addresses: { ip: string; reason: string }[];42 banned_events: { id: string; reason: string }[];43 kinds: { allow: number[]; block: number[] };44 retention: { kind: number | null; days: number }[];45 // The Connect fold's app shortcuts (connections.ts), in order.46 connections: Connection[];47 // Entries the document had that no relay would take, one line each.48 warnings: string[];49}5051const hex64 = (v: unknown): v is string => typeof v === "string" && /^[0-9a-f]{64}$/.test(v);52const kindOK = (k: unknown): k is number => Number.isInteger(k) && (k as number) >= 0 && (k as number) <= 65535;53const str = (v: unknown, max: number) => (typeof v === "string" ? v.slice(0, max) : "");5455// parseConfig reads a document against the current policy (the merged maps56// need it) and returns what a relay would apply, or the reason it is not a57// configuration at all.58export function parseConfig(raw: unknown, cur: Policy = DEFAULT_POLICY): Config | string {59 const c = raw as Record<string, unknown>;60 if (!c || typeof c !== "object" || Array.isArray(c)) return "invalid: not a bind.ws relay configuration";61 if (!FORMATS.has(c.format as string)) return "invalid: format must be " + FORMAT;62 const warnings: string[] = [];63 const out: Config = { name: str(c.name, 64), sections: [], policy: {}, members: [], bans: [], addresses: [], banned_events: [], kinds: { allow: [], block: [] }, retention: [], connections: [], warnings };64 if (c.template && typeof c.template === "object") {65 const t = c.template as Record<string, unknown>;66 const template: Template = { title: str(t.title, 80), about: str(t.about, 400) };67 if (t.source === "required" || t.source === "optional") template.source = t.source;68 if (Number.isInteger(t.every) && (t.every as number) > 0) template.every = t.every as number;69 out.template = template;70 }71 if (c.policy !== undefined) {72 if (!c.policy || typeof c.policy !== "object") return "invalid: policy must be an object";73 const given = c.policy as Record<string, unknown>;74 out.policy = policyPatch(given, cur);75 for (const k of Object.keys(given)) {76 if (NOT_IN_A_FILE.has(k)) warnings.push(`policy.${k}: not carried by a configuration`);77 else if (!POLICY_KEYS.has(k)) warnings.push(`policy.${k}: not a setting`);78 else if (!(k in out.policy)) warnings.push(`policy.${k}: value not accepted`);79 }80 out.sections.push("policy");81 }82 const list = (key: Section): unknown[] | null => {83 if (c[key] === undefined) return null;84 if (!Array.isArray(c[key])) {85 warnings.push(`${key}: not a list`);86 return null;87 }88 out.sections.push(key);89 return c[key] as unknown[];90 };91 for (const [i, m] of (list("members") ?? []).entries()) {92 const r = (m ?? {}) as Record<string, unknown>;93 if (!hex64(r.pubkey)) {94 warnings.push(`members[${i}]: pubkey must be 64 hex chars`);95 continue;96 }97 const member: ConfigMember = { pubkey: r.pubkey, name: typeof r.name === "string" ? r.name : null, note: str(r.note, 2000), role: r.role === "moderator" ? "moderator" : "member" };98 if (Number.isInteger(r.keepDays) && (r.keepDays as number) > 0) member.keepDays = r.keepDays as number;99 if (Number.isInteger(r.maxBytes) && (r.maxBytes as number) > 0) member.maxBytes = r.maxBytes as number;100 out.members.push(member);101 }102 for (const [i, b] of (list("bans") ?? []).entries()) {103 const r = (b ?? {}) as Record<string, unknown>;104 if (hex64(r.pubkey)) out.bans.push({ pubkey: r.pubkey, reason: str(r.reason, 200) });105 else warnings.push(`bans[${i}]: pubkey must be 64 hex chars`);106 }107 for (const [i, a] of (list("addresses") ?? []).entries()) {108 const r = (a ?? {}) as Record<string, unknown>;109 const ip = typeof r.ip === "string" ? r.ip.trim().toLowerCase() : "";110 if (validIP(ip)) out.addresses.push({ ip, reason: str(r.reason, 200) });111 else warnings.push(`addresses[${i}]: not an IP address`);112 }113 for (const [i, e] of (list("banned_events") ?? []).entries()) {114 const r = (e ?? {}) as Record<string, unknown>;115 if (hex64(r.id)) out.banned_events.push({ id: r.id, reason: str(r.reason, 200) });116 else warnings.push(`banned_events[${i}]: id must be 64 hex chars`);117 }118 if (c.kinds !== undefined) {119 if (!c.kinds || typeof c.kinds !== "object") return "invalid: kinds must be an object with allow and block lists";120 const k = c.kinds as Record<string, unknown>;121 for (const rule of ["allow", "block"] as const) {122 for (const [i, n] of (Array.isArray(k[rule]) ? (k[rule] as unknown[]) : []).entries()) {123 if (kindOK(n)) out.kinds[rule].push(n);124 else warnings.push(`kinds.${rule}[${i}]: kind out of range`);125 }126 out.kinds[rule] = [...new Set(out.kinds[rule])].sort((a, b) => a - b);127 }128 out.sections.push("kinds");129 }130 for (const [i, r] of (list("retention") ?? []).entries()) {131 const x = (r ?? {}) as Record<string, unknown>;132 const kind = x.kind === null ? null : x.kind;133 if ((kind === null || kindOK(kind)) && Number.isInteger(x.days) && (x.days as number) > 0 && (x.days as number) <= 36500) out.retention.push({ kind: kind as number | null, days: x.days as number });134 else warnings.push(`retention[${i}]: needs a kind (or null) and days from 1 to 36500`);135 }136 if (c.connections !== undefined) {137 const parsed = parseConnections(c.connections);138 if (typeof parsed === "string") return parsed;139 out.connections = parsed.list;140 warnings.push(...parsed.warnings);141 out.sections.push("connections");142 }143 return out;144}145146export interface Changes {147 policy: { field: string; from: unknown; to: unknown }[];148 members: { add: ConfigMember[]; remove: { pubkey: string; name: string | null }[]; change: { pubkey: string; from: ConfigMember; to: ConfigMember }[] };149 bans: { add: string[]; remove: string[] };150 addresses: { add: string[]; remove: string[] };151 banned_events: { add: string[]; remove: string[] };152 kinds: { allow: { add: number[]; remove: number[] }; block: { add: number[]; remove: number[] } };153 retention: { add: { kind: number | null; days: number }[]; remove: { kind: number | null; days: number }[] };154 connections: { add: Connection[]; remove: Connection[]; reordered: boolean };155 // One line per change, for a person; empty when applying would change nothing.156 summary: string[];157}158159const currentMembers = (s: Settings): ConfigMember[] =>160 s.members().filter((m) => m.role !== "owner").map((m) => ({ pubkey: m.pubkey, name: m.name, note: m.note, role: m.role === "moderator" ? "moderator" : "member", ...(m.keep_days ? { keepDays: m.keep_days } : {}), ...(m.max_bytes ? { maxBytes: m.max_bytes } : {}) }));161162function diff<T>(before: T[], after: T[], key: (x: T) => string): { add: T[]; remove: T[] } {163 const b = new Map(before.map((x) => [key(x), x]));164 const a = new Map(after.map((x) => [key(x), x]));165 return { add: [...a.values()].filter((x) => !b.has(key(x))), remove: [...b.values()].filter((x) => !a.has(key(x))) };166}167const short = (pk: string) => pk.slice(0, 8);168const show = (v: unknown) => {169 const s = JSON.stringify(v);170 return s.length > 60 ? s.slice(0, 57) + "..." : s;171};172173// planConfig compares the document with the relay as it is. Only the174// sections the document has are compared.175export function planConfig(s: Settings, cfg: Config): Changes {176 const has = (k: Section) => cfg.sections.includes(k);177 const ch: Changes = { policy: [], members: { add: [], remove: [], change: [] }, bans: { add: [], remove: [] }, addresses: { add: [], remove: [] }, banned_events: { add: [], remove: [] }, kinds: { allow: { add: [], remove: [] }, block: { add: [], remove: [] } }, retention: { add: [], remove: [] }, connections: { add: [], remove: [], reordered: false }, summary: [] };178 if (has("policy")) {179 for (const [k, to] of Object.entries(cfg.policy)) {180 const from = (s.policy as unknown as Record<string, unknown>)[k];181 if (JSON.stringify(from) !== JSON.stringify(to)) ch.policy.push({ field: k, from, to });182 }183 for (const c of ch.policy) ch.summary.push(`${c.field}: ${show(c.from)} to ${show(c.to)}`);184 }185 if (has("members")) {186 const cur = currentMembers(s);187 const d = diff(cur, cfg.members, (m) => m.pubkey);188 ch.members.add = d.add;189 ch.members.remove = d.remove.map((m) => ({ pubkey: m.pubkey, name: m.name }));190 const next = new Map(cfg.members.map((m) => [m.pubkey, m]));191 for (const m of cur) {192 const to = next.get(m.pubkey);193 if (to && JSON.stringify(m) !== JSON.stringify(to)) ch.members.change.push({ pubkey: m.pubkey, from: m, to });194 }195 const m = ch.members;196 if (m.add.length || m.remove.length || m.change.length) ch.summary.push(`members: ${m.add.length} added (${m.add.map((x) => x.name || short(x.pubkey)).join(", ") || "none"}), ${m.remove.length} removed (${m.remove.map((x) => x.name || short(x.pubkey)).join(", ") || "none"}), ${m.change.length} changed`);197 }198 const lists: [Section, keyof Pick<Changes, "bans" | "addresses" | "banned_events">, string[], string[]][] = [199 ["bans", "bans", s.listBans().map((b) => b.pubkey), cfg.bans.map((b) => b.pubkey)],200 ["addresses", "addresses", s.listIPBlocks().map((b) => b.ip), cfg.addresses.map((a) => a.ip)],201 ["banned_events", "banned_events", s.listEvents("ban").map((e) => e.id), cfg.banned_events.map((e) => e.id)],202 ];203 for (const [section, field, before, after] of lists) {204 if (!has(section)) continue;205 const d = diff(before, after, (x) => x);206 ch[field] = d;207 if (d.add.length || d.remove.length) ch.summary.push(`${section.replace("_", " ")}: ${d.add.length} added, ${d.remove.length} removed`);208 }209 if (has("kinds")) {210 for (const rule of ["allow", "block"] as const) {211 const d = diff(s.listKinds(rule), cfg.kinds[rule], String);212 ch.kinds[rule] = d;213 if (d.add.length || d.remove.length) ch.summary.push(`kinds ${rule === "allow" ? "allowed" : "blocked"}: ${d.add.length ? "+" + d.add.join(", ") : ""}${d.add.length && d.remove.length ? " " : ""}${d.remove.length ? "-" + d.remove.join(", ") : ""}`);214 }215 }216 if (has("retention")) {217 const d = diff(s.listRetention(), cfg.retention, (r) => `${r.kind}:${r.days}`);218 ch.retention = d;219 const say = (r: { kind: number | null; days: number }) => `${r.kind === null ? "everything" : "kind " + r.kind} ${r.days} days`;220 if (d.add.length || d.remove.length) ch.summary.push(`retention: ${[...d.add.map((r) => "+" + say(r)), ...d.remove.map((r) => "-" + say(r))].join(", ")}`);221 }222 if (has("connections")) {223 // A shortcut is the whole of its entry: the same template with another224 // visibility or input is one removed and one added.225 const cur = s.connections();226 const d = diff(cur, cfg.connections, (c) => JSON.stringify(c));227 ch.connections = { ...d, reordered: d.add.length === 0 && d.remove.length === 0 && JSON.stringify(cur) !== JSON.stringify(cfg.connections) };228 const say = (c: Connection) => c.template + (c.visibility === "public" ? "" : " (" + c.visibility + ")");229 if (d.add.length || d.remove.length) ch.summary.push(`connections: ${[...d.add.map((c) => "+" + say(c)), ...d.remove.map((c) => "-" + say(c))].join(", ")}`);230 else if (ch.connections.reordered) ch.summary.push("connections: reordered");231 }232 return ch;233}234235// applyConfig writes the document's sections; the owner is never touched.236export function applyConfig(s: Settings, cfg: Config, now: number) {237 const has = (k: Section) => cfg.sections.includes(k);238 if (has("policy")) s.update(cfg.policy);239 if (has("members")) {240 for (const m of s.members()) if (m.role !== "owner") s.removeMember(m.pubkey);241 for (const m of cfg.members) {242 s.upsertMember(m.pubkey, { name: m.name, note: m.note, via: "import", keepDays: m.keepDays, maxBytes: m.maxBytes }, now, true);243 if (m.role === "moderator") s.setRole(m.pubkey, "moderator");244 }245 }246 if (has("bans")) {247 for (const b of s.listBans()) s.setBan(b.pubkey, false);248 for (const b of cfg.bans) if (!s.isOwner(b.pubkey)) s.setBan(b.pubkey, true, b.reason, now);249 }250 if (has("addresses")) {251 for (const b of s.listIPBlocks()) s.setIPBlock(b.ip, false);252 for (const a of cfg.addresses) s.setIPBlock(a.ip, true, a.reason, now);253 }254 if (has("banned_events")) {255 for (const e of s.listEvents("ban")) s.setEvent(e.id, null);256 for (const e of cfg.banned_events) s.setEvent(e.id, "ban", e.reason, now);257 }258 if (has("kinds")) {259 for (const k of [...s.listKinds("allow"), ...s.listKinds("block")]) s.setKind(k, null);260 for (const k of cfg.kinds.allow) s.setKind(k, "allow");261 for (const k of cfg.kinds.block) s.setKind(k, "block");262 }263 if (has("retention")) {264 for (const r of s.listRetention()) s.setRetention(r.kind, 0);265 for (const r of cfg.retention) s.setRetention(r.kind, r.days);266 }267 if (has("connections")) s.setConnections(cfg.connections);268}269270// exportConfig writes the relay as a document, every section, lists in a271// fixed order so two exports diff cleanly.272export function exportConfig(s: Settings, name: string) {273 const byKey = <T>(list: T[], key: (x: T) => string) => [...list].sort((a, b) => key(a).localeCompare(key(b)));274 return {275 format: FORMAT,276 exported_at: Math.floor(Date.now() / 1000),277 name,278 policy: { ...s.policy, owner: undefined, lease: undefined, succession: undefined, customHosts: undefined },279 members: byKey(currentMembers(s), (m) => m.pubkey),280 bans: byKey(s.listBans(), (b) => b.pubkey),281 banned_events: byKey(s.listEvents("ban"), (e) => e.id),282 addresses: byKey(s.listIPBlocks(), (a) => a.ip),283 kinds: { allow: [...s.listKinds("allow")].sort((a, b) => a - b), block: [...s.listKinds("block")].sort((a, b) => a - b) },284 retention: s.listRetention(),285 connections: s.connections(),286 };287}