1// A relay's configuration as a file in your repository: check it, see what2// applying it would change, apply it, or pull the relay's current one.3//4// npm run relay check <file.jsonc>5// npm run relay plan <file.jsonc> <wss://name.bind.ws>6// npm run relay push <file.jsonc> <wss://name.bind.ws>7// npm run relay pull <wss://name.bind.ws> [file.json]8//9// plan, push and pull sign as the owner: RELAY_SK holds the secret key, hex10// or nsec. check needs no relay. plan is importconfig with dryRun, so it11// touches nothing; push prints the same plan and then applies it. The12// schema the file may reference is at https://bind.ws/relay-config.schema.json.13import { writeFileSync } from "node:fs";14import { finalizeEvent, getPublicKey } from "nostr-tools/pure";15import { getToken } from "nostr-tools/nip98";16import { decode } from "nostr-tools/nip19";17import { checkConfig, describe, readConfig } from "../check/config.mjs";1819const [cmd, a, b] = process.argv.slice(2);20const usage = () => {21 console.error("usage: npm run relay check <file> | plan <file> <url> | push <file> <url> | pull <url> [file]");22 process.exit(2);23};2425function key() {26 const raw = process.env.RELAY_SK ?? "";27 if (/^[0-9a-f]{64}$/i.test(raw)) return Uint8Array.from(Buffer.from(raw, "hex"));28 if (raw.startsWith("nsec")) return decode(raw).data;29 console.error("RELAY_SK must be the owner's secret key, hex or nsec");30 process.exit(2);31}3233const httpURL = (url) => new URL(url.replace(/^ws(s?):\/\//, "http$1://")).origin + "/";3435async function rpc(url, sk, method, ...params) {36 const payload = { method, params };37 const body = JSON.stringify(payload);38 const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);39 const resp = await fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body });40 const json = await resp.json();41 if (json.error) throw new Error(json.error);42 return json.result;43}4445function printPlan(plan) {46 const { changes, warnings } = plan;47 console.log(changes.summary.length ? changes.summary.map((l) => " " + l).join("\n") : " nothing would change");48 if (warnings.length) console.log("not taken:\n" + warnings.map((w) => " " + w).join("\n"));49}5051if (cmd === "check" && a) {52 const cfg = await checkConfig(readConfig(a), a);53 console.log(`${a}: ok\n ${describe(cfg).join("\n ")}`);54} else if ((cmd === "plan" || cmd === "push") && a && b) {55 const doc = readConfig(a);56 await checkConfig(doc, a);57 const url = httpURL(b);58 const sk = key();59 console.log(`${cmd === "plan" ? "would apply" : "applying"} ${a} to ${url} as ${getPublicKey(sk).slice(0, 8)}:`);60 const plan = await rpc(url, sk, "importconfig", doc, { dryRun: true });61 printPlan(plan);62 if (cmd === "push") {63 if (plan.changes.summary.length === 0) console.log("nothing to apply");64 else {65 await rpc(url, sk, "importconfig", doc);66 console.log("applied");67 }68 }69} else if (cmd === "pull" && a) {70 const url = httpURL(a);71 const sk = key();72 const cfg = await rpc(url, sk, "exportconfig");73 delete cfg.exported_at; // the file is what the relay says, not when74 const text = JSON.stringify({ $schema: "https://bind.ws/relay-config.schema.json", ...cfg }, null, 2) + "\n";75 if (b) {76 writeFileSync(b, text);77 console.log(`${b}: ${cfg.name} pulled from ${url}`);78 } else process.stdout.write(text);79} else usage();