CONTRACT.md section 7 (founder-reviewed): pairwise HMAC-SHA256 over TIMESTAMP.BODY in MAS-Modes-* headers, 300 s window; master commands only from the configured master; statuses only at the handoff's tokenized URL from its peer; primed webhooks must name a known peer. mas_modes.signing, signed contract.post, authenticating json_server, EmailMode/stubs/gateway signing. conformance/auth.json vectors + 7 authorization flows. 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
126 lines
5.5 KiB
Python
126 lines
5.5 KiB
Python
"""Wire the email side, the stubs and the gateway together on loopback HTTP.
|
|
|
|
Used by the tests and by the contract flow runner. Mail enters through the
|
|
real gateway (`build_event` from raw RFC 822, with trusted
|
|
Authentication-Results), so the whole path is exercised. Time is a fake
|
|
clock that only moves on `advance()`. Every peer gets its own pairwise key
|
|
with email: `callback-1` (the stub) and `cms-1` (the stub master), plus
|
|
`gateway` when events are signed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import secrets
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from mas_gateway.core import Resolver, gateway, message_meta_from_rfc822
|
|
|
|
from .contract import serve
|
|
from .email_mode import EmailMode, RoleConfig
|
|
from .stubs.callback import CallbackStub
|
|
from .stubs.master import StubMaster
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
CUSTOMER = "customer@example.net"
|
|
|
|
|
|
def reference_ticket_service():
|
|
"""The reference ticket service (examples/), the master-role store."""
|
|
path = str(ROOT / "examples/ticket_service")
|
|
if path not in sys.path:
|
|
sys.path.append(path)
|
|
from ticket_service import TicketService
|
|
return TicketService
|
|
|
|
|
|
class FakeClock:
|
|
def __init__(self, t: float = 1_000_000.0):
|
|
self.t = t
|
|
|
|
def __call__(self) -> float:
|
|
return self.t
|
|
|
|
|
|
class Harness:
|
|
"""roles: resource type -> {"role": "master"|"slave", ...RoleConfig fields}.
|
|
The strings "{callback}" and "{master}" in them are replaced by the stub URLs.
|
|
Without explicit settings, master roles hand callbacks to the stub and
|
|
slave roles take commands from the stub master."""
|
|
|
|
def __init__(self, statute_file, roles: dict, scenario: str = "completed",
|
|
auto_assign: bool = True, max_sent: Optional[int] = None, ticket_ids=None,
|
|
sign_events: bool = False):
|
|
self.resolver = Resolver.with_statute_files([statute_file])
|
|
[self.domain] = self.resolver.pinned
|
|
self.clock = FakeClock()
|
|
self.keys = {peer: secrets.token_bytes(32) for peer in ("callback-1", "cms-1", "gateway")}
|
|
self.callback = CallbackStub({"email": self.keys["callback-1"]}, default=scenario)
|
|
self._servers = []
|
|
cb_url = self.callback_url = self._serve(self.callback.server()) + "/handoffs"
|
|
self.master = StubMaster({"email": self.keys["cms-1"]}, auto_assign=auto_assign)
|
|
master_url = self._serve(self.master.server()) + "/events"
|
|
|
|
def fill(v):
|
|
if isinstance(v, str):
|
|
return v.replace("{callback}", cb_url).replace("{master}", master_url)
|
|
if isinstance(v, dict):
|
|
return {k: fill(x) for k, x in v.items()}
|
|
return v
|
|
defaults = {"master": {"modes": {"callback": {"url": "{callback}", "peer": "callback-1"}}},
|
|
"slave": {"master_url": "{master}", "master_peer": "cms-1"}}
|
|
configs = {t: RoleConfig(**fill({**defaults[c["role"]], **c})) for t, c in roles.items()}
|
|
self.tickets = None
|
|
if any(c.role == "master" for c in configs.values()):
|
|
kw = {"id_factory": (lambda it=iter(ticket_ids): next(it))} if ticket_ids else {}
|
|
self.tickets = reference_ticket_service()(require_dmarc_pass=True, max_sent=max_sent, **kw)
|
|
self.signer = ("gateway", self.keys["gateway"]) if sign_events else None
|
|
self.email = EmailMode(configs, dict(self.keys), self.tickets,
|
|
event_peer="gateway" if sign_events else None,
|
|
statutes=dict(self.resolver.pinned), clock=self.clock,
|
|
max_sent=max_sent)
|
|
self.email.base_url = self._serve(self.email.server())
|
|
self.master.email_url = self.email.base_url + "/master"
|
|
self.app_url = self.email.base_url + "/mas-events"
|
|
|
|
def _serve(self, server):
|
|
self._servers.append(server)
|
|
return serve(server)
|
|
|
|
def close(self):
|
|
for s in self._servers:
|
|
s.shutdown()
|
|
|
|
# -- driving -------------------------------------------------------------------
|
|
|
|
def mail(self, local_part: str, text: str = "", subject: str = "Help needed",
|
|
sender: str = CUSTOMER, dmarc: Optional[str] = "pass", headers: str = ""):
|
|
to = f"{local_part}@{self.domain}"
|
|
ar = (f"Authentication-Results: {self.domain}; dmarc={dmarc} header.from={sender.split('@')[1]}\r\n"
|
|
if dmarc else "")
|
|
raw = (f"{ar}From: <{sender}>\r\nTo: {to}\r\nSubject: {subject}\r\n"
|
|
f"Message-ID: <{abs(hash((to, subject, text, self.clock.t)))}@example.net>\r\n"
|
|
f"{headers}\r\n{text}\r\n").encode()
|
|
meta = message_meta_from_rfc822(raw, trusted_authserv_id=self.domain)
|
|
[d] = gateway([to], meta, self.resolver, self.app_url, self.signer)
|
|
return d
|
|
|
|
def advance(self, seconds: float):
|
|
self.clock.t += seconds
|
|
return self.email.tick()
|
|
|
|
def outbox(self) -> list[dict]:
|
|
"""Every mail email decided to send, in either role."""
|
|
mails = self.email.mailer.outbox()
|
|
if self.tickets is not None:
|
|
mails = self.tickets.outbox() + mails
|
|
return mails
|
|
|
|
def thread(self, ticket_id: str) -> list[str]:
|
|
"""Mode entries recorded in a master-role ticket thread."""
|
|
t = self.tickets.ticket(ticket_id)
|
|
return [m["body"] for m in t["messages"] if m["disposition"].startswith("mode-")]
|
|
|
|
def statuses(self, handoff_id: str) -> list[str]:
|
|
return [m["status"] for m in self.callback.handoffs[handoff_id].sent]
|