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:
tegwick 2026-10-19 13:22:22 +02:00
parent ecb1ba47f6
commit a841ceec73
23 changed files with 938 additions and 49 deletions

31
impl/js/src/batch.mjs Normal file
View file

@ -0,0 +1,31 @@
// Batch evaluator for differential testing against another implementation.
// stdin: JSON {statutes: {name: doc}, cases: [{op, ...}]}; stdout: JSON results.
// {op: "parse", local, profile} -> {ok, parsed} | {ok: false}
// {op: "interpret", local, statute} -> {ok, canonical, stand_ins, key} | {ok: false}
// {op: "dest", local, statute, needs, lossy} -> {ok, text} | {ok: false}
import { readFileSync } from "node:fs";
import { parseLocalPart } from "./parser.mjs";
import { Statute, interpret, targetKey, forDestination } from "./statute.mjs";
const input = JSON.parse(readFileSync(0, "utf8"));
const statutes = Object.fromEntries(Object.entries(input.statutes ?? {}).map(([k, d]) => [k, new Statute(d)]));
const out = input.cases.map((c) => {
try {
if (c.op === "parse") {
const { required_profile, generator_allowed, ...parsed } = parseLocalPart(c.local, c.profile);
return { ok: true, parsed };
}
if (c.op === "interpret") {
const st = statutes[c.statute];
const r = interpret(c.local, st);
return { ok: true, canonical: r.canonical, stand_ins: r.stand_ins, key: targetKey(r.parsed, st) };
}
if (c.op === "dest") {
return { ok: true, text: forDestination(c.local, statutes[c.statute], c.needs, { allowLossy: c.lossy }) };
}
return { ok: false, error: "unknown op" };
} catch (e) {
return { ok: false, error: e.code ?? e.reason ?? String(e) };
}
});
process.stdout.write(JSON.stringify(out));

116
impl/js/src/master.mjs Normal file
View 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");
}

84
impl/js/src/parser.mjs Normal file
View file

@ -0,0 +1,84 @@
// MAS canonical local-part parser, written from mas-v0.2-spec/grammar/mas-v0.2.abnf
// and SPECIFICATION.md section 4. No I/O.
const ATOM_CHAR = "A-Za-z0-9#%&'*\\-/?_`{|}~";
const ATOM = `[${ATOM_CHAR}]+`;
const VALUE_SEG = `[${ATOM_CHAR}=]+`;
const DOT_STRING = new RegExp(`^${ATOM}(?:\\.${ATOM})*$`);
const VALUE = new RegExp(`^${VALUE_SEG}(?:\\.${VALUE_SEG})*$`);
const RESOURCE_TYPE = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/;
export class MasSyntaxError extends Error {
constructor(code, message) {
super(message ?? code);
this.code = code;
}
}
/**
* Parse a canonical MAS local-part.
* @param {string} local
* @param {"p1"|"p2"} profile P1 rejects interactions; P2 accepts them.
* @returns {{target: string, target_kind: "opaque"|"resource", resource_type?: string,
* resource_id?: string, facet: string|null, interaction: string|null,
* required_profile: "P1"|"P2", generator_allowed: boolean}}
*/
export function parseLocalPart(local, profile = "p2") {
if (typeof local !== "string" || local.length === 0) throw new MasSyntaxError("empty");
if (/[^\x21-\x7e]/.test(local)) throw new MasSyntaxError("non-ascii", "outside the ASCII grammar");
if (/[$^]/.test(local)) throw new MasSyntaxError("reserved-operator");
let rest = local;
let interaction = null;
const bang = rest.indexOf("!");
if (bang >= 0) {
if (rest.indexOf("!", bang + 1) >= 0) throw new MasSyntaxError("multiple-interactions");
interaction = rest.slice(bang + 1);
rest = rest.slice(0, bang);
if (interaction.includes("+")) throw new MasSyntaxError("operator-order", "'+' after '!'");
if (!interaction) throw new MasSyntaxError("empty-interaction");
if (!VALUE.test(interaction)) throw new MasSyntaxError("invalid-interaction");
if (profile === "p1") throw new MasSyntaxError("interaction-not-in-p1");
}
let facet = null;
const plus = rest.indexOf("+");
if (plus >= 0) {
if (rest.indexOf("+", plus + 1) >= 0) throw new MasSyntaxError("multiple-facets");
facet = rest.slice(plus + 1);
rest = rest.slice(0, plus);
if (!facet) throw new MasSyntaxError("empty-facet");
if (!VALUE.test(facet)) throw new MasSyntaxError("invalid-facet");
}
const target = rest;
if (!target) throw new MasSyntaxError("empty-target");
const out = { target, facet, interaction };
const eq = target.indexOf("=");
if (eq >= 0) {
const type = target.slice(0, eq);
const id = target.slice(eq + 1);
if (!type) throw new MasSyntaxError("empty-resource-type");
if (!id) throw new MasSyntaxError("empty-resource-id");
if (!RESOURCE_TYPE.test(type)) throw new MasSyntaxError("invalid-resource-type");
if (!VALUE.test(id)) throw new MasSyntaxError("invalid-resource-id");
Object.assign(out, { target_kind: "resource", resource_type: type, resource_id: id });
} else {
if (!DOT_STRING.test(target)) throw new MasSyntaxError("invalid-target");
out.target_kind = "opaque";
}
out.required_profile = interaction === null ? "P1" : "P2";
out.generator_allowed = !(facet !== null && interaction !== null);
return out;
}
/** Render parsed components back to a local-part. */
export function render(target, facet, interaction) {
return target + (facet !== null && facet !== undefined ? "+" + facet : "")
+ (interaction !== null && interaction !== undefined ? "!" + interaction : "");
}
/** SMTP local-part limit (SPECIFICATION section 13), in octets. */
export const MAX_OCTETS = 64;
export const octets = (s) => new TextEncoder().encode(s).length;

38
impl/js/src/signing.mjs Normal file
View file

@ -0,0 +1,38 @@
// Peer authentication, written from mas-modes-v0.1-spec/CONTRACT.md section 7.
import { createHmac, timingSafeEqual } from "node:crypto";
export const PEER = "mas-modes-peer";
export const TIMESTAMP = "mas-modes-timestamp";
export const SIGNATURE = "mas-modes-signature";
export const WINDOW = 300;
/** "v1=" + hex HMAC-SHA256(key, TIMESTAMP + "." + BODY). body: Buffer or string. */
export function signature(key, timestamp, body) {
return "v1=" + createHmac("sha256", key).update(`${timestamp}.`).update(body).digest("hex");
}
export function sign(body, peer, key, timestamp = Math.floor(Date.now() / 1000)) {
return { "MAS-Modes-Peer": peer, "MAS-Modes-Timestamp": String(timestamp),
"MAS-Modes-Signature": signature(key, timestamp, body) };
}
export class AuthError extends Error {
constructor(code, status = 401) {
super(code);
this.code = code;
this.status = status;
}
}
/** Returns the authenticated peer id or throws AuthError. keys: {peer: Buffer}. */
export function verify(headers, body, keys, now = Date.now() / 1000) {
const h = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
const peer = h[PEER], ts = h[TIMESTAMP], sig = h[SIGNATURE];
const key = peer ? keys[peer] : undefined;
if (!key || !ts || !sig || !/^\d+$/.test(ts)) throw new AuthError("unauthenticated-peer");
const want = Buffer.from(signature(key, Number(ts), body));
const got = Buffer.from(String(sig));
if (want.length !== got.length || !timingSafeEqual(want, got)) throw new AuthError("unauthenticated-peer");
if (Math.abs(now - Number(ts)) > WINDOW) throw new AuthError("replayed");
return peer;
}

131
impl/js/src/statute.mjs Normal file
View file

@ -0,0 +1,131 @@
// v0.2 statute-aware rules, written from mas-v0.2-spec/SPECIFICATION.md:
// stand-ins (6.3), equivalence (8), destination transforms (12.1), length (13).
// Pure functions over a statute document; no I/O, no schema validation.
import { parseLocalPart, render, MasSyntaxError, MAX_OCTETS, octets } from "./parser.mjs";
export class Statute {
/** @param {object} doc a statute document (already validated by its publisher) */
constructor(doc) {
this.namespace = doc.namespace;
this.version = doc.mas_version;
const v2 = this.version === "0.2";
this.p2 = Boolean(doc.profiles?.p2);
this.resourceTypes = new Set(Object.keys(doc.resource_types ?? {}));
this.modes = new Set(Object.entries(doc.interaction_modes ?? {})
.filter(([, m]) => m.enabled !== false).map(([k]) => k));
this.localPartCase = doc.equivalence?.local_part_case ?? "exact";
this.resourceIdCase = v2 ? doc.equivalence?.resource_id_case ?? "exact" : "exact";
this.altResource = v2 && Boolean(doc.alternate_syntax?.resource);
this.altInteraction = v2 && Boolean(doc.alternate_syntax?.interaction);
}
}
/**
* Read a received local-part under a statute (6.3 matching order).
* @returns {{canonical: string, stand_ins: string[], parsed: object}}
*/
export function interpret(local, statute) {
let rest = local;
const standIns = [];
// 1. Interaction: no "!" yet, and the local-part ends in ---MODE, MODE enabled.
if (statute.altInteraction && statute.p2 && !rest.includes("!")) {
const at = rest.lastIndexOf("---");
if (at > 0) {
const mode = rest.slice(at + 3);
if (statute.modes.has(mode)) {
rest = rest.slice(0, at) + "!" + mode;
standIns.push("interaction");
}
}
}
let parsed = parseLocalPart(rest, "p2");
// 2. Resource: the remaining target is TYPE--ID with TYPE declared and ID not
// beginning with "-"; split at the first "--".
if (statute.altResource && parsed.target_kind === "opaque") {
const at = parsed.target.indexOf("--");
const id = parsed.target.slice(at + 2);
if (at > 0 && statute.resourceTypes.has(parsed.target.slice(0, at)) && id && !id.startsWith("-")) {
const target = parsed.target.slice(0, at) + "=" + parsed.target.slice(at + 2);
parsed = parseLocalPart(render(target, parsed.facet, parsed.interaction), "p2");
standIns.push("resource");
}
}
return { canonical: render(parsed.target, parsed.facet, parsed.interaction), stand_ins: standIns, parsed };
}
/** What a local-part names (8): the target under the statute's case rules. */
export function targetKey(parsed, statute) {
const fold = statute.localPartCase === "ascii-insensitive";
if (parsed.target_kind === "resource") {
const id = fold || statute.resourceIdCase === "insensitive"
? parsed.resource_id.toLowerCase() : parsed.resource_id;
return `${parsed.resource_type}=${id}`;
}
return fold ? parsed.target.toLowerCase() : parsed.target;
}
export function equivalent(a, b, statute) {
return targetKey(interpret(a, statute).parsed, statute) === targetKey(interpret(b, statute).parsed, statute);
}
export class CannotExpress extends Error {
constructor(reason) {
super(reason);
this.reason = reason;
}
}
const TRANSFORMS = ["drop-facet", "drop-interaction", "resource-alternate", "interaction-alternate", "lowercase"];
const LOSSY = new Set(["drop-facet", "drop-interaction"]);
/**
* Write a canonical local-part for a destination that needs `needs` (12.1).
* Transforms apply only where they change something; lossy ones only on
* explicit request. Refuses (CannotExpress) rather than emit a form the
* namespace would read differently, or one over 64 octets (13).
*/
export function forDestination(local, statute, needs = [], { allowLossy = false } = {}) {
for (const n of needs) if (!TRANSFORMS.includes(n)) throw new Error(`unknown transform ${n}`);
if (needs.some((n) => LOSSY.has(n)) && !allowLossy) throw new CannotExpress("lossy transform not requested");
const p = parseLocalPart(local, "p2");
let facet = p.facet;
let interaction = p.interaction;
if (needs.includes("drop-facet")) facet = null;
if (needs.includes("drop-interaction")) interaction = null;
const want = { key: targetKey(p, statute), facet, interaction };
let target = p.target;
if (needs.includes("resource-alternate") && p.target_kind === "resource") {
if (!statute.altResource) throw new CannotExpress("statute does not enable the resource stand-in");
target = `${p.resource_type}--${p.resource_id}`;
}
let text = target + (facet !== null ? "+" + facet : "");
if (interaction !== null) {
if (needs.includes("interaction-alternate")) {
if (!statute.altInteraction || !statute.modes.has(interaction)) {
throw new CannotExpress("statute does not enable the interaction stand-in for this mode");
}
text += "---" + interaction;
} else {
text += "!" + interaction;
}
}
if (needs.includes("lowercase") && text !== text.toLowerCase()) {
if (statute.resourceIdCase !== "insensitive") throw new CannotExpress("statute does not make identifiers case-insensitive");
text = text.toLowerCase();
}
if (octets(text) > MAX_OCTETS) throw new CannotExpress(`${octets(text)} octets after transforms`);
let got;
try {
got = interpret(text, statute).parsed;
} catch (e) {
if (e instanceof MasSyntaxError) throw new CannotExpress(`not parseable: ${e.code}`);
throw e;
}
if (targetKey(got, statute) !== want.key || got.facet !== want.facet || got.interaction !== want.interaction) {
throw new CannotExpress(`${text} would be read as ${render(got.target, got.facet, got.interaction)}`);
}
return text;
}