// An outside master (slave role, CONTRACT.md section 3.2), written from the // mode contract. It receives email's signed events, and sends signed // commands when told to through a loopback-only control API, so a flow // runner in any language can drive it. // // node src/master.mjs --peer-id cms-1 --key-hex HEX [--email-url URL] [--port 0] // -> prints {"events_url": ..., "control_url": ...} on the first stdout line // // Contract: POST /events (signed by email) // Control: POST /control/config {email_url} // POST /control/command {type, case?, ...fields} -> {status, body} // GET /control/received -> [messages] import { createServer } from "node:http"; import { randomUUID } from "node:crypto"; import { sign, verify, AuthError } from "./signing.mjs"; const CONTRACT_VERSION = "0.1"; export class OutsideMaster { constructor({ peerId = "cms-1", emailPeer = "email", key, emailUrl = null }) { this.peerId = peerId; this.emailPeer = emailPeer; this.keys = { [emailPeer]: key }; this.emailUrl = emailUrl; this.received = []; this.seen = new Map(); // idempotency: message id -> response } /** Contract endpoint: one signed message from email. Returns [status, body]. */ receive(headers, raw) { let peer; try { peer = verify(headers, raw, this.keys); } catch (e) { if (e instanceof AuthError) return [e.status, { ok: false, error: e.code }]; throw e; } if (peer !== this.emailPeer) return [403, { ok: false, error: "wrong-peer" }]; let msg; try { msg = JSON.parse(raw.toString("utf8")); } catch { return [400, { ok: false, error: "invalid-json" }]; } if (msg.mas_modes_version !== CONTRACT_VERSION || typeof msg.type !== "string" || !msg.id) { return [400, { ok: false, error: "schema" }]; } if (this.seen.has(msg.id)) return this.seen.get(msg.id); this.received.push(msg); const out = [200, { ok: true }]; this.seen.set(msg.id, out); return out; } /** Send one signed command to email. Returns [status, body]. */ async command(type, fields = {}) { const msg = { mas_modes_version: CONTRACT_VERSION, type, id: randomUUID().replaceAll("-", ""), ...fields }; for (const k of Object.keys(msg)) if (msg[k] === null || msg[k] === undefined) delete msg[k]; const body = Buffer.from(JSON.stringify(msg)); const res = await fetch(this.emailUrl, { method: "POST", body, headers: { "Content-Type": "application/json", ...sign(body, this.peerId, this.keys[this.emailPeer]) }, }); const text = await res.text(); let parsed = null; try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; } return [res.status, parsed]; } } const isLoopback = (addr) => addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1"; export function serve(master, port = 0) { const server = createServer(async (req, res) => { const chunks = []; for await (const c of req) chunks.push(c); const raw = Buffer.concat(chunks); const send = (status, obj) => { const out = JSON.stringify(obj); res.writeHead(status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(out) }); res.end(out); }; try { if (req.method === "POST" && req.url === "/events") return send(...master.receive(req.headers, raw)); if (!req.url.startsWith("/control/")) return send(404, { ok: false, error: "not-found" }); if (!isLoopback(req.socket.remoteAddress)) return send(403, { ok: false, error: "control is loopback-only" }); if (req.method === "GET" && req.url === "/control/received") return send(200, master.received); const body = raw.length ? JSON.parse(raw.toString("utf8")) : {}; if (req.method === "POST" && req.url === "/control/config") { master.emailUrl = body.email_url; return send(200, { ok: true }); } if (req.method === "POST" && req.url === "/control/command") { const { type, ...fields } = body; const [status, resp] = await master.command(type, fields); return send(200, { status, body: resp }); } send(404, { ok: false, error: "not-found" }); } catch (e) { send(500, { ok: false, error: String(e) }); } }); return new Promise((resolve) => server.listen(port, "127.0.0.1", () => resolve(server))); } if (import.meta.url === `file://${process.argv[1]}`) { const arg = (name, dflt) => { const i = process.argv.indexOf(`--${name}`); return i >= 0 ? process.argv[i + 1] : dflt; }; const master = new OutsideMaster({ peerId: arg("peer-id", "cms-1"), key: Buffer.from(arg("key-hex"), "hex"), emailUrl: arg("email-url", null) }); const server = await serve(master, Number(arg("port", "0"))); const base = `http://127.0.0.1:${server.address().port}`; process.stdout.write(JSON.stringify({ events_url: `${base}/events`, control_url: `${base}/control` }) + "\n"); }