@davidrydberg/proofy 0.0.0-stage → 0.1.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 (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +64 -2
  3. package/dist/args.d.ts +10 -0
  4. package/dist/args.js +36 -0
  5. package/dist/bin/proofy.d.ts +2 -0
  6. package/dist/bin/proofy.js +4 -0
  7. package/dist/cli.d.ts +9 -0
  8. package/dist/cli.js +140 -0
  9. package/dist/commands/approve.d.ts +4 -0
  10. package/dist/commands/approve.js +48 -0
  11. package/dist/commands/context.d.ts +16 -0
  12. package/dist/commands/context.js +27 -0
  13. package/dist/commands/dev.d.ts +4 -0
  14. package/dist/commands/dev.js +161 -0
  15. package/dist/commands/home.d.ts +3 -0
  16. package/dist/commands/home.js +122 -0
  17. package/dist/commands/init.d.ts +4 -0
  18. package/dist/commands/init.js +122 -0
  19. package/dist/commands/report.d.ts +4 -0
  20. package/dist/commands/report.js +26 -0
  21. package/dist/commands/run.d.ts +6 -0
  22. package/dist/commands/run.js +166 -0
  23. package/dist/commands/types.d.ts +8 -0
  24. package/dist/commands/types.js +1 -0
  25. package/dist/config.d.ts +34 -0
  26. package/dist/config.js +62 -0
  27. package/dist/deps.d.ts +10 -0
  28. package/dist/deps.js +1 -0
  29. package/dist/detect.d.ts +17 -0
  30. package/dist/detect.js +96 -0
  31. package/dist/errors.d.ts +6 -0
  32. package/dist/errors.js +21 -0
  33. package/dist/github.d.ts +16 -0
  34. package/dist/github.js +121 -0
  35. package/dist/human.d.ts +30 -0
  36. package/dist/human.js +160 -0
  37. package/dist/output.d.ts +8 -0
  38. package/dist/output.js +21 -0
  39. package/dist/project.d.ts +21 -0
  40. package/dist/project.js +71 -0
  41. package/dist/run/approvals.d.ts +15 -0
  42. package/dist/run/approvals.js +26 -0
  43. package/dist/run/engine.d.ts +48 -0
  44. package/dist/run/engine.js +168 -0
  45. package/dist/run/fingerprint.d.ts +11 -0
  46. package/dist/run/fingerprint.js +40 -0
  47. package/dist/run/history.d.ts +29 -0
  48. package/dist/run/history.js +85 -0
  49. package/dist/run/report.d.ts +14 -0
  50. package/dist/run/report.js +115 -0
  51. package/dist/run/results.d.ts +70 -0
  52. package/dist/run/results.js +129 -0
  53. package/dist/run/spec.d.ts +6 -0
  54. package/dist/run/spec.js +16 -0
  55. package/dist/sandbox/compose.d.ts +38 -0
  56. package/dist/sandbox/compose.js +132 -0
  57. package/dist/sandbox/contract.d.ts +19 -0
  58. package/dist/sandbox/contract.js +20 -0
  59. package/dist/sandbox/docker.d.ts +17 -0
  60. package/dist/sandbox/docker.js +48 -0
  61. package/dist/sandbox/sandbox.d.ts +46 -0
  62. package/dist/sandbox/sandbox.js +239 -0
  63. package/dist/standin/ca.d.ts +6 -0
  64. package/dist/standin/ca.js +40 -0
  65. package/dist/standin/dns.d.ts +10 -0
  66. package/dist/standin/dns.js +53 -0
  67. package/dist/standin/forward.d.ts +1 -0
  68. package/dist/standin/forward.js +17 -0
  69. package/dist/standin/handle.d.ts +49 -0
  70. package/dist/standin/handle.js +130 -0
  71. package/dist/standin/server.d.ts +1 -0
  72. package/dist/standin/server.js +88 -0
  73. package/dist/style.d.ts +23 -0
  74. package/dist/style.js +38 -0
  75. package/dist/test/global-setup.d.ts +1 -0
  76. package/dist/test/global-setup.js +20 -0
  77. package/dist/test/index.d.ts +102 -0
  78. package/dist/test/index.js +184 -0
  79. package/docker/standin.Dockerfile +3 -0
  80. package/package.json +68 -4
  81. package/schema/proofy.schema.json +179 -0
  82. package/templates/e2e/AGENTS.md +90 -0
  83. package/templates/e2e/templates/compose.yml +33 -0
  84. package/templates/e2e/templates/spec.md +20 -0
  85. package/templates/e2e/templates/standin.mjs +29 -0
  86. package/templates/e2e/templates/test.spec.ts +28 -0
@@ -0,0 +1,239 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readdir, readFile, rename, rm, symlink, writeFile } from "node:fs/promises";
3
+ import { createRequire } from "node:module";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { stringify } from "yaml";
8
+ import { CliError } from "../errors.js";
9
+ import { MOUNT, RUNNER } from "./contract.js";
10
+ import { sandboxCompose, subnetFor } from "./compose.js";
11
+ import { tail } from "./docker.js";
12
+ export const PKG_DIR = fileURLToPath(new URL("../../", import.meta.url));
13
+ export function cacheDirFor(env, root) {
14
+ const base = env.XDG_CACHE_HOME || join(homedir(), ".cache");
15
+ return join(base, "proofy", createHash("sha256").update(root).digest("hex").slice(0, 12));
16
+ }
17
+ export function playwrightVersion() {
18
+ const require = createRequire(import.meta.url);
19
+ return require("@playwright/test/package.json").version;
20
+ }
21
+ export function runnerImage() {
22
+ return `mcr.microsoft.com/playwright:v${playwrightVersion()}-noble`;
23
+ }
24
+ export function standinImage() {
25
+ const pkg = createRequire(import.meta.url)(join(PKG_DIR, "package.json"));
26
+ return `proofy-standin:${pkg.version}`;
27
+ }
28
+ /** Install phase, with internet: images and builds. Nothing of the app runs yet. */
29
+ export async function installImages(proc, options, sandbox) {
30
+ const { log } = options;
31
+ const standin = standinImage();
32
+ if ((await proc("docker", ["image", "inspect", standin])).code !== 0) {
33
+ log(`building ${standin}`);
34
+ await must(proc("docker", ["build", "-q", "-t", standin, "-f", join(PKG_DIR, "docker", "standin.Dockerfile"), join(PKG_DIR, "docker")]), "Building the fake-service image failed");
35
+ }
36
+ const runner = runnerImage();
37
+ if ((await proc("docker", ["image", "inspect", runner])).code !== 0) {
38
+ log(`pulling ${runner} (once, about 1.5 GB)`);
39
+ await must(proc("docker", ["pull", "-q", runner]), `Pulling ${runner} failed`);
40
+ }
41
+ log("building and pulling the app's images");
42
+ await must(proc("docker", [...compose(sandbox), "build", "--quiet"]), "Building the app's images failed");
43
+ await must(proc("docker", [...compose(sandbox), "pull", "--ignore-buildable", "--policy", "missing", "--quiet"]), "Pulling the app's images failed");
44
+ await mkdir(options.cacheDir, { recursive: true });
45
+ const ca = join(options.cacheDir, "ca");
46
+ await mkdir(ca, { recursive: true });
47
+ await must(proc("docker", ["run", "--rm", "--user", hostUser(), "-v", `${ca}:${MOUNT.ca}`, "-v", `${PKG_DIR}:${MOUNT.pkg}:ro`, standin, "node", `${MOUNT.pkg}/dist/standin/server.js`, "ca"]), "Creating the sandbox certificate authority failed");
48
+ await downloadAssets(options, sandbox);
49
+ }
50
+ /** Writes the sealed compose file and everything the containers mount. */
51
+ export async function prepare(proc, options, attempt = 0) {
52
+ const { config, sourceDir } = options;
53
+ if (config.compose.length === 0) {
54
+ throw new CliError("e2e/proofy.json lists no compose file", "CONFIG_INVALID", ['Ask your agent: "set up proofy" (Setup in e2e/AGENTS.md)']);
55
+ }
56
+ if (!config.app) {
57
+ throw new CliError("e2e/proofy.json has no app: the browser does not know where to start", "CONFIG_INVALID", ['Ask your agent: "set up proofy"']);
58
+ }
59
+ const workDir = join(options.cacheDir, "sandboxes", options.project);
60
+ await rm(workDir, { recursive: true, force: true });
61
+ const dirs = {
62
+ pkg: PKG_DIR.replace(/\/$/, ""),
63
+ ca: join(options.cacheDir, "ca"),
64
+ runSetup: join(workDir, "setup"),
65
+ out: join(workDir, "out"),
66
+ e2e: options.e2eDir,
67
+ work: join(workDir, "runner"),
68
+ nodeModules: join(workDir, "node_modules"),
69
+ };
70
+ for (const dir of [dirs.ca, dirs.runSetup, dirs.out, join(dirs.work, "exec"), join(dirs.nodeModules, "@davidrydberg"), join(dirs.nodeModules, "@playwright")]) {
71
+ await mkdir(dir, { recursive: true });
72
+ }
73
+ // Test files import "@davidrydberg/proofy/test"; Node finds it through /node_modules in the runner container.
74
+ await symlink(MOUNT.pkg, join(dirs.nodeModules, "@davidrydberg", "proofy"));
75
+ await symlink(`${MOUNT.pkg}/node_modules/@playwright/test`, join(dirs.nodeModules, "@playwright", "test"));
76
+ // An empty env file keeps compose from reading the repository's .env for interpolation. Relative paths in the
77
+ // compose files resolve against the first file's folder, as they do when the app is started by hand.
78
+ const emptyEnv = join(workDir, "empty.env");
79
+ await writeFile(emptyEnv, "");
80
+ const args = ["compose", ...config.compose.flatMap((file) => ["-f", join(sourceDir, file)]), "--env-file", emptyEnv];
81
+ const normalized = await proc("docker", [...args, "config", "--format", "json", "--no-env-resolution"]);
82
+ if (normalized.code !== 0) {
83
+ throw new CliError(`docker compose could not read ${config.compose.join(", ")}`, "CONFIG_INVALID", [tail(normalized.stderr, 8)]);
84
+ }
85
+ const model = JSON.parse(normalized.stdout);
86
+ const { subnet, standinIp } = subnetFor(options.project, attempt);
87
+ const generated = sandboxCompose({ model, config, mode: options.mode, subnet, standinIp, standinImage: standinImage(), runnerImage: runnerImage(), user: hostUser(), dirs });
88
+ const composeFile = join(workDir, "compose.yml");
89
+ await writeFile(composeFile, stringify(generated.compose));
90
+ const assets = Object.fromEntries(config.assets.map((url) => [assetKey(url), assetFile(url)]));
91
+ await writeFile(join(dirs.runSetup, "standin.json"), JSON.stringify({ hosts: config.hosts, assets, address: standinIp, standinsDir: `${MOUNT.e2e}/standins`, assetsDir: `${MOUNT.runSetup}/assets` }));
92
+ return { project: options.project, composeFile, workDir, notes: generated.notes };
93
+ }
94
+ /** Sealed phase: starts the app with no way out, waits for it, seeds the empty database. */
95
+ export async function start(proc, options, sandbox) {
96
+ const { config, log } = options;
97
+ await mkdir(options.logDir, { recursive: true });
98
+ let current = sandbox;
99
+ for (let attempt = 0;; attempt++) {
100
+ log("starting the sealed sandbox");
101
+ const up = await proc("docker", [...compose(current), "up", "-d", "--wait", "--wait-timeout", String(Math.round(config.limits.startMinutes * 60))], {
102
+ timeoutMs: config.limits.startMinutes * 60_000 + 30_000,
103
+ });
104
+ if (up.code === 0)
105
+ break;
106
+ if (/overlap/i.test(up.stderr) && attempt < 5) {
107
+ await stop(proc, current);
108
+ current = await prepare(proc, options, attempt + 1);
109
+ continue;
110
+ }
111
+ throw new CliError(up.timedOut ? "The sandbox did not start in time" : "The sandbox did not start", "ENVIRONMENT_ERROR", [
112
+ tail(up.stderr || up.stdout, 12),
113
+ `Container logs: ${join(options.logDir, "sandbox.log")}`,
114
+ ]);
115
+ }
116
+ if (config.seed) {
117
+ log(`seeding: ${config.seed.command.join(" ")}`);
118
+ const seed = await proc("docker", [...compose(current), "exec", "-T", config.seed.service, ...config.seed.command], { timeoutMs: config.limits.startMinutes * 60_000 });
119
+ await writeFile(join(options.logDir, "seed.log"), `${seed.stdout}\n${seed.stderr}`);
120
+ if (seed.code !== 0) {
121
+ throw new CliError(`The seed command failed with exit code ${seed.code}`, "ENVIRONMENT_ERROR", [tail(seed.stderr || seed.stdout, 12), `Full output: ${join(options.logDir, "seed.log")}`]);
122
+ }
123
+ }
124
+ return current;
125
+ }
126
+ /** Runs Playwright in the runner container, answering exec() requests from the tests meanwhile. */
127
+ export async function runTests(proc, options, sandbox, playwrightArgs) {
128
+ const bridge = startExecBridge(proc, options, sandbox);
129
+ try {
130
+ const limit = options.config.limits.maxRunMinutes;
131
+ const result = await proc("docker", [...compose(sandbox), "run", "--rm", "-T", RUNNER, "node", `${MOUNT.pkg}/node_modules/@playwright/test/cli.js`, "test", "-c", `${MOUNT.work}/playwright.config.mjs`, ...playwrightArgs], limit === undefined ? {} : { timeoutMs: limit * 60_000 });
132
+ await writeFile(join(sandbox.workDir, "out", "playwright.log"), `${result.stdout}\n${result.stderr}`);
133
+ if (result.timedOut) {
134
+ throw new CliError(`The run took longer than maxRunMinutes (${limit})`, "ENVIRONMENT_ERROR", ["Raise limits.maxRunMinutes in e2e/proofy.json or run fewer flows"]);
135
+ }
136
+ return result.code;
137
+ }
138
+ finally {
139
+ await bridge.stop();
140
+ }
141
+ }
142
+ export async function saveLogs(proc, sandbox, dir) {
143
+ const logs = await proc("docker", [...compose(sandbox), "logs", "--no-color", "--timestamps"]);
144
+ await mkdir(dir, { recursive: true });
145
+ await writeFile(join(dir, "sandbox.log"), logs.stdout + logs.stderr);
146
+ }
147
+ export async function stop(proc, sandbox) {
148
+ await proc("docker", [...compose(sandbox), "down", "-v", "--remove-orphans", "--timeout", "5"], { timeoutMs: 120_000 });
149
+ }
150
+ export async function forwardedPorts(proc, sandbox) {
151
+ const port = async (inner) => {
152
+ const result = await proc("docker", [...compose(sandbox), "port", "proofy-forward", inner]);
153
+ const match = /:(\d+)\s*$/.exec(result.stdout.trim());
154
+ return match ? `http://127.0.0.1:${match[1]}` : undefined;
155
+ };
156
+ const app = await port("8080");
157
+ const mail = await port("8025");
158
+ return { ...(app ? { app } : {}), ...(mail ? { mail } : {}) };
159
+ }
160
+ export function hostUser() {
161
+ return `${process.getuid?.() ?? 0}:${process.getgid?.() ?? 0}`;
162
+ }
163
+ export function compose(sandbox) {
164
+ return ["compose", "-p", sandbox.project, "-f", sandbox.composeFile];
165
+ }
166
+ // Tests ask for exec() by writing a request file to a folder shared with the host, which runs the named command
167
+ // with docker compose exec. The runner container never gets access to Docker itself.
168
+ function startExecBridge(proc, options, sandbox) {
169
+ const dir = join(sandbox.workDir, "runner", "exec");
170
+ let running = true;
171
+ const busy = new Set();
172
+ const handle = async (file) => {
173
+ const id = file.slice(0, -".req.json".length);
174
+ const request = JSON.parse(await readFile(join(dir, file), "utf8"));
175
+ await rm(join(dir, file), { force: true });
176
+ const named = options.config.exec[request.name];
177
+ let response;
178
+ if (!named) {
179
+ response = { error: `exec("${request.name}") is not in e2e/proofy.json exec. Named commands: ${Object.keys(options.config.exec).join(", ") || "none"}` };
180
+ }
181
+ else {
182
+ const result = await proc("docker", [...compose(sandbox), "exec", "-T", named.service, ...named.command, ...request.args], {
183
+ timeoutMs: options.config.limits.testMinutes * 60_000,
184
+ });
185
+ response = { code: result.code, stdout: result.stdout, stderr: result.stderr };
186
+ }
187
+ await writeFile(join(dir, `${id}.tmp`), JSON.stringify(response));
188
+ await rename(join(dir, `${id}.tmp`), join(dir, `${id}.res.json`));
189
+ };
190
+ const loop = (async () => {
191
+ while (running) {
192
+ let files = [];
193
+ try {
194
+ files = await readdir(dir);
195
+ }
196
+ catch {
197
+ // The folder appears with the first request.
198
+ }
199
+ for (const file of files) {
200
+ if (!file.endsWith(".req.json") || busy.has(file))
201
+ continue;
202
+ busy.add(file);
203
+ void handle(file).finally(() => busy.delete(file));
204
+ }
205
+ await new Promise((resolve) => setTimeout(resolve, 100));
206
+ }
207
+ })();
208
+ return {
209
+ stop: async () => {
210
+ running = false;
211
+ await loop;
212
+ },
213
+ };
214
+ }
215
+ async function downloadAssets(options, sandbox) {
216
+ if (options.config.assets.length === 0)
217
+ return;
218
+ const dir = join(sandbox.workDir, "setup", "assets");
219
+ await mkdir(dir, { recursive: true });
220
+ for (const url of options.config.assets) {
221
+ options.log(`downloading ${url}`);
222
+ const response = await fetch(url);
223
+ if (!response.ok)
224
+ throw new CliError(`Downloading asset ${url} failed with HTTP ${response.status}`, "ENVIRONMENT_ERROR", ["Check the URL in e2e/proofy.json assets"]);
225
+ await writeFile(join(dir, assetFile(url)), Buffer.from(await response.arrayBuffer()));
226
+ }
227
+ }
228
+ function assetKey(url) {
229
+ const parsed = new URL(url);
230
+ return `${parsed.hostname}${parsed.pathname}`;
231
+ }
232
+ function assetFile(url) {
233
+ return createHash("sha256").update(assetKey(url)).digest("hex").slice(0, 24);
234
+ }
235
+ async function must(result, message) {
236
+ const done = await result;
237
+ if (done.code !== 0)
238
+ throw new CliError(message, "ENVIRONMENT_ERROR", [tail(done.stderr || done.stdout, 12)]);
239
+ }
@@ -0,0 +1,6 @@
1
+ export declare function ensureCa(dir: string): void;
2
+ /** A certificate for one hostname, signed by the sandbox authority and cached on disk. */
3
+ export declare function leafFor(dir: string, host: string): {
4
+ key: Buffer;
5
+ cert: Buffer;
6
+ };
@@ -0,0 +1,40 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ // Runs inside the fake-service container, which has openssl. The certificate authority lives in the project's
5
+ // cache folder, so apps that copy it into their trust store at start keep trusting it across runs.
6
+ const SYSTEM_BUNDLE = "/etc/ssl/certs/ca-certificates.crt";
7
+ export function ensureCa(dir) {
8
+ mkdirSync(join(dir, "leaf"), { recursive: true });
9
+ if (!existsSync(join(dir, "ca.crt"))) {
10
+ openssl([
11
+ "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", "-nodes",
12
+ "-keyout", join(dir, "ca.key"), "-out", join(dir, "ca.crt"), "-days", "3650",
13
+ "-subj", "/CN=proofy sandbox CA",
14
+ "-addext", "basicConstraints=critical,CA:TRUE", "-addext", "keyUsage=critical,keyCertSign,cRLSign",
15
+ ]);
16
+ }
17
+ if (!existsSync(join(dir, "leaf.key"))) {
18
+ openssl(["genpkey", "-algorithm", "EC", "-pkeyopt", "ec_paramgen_curve:P-256", "-out", join(dir, "leaf.key")]);
19
+ }
20
+ // For clients that take one bundle file instead of adding to the system store, such as Python's requests.
21
+ const system = existsSync(SYSTEM_BUNDLE) ? readFileSync(SYSTEM_BUNDLE, "utf8") : "";
22
+ writeFileSync(join(dir, "ca-bundle.crt"), `${system}\n${readFileSync(join(dir, "ca.crt"), "utf8")}`);
23
+ }
24
+ /** A certificate for one hostname, signed by the sandbox authority and cached on disk. */
25
+ export function leafFor(dir, host) {
26
+ const safe = host.replace(/[^a-z0-9.-]/gi, "_");
27
+ const file = join(dir, "leaf", `${safe}.crt`);
28
+ if (!existsSync(file)) {
29
+ openssl([
30
+ "req", "-x509", "-new", "-key", join(dir, "leaf.key"), "-subj", `/CN=${safe}`,
31
+ "-addext", `subjectAltName=DNS:${safe}`, "-addext", "basicConstraints=critical,CA:FALSE",
32
+ "-addext", "extendedKeyUsage=serverAuth",
33
+ "-CA", join(dir, "ca.crt"), "-CAkey", join(dir, "ca.key"), "-days", "365", "-out", file,
34
+ ]);
35
+ }
36
+ return { key: readFileSync(join(dir, "leaf.key")), cert: readFileSync(file) };
37
+ }
38
+ function openssl(args) {
39
+ execFileSync("openssl", args, { stdio: ["ignore", "ignore", "pipe"] });
40
+ }
@@ -0,0 +1,10 @@
1
+ export interface Question {
2
+ id: number;
3
+ name: string;
4
+ type: number;
5
+ end: number;
6
+ }
7
+ export declare function parseQuery(msg: Buffer): Question | undefined;
8
+ /** Answers A queries with the given address and everything else with an empty, successful answer. */
9
+ export declare function answer(msg: Buffer, question: Question, address: string): Buffer;
10
+ export declare function startDns(address: string, onQuery: (name: string) => void, port?: number): void;
@@ -0,0 +1,53 @@
1
+ import { createSocket } from "node:dgram";
2
+ // A catch-all DNS server. Docker's embedded DNS answers compose service names itself and forwards every other
3
+ // name here, so every outside hostname resolves to the fake-service container and no call can slip past it.
4
+ const TYPE_A = 1;
5
+ export function parseQuery(msg) {
6
+ if (msg.length < 17)
7
+ return undefined;
8
+ const labels = [];
9
+ let i = 12;
10
+ while (i < msg.length && msg[i] !== 0) {
11
+ const length = msg[i] ?? 0;
12
+ if (length > 63 || i + 1 + length > msg.length)
13
+ return undefined;
14
+ labels.push(msg.subarray(i + 1, i + 1 + length).toString("latin1"));
15
+ i += length + 1;
16
+ }
17
+ if (i + 5 > msg.length)
18
+ return undefined;
19
+ return { id: msg.readUInt16BE(0), name: labels.join(".").toLowerCase(), type: msg.readUInt16BE(i + 1), end: i + 5 };
20
+ }
21
+ /** Answers A queries with the given address and everything else with an empty, successful answer. */
22
+ export function answer(msg, question, address) {
23
+ const header = Buffer.alloc(12);
24
+ header.writeUInt16BE(question.id, 0);
25
+ header.writeUInt16BE(0x8180, 2);
26
+ header.writeUInt16BE(1, 4);
27
+ const isA = question.type === TYPE_A;
28
+ header.writeUInt16BE(isA ? 1 : 0, 6);
29
+ const parts = [header, msg.subarray(12, question.end)];
30
+ if (isA) {
31
+ const record = Buffer.alloc(16);
32
+ record.writeUInt16BE(0xc00c, 0);
33
+ record.writeUInt16BE(TYPE_A, 2);
34
+ record.writeUInt16BE(1, 4);
35
+ record.writeUInt32BE(5, 6);
36
+ record.writeUInt16BE(4, 10);
37
+ address.split(".").forEach((octet, index) => record.writeUInt8(Number(octet), 12 + index));
38
+ parts.push(record);
39
+ }
40
+ return Buffer.concat(parts);
41
+ }
42
+ export function startDns(address, onQuery, port = 53) {
43
+ const socket = createSocket("udp4");
44
+ socket.on("message", (msg, remote) => {
45
+ const question = parseQuery(msg);
46
+ if (!question)
47
+ return;
48
+ if (question.type === TYPE_A)
49
+ onQuery(question.name);
50
+ socket.send(answer(msg, question, address), remote.port, remote.address);
51
+ });
52
+ socket.bind(port);
53
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,17 @@
1
+ import { connect, createServer } from "node:net";
2
+ // Opens ports of the sealed sandbox to the host for `proofy dev up`. This container is the only one with a
3
+ // second network; the app itself stays on the sealed one. Arguments: listenPort=host:port ...
4
+ for (const pair of process.argv.slice(2)) {
5
+ const [listen = "", target = ""] = pair.split("=");
6
+ const [host = "", port = ""] = target.split(":");
7
+ createServer((client) => {
8
+ const upstream = connect(Number(port), host);
9
+ client.pipe(upstream).pipe(client);
10
+ const close = () => {
11
+ client.destroy();
12
+ upstream.destroy();
13
+ };
14
+ client.on("error", close);
15
+ upstream.on("error", close);
16
+ }).listen(Number(listen));
17
+ }
@@ -0,0 +1,49 @@
1
+ export type HostMode = "fake" | "record";
2
+ export interface StandinRequest {
3
+ method: string;
4
+ path: string;
5
+ query: Record<string, string>;
6
+ headers: Record<string, string>;
7
+ body: string;
8
+ json: unknown;
9
+ }
10
+ export interface StandinResponse {
11
+ status: number;
12
+ json?: unknown;
13
+ body?: string | Buffer;
14
+ headers?: Record<string, string>;
15
+ }
16
+ export interface RecordedRequest {
17
+ seq: number;
18
+ at: string;
19
+ host: string;
20
+ method: string;
21
+ path: string;
22
+ query: Record<string, string>;
23
+ headers: Record<string, string>;
24
+ body: string;
25
+ handledAs: HostMode | "asset" | "refused" | "error";
26
+ status: number;
27
+ error?: string;
28
+ }
29
+ export interface StandinSetup {
30
+ hosts: Record<string, HostMode>;
31
+ /** "host/path" to a file downloaded during the install phase. */
32
+ assets: Record<string, string>;
33
+ standinsDir: string;
34
+ assetsDir: string;
35
+ }
36
+ export declare class Standin {
37
+ private readonly setup;
38
+ private readonly onRecord;
39
+ readonly requests: RecordedRequest[];
40
+ private readonly handlers;
41
+ private readonly states;
42
+ constructor(setup: StandinSetup, onRecord?: (request: RecordedRequest) => void);
43
+ handle(host: string, method: string, url: string, headers: Record<string, string>, body: Buffer): Promise<StandinResponse>;
44
+ /** Recorded requests for one host, or all of them. */
45
+ recordedFor(host: string | undefined): RecordedRequest[];
46
+ private handler;
47
+ private state;
48
+ }
49
+ export declare function refusal(host: string): StandinResponse;
@@ -0,0 +1,130 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ const SECRET_HEADER = /^(authorization|proxy-authorization|cookie|set-cookie|x-api-key|api-key|x-auth-token)$|secret|token|signature/i;
5
+ const MAX_BODY = 64 * 1024;
6
+ const CONTENT_TYPES = {
7
+ js: "text/javascript",
8
+ mjs: "text/javascript",
9
+ css: "text/css",
10
+ json: "application/json",
11
+ svg: "image/svg+xml",
12
+ png: "image/png",
13
+ jpg: "image/jpeg",
14
+ jpeg: "image/jpeg",
15
+ gif: "image/gif",
16
+ webp: "image/webp",
17
+ woff: "font/woff",
18
+ woff2: "font/woff2",
19
+ ttf: "font/ttf",
20
+ };
21
+ export class Standin {
22
+ setup;
23
+ onRecord;
24
+ requests = [];
25
+ handlers = new Map();
26
+ states = new Map();
27
+ constructor(setup, onRecord = () => { }) {
28
+ this.setup = setup;
29
+ this.onRecord = onRecord;
30
+ }
31
+ async handle(host, method, url, headers, body) {
32
+ const parsed = new URL(url, `http://${host}`);
33
+ const path = parsed.pathname;
34
+ const query = Object.fromEntries(parsed.searchParams);
35
+ const text = body.toString("utf8");
36
+ const record = {
37
+ seq: this.requests.length + 1,
38
+ at: new Date().toISOString(),
39
+ host,
40
+ method,
41
+ path,
42
+ query,
43
+ headers: redact(headers),
44
+ body: text.length > MAX_BODY ? `${text.slice(0, MAX_BODY)}... (${text.length} characters)` : text,
45
+ };
46
+ let response;
47
+ let handledAs;
48
+ let error;
49
+ const asset = this.setup.assets[`${host}${path}`];
50
+ const mode = this.setup.hosts[host];
51
+ try {
52
+ if (asset !== undefined) {
53
+ handledAs = "asset";
54
+ response = { status: 200, body: await readFile(join(this.setup.assetsDir, asset)), headers: { "content-type": contentType(path) } };
55
+ }
56
+ else if (mode === "record") {
57
+ handledAs = "record";
58
+ response = { status: 200, json: {} };
59
+ }
60
+ else if (mode === "fake") {
61
+ handledAs = "fake";
62
+ const handler = await this.handler(host);
63
+ response = await handler({ method, path, query, headers, body: text, json: parseJson(text) }, { state: this.state(host) });
64
+ if (!response || typeof response.status !== "number")
65
+ throw new Error(`the fake for ${host} returned no status`);
66
+ }
67
+ else {
68
+ handledAs = "refused";
69
+ response = refusal(host);
70
+ }
71
+ }
72
+ catch (caught) {
73
+ handledAs = "error";
74
+ error = caught instanceof Error ? caught.message : String(caught);
75
+ response = { status: 500, json: { error: { code: "proofy_fake_error", message: error } } };
76
+ }
77
+ const done = { ...record, handledAs, status: response.status, ...(error ? { error } : {}) };
78
+ this.requests.push(done);
79
+ this.onRecord(done);
80
+ return response;
81
+ }
82
+ /** Recorded requests for one host, or all of them. */
83
+ recordedFor(host) {
84
+ return host ? this.requests.filter((request) => request.host === host) : this.requests;
85
+ }
86
+ handler(host) {
87
+ let handler = this.handlers.get(host);
88
+ if (!handler) {
89
+ const file = join(this.setup.standinsDir, `${host}.mjs`);
90
+ handler = import(pathToFileURL(file).href).then((module) => {
91
+ if (typeof module.default !== "function")
92
+ throw new Error(`e2e/standins/${host}.mjs has no default export function`);
93
+ return module.default;
94
+ });
95
+ this.handlers.set(host, handler);
96
+ }
97
+ return handler;
98
+ }
99
+ state(host) {
100
+ let state = this.states.get(host);
101
+ if (!state) {
102
+ state = new Map();
103
+ this.states.set(host, state);
104
+ }
105
+ return state;
106
+ }
107
+ }
108
+ export function refusal(host) {
109
+ return {
110
+ status: 502,
111
+ json: { error: { code: "proofy_refused", message: `${host} is not allowed in the sealed sandbox. List it in e2e/proofy.json hosts as fake or record` } },
112
+ };
113
+ }
114
+ function redact(headers) {
115
+ return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, SECRET_HEADER.test(key) ? "(redacted)" : value]));
116
+ }
117
+ function parseJson(text) {
118
+ if (!text)
119
+ return undefined;
120
+ try {
121
+ return JSON.parse(text);
122
+ }
123
+ catch {
124
+ return undefined;
125
+ }
126
+ }
127
+ function contentType(path) {
128
+ const extension = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
129
+ return CONTENT_TYPES[extension] ?? "application/octet-stream";
130
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,88 @@
1
+ import { appendFileSync, readFileSync } from "node:fs";
2
+ import { createServer as createHttp } from "node:http";
3
+ import { createServer as createHttps } from "node:https";
4
+ import { createSecureContext } from "node:tls";
5
+ import { ADMIN_PORT, MOUNT } from "../sandbox/contract.js";
6
+ import { ensureCa, leafFor } from "./ca.js";
7
+ import { startDns } from "./dns.js";
8
+ import { Standin } from "./handle.js";
9
+ // The fake-service container's entry point. Paths are the container's mounts, set up by the sandbox compose file.
10
+ const SETUP_FILE = `${MOUNT.runSetup}/standin.json`;
11
+ const CA_DIR = MOUNT.ca;
12
+ const OUT = MOUNT.out;
13
+ const command = process.argv[2];
14
+ if (command === "ca") {
15
+ ensureCa(CA_DIR);
16
+ }
17
+ else {
18
+ serve();
19
+ }
20
+ function serve() {
21
+ const setup = JSON.parse(readFileSync(SETUP_FILE, "utf8"));
22
+ ensureCa(CA_DIR);
23
+ const standin = new Standin(setup, (request) => appendFileSync(`${OUT}/requests.jsonl`, `${JSON.stringify(request)}\n`));
24
+ const seen = new Set();
25
+ startDns(setup.address, (name) => {
26
+ if (seen.has(name))
27
+ return;
28
+ seen.add(name);
29
+ appendFileSync(`${OUT}/dns.jsonl`, `${JSON.stringify({ at: new Date().toISOString(), name })}\n`);
30
+ });
31
+ const listener = (secure) => async (req, res) => {
32
+ const host = (req.headers.host ?? "").replace(/:\d+$/, "").toLowerCase();
33
+ const body = await readBody(req);
34
+ const headers = Object.fromEntries(Object.entries(req.headers).map(([key, value]) => [key, Array.isArray(value) ? value.join(", ") : (value ?? "")]));
35
+ send(res, await standin.handle(host, req.method ?? "GET", req.url ?? "/", { ...headers, "x-proofy-scheme": secure ? "https" : "http" }, body));
36
+ };
37
+ createHttp(listener(false)).listen(80);
38
+ const contexts = new Map();
39
+ createHttps({
40
+ ...leafFor(CA_DIR, "localhost"),
41
+ SNICallback: (servername, done) => {
42
+ try {
43
+ let context = contexts.get(servername);
44
+ if (!context) {
45
+ context = createSecureContext(leafFor(CA_DIR, servername));
46
+ contexts.set(servername, context);
47
+ }
48
+ done(null, context);
49
+ }
50
+ catch (error) {
51
+ done(error);
52
+ }
53
+ },
54
+ }, listener(true)).listen(443);
55
+ createHttp((req, res) => {
56
+ const url = new URL(req.url ?? "/", "http://admin");
57
+ if (url.pathname === "/health") {
58
+ send(res, { status: 200, json: { ok: true } });
59
+ }
60
+ else if (url.pathname === "/requests") {
61
+ send(res, { status: 200, json: standin.recordedFor(url.searchParams.get("host") ?? undefined) });
62
+ }
63
+ else {
64
+ send(res, { status: 404, json: { error: "unknown admin path" } });
65
+ }
66
+ }).listen(ADMIN_PORT);
67
+ }
68
+ function readBody(req) {
69
+ return new Promise((resolve, reject) => {
70
+ const chunks = [];
71
+ req.on("data", (chunk) => chunks.push(chunk));
72
+ req.on("end", () => resolve(Buffer.concat(chunks)));
73
+ req.on("error", reject);
74
+ });
75
+ }
76
+ function send(res, response) {
77
+ const headers = { ...(response.headers ?? {}) };
78
+ let body = "";
79
+ if (response.json !== undefined) {
80
+ headers["content-type"] ??= "application/json";
81
+ body = JSON.stringify(response.json);
82
+ }
83
+ else if (response.body !== undefined) {
84
+ body = response.body;
85
+ }
86
+ res.writeHead(response.status, headers);
87
+ res.end(body);
88
+ }
@@ -0,0 +1,23 @@
1
+ export interface Style {
2
+ color: boolean;
3
+ width: number;
4
+ }
5
+ export interface Painter {
6
+ bold: (text: string) => string;
7
+ dim: (text: string) => string;
8
+ red: (text: string) => string;
9
+ green: (text: string) => string;
10
+ yellow: (text: string) => string;
11
+ cyan: (text: string) => string;
12
+ }
13
+ export declare const GLYPH: {
14
+ ok: string;
15
+ fail: string;
16
+ warn: string;
17
+ dot: string;
18
+ };
19
+ export declare function resolveStyle(env: NodeJS.ProcessEnv, isTTY: boolean, columns: number | undefined): Style;
20
+ export declare function painter(style: Style): Painter;
21
+ export declare function truncate(text: string, max: number): string;
22
+ export declare function padVisible(text: string, width: number, align?: "left" | "right"): string;
23
+ export declare function stripAnsi(text: string): string;