MAS-WP-0014 T01: independent JS email side for mode contract v0.1; MCP-1..6; flow a-008
impl/js/src/email.mjs (written from CONTRACT.md + FLOWS.md): both roles, case store, handoffs, timeouts, one-cap outbox with holding, authorization. Passes all 22 flows from the bundle; with the JS master all 12 slave flows (no Python contract party). Writing it found six contract gaps, folded into the draft as MCP-1..6 (acknowledgement, details convention, resume names the outcome, opaque case key, error table, transfer reporting); flows now check outcome tokens, not reference prose. A mutation check found a missing flow: a-008 (per-type master check). 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
a5310dd63c
commit
71c17fb5f9
14 changed files with 844 additions and 44 deletions
470
impl/js/src/email.mjs
Normal file
470
impl/js/src/email.mjs
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
// An email side for the MAS mode contract (mas-modes-v0.1-spec/CONTRACT.md),
|
||||
// written from the contract and FLOWS.md, independent of mas_modes.email_mode.
|
||||
// Both roles (section 3), peer authentication (7), principle 10 with one
|
||||
// send cap that holds over the cap (6). Zero dependencies.
|
||||
//
|
||||
// node src/email.mjs --config FILE -> prints {"base_url": ...} on the first line
|
||||
//
|
||||
// Contract: POST /mas-events gateway events (unsigned on loopback unless event_peer)
|
||||
// POST /mode-status/TOKEN handoff.status from the handoff's mode
|
||||
// POST /master outside-master commands (slave role)
|
||||
// Control: GET /control/outbox (loopback only)
|
||||
// POST /control/advance {seconds} -> {timed_out: [...]}
|
||||
import { createServer } from "node:http";
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { sign, verify, AuthError } from "./signing.mjs";
|
||||
import { parseLocalPart } from "./parser.mjs";
|
||||
import { Statute, targetKey } from "./statute.mjs";
|
||||
|
||||
const VERSION = "0.1";
|
||||
const BUNDLE = new URL("../../../mas-modes-v0.1-spec/", import.meta.url);
|
||||
const T = JSON.parse(readFileSync(new URL("conformance/transitions.json", BUNDLE)));
|
||||
const TERMINAL = new Set(T.terminal);
|
||||
const allowed = (from, to) => (T.transitions[from ?? "(new)"] ?? []).includes(to);
|
||||
const NOT_ENABLED = new Set(["InteractionModeNotEnabled", "ProfileNotEnabled"]);
|
||||
|
||||
const HELD_MAX_AGE = 7 * 86400;
|
||||
const HELD_MAX_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
const err = (status, error, extra = {}) => [status, { ok: false, error, ...extra }];
|
||||
const newId = (p) => p + randomBytes(6).toString("hex");
|
||||
|
||||
/** Details: `Field: value` lines, field names ignoring case, first wins (CONTRACT 3.1.4). */
|
||||
export function details(text, fields) {
|
||||
const out = {};
|
||||
for (const line of (text ?? "").split(/\r?\n/)) {
|
||||
const m = /^\s*([A-Za-z_-]+)\s*:\s*(.+?)\s*$/.exec(line);
|
||||
if (m && fields.includes(m[1].toLowerCase()) && !(m[1].toLowerCase() in out)) out[m[1].toLowerCase()] = m[2];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Principle 10 (CONTRACT 6): why email must not answer this message, or null. */
|
||||
export function refusal(msg) {
|
||||
const auto = (msg.auto_submitted ?? "no").toLowerCase() !== "no";
|
||||
const bulk = ["bulk", "list", "junk"].includes((msg.precedence ?? "").toLowerCase());
|
||||
if (!msg.from || auto || bulk) return "automated-sender";
|
||||
if (msg.authentication?.dmarc !== "pass") return "unauthenticated-sender";
|
||||
return null;
|
||||
}
|
||||
|
||||
class Mailer {
|
||||
constructor(maxSent, clock) {
|
||||
this.maxSent = maxSent;
|
||||
this.clock = clock;
|
||||
this.outbox = [];
|
||||
}
|
||||
|
||||
/** {reason: null} when sent; else {reason, held}. One cap for every sender. */
|
||||
reply(caseRef, from, msg, subject, body) {
|
||||
const why = refusal(msg);
|
||||
if (why) return { reason: why, held: false };
|
||||
const now = this.clock();
|
||||
this.outbox = this.outbox.filter((m) => !m.held || now - m.created < HELD_MAX_AGE);
|
||||
const mail = { case_ref: caseRef, mail_from: from, mail_to: msg.from,
|
||||
subject: /^re:/i.test(subject) ? subject : `Re: ${subject}`, body,
|
||||
in_reply_to: msg.message_id ?? null, created: now, held: null, sent: 0 };
|
||||
const capped = this.maxSent !== null && this.maxSent !== undefined;
|
||||
if (capped && this.outbox.filter((m) => m.sent).length >= this.maxSent) {
|
||||
const heldBytes = this.outbox.filter((m) => m.held).reduce((n, m) => n + Buffer.byteLength(m.body), 0);
|
||||
if (heldBytes + Buffer.byteLength(body) > HELD_MAX_BYTES) return { reason: "send-cap-reached", held: false };
|
||||
this.outbox.push({ ...mail, held: "send-cap-reached" });
|
||||
return { reason: "send-cap-reached", held: true };
|
||||
}
|
||||
this.outbox.push({ ...mail, sent: capped ? 1 : 0 }); // with a cap, an SMTP sink counts sends
|
||||
return { reason: null, held: false };
|
||||
}
|
||||
}
|
||||
|
||||
const isLoopback = (a) => a === "127.0.0.1" || a === "::1" || a === "::ffff:127.0.0.1";
|
||||
|
||||
export class EmailSide {
|
||||
constructor(config) {
|
||||
this.peerId = config.peer_id ?? "email";
|
||||
this.keys = Object.fromEntries(Object.entries(config.keys).map(([p, k]) => [p, Buffer.from(k, "hex")]));
|
||||
this.eventPeer = config.event_peer ?? null;
|
||||
this.statute = new Statute(config.statute);
|
||||
this.domain = config.statute.namespace;
|
||||
this.roles = config.roles;
|
||||
this.now = config.clock_start ?? 1_000_000;
|
||||
this.mailer = new Mailer(config.max_sent ?? null, () => this.now);
|
||||
this.baseUrl = "";
|
||||
this.cases = new Map(); // key -> {address, id, type}
|
||||
this.intakes = new Map(); // ref -> {type, messageRef, mode, assigned}
|
||||
this.messages = new Map(); // ref -> {caseKey, msg}
|
||||
this.handoffs = new Map(); // id -> handoff
|
||||
this.policies = new Map(); // case key -> {autonomous_send, transfers}
|
||||
this.seen = new Map(); // message id -> response (idempotency)
|
||||
this.queue = [];
|
||||
this.counter = 0;
|
||||
for (const [type, cfg] of Object.entries(this.roles)) {
|
||||
const peers = cfg.role === "master" ? Object.values(cfg.modes ?? {}).map((m) => m.peer) : [cfg.master_peer];
|
||||
if (typeof cfg.transfers === "object") peers.push(cfg.transfers.peer);
|
||||
for (const p of peers) if (!(p in this.keys)) throw new Error(`${type}: no key for peer ${p}`);
|
||||
}
|
||||
}
|
||||
|
||||
// -- helpers ------------------------------------------------------------------
|
||||
|
||||
caseFor(address) {
|
||||
const local = address.slice(0, address.lastIndexOf("@"));
|
||||
return { address, key: targetKey(parseLocalPart(local, "p2"), this.statute) };
|
||||
}
|
||||
|
||||
send(url, peer, msg, onResult) {
|
||||
this.queue.push({ url, peer, msg, onResult });
|
||||
}
|
||||
|
||||
async flush() {
|
||||
while (this.queue.length) {
|
||||
const { url, peer, msg, onResult } = this.queue.shift();
|
||||
const body = Buffer.from(JSON.stringify(msg));
|
||||
let status = null;
|
||||
try {
|
||||
const res = await fetch(url, { method: "POST", body,
|
||||
headers: { "Content-Type": "application/json", ...sign(body, this.peerId, this.keys[peer]) } });
|
||||
status = res.status;
|
||||
await res.text();
|
||||
} catch { status = null; }
|
||||
if (onResult) onResult(status);
|
||||
}
|
||||
}
|
||||
|
||||
message(type, kase, fields) {
|
||||
const m = { mas_modes_version: VERSION, type, id: randomUUID().replaceAll("-", "") };
|
||||
if (kase) m.case = kase;
|
||||
for (const [k, v] of Object.entries(fields)) if (v !== null && v !== undefined) m[k] = v;
|
||||
return m;
|
||||
}
|
||||
|
||||
openHandoff(key) {
|
||||
for (const h of this.handoffs.values()) if (h.caseKey === key && !TERMINAL.has(h.status)) return h;
|
||||
return null;
|
||||
}
|
||||
|
||||
// -- inbound mail (gateway events) ---------------------------------------------
|
||||
|
||||
event(ev) {
|
||||
const mas = ev.mas ?? {};
|
||||
const msg = ev.message ?? {};
|
||||
if (!mas.valid || !mas.namespace_confirmed) return [422, { status: "not-handled" }];
|
||||
const type = mas.kind === "resource" ? mas.resource_type : mas.intake_for;
|
||||
const cfg = this.roles[type];
|
||||
if (!cfg) return [404, { status: "not-handled" }];
|
||||
return cfg.role === "master" ? this.masterEvent(ev, mas, msg, type, cfg) : this.slaveEvent(ev, mas, msg, type, cfg);
|
||||
}
|
||||
|
||||
masterEvent(ev, mas, msg, type, cfg) {
|
||||
let kase, created = false;
|
||||
if (mas.kind === "resource") {
|
||||
kase = this.cases.get(this.caseFor(`${mas.canonical_local_part}@${mas.domain}`).key);
|
||||
if (!kase) return [202, { status: "unknown-resource" }];
|
||||
} else {
|
||||
const id = `js-${++this.counter}`;
|
||||
const address = `${type}=${id}@${mas.domain}`;
|
||||
kase = { ...this.caseFor(address), id, type };
|
||||
this.cases.set(kase.key, kase);
|
||||
created = true;
|
||||
}
|
||||
const ref = { address: kase.address, key: kase.key };
|
||||
const failures = new Set((mas.failures ?? []).map((f) => f.code));
|
||||
const mode = mas.interaction ?? null;
|
||||
if (created) {
|
||||
const lines = [`Your request is now case ${kase.id}.`, `Reply to ${kase.address}; no reference in the subject is needed.`];
|
||||
if (mode && [...failures].some((f) => NOT_ENABLED.has(f))) {
|
||||
lines.push(`The requested mode '${mode}' is not enabled here (refused: mode-not-enabled); we continue by email.`);
|
||||
}
|
||||
this.mailer.reply(kase.key, kase.address, msg, msg.subject || "Your request", lines.join("\n"));
|
||||
}
|
||||
const open = this.openHandoff(kase.key);
|
||||
if (open) {
|
||||
if (open.status === "negotiating") {
|
||||
const d = details(msg.text, open.needs ?? []);
|
||||
if (Object.keys(d).length) this.send(open.url, open.peer, this.message("handoff.update", ref, { handoff_id: open.id, details: d }));
|
||||
}
|
||||
return [200, { status: "correlated", case: ref, handoff_id: open.id }];
|
||||
}
|
||||
if (!mode) return [created ? 201 : 200, { status: created ? "created" : "correlated", case: ref }];
|
||||
const endpoint = cfg.modes?.[mode];
|
||||
const h = this.newHandoff("master", type, ref, mode, msg, endpoint, this.now + (cfg.timeout ?? 3600));
|
||||
if ([...failures].some((f) => NOT_ENABLED.has(f))) {
|
||||
this.status(h, "requested", {});
|
||||
this.status(h, "refused", { reason: "mode-not-enabled" }, !created);
|
||||
} else if (!endpoint) {
|
||||
this.status(h, "requested", {});
|
||||
this.status(h, "refused", { reason: "mode-unavailable" });
|
||||
} else {
|
||||
this.request(h, ref, details(msg.text, cfg.fields ?? ["phone", "time"]), msg);
|
||||
}
|
||||
return [created ? 201 : 200, { status: created ? "created" : "correlated", case: ref, handoff_id: h.id }];
|
||||
}
|
||||
|
||||
newHandoff(role, type, ref, mode, msg, endpoint, deadline) {
|
||||
const h = { id: newId("h-"), role, type, caseAddress: ref.address, caseKey: ref.key, mode, status: null,
|
||||
seq: 0, relaySeq: 0, deadline, participant: msg, url: endpoint?.url ?? endpoint?.webhook ?? null,
|
||||
peer: endpoint?.peer ?? null, token: randomBytes(24).toString("base64url"), needs: [] };
|
||||
this.handoffs.set(h.id, h);
|
||||
return h;
|
||||
}
|
||||
|
||||
request(h, ref, d, msg) {
|
||||
this.status(h, "requested", {});
|
||||
const req = this.message("handoff.request", ref, { handoff_id: h.id, mode: h.mode, details: d,
|
||||
status_url: `${this.baseUrl}/mode-status/${h.token}`, participant: msg.from ? { address: msg.from } : null });
|
||||
this.send(h.url, h.peer, req, (status) => {
|
||||
if (status === null || status >= 400) this.status(h, "refused", { reason: "mode-unavailable", detail: `mode endpoint answered ${status}` });
|
||||
});
|
||||
}
|
||||
|
||||
/** Record a status and react to it, per role (CONTRACT 3). */
|
||||
status(h, status, info, notify = true) {
|
||||
h.status = status;
|
||||
if (info.needs) h.needs = info.needs;
|
||||
const ref = { address: h.caseAddress, key: h.caseKey };
|
||||
const subject = h.participant.subject || "Your request";
|
||||
const ask = (send) => send(subject, [`To arrange the ${h.mode}, please reply to this message with:`,
|
||||
...info.needs.map((n) => `${n[0].toUpperCase()}${n.slice(1)}: ...`)].join("\n"));
|
||||
if (h.role === "master") {
|
||||
if (status === "negotiating") ask((s, b) => this.mailer.reply(h.caseKey, h.caseAddress, h.participant, s, b));
|
||||
else if (TERMINAL.has(status) && notify) {
|
||||
const why = info.reason ?? info.detail;
|
||||
this.mailer.reply(h.caseKey, h.caseAddress, h.participant, subject,
|
||||
`Update on your ${h.mode} request. Outcome: ${status}${why ? ` (${why})` : ""}. We continue by email.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const cfg = this.roles[h.type];
|
||||
if (status !== "requested") {
|
||||
h.relaySeq += 1;
|
||||
this.send(cfg.master_url, cfg.master_peer, this.message("handoff.status", ref,
|
||||
{ handoff_id: h.id, seq: h.relaySeq, status, ...info }));
|
||||
}
|
||||
if (status === "negotiating" && this.policies.get(h.caseKey).autonomous_send) {
|
||||
ask((s, b) => this.mailer.reply(h.caseKey, h.caseAddress, h.participant, s, b));
|
||||
}
|
||||
}
|
||||
|
||||
slaveEvent(ev, mas, msg, type, cfg) {
|
||||
if (mas.kind !== "resource") {
|
||||
const ref = newId("i-");
|
||||
const messageRef = newId("m-");
|
||||
this.messages.set(messageRef, { caseKey: null, msg });
|
||||
this.intakes.set(ref, { type, domain: mas.domain, messageRef, mode: mas.interaction ?? null, assigned: null });
|
||||
this.send(cfg.master_url, cfg.master_peer, this.message("case.intake", null,
|
||||
{ intake_ref: ref, resource_type: type, event: ev, requested_mode: mas.interaction ?? null }));
|
||||
return [202, { status: "intake-reported", intake_ref: ref }];
|
||||
}
|
||||
const kase = this.cases.get(this.caseFor(`${mas.canonical_local_part}@${mas.domain}`).key);
|
||||
if (!kase) return [202, { status: "unknown-resource" }];
|
||||
const ref = { address: kase.address, key: kase.key };
|
||||
const messageRef = newId("m-");
|
||||
this.messages.set(messageRef, { caseKey: kase.key, msg });
|
||||
this.send(cfg.master_url, cfg.master_peer, this.message("case.message", ref, { event: ev, message_ref: messageRef }));
|
||||
const open = this.openHandoff(kase.key);
|
||||
if (open?.status === "negotiating" && open.url) {
|
||||
const d = details(msg.text, open.needs);
|
||||
if (Object.keys(d).length) this.send(open.url, open.peer, this.message("handoff.update", ref, { handoff_id: open.id, details: d }));
|
||||
} else if (!open && mas.interaction) {
|
||||
this.transfer(type, cfg, ref, mas.interaction, msg);
|
||||
}
|
||||
return [200, { status: "reported", case: ref, message_ref: messageRef }];
|
||||
}
|
||||
|
||||
transfer(type, cfg, ref, mode, msg) {
|
||||
const t = this.policies.get(ref.key).transfers;
|
||||
const webhook = typeof t === "object" ? t : null;
|
||||
const h = this.newHandoff("slave", type, ref, mode, msg, webhook, null);
|
||||
this.send(cfg.master_url, cfg.master_peer, this.message("handoff.requested", ref,
|
||||
{ handoff_id: h.id, mode, forwarded: Boolean(webhook) }));
|
||||
if (!webhook) {
|
||||
this.status(h, "requested", {});
|
||||
this.status(h, "refused", { reason: "blocked" });
|
||||
} else {
|
||||
this.request(h, ref, details(msg.text, cfg.fields ?? ["phone", "time"]), msg);
|
||||
}
|
||||
return h.id;
|
||||
}
|
||||
|
||||
// -- statuses from modes -------------------------------------------------------
|
||||
|
||||
modeStatus(msg, peer, token) {
|
||||
const h = [...this.handoffs.values()].find((x) => x.token === token);
|
||||
if (!h) return err(404, "unknown-handoff");
|
||||
if (peer !== h.peer) return err(403, "wrong-peer");
|
||||
if (msg.mas_modes_version !== VERSION || msg.type !== "handoff.status" || !Number.isInteger(msg.seq)
|
||||
|| typeof msg.status !== "string" || !msg.case) return err(400, "schema");
|
||||
if (msg.handoff_id !== h.id || msg.case.key !== h.caseKey) return err(404, "unknown-handoff");
|
||||
if (msg.seq <= h.seq) return [200, { ok: true, ignored: "stale" }];
|
||||
if (msg.status === "timed-out" || !allowed(h.status, msg.status)) return err(409, "invalid-transition");
|
||||
if (msg.status === "negotiating" && !Array.isArray(msg.needs)) return err(400, "schema");
|
||||
h.seq = msg.seq;
|
||||
const info = {};
|
||||
for (const k of ["needs", "at", "outcome", "reason", "detail"]) if (k in msg) info[k] = msg[k];
|
||||
this.status(h, msg.status, info);
|
||||
return [200, { ok: true }];
|
||||
}
|
||||
|
||||
advance(seconds) {
|
||||
this.now += seconds;
|
||||
const out = [];
|
||||
for (const h of this.handoffs.values()) {
|
||||
if (h.role !== "master" || h.status === null || TERMINAL.has(h.status) || h.deadline > this.now) continue;
|
||||
this.status(h, "timed-out", {});
|
||||
if (h.url) this.send(h.url, h.peer, this.message("handoff.cancel", { address: h.caseAddress, key: h.caseKey },
|
||||
{ handoff_id: h.id, reason: "timed-out" }));
|
||||
out.push(h.id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// -- outside-master commands (slave role) ---------------------------------------
|
||||
|
||||
isMaster(type, peer) {
|
||||
const cfg = this.roles[type];
|
||||
return Boolean(cfg && cfg.role === "slave" && peer && peer === cfg.master_peer);
|
||||
}
|
||||
|
||||
policyPeerProblem(policy) {
|
||||
const t = policy?.transfers;
|
||||
if (t && typeof t === "object" && !(t.peer in this.keys)) return err(400, "unknown-peer");
|
||||
if (t !== undefined && t !== "block" && !(t && typeof t === "object" && t.webhook && t.peer)) return err(400, "schema");
|
||||
return null;
|
||||
}
|
||||
|
||||
master(msg, peer) {
|
||||
if (!Object.keys(this.roles).some((t) => this.isMaster(t, peer))) return err(403, "wrong-peer");
|
||||
if (msg.mas_modes_version !== VERSION || !msg.id || typeof msg.type !== "string") return err(400, "schema");
|
||||
if (msg.type === "case.assign") return this.assign(msg, peer);
|
||||
if (!msg.case?.key) return err(400, "schema");
|
||||
const kase = this.cases.get(msg.case.key);
|
||||
if (!kase) return err(404, "unknown-case");
|
||||
if (!this.isMaster(kase.type, peer)) return err(403, "wrong-peer");
|
||||
if (msg.type === "case.policy") {
|
||||
const bad = this.policyPeerProblem(msg);
|
||||
if (bad) return bad;
|
||||
const pol = { ...this.policies.get(kase.key) };
|
||||
for (const k of ["autonomous_send", "transfers"]) if (k in msg) pol[k] = msg[k];
|
||||
this.policies.set(kase.key, pol);
|
||||
return [200, { ok: true, policy: pol }];
|
||||
}
|
||||
if (msg.type === "mail.send") {
|
||||
if (typeof msg.in_reply_to !== "string" || typeof msg.body !== "string") return err(400, "schema");
|
||||
const m = this.messages.get(msg.in_reply_to);
|
||||
if (!m || m.caseKey !== kase.key) return err(409, "refused", { reason: "unknown-message" });
|
||||
const r = this.mailer.reply(kase.key, kase.address, m.msg, msg.subject || m.msg.subject || "Your request", msg.body);
|
||||
if (r.reason === null) return [200, { ok: true, sent: true }];
|
||||
return err(409, "refused", { reason: r.reason, ...(r.reason === "send-cap-reached" ? { held: r.held } : {}) });
|
||||
}
|
||||
if (msg.type === "handoff.cancel") {
|
||||
const h = this.handoffs.get(msg.handoff_id);
|
||||
if (!h || h.caseKey !== kase.key) return err(404, "unknown-handoff");
|
||||
if (h.url && !TERMINAL.has(h.status)) {
|
||||
this.send(h.url, h.peer, this.message("handoff.cancel", msg.case, { handoff_id: h.id, reason: msg.reason }));
|
||||
}
|
||||
return [200, { ok: true }];
|
||||
}
|
||||
return err(400, "schema");
|
||||
}
|
||||
|
||||
assign(msg, peer) {
|
||||
const it = this.intakes.get(msg.intake_ref);
|
||||
if (it && !this.isMaster(it.type, peer)) return err(403, "wrong-peer");
|
||||
const bad = this.policyPeerProblem(msg.policy);
|
||||
if (bad) return bad;
|
||||
if (!it) return err(404, "unknown-intake");
|
||||
if (it.assigned !== null) return err(409, "already-assigned");
|
||||
let kase;
|
||||
try {
|
||||
parseLocalPart(`${it.type}=${msg.resource_id}`, "p1");
|
||||
kase = { ...this.caseFor(`${it.type}=${msg.resource_id}@${it.domain}`), id: msg.resource_id, type: it.type };
|
||||
} catch {
|
||||
return err(400, "invalid-resource-id");
|
||||
}
|
||||
if (this.cases.has(kase.key)) return err(409, "case-exists");
|
||||
const cfg = this.roles[it.type];
|
||||
this.cases.set(kase.key, kase);
|
||||
this.policies.set(kase.key, { autonomous_send: cfg.autonomous_send ?? false, transfers: cfg.transfers ?? "block",
|
||||
...(msg.policy ?? {}) });
|
||||
it.assigned = msg.resource_id;
|
||||
const m = this.messages.get(it.messageRef);
|
||||
m.caseKey = kase.key;
|
||||
const ref = { address: kase.address, key: kase.key };
|
||||
const out = { ok: true, case: ref, message_ref: it.messageRef };
|
||||
if (this.policies.get(kase.key).autonomous_send) {
|
||||
out.acknowledged = this.mailer.reply(kase.key, kase.address, m.msg, m.msg.subject || "Your request",
|
||||
`Your request is now case ${kase.id}.\nReply to ${kase.address} to add to it.`).reason === null;
|
||||
}
|
||||
if (it.mode) out.handoff_id = this.transfer(it.type, cfg, ref, it.mode, m.msg);
|
||||
return [200, out];
|
||||
}
|
||||
|
||||
// -- HTTP ----------------------------------------------------------------------
|
||||
|
||||
listen(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 reply = (status, obj) => {
|
||||
const out = JSON.stringify(obj);
|
||||
res.writeHead(status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(out) });
|
||||
res.end(out);
|
||||
};
|
||||
const loop = isLoopback(req.socket.remoteAddress);
|
||||
try {
|
||||
if (req.url.startsWith("/control/")) {
|
||||
if (!loop) return reply(403, { ok: false, error: "control is loopback-only" });
|
||||
if (req.method === "GET" && req.url === "/control/outbox") return reply(200, this.mailer.outbox);
|
||||
if (req.method === "POST" && req.url === "/control/advance") {
|
||||
const out = this.advance(JSON.parse(raw.toString() || "{}").seconds ?? 0);
|
||||
await this.flush();
|
||||
return reply(200, { ok: true, timed_out: out });
|
||||
}
|
||||
return reply(404, { ok: false, error: "not-found" });
|
||||
}
|
||||
const signed = Object.keys(req.headers).some((h) => h.startsWith("mas-modes-"));
|
||||
let peer = null;
|
||||
const unsignedOk = req.url === "/mas-events" && this.eventPeer === null && loop;
|
||||
if (signed || !unsignedOk) {
|
||||
try {
|
||||
peer = verify(req.headers, raw, this.keys);
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return reply(e.status, { ok: false, error: e.code });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
let msg;
|
||||
try { msg = JSON.parse(raw.toString("utf8")); } catch { return reply(400, { ok: false, error: "invalid-json" }); }
|
||||
let out;
|
||||
if (msg?.id && this.seen.has(msg.id) && req.url !== "/mas-events") out = this.seen.get(msg.id);
|
||||
else if (req.url === "/mas-events") {
|
||||
out = peer !== this.eventPeer ? err(403, "wrong-peer") : this.event(msg);
|
||||
} else if (req.url.startsWith("/mode-status/")) {
|
||||
out = this.modeStatus(msg, peer, req.url.slice("/mode-status/".length));
|
||||
} else if (req.url === "/master") {
|
||||
out = this.master(msg, peer);
|
||||
} else {
|
||||
out = err(404, "not-found");
|
||||
}
|
||||
if (msg?.id && req.url !== "/mas-events" && out[0] < 500) this.seen.set(msg.id, out);
|
||||
await this.flush();
|
||||
reply(...out);
|
||||
} catch (e) {
|
||||
reply(500, { ok: false, error: String(e) });
|
||||
}
|
||||
});
|
||||
return new Promise((resolve) => server.listen(port, "127.0.0.1", () => {
|
||||
this.baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||||
resolve(server);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const i = process.argv.indexOf("--config");
|
||||
const config = JSON.parse(readFileSync(process.argv[i + 1], "utf8"));
|
||||
const side = new EmailSide(config);
|
||||
await side.listen(Number(config.port ?? 0));
|
||||
process.stdout.write(JSON.stringify({ base_url: side.baseUrl }) + "\n");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue