@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,383 @@
|
|
|
1
|
+
// THE THINGS ONE RUN OWNS, AND WHAT HAPPENS TO THEM WHEN THE RUN ENDS, WHATEVER ENDS IT.
|
|
2
|
+
// The layer that makes the resources writes a live marker file first (markers/<run id>.json, in a folder the sandboxed check cannot reach: the process id, the
|
|
3
|
+
// start time and what the run made that a crash would leave behind: the preview process, the database container and the private copy). Resources are closed newest first and closing never throws.
|
|
4
|
+
// A later run sweeps what a dead process left: only the exact run folders and copies named by a dead marker (or by no marker and old enough), never a
|
|
5
|
+
// folder that was not made by a run.
|
|
6
|
+
// -- DEP-MODULES -- //
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { randomBytes } from 'node:crypto';
|
|
9
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
// -- MODULES -- //
|
|
12
|
+
import { pidAlive } from './lock.js';
|
|
13
|
+
import { NAME, copiesDir, endedPath, ensureDir, logDirFor, logsDir, markerPath, markersDir, runsDir, under } from './paths.js';
|
|
14
|
+
import { dropPooledDatabase, pooledNames } from './dbpool.js';
|
|
15
|
+
import { renameRetry } from './fsretry.js';
|
|
16
|
+
import { CONTAINER_PREFIX, removeContainer } from './provision.js';
|
|
17
|
+
// -- CONSTANTS -- //
|
|
18
|
+
/** A run folder with no marker is only an orphan after this long (the marker is written first, so younger ones are being made right now). */
|
|
19
|
+
const NO_MARKER_GRACE_MS = 15 * 60_000;
|
|
20
|
+
const DAY_MS = 86_400_000;
|
|
21
|
+
// -- FUNCTIONS -- //
|
|
22
|
+
/** 'r' + a timestamp + random characters: a plain name, and sortable by time. */
|
|
23
|
+
export const newRunId = () => 'r' + Date.now().toString(36) + randomBytes(3).toString('hex');
|
|
24
|
+
const noop = () => { };
|
|
25
|
+
/** Writes a file whole: a temporary file renamed into place, so a reader never sees half of it. */
|
|
26
|
+
function writeWhole(file, text) {
|
|
27
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
28
|
+
writeFileSync(tmp, text, { mode: 0o600 });
|
|
29
|
+
renameRetry(tmp, file);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Reads the marker of a run and checks it against what the tool itself would have written for that run id. A field that names anything else (a database,
|
|
33
|
+
* a role, a container, a process that is older than the run) is dropped and a note says so, so a marker can never make a sweep touch something that is not the
|
|
34
|
+
* run's own. A marker that does not hold a process id and its own run id is not a marker (null).
|
|
35
|
+
*/
|
|
36
|
+
export function readMarker(runId) {
|
|
37
|
+
let v;
|
|
38
|
+
try {
|
|
39
|
+
v = JSON.parse(readFileSync(markerPath(runId), 'utf8'));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (!v || typeof v !== 'object')
|
|
45
|
+
return null;
|
|
46
|
+
const m = v;
|
|
47
|
+
if (typeof m.pid !== 'number' || m.runId !== runId)
|
|
48
|
+
return null;
|
|
49
|
+
const startedAt = typeof m.startedAt === 'string' ? m.startedAt : '';
|
|
50
|
+
const notes = [];
|
|
51
|
+
const out = { runId, pid: m.pid, startedAt };
|
|
52
|
+
if (m.previewPid !== undefined) {
|
|
53
|
+
const spawned = Date.parse(String(m.previewStartedAt ?? ''));
|
|
54
|
+
if (Number.isInteger(m.previewPid) &&
|
|
55
|
+
m.previewPid > 1 &&
|
|
56
|
+
m.previewPid !== m.pid &&
|
|
57
|
+
Number.isFinite(spawned) &&
|
|
58
|
+
spawned >= Date.parse(startedAt) - 1000) {
|
|
59
|
+
out.previewPid = m.previewPid;
|
|
60
|
+
out.previewStartedAt = String(m.previewStartedAt);
|
|
61
|
+
}
|
|
62
|
+
else
|
|
63
|
+
notes.push(`The marker of run ${runId} names a preview process that does not check out, so it was ignored.`);
|
|
64
|
+
}
|
|
65
|
+
if (m.containerName !== undefined) {
|
|
66
|
+
if (m.containerName === `${CONTAINER_PREFIX}${runId}`)
|
|
67
|
+
out.containerName = m.containerName;
|
|
68
|
+
else
|
|
69
|
+
notes.push(`The marker of run ${runId} names a container that is not the one this run makes, so it was ignored.`);
|
|
70
|
+
}
|
|
71
|
+
if (m.dbName !== undefined || m.roleName !== undefined || m.poolId !== undefined) {
|
|
72
|
+
const names = pooledNames(runId);
|
|
73
|
+
if (m.dbName === names.dbName &&
|
|
74
|
+
m.roleName === names.roleName &&
|
|
75
|
+
typeof m.poolId === 'string' &&
|
|
76
|
+
NAME.test(m.poolId)) {
|
|
77
|
+
out.dbName = m.dbName;
|
|
78
|
+
out.roleName = m.roleName;
|
|
79
|
+
out.poolId = m.poolId;
|
|
80
|
+
}
|
|
81
|
+
else
|
|
82
|
+
notes.push(`The marker of run ${runId} names a database or role that is not this run's, so it was ignored.`);
|
|
83
|
+
}
|
|
84
|
+
if (typeof m.copyDir === 'string')
|
|
85
|
+
out.copyDir = m.copyDir;
|
|
86
|
+
return { marker: out, notes };
|
|
87
|
+
}
|
|
88
|
+
export class RunScope {
|
|
89
|
+
runId;
|
|
90
|
+
disposers = [];
|
|
91
|
+
log;
|
|
92
|
+
info = {};
|
|
93
|
+
started = false;
|
|
94
|
+
closed = false;
|
|
95
|
+
constructor(runId = newRunId(), log = noop) {
|
|
96
|
+
if (!NAME.test(runId))
|
|
97
|
+
throw new Error('The run id is not a plain name.');
|
|
98
|
+
this.runId = runId;
|
|
99
|
+
this.log = log;
|
|
100
|
+
}
|
|
101
|
+
/** The folder of this run. */
|
|
102
|
+
get dir() {
|
|
103
|
+
return under(runsDir(), this.runId);
|
|
104
|
+
}
|
|
105
|
+
/** Writes the live marker. Called before anything else is made; every method that makes a resource calls it too, so it can never be skipped. */
|
|
106
|
+
start() {
|
|
107
|
+
if (this.started)
|
|
108
|
+
return;
|
|
109
|
+
ensureDir(this.dir);
|
|
110
|
+
ensureDir(markersDir());
|
|
111
|
+
ensureDir(logDirFor(this.runId));
|
|
112
|
+
this.started = true;
|
|
113
|
+
this.writeMarker();
|
|
114
|
+
}
|
|
115
|
+
writeMarker() {
|
|
116
|
+
const marker = {
|
|
117
|
+
runId: this.runId,
|
|
118
|
+
pid: process.pid,
|
|
119
|
+
startedAt: new Date().toISOString(),
|
|
120
|
+
...this.info
|
|
121
|
+
};
|
|
122
|
+
writeWhole(markerPath(this.runId), JSON.stringify(marker) + '\n');
|
|
123
|
+
}
|
|
124
|
+
/** Records what a crash would leave behind (the preview process id, the database container, the private copy) so a later sweep can remove it. */
|
|
125
|
+
setInfo(info) {
|
|
126
|
+
// After the scope closed nothing may rewrite the marker: it was removed with the run
|
|
127
|
+
if (this.closed)
|
|
128
|
+
return;
|
|
129
|
+
this.start();
|
|
130
|
+
const stamped = info.previewPid !== undefined && info.previewStartedAt === undefined
|
|
131
|
+
? { ...info, previewStartedAt: new Date().toISOString() }
|
|
132
|
+
: info;
|
|
133
|
+
this.info = { ...this.info, ...stamped };
|
|
134
|
+
this.writeMarker();
|
|
135
|
+
}
|
|
136
|
+
/** Registers a resource. It is closed when the scope closes, newest first. */
|
|
137
|
+
add(name, dispose) {
|
|
138
|
+
// A resource made after the scope closed is closed at once: nothing will come back to do it
|
|
139
|
+
if (this.closed) {
|
|
140
|
+
void Promise.resolve()
|
|
141
|
+
.then(dispose)
|
|
142
|
+
.catch(() => this.log(`Could not close ${name}.`));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
this.start();
|
|
146
|
+
this.disposers.push({ name, dispose });
|
|
147
|
+
}
|
|
148
|
+
/** Closes every resource newest first, then removes the marker. Never throws; a failure goes to the log. Safe to call twice. */
|
|
149
|
+
async close() {
|
|
150
|
+
if (this.closed)
|
|
151
|
+
return;
|
|
152
|
+
this.closed = true;
|
|
153
|
+
while (this.disposers.length) {
|
|
154
|
+
const item = this.disposers.pop();
|
|
155
|
+
if (!item)
|
|
156
|
+
break;
|
|
157
|
+
try {
|
|
158
|
+
await item.dispose();
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
this.log(`Could not close ${item.name}.`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
if (this.started) {
|
|
166
|
+
writeWhole(endedPath(this.runId), new Date().toISOString() + '\n');
|
|
167
|
+
rmSync(markerPath(this.runId), { force: true });
|
|
168
|
+
removeTree(logDirFor(this.runId));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
this.log('Could not remove the live marker of this run.');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Removes a folder without ever walking through a link: a symlink or junction is removed as a link (not recursed into), a folder is emptied then removed.
|
|
178
|
+
* Retries a few times because Windows holds files of a process that has only just been killed.
|
|
179
|
+
*/
|
|
180
|
+
export function removeTree(path) {
|
|
181
|
+
let st;
|
|
182
|
+
try {
|
|
183
|
+
st = lstatSync(path);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (st.isSymbolicLink()) {
|
|
189
|
+
try {
|
|
190
|
+
rmSync(path, { recursive: false, force: true });
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
try {
|
|
194
|
+
unlinkSync(path);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
rmdirSync(path);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (!st.isDirectory()) {
|
|
203
|
+
rmSync(path, { force: true, maxRetries: 5, retryDelay: 100 });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
for (const entry of readdirSync(path))
|
|
207
|
+
removeTree(join(path, entry));
|
|
208
|
+
// Windows holds a folder for a moment after a process that used it was killed
|
|
209
|
+
for (let i = 0;; i++) {
|
|
210
|
+
try {
|
|
211
|
+
rmdirSync(path);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
if (i >= 5 || e.code === 'ENOENT') {
|
|
216
|
+
if (e.code === 'ENOENT')
|
|
217
|
+
return;
|
|
218
|
+
throw e;
|
|
219
|
+
}
|
|
220
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** Kills a process and everything it started. */
|
|
225
|
+
export function killTree(pid) {
|
|
226
|
+
if (!Number.isInteger(pid) || pid <= 1)
|
|
227
|
+
return Promise.resolve();
|
|
228
|
+
if (process.platform === 'win32') {
|
|
229
|
+
// Windows cannot deliver SIGTERM to a child, so the tree is ended by taskkill
|
|
230
|
+
return new Promise((resolve) => {
|
|
231
|
+
execFile('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true }, () => resolve());
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
// A child spawned detached leads its own group: the negative id reaches the whole group. A child that is not a leader falls back to itself
|
|
235
|
+
const send = (signal) => {
|
|
236
|
+
try {
|
|
237
|
+
process.kill(-pid, signal);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
try {
|
|
241
|
+
process.kill(pid, signal);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
/* Already gone */
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
send('SIGTERM');
|
|
249
|
+
return new Promise((resolve) => {
|
|
250
|
+
const until = Date.now() + 3_000;
|
|
251
|
+
const timer = setInterval(() => {
|
|
252
|
+
if (!pidAlive(pid)) {
|
|
253
|
+
clearInterval(timer);
|
|
254
|
+
resolve();
|
|
255
|
+
}
|
|
256
|
+
else if (Date.now() > until) {
|
|
257
|
+
clearInterval(timer);
|
|
258
|
+
send('SIGKILL');
|
|
259
|
+
resolve();
|
|
260
|
+
}
|
|
261
|
+
}, 100);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
/** On a stop signal: closes the scope, then calls onKill (which exits the process). Returns the function that removes the handlers. */
|
|
265
|
+
export function installSignalHandlers(scope, onKill) {
|
|
266
|
+
let busy = false;
|
|
267
|
+
const handler = () => {
|
|
268
|
+
if (busy)
|
|
269
|
+
return;
|
|
270
|
+
busy = true;
|
|
271
|
+
void scope.close().finally(() => onKill());
|
|
272
|
+
};
|
|
273
|
+
const signals = ['SIGINT', 'SIGTERM', 'SIGBREAK'];
|
|
274
|
+
for (const s of signals)
|
|
275
|
+
process.on(s, handler);
|
|
276
|
+
return () => {
|
|
277
|
+
for (const s of signals)
|
|
278
|
+
process.off(s, handler);
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/** Removes a run folder and its private copy by their exact paths. */
|
|
282
|
+
function removeRun(id) {
|
|
283
|
+
removeTree(under(copiesDir(), id));
|
|
284
|
+
removeTree(under(runsDir(), id));
|
|
285
|
+
removeTree(logDirFor(id));
|
|
286
|
+
rmSync(markerPath(id), { force: true });
|
|
287
|
+
rmSync(endedPath(id), { force: true });
|
|
288
|
+
}
|
|
289
|
+
const ageOf = (path) => {
|
|
290
|
+
try {
|
|
291
|
+
return Date.now() - statSync(path).mtimeMs;
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return 0;
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
const plainIds = (dir) => {
|
|
298
|
+
if (!existsSync(dir))
|
|
299
|
+
return [];
|
|
300
|
+
return readdirSync(dir).filter((n) => NAME.test(n));
|
|
301
|
+
};
|
|
302
|
+
/** The run ids that have a folder, a marker or an end note. */
|
|
303
|
+
export function allRunIds() {
|
|
304
|
+
const ids = new Set(plainIds(runsDir()));
|
|
305
|
+
if (existsSync(markersDir()))
|
|
306
|
+
for (const f of readdirSync(markersDir())) {
|
|
307
|
+
const m = /^(.+)\.(json|ended)$/.exec(f);
|
|
308
|
+
if (m && NAME.test(m[1]))
|
|
309
|
+
ids.add(m[1]);
|
|
310
|
+
}
|
|
311
|
+
return [...ids];
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Cleans what dead runs left: a run whose marker names a dead process, or that has neither a marker nor an end note and is older than 15 minutes.
|
|
315
|
+
* Stops the recorded preview process tree and database container, then removes only those run folders and copies. Returns how many runs it cleaned.
|
|
316
|
+
*/
|
|
317
|
+
export async function sweepOrphans(log = noop, deps = {}) {
|
|
318
|
+
let cleaned = 0;
|
|
319
|
+
const seen = new Set();
|
|
320
|
+
for (const id of allRunIds()) {
|
|
321
|
+
seen.add(id);
|
|
322
|
+
const dir = under(runsDir(), id);
|
|
323
|
+
const checked = readMarker(id);
|
|
324
|
+
const marker = checked?.marker ?? null;
|
|
325
|
+
for (const note of checked?.notes ?? [])
|
|
326
|
+
log(note);
|
|
327
|
+
if (marker) {
|
|
328
|
+
if (pidAlive(marker.pid))
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
else if (existsSync(markerPath(id)) ||
|
|
332
|
+
existsSync(endedPath(id)) ||
|
|
333
|
+
(existsSync(dir) ? ageOf(dir) : Infinity) < NO_MARKER_GRACE_MS) {
|
|
334
|
+
continue; // Finished in order (clearRuns handles it), or too young to judge
|
|
335
|
+
}
|
|
336
|
+
if (marker?.previewPid && pidAlive(marker.previewPid))
|
|
337
|
+
await killTree(marker.previewPid);
|
|
338
|
+
if (marker?.dbName && marker.roleName && marker.poolId) {
|
|
339
|
+
// A pooled run: only its own database and roles go. The pool container is never removed here, other runs live in it
|
|
340
|
+
await dropPooledDatabase({ dbName: marker.dbName, roleName: marker.roleName, poolId: marker.poolId }, { docker: deps.docker });
|
|
341
|
+
}
|
|
342
|
+
else if (marker?.containerName)
|
|
343
|
+
await removeContainer(marker.containerName, deps.docker);
|
|
344
|
+
removeRun(id);
|
|
345
|
+
cleaned++;
|
|
346
|
+
log(`Cleaned the leftovers of run ${id}.`);
|
|
347
|
+
}
|
|
348
|
+
// A copy whose run folder is gone: removed when it is old enough that no run is still making it
|
|
349
|
+
for (const id of plainIds(copiesDir())) {
|
|
350
|
+
if (seen.has(id) || ageOf(under(copiesDir(), id)) < NO_MARKER_GRACE_MS)
|
|
351
|
+
continue;
|
|
352
|
+
removeTree(under(copiesDir(), id));
|
|
353
|
+
cleaned++;
|
|
354
|
+
log(`Removed the orphaned copy of run ${id}.`);
|
|
355
|
+
}
|
|
356
|
+
// The logs of a run that has no folder, marker or copy any more: old enough that no run is still writing them
|
|
357
|
+
for (const id of plainIds(logsDir())) {
|
|
358
|
+
if (seen.has(id) || existsSync(markerPath(id)) || ageOf(logDirFor(id)) < NO_MARKER_GRACE_MS)
|
|
359
|
+
continue;
|
|
360
|
+
removeTree(logDirFor(id));
|
|
361
|
+
cleaned++;
|
|
362
|
+
log(`Removed the orphaned logs of run ${id}.`);
|
|
363
|
+
}
|
|
364
|
+
return cleaned;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Removes finished run folders (and their copies). A run with the live marker of a running process is always kept; a run whose process died is left to
|
|
368
|
+
* sweepOrphans, which also stops what it left running. By default only runs older than 7 days go; `all` removes every finished one.
|
|
369
|
+
*/
|
|
370
|
+
export function clearRuns(opts = {}) {
|
|
371
|
+
const limit = (opts.olderThanDays ?? 7) * DAY_MS;
|
|
372
|
+
let removed = 0;
|
|
373
|
+
for (const id of allRunIds()) {
|
|
374
|
+
const dir = under(runsDir(), id);
|
|
375
|
+
if (existsSync(markerPath(id)))
|
|
376
|
+
continue;
|
|
377
|
+
if (!opts.all && (existsSync(dir) ? ageOf(dir) : Infinity) < limit)
|
|
378
|
+
continue;
|
|
379
|
+
removeRun(id);
|
|
380
|
+
removed++;
|
|
381
|
+
}
|
|
382
|
+
return { removed };
|
|
383
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// THE NAMES A SEALED VALUE MAY HAVE AND MAY BE GIVEN TO. PURE CONSTANTS AND PREDICATES (NO FILES, NO KEYS), SO THE RECIPE VALIDATOR, THE SEALED STORE AND THE
|
|
2
|
+
// ENV RULES SHARE ONE SET. Ported verbatim from the old tool's lib/secrets.mjs.
|
|
3
|
+
// -- CONSTANTS -- //
|
|
4
|
+
/** The setting names a sealed value may be given to (and the names that may be imported): Supabase's own and the app's auth secret. NODE_OPTIONS, PATH and the like can never be one. */
|
|
5
|
+
export const SUPABASE_KEY = /^((NEXT_PUBLIC_|PUBLIC_|VITE_)?SUPABASE_(URL|ANON_KEY|PUBLISHABLE_KEY)|SUPABASE_SERVICE_ROLE_KEY|SUPABASE_SECRET_KEY|ATTENDEE_AUTH_SECRET)$/;
|
|
6
|
+
/** The mail settings: the app's Postmark token, From address and stream. A preview given them can send mail, so it only runs behind the mail guard. */
|
|
7
|
+
export const MAIL_ENV_KEY = /^POSTMARK_(SERVER_TOKEN|FROM_EMAIL|MESSAGE_STREAM)$/;
|
|
8
|
+
export const SECRET_ENV_KEY = new RegExp(`(?:${SUPABASE_KEY.source})|(?:${MAIL_ENV_KEY.source})`);
|
|
9
|
+
/** Names that hold a key which bypasses row-level security, whatever the value looks like. */
|
|
10
|
+
export const SERVICE_NAME = /(SERVICE_ROLE_KEY|SUPABASE_SECRET_KEY)$/;
|
|
11
|
+
/** A setting a browser bundle may carry: a service key never goes into one. */
|
|
12
|
+
export const CLIENT_NAME = /^(NEXT_PUBLIC_|PUBLIC_|VITE_)/;
|
|
13
|
+
/** In a recipe: {secret:NAME}. */
|
|
14
|
+
export const SECRET_PLACEHOLDER = /^\{secret:([A-Z0-9_]{2,60})\}$/;
|
|
15
|
+
export const URL_KEY = /SUPABASE_URL$/;
|
|
16
|
+
export const EMAIL = /^[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,200}\.[A-Za-z]{2,24}$/;
|
|
17
|
+
/** The whole mail set by name: a variable of any of these providers is a mail setting. SES must be a whole word (SESSION_SECRET is not mail). */
|
|
18
|
+
export const MAIL_SET_NAME = /(MAIL|SMTP|POSTMARK|SENDGRID|MAILGUN|RESEND)|(^|_)SES(_|$)/i;
|
|
19
|
+
// -- FUNCTIONS -- //
|
|
20
|
+
export const isMailKey = (name) => MAIL_ENV_KEY.test(String(name));
|
|
21
|
+
/** Every plain sentence about how a recipe env setting `key` uses the placeholder `value`, or an empty list. Names only, never the value. */
|
|
22
|
+
export function placeholderProblems(key, value) {
|
|
23
|
+
const m = SECRET_PLACEHOLDER.exec(value);
|
|
24
|
+
if (m && m[1] !== undefined && SERVICE_NAME.test(m[1]) && CLIENT_NAME.test(key))
|
|
25
|
+
return [`The recipe gives ${key}, which a browser bundle carries, the service key ${m[1]}.`];
|
|
26
|
+
if (m &&
|
|
27
|
+
m[1] !== undefined &&
|
|
28
|
+
SECRET_ENV_KEY.test(m[1]) &&
|
|
29
|
+
SECRET_ENV_KEY.test(key) &&
|
|
30
|
+
(isMailKey(key) !== isMailKey(m[1]) || (isMailKey(key) && key !== m[1])))
|
|
31
|
+
return [
|
|
32
|
+
`The recipe gives ${key} the sealed value ${m[1]}: a mail setting takes only the sealed value of its own name (another kind of value is never swapped in).`
|
|
33
|
+
];
|
|
34
|
+
if (!m || m[1] === undefined || !SECRET_ENV_KEY.test(m[1]) || !SECRET_ENV_KEY.test(key))
|
|
35
|
+
return [
|
|
36
|
+
`The recipe gives ${key} a sealed value that is not written as {secret:NAME}, or names a key that only a Supabase setting or a mail setting (POSTMARK_SERVER_TOKEN, POSTMARK_FROM_EMAIL, POSTMARK_MESSAGE_STREAM) may take.`
|
|
37
|
+
];
|
|
38
|
+
return [];
|
|
39
|
+
}
|