mail-address-semantics/impl/js/src/statute.mjs
tegwick a841ceec73 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
2026-10-19 13:22:22 +02:00

131 lines
5.6 KiB
JavaScript

// 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;
}