@agent-flow-board/bridge 0.2.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 +16 -0
- package/dist/checks/appdir.js +105 -0
- package/dist/checks/approve.js +213 -0
- package/dist/checks/audit.js +3 -2
- package/dist/checks/bind.js +3 -2
- package/dist/checks/broker.js +439 -0
- package/dist/checks/browser.js +137 -32
- package/dist/checks/browserpool.js +221 -0
- package/dist/checks/cache.js +710 -0
- package/dist/checks/command.js +75 -88
- package/dist/checks/commands.js +219 -0
- package/dist/checks/connections.js +4 -3
- 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 +50 -1
- package/dist/checks/envrules.js +322 -0
- package/dist/checks/evidence.js +114 -0
- package/dist/checks/fsretry.js +29 -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 +28 -4
- 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 +24 -0
- package/dist/checks/preview.js +418 -191
- package/dist/checks/profiles.js +110 -0
- package/dist/checks/provision.js +1 -1
- package/dist/checks/recipe.js +19 -5
- 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 +243 -53
- package/dist/checks/sandbox.js +32 -10
- package/dist/checks/scheduler.js +383 -0
- package/dist/checks/scope.js +119 -30
- 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 +7 -1
- package/dist/checks/singleflight.js +307 -0
- package/dist/checks/store.js +92 -0
- package/dist/checks/surface.js +124 -0
- package/dist/checks/warm.js +342 -0
- package/dist/checks/watch.js +190 -63
- package/dist/checks/wire.js +87 -0
- package/package.json +1 -1
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
// SEALED SECRETS OF ONE REGISTRATION. A preview never inherits the repo's .env on its own. The one way a few values get in is an explicit import: the TOOL (not the agent)
|
|
2
|
+
// reads the named keys from an env file inside the registration's repo, once, with the owner's single-use approval code, and seals them for that registration in the
|
|
3
|
+
// state folder. A recipe names a key as {secret:NAME}; the value is filled in when the preview starts. Every message here names keys, never values.
|
|
4
|
+
//
|
|
5
|
+
// WHAT THIS DOES AND DOES NOT PROTECT. The preview runs the repo's own code with these values in its environment, and that code is written by the agent, so it can read
|
|
6
|
+
// them. The protection is against accidents: an import is limited to Supabase-style and mail keys from a test env file, the project ref has to match the URL and the
|
|
7
|
+
// keys, a service key needs a flag, a registration cannot open another's store (it is bound into the seal), nothing prints a value, and the audit chain records what was
|
|
8
|
+
// imported and that it was approved with a code.
|
|
9
|
+
// -- DEP-MODULES -- //
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { closeSync, fstatSync, lstatSync, openSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { basename, join, resolve } from 'node:path';
|
|
13
|
+
// -- MODULES -- //
|
|
14
|
+
import { consumeApproval, peekApproval } from './approve.js';
|
|
15
|
+
import { appendAudit } from './audit.js';
|
|
16
|
+
import { withLockSync } from './lock.js';
|
|
17
|
+
import { ORG_SLUG, ensureDir, isInside, stateDirFor } from './paths.js';
|
|
18
|
+
import { renameRetry } from './fsretry.js';
|
|
19
|
+
import { CLIENT_NAME, EMAIL, SECRET_ENV_KEY, SECRET_PLACEHOLDER, SERVICE_NAME, URL_KEY, isMailKey } from './sealedNames.js';
|
|
20
|
+
import { seal, unseal } from './store.js';
|
|
21
|
+
// -- CONSTANTS -- //
|
|
22
|
+
const NAME_RE = /^[A-Za-z0-9_-]{1,32}$/;
|
|
23
|
+
const ENV_FILES = new Set(['.env', '.env.local', '.env.test']);
|
|
24
|
+
const TEST_REF = /^[a-z0-9]{6,40}$/;
|
|
25
|
+
const STREAM = /^[A-Za-z0-9_-]{1,40}$/;
|
|
26
|
+
const MAX_KEYS = 12;
|
|
27
|
+
const MAIL_NAMES = 'POSTMARK_SERVER_TOKEN, POSTMARK_FROM_EMAIL and POSTMARK_MESSAGE_STREAM';
|
|
28
|
+
/** The action word of the owner's approval code for an import. */
|
|
29
|
+
export const SECRETS_ACTION = 'secrets';
|
|
30
|
+
// -- FUNCTIONS -- //
|
|
31
|
+
const refOf = (org, name) => {
|
|
32
|
+
if (!ORG_SLUG.test(org))
|
|
33
|
+
throw new Error('The organisation is not a slug.');
|
|
34
|
+
if (!NAME_RE.test(name))
|
|
35
|
+
throw new Error('The registration name is not a plain name.');
|
|
36
|
+
return `${org}/${name}`;
|
|
37
|
+
};
|
|
38
|
+
const fileOf = (org, name) => join(stateDirFor(org, name), 'secrets.sealed');
|
|
39
|
+
const lockOf = (ref) => `secrets-${createHash('sha256').update(ref.toLowerCase()).digest('hex').slice(0, 20)}`;
|
|
40
|
+
const helpOf = (ref) => `agent-flow-board check secrets import --project ${ref.split('/')[1] ?? ''} --org ${ref.split('/')[0] ?? ''} --from <env file in the repo> --keys <NAME,NAME> --test-ref <supabase project ref> --approval <code from check approve --action secrets --target ${ref}> (mail settings: --keys POSTMARK_SERVER_TOKEN,POSTMARK_FROM_EMAIL,POSTMARK_MESSAGE_STREAM --test-inbox <the one inbox a preview may mail> --owner-said "<says the token is a test token>")`;
|
|
41
|
+
/** NAME=value lines of an env file. `export ` and one pair of quotes are allowed; a value that opens a quote it does not close on the same line is refused (names the variable). */
|
|
42
|
+
export function parseImportEnv(text) {
|
|
43
|
+
const out = new Map();
|
|
44
|
+
for (const raw of String(text).split(/\r?\n/)) {
|
|
45
|
+
const line = raw.trim();
|
|
46
|
+
if (!line || line.startsWith('#'))
|
|
47
|
+
continue;
|
|
48
|
+
const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
49
|
+
if (!m || m[1] === undefined)
|
|
50
|
+
continue;
|
|
51
|
+
let v = (m[2] ?? '').trim();
|
|
52
|
+
if (v.startsWith('"') || v.startsWith("'") || v.startsWith('`')) {
|
|
53
|
+
const q = v[0];
|
|
54
|
+
const end = v.indexOf(q, 1);
|
|
55
|
+
if (end < 0)
|
|
56
|
+
throw new Error(`${m[1]} has a value over several lines, which an import does not read. Put it on one line.`);
|
|
57
|
+
v = v.slice(1, end);
|
|
58
|
+
}
|
|
59
|
+
else
|
|
60
|
+
v = v.replace(/\s+#.*$/, '');
|
|
61
|
+
out.set(m[1], v);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
function jwtPayload(v) {
|
|
66
|
+
const parts = String(v).split('.');
|
|
67
|
+
if (parts.length !== 3 || !/^eyJ/.test(parts[0] ?? ''))
|
|
68
|
+
return null;
|
|
69
|
+
try {
|
|
70
|
+
const body = JSON.parse(Buffer.from((parts[1] ?? '').replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
|
|
71
|
+
return body && typeof body === 'object' ? body : null;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** A key that bypasses row-level security: a JWT with role service_role, a new-style secret key, or a service key by its name. */
|
|
78
|
+
const isServiceKey = (v, name = '') => SERVICE_NAME.test(name) ||
|
|
79
|
+
jwtPayload(v)?.role === 'service_role' ||
|
|
80
|
+
String(v).startsWith('sb_secret_');
|
|
81
|
+
const hostFor = (testRef) => `${testRef}.supabase.co`;
|
|
82
|
+
// -- THE STORE -- //
|
|
83
|
+
function readStore(org, name) {
|
|
84
|
+
const ref = refOf(org, name);
|
|
85
|
+
let blob;
|
|
86
|
+
try {
|
|
87
|
+
blob = readFileSync(fileOf(org, name));
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
if (e.code === 'ENOENT')
|
|
91
|
+
return null;
|
|
92
|
+
throw e;
|
|
93
|
+
}
|
|
94
|
+
let doc;
|
|
95
|
+
try {
|
|
96
|
+
doc = JSON.parse(Buffer.from(unseal(ref, blob)).toString('utf8'));
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
throw new Error(`The sealed secrets of ${ref} are damaged or were made for another registration. Import them again: ${helpOf(ref)}`);
|
|
100
|
+
}
|
|
101
|
+
if (doc?.ref !== ref || typeof doc.values !== 'object' || doc.values === null)
|
|
102
|
+
throw new Error(`The sealed secrets of ${ref} are damaged. Import them again: ${helpOf(ref)}`);
|
|
103
|
+
return doc;
|
|
104
|
+
}
|
|
105
|
+
const readStoreSafe = (org, name) => {
|
|
106
|
+
try {
|
|
107
|
+
return readStore(org, name);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
/** Written to a file beside the store and moved into place, so a crash leaves the old store whole. Callers hold the lock. */
|
|
114
|
+
function writeStore(org, name, doc) {
|
|
115
|
+
ensureDir(stateDirFor(org, name));
|
|
116
|
+
const f = fileOf(org, name);
|
|
117
|
+
const tmp = `${f}.tmp-${process.pid}-${Date.now()}`;
|
|
118
|
+
try {
|
|
119
|
+
writeFileSync(tmp, seal(doc.ref, Buffer.from(JSON.stringify(doc), 'utf8')), { mode: 0o600 });
|
|
120
|
+
renameRetry(tmp, f);
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
rmSync(tmp, { force: true });
|
|
124
|
+
throw e;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// -- IMPORT, LIST, FORGET -- //
|
|
128
|
+
/**
|
|
129
|
+
* Reads the named keys from an env file and seals them for the registration. Throws one plain sentence naming a key or a rule, never a value.
|
|
130
|
+
* The owner's approval code (action 'secrets', target org/NAME) must be valid before the file is read, and it is used last, after every rule and every value has been
|
|
131
|
+
* checked, so a refused import never burns the code.
|
|
132
|
+
*/
|
|
133
|
+
export function importSecrets(input) {
|
|
134
|
+
const ref = refOf(input.org, input.name);
|
|
135
|
+
const action = input.action ?? 'secrets-import';
|
|
136
|
+
// -- THE ASKED-FOR KEYS -- //
|
|
137
|
+
if (!input.repo)
|
|
138
|
+
throw new Error(`${ref} has no repo folder, so there is no env file to import from.`);
|
|
139
|
+
if (!input.file)
|
|
140
|
+
throw new Error('Say which env file: --from <file in the repo>.');
|
|
141
|
+
const names = [...new Set((input.keys ?? []).map((k) => k.trim()).filter(Boolean))];
|
|
142
|
+
if (!names.length)
|
|
143
|
+
throw new Error('Name the keys to import: --keys NAME,NAME.');
|
|
144
|
+
if (names.length > MAX_KEYS)
|
|
145
|
+
throw new Error(`Import at most ${MAX_KEYS} keys.`);
|
|
146
|
+
const bad = names.filter((k) => !SECRET_ENV_KEY.test(k));
|
|
147
|
+
if (bad.length)
|
|
148
|
+
throw new Error(`${bad.join(', ')} cannot be imported (only Supabase settings such as PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY and the app's auth secret, and the mail settings ${MAIL_NAMES}).`);
|
|
149
|
+
const mailNames = names.filter(isMailKey);
|
|
150
|
+
const supaNames = names.filter((k) => !isMailKey(k));
|
|
151
|
+
const testRef = input.testRef ?? '';
|
|
152
|
+
if (supaNames.length && !TEST_REF.test(testRef))
|
|
153
|
+
throw new Error('Say which Supabase project the keys belong to: --test-ref <the project ref, lower case letters and digits>.');
|
|
154
|
+
if (mailNames.length) {
|
|
155
|
+
if (!EMAIL.test(String(input.testInbox ?? '')))
|
|
156
|
+
throw new Error('Mail settings need the one inbox a preview may send to: --test-inbox <address>.');
|
|
157
|
+
const said = String(input.ownerSaid ?? '');
|
|
158
|
+
if (said.trim().length < 10 || !/\btest\b/i.test(said))
|
|
159
|
+
throw new Error("Mail settings need the owner's word that the token is a test token: --owner-said '<sentence of 10 or more characters that says this is a test token>'.");
|
|
160
|
+
}
|
|
161
|
+
// -- THE FILE, BEFORE IT IS READ -- //
|
|
162
|
+
const resolved = resolve(input.file);
|
|
163
|
+
const fileName = basename(resolved);
|
|
164
|
+
if (!ENV_FILES.has(fileName))
|
|
165
|
+
throw new Error(`${fileName} is not a file an import reads (use .env, .env.local or .env.test; never .env.production).`);
|
|
166
|
+
let st;
|
|
167
|
+
try {
|
|
168
|
+
st = lstatSync(resolved, { bigint: true });
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
throw new Error(`${fileName} does not exist there.`);
|
|
172
|
+
}
|
|
173
|
+
if (st.isSymbolicLink() || !st.isFile())
|
|
174
|
+
throw new Error(`${fileName} is a link or not a file, which an import does not read.`);
|
|
175
|
+
let realFile;
|
|
176
|
+
try {
|
|
177
|
+
realFile = realpathSync.native(resolved);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
throw new Error(`${fileName} does not exist there.`);
|
|
181
|
+
}
|
|
182
|
+
if (!isInside(realpathSync.native(resolve(input.repo)), realFile))
|
|
183
|
+
throw new Error(`${fileName} is not inside the repo folder of ${ref}.`);
|
|
184
|
+
// -- THE OWNER'S APPROVAL -- //
|
|
185
|
+
if (!input.approval || !peekApproval(SECRETS_ACTION, ref, input.approval))
|
|
186
|
+
throw new Error(`The owner has not approved this import. Run \`agent-flow-board check approve --action secrets --target ${ref}\` in a terminal and pass the code with --approval.`);
|
|
187
|
+
// -- THE READ: ONE HANDLE FOR THE CHECKS AND THE READ, A FILE WITH A SECOND NAME REFUSED -- //
|
|
188
|
+
let text;
|
|
189
|
+
const fd = openSync(resolved, 'r');
|
|
190
|
+
try {
|
|
191
|
+
const fst = fstatSync(fd, { bigint: true });
|
|
192
|
+
if (!fst.isFile() || fst.nlink > 1n || fst.ino !== st.ino || fst.dev !== st.dev)
|
|
193
|
+
throw new Error(`${fileName} changed while it was being read, or has a second name (a hard link), so it is not read.`);
|
|
194
|
+
text = readFileSync(fd, 'utf8');
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
closeSync(fd);
|
|
198
|
+
}
|
|
199
|
+
const found = parseImportEnv(text);
|
|
200
|
+
const missing = names.filter((k) => !found.get(k));
|
|
201
|
+
if (missing.length)
|
|
202
|
+
throw new Error(`${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} not set in ${fileName}.`);
|
|
203
|
+
// -- THE PROJECT REF: THE URL AND EVERY SIGNED KEY BELONG TO IT -- //
|
|
204
|
+
const entries = {};
|
|
205
|
+
const service = [];
|
|
206
|
+
const urls = names.filter((k) => URL_KEY.test(k));
|
|
207
|
+
if (supaNames.length && !urls.length)
|
|
208
|
+
throw new Error('Import the Supabase URL too (a key ending in SUPABASE_URL), so the project ref can be checked against it.');
|
|
209
|
+
for (const k of urls) {
|
|
210
|
+
let host;
|
|
211
|
+
try {
|
|
212
|
+
const u = new URL(found.get(k));
|
|
213
|
+
if (u.protocol !== 'https:')
|
|
214
|
+
throw new Error('not https');
|
|
215
|
+
host = u.hostname;
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
throw new Error(`${k} is not an https address.`);
|
|
219
|
+
}
|
|
220
|
+
if (host !== hostFor(testRef))
|
|
221
|
+
throw new Error(`${k} does not belong to the project ref you gave (--test-ref). Nothing was imported.`);
|
|
222
|
+
}
|
|
223
|
+
for (const k of names) {
|
|
224
|
+
const v = found.get(k);
|
|
225
|
+
if (isMailKey(k)) {
|
|
226
|
+
const ok = k === 'POSTMARK_FROM_EMAIL'
|
|
227
|
+
? EMAIL.test(v)
|
|
228
|
+
: k === 'POSTMARK_MESSAGE_STREAM'
|
|
229
|
+
? STREAM.test(v)
|
|
230
|
+
: v.length >= 8 && !/\s/.test(v);
|
|
231
|
+
if (!ok)
|
|
232
|
+
throw new Error(`${k} in ${fileName} does not look like a ${k === 'POSTMARK_FROM_EMAIL' ? 'single email address' : k === 'POSTMARK_MESSAGE_STREAM' ? 'stream name' : 'token'}. Nothing was imported.`);
|
|
233
|
+
entries[k] = { v, service: false, mail: true };
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const claims = jwtPayload(v);
|
|
237
|
+
if (claims?.ref !== undefined && claims.ref !== testRef)
|
|
238
|
+
throw new Error(`${k} is signed for another Supabase project than --test-ref. Nothing was imported.`);
|
|
239
|
+
const svc = isServiceKey(v, k);
|
|
240
|
+
if (svc && !input.allowServiceRole)
|
|
241
|
+
throw new Error(`${k} is a service key, which bypasses row-level security and is not imported by default. Import the anon key; add --allow-service-role only when the app cannot start without it.`);
|
|
242
|
+
if (svc)
|
|
243
|
+
service.push(k);
|
|
244
|
+
entries[k] = { v, service: svc };
|
|
245
|
+
}
|
|
246
|
+
// -- THE CODE IS USED LAST -- //
|
|
247
|
+
if (!consumeApproval(SECRETS_ACTION, ref, input.approval ?? ''))
|
|
248
|
+
throw new Error('The owner code was used or expired just now, so nothing was imported. The code is spent: ask the owner for a new one.');
|
|
249
|
+
// -- THE WRITE, UNDER A LOCK -- //
|
|
250
|
+
const statement = String(input.ownerSaid ?? '').slice(0, 300);
|
|
251
|
+
withLockSync(lockOf(ref), () => {
|
|
252
|
+
const before = readStoreSafe(input.org, input.name);
|
|
253
|
+
// Values of an earlier import stay, unless they belong to another Supabase project (mail values do not depend on one)
|
|
254
|
+
const sameProject = !before || !supaNames.length || !before.testRef || before.testRef === testRef;
|
|
255
|
+
const keep = before
|
|
256
|
+
? Object.fromEntries(Object.entries(before.values).filter(([, e]) => sameProject || e.mail))
|
|
257
|
+
: {};
|
|
258
|
+
const inbox = mailNames.length ? (input.testInbox ?? null) : (before?.testInbox ?? null);
|
|
259
|
+
// Each import remembers its own env file and keys, so a refresh reads every key from the file it came from
|
|
260
|
+
const groups = [
|
|
261
|
+
...(before?.source.groups ?? [])
|
|
262
|
+
.map((g) => ({ ...g, keys: g.keys.filter((k) => !names.includes(k)) }))
|
|
263
|
+
.filter((g) => g.keys.length),
|
|
264
|
+
{ file: realFile, keys: names, statement }
|
|
265
|
+
];
|
|
266
|
+
// The chain gets its line first: a store never exists without one. It names keys and the test inbox, never a value
|
|
267
|
+
appendAudit(action, {
|
|
268
|
+
project: ref,
|
|
269
|
+
keys: names.join(','),
|
|
270
|
+
serviceKeys: service.join(','),
|
|
271
|
+
testRef: supaNames.length ? testRef : '',
|
|
272
|
+
testInbox: mailNames.length ? (input.testInbox ?? '') : '',
|
|
273
|
+
file: fileName,
|
|
274
|
+
approval: 'approved with code',
|
|
275
|
+
ownerSaid: statement.slice(0, 150)
|
|
276
|
+
});
|
|
277
|
+
writeStore(input.org, input.name, {
|
|
278
|
+
version: 1,
|
|
279
|
+
ref,
|
|
280
|
+
testRef: supaNames.length ? testRef : (before?.testRef ?? null),
|
|
281
|
+
testInbox: inbox,
|
|
282
|
+
importedAt: new Date().toISOString(),
|
|
283
|
+
statement,
|
|
284
|
+
source: {
|
|
285
|
+
groups,
|
|
286
|
+
allowServiceRole: Boolean(input.allowServiceRole || before?.source.allowServiceRole)
|
|
287
|
+
},
|
|
288
|
+
values: { ...keep, ...entries }
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
return {
|
|
292
|
+
keys: names,
|
|
293
|
+
serviceKeys: service,
|
|
294
|
+
testRef: supaNames.length ? testRef : null,
|
|
295
|
+
testInbox: mailNames.length ? (input.testInbox ?? null) : null
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
/** The names (never the values) kept for a registration, and which of them is a service key. */
|
|
299
|
+
export function listSecrets(org, name) {
|
|
300
|
+
const doc = readStore(org, name);
|
|
301
|
+
if (!doc)
|
|
302
|
+
return { keys: [], serviceKeys: [], testRef: null, testInbox: null };
|
|
303
|
+
return {
|
|
304
|
+
keys: Object.keys(doc.values).sort(),
|
|
305
|
+
serviceKeys: Object.entries(doc.values)
|
|
306
|
+
.filter(([, e]) => e.service)
|
|
307
|
+
.map(([k]) => k)
|
|
308
|
+
.sort(),
|
|
309
|
+
testRef: doc.testRef,
|
|
310
|
+
testInbox: doc.testInbox,
|
|
311
|
+
importedAt: doc.importedAt
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
/** Removes the sealed secrets of a registration. Returns true when there were any. */
|
|
315
|
+
export function forgetSecrets(org, name) {
|
|
316
|
+
const ref = refOf(org, name);
|
|
317
|
+
let had = false;
|
|
318
|
+
withLockSync(lockOf(ref), () => {
|
|
319
|
+
try {
|
|
320
|
+
readFileSync(fileOf(org, name));
|
|
321
|
+
had = true;
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
had = false;
|
|
325
|
+
}
|
|
326
|
+
rmSync(fileOf(org, name), { force: true });
|
|
327
|
+
});
|
|
328
|
+
appendAudit('secrets-forget', { project: ref, had });
|
|
329
|
+
return had;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Reads the same env file and keys again (the owner's sentence, test project and test inbox stay as they were), so a changed .env needs one command and no value
|
|
333
|
+
* is typed. A refresh needs a fresh approval code like any import, so an agent cannot trigger it alone. A store made from more than one env file is refreshed
|
|
334
|
+
* by importing each again.
|
|
335
|
+
*/
|
|
336
|
+
export function refreshSecrets(opts) {
|
|
337
|
+
const doc = readStore(opts.org, opts.name);
|
|
338
|
+
const ref = refOf(opts.org, opts.name);
|
|
339
|
+
const group = doc?.source.groups[0];
|
|
340
|
+
if (!doc || !group)
|
|
341
|
+
throw new Error(`Nothing was imported for ${ref} that can be read again. Import it: ${helpOf(ref)}`);
|
|
342
|
+
if (doc.source.groups.length > 1)
|
|
343
|
+
throw new Error(`${ref} was imported from more than one env file. Refresh by importing each again with its own approval code.`);
|
|
344
|
+
const r = importSecrets({
|
|
345
|
+
org: opts.org,
|
|
346
|
+
name: opts.name,
|
|
347
|
+
repo: opts.repo,
|
|
348
|
+
file: group.file,
|
|
349
|
+
keys: group.keys,
|
|
350
|
+
testRef: doc.testRef ?? undefined,
|
|
351
|
+
testInbox: doc.testInbox ?? undefined,
|
|
352
|
+
ownerSaid: group.statement || doc.statement,
|
|
353
|
+
allowServiceRole: doc.source.allowServiceRole,
|
|
354
|
+
approval: opts.approval,
|
|
355
|
+
action: 'secrets-refresh'
|
|
356
|
+
});
|
|
357
|
+
return { keys: r.keys };
|
|
358
|
+
}
|
|
359
|
+
/** The one inbox a preview of this registration may send mail to, or null when it has no sealed mail settings. */
|
|
360
|
+
export function mailInboxOf(org, name) {
|
|
361
|
+
const doc = readStoreSafe(org, name);
|
|
362
|
+
return doc && Object.values(doc.values).some((e) => e.mail) ? (doc.testInbox ?? null) : null;
|
|
363
|
+
}
|
|
364
|
+
/** Plain sentences for doctor about a store that is damaged or unusable. Never puts a value into the text. */
|
|
365
|
+
export function storeProblems(org, name) {
|
|
366
|
+
const ref = refOf(org, name);
|
|
367
|
+
try {
|
|
368
|
+
const doc = readStore(org, name);
|
|
369
|
+
if (doc &&
|
|
370
|
+
Object.values(doc.values).some((e) => e.mail) &&
|
|
371
|
+
!EMAIL.test(String(doc.testInbox ?? '')))
|
|
372
|
+
return [
|
|
373
|
+
`The sealed mail settings of ${ref} have no test inbox, so a preview would not be given them.`
|
|
374
|
+
];
|
|
375
|
+
return [];
|
|
376
|
+
}
|
|
377
|
+
catch (e) {
|
|
378
|
+
return [e.message];
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
// -- USING THEM -- //
|
|
382
|
+
/** The setting names that keep a service key out of the build: a key that bypasses row-level security reaches only the start step. */
|
|
383
|
+
export const omitKeys = (env, keys = []) => Object.fromEntries(Object.entries(env).filter(([k]) => !keys.includes(k)));
|
|
384
|
+
/** Every form a value could show up in a log: as it is, address-encoded and base64 (padded and not). */
|
|
385
|
+
function forms(v) {
|
|
386
|
+
const b64 = Buffer.from(v, 'utf8').toString('base64');
|
|
387
|
+
return [...new Set([v, encodeURIComponent(v), b64, b64.replace(/=+$/, '')])].filter((x) => x.length >= 4);
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Fills {secret:NAME} in a recipe's env from the sealed store of the registration. The same env comes back when there is no placeholder.
|
|
391
|
+
* A name that is not imported is a plain error that tells the agent the command to run. The sealed URL must still belong to the ref it was imported for.
|
|
392
|
+
*/
|
|
393
|
+
export function resolveSealedEnv(org, name, env) {
|
|
394
|
+
const wanted = Object.entries(env).filter(([, v]) => typeof v === 'string' && SECRET_PLACEHOLDER.test(v));
|
|
395
|
+
if (!wanted.length)
|
|
396
|
+
return {
|
|
397
|
+
env,
|
|
398
|
+
startOnly: [],
|
|
399
|
+
hosts: [],
|
|
400
|
+
redactions: [],
|
|
401
|
+
testRef: null,
|
|
402
|
+
filled: [],
|
|
403
|
+
mailInbox: null
|
|
404
|
+
};
|
|
405
|
+
const ref = refOf(org, name);
|
|
406
|
+
const doc = readStore(org, name);
|
|
407
|
+
if (!doc)
|
|
408
|
+
throw new Error(`Missing: the recipe of ${ref} names sealed keys, but none are imported (invalid environment variables for this run). Import them: ${helpOf(ref)}`);
|
|
409
|
+
const out = { ...env };
|
|
410
|
+
const startOnly = [];
|
|
411
|
+
const redactions = [];
|
|
412
|
+
for (const [k, v] of wanted) {
|
|
413
|
+
const secretName = SECRET_PLACEHOLDER.exec(v)[1];
|
|
414
|
+
const entry = doc.values[secretName];
|
|
415
|
+
if (!entry)
|
|
416
|
+
throw new Error(`Missing: the recipe of ${ref} names the sealed key ${secretName}, but it is not imported (invalid environment variables for this run). Import it: ${helpOf(ref)}`);
|
|
417
|
+
if (!SECRET_ENV_KEY.test(k))
|
|
418
|
+
throw new Error(`${k} may not be given a sealed value.`);
|
|
419
|
+
// A mail value goes to a mail setting and a Supabase value to a Supabase setting, never across
|
|
420
|
+
if (isMailKey(k) !== isMailKey(secretName) || (isMailKey(k) && k !== secretName))
|
|
421
|
+
throw new Error(`${k} may not be given the sealed value ${secretName}: a mail setting takes only the sealed value of its own name.`);
|
|
422
|
+
if (entry.service && CLIENT_NAME.test(k))
|
|
423
|
+
throw new Error(`${k} is a setting the browser bundle carries, so a service key may not be given to it.`);
|
|
424
|
+
out[k] = entry.v;
|
|
425
|
+
// A key that bypasses row-level security, and every mail setting, reaches the start step only
|
|
426
|
+
if (entry.service || isMailKey(k))
|
|
427
|
+
startOnly.push(k);
|
|
428
|
+
redactions.push(...forms(entry.v));
|
|
429
|
+
}
|
|
430
|
+
// The URL in the store has to be the project's own address, still: a store that was edited or made for another ref is not used
|
|
431
|
+
let host = null;
|
|
432
|
+
for (const [key, entry] of Object.entries(doc.values))
|
|
433
|
+
if (URL_KEY.test(key)) {
|
|
434
|
+
const h = new URL(entry.v).hostname;
|
|
435
|
+
if (!doc.testRef || h !== hostFor(doc.testRef))
|
|
436
|
+
throw new Error(`The sealed ${key} of ${ref} does not belong to its project ref any more. Import the secrets again: ${helpOf(ref)}`);
|
|
437
|
+
host = h;
|
|
438
|
+
}
|
|
439
|
+
// Mail settings reach the preview only with the inbox the guard sends to; without one the run stops
|
|
440
|
+
const mail = wanted.some(([k]) => isMailKey(k));
|
|
441
|
+
if (mail && !EMAIL.test(String(doc.testInbox ?? '')))
|
|
442
|
+
throw new Error(`The sealed mail settings of ${ref} have no test inbox. Import them again: ${helpOf(ref)}`);
|
|
443
|
+
return {
|
|
444
|
+
env: out,
|
|
445
|
+
startOnly,
|
|
446
|
+
hosts: host ? [host] : [],
|
|
447
|
+
redactions: [...new Set(redactions)],
|
|
448
|
+
testRef: doc.testRef,
|
|
449
|
+
filled: wanted.map(([k]) => k),
|
|
450
|
+
mailInbox: mail ? doc.testInbox : null
|
|
451
|
+
};
|
|
452
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// `check secrets import|list|forget`: THE COMMAND SIDE OF THE SEALED STORE (secrets.ts holds every rule)
|
|
2
|
+
// Import needs the owner's approval code (check approve --action secrets --target <org>/<NAME>): an agent cannot mint one. Names only are ever printed.
|
|
3
|
+
// -- MODULES -- //
|
|
4
|
+
import { findRegistration } from './registry.js';
|
|
5
|
+
import { forgetSecrets, importSecrets, listSecrets } from './secrets.js';
|
|
6
|
+
// -- CONSTANTS -- //
|
|
7
|
+
export const SECRETS_USAGE = [
|
|
8
|
+
'Usage: agent-flow-board check secrets <command> --org <slug> --project <NAME>',
|
|
9
|
+
' import --from <file> --keys A,B [--test-ref <ref>] [--test-inbox <email>] [--owner-said "<sentence>"] [--allow-service-role] --approval <code>',
|
|
10
|
+
' list names of the sealed values, never a value',
|
|
11
|
+
' forget removes every sealed value of the registration'
|
|
12
|
+
].join('\n');
|
|
13
|
+
// -- FUNCTIONS -- //
|
|
14
|
+
export async function secretsCommand(args, ctx) {
|
|
15
|
+
const flag = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);
|
|
16
|
+
const has = (name) => args.includes(name);
|
|
17
|
+
const show = (value, text) => ctx.json ? ctx.out(JSON.stringify(value)) : ctx.log(text);
|
|
18
|
+
try {
|
|
19
|
+
const sub = args[0];
|
|
20
|
+
const org = flag('--org');
|
|
21
|
+
const name = flag('--project');
|
|
22
|
+
if (!sub || !org || !name) {
|
|
23
|
+
ctx.log(SECRETS_USAGE);
|
|
24
|
+
return 2;
|
|
25
|
+
}
|
|
26
|
+
const reg = findRegistration(org, name);
|
|
27
|
+
if (!reg)
|
|
28
|
+
throw new Error(`There is no registration ${name} in ${org}. Register it first: check project ${name} --org ${org} --repo <dir>`);
|
|
29
|
+
if (sub === 'list') {
|
|
30
|
+
const listed = listSecrets(org, name);
|
|
31
|
+
show({ ok: true, ...listed }, listed.keys.length
|
|
32
|
+
? `Sealed values: ${listed.keys.join(', ')}${listed.testRef ? ` (test project ${listed.testRef})` : ''}`
|
|
33
|
+
: 'Nothing is sealed for this registration.');
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
if (sub === 'forget') {
|
|
37
|
+
forgetSecrets(org, name);
|
|
38
|
+
show({ ok: true }, `Forgot the sealed values of ${org}/${name}.`);
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
if (sub === 'import') {
|
|
42
|
+
if (!reg.repo)
|
|
43
|
+
throw new Error(`${name} is a live site: it has no repo to import keys from.`);
|
|
44
|
+
const file = flag('--from');
|
|
45
|
+
const keys = flag('--keys');
|
|
46
|
+
const approval = flag('--approval');
|
|
47
|
+
if (!file || !keys)
|
|
48
|
+
throw new Error('Say --from <file> and --keys A,B.');
|
|
49
|
+
if (!approval)
|
|
50
|
+
throw new Error(`Secrets are imported with the owner's approval code. The owner runs, in a terminal: agent-flow-board check approve --action secrets --target ${org}/${name}, then you pass --approval <code>.`);
|
|
51
|
+
const done = importSecrets({
|
|
52
|
+
org,
|
|
53
|
+
name,
|
|
54
|
+
repo: reg.repo,
|
|
55
|
+
file,
|
|
56
|
+
keys: keys
|
|
57
|
+
.split(',')
|
|
58
|
+
.map((k) => k.trim())
|
|
59
|
+
.filter(Boolean),
|
|
60
|
+
testRef: flag('--test-ref'),
|
|
61
|
+
testInbox: flag('--test-inbox'),
|
|
62
|
+
ownerSaid: flag('--owner-said'),
|
|
63
|
+
allowServiceRole: has('--allow-service-role'),
|
|
64
|
+
approval
|
|
65
|
+
});
|
|
66
|
+
show({ ok: true, ...done }, `Sealed ${done.keys.length} value(s): ${done.keys.join(', ')}.`);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
ctx.log(SECRETS_USAGE);
|
|
70
|
+
return 2;
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
74
|
+
if (ctx.json)
|
|
75
|
+
ctx.out(JSON.stringify({ ok: false, event: 'error', error: message }));
|
|
76
|
+
else
|
|
77
|
+
ctx.log(message);
|
|
78
|
+
return 2;
|
|
79
|
+
}
|
|
80
|
+
}
|