MAS-WP-0009 T01: independent JS implementation (impl/js), differential test, SCP-9
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
This commit is contained in:
parent
ecb1ba47f6
commit
a841ceec73
23 changed files with 938 additions and 49 deletions
116
impl/js/src/master.mjs
Normal file
116
impl/js/src/master.mjs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// 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");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue