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
131 lines
5.6 KiB
JavaScript
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;
|
|
}
|