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
31
impl/js/src/batch.mjs
Normal file
31
impl/js/src/batch.mjs
Normal 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
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");
|
||||
}
|
||||
84
impl/js/src/parser.mjs
Normal file
84
impl/js/src/parser.mjs
Normal 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
38
impl/js/src/signing.mjs
Normal 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
131
impl/js/src/statute.mjs
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue