@agent-flow-board/bridge 0.1.0 → 0.3.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/README.md +29 -1
- package/dist/checks/appdir.js +105 -0
- package/dist/checks/approve.js +213 -0
- package/dist/checks/audit.js +126 -0
- package/dist/checks/bind.js +100 -0
- package/dist/checks/board.js +107 -0
- package/dist/checks/broker.js +439 -0
- package/dist/checks/browser.js +275 -0
- package/dist/checks/browserpool.js +221 -0
- package/dist/checks/cache.js +710 -0
- package/dist/checks/child.js +211 -0
- package/dist/checks/command.js +267 -0
- package/dist/checks/commands.js +219 -0
- package/dist/checks/connect.js +79 -0
- package/dist/checks/connections.js +42 -0
- package/dist/checks/copy.js +320 -0
- package/dist/checks/dbpool.js +710 -0
- package/dist/checks/doctor.js +649 -0
- package/dist/checks/envPolicy.js +212 -0
- package/dist/checks/envrules.js +322 -0
- package/dist/checks/evidence.js +114 -0
- package/dist/checks/fsretry.js +29 -0
- package/dist/checks/guard.js +188 -0
- package/dist/checks/guardrails.js +155 -0
- package/dist/checks/importlegacy.js +300 -0
- package/dist/checks/install.js +150 -0
- package/dist/checks/live.js +76 -0
- package/dist/checks/lock.js +207 -0
- package/dist/checks/login.js +203 -0
- package/dist/checks/mailguard-hook.cjs +216 -0
- package/dist/checks/mailguard.js +98 -0
- package/dist/checks/paths.js +85 -0
- package/dist/checks/preview.js +577 -0
- package/dist/checks/profiles.js +110 -0
- package/dist/checks/provision.js +175 -0
- package/dist/checks/recipe.js +246 -0
- package/dist/checks/redact.js +52 -0
- package/dist/checks/registry.js +548 -0
- package/dist/checks/repoenv.js +162 -0
- package/dist/checks/roots.js +144 -0
- package/dist/checks/routing.js +68 -0
- package/dist/checks/run.js +443 -0
- package/dist/checks/sandbox.js +208 -0
- package/dist/checks/scheduler.js +383 -0
- package/dist/checks/scope.js +383 -0
- package/dist/checks/sealedNames.js +39 -0
- package/dist/checks/secrets.js +452 -0
- package/dist/checks/secretscmd.js +80 -0
- package/dist/checks/service.js +589 -0
- package/dist/checks/signin.js +79 -0
- package/dist/checks/singleflight.js +307 -0
- package/dist/checks/store.js +92 -0
- package/dist/checks/surface.js +124 -0
- package/dist/checks/types.js +2 -0
- package/dist/checks/warm.js +342 -0
- package/dist/checks/watch.js +228 -0
- package/dist/checks/wire.js +87 -0
- package/dist/cli.js +18 -1
- package/dist/hooks/adapters.js +3 -1
- package/dist/hooks/call.js +6 -1
- package/dist/hooks/modVersion.js +26 -0
- package/package.json +2 -2
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// A THROWAWAY DATABASE FOR ONE RUN: A NEW POSTGRES CONTAINER WITH ITS OWN NAME, ITS OWN VOLUME AND ITS OWN PORT. Docker is opt in (`--database tool`).
|
|
2
|
+
// Nothing here can reach the container or the volume that holds a person's own database on port 5433: the port is refused, and every docker command
|
|
3
|
+
// is checked before it runs so that it can only name a container that starts with `afb-checks-`.
|
|
4
|
+
// -- DEP-MODULES -- //
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import { createServer } from 'node:net';
|
|
8
|
+
// -- MODULES -- //
|
|
9
|
+
import { FORBIDDEN_PORTS } from './guardrails.js';
|
|
10
|
+
// -- CONSTANTS -- //
|
|
11
|
+
/** The ports a check never binds or publishes: the dev server, the build preview and the local PostgreSQL. */
|
|
12
|
+
const NEVER_PORTS = FORBIDDEN_PORTS;
|
|
13
|
+
/** The local PostgreSQL of the person. No container of ours ever publishes on it. */
|
|
14
|
+
const PERSON_DB_PORT = 5433;
|
|
15
|
+
/** Every container this tool makes or removes starts with this. */
|
|
16
|
+
export const CONTAINER_PREFIX = 'afb-checks-';
|
|
17
|
+
const IMAGE = 'postgres:16-alpine';
|
|
18
|
+
const DOCKER_COMMANDS = new Set(['version', 'run', 'rm', 'exec', 'inspect', 'logs']);
|
|
19
|
+
// -- FUNCTIONS -- //
|
|
20
|
+
/** Runs the real docker program. Never throws: a program that cannot start is a status of -1. */
|
|
21
|
+
export const realDocker = (args) => new Promise((resolve) => {
|
|
22
|
+
execFile('docker', args, { windowsHide: true, timeout: 120_000, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
23
|
+
const code = err
|
|
24
|
+
? typeof err.code === 'number'
|
|
25
|
+
? err.code
|
|
26
|
+
: -1
|
|
27
|
+
: 0;
|
|
28
|
+
resolve({ status: code, stdout: String(stdout), stderr: String(stderr) });
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
/**
|
|
32
|
+
* Refuses any docker command whose target is not ours. `run` must name its container with --name afb-checks-..., and `rm`, `exec`, `inspect` and `logs`
|
|
33
|
+
* may only name containers that start with afb-checks-. `-v` is a flag of `rm` here (remove the volumes of that container), and `run` never gets a
|
|
34
|
+
* host path or a named volume to mount, so the volume it makes is its own anonymous one.
|
|
35
|
+
*/
|
|
36
|
+
export function checkDockerArgs(args) {
|
|
37
|
+
const [cmd, ...rest] = args;
|
|
38
|
+
if (!cmd || !DOCKER_COMMANDS.has(cmd))
|
|
39
|
+
throw new Error('That docker command is not one the check tool runs.');
|
|
40
|
+
if (cmd === 'version')
|
|
41
|
+
return;
|
|
42
|
+
if (cmd === 'run') {
|
|
43
|
+
const i = rest.indexOf('--name');
|
|
44
|
+
const name = i >= 0 ? rest[i + 1] : undefined;
|
|
45
|
+
if (!name || !name.startsWith(CONTAINER_PREFIX))
|
|
46
|
+
throw new Error(`The check tool only starts containers named ${CONTAINER_PREFIX}...`);
|
|
47
|
+
if (rest.some((a) => a === '-v' || a === '--volume' || a === '--mount'))
|
|
48
|
+
throw new Error('The check tool never mounts a volume or folder into a container.');
|
|
49
|
+
const p = rest.indexOf('-p');
|
|
50
|
+
const publish = p >= 0 ? (rest[p + 1] ?? '').split(':') : [];
|
|
51
|
+
const hostPort = Number(publish.length === 3 ? publish[1] : publish[0]);
|
|
52
|
+
if (NEVER_PORTS.has(hostPort))
|
|
53
|
+
throw new Error('That port belongs to a dev server or to the person’s own database; the check tool never uses it.');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
// rm, exec, inspect, logs: a flag, a command word after the container, or the container name
|
|
57
|
+
const names = cmd === 'exec'
|
|
58
|
+
? [rest.find((a) => !a.startsWith('-'))]
|
|
59
|
+
: rest.filter((a) => !a.startsWith('-'));
|
|
60
|
+
if (!names.length || names.some((n) => !n || !n.startsWith(CONTAINER_PREFIX)))
|
|
61
|
+
throw new Error(`The check tool only touches containers named ${CONTAINER_PREFIX}...`);
|
|
62
|
+
}
|
|
63
|
+
async function guarded(docker, args) {
|
|
64
|
+
checkDockerArgs(args);
|
|
65
|
+
return docker(args);
|
|
66
|
+
}
|
|
67
|
+
/** Binds 127.0.0.1:0, reads the port and closes it. Never returns a forbidden port or the person's database port. */
|
|
68
|
+
export async function freePort() {
|
|
69
|
+
for (let i = 0; i < 25; i++) {
|
|
70
|
+
const port = await new Promise((resolve, reject) => {
|
|
71
|
+
const s = createServer();
|
|
72
|
+
s.once('error', reject);
|
|
73
|
+
s.listen(0, '127.0.0.1', () => {
|
|
74
|
+
const addr = s.address();
|
|
75
|
+
s.close(() => addr && typeof addr === 'object'
|
|
76
|
+
? resolve(addr.port)
|
|
77
|
+
: reject(new Error('No port was given.')));
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
if (!NEVER_PORTS.has(port))
|
|
81
|
+
return port;
|
|
82
|
+
}
|
|
83
|
+
throw new Error('No free port was found that is allowed.');
|
|
84
|
+
}
|
|
85
|
+
/** Removes ONE container of ours and its volumes, by its exact name. Never throws. */
|
|
86
|
+
export async function removeContainer(name, docker = realDocker) {
|
|
87
|
+
try {
|
|
88
|
+
await guarded(docker, ['rm', '-f', '-v', name]);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
/* Not ours, already gone, or Docker is not running: nothing to remove */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
95
|
+
/**
|
|
96
|
+
* Starts a new postgres container for this run and registers its removal on the scope. When `runtimeRole` is given the database also gets that login role,
|
|
97
|
+
* and `databaseUrl` uses it while `ownerUrl` keeps the superuser (migrations need the owner).
|
|
98
|
+
*/
|
|
99
|
+
export async function makeThrowawayDatabase(runId, scope, log, opts = {}) {
|
|
100
|
+
const docker = opts.docker ?? realDocker;
|
|
101
|
+
const version = await docker(['version']);
|
|
102
|
+
if (version.status !== 0)
|
|
103
|
+
throw new Error('--database tool needs Docker, which is not running');
|
|
104
|
+
const port = await (opts.pickPort ?? freePort)();
|
|
105
|
+
if (port === PERSON_DB_PORT || NEVER_PORTS.has(port))
|
|
106
|
+
throw new Error(`Port ${port} is not one the check tool may use for a database.`);
|
|
107
|
+
const name = `${CONTAINER_PREFIX}${runId}`;
|
|
108
|
+
const password = randomBytes(18).toString('hex');
|
|
109
|
+
const args = [
|
|
110
|
+
'run',
|
|
111
|
+
'-d',
|
|
112
|
+
'--name',
|
|
113
|
+
name,
|
|
114
|
+
'-e',
|
|
115
|
+
`POSTGRES_PASSWORD=${password}`,
|
|
116
|
+
'-e',
|
|
117
|
+
'POSTGRES_DB=app',
|
|
118
|
+
'-p',
|
|
119
|
+
`127.0.0.1:${port}:5432`,
|
|
120
|
+
IMAGE
|
|
121
|
+
];
|
|
122
|
+
const started = await guarded(docker, args);
|
|
123
|
+
// Registered at once: a failure from here on still removes what was made
|
|
124
|
+
const dispose = () => removeContainer(name, docker);
|
|
125
|
+
scope.add(`database ${name}`, dispose);
|
|
126
|
+
scope.setInfo?.({ containerName: name });
|
|
127
|
+
if (started.status !== 0)
|
|
128
|
+
throw new Error('The throwaway database could not be started.');
|
|
129
|
+
log(`Throwaway database ${name} started on port ${port}.`);
|
|
130
|
+
await waitReady(docker, name, opts.readyTimeoutMs ?? 60_000);
|
|
131
|
+
const ownerUrl = `postgresql://postgres:${password}@127.0.0.1:${port}/app`;
|
|
132
|
+
if (!opts.runtimeRole)
|
|
133
|
+
return { databaseUrl: ownerUrl, ownerUrl };
|
|
134
|
+
const role = opts.runtimeRole;
|
|
135
|
+
if (!/^[a-z_][a-z0-9_]{0,40}$/.test(role))
|
|
136
|
+
throw new Error('The runtime role is not a plain role name.');
|
|
137
|
+
const rolePassword = randomBytes(18).toString('hex');
|
|
138
|
+
const made = await guarded(docker, [
|
|
139
|
+
'exec',
|
|
140
|
+
name,
|
|
141
|
+
'psql',
|
|
142
|
+
'-U',
|
|
143
|
+
'postgres',
|
|
144
|
+
'-d',
|
|
145
|
+
'app',
|
|
146
|
+
'-c',
|
|
147
|
+
`CREATE ROLE ${role} LOGIN PASSWORD '${rolePassword}'`
|
|
148
|
+
]);
|
|
149
|
+
if (made.status !== 0)
|
|
150
|
+
throw new Error('The runtime role of the throwaway database could not be made.');
|
|
151
|
+
return {
|
|
152
|
+
databaseUrl: `postgresql://${role}:${rolePassword}@127.0.0.1:${port}/app`,
|
|
153
|
+
ownerUrl,
|
|
154
|
+
roleName: role
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
async function waitReady(docker, name, timeoutMs) {
|
|
158
|
+
const until = Date.now() + timeoutMs;
|
|
159
|
+
while (Date.now() < until) {
|
|
160
|
+
// With -h the check only passes when the real server listens, not the temporary one of the first start
|
|
161
|
+
const r = await guarded(docker, [
|
|
162
|
+
'exec',
|
|
163
|
+
name,
|
|
164
|
+
'pg_isready',
|
|
165
|
+
'-U',
|
|
166
|
+
'postgres',
|
|
167
|
+
'-h',
|
|
168
|
+
'127.0.0.1'
|
|
169
|
+
]);
|
|
170
|
+
if (r.status === 0)
|
|
171
|
+
return;
|
|
172
|
+
await sleep(500);
|
|
173
|
+
}
|
|
174
|
+
throw new Error('The throwaway database did not accept connections in time.');
|
|
175
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// THE RECIPE: .afb-checks.json IN THE PROJECT'S OWN REPO SAYS HOW ITS PREVIEW IS BUILT, STARTED AND SET UP.
|
|
2
|
+
// The repo is the one thing an agent edits, so the recipe is never trusted as it stands: it is pinned (bind.ts) and it is checked here
|
|
3
|
+
// against constants of this package. A recipe cannot name another program, a port a check never uses, an env key outside the list, or a value
|
|
4
|
+
// that is anything but a placeholder or a short plain word. It can only ask for less than the guardrails allow.
|
|
5
|
+
// Sealed secrets ({secret:NAME}) are allowed only on Supabase-style and mail settings (sealedNames.ts), a mail setting takes only the sealed value of its own name, and a
|
|
6
|
+
// service key never goes to a PUBLIC_, VITE_ or NEXT_PUBLIC_ name. The values are filled in by secrets.ts when the preview starts; this file only checks the shape.
|
|
7
|
+
// -- DEP-MODULES -- //
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
// -- MODULES -- //
|
|
12
|
+
import { FORBIDDEN_PORTS, checkRepoFolder, commandProblem } from './guardrails.js';
|
|
13
|
+
import { MAIL_ENV_KEY, SECRET_ENV_KEY, placeholderProblems } from './sealedNames.js';
|
|
14
|
+
// -- CONSTANTS -- //
|
|
15
|
+
export const RECIPE_FILE = '.afb-checks.json';
|
|
16
|
+
/** The only keys of a recipe. */
|
|
17
|
+
// There is no migrate or seed step: the app migrates and seeds itself at start from its env (AFB_AUTO_MIGRATE, AFB_SEED_DEMO), and a key nothing reads is refused
|
|
18
|
+
export const RECIPE_KEYS = [
|
|
19
|
+
'version',
|
|
20
|
+
'build',
|
|
21
|
+
'start',
|
|
22
|
+
'ready',
|
|
23
|
+
'needsDatabase',
|
|
24
|
+
'runtimeRole',
|
|
25
|
+
'testUser',
|
|
26
|
+
'env',
|
|
27
|
+
'appPaths',
|
|
28
|
+
'warm'
|
|
29
|
+
];
|
|
30
|
+
/** Env keys a recipe may name: the app's own settings and the database urls, never PATH, NODE_OPTIONS or anything that loads code. */
|
|
31
|
+
const ENV_KEY = /^(AFB_[A-Z0-9_]{1,50}|BETTER_AUTH_SECRET|DATABASE_URL|DATABASE_OWNER_URL|EMAIL_PROVIDER|EMAIL_FROM|ORIGIN)$/;
|
|
32
|
+
/** A browser-visible setting (SvelteKit PUBLIC_*, Vite VITE_*). It is inlined into the page's bundle, so it is never a secret. */
|
|
33
|
+
const PUBLIC_ENV_KEY = /^(PUBLIC|VITE)_[A-Z0-9_]{1,60}$/;
|
|
34
|
+
/** The values the tool fills in itself. */
|
|
35
|
+
const PLACEHOLDER = /^\{(baseUrl|port|random|databaseUrl|databaseOwnerUrl)\}$/;
|
|
36
|
+
/** Or a short plain word: no shell characters, no path, no url with a port. */
|
|
37
|
+
const PLAIN = /^[A-Za-z0-9_.:@-]{0,60}$/;
|
|
38
|
+
const READY_PATH = /^\/[A-Za-z0-9_\-./]*$/;
|
|
39
|
+
const APP_PATH = /^[A-Za-z0-9_.\-/]+$/;
|
|
40
|
+
/** The files that decide what runs, besides the config files found by pattern. */
|
|
41
|
+
const FIXED_FILES = [
|
|
42
|
+
'package.json',
|
|
43
|
+
'yarn.lock',
|
|
44
|
+
'package-lock.json',
|
|
45
|
+
'pnpm-lock.yaml',
|
|
46
|
+
'.yarnrc.yml',
|
|
47
|
+
RECIPE_FILE
|
|
48
|
+
];
|
|
49
|
+
const CONFIG_FILE = /^(vite|svelte|next|astro|nuxt)\.config\.[A-Za-z]+$/;
|
|
50
|
+
// -- FUNCTIONS -- //
|
|
51
|
+
const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
52
|
+
/** The scripts of the package.json of a repo (only string bodies). An unreadable package.json has none. */
|
|
53
|
+
function scriptsOf(repo) {
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(readFileSync(join(repo, 'package.json'), 'utf8'));
|
|
56
|
+
const raw = isObject(parsed) && isObject(parsed.scripts) ? parsed.scripts : {};
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const [k, v] of Object.entries(raw))
|
|
59
|
+
if (typeof v === 'string')
|
|
60
|
+
out[k] = v;
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** The package manager the lockfile names. Yarn when there is none. */
|
|
68
|
+
function packageManager(repo) {
|
|
69
|
+
if (existsSync(join(repo, 'yarn.lock')))
|
|
70
|
+
return 'yarn';
|
|
71
|
+
if (existsSync(join(repo, 'package-lock.json')))
|
|
72
|
+
return 'npm';
|
|
73
|
+
if (existsSync(join(repo, 'pnpm-lock.yaml')))
|
|
74
|
+
return 'pnpm';
|
|
75
|
+
return 'yarn';
|
|
76
|
+
}
|
|
77
|
+
/** The recipe of a repo, parsed, or null when it has none. A recipe that is not valid JSON is an error, never ignored. */
|
|
78
|
+
export function loadRecipe(repo) {
|
|
79
|
+
const file = join(repo, RECIPE_FILE);
|
|
80
|
+
if (!existsSync(file))
|
|
81
|
+
return null;
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
throw new Error(`${RECIPE_FILE} in ${repo} is not valid JSON. Fix it; a recipe that cannot be read is never guessed at.`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Every problem of a recipe, as plain sentences. An empty list means it may be used. */
|
|
90
|
+
export function validateRecipe(recipe, scripts) {
|
|
91
|
+
const out = [];
|
|
92
|
+
if (!isObject(recipe))
|
|
93
|
+
return [`${RECIPE_FILE} must be a JSON object.`];
|
|
94
|
+
const extra = Object.keys(recipe).filter((k) => !RECIPE_KEYS.includes(k));
|
|
95
|
+
if (extra.length)
|
|
96
|
+
out.push(`${RECIPE_FILE} names ${extra.join(', ')}, which a recipe may not set (it may set ${RECIPE_KEYS.join(', ')}).`);
|
|
97
|
+
if (recipe.version !== 1)
|
|
98
|
+
out.push(`${RECIPE_FILE} needs "version": 1.`);
|
|
99
|
+
for (const key of ['build', 'start']) {
|
|
100
|
+
const value = recipe[key];
|
|
101
|
+
if (value === undefined) {
|
|
102
|
+
if (key === 'start')
|
|
103
|
+
out.push(`${RECIPE_FILE} needs a "start" command.`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const bad = commandProblem(value, scripts, `The recipe's ${key} command`);
|
|
107
|
+
if (bad)
|
|
108
|
+
out.push(bad);
|
|
109
|
+
for (const p of FORBIDDEN_PORTS)
|
|
110
|
+
if (new RegExp(`(^|[^0-9])${p}([^0-9]|$)`).test(String(value)))
|
|
111
|
+
out.push(`The recipe's ${key} command mentions port ${p}, which a check never uses.`);
|
|
112
|
+
}
|
|
113
|
+
if (recipe.ready !== undefined && !READY_PATH.test(String(recipe.ready)))
|
|
114
|
+
out.push("The recipe's ready path must start with / and hold only plain path characters.");
|
|
115
|
+
for (const p of FORBIDDEN_PORTS)
|
|
116
|
+
if (new RegExp(`(^|[^0-9])${p}([^0-9]|$)`).test(String(recipe.ready ?? '')))
|
|
117
|
+
out.push(`The recipe's ready path mentions port ${p}, which a check never uses.`);
|
|
118
|
+
if (recipe.needsDatabase !== undefined && typeof recipe.needsDatabase !== 'boolean')
|
|
119
|
+
out.push('"needsDatabase" must be true or false.');
|
|
120
|
+
if (recipe.runtimeRole !== undefined &&
|
|
121
|
+
!/^[a-z_][a-z0-9_]{0,40}$/.test(String(recipe.runtimeRole)))
|
|
122
|
+
out.push('"runtimeRole" must be a plain lower case role name.');
|
|
123
|
+
if (recipe.testUser !== undefined) {
|
|
124
|
+
const u = recipe.testUser;
|
|
125
|
+
const email = isObject(u) ? String(u.email ?? '') : '';
|
|
126
|
+
const password = isObject(u) ? String(u.password ?? '') : '';
|
|
127
|
+
const ok = isObject(u) &&
|
|
128
|
+
Object.keys(u).every((k) => ['email', 'password'].includes(k)) &&
|
|
129
|
+
PLAIN.test(email) &&
|
|
130
|
+
email.includes('@') &&
|
|
131
|
+
PLAIN.test(password) &&
|
|
132
|
+
password.length >= 8;
|
|
133
|
+
if (!ok)
|
|
134
|
+
out.push('"testUser" must be { "email": ..., "password": ... } with a plain email and a password of 8 or more plain characters. It is a preview-only account, public by design.');
|
|
135
|
+
}
|
|
136
|
+
// Warm previews are not wired in this version (the benchmark rule waived them), so the key is refused instead of being accepted and ignored
|
|
137
|
+
if (recipe.warm !== undefined) {
|
|
138
|
+
if (typeof recipe.warm !== 'boolean')
|
|
139
|
+
out.push('"warm" must be true or false.');
|
|
140
|
+
else if (recipe.warm)
|
|
141
|
+
out.push('"warm": warm previews are not enabled in this version.');
|
|
142
|
+
}
|
|
143
|
+
if (recipe.appPaths !== undefined &&
|
|
144
|
+
!(Array.isArray(recipe.appPaths) &&
|
|
145
|
+
recipe.appPaths.every((p) => typeof p === 'string' && APP_PATH.test(p) && !p.includes('..'))))
|
|
146
|
+
out.push('"appPaths" must be a list of plain folder or file names inside the repo.');
|
|
147
|
+
if (recipe.env !== undefined) {
|
|
148
|
+
if (!isObject(recipe.env))
|
|
149
|
+
out.push('"env" must be an object of setting names and values.');
|
|
150
|
+
else
|
|
151
|
+
for (const [k, v] of Object.entries(recipe.env)) {
|
|
152
|
+
if (!ENV_KEY.test(k) && !PUBLIC_ENV_KEY.test(k) && !SECRET_ENV_KEY.test(k))
|
|
153
|
+
out.push(`The recipe sets ${k}, which is not an env key a recipe may set.`);
|
|
154
|
+
// A sealed value only on a Supabase-style or mail setting, and only a key of that kind: never a database, NODE_OPTIONS or a path
|
|
155
|
+
else if (typeof v === 'string' && v.startsWith('{secret:'))
|
|
156
|
+
out.push(...placeholderProblems(k, v));
|
|
157
|
+
// A mail setting is never written in the recipe: its value comes from the sealed store, behind the mail guard
|
|
158
|
+
else if (MAIL_ENV_KEY.test(k))
|
|
159
|
+
out.push(`The recipe gives ${k}, a mail setting, a value that is not its own sealed value ({secret:${k}}).`);
|
|
160
|
+
else if (typeof v !== 'string' || !(PLACEHOLDER.test(v) || PLAIN.test(v)))
|
|
161
|
+
out.push(`The recipe gives ${k} a value that is not a placeholder ({baseUrl}, {port}, {random}, {databaseUrl}, {databaseOwnerUrl}) or a short plain word.`);
|
|
162
|
+
else if (/^\d+$/.test(v) && FORBIDDEN_PORTS.has(Number(v)))
|
|
163
|
+
out.push(`The recipe gives ${k} the port ${v}, which a check never uses.`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
/** The files that decide what runs, as relative names that exist in the repo, sorted. */
|
|
169
|
+
export function decidingFiles(repo) {
|
|
170
|
+
const found = FIXED_FILES.filter((f) => existsSync(join(repo, f)));
|
|
171
|
+
let names = [];
|
|
172
|
+
try {
|
|
173
|
+
names = readdirSync(repo).filter((n) => CONFIG_FILE.test(n));
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
/* An unreadable folder has no config files */
|
|
177
|
+
}
|
|
178
|
+
return [...new Set([...found, ...names])].sort();
|
|
179
|
+
}
|
|
180
|
+
/** A sha256 over the names and the line-ending-normalised contents of the deciding files. Any change to them changes the pin. */
|
|
181
|
+
export function pinFiles(repo) {
|
|
182
|
+
const hash = createHash('sha256');
|
|
183
|
+
for (const name of decidingFiles(repo)) {
|
|
184
|
+
const text = readFileSync(join(repo, name), 'utf8').replace(/\r\n/g, '\n');
|
|
185
|
+
hash.update(`${name}\0${text}\0`);
|
|
186
|
+
}
|
|
187
|
+
return hash.digest('hex');
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* What a run needs to know about a repo: the checked folder, then the recipe when there is one, else plain detection from package.json.
|
|
191
|
+
* Throws one plain sentence when the folder, the recipe or the commands are refused.
|
|
192
|
+
*/
|
|
193
|
+
export function resolveProjectConfig(repo) {
|
|
194
|
+
const folder = checkRepoFolder(repo);
|
|
195
|
+
if (folder)
|
|
196
|
+
throw new Error(folder);
|
|
197
|
+
const scripts = scriptsOf(repo);
|
|
198
|
+
const recipe = loadRecipe(repo);
|
|
199
|
+
if (recipe !== null) {
|
|
200
|
+
const problems = validateRecipe(recipe, scripts);
|
|
201
|
+
if (problems.length)
|
|
202
|
+
throw new Error(problems[0]);
|
|
203
|
+
const r = recipe;
|
|
204
|
+
const config = {
|
|
205
|
+
repo,
|
|
206
|
+
start: String(r.start),
|
|
207
|
+
needsDatabase: r.needsDatabase === true,
|
|
208
|
+
env: isObject(r.env)
|
|
209
|
+
? Object.fromEntries(Object.entries(r.env).map(([k, v]) => [k, String(v)]))
|
|
210
|
+
: {},
|
|
211
|
+
appPaths: Array.isArray(r.appPaths) ? r.appPaths.map(String) : []
|
|
212
|
+
};
|
|
213
|
+
if (typeof r.build === 'string')
|
|
214
|
+
config.build = r.build;
|
|
215
|
+
if (typeof r.ready === 'string')
|
|
216
|
+
config.ready = r.ready;
|
|
217
|
+
if (typeof r.runtimeRole === 'string')
|
|
218
|
+
config.runtimeRole = r.runtimeRole;
|
|
219
|
+
if (isObject(r.testUser))
|
|
220
|
+
config.testUser = { email: String(r.testUser.email), password: String(r.testUser.password) };
|
|
221
|
+
return config;
|
|
222
|
+
}
|
|
223
|
+
// No recipe: detect plainly. npm needs `run` and a `--` before flags; yarn and pnpm run a script by its name.
|
|
224
|
+
const pm = packageManager(repo);
|
|
225
|
+
const run = (script) => (pm === 'npm' ? `npm run ${script}` : `${pm} ${script}`);
|
|
226
|
+
if (!Object.prototype.hasOwnProperty.call(scripts, 'preview'))
|
|
227
|
+
throw new Error(`${repo} has no "preview" script. Add a ${RECIPE_FILE} that says how to build and start the app, or add a preview script.`);
|
|
228
|
+
const config = {
|
|
229
|
+
repo,
|
|
230
|
+
start: pm === 'npm' ? 'npm run preview -- --port {port}' : `${run('preview')} --port {port}`,
|
|
231
|
+
needsDatabase: false,
|
|
232
|
+
env: {},
|
|
233
|
+
appPaths: []
|
|
234
|
+
};
|
|
235
|
+
if (Object.prototype.hasOwnProperty.call(scripts, 'build'))
|
|
236
|
+
config.build = run('build');
|
|
237
|
+
const startBad = commandProblem(config.start, scripts, 'The start command');
|
|
238
|
+
if (startBad)
|
|
239
|
+
throw new Error(startBad);
|
|
240
|
+
if (config.build) {
|
|
241
|
+
const buildBad = commandProblem(config.build, scripts, 'The build command');
|
|
242
|
+
if (buildBad)
|
|
243
|
+
throw new Error(buildBad);
|
|
244
|
+
}
|
|
245
|
+
return config;
|
|
246
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// REDACTION: NOTHING THAT LOOKS LIKE A SECRET, AND NOTHING THE RUN WAS TOLD IS A SECRET, REACHES A LOG, A RESULT OR AN UPLOAD.
|
|
2
|
+
// This file imports nothing, so the sandboxed child can load it without touching the token store.
|
|
3
|
+
// -- CONSTANTS -- //
|
|
4
|
+
/** The shapes of tokens, whatever their value. */
|
|
5
|
+
const PATTERNS = [
|
|
6
|
+
/\b(?:afx|afb|afrun)_[A-Za-z0-9._-]{12,}/g, // A board runner or bridge token
|
|
7
|
+
/\bsb_(?:secret|publishable)_[A-Za-z0-9_-]{16,}/g, // A Supabase key of the new kind
|
|
8
|
+
/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi,
|
|
9
|
+
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}/g, // A JWT
|
|
10
|
+
/\b(?:set-cookie|cookie|authorization)\s*:\s*[^\r\n]+/gi
|
|
11
|
+
];
|
|
12
|
+
/** user:password@host in any address: the password is hidden whatever it is, so a database url in a log never shows it. */
|
|
13
|
+
const USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/[^\s:@/]+):[^\s@/]+@/gi;
|
|
14
|
+
/** A needle shorter than this is ignored, because removing a 3 letter word would wipe the text. */
|
|
15
|
+
const MIN_NEEDLE = 6;
|
|
16
|
+
/** Variables whose value is a secret by the look of their name. */
|
|
17
|
+
const SECRET_NAME = /(PASSWORD|SECRET|TOKEN|KEY|URL|URI)$/i;
|
|
18
|
+
const MIN_ENV_VALUE = 8;
|
|
19
|
+
// -- FUNCTIONS -- //
|
|
20
|
+
/** Masks what looks like a token, a cookie header or a password inside an address. */
|
|
21
|
+
export function redactPatterns(text) {
|
|
22
|
+
let out = String(text);
|
|
23
|
+
for (const p of PATTERNS)
|
|
24
|
+
out = out.replace(p, '[hidden]');
|
|
25
|
+
return out.replace(USERINFO, '$1:[hidden]@');
|
|
26
|
+
}
|
|
27
|
+
/** Every spelling of a needle that could show up in a log: raw, as in a URL, and as base64 (padded and not). */
|
|
28
|
+
function forms(needle) {
|
|
29
|
+
const b64 = Buffer.from(needle, 'utf8').toString('base64');
|
|
30
|
+
return [...new Set([needle, encodeURIComponent(needle), b64, b64.replace(/=+$/, '')])].filter((f) => f.length >= MIN_NEEDLE);
|
|
31
|
+
}
|
|
32
|
+
/** needles: exact strings (a typed password, a token) to remove wherever they appear, then the token shapes. */
|
|
33
|
+
export function redact(text, needles = []) {
|
|
34
|
+
let out = String(text);
|
|
35
|
+
// Longest first, so a needle that contains another is removed whole
|
|
36
|
+
const all = needles
|
|
37
|
+
.filter((n) => typeof n === 'string' && n.length >= MIN_NEEDLE)
|
|
38
|
+
.flatMap(forms)
|
|
39
|
+
.sort((a, b) => b.length - a.length);
|
|
40
|
+
for (const f of all)
|
|
41
|
+
out = out.split(f).join('[hidden]');
|
|
42
|
+
return redactPatterns(out);
|
|
43
|
+
}
|
|
44
|
+
/** The values of the variables that look like secrets (by name), ready to be used as needles. */
|
|
45
|
+
export function needlesFromEnv(env) {
|
|
46
|
+
const out = new Set();
|
|
47
|
+
for (const [name, value] of Object.entries(env)) {
|
|
48
|
+
if (typeof value === 'string' && value.length >= MIN_ENV_VALUE && SECRET_NAME.test(name))
|
|
49
|
+
out.add(value);
|
|
50
|
+
}
|
|
51
|
+
return [...out];
|
|
52
|
+
}
|