humanish 0.20.5 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/comms-email-catch.d.ts +53 -0
- package/dist/comms-email-catch.js +209 -0
- package/dist/comms-email-catch.js.map +1 -0
- package/dist/comms-evidence.d.ts +27 -0
- package/dist/comms-evidence.js +31 -0
- package/dist/comms-evidence.js.map +1 -0
- package/dist/comms-faux-inbox.js +12 -2
- package/dist/comms-faux-inbox.js.map +1 -1
- package/dist/comms-sandbox-catch.d.ts +63 -0
- package/dist/comms-sandbox-catch.js +157 -0
- package/dist/comms-sandbox-catch.js.map +1 -0
- package/dist/comms-types.d.ts +2 -2
- package/dist/concurrent-shared-world-lab.js +9 -2
- package/dist/concurrent-shared-world-lab.js.map +1 -1
- package/dist/cua-actor-lab.d.ts +14 -0
- package/dist/cua-actor-lab.js +24 -2
- package/dist/cua-actor-lab.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.js +8 -3
- package/dist/index.js.map +1 -1
- package/dist/init-templates.js +7 -4
- package/dist/init-templates.js.map +1 -1
- package/dist/run.d.ts +15 -0
- package/dist/run.js +9 -0
- package/dist/run.js.map +1 -1
- package/docs/architecture/external-public-shared-world.md +8 -5
- package/docs/contracts/run-bundle.md +7 -1
- package/docs/contracts/schemas.md +1 -1
- package/docs/goals/current.md +1 -1
- package/docs/product/cineguessr-3player-external-public.md +14 -5
- package/docs/ramp/README.md +1 -1
- package/package.json +1 -1
- package/dist/comms-resend-catch.d.ts +0 -30
- package/dist/comms-resend-catch.js +0 -120
- package/dist/comms-resend-catch.js.map +0 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type ServerResponse } from "node:http";
|
|
2
|
+
import type { CommsChannel } from "./comms-types.js";
|
|
3
|
+
/** One send, normalized across providers. `body` is html-preferred, else text. */
|
|
4
|
+
export interface NormalizedSend {
|
|
5
|
+
from: string;
|
|
6
|
+
to: string[];
|
|
7
|
+
subject?: string;
|
|
8
|
+
body: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* A provider wire-shape adapter. Vendor-neutral seam: the server owns the HTTP + routing + bus
|
|
12
|
+
* delivery; a profile only knows how ONE provider (or a custom app) shapes its send request.
|
|
13
|
+
*/
|
|
14
|
+
export interface EmailSendProfile {
|
|
15
|
+
/** Descriptive name (e.g. "generic", "sendgrid"). Not a dependency — just a label. */
|
|
16
|
+
name: string;
|
|
17
|
+
/** Request paths (method POST) this profile accepts a send on. */
|
|
18
|
+
sendPaths: string[];
|
|
19
|
+
/** Parse a POST body (already JSON-parsed) into zero+ normalized sends (a batch yields many). */
|
|
20
|
+
parse(path: string, body: unknown): NormalizedSend[];
|
|
21
|
+
/** Optional provider-faithful success response. Default: 200 `{ id }` (single) / `{ data:[{id}] }`. */
|
|
22
|
+
respond?: (res: ServerResponse, ids: string[], batch: boolean) => void;
|
|
23
|
+
}
|
|
24
|
+
export interface EmailCatchServer {
|
|
25
|
+
/** Point the app's email-API base URL here (loopback only). */
|
|
26
|
+
readonly url: string;
|
|
27
|
+
readonly port: number;
|
|
28
|
+
/** Every normalized send accepted this run (runtime-only; for inspection/tests). */
|
|
29
|
+
readonly received: NormalizedSend[];
|
|
30
|
+
close(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
export interface EmailCatchOptions {
|
|
33
|
+
/** Bind host. Default 127.0.0.1 (loopback ONLY). */
|
|
34
|
+
host?: string;
|
|
35
|
+
/** Port. Default 0 (ephemeral). */
|
|
36
|
+
port?: number;
|
|
37
|
+
/** Wire-shape profiles, tried in order by path. Default [genericEmailProfile, sendgridEmailProfile]. */
|
|
38
|
+
profiles?: EmailSendProfile[];
|
|
39
|
+
/** Deterministic id for responses (tests). Default a zero-padded counter. */
|
|
40
|
+
idFor?: (n: number) => string;
|
|
41
|
+
}
|
|
42
|
+
/** The flat-JSON shape: `{ from, to, subject, html, text }`. Matches Resend (POST /emails) AND a
|
|
43
|
+
* custom app that sends the common shape AND Postmark's TitleCase keys (From/To/HtmlBody/…). */
|
|
44
|
+
export declare const genericEmailProfile: EmailSendProfile;
|
|
45
|
+
/** SendGrid's nested shape: `from.email`, `personalizations[].to[].email`, `content[].value`. Proves
|
|
46
|
+
* the seam handles a structurally different vendor, not just a field-name rename. */
|
|
47
|
+
export declare const sendgridEmailProfile: EmailSendProfile;
|
|
48
|
+
export declare const DEFAULT_EMAIL_PROFILES: EmailSendProfile[];
|
|
49
|
+
/**
|
|
50
|
+
* Start the vendor-neutral email catch, routing accepted sends into `channel`. Returns the loopback
|
|
51
|
+
* URL to hand the app as its email-API base URL. `close()` in a finally (mirror by-id teardown).
|
|
52
|
+
*/
|
|
53
|
+
export declare function startEmailCatchServer(channel: CommsChannel, options?: EmailCatchOptions): Promise<EmailCatchServer>;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// A VENDOR-NEUTRAL loopback catch server for email-send APIs (#297). An app hardwired to a hosted
|
|
2
|
+
// email provider is redirected into the faux bus with ONE env var and no code change: point the app's
|
|
3
|
+
// API base URL at this server. The catch does not depend on, or name itself after, any one vendor —
|
|
4
|
+
// it normalizes each provider's distinct wire shape (Resend's flat body, SendGrid's nested
|
|
5
|
+
// personalizations, Postmark's TitleCase, a custom app's own JSON) to one shape via pluggable
|
|
6
|
+
// PROFILES, and routes the result into a CommsChannel. Resend-compatible and SendGrid-compatible out
|
|
7
|
+
// of the box; extend with a custom profile for anything else. (There is no standardized email-send
|
|
8
|
+
// request shape across vendors, so normalization is by design, not a shortcut.)
|
|
9
|
+
//
|
|
10
|
+
// 127.0.0.1 only; request bodies are never logged; request size is capped.
|
|
11
|
+
import { createServer } from "node:http";
|
|
12
|
+
// ---------------------------------------------------------------- shared parse helpers
|
|
13
|
+
function str(value) {
|
|
14
|
+
return typeof value === "string" ? value : "";
|
|
15
|
+
}
|
|
16
|
+
function optStr(value) {
|
|
17
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
18
|
+
}
|
|
19
|
+
/** "Name <email@host>" → "email@host"; a bare address passes through. */
|
|
20
|
+
function bareEmail(value) {
|
|
21
|
+
const angle = value.match(/<([^>]+)>/);
|
|
22
|
+
return (angle ? angle[1] ?? value : value).trim();
|
|
23
|
+
}
|
|
24
|
+
/** A recipient field → address strings: an array, a single, or a comma-separated string (Postmark). */
|
|
25
|
+
function toAddresses(value) {
|
|
26
|
+
if (Array.isArray(value))
|
|
27
|
+
return value.map((entry) => bareEmail(String(entry)));
|
|
28
|
+
if (typeof value === "string")
|
|
29
|
+
return value.split(",").map((part) => bareEmail(part)).filter((part) => part.length > 0);
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
// ---------------------------------------------------------------- built-in profiles
|
|
33
|
+
/** The flat-JSON shape: `{ from, to, subject, html, text }`. Matches Resend (POST /emails) AND a
|
|
34
|
+
* custom app that sends the common shape AND Postmark's TitleCase keys (From/To/HtmlBody/…). */
|
|
35
|
+
export const genericEmailProfile = {
|
|
36
|
+
name: "generic",
|
|
37
|
+
sendPaths: ["/emails", "/emails/batch", "/email", "/email/batch", "/send"],
|
|
38
|
+
parse(_path, body) {
|
|
39
|
+
const items = Array.isArray(body) ? body : [body];
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const item of items) {
|
|
42
|
+
if (typeof item !== "object" || item === null)
|
|
43
|
+
continue;
|
|
44
|
+
const rec = item;
|
|
45
|
+
const from = str(rec.from ?? rec.From);
|
|
46
|
+
const to = toAddresses(rec.to ?? rec.To);
|
|
47
|
+
const subject = optStr(rec.subject ?? rec.Subject);
|
|
48
|
+
const bodyValue = str(rec.html ?? rec.HtmlBody ?? rec.text ?? rec.TextBody);
|
|
49
|
+
if (to.length === 0 && from === "" && bodyValue === "")
|
|
50
|
+
continue;
|
|
51
|
+
out.push({ from, to, ...(subject === undefined ? {} : { subject }), body: bodyValue });
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
/** SendGrid's nested shape: `from.email`, `personalizations[].to[].email`, `content[].value`. Proves
|
|
57
|
+
* the seam handles a structurally different vendor, not just a field-name rename. */
|
|
58
|
+
export const sendgridEmailProfile = {
|
|
59
|
+
name: "sendgrid",
|
|
60
|
+
sendPaths: ["/v3/mail/send"],
|
|
61
|
+
parse(_path, body) {
|
|
62
|
+
if (typeof body !== "object" || body === null)
|
|
63
|
+
return [];
|
|
64
|
+
const rec = body;
|
|
65
|
+
const fromObj = rec.from;
|
|
66
|
+
const from = typeof fromObj === "object" && fromObj !== null ? str(fromObj.email) : str(fromObj);
|
|
67
|
+
const subject = optStr(rec.subject);
|
|
68
|
+
const content = Array.isArray(rec.content) ? rec.content : [];
|
|
69
|
+
const isPart = (part) => typeof part === "object" && part !== null;
|
|
70
|
+
const chosen = content.find((part) => isPart(part) && part.type === "text/html") ??
|
|
71
|
+
content.find((part) => isPart(part) && part.type === "text/plain");
|
|
72
|
+
const bodyValue = isPart(chosen) ? str(chosen.value) : "";
|
|
73
|
+
const personalizations = Array.isArray(rec.personalizations) ? rec.personalizations : [];
|
|
74
|
+
const to = [];
|
|
75
|
+
for (const personalization of personalizations) {
|
|
76
|
+
if (!isPart(personalization))
|
|
77
|
+
continue;
|
|
78
|
+
const recipients = Array.isArray(personalization.to) ? personalization.to : [];
|
|
79
|
+
for (const recipient of recipients) {
|
|
80
|
+
if (!isPart(recipient))
|
|
81
|
+
continue;
|
|
82
|
+
const email = str(recipient.email);
|
|
83
|
+
if (email.length > 0)
|
|
84
|
+
to.push(email);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (to.length === 0)
|
|
88
|
+
return [];
|
|
89
|
+
return [{ from, to, ...(subject === undefined ? {} : { subject }), body: bodyValue }];
|
|
90
|
+
},
|
|
91
|
+
respond(res, ids, _batch) {
|
|
92
|
+
// SendGrid answers 202 with the id in a header and an empty body.
|
|
93
|
+
res.statusCode = 202;
|
|
94
|
+
if (ids[0] !== undefined)
|
|
95
|
+
res.setHeader("x-message-id", ids[0]);
|
|
96
|
+
res.end();
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
export const DEFAULT_EMAIL_PROFILES = [genericEmailProfile, sendgridEmailProfile];
|
|
100
|
+
// ---------------------------------------------------------------- server
|
|
101
|
+
class BodyTooLargeError extends Error {
|
|
102
|
+
}
|
|
103
|
+
function readBody(req, limit) {
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
let size = 0;
|
|
106
|
+
const chunks = [];
|
|
107
|
+
req.on("data", (chunk) => {
|
|
108
|
+
size += chunk.length;
|
|
109
|
+
if (size > limit) {
|
|
110
|
+
// Reject WITHOUT destroying the socket here: the handler responds 413 cleanly first (a
|
|
111
|
+
// write-after-destroy would otherwise reset the connection), then tears the request down.
|
|
112
|
+
reject(new BodyTooLargeError("request body too large"));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
chunks.push(chunk);
|
|
116
|
+
});
|
|
117
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
118
|
+
req.on("error", reject);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const MAX_BODY_BYTES = 5 * 1024 * 1024;
|
|
122
|
+
/**
|
|
123
|
+
* Start the vendor-neutral email catch, routing accepted sends into `channel`. Returns the loopback
|
|
124
|
+
* URL to hand the app as its email-API base URL. `close()` in a finally (mirror by-id teardown).
|
|
125
|
+
*/
|
|
126
|
+
export async function startEmailCatchServer(channel, options = {}) {
|
|
127
|
+
const host = options.host ?? "127.0.0.1";
|
|
128
|
+
const profiles = options.profiles ?? DEFAULT_EMAIL_PROFILES;
|
|
129
|
+
const received = [];
|
|
130
|
+
let idCounter = 0;
|
|
131
|
+
const idFor = options.idFor ?? ((n) => `humanish-catch-${n.toString().padStart(6, "0")}`);
|
|
132
|
+
const respondJson = (res, status, value) => {
|
|
133
|
+
res.statusCode = status;
|
|
134
|
+
res.setHeader("content-type", "application/json");
|
|
135
|
+
res.end(JSON.stringify(value));
|
|
136
|
+
};
|
|
137
|
+
const handle = async (req, res) => {
|
|
138
|
+
const path = (req.url ?? "/").split("?")[0] ?? "/";
|
|
139
|
+
if (req.method === "GET" && (path === "/" || path === "/health")) {
|
|
140
|
+
respondJson(res, 200, { ok: true, service: "humanish-email-catch", channel: channel.channel, profiles: profiles.map((p) => p.name) });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const profile = req.method === "POST" ? profiles.find((candidate) => candidate.sendPaths.includes(path)) : undefined;
|
|
144
|
+
if (profile === undefined) {
|
|
145
|
+
respondJson(res, 404, { error: "not found" });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
let raw;
|
|
149
|
+
try {
|
|
150
|
+
raw = await readBody(req, MAX_BODY_BYTES);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (!res.headersSent)
|
|
154
|
+
respondJson(res, error instanceof BodyTooLargeError ? 413 : 400, { error: "request body could not be read" });
|
|
155
|
+
req.destroy();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
let parsed;
|
|
159
|
+
try {
|
|
160
|
+
parsed = JSON.parse(raw.length > 0 ? raw : "{}");
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
respondJson(res, 422, { error: "invalid JSON body" });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const sends = profile.parse(path, parsed);
|
|
167
|
+
if (sends.length === 0) {
|
|
168
|
+
// A well-formed request that names no deliverable recipient (empty body, or a shape the profile
|
|
169
|
+
// could not resolve to a send) — return a bad-request rather than a fabricated success id.
|
|
170
|
+
respondJson(res, 422, { error: "no deliverable message in request" });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const ids = [];
|
|
174
|
+
for (const send of sends) {
|
|
175
|
+
received.push(send);
|
|
176
|
+
const inbound = {
|
|
177
|
+
from: send.from,
|
|
178
|
+
to: send.to,
|
|
179
|
+
...(send.subject === undefined ? {} : { subject: send.subject }),
|
|
180
|
+
body: send.body
|
|
181
|
+
};
|
|
182
|
+
await channel.deliverRaw(inbound);
|
|
183
|
+
idCounter += 1;
|
|
184
|
+
ids.push(idFor(idCounter));
|
|
185
|
+
}
|
|
186
|
+
const batch = Array.isArray(parsed) || path.endsWith("/batch");
|
|
187
|
+
if (profile.respond)
|
|
188
|
+
profile.respond(res, ids, batch);
|
|
189
|
+
else if (batch)
|
|
190
|
+
respondJson(res, 200, { data: ids.map((id) => ({ id })) });
|
|
191
|
+
else
|
|
192
|
+
respondJson(res, 200, { id: ids[0] ?? idFor(idCounter) });
|
|
193
|
+
};
|
|
194
|
+
const server = createServer((req, res) => {
|
|
195
|
+
void handle(req, res).catch(() => {
|
|
196
|
+
if (!res.headersSent)
|
|
197
|
+
respondJson(res, 500, { error: "catch server error" });
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
await new Promise((resolve) => server.listen(options.port ?? 0, host, () => resolve()));
|
|
201
|
+
const port = server.address().port;
|
|
202
|
+
return {
|
|
203
|
+
url: `http://${host}:${port}`,
|
|
204
|
+
port,
|
|
205
|
+
received,
|
|
206
|
+
close: () => new Promise((resolve) => server.close(() => resolve()))
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
//# sourceMappingURL=comms-email-catch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"comms-email-catch.js","sourceRoot":"","sources":["../src/comms-email-catch.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CommsMessage } from "./comms-types.js";
|
|
2
|
+
export declare const COMMS_THREAD_SCHEMA = "humanish.comms-thread.v1";
|
|
3
|
+
export interface CommsThreadEntry {
|
|
4
|
+
id: string;
|
|
5
|
+
channel: CommsMessage["channel"];
|
|
6
|
+
/** sha256-16 of the raw sender address. */
|
|
7
|
+
fromDigest: string;
|
|
8
|
+
/** sha256-16 of each recipient inbox address. */
|
|
9
|
+
toDigests: string[];
|
|
10
|
+
/** sha256-16 of the subject (non-reversible for a PII subject; correlates identical subjects). */
|
|
11
|
+
subjectDigest?: string;
|
|
12
|
+
/** sha256-16 of each actionable link (high-entropy → non-reversible). */
|
|
13
|
+
linkDigests: string[];
|
|
14
|
+
/** COUNT ONLY — a short OTP's digest is reversible, so the code itself never lands here. */
|
|
15
|
+
codeCount: number;
|
|
16
|
+
sentAt: number;
|
|
17
|
+
deliveredAt: number;
|
|
18
|
+
}
|
|
19
|
+
export interface CommsThreadArtifact {
|
|
20
|
+
schema: typeof COMMS_THREAD_SCHEMA;
|
|
21
|
+
channel: CommsMessage["channel"];
|
|
22
|
+
count: number;
|
|
23
|
+
thread: CommsThreadEntry[];
|
|
24
|
+
}
|
|
25
|
+
/** Project polled inbox messages into the digest-only thread artifact. Pure; the caller writes it into
|
|
26
|
+
* the run dir and registers it as an adapter-artifact. */
|
|
27
|
+
export declare function buildCommsThreadArtifact(messages: CommsMessage[]): CommsThreadArtifact;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Digest-only evidence for a captured comms thread (#297). Proves "the verification mail arrived and
|
|
2
|
+
// the persona could act on it" WITHOUT persisting raw PHI. Written as an adapter-artifact
|
|
3
|
+
// (humanish.comms-thread.v1), so it inherits the bundle's existence-verify + public-safety scan.
|
|
4
|
+
//
|
|
5
|
+
// Digest discipline (deliberate, see below): addresses + links are digested (high entropy → the digest
|
|
6
|
+
// is not reversible). The subject is DIGESTED too, not stored as text — redactText only scrubs
|
|
7
|
+
// secret-SHAPED tokens/paths, not free-form PII (the #108 gap), so a subject like "results for <name>"
|
|
8
|
+
// would pass through verbatim; a sha256-16 keeps a PII subject non-reversible while still letting you
|
|
9
|
+
// correlate identical subjects. OTP CODES are a COUNT ONLY, never digested — a sha256 of a 6-digit code
|
|
10
|
+
// has ~10^6 preimages and is trivially brute-forced back to the code, so a "code digest" would leak it.
|
|
11
|
+
// Net: NO raw address/subject/link/OTP text ever lands in the artifact. Same caution as the lobby code.
|
|
12
|
+
import { digestText } from "./redaction.js";
|
|
13
|
+
export const COMMS_THREAD_SCHEMA = "humanish.comms-thread.v1";
|
|
14
|
+
/** Project polled inbox messages into the digest-only thread artifact. Pure; the caller writes it into
|
|
15
|
+
* the run dir and registers it as an adapter-artifact. */
|
|
16
|
+
export function buildCommsThreadArtifact(messages) {
|
|
17
|
+
const channel = messages[0]?.channel ?? "email";
|
|
18
|
+
const thread = messages.map((message) => ({
|
|
19
|
+
id: message.id,
|
|
20
|
+
channel: message.channel,
|
|
21
|
+
fromDigest: digestText(message.from, 16),
|
|
22
|
+
toDigests: message.to.map((address) => address.digest),
|
|
23
|
+
...(message.subject === undefined ? {} : { subjectDigest: digestText(message.subject, 16) }),
|
|
24
|
+
linkDigests: message.links.map((link) => digestText(link, 16)),
|
|
25
|
+
codeCount: message.codes.length,
|
|
26
|
+
sentAt: message.sentAt,
|
|
27
|
+
deliveredAt: message.deliveredAt
|
|
28
|
+
}));
|
|
29
|
+
return { schema: COMMS_THREAD_SCHEMA, channel, count: thread.length, thread };
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=comms-evidence.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"comms-evidence.js","sourceRoot":"","sources":["../src/comms-evidence.ts"],"names":[],"mappings":"AAAA,qGAAqG;AACrG,0FAA0F;AAC1F,iGAAiG;AACjG,EAAE;AACF,uGAAuG;AACvG,+FAA+F;AAC/F,uGAAuG;AACvG,sGAAsG;AACtG,wGAAwG;AACxG,wGAAwG;AACxG,wGAAwG;AAGxG,OAAO,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAE5C,MAAM,CAAC,MAAM,mBAAmB,GAAG,0BAA0B,CAAC;AA0B9D;2DAC2D;AAC3D,MAAM,UAAU,wBAAwB,CAAC,QAAwB;IAC/D,MAAM,OAAO,GAA4B,QAAQ,CAAC,CAAC,CAAC,EAAE,OAAO,IAAI,OAAO,CAAC;IACzE,MAAM,MAAM,GAAuB,QAAQ,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;QAC5D,EAAE,EAAE,OAAO,CAAC,EAAE;QACd,OAAO,EAAE,OAAO,CAAC,OAAO;QACxB,UAAU,EAAE,UAAU,CAAC,OAAO,CAAC,IAAI,EAAE,EAAE,CAAC;QACxC,SAAS,EAAE,OAAO,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC;QACtD,GAAG,CAAC,OAAO,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,UAAU,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,EAAE,CAAC;QAC5F,WAAW,EAAE,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,UAAU,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;QAC9D,SAAS,EAAE,OAAO,CAAC,KAAK,CAAC,MAAM;QAC/B,MAAM,EAAE,OAAO,CAAC,MAAM;QACtB,WAAW,EAAE,OAAO,CAAC,WAAW;KACjC,CAAC,CAAC,CAAC;IACJ,OAAO,EAAE,MAAM,EAAE,mBAAmB,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC;AAChF,CAAC"}
|
package/dist/comms-faux-inbox.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The FAUX in-process email/SMS bus (#297 Stage 2). Deterministic, $0, offline, public-safe: a
|
|
2
|
-
// message an app-under-test "sends" (via an ingress like the
|
|
2
|
+
// message an app-under-test "sends" (via an ingress like the vendor-neutral email catch) is routed to the
|
|
3
3
|
// addressed actor inbox and read back through the same CommsChannel port a real provider adapter
|
|
4
4
|
// would implement. Nothing leaves the process. See comms-types.ts for the port + public-safety notes.
|
|
5
5
|
import { digestText } from "./redaction.js";
|
|
@@ -51,7 +51,9 @@ export function extractOtpCodes(body) {
|
|
|
51
51
|
list.push(c);
|
|
52
52
|
}
|
|
53
53
|
};
|
|
54
|
-
|
|
54
|
+
// The alphanumeric alternative requires at least one DIGIT (lookahead) so a labeled prose word like
|
|
55
|
+
// "your code is INVALID" isn't captured as a code; pure-digit codes (4–8) match directly.
|
|
56
|
+
const labeledRe = /(?:one[-\s]?time\s+(?:pass)?code|verification\s+code|security\s+code|access\s+code|login\s+code|confirmation\s+code|passcode|\bOTP\b|\bPIN\b|\bcode\b)\D{0,15}\b([0-9]{4,8}|(?=[A-Za-z0-9]*[0-9])[A-Z0-9]{6,8})\b/gi;
|
|
55
57
|
let m;
|
|
56
58
|
while ((m = labeledRe.exec(text)) !== null)
|
|
57
59
|
push(labeled, m[1] ?? "");
|
|
@@ -91,6 +93,14 @@ export class FauxInbox {
|
|
|
91
93
|
if (existing)
|
|
92
94
|
return existing;
|
|
93
95
|
const value = this.channel === "sms" ? smsAddressFor(actorId) : `${sanitizeLocalPart(actorId)}@${this.domain}`;
|
|
96
|
+
// Address collision guard: two distinct actor ids can sanitize to the same local part. Reuse the
|
|
97
|
+
// existing inbox rather than resetting its queue (which would drop already-delivered mail). Both
|
|
98
|
+
// actors then share it — a faux-world edge; declare distinct addresses to avoid it.
|
|
99
|
+
const prior = this.byValue.get(value.toLowerCase());
|
|
100
|
+
if (prior) {
|
|
101
|
+
this.byActor.set(actorId, prior);
|
|
102
|
+
return prior;
|
|
103
|
+
}
|
|
94
104
|
const address = { channel: this.channel, actorId, value, digest: digestText(value, 16) };
|
|
95
105
|
this.byActor.set(actorId, address);
|
|
96
106
|
this.byValue.set(value.toLowerCase(), address);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"comms-faux-inbox.js","sourceRoot":"","sources":["../src/comms-faux-inbox.ts"],"names":[],"mappings":"AAAA,+FAA+F;AAC/F,
|
|
1
|
+
{"version":3,"file":"comms-faux-inbox.js","sourceRoot":"","sources":["../src/comms-faux-inbox.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { CommsChannel } from "./comms-types.js";
|
|
2
|
+
import { type EmailSendProfile } from "./comms-email-catch.js";
|
|
3
|
+
import { type DetachedTimers } from "./e2b-detached.js";
|
|
4
|
+
import type { E2BDesktopSandbox } from "./e2b-desktop-launch.js";
|
|
5
|
+
/** The default in-sandbox loopback port for the catch. Fixed (not ephemeral) so the injected base-URL
|
|
6
|
+
* env is known before `createDesktopSandbox`. 8025 is the conventional local-mail-UI port and is
|
|
7
|
+
* unlikely to collide with a subject app; override via config if it does. */
|
|
8
|
+
export declare const DEFAULT_SANDBOX_CATCH_PORT = 8025;
|
|
9
|
+
/**
|
|
10
|
+
* The self-contained in-sandbox capture server (a plain node ESM string — runs on the sandbox's own
|
|
11
|
+
* node, imports nothing from humanish). It is DELIBERATELY dumb: it records each POST verbatim as an
|
|
12
|
+
* NDJSON line `{t, path, body}` and returns a plausible provider success — all normalization/profile
|
|
13
|
+
* parsing happens host-side on the drained lines, so the typed, tested profiles stay in one place.
|
|
14
|
+
* argv: <port> <deliveriesFile>.
|
|
15
|
+
*/
|
|
16
|
+
export declare const SANDBOX_CATCH_SCRIPT: string;
|
|
17
|
+
export interface DeployCommsCatchOptions {
|
|
18
|
+
/** Fixed loopback port the catch listens on (default 8025). Must be free inside the sandbox. */
|
|
19
|
+
port?: number;
|
|
20
|
+
/** In-sandbox working dir for the script + NDJSON (default /tmp/humanish-comms). */
|
|
21
|
+
dir?: string;
|
|
22
|
+
/** Detached-process name ([a-z0-9-]); default "comms-catch". */
|
|
23
|
+
name?: string;
|
|
24
|
+
/** Readiness-probe budget (ms) for the catch's /health (default 15000). */
|
|
25
|
+
readyTimeoutMs?: number;
|
|
26
|
+
requestTimeoutMs?: number;
|
|
27
|
+
timers?: DetachedTimers;
|
|
28
|
+
}
|
|
29
|
+
export interface DeployedCommsCatch {
|
|
30
|
+
port: number;
|
|
31
|
+
/** Inject THIS as the app's email-API base URL (e.g. RESEND_API_URL) — the sandbox's own loopback. */
|
|
32
|
+
baseUrl: string;
|
|
33
|
+
deliveriesPath: string;
|
|
34
|
+
/** Whether the catch's /health returned OUR service marker within the readiness budget. Callers MUST
|
|
35
|
+
* treat `ready === false` as fatal (do not inject baseUrl into a dead catch — the app's sends would
|
|
36
|
+
* silently fail with nothing captured). */
|
|
37
|
+
ready: boolean;
|
|
38
|
+
}
|
|
39
|
+
/** A raw send the in-sandbox catch captured (host-side parsing happens in routeCapturedSends). */
|
|
40
|
+
export interface RawCapturedSend {
|
|
41
|
+
path: string;
|
|
42
|
+
body: string;
|
|
43
|
+
t: number;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Write + launch the in-sandbox catch (detached), then probe it ready. Call AFTER the subject sandbox
|
|
47
|
+
* is created and BEFORE the subject app's serve.start, so the base URL resolves at the app's boot.
|
|
48
|
+
*/
|
|
49
|
+
export declare function deployCommsCatch(desktop: E2BDesktopSandbox, options?: DeployCommsCatchOptions): Promise<DeployedCommsCatch>;
|
|
50
|
+
/**
|
|
51
|
+
* Drain new captured sends from the in-sandbox NDJSON since `cursor` (a line count). Returns the fresh
|
|
52
|
+
* sends and the new cursor. Cheap `cat` over commands.run; NDJSON is small for a run.
|
|
53
|
+
*/
|
|
54
|
+
export declare function drainCommsCatch(desktop: E2BDesktopSandbox, deployed: Pick<DeployedCommsCatch, "deliveriesPath">, cursor?: number, requestTimeoutMs?: number): Promise<{
|
|
55
|
+
sends: RawCapturedSend[];
|
|
56
|
+
cursor: number;
|
|
57
|
+
}>;
|
|
58
|
+
/**
|
|
59
|
+
* Parse drained raw sends with the host profiles and route them into the CommsChannel (the host-side
|
|
60
|
+
* FauxInbox). Returns the number of inbox deliveries made. Same profiles as the host catch, so the
|
|
61
|
+
* in-sandbox and in-process routes normalize identically.
|
|
62
|
+
*/
|
|
63
|
+
export declare function routeCapturedSends(sends: RawCapturedSend[], channel: CommsChannel, profiles?: EmailSendProfile[]): Promise<number>;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Deploy the vendor-neutral email catch INSIDE the subject E2B sandbox and bridge captured sends back
|
|
2
|
+
// to the host bus (#297 config-block core). The host `startEmailCatchServer` binds the HOST's loopback,
|
|
3
|
+
// which a sandboxed app cannot reach — `127.0.0.1:PORT` from the app is the SANDBOX's loopback. So the
|
|
4
|
+
// listener must live in the sandbox: we write a tiny self-contained capture server (no deps, no host
|
|
5
|
+
// import) into the sandbox, launch it detached (the same substrate that serves the subject app), and
|
|
6
|
+
// each poll `cat` its append-only NDJSON of captured sends back to the host, where the real profiles
|
|
7
|
+
// parse them and route into the CommsChannel. A FIXED loopback port is chosen up front so the app's
|
|
8
|
+
// injected base-URL env (`http://127.0.0.1:<port>`) is known before the sandbox is created.
|
|
9
|
+
import { DEFAULT_EMAIL_PROFILES } from "./comms-email-catch.js";
|
|
10
|
+
import { startDetachedProcess } from "./e2b-detached.js";
|
|
11
|
+
/** The default in-sandbox loopback port for the catch. Fixed (not ephemeral) so the injected base-URL
|
|
12
|
+
* env is known before `createDesktopSandbox`. 8025 is the conventional local-mail-UI port and is
|
|
13
|
+
* unlikely to collide with a subject app; override via config if it does. */
|
|
14
|
+
export const DEFAULT_SANDBOX_CATCH_PORT = 8025;
|
|
15
|
+
const DEFAULT_CATCH_DIR = "/tmp/humanish-comms";
|
|
16
|
+
/** Single-quote for safe shell interpolation. */
|
|
17
|
+
function shq(value) {
|
|
18
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The self-contained in-sandbox capture server (a plain node ESM string — runs on the sandbox's own
|
|
22
|
+
* node, imports nothing from humanish). It is DELIBERATELY dumb: it records each POST verbatim as an
|
|
23
|
+
* NDJSON line `{t, path, body}` and returns a plausible provider success — all normalization/profile
|
|
24
|
+
* parsing happens host-side on the drained lines, so the typed, tested profiles stay in one place.
|
|
25
|
+
* argv: <port> <deliveriesFile>.
|
|
26
|
+
*/
|
|
27
|
+
export const SANDBOX_CATCH_SCRIPT = [
|
|
28
|
+
'import { createServer } from "node:http";',
|
|
29
|
+
'import { appendFileSync, mkdirSync } from "node:fs";',
|
|
30
|
+
'import { dirname } from "node:path";',
|
|
31
|
+
'const port = Number(process.argv[2] || 8025);',
|
|
32
|
+
'const outFile = process.argv[3] || "/tmp/humanish-comms/deliveries.ndjson";',
|
|
33
|
+
'try { mkdirSync(dirname(outFile), { recursive: true }); } catch {}',
|
|
34
|
+
'const MAX = 5 * 1024 * 1024;',
|
|
35
|
+
'createServer((req, res) => {',
|
|
36
|
+
' const path = (req.url || "/").split("?")[0];',
|
|
37
|
+
' if (req.method === "GET" && (path === "/" || path === "/health")) { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ ok: true, service: "humanish-comms-catch" })); return; }',
|
|
38
|
+
' if (req.method !== "POST") { res.writeHead(404, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "not found" })); return; }',
|
|
39
|
+
' let size = 0; const chunks = [];',
|
|
40
|
+
' req.on("data", (c) => { size += c.length; if (size > MAX) { req.destroy(); } else { chunks.push(c); } });',
|
|
41
|
+
' req.on("end", () => {',
|
|
42
|
+
' const body = Buffer.concat(chunks).toString("utf8");',
|
|
43
|
+
' try { appendFileSync(outFile, JSON.stringify({ t: Date.now(), path, body }) + "\\n"); } catch {}',
|
|
44
|
+
' const id = "humanish-catch-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);',
|
|
45
|
+
' if (path === "/v3/mail/send") { res.writeHead(202, { "x-message-id": id }); res.end(); }',
|
|
46
|
+
' else if (path.endsWith("/batch")) { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ data: [{ id }] })); }',
|
|
47
|
+
' else { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ id })); }',
|
|
48
|
+
' });',
|
|
49
|
+
' req.on("error", () => { try { res.writeHead(400); res.end(); } catch {} });',
|
|
50
|
+
'}).listen(port, "127.0.0.1");'
|
|
51
|
+
].join("\n");
|
|
52
|
+
/** Readiness probe that asserts OUR service marker in the /health body (not merely any 2xx) — so a
|
|
53
|
+
* process squatting on the fixed port cannot produce a false "ready" while the app's sends bypass us. */
|
|
54
|
+
async function catchHealthy(desktop, port, options) {
|
|
55
|
+
const now = options.now ?? Date.now;
|
|
56
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
57
|
+
const deadline = now() + options.timeoutMs;
|
|
58
|
+
for (;;) {
|
|
59
|
+
const result = await desktop.commands
|
|
60
|
+
.run(`curl -s --max-time 5 http://127.0.0.1:${port}/health 2>/dev/null || true`, { requestTimeoutMs: options.requestTimeoutMs })
|
|
61
|
+
.catch(() => ({ stdout: "" }));
|
|
62
|
+
if ((result.stdout ?? "").includes("humanish-comms-catch"))
|
|
63
|
+
return true;
|
|
64
|
+
if (now() >= deadline)
|
|
65
|
+
return false;
|
|
66
|
+
await sleep(1000);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Write + launch the in-sandbox catch (detached), then probe it ready. Call AFTER the subject sandbox
|
|
71
|
+
* is created and BEFORE the subject app's serve.start, so the base URL resolves at the app's boot.
|
|
72
|
+
*/
|
|
73
|
+
export async function deployCommsCatch(desktop, options = {}) {
|
|
74
|
+
// Validate the port to an integer before it reaches the shell command (defense-in-depth: a future
|
|
75
|
+
// caller might cast a config value; the value is typed `number` but this makes injection impossible).
|
|
76
|
+
const port = Math.trunc(Number(options.port ?? DEFAULT_SANDBOX_CATCH_PORT));
|
|
77
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65_535) {
|
|
78
|
+
throw new Error(`deployCommsCatch: invalid port ${JSON.stringify(options.port)}`);
|
|
79
|
+
}
|
|
80
|
+
const dir = options.dir ?? DEFAULT_CATCH_DIR;
|
|
81
|
+
const name = options.name ?? "comms-catch";
|
|
82
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
|
|
83
|
+
const scriptPath = `${dir}/catch.mjs`;
|
|
84
|
+
const deliveriesPath = `${dir}/deliveries.ndjson`;
|
|
85
|
+
await desktop.commands.run(`mkdir -p ${shq(dir)}`, { requestTimeoutMs });
|
|
86
|
+
await desktop.files.write(scriptPath, SANDBOX_CATCH_SCRIPT);
|
|
87
|
+
await startDetachedProcess(desktop, {
|
|
88
|
+
name,
|
|
89
|
+
command: `node ${shq(scriptPath)} ${port} ${shq(deliveriesPath)}`,
|
|
90
|
+
requestTimeoutMs
|
|
91
|
+
});
|
|
92
|
+
const ready = await catchHealthy(desktop, port, {
|
|
93
|
+
timeoutMs: options.readyTimeoutMs ?? 15_000,
|
|
94
|
+
requestTimeoutMs,
|
|
95
|
+
...(options.timers ?? {})
|
|
96
|
+
});
|
|
97
|
+
return { port, baseUrl: `http://127.0.0.1:${port}`, deliveriesPath, ready };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Drain new captured sends from the in-sandbox NDJSON since `cursor` (a line count). Returns the fresh
|
|
101
|
+
* sends and the new cursor. Cheap `cat` over commands.run; NDJSON is small for a run.
|
|
102
|
+
*/
|
|
103
|
+
export async function drainCommsCatch(desktop, deployed, cursor = 0, requestTimeoutMs = 30_000) {
|
|
104
|
+
const result = await desktop.commands.run(`cat ${shq(deployed.deliveriesPath)} 2>/dev/null || true`, { requestTimeoutMs });
|
|
105
|
+
const stdout = result.stdout ?? "";
|
|
106
|
+
let lines = stdout.split("\n").filter((line) => line.trim().length > 0);
|
|
107
|
+
// If the file doesn't end in a newline, the last line may be a PARTIAL append (the host `cat` raced
|
|
108
|
+
// an in-sandbox append of a large body). Drop it and don't advance the cursor past it — it re-reads
|
|
109
|
+
// complete on the next poll, so a captured send is never lost to the race (the script only ever emits
|
|
110
|
+
// valid JSON, so an incomplete line is the only cause of a parse miss).
|
|
111
|
+
if (!stdout.endsWith("\n") && lines.length > 0)
|
|
112
|
+
lines = lines.slice(0, -1);
|
|
113
|
+
const sends = [];
|
|
114
|
+
for (const line of lines.slice(cursor)) {
|
|
115
|
+
try {
|
|
116
|
+
const parsed = JSON.parse(line);
|
|
117
|
+
if (typeof parsed.path === "string" && typeof parsed.body === "string") {
|
|
118
|
+
sends.push({ path: parsed.path, body: parsed.body, t: typeof parsed.t === "number" ? parsed.t : 0 });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// skip a malformed line
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { sends, cursor: lines.length };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Parse drained raw sends with the host profiles and route them into the CommsChannel (the host-side
|
|
129
|
+
* FauxInbox). Returns the number of inbox deliveries made. Same profiles as the host catch, so the
|
|
130
|
+
* in-sandbox and in-process routes normalize identically.
|
|
131
|
+
*/
|
|
132
|
+
export async function routeCapturedSends(sends, channel, profiles = DEFAULT_EMAIL_PROFILES) {
|
|
133
|
+
let delivered = 0;
|
|
134
|
+
for (const send of sends) {
|
|
135
|
+
let parsed;
|
|
136
|
+
try {
|
|
137
|
+
parsed = JSON.parse(send.body.length > 0 ? send.body : "{}");
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const profile = profiles.find((candidate) => candidate.sendPaths.includes(send.path)) ?? profiles[0];
|
|
143
|
+
if (profile === undefined)
|
|
144
|
+
continue;
|
|
145
|
+
for (const normalized of profile.parse(send.path, parsed)) {
|
|
146
|
+
const messages = await channel.deliverRaw({
|
|
147
|
+
from: normalized.from,
|
|
148
|
+
to: normalized.to,
|
|
149
|
+
...(normalized.subject === undefined ? {} : { subject: normalized.subject }),
|
|
150
|
+
body: normalized.body
|
|
151
|
+
});
|
|
152
|
+
delivered += messages.length;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return delivered;
|
|
156
|
+
}
|
|
157
|
+
//# sourceMappingURL=comms-sandbox-catch.js.map
|