@agent-flow-board/bridge 0.2.0 → 0.4.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.
Files changed (54) hide show
  1. package/README.md +26 -0
  2. package/dist/checks/appdir.js +105 -0
  3. package/dist/checks/approve.js +274 -0
  4. package/dist/checks/askBoard.js +143 -0
  5. package/dist/checks/audit.js +3 -2
  6. package/dist/checks/bind.js +3 -2
  7. package/dist/checks/board.js +30 -0
  8. package/dist/checks/broker.js +439 -0
  9. package/dist/checks/browser.js +137 -32
  10. package/dist/checks/browserpool.js +221 -0
  11. package/dist/checks/cache.js +710 -0
  12. package/dist/checks/command.js +126 -87
  13. package/dist/checks/commands.js +315 -0
  14. package/dist/checks/connections.js +4 -3
  15. package/dist/checks/copy.js +320 -0
  16. package/dist/checks/dbpool.js +710 -0
  17. package/dist/checks/doctor.js +655 -0
  18. package/dist/checks/envPolicy.js +50 -1
  19. package/dist/checks/envrules.js +322 -0
  20. package/dist/checks/evidence.js +114 -0
  21. package/dist/checks/fsretry.js +29 -0
  22. package/dist/checks/importlegacy.js +300 -0
  23. package/dist/checks/install.js +150 -0
  24. package/dist/checks/live.js +76 -0
  25. package/dist/checks/lock.js +28 -4
  26. package/dist/checks/login.js +307 -0
  27. package/dist/checks/mailguard-hook.cjs +216 -0
  28. package/dist/checks/mailguard.js +98 -0
  29. package/dist/checks/paths.js +24 -0
  30. package/dist/checks/preview.js +418 -191
  31. package/dist/checks/profiles.js +110 -0
  32. package/dist/checks/provision.js +1 -1
  33. package/dist/checks/recipe.js +19 -5
  34. package/dist/checks/registry.js +548 -0
  35. package/dist/checks/repoenv.js +162 -0
  36. package/dist/checks/roots.js +144 -0
  37. package/dist/checks/routing.js +68 -0
  38. package/dist/checks/run.js +243 -53
  39. package/dist/checks/sandbox.js +32 -10
  40. package/dist/checks/scheduler.js +383 -0
  41. package/dist/checks/scope.js +119 -30
  42. package/dist/checks/sealedNames.js +39 -0
  43. package/dist/checks/secrets.js +454 -0
  44. package/dist/checks/secretscmd.js +80 -0
  45. package/dist/checks/service.js +589 -0
  46. package/dist/checks/signin.js +7 -1
  47. package/dist/checks/signinKey.js +104 -0
  48. package/dist/checks/singleflight.js +307 -0
  49. package/dist/checks/store.js +92 -0
  50. package/dist/checks/surface.js +124 -0
  51. package/dist/checks/warm.js +342 -0
  52. package/dist/checks/watch.js +190 -63
  53. package/dist/checks/wire.js +87 -0
  54. package/package.json +1 -1
@@ -0,0 +1,307 @@
1
+ // `check login <NAME> --org <slug> [--seeded]`: SAVING A SIGN-IN FOR A REGISTRATION'S SITE, AND THE RULES FOR USING ONE
2
+ // --seeded signs in with a seeded account (AFB_CHECKS_<NAME>_EMAIL and _PASSWORD) or the recipe's test user, through the app's own form.
3
+ // Without --seeded a person signs in by hand in a visible browser window on their own desktop. It needs the owner's `login` approval (a code, from the board or a terminal),
4
+ // used up before the window opens, and a desktop session to show the window on. The window is a fresh temporary browser profile with no debugging port, deleted afterwards,
5
+ // and the bridge does not return what is typed into it to the agent. The wait ends when a new session cookie of the site appears, the window is closed, or the time runs out.
6
+ // Nothing here starts a preview itself: the caller injects `target`, and the sign in function is injected too, so tests launch no browser.
7
+ // A cookie, a password or a token is never printed.
8
+ // -- DEP-MODULES -- //
9
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ // -- MODULES -- //
13
+ import { consumeApproval, peekApproval } from './approve.js';
14
+ import { loadPlaywright } from './browser.js';
15
+ import { ORG_SLUG, registrationsDir } from './paths.js';
16
+ import { expiryNote, loadProfileChecked, saveProfile } from './profiles.js';
17
+ import { redact } from './redact.js';
18
+ import { openSignin } from './signinKey.js';
19
+ import { signInWithPassword } from './signin.js';
20
+ // -- CONSTANTS -- //
21
+ const NAME = /^[A-Za-z0-9_-]{1,32}$/;
22
+ /** The window waits this long for a sign in (minutes), unless --timeout says otherwise. */
23
+ const DEFAULT_TIMEOUT_MINUTES = 5;
24
+ // -- FUNCTIONS -- //
25
+ /** The variable names of a registration: AFB_CHECKS_<NAME upper, dash to underscore>_EMAIL and _PASSWORD. */
26
+ export function envNames(name) {
27
+ const n = String(name).toUpperCase().replace(/-/g, '_');
28
+ return { email: `AFB_CHECKS_${n}_EMAIL`, password: `AFB_CHECKS_${n}_PASSWORD` };
29
+ }
30
+ /** The seeded account of a registration from the environment, or null when either variable is missing or empty. */
31
+ export function seededAccount(name, env = process.env) {
32
+ const n = envNames(name);
33
+ const email = env[n.email];
34
+ const password = env[n.password];
35
+ return email && password ? { email, password } : null;
36
+ }
37
+ /**
38
+ * Whether a run uses a saved sign-in. Only when the script's header (or the check) names the very registration the run uses: a run that was
39
+ * routed there by a fallback, or a script that names another registration, opens signed out. Names compare without regard to case.
40
+ */
41
+ export function chooseState(input) {
42
+ const { registration, scriptRegistration, profile } = input;
43
+ if (!profile || !scriptRegistration || !registration)
44
+ return 'none';
45
+ return scriptRegistration.toLowerCase() === registration.toLowerCase() ? 'saved' : 'none';
46
+ }
47
+ /** Pictures of a signed-in run are uploaded only when the owner opted in on the registration. A signed-out run uploads as usual. */
48
+ export function pictureUploadAllowed(registration, signedIn) {
49
+ return !signedIn || registration.uploadSignedInPictures === true;
50
+ }
51
+ /** The saved sign-in a run should open with, with the expiry note, or null. Applies chooseState. */
52
+ export function savedStateFor(input) {
53
+ const { profile, damaged } = loadProfileChecked(input.org, input.registration, input.bindKey);
54
+ // A saved sign-in that cannot be opened says so: the run would otherwise go signed out and fail on a sign-in page with no explanation
55
+ if (damaged)
56
+ return {
57
+ profile: null,
58
+ note: 'The saved sign-in is damaged, make it again (agent-flow-board check login).'
59
+ };
60
+ if (chooseState({ ...input, profile }) !== 'saved' || !profile)
61
+ return null;
62
+ return { profile, note: expiryNote(profile) };
63
+ }
64
+ /** Cookies and origins that belong to the site's hosts, nothing else (no other site's sign-in is saved). */
65
+ export function keepRelated(state, hosts) {
66
+ const s = state;
67
+ const bare = (d) => d.replace(/^\./, '').toLowerCase();
68
+ const related = (d) => hosts.some((h) => {
69
+ const a = bare(d);
70
+ const b = h.toLowerCase();
71
+ return a === b || b.endsWith(`.${a}`) || a.endsWith(`.${b}`);
72
+ });
73
+ return {
74
+ cookies: (s?.cookies ?? []).filter((c) => related(String(c.domain ?? ''))),
75
+ origins: (s?.origins ?? []).filter((o) => {
76
+ try {
77
+ return related(new URL(String(o.origin)).hostname);
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ })
83
+ };
84
+ }
85
+ /**
86
+ * Whether this process can show a window to a person: null when it can. On Windows a service has no console session name; on Linux a desktop sets DISPLAY or WAYLAND_DISPLAY;
87
+ * on a Mac a shell opened over SSH is not the desktop. Otherwise one plain sentence says why a window cannot be shown.
88
+ */
89
+ export function desktopProblem(env = process.env, platform = process.platform) {
90
+ const none = 'This process has no desktop to show a window on (for example it runs as a service or over a remote shell). Run the login from a terminal opened on the computer itself.';
91
+ if (platform === 'win32')
92
+ return env.SESSIONNAME ? null : none;
93
+ if (platform === 'darwin')
94
+ return env.SSH_CONNECTION || env.SSH_TTY ? none : null;
95
+ return env.DISPLAY || env.WAYLAND_DISPLAY ? null : none;
96
+ }
97
+ /** A cookie is told apart by its name and domain, never by its value. */
98
+ const cookieId = (c) => {
99
+ const k = c;
100
+ return `${k.domain ?? ''}|${k.name ?? ''}`;
101
+ };
102
+ /**
103
+ * The headed login: a visible browser in a fresh temporary profile (no debugging port, deleted afterwards). It ends when a session cookie of the site appears that was not there
104
+ * when the page loaded, when the window is closed, or when the time runs out. Only cookies and origins of the site are kept.
105
+ */
106
+ async function headedLogin(target, say, timeoutMs) {
107
+ const { chromium } = await loadPlaywright();
108
+ const profile = mkdtempSync(join(tmpdir(), 'afb-login-'));
109
+ const hosts = [new URL(target.baseUrl).hostname, ...(target.hosts ?? [])];
110
+ let context = null;
111
+ try {
112
+ context = await chromium.launchPersistentContext(profile, {
113
+ headless: false,
114
+ viewport: null,
115
+ baseURL: target.baseUrl
116
+ });
117
+ let closed = false;
118
+ context.on('close', () => void (closed = true));
119
+ const page = context.pages()[0] ?? (await context.newPage());
120
+ await page.goto(target.baseUrl);
121
+ const before = new Set(keepRelated(await context.storageState(), hosts).cookies.map(cookieId));
122
+ say(`A browser window is open on ${target.baseUrl}. Sign in there the way you normally do (a password or Google). The bridge does not return what you type to your agent.`);
123
+ const deadline = Date.now() + timeoutMs;
124
+ while (!closed && Date.now() < deadline) {
125
+ await new Promise((r) => setTimeout(r, 1000));
126
+ if (closed)
127
+ break;
128
+ let kept;
129
+ try {
130
+ kept = keepRelated(await context.storageState(), hosts);
131
+ }
132
+ catch {
133
+ break;
134
+ }
135
+ if (kept.cookies.some((c) => !before.has(cookieId(c)))) {
136
+ // A short settle, so a sign in that sets several cookies is kept whole
137
+ await new Promise((r) => setTimeout(r, 2000));
138
+ return { state: 'signed-in', storage: keepRelated(await context.storageState(), hosts) };
139
+ }
140
+ }
141
+ return closed ? { state: 'closed' } : { state: 'timeout' };
142
+ }
143
+ finally {
144
+ await context?.close().catch(() => undefined);
145
+ rmSync(profile, { recursive: true, force: true });
146
+ }
147
+ }
148
+ function loadRegistrationFile(org, name) {
149
+ try {
150
+ return JSON.parse(readFileSync(join(registrationsDir(), org, `${name}.json`), 'utf8'));
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ }
156
+ /** The default target: only a live site needs no preview. A repo registration needs the caller to start its preview. */
157
+ async function defaultTarget(registration) {
158
+ if (!registration.url)
159
+ throw new Error(`${registration.name} is built from a repo, so its sign-in needs a preview. Start one through the run command.`);
160
+ return {
161
+ baseUrl: registration.url,
162
+ hosts: registration.extraHosts ?? [],
163
+ stop: async () => undefined
164
+ };
165
+ }
166
+ function flagValue(args, flag) {
167
+ const i = args.indexOf(flag);
168
+ return i >= 0 ? args[i + 1] : undefined;
169
+ }
170
+ /** `check login <NAME> --org <slug> [--seeded]`. Exit code 0 saved, 1 a problem with what was asked, 2 could not run. */
171
+ export async function loginCommand(args, ctx) {
172
+ const deps = {
173
+ signIn: (baseUrl, account) => signInWithPassword(baseUrl, account),
174
+ headed: headedLogin,
175
+ desktop: () => desktopProblem(),
176
+ approval: {
177
+ peek: (target, code) => peekApproval('login', target, code),
178
+ consume: (target, code) => consumeApproval('login', target, code)
179
+ },
180
+ env: process.env,
181
+ loadRegistration: loadRegistrationFile,
182
+ target: defaultTarget,
183
+ save: saveProfile,
184
+ ...ctx.deps
185
+ };
186
+ const say = (line) => ctx.log(line);
187
+ const flags = new Set(['--org']);
188
+ const positional = args.filter((a, i) => !a.startsWith('--') && !flags.has(args[i - 1] ?? ''));
189
+ const name = positional[0];
190
+ const org = flagValue(args, '--org');
191
+ const seeded = args.includes('--seeded');
192
+ // The sign in the owner typed on the board, encrypted to this machine: opened here in memory, never shown to the agent
193
+ const fromBoard = args.includes('--from-board');
194
+ if (!name || !org) {
195
+ say('Usage: check login <NAME> --org <slug> [--seeded | --from-board | --approval <code>]');
196
+ return 1;
197
+ }
198
+ if (!NAME.test(name) || !ORG_SLUG.test(org)) {
199
+ say('The registration name or the organisation is not a plain name.');
200
+ return 1;
201
+ }
202
+ // A headed login shows a window to a person: it needs the owner's login approval and a desktop to show it on, and stops at once without either
203
+ const approval = flagValue(args, '--approval');
204
+ const minutes = flagValue(args, '--timeout');
205
+ const timeoutMinutes = minutes === undefined ? DEFAULT_TIMEOUT_MINUTES : Number(minutes);
206
+ if (fromBoard && !ctx.board) {
207
+ say('Reading a sign in from the board needs the check command with --server and --org.');
208
+ return 1;
209
+ }
210
+ if (!seeded && !fromBoard) {
211
+ if (!Number.isFinite(timeoutMinutes) || timeoutMinutes < 1 || timeoutMinutes > 30) {
212
+ say('--timeout takes a number of minutes from 1 to 30.');
213
+ return 1;
214
+ }
215
+ const problem = deps.desktop();
216
+ if (problem) {
217
+ say(problem);
218
+ return 1;
219
+ }
220
+ if (!approval || !deps.approval.peek(`${org}/${name}`, approval)) {
221
+ say(`Signing in by hand needs the owner's approval. Ask for it: agent-flow-board check approve --request --action login --target ${org}/${name}, then pass --approval <code>. Or use --seeded with AFB_CHECKS_<NAME>_EMAIL and AFB_CHECKS_<NAME>_PASSWORD set.`);
222
+ return 1;
223
+ }
224
+ }
225
+ const registration = deps.loadRegistration(org, name);
226
+ if (!registration || registration.org !== org) {
227
+ say(`No registration "${name}" in ${org}.`);
228
+ return 1;
229
+ }
230
+ let target = null;
231
+ const secrets = [];
232
+ try {
233
+ target = await deps.target(registration);
234
+ const bindKey = new URL(target.baseUrl).origin;
235
+ let state;
236
+ if (fromBoard && ctx.board) {
237
+ const board = ctx.board();
238
+ const leased = await board.leaseSignins(name);
239
+ const envelope = leased.signins.find((e) => e.registration.toLowerCase() === name.toLowerCase());
240
+ if (!envelope) {
241
+ say(`No sign in is waiting on the board for ${name}. Run the login without --from-board to sign in in a window on this computer.`);
242
+ return 1;
243
+ }
244
+ let account = null;
245
+ try {
246
+ account = await openSignin(envelope, { orgId: leased.orgId, runnerId: leased.runnerId });
247
+ secrets.push(account.password);
248
+ state = await deps.signIn(target.baseUrl, account);
249
+ }
250
+ catch (e) {
251
+ // Only pass or fail goes back to the board, and the message never holds what was typed
252
+ await board.ackSignin({ id: envelope.id, result: 'fail' }).catch(() => undefined);
253
+ say(`That did not sign in. ${redact(e instanceof Error ? e.message : '', secrets).slice(0, 160)}`);
254
+ return 1;
255
+ }
256
+ finally {
257
+ if (account)
258
+ account.password = '';
259
+ }
260
+ await board.ackSignin({ id: envelope.id, result: 'pass' }).catch(() => undefined);
261
+ }
262
+ else if (seeded) {
263
+ const account = seededAccount(name, deps.env) ?? target.testUser ?? null;
264
+ if (!account) {
265
+ const n = envNames(name);
266
+ say(`No account to sign in with. Set ${n.email} and ${n.password}, or give the recipe a test user.`);
267
+ return 1;
268
+ }
269
+ secrets.push(account.password);
270
+ state = await deps.signIn(target.baseUrl, account);
271
+ }
272
+ else {
273
+ // The approval is used up before the window opens, so a window never opens twice on one code
274
+ if (!deps.approval.consume(`${org}/${name}`, approval ?? '')) {
275
+ say('The owner code was used or ran out just now. Ask for a new one.');
276
+ return 1;
277
+ }
278
+ const outcome = await deps.headed(target, say, timeoutMinutes * 60_000);
279
+ if (outcome.state === 'closed') {
280
+ say('The window was closed before a sign in was seen, so nothing was saved.');
281
+ return 4;
282
+ }
283
+ if (outcome.state === 'timeout') {
284
+ say(`No sign in was seen within ${timeoutMinutes} minutes, so nothing was saved.`);
285
+ return 5;
286
+ }
287
+ state = outcome.storage;
288
+ }
289
+ const kept = state;
290
+ if (!kept || (!(kept.cookies?.length ?? 0) && !(kept.origins?.length ?? 0))) {
291
+ say('Nothing was saved because the browser holds no sign-in for this address.');
292
+ return 1;
293
+ }
294
+ deps.save(org, name, bindKey, state);
295
+ say(`Saved the sign-in for ${org}/${name} on ${bindKey}. It stays on this machine, sealed.`);
296
+ if (ctx.json)
297
+ ctx.out(JSON.stringify({ saved: true, registration: `${org}/${name}`, site: bindKey }));
298
+ return 0;
299
+ }
300
+ catch (e) {
301
+ say(redact(e instanceof Error ? e.message : String(e), secrets));
302
+ return 2;
303
+ }
304
+ finally {
305
+ await target?.stop().catch(() => undefined);
306
+ }
307
+ }
@@ -0,0 +1,216 @@
1
+ "use strict";
2
+ // MAIL GUARD, THE RUNTIME HOOK. The tool, never a recipe, loads this file into the start step of a preview that has sealed mail settings (NODE_OPTIONS=--require). It stands
3
+ // between the app and Postmark: every send to a Postmark host is rewritten to go to the ONE test inbox the owner named, and anything it cannot read and rewrite is refused.
4
+ // Each rewrite and each refusal is a line in the run log (addresses masked) and a JSON line in AFB_MAIL_FENCE_LOG, which the tool counts into the run's result.
5
+ //
6
+ // WHAT THIS DOES AND DOES NOT PROTECT. It is a fence against mistakes (an app that mails the people in a test database), not against the repo's own code: code the agent wrote
7
+ // can load another HTTP client, spawn a process or call a mail API this file does not know. The owner's word that the token is a test token is what covers that, and it is in
8
+ // the audit chain. This file is CommonJS (a .cts compiled to .cjs) because --require loads CommonJS only; import-equals keeps the real module objects so they can be patched.
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ // import-equals is the only form that keeps the real CommonJS module objects, so the guard can patch them (a --require hook is CommonJS)
11
+ /* eslint-disable @typescript-eslint/no-require-imports */
12
+ const fs = require("node:fs");
13
+ const path = require("node:path");
14
+ const net = require("node:net");
15
+ const http = require("node:http");
16
+ const https = require("node:https");
17
+ // -- CONSTANTS -- //
18
+ const INBOX = String(process.env.AFB_MAIL_FENCE_INBOX || '')
19
+ .trim()
20
+ .toLowerCase();
21
+ const LOG = process.env.AFB_MAIL_FENCE_LOG || '';
22
+ const POSTMARK = /(^|\.)postmarkapp\.com\.?$/i;
23
+ const SEND_PATHS = new Set([
24
+ '/email',
25
+ '/email/batch',
26
+ '/email/withtemplate',
27
+ '/email/batchwithtemplates'
28
+ ]);
29
+ const ADDRESS = /[^<>\s,;"]+@[^<>\s,;"]+/g;
30
+ // -- FUNCTIONS -- //
31
+ const record = (event) => {
32
+ const line = JSON.stringify({ at: new Date().toISOString(), pid: process.pid, ...event });
33
+ try {
34
+ if (LOG)
35
+ fs.appendFileSync(LOG, line + '\n');
36
+ }
37
+ catch {
38
+ // a log that cannot be written must not take the preview down; the console line below still shows
39
+ }
40
+ if (event.event !== 'loaded' && event.event !== 'listening')
41
+ process.stderr.write(`[afb-checks mail guard] ${event.event}: ${event.note || ''}\n`);
42
+ };
43
+ const mask = (a) => String(a).replace(/^(.).*@/, '$1***@');
44
+ const addresses = (v) => (typeof v === 'string' ? v.match(ADDRESS) || [] : []);
45
+ record({
46
+ event: 'loaded',
47
+ exe: path.basename(String(process.argv[1] || process.argv[0] || '')),
48
+ inbox: INBOX ? mask(INBOX) : null
49
+ });
50
+ /** One Postmark message: only the test inbox stays as a recipient; Cc and Bcc go; the subject says who it would have gone to (masked). */
51
+ function rewriteMessage(m) {
52
+ if (!m || typeof m !== 'object' || Array.isArray(m))
53
+ return { ok: false, why: 'a message the guard could not read' };
54
+ const msg = m;
55
+ // A recipient field in another spelling (cc, TO) could still be read by Postmark: the whole message is refused
56
+ if (Object.keys(msg).some((k) => /^(to|cc|bcc)$/i.test(k) && !['To', 'Cc', 'Bcc'].includes(k)))
57
+ return { ok: false, why: 'a recipient field spelled in a way the guard does not rewrite' };
58
+ const all = ['To', 'Cc', 'Bcc'].flatMap((k) => addresses(msg[k]));
59
+ if (!all.length)
60
+ return { ok: false, why: 'a message with no recipient it could read' };
61
+ if (all.every((a) => a.toLowerCase() === INBOX) && !msg.Cc && !msg.Bcc)
62
+ return { ok: true, message: msg, changed: false };
63
+ const out = { ...msg, To: INBOX };
64
+ delete out.Cc;
65
+ delete out.Bcc;
66
+ if (typeof msg.Subject === 'string')
67
+ out.Subject = `[afb-checks test, would go to ${[...new Set(all.map(mask))].join(', ')}] ${msg.Subject}`;
68
+ return { ok: true, message: out, changed: true, was: all.map(mask) };
69
+ }
70
+ function rewriteBody(text) {
71
+ let json;
72
+ try {
73
+ json = JSON.parse(text);
74
+ }
75
+ catch {
76
+ return { ok: false, why: 'a body that is not JSON' };
77
+ }
78
+ const wrapped = json &&
79
+ typeof json === 'object' &&
80
+ !Array.isArray(json) &&
81
+ Array.isArray(json.Messages);
82
+ const list = Array.isArray(json)
83
+ ? json
84
+ : wrapped
85
+ ? json.Messages
86
+ : [json];
87
+ const done = list.map(rewriteMessage);
88
+ for (const d of done)
89
+ if (!d.ok)
90
+ return { ok: false, why: d.why };
91
+ const messages = done.map((d) => (d.ok ? d.message : {}));
92
+ const body = Array.isArray(json)
93
+ ? messages
94
+ : wrapped
95
+ ? { ...json, Messages: messages }
96
+ : messages[0];
97
+ return {
98
+ ok: true,
99
+ body: JSON.stringify(body),
100
+ count: done.length,
101
+ changed: done.filter((d) => d.ok && d.changed).length
102
+ };
103
+ }
104
+ const refusal = (why) => new Response(JSON.stringify({
105
+ ErrorCode: 300,
106
+ Message: `afb-checks mail guard refused this send: ${why}. Mail from a preview goes only to the test inbox.`
107
+ }), { status: 422, headers: { 'content-type': 'application/json' } });
108
+ const realFetch = globalThis.fetch;
109
+ if (typeof realFetch === 'function') {
110
+ globalThis.fetch = async function guardedFetch(input, init) {
111
+ let url;
112
+ try {
113
+ const raw = typeof input === 'object' &&
114
+ input !== null &&
115
+ 'url' in input &&
116
+ typeof input.url === 'string'
117
+ ? input.url
118
+ : String(input);
119
+ url = new URL(raw);
120
+ }
121
+ catch {
122
+ // An address the guard cannot read is not sent on: it could be a mail host written in a way it does not know
123
+ record({ event: 'refused', note: 'an address the guard could not read' });
124
+ return refusal('an address the guard could not read');
125
+ }
126
+ if (!POSTMARK.test(url.hostname))
127
+ return realFetch.call(this, input, init);
128
+ const asRequest = typeof input === 'object' && input !== null && 'method' in input ? input : null;
129
+ const method = String((init && init.method) || (asRequest && asRequest.method) || 'GET').toUpperCase();
130
+ // Reading the server's own settings or a message's status sends nothing
131
+ if (method === 'GET' || method === 'HEAD')
132
+ return realFetch.call(this, input, init);
133
+ const key = url.pathname.replace(/\/+$/, '').toLowerCase();
134
+ if (!INBOX) {
135
+ record({ event: 'refused', note: 'no test inbox was given to the guard' });
136
+ return refusal('the guard has no test inbox');
137
+ }
138
+ if (method !== 'POST' || !SEND_PATHS.has(key)) {
139
+ record({
140
+ event: 'refused',
141
+ note: `${method} ${url.pathname} is not a send the guard can rewrite`
142
+ });
143
+ return refusal(`${method} ${url.pathname} is not a send the guard can rewrite`);
144
+ }
145
+ let text = null;
146
+ try {
147
+ if (init && init.body !== undefined && init.body !== null)
148
+ text = typeof init.body === 'string' ? init.body : null;
149
+ else if (asRequest && typeof asRequest.clone === 'function')
150
+ text = await asRequest.clone().text();
151
+ }
152
+ catch {
153
+ text = null;
154
+ }
155
+ if (typeof text !== 'string') {
156
+ record({ event: 'refused', note: 'the body was not plain text the guard could read' });
157
+ return refusal('the body was not plain text');
158
+ }
159
+ const r = rewriteBody(text);
160
+ if (!r.ok) {
161
+ record({ event: 'refused', note: r.why });
162
+ return refusal(r.why);
163
+ }
164
+ record({
165
+ event: 'sent',
166
+ note: `${r.count} message(s) to the test inbox, ${r.changed} rewritten`,
167
+ count: r.count,
168
+ rewritten: r.changed
169
+ });
170
+ const headers = new Headers((init && init.headers) || (asRequest && asRequest.headers) || {});
171
+ headers.delete('content-length');
172
+ return realFetch.call(this, url.toString(), { method: 'POST', headers, body: r.body });
173
+ };
174
+ }
175
+ // The tool counts the guard as loaded only when a process that has it listens on the preview's port, so a launcher (yarn, cross-env) or another runtime cannot stand in for the app
176
+ const realListen = net.Server.prototype.listen;
177
+ net.Server.prototype.listen = function guardedListen(...args) {
178
+ this.once('listening', () => {
179
+ const a = this.address();
180
+ if (a && typeof a === 'object')
181
+ record({ event: 'listening', port: a.port });
182
+ });
183
+ return realListen.apply(this, args);
184
+ };
185
+ // Another way into Postmark that cannot be rewritten (node:http, node:https) is refused outright
186
+ const hostOf = (name, a) => {
187
+ try {
188
+ if (typeof a === 'string')
189
+ return new URL(a.startsWith('//') ? `${name}:${a}` : a).hostname;
190
+ if (a instanceof URL)
191
+ return a.hostname;
192
+ if (a && typeof a === 'object') {
193
+ const o = a;
194
+ return String(o.hostname || String(o.host || '').split(':')[0] || '');
195
+ }
196
+ return '';
197
+ }
198
+ catch {
199
+ return '';
200
+ }
201
+ };
202
+ for (const [name, mod] of [
203
+ ['http', http],
204
+ ['https', https]
205
+ ]) {
206
+ for (const fn of ['request', 'get']) {
207
+ const real = mod[fn];
208
+ mod[fn] = function guarded(...args) {
209
+ if (POSTMARK.test(hostOf(name, args[0]))) {
210
+ record({ event: 'refused', note: `node:${name} ${fn} to a mail host` });
211
+ throw new Error('afb-checks mail guard refused a direct request to a mail host. Mail from a preview goes only to the test inbox, through fetch.');
212
+ }
213
+ return real.apply(this, args);
214
+ };
215
+ }
216
+ }
@@ -0,0 +1,98 @@
1
+ // THE TOOL SIDE OF THE MAIL GUARD. The guard itself is mailguard-hook.cts (compiled to mailguard-hook.cjs), loaded into the preview's start step. Here the tool decides when it
2
+ // is on (the registration has sealed mail settings), gives it the one test inbox, reads back what it did, and applies the fail-closed rule over the WHOLE mail set: a mail
3
+ // setting that did not come from the sealed store is never given to a preview, and a run with sealed mail settings counts only when the guard reported listening on the preview port.
4
+ // A recipe cannot ask for the guard, change it or turn it off: the environment is built here after every recipe check.
5
+ // -- DEP-MODULES -- //
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { dirname, join } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ // -- MODULES -- //
10
+ import { EMAIL, MAIL_SET_NAME } from './sealedNames.js';
11
+ // -- CONSTANTS -- //
12
+ /** The run's own mail setting: the mailer is told to only log. It is set by the tool, so it is never counted as an unsealed mail setting. */
13
+ const RUN_OWN_MAIL = new Set(['EMAIL_PROVIDER']);
14
+ // -- FUNCTIONS -- //
15
+ /** The hook file next to this module (dist/checks/mailguard-hook.cjs once built). */
16
+ export const GUARD_FILE = join(dirname(fileURLToPath(import.meta.url)), 'mailguard-hook.cjs');
17
+ const maskAddress = (a) => String(a).replace(/^(.).*@/, '$1***@');
18
+ /** Every variable of the whole mail set: POSTMARK, SMTP, SENDGRID, MAILGUN, RESEND, SES and anything with MAIL in its name. */
19
+ export const mailSetNames = (env) => Object.keys(env).filter((k) => MAIL_SET_NAME.test(k) && !RUN_OWN_MAIL.has(k.toUpperCase()));
20
+ /**
21
+ * Fail closed over the whole mail set. `sealed` is the list of names that were filled from the sealed store (SealedResolution.filled). Any other mail-set name in `env`
22
+ * is stripped ('strip', the default) or listed as a problem so the run refuses ('refuse'). Names only, never a value.
23
+ */
24
+ export function enforceMailSet(env, sealed, mode = 'strip') {
25
+ const out = { ...env };
26
+ const stripped = [];
27
+ const problems = [];
28
+ for (const k of mailSetNames(env)) {
29
+ if (sealed.includes(k))
30
+ continue;
31
+ if (mode === 'strip') {
32
+ delete out[k];
33
+ stripped.push(k);
34
+ }
35
+ else
36
+ problems.push(`${k} is a mail setting that did not come from the sealed store, so this run does not start. Import the mail settings with a test inbox, or remove it.`);
37
+ }
38
+ return { env: out, stripped, problems };
39
+ }
40
+ /**
41
+ * The start environment with the guard loaded and told its inbox. `--require <hook>` is appended to NODE_OPTIONS LAST, so nothing earlier can unload it; a NODE_OPTIONS
42
+ * that was already there is kept in front. A variable spelled in another case (Windows) is folded into the one NODE_OPTIONS.
43
+ */
44
+ export function withMailGuard(env, { inbox, log, hookFile = GUARD_FILE }) {
45
+ if (!EMAIL.test(inbox))
46
+ throw new Error('The mail guard needs one test inbox that is an email address.');
47
+ if (!log)
48
+ throw new Error('The mail guard needs a log file to report to.');
49
+ const out = {};
50
+ let previous = '';
51
+ for (const [k, v] of Object.entries(env)) {
52
+ if (k.toUpperCase() === 'NODE_OPTIONS')
53
+ previous = [previous, v].filter(Boolean).join(' ');
54
+ else
55
+ out[k] = v;
56
+ }
57
+ const req = `--require "${hookFile.replace(/\\/g, '/')}"`;
58
+ out.NODE_OPTIONS = [previous, req].filter(Boolean).join(' ');
59
+ out.AFB_MAIL_FENCE_INBOX = inbox;
60
+ out.AFB_MAIL_FENCE_LOG = log;
61
+ return out;
62
+ }
63
+ /**
64
+ * What the guard wrote: whether a process of the app loaded it, and how many messages it sent on (rewritten to the test inbox) and refused. `port` is the preview's port:
65
+ * the guard counts as loaded only when a process that has it is listening there, so a launcher (yarn, cross-env) or another runtime cannot stand in for the app.
66
+ */
67
+ export function readFence(log, port = null) {
68
+ const out = { loaded: false, sends: 0, rewritten: 0, refused: 0 };
69
+ if (!log || !existsSync(log))
70
+ return out;
71
+ for (const line of readFileSync(log, 'utf8').split('\n')) {
72
+ if (!line.trim())
73
+ continue;
74
+ try {
75
+ const e = JSON.parse(line);
76
+ if (e.event === 'listening' && port !== null && Number(e.port) === Number(port))
77
+ out.loaded = true;
78
+ else if (e.event === 'sent') {
79
+ out.sends += Number(e.count) || 0;
80
+ out.rewritten += Number(e.rewritten) || 0;
81
+ }
82
+ else if (e.event === 'refused')
83
+ out.refused += 1;
84
+ }
85
+ catch {
86
+ /* A line the guard did not write completely */
87
+ }
88
+ }
89
+ return out;
90
+ }
91
+ /** The sentence that stops a run, or null when it may count: sealed mail settings were given and the guard did not report listening on the preview port. */
92
+ export function fenceProblem(mailGiven, fence) {
93
+ if (!mailGiven || fence.loaded)
94
+ return null;
95
+ return 'The mail guard did not report listening on the preview port, so mail may not have been fenced. The run does not count.';
96
+ }
97
+ /** One plain sentence for the run log. */
98
+ export const describeFence = (inbox, f) => `Mail guard on: mail goes only to ${maskAddress(inbox)}. ${f.sends} message(s) sent there (${f.rewritten} rewritten from another recipient), ${f.refused} refused.`;
@@ -25,6 +25,16 @@ export const auditFile = () => join(checksHome(), 'audit.log');
25
25
  export const bindingsFile = () => join(checksHome(), 'bindings.json');
26
26
  /** Private copies of repos, one per run, kept so a crashed run can be swept. */
27
27
  export const copiesDir = () => join(checksHome(), 'copies');
28
+ /**
29
+ * The live and end markers of runs, written by the tool only. The sandboxed child can write its run folder, so a marker kept there could be forged and make a later
30
+ * sweep kill a process or remove a container: markers live here, outside every folder the child may read or write.
31
+ */
32
+ export const markersDir = () => join(checksHome(), 'markers');
33
+ export const markerPath = (runId) => join(markersDir(), `${assertName(runId, 'run id')}.json`);
34
+ export const endedPath = (runId) => join(markersDir(), `${assertName(runId, 'run id')}.ended`);
35
+ /** Build, install and preview logs and the mail guard's log: they can hold values the app printed, so they are kept where the child cannot read. */
36
+ export const logsDir = () => join(checksHome(), 'logs');
37
+ export const logDirFor = (runId) => join(logsDir(), assertName(runId, 'run id'));
28
38
  export function ensureDir(dir) {
29
39
  mkdirSync(dir, { recursive: true, mode: 0o700 });
30
40
  return dir;
@@ -59,3 +69,17 @@ export function isInside(parent, child) {
59
69
  return false;
60
70
  }
61
71
  }
72
+ // -- REGISTRATIONS, SECRETS, CACHES AND POOLS (AFB-20261108-0925) -- //
73
+ /** One JSON file per registration: registrations/<org>/<NAME>.json. */
74
+ export const registrationsDir = () => join(checksHome(), 'registrations');
75
+ /** Everything one registration owns apart from its definition: sealed secrets, sign-ins, kept builds. Never shared with another registration. */
76
+ export const stateDirFor = (org, name) => join(checksHome(), 'state', org, name);
77
+ /** Kept dependencies and builds of one registration. */
78
+ export const cacheDirFor = (org, name) => join(stateDirFor(org, name), 'cache');
79
+ /** The machine broker: slot files with a heartbeat, shared by `check run` processes and the worker. */
80
+ export const brokerDir = () => join(checksHome(), 'broker');
81
+ /** The shared browser and database pools of this machine, found through files here. */
82
+ export const poolDir = () => join(checksHome(), 'pool');
83
+ /** Owner approvals: single-use codes (hashed) and the approved roots. */
84
+ export const approvalsFile = () => join(checksHome(), 'approvals.json');
85
+ export const rootsFile = () => join(checksHome(), 'roots.json');