impl/js (Node, zero deps, written from the specs): parser, v0.2 statute-aware rules, signing, outside master. Passes addresses.json, statute-aware.json and auth.json unchanged; its master passes the 11 slave-setup mode flows against the Python email side (ExternalMaster). Differential testing vs mas_core found two unstated stand-in rules, now SCP-9 in the v0.2 draft with fixtures si-011..013, dt-109; ABNF comments no longer say v0.1. Runner: external masters, step errors fail the flow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 5787@bnt-lap001 Assistant-Session: 5db26b82-138b-40cc-9856-4d5eb0f752fd
116 lines
4.9 KiB
JavaScript
116 lines
4.9 KiB
JavaScript
// 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");
|
|
}
|