@foldrun/core 8.0.1 → 9.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.
- package/dist/src/approvals.d.ts +18 -10
- package/dist/src/approvals.js +51 -1
- package/dist/src/catalog.d.ts +14 -0
- package/dist/src/catalog.js +28 -0
- package/dist/src/cron.d.ts +9 -0
- package/dist/src/cron.js +20 -0
- package/dist/src/frontmatter-fields.js +1 -1
- package/dist/src/frontmatter-schema.d.ts +5 -1
- package/dist/src/frontmatter-schema.js +40 -1
- package/dist/src/history.d.ts +5 -0
- package/dist/src/history.js +31 -4
- package/dist/src/hooks.d.ts +2 -0
- package/dist/src/hooks.js +13 -0
- package/dist/src/library.js +4 -0
- package/dist/src/notify.d.ts +21 -4
- package/dist/src/notify.js +105 -22
- package/dist/src/package.d.ts +5 -1
- package/dist/src/package.js +5 -3
- package/dist/src/runner.js +17 -9
- package/dist/src/scan.js +21 -0
- package/dist/src/secrets.d.ts +92 -10
- package/dist/src/secrets.js +258 -28
- package/dist/src/storage.d.ts +76 -4
- package/dist/src/storage.js +219 -22
- package/dist/src/store.d.ts +20 -2
- package/dist/src/store.js +50 -15
- package/dist/src/triggers.js +2 -2
- package/dist/src/webhook.d.ts +19 -3
- package/dist/src/webhook.js +19 -6
- package/package.json +1 -1
- package/src/approvals.ts +55 -1
- package/src/catalog.ts +27 -0
- package/src/cron.ts +19 -0
- package/src/frontmatter-fields.ts +1 -1
- package/src/frontmatter-schema.ts +38 -2
- package/src/history.ts +31 -4
- package/src/hooks.ts +14 -0
- package/src/library.ts +3 -0
- package/src/notify.ts +104 -23
- package/src/package.ts +5 -3
- package/src/runner.ts +17 -8
- package/src/scan.ts +23 -0
- package/src/secrets.ts +283 -34
- package/src/storage.ts +223 -19
- package/src/store.ts +49 -14
- package/src/triggers.ts +2 -2
- package/src/webhook.ts +26 -6
package/src/scan.ts
CHANGED
|
@@ -270,6 +270,29 @@ export const SCAN_RULES: readonly ScanRule[] = [
|
|
|
270
270
|
title: "A required frontmatter field is missing",
|
|
271
271
|
help: "A skill with no description is never loaded: the model chooses skills by their descriptions.",
|
|
272
272
|
},
|
|
273
|
+
{
|
|
274
|
+
// 30 Oct 2026: check and the scheduler read a schedule with the same
|
|
275
|
+
// cronCanFire (core cron.ts), so check says what the scheduler does.
|
|
276
|
+
id: "config/cron-never-fires",
|
|
277
|
+
category: "config",
|
|
278
|
+
level: "warn",
|
|
279
|
+
title: "A schedule that names a day no month has",
|
|
280
|
+
help:
|
|
281
|
+
"`0 0 30 2 *` is a valid cron — every field is in range — but no February has a 30th, so the flow never runs and the " +
|
|
282
|
+
"scheduler never lists a next run for it. Pick a day every named month has, or add a weekday (either then matches).",
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
// 30 Oct 2026: a signing secret is looked up in the vault by name.
|
|
286
|
+
id: "secret/signing-secret-value",
|
|
287
|
+
category: "secret",
|
|
288
|
+
level: "warn",
|
|
289
|
+
title: "A signing_secret: that holds a value, not a secret's name",
|
|
290
|
+
help:
|
|
291
|
+
"A flow's `signing_secret:` and `notify: signing_secret:` name a vault entry (`WEBHOOK_SIGNING_KEY`, or `${WEBHOOK_SIGNING_KEY}`). " +
|
|
292
|
+
"Anything else is looked up as a name and never found — every signed delivery is refused, every notification goes unsigned — " +
|
|
293
|
+
"and the value itself is deployed and versioned in plain text. Put it in the vault (`foldrun secrets set NAME`), write the name, and rotate it.",
|
|
294
|
+
owasp: ["ASI03"],
|
|
295
|
+
},
|
|
273
296
|
{
|
|
274
297
|
id: "check/ignore-needs-reason",
|
|
275
298
|
category: "config",
|
package/src/secrets.ts
CHANGED
|
@@ -111,8 +111,15 @@ interface StoredSecret {
|
|
|
111
111
|
* these sites alone. Metadata, not a value, so it is stored in the clear
|
|
112
112
|
* beside the ciphertext and survives the value being set again. */
|
|
113
113
|
sites?: string[];
|
|
114
|
+
/** An ACCOUNT secret limited to these workspaces (GitHub's "selected
|
|
115
|
+
* repositories" for an organisation secret). Absent: every workspace may
|
|
116
|
+
* use it, as before. Every other workspace is answered as if it were not
|
|
117
|
+
* set (getSecret). Metadata like `sites`: in the clear, and in the seal
|
|
118
|
+
* of a bound record. Never on a workspace's own record. */
|
|
119
|
+
workspaces?: string[];
|
|
114
120
|
/** 2: sealed with the record's place as AAD — account, scope, name, kind,
|
|
115
|
-
* key and
|
|
121
|
+
* key, sites and, when limited, workspaces (vaultAad). Absent: the older,
|
|
122
|
+
* unbound form. */
|
|
116
123
|
v?: 2;
|
|
117
124
|
}
|
|
118
125
|
|
|
@@ -143,15 +150,47 @@ type SecretsFile = Record<string, StoredSecret>;
|
|
|
143
150
|
// every rename a re-seal racing other writers. What that leaves open — a
|
|
144
151
|
// ciphertext moved between two workspaces of the same account by someone who
|
|
145
152
|
// can already write the volume — is the residual, and stays inside one account.
|
|
146
|
-
|
|
153
|
+
//
|
|
154
|
+
// Rolling out (30 Oct 2026, accepted, no switch): a pod from before this
|
|
155
|
+
// release cannot open a limited record (its AAD lacks the list), and one that
|
|
156
|
+
// sets a new value over it writes the record back without the list — open to
|
|
157
|
+
// every workspace again. So limits are set after every pod, worker and the
|
|
158
|
+
// egress proxy run this version; the secrets docs say so.
|
|
159
|
+
//
|
|
160
|
+
// The workspaces an account secret is limited to are in the seal too (30 Oct
|
|
161
|
+
// 2026), appended only when the record has the list: every record sealed
|
|
162
|
+
// before keeps the AAD it was sealed with, and a list added to, widened in or
|
|
163
|
+
// stripped from a sealed record on disk makes it fail to open — "not set",
|
|
164
|
+
// never "every workspace". These ARE workspace names, so a rename re-seals
|
|
165
|
+
// them (retargetSecretWorkspaces), under the vault lock.
|
|
166
|
+
function vaultAad(tenant: string, workspace: string | undefined, name: string, rec: Pick<StoredSecret, "kind" | "k" | "sites" | "workspaces">): Buffer {
|
|
147
167
|
if (rec.sites !== undefined && (!Array.isArray(rec.sites) || rec.sites.some((x) => typeof x !== "string"))) {
|
|
148
168
|
throw new Error("malformed sites");
|
|
149
169
|
}
|
|
170
|
+
if (rec.workspaces !== undefined && (!Array.isArray(rec.workspaces) || rec.workspaces.some((x) => typeof x !== "string"))) {
|
|
171
|
+
throw new Error("malformed workspaces");
|
|
172
|
+
}
|
|
150
173
|
return Buffer.from(
|
|
151
|
-
JSON.stringify([
|
|
174
|
+
JSON.stringify([
|
|
175
|
+
"foldrun-vault/2",
|
|
176
|
+
tenant,
|
|
177
|
+
workspace ? "w" : "a",
|
|
178
|
+
name,
|
|
179
|
+
rec.kind ?? "",
|
|
180
|
+
rec.k ?? "install",
|
|
181
|
+
[...(rec.sites ?? [])].sort(),
|
|
182
|
+
...(rec.workspaces !== undefined ? [["workspaces", [...rec.workspaces].sort()]] : []),
|
|
183
|
+
]),
|
|
152
184
|
"utf8",
|
|
153
185
|
);
|
|
154
186
|
}
|
|
187
|
+
|
|
188
|
+
/** May an account record be used in this workspace? No list: everywhere. A
|
|
189
|
+
* list that is not a list of names (rewritten on disk) allows nowhere. */
|
|
190
|
+
function usableIn(rec: Pick<StoredSecret, "workspaces"> | undefined, workspace: string): boolean {
|
|
191
|
+
if (!rec || rec.workspaces === undefined) return true;
|
|
192
|
+
return Array.isArray(rec.workspaces) && rec.workspaces.includes(workspace);
|
|
193
|
+
}
|
|
155
194
|
const writeBound = () => process.env.FOLDRUN_VAULT_WRITE_BOUND === "1";
|
|
156
195
|
const requireBound = () => process.env.FOLDRUN_VAULT_REQUIRE_BOUND === "1";
|
|
157
196
|
|
|
@@ -162,7 +201,7 @@ function sealRecord(
|
|
|
162
201
|
workspace: string | undefined,
|
|
163
202
|
name: string,
|
|
164
203
|
value: string,
|
|
165
|
-
meta: { kind?: SecretKind; sites?: string[]; updatedAt?: string },
|
|
204
|
+
meta: { kind?: SecretKind; sites?: string[]; workspaces?: string[]; updatedAt?: string },
|
|
166
205
|
bound = writeBound(),
|
|
167
206
|
): StoredSecret {
|
|
168
207
|
const { key, mark } = keyFor(tenant);
|
|
@@ -174,6 +213,8 @@ function sealRecord(
|
|
|
174
213
|
...(meta.kind ? { kind: meta.kind } : {}),
|
|
175
214
|
...(mark ? { k: mark } : {}),
|
|
176
215
|
...(meta.sites?.length ? { sites: [...meta.sites] } : {}),
|
|
216
|
+
// An empty list is a choice (no workspace may use it), kept as written.
|
|
217
|
+
...(meta.workspaces !== undefined ? { workspaces: [...meta.workspaces] } : {}),
|
|
177
218
|
};
|
|
178
219
|
const iv = crypto.randomBytes(12);
|
|
179
220
|
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv, GCM);
|
|
@@ -284,36 +325,101 @@ export function mutateVaultFile(file: string, change: (all: SecretsFile) => Secr
|
|
|
284
325
|
|
|
285
326
|
export const SECRET_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
|
|
286
327
|
|
|
328
|
+
/**
|
|
329
|
+
* How a `signing_secret:` is shown in an answer, a log line or a delivery
|
|
330
|
+
* record: the name it holds, and only a name (30 Oct 2026). It is meant to
|
|
331
|
+
* name a vault entry, but nothing stopped the value itself being written
|
|
332
|
+
* there, and notify's test send echoed it back to the dashboard as "signed
|
|
333
|
+
* with <it>". Anything that is not a secret name is described, never
|
|
334
|
+
* repeated (foldrun check flags it as secret/signing-secret-value).
|
|
335
|
+
*/
|
|
336
|
+
export function signingSecretShown(name: string): string {
|
|
337
|
+
return SECRET_NAME.test(name) ? name : "the signing_secret: (not a secret name — see foldrun check)";
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Which workspaces an ACCOUNT secret may be used in, given with the value so
|
|
342
|
+
* both are sealed in one locked write (30 Oct 2026): a new secret created
|
|
343
|
+
* and limited in two calls was open to every workspace between them, and
|
|
344
|
+
* stayed open if the second failed. `workspaces` undefined keeps what the
|
|
345
|
+
* record has; null opens it to every workspace; a list limits it.
|
|
346
|
+
* `exists` is asked about each name under the vault lock, so a workspace
|
|
347
|
+
* deleted meanwhile is not granted (the delete takes its name off every
|
|
348
|
+
* list under the same lock — retargetSecretWorkspaces).
|
|
349
|
+
*/
|
|
350
|
+
export interface SecretLimit {
|
|
351
|
+
workspaces?: string[] | null;
|
|
352
|
+
exists?: (workspace: string) => boolean;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** A list as stored: trimmed, de-duplicated, sorted; every name a safe one
|
|
356
|
+
* and, when the caller can say, one that exists. */
|
|
357
|
+
function cleanWorkspaceList(workspaces: unknown): string[] | null {
|
|
358
|
+
if (workspaces === null) return null;
|
|
359
|
+
if (!Array.isArray(workspaces) || workspaces.some((w) => typeof w !== "string")) {
|
|
360
|
+
throw new Error("workspaces is a list of workspace names, or null for every workspace");
|
|
361
|
+
}
|
|
362
|
+
const clean = [...new Set((workspaces as string[]).map((w) => w.trim()).filter(Boolean))].sort();
|
|
363
|
+
for (const w of clean) assertSafeName(w, "workspace");
|
|
364
|
+
return clean;
|
|
365
|
+
}
|
|
366
|
+
function assertWorkspacesExist(list: string[] | null, exists?: (w: string) => boolean) {
|
|
367
|
+
if (!list || !exists) return;
|
|
368
|
+
const missing = list.filter((w) => !exists(w));
|
|
369
|
+
if (missing.length) throw Object.assign(new Error(`no workspace ${missing.join(", ")} in this account`), { status: 400 });
|
|
370
|
+
}
|
|
371
|
+
|
|
287
372
|
export function setSecret(
|
|
288
373
|
tenant: string,
|
|
289
374
|
name: string,
|
|
290
375
|
value: string,
|
|
291
376
|
workspace?: string,
|
|
292
377
|
kind?: SecretKind,
|
|
378
|
+
limit: SecretLimit = {},
|
|
293
379
|
) {
|
|
294
|
-
|
|
380
|
+
// A string, not anything that prints as one: RegExp.test and the vault's
|
|
381
|
+
// keys both coerce, so ["RESEND_API_KEY"] from a JSON body was stored as
|
|
382
|
+
// RESEND_API_KEY past a caller's check on the name (30 Oct 2026).
|
|
383
|
+
if (typeof name !== "string" || !SECRET_NAME.test(name)) {
|
|
295
384
|
throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
|
|
296
385
|
}
|
|
297
386
|
if (value.length > 8192) throw new Error("secret too long");
|
|
387
|
+
const wanted = limitFor(workspace, limit);
|
|
298
388
|
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
299
389
|
// A new value keeps the sites the old one was bound to: binding is a
|
|
300
390
|
// property of the credential's use, not of its current value, and a
|
|
301
391
|
// rotation that silently unbound it would put the new value in pods.
|
|
302
392
|
// Sealed here, under the lock: a bound record's AAD names those sites.
|
|
303
393
|
const sites = all[name]?.sites;
|
|
394
|
+
// The workspaces it is limited to stay too, for the same reason: a new
|
|
395
|
+
// value that every workspace could suddenly read is a widening nobody
|
|
396
|
+
// asked for — unless this call says otherwise, in the same write.
|
|
397
|
+
if (wanted !== undefined) assertWorkspacesExist(wanted, limit.exists);
|
|
398
|
+
const workspaces = wanted !== undefined ? (wanted ?? undefined) : all[name]?.workspaces;
|
|
304
399
|
// Once bound, always bound: a re-save with the switch off must not
|
|
305
|
-
// quietly drop the record's authentication.
|
|
306
|
-
|
|
400
|
+
// quietly drop the record's authentication. A limited record is always
|
|
401
|
+
// bound, so its list is in its seal (see setSecretWorkspaces).
|
|
402
|
+
all[name] = sealRecord(tenant, workspace, name, value, { kind, sites, workspaces }, all[name]?.v === 2 || workspaces !== undefined || writeBound());
|
|
307
403
|
return all;
|
|
308
404
|
});
|
|
309
405
|
}
|
|
310
406
|
|
|
407
|
+
/** The list a write was asked to store, checked: undefined when it was not
|
|
408
|
+
* asked to change it. Only the account's store has lists. */
|
|
409
|
+
function limitFor(workspace: string | undefined, limit: SecretLimit): string[] | null | undefined {
|
|
410
|
+
if (limit.workspaces === undefined) return undefined;
|
|
411
|
+
if (workspace) throw new Error("a workspace's own secret is that workspace's already — only an account secret is limited to workspaces");
|
|
412
|
+
return cleanWorkspaceList(limit.workspaces);
|
|
413
|
+
}
|
|
414
|
+
|
|
311
415
|
/**
|
|
312
416
|
* Bind a secret to sites, or unbind it (an empty list). The secret must
|
|
313
417
|
* exist in that scope. Sites are `api.example.com` or `*.example.com` — no
|
|
314
418
|
* scheme, path or port. Returns the list as stored.
|
|
315
419
|
*/
|
|
316
420
|
export function setSecretSites(tenant: string, name: string, sites: string[], workspace?: string): string[] {
|
|
421
|
+
// all[name] would find ["NAME"] as NAME: see setSecret.
|
|
422
|
+
if (typeof name !== "string") throw new Error("a secret's name is a string");
|
|
317
423
|
const clean = [...new Set(sites.map((x) => x.trim().toLowerCase()).filter(Boolean))];
|
|
318
424
|
for (const site of clean) {
|
|
319
425
|
if (!SITE.test(site)) throw new Error(`"${site}" is not a site — a domain like api.example.com or *.example.com, no scheme, path or port`);
|
|
@@ -340,7 +446,7 @@ export function setSecretSites(tenant: string, name: string, sites: string[], wo
|
|
|
340
446
|
if (rec.v === 2) {
|
|
341
447
|
const value = openRecord(tenant, workspace, name, rec);
|
|
342
448
|
if (value === null) throw new Error(`${name} cannot be read with this install's keys — set its value again before binding it`);
|
|
343
|
-
all[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: clean, updatedAt: rec.updatedAt }, true);
|
|
449
|
+
all[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: clean, workspaces: rec.workspaces, updatedAt: rec.updatedAt }, true);
|
|
344
450
|
return all;
|
|
345
451
|
}
|
|
346
452
|
if (clean.length) rec.sites = clean;
|
|
@@ -365,7 +471,10 @@ export function secretSites(tenant: string, names: string[], workspace?: string)
|
|
|
365
471
|
// when it opens, else the account's. Picking by presence paired an
|
|
366
472
|
// account value with a workspace record's (unauthenticated) sites.
|
|
367
473
|
const ownRec = own[name];
|
|
368
|
-
const
|
|
474
|
+
const ownOpens = Boolean(ownRec && openRecord(tenant, workspace, name, ownRec) !== null);
|
|
475
|
+
// An account record limited to other workspaces is not this one's.
|
|
476
|
+
if (!ownOpens && workspace && !usableIn(account[name], workspace)) continue;
|
|
477
|
+
const rec = ownOpens ? ownRec : account[name];
|
|
369
478
|
if (rec?.sites?.length) out[name] = [...rec.sites];
|
|
370
479
|
}
|
|
371
480
|
return out;
|
|
@@ -452,7 +561,12 @@ function decrypt(tenant: string, rec: StoredSecret | undefined, aad?: Buffer): s
|
|
|
452
561
|
}
|
|
453
562
|
}
|
|
454
563
|
|
|
455
|
-
/** Nearest wins: a workspace secret shadows an account one of the same name.
|
|
564
|
+
/** Nearest wins: a workspace secret shadows an account one of the same name.
|
|
565
|
+
* An account secret limited to some workspaces (setSecretWorkspaces) is
|
|
566
|
+
* absent for every other — the answer a secret that was never set gets, on
|
|
567
|
+
* every path that resolves one for a workspace: runs, tool tests, notify,
|
|
568
|
+
* the egress proxy, MCP servers. Without a workspace it is the account's
|
|
569
|
+
* own use (its model key, its mail sender), which no list limits. */
|
|
456
570
|
export function getSecret(
|
|
457
571
|
tenant: string,
|
|
458
572
|
name: string,
|
|
@@ -462,10 +576,129 @@ export function getSecret(
|
|
|
462
576
|
const own = openRecord(tenant, workspace, name, read(tenant, workspace)[name]);
|
|
463
577
|
if (own !== null) return { value: own, scope: "workspace" };
|
|
464
578
|
}
|
|
465
|
-
const
|
|
579
|
+
const rec = read(tenant)[name];
|
|
580
|
+
if (workspace && !usableIn(rec, workspace)) return null;
|
|
581
|
+
const shared = openRecord(tenant, undefined, name, rec);
|
|
466
582
|
return shared === null ? null : { value: shared, scope: "account" };
|
|
467
583
|
}
|
|
468
584
|
|
|
585
|
+
/** May the ACCOUNT's secret `name` be used for this workspace? True when it
|
|
586
|
+
* is not limited (or not set); for callers that read the account's copy on
|
|
587
|
+
* a workspace's behalf without getSecret's workspace-first lookup — the
|
|
588
|
+
* account's mail sender for a workspace's notifications (notify.ts). */
|
|
589
|
+
export function accountSecretUsableIn(tenant: string, name: string, workspace: string): boolean {
|
|
590
|
+
return usableIn(read(tenant)[name], workspace);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Limit an account secret to some workspaces, or lift the limit (null).
|
|
595
|
+
* An empty list means no workspace may use it. Returns the list as stored,
|
|
596
|
+
* sorted, or null.
|
|
597
|
+
*
|
|
598
|
+
* `exists` is asked about each name under the vault lock (the API passes
|
|
599
|
+
* "is there such a workspace"), so it cannot grant a name a delete has just
|
|
600
|
+
* taken off every list. `ifWorkspaces`, when given, is the list the caller
|
|
601
|
+
* saw (null: every workspace): a list changed since is refused (409), so
|
|
602
|
+
* two admins editing it at once do not silently overwrite each other.
|
|
603
|
+
*
|
|
604
|
+
* The record is sealed (v2) whatever it was, whatever
|
|
605
|
+
* FOLDRUN_VAULT_WRITE_BOUND says: a list in the clear on an unbound record
|
|
606
|
+
* could be stripped on disk to open the secret to every workspace; sealed,
|
|
607
|
+
* a stripped list makes it unreadable. Every reader since 29 Oct 2026 reads v2.
|
|
608
|
+
*/
|
|
609
|
+
export function setSecretWorkspaces(
|
|
610
|
+
tenant: string,
|
|
611
|
+
name: string,
|
|
612
|
+
workspaces: string[] | null,
|
|
613
|
+
opts: { exists?: (workspace: string) => boolean; ifWorkspaces?: string[] | null } = {},
|
|
614
|
+
): string[] | null {
|
|
615
|
+
if (typeof name !== "string") throw new Error("a secret's name is a string");
|
|
616
|
+
const clean = cleanWorkspaceList(workspaces);
|
|
617
|
+
const expected = opts.ifWorkspaces === undefined ? undefined : cleanWorkspaceList(opts.ifWorkspaces);
|
|
618
|
+
let found = false;
|
|
619
|
+
mutateVaultFile(secretsFile(tenant), (all) => {
|
|
620
|
+
const rec = all[name];
|
|
621
|
+
if (!rec) return null;
|
|
622
|
+
found = true;
|
|
623
|
+
if (expected !== undefined) {
|
|
624
|
+
const now = Array.isArray(rec.workspaces) ? [...rec.workspaces].sort() : null;
|
|
625
|
+
if (JSON.stringify(now) !== JSON.stringify(expected)) {
|
|
626
|
+
throw Object.assign(new Error(`${name}'s workspaces changed since you loaded them (now ${now === null ? "every workspace" : now.length ? now.join(", ") : "none"}) — reload and try again`), { status: 409 });
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
assertWorkspacesExist(clean, opts.exists);
|
|
630
|
+
all[name] = withWorkspaces(tenant, name, rec, clean);
|
|
631
|
+
return all;
|
|
632
|
+
});
|
|
633
|
+
if (!found) throw Object.assign(new Error(`no secret ${name} in the account`), { status: 404 });
|
|
634
|
+
return clean;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** An account record with its workspace list replaced (null: removed),
|
|
638
|
+
* opened and sealed again as a bound record with the new list in its seal.
|
|
639
|
+
* One that will not open is refused, never relabelled (setSecretSites). */
|
|
640
|
+
function withWorkspaces(tenant: string, name: string, rec: StoredSecret, workspaces: string[] | null): StoredSecret {
|
|
641
|
+
const value = openRecord(tenant, undefined, name, rec);
|
|
642
|
+
if (value === null) throw new Error(`${name} cannot be read with this install's keys — set its value again before changing its workspaces`);
|
|
643
|
+
return sealRecord(tenant, undefined, name, value, { kind: rec.kind, sites: rec.sites, workspaces: workspaces ?? undefined, updatedAt: rec.updatedAt }, true);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* A workspace is being renamed (to: its new name) or deleted (to: null):
|
|
648
|
+
* every account secret limited to it follows. Without this a rename quietly
|
|
649
|
+
* cut the workspace off its secrets, and a delete left the name on the list
|
|
650
|
+
* for the next workspace given it.
|
|
651
|
+
*
|
|
652
|
+
* `failed` names the records that hold the name but will not open with this
|
|
653
|
+
* install's keys: they are left exactly as they are, so the old name is
|
|
654
|
+
* STILL on their list — a key restored later would bring the grant back.
|
|
655
|
+
* The caller must not report the rename or delete as clean while any are
|
|
656
|
+
* left (the API refuses it, naming them: set them again, or delete them).
|
|
657
|
+
*
|
|
658
|
+
* Names, not ids: a grant naming a workspace's immutable id would survive a
|
|
659
|
+
* rename untouched and could never pass to a new workspace given an old
|
|
660
|
+
* name — the stronger design, not built yet (30 Oct 2026).
|
|
661
|
+
*/
|
|
662
|
+
export function retargetSecretWorkspaces(tenant: string, from: string, to: string | null): { changed: string[]; failed: string[] } {
|
|
663
|
+
const changed: string[] = [];
|
|
664
|
+
const failed: string[] = [];
|
|
665
|
+
const file = secretsFile(tenant);
|
|
666
|
+
if (!fs.existsSync(file)) return { changed, failed };
|
|
667
|
+
mutateVaultFile(file, (all) => {
|
|
668
|
+
changed.length = 0;
|
|
669
|
+
failed.length = 0;
|
|
670
|
+
const staged: typeof all = {};
|
|
671
|
+
for (const [name, rec] of Object.entries(all)) {
|
|
672
|
+
const list = rec.workspaces as unknown;
|
|
673
|
+
if (list === undefined) continue;
|
|
674
|
+
if (!Array.isArray(list)) {
|
|
675
|
+
// A list rewritten on disk into something else allows nowhere
|
|
676
|
+
// (usableIn) — nothing for this rename to carry.
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
if (!list.includes(from)) continue;
|
|
680
|
+
const next = [...new Set(list.map((w) => (w === from ? to : w)).filter((w): w is string => typeof w === "string"))].sort();
|
|
681
|
+
try {
|
|
682
|
+
staged[name] = withWorkspaces(tenant, name, rec, next);
|
|
683
|
+
changed.push(name);
|
|
684
|
+
} catch {
|
|
685
|
+
failed.push(name);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
// All or nothing (Codex, 30 Oct 2026): writing the readable records and
|
|
689
|
+
// refusing the rename for the unreadable ones moved grants to a name the
|
|
690
|
+
// workspace never took. One failure leaves the vault exactly as it was.
|
|
691
|
+
if (failed.length) {
|
|
692
|
+
changed.length = 0;
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
if (!changed.length) return null;
|
|
696
|
+
Object.assign(all, staged);
|
|
697
|
+
return all;
|
|
698
|
+
});
|
|
699
|
+
return { changed, failed };
|
|
700
|
+
}
|
|
701
|
+
|
|
469
702
|
export function deleteSecret(tenant: string, name: string, workspace?: string) {
|
|
470
703
|
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
471
704
|
if (!(name in all)) return null;
|
|
@@ -486,19 +719,27 @@ export interface SecretEntry {
|
|
|
486
719
|
/** The sites it is bound to — the proxy fills it for these and no other,
|
|
487
720
|
* and it never enters a sandbox. Absent when unbound. */
|
|
488
721
|
sites?: string[];
|
|
722
|
+
/** An account secret limited to these workspaces; absent: every one. */
|
|
723
|
+
workspaces?: string[];
|
|
489
724
|
}
|
|
490
725
|
|
|
491
726
|
// Metadata only — never values. Without a workspace this lists the account
|
|
492
727
|
// store; with one it lists what that workspace actually resolves, marking
|
|
493
728
|
// which scope each name comes from.
|
|
494
729
|
export function listSecrets(tenant: string, workspace?: string): SecretEntry[] {
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
730
|
+
const records = Object.entries(read(tenant));
|
|
731
|
+
const account = records
|
|
732
|
+
// What a workspace resolves leaves out an account secret limited to
|
|
733
|
+
// others: it is not set, as far as that workspace is concerned.
|
|
734
|
+
.filter(([, rec]) => !workspace || usableIn(rec, workspace))
|
|
735
|
+
.map(([name, rec]) => ({
|
|
736
|
+
name,
|
|
737
|
+
updatedAt: rec.updatedAt,
|
|
738
|
+
scope: "account" as const,
|
|
739
|
+
...(rec.kind ? { kind: rec.kind } : {}),
|
|
740
|
+
...(rec.sites?.length ? { sites: [...rec.sites] } : {}),
|
|
741
|
+
...(!workspace && Array.isArray(rec.workspaces) ? { workspaces: [...rec.workspaces] } : {}),
|
|
742
|
+
}));
|
|
502
743
|
if (!workspace) return account.sort((a, b) => a.name.localeCompare(b.name));
|
|
503
744
|
|
|
504
745
|
const own = Object.entries(read(tenant, workspace)).map(([name, rec]) => ({
|
|
@@ -643,6 +884,9 @@ export function rotateMasterKey(
|
|
|
643
884
|
// Kept too (it was dropped until 29 Oct 2026): a rotation that
|
|
644
885
|
// unbinds every bound secret puts their values into pods.
|
|
645
886
|
...(rec.sites?.length ? { sites: rec.sites } : {}),
|
|
887
|
+
// And the workspaces it is limited to: dropped, it would read as
|
|
888
|
+
// "every workspace" (and fail its seal).
|
|
889
|
+
...(rec.workspaces !== undefined ? { workspaces: rec.workspaces } : {}),
|
|
646
890
|
...(aad ? { v: 2 as const } : {}),
|
|
647
891
|
};
|
|
648
892
|
count++;
|
|
@@ -697,6 +941,7 @@ export function setOAuth2Secret(
|
|
|
697
941
|
name: string,
|
|
698
942
|
config: OAuth2Config,
|
|
699
943
|
workspace?: string,
|
|
944
|
+
limit: SecretLimit = {},
|
|
700
945
|
) {
|
|
701
946
|
for (const field of ["token_url", "client_id"] as const) {
|
|
702
947
|
if (!config[field]?.trim()) throw new Error(`oauth2 secret needs ${field}`);
|
|
@@ -714,7 +959,7 @@ export function setOAuth2Secret(
|
|
|
714
959
|
if (!/^https:\/\//.test(config.token_url) && !/^http:\/\/(127\.0\.0\.1|localhost)[:/]/.test(config.token_url)) {
|
|
715
960
|
throw new Error("token_url must be https — a refresh token over http is a leaked one");
|
|
716
961
|
}
|
|
717
|
-
setSecret(tenant, name, OAUTH2_PREFIX + JSON.stringify(config), workspace, "oauth2");
|
|
962
|
+
setSecret(tenant, name, OAUTH2_PREFIX + JSON.stringify(config), workspace, "oauth2", limit);
|
|
718
963
|
}
|
|
719
964
|
|
|
720
965
|
export function isOAuth2Value(value: string): boolean {
|
|
@@ -759,7 +1004,7 @@ export function replaceOAuth2RefreshToken(tenant: string, name: string, workspac
|
|
|
759
1004
|
// Built whole, not spread over the old record: a spread kept a stale
|
|
760
1005
|
// `k: "tenant"` when this seal fell back to the install key, and the
|
|
761
1006
|
// record then opened with neither.
|
|
762
|
-
all[name] = sealRecord(tenant, scope, name, next, { kind: rec.kind, sites: rec.sites }, rec.v === 2 || writeBound());
|
|
1007
|
+
all[name] = sealRecord(tenant, scope, name, next, { kind: rec.kind, sites: rec.sites, workspaces: rec.workspaces }, rec.v === 2 || writeBound());
|
|
763
1008
|
replaced = true;
|
|
764
1009
|
}
|
|
765
1010
|
return all;
|
|
@@ -961,6 +1206,7 @@ export function setServiceAccountSecret(
|
|
|
961
1206
|
name: string,
|
|
962
1207
|
config: ServiceAccountConfig,
|
|
963
1208
|
workspace?: string,
|
|
1209
|
+
limit: SecretLimit = {},
|
|
964
1210
|
) {
|
|
965
1211
|
for (const field of ["token_url", "issuer", "private_key", "scope"] as const) {
|
|
966
1212
|
if (!config[field]?.trim()) throw new Error(`service account secret needs ${field}`);
|
|
@@ -973,7 +1219,7 @@ export function setServiceAccountSecret(
|
|
|
973
1219
|
if (config.subject.length > 320 || /[\u0000-\u001f\u007f\s]/.test(config.subject.trim())) throw new Error("subject is one identifier, no spaces or control characters");
|
|
974
1220
|
}
|
|
975
1221
|
const stored: ServiceAccountConfig = { ...config, ...(config.subject !== undefined ? { subject: config.subject.trim() } : {}) };
|
|
976
|
-
setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(stored), workspace, "service-account");
|
|
1222
|
+
setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(stored), workspace, "service-account", limit);
|
|
977
1223
|
}
|
|
978
1224
|
|
|
979
1225
|
function base64url(input: Buffer | string): string {
|
|
@@ -1051,9 +1297,9 @@ async function exchangeServiceAccount(config: ServiceAccountConfig, cacheKey: st
|
|
|
1051
1297
|
|
|
1052
1298
|
const FILE_PREFIX = "@file ";
|
|
1053
1299
|
|
|
1054
|
-
export function setFileSecret(tenant: string, name: string, content: string, workspace?: string) {
|
|
1300
|
+
export function setFileSecret(tenant: string, name: string, content: string, workspace?: string, limit: SecretLimit = {}) {
|
|
1055
1301
|
if (!content) throw new Error("a file secret needs content");
|
|
1056
|
-
setSecret(tenant, name, FILE_PREFIX + content, workspace, "file");
|
|
1302
|
+
setSecret(tenant, name, FILE_PREFIX + content, workspace, "file", limit);
|
|
1057
1303
|
}
|
|
1058
1304
|
|
|
1059
1305
|
export function isFileValue(value: string): boolean {
|
|
@@ -1084,7 +1330,7 @@ export interface SshConfig {
|
|
|
1084
1330
|
password?: string;
|
|
1085
1331
|
}
|
|
1086
1332
|
|
|
1087
|
-
export function setSshSecret(tenant: string, name: string, config: SshConfig, workspace?: string) {
|
|
1333
|
+
export function setSshSecret(tenant: string, name: string, config: SshConfig, workspace?: string, limit: SecretLimit = {}) {
|
|
1088
1334
|
const host = config.host?.trim();
|
|
1089
1335
|
const user = config.user?.trim();
|
|
1090
1336
|
if (!host || /[\s'"`$\\]/.test(host)) throw new Error("ssh secret needs a host (no spaces or quotes)");
|
|
@@ -1102,7 +1348,7 @@ export function setSshSecret(tenant: string, name: string, config: SshConfig, wo
|
|
|
1102
1348
|
host, user, port,
|
|
1103
1349
|
...(hasKey ? { private_key: config.private_key } : { password: config.password }),
|
|
1104
1350
|
};
|
|
1105
|
-
setSecret(tenant, name, SSH_PREFIX + JSON.stringify(stored), workspace, "ssh");
|
|
1351
|
+
setSecret(tenant, name, SSH_PREFIX + JSON.stringify(stored), workspace, "ssh", limit);
|
|
1106
1352
|
}
|
|
1107
1353
|
|
|
1108
1354
|
export function isSshValue(value: string): boolean {
|
|
@@ -1137,7 +1383,7 @@ export interface ClientCertConfig {
|
|
|
1137
1383
|
export?: boolean;
|
|
1138
1384
|
}
|
|
1139
1385
|
|
|
1140
|
-
export function setClientCertSecret(tenant: string, name: string, config: ClientCertConfig, workspace?: string) {
|
|
1386
|
+
export function setClientCertSecret(tenant: string, name: string, config: ClientCertConfig, workspace?: string, limit: SecretLimit = {}) {
|
|
1141
1387
|
if (!/-----BEGIN CERTIFICATE-----/.test(config.cert ?? "")) throw new Error("cert must be a PEM certificate");
|
|
1142
1388
|
if (!/-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----/.test(config.key ?? "")) throw new Error("key must be a PEM private key");
|
|
1143
1389
|
// The pair must match: a certificate presented with another key's proof
|
|
@@ -1157,17 +1403,20 @@ export function setClientCertSecret(tenant: string, name: string, config: Client
|
|
|
1157
1403
|
...(config.passphrase ? { passphrase: config.passphrase } : {}),
|
|
1158
1404
|
...(config.export ? { export: true } : {}),
|
|
1159
1405
|
};
|
|
1160
|
-
if (!SECRET_NAME.test(name)) throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
|
|
1406
|
+
if (typeof name !== "string" || !SECRET_NAME.test(name)) throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
|
|
1161
1407
|
const plainCert = CLIENT_CERT_PREFIX + JSON.stringify(stored);
|
|
1162
1408
|
// Written under the vault's own lock, with the binding checked in the same
|
|
1163
1409
|
// step: a value set over a secret keeps its site binding, and a wildcard
|
|
1164
1410
|
// one would present the certificate to a whole domain.
|
|
1411
|
+
const wanted = limitFor(workspace, limit);
|
|
1165
1412
|
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
1166
1413
|
const sites = all[name]?.sites;
|
|
1414
|
+
if (wanted !== undefined) assertWorkspacesExist(wanted, limit.exists);
|
|
1415
|
+
const workspaces = wanted !== undefined ? (wanted ?? undefined) : all[name]?.workspaces;
|
|
1167
1416
|
if (sites?.some((site) => site.startsWith("*."))) {
|
|
1168
1417
|
throw new Error(`${name} is bound to ${sites.join(", ")} — a client certificate is bound to exact hosts; unbind the wildcard first`);
|
|
1169
1418
|
}
|
|
1170
|
-
all[name] = sealRecord(tenant, workspace, name, plainCert, { kind: "client-cert", sites }, all[name]?.v === 2 || writeBound());
|
|
1419
|
+
all[name] = sealRecord(tenant, workspace, name, plainCert, { kind: "client-cert", sites, workspaces }, all[name]?.v === 2 || workspaces !== undefined || writeBound());
|
|
1171
1420
|
return all;
|
|
1172
1421
|
});
|
|
1173
1422
|
}
|
|
@@ -1181,11 +1430,11 @@ export interface IdentityConfig {
|
|
|
1181
1430
|
audience: string;
|
|
1182
1431
|
}
|
|
1183
1432
|
|
|
1184
|
-
export function setIdentitySecret(tenant: string, name: string, config: IdentityConfig, workspace?: string) {
|
|
1433
|
+
export function setIdentitySecret(tenant: string, name: string, config: IdentityConfig, workspace?: string, limit: SecretLimit = {}) {
|
|
1185
1434
|
const audience = typeof config.audience === "string" ? config.audience.trim() : "";
|
|
1186
1435
|
if (!audience) throw new Error("an identity needs an audience — the value your cloud's trust expects in aud");
|
|
1187
1436
|
if (audience.length > 600 || /[\u0000-\u001f\u007f]/.test(audience)) throw new Error("audience is one value, at most 600 characters, no control characters");
|
|
1188
|
-
setSecret(tenant, name, IDENTITY_PREFIX + JSON.stringify({ audience }), workspace, "identity");
|
|
1437
|
+
setSecret(tenant, name, IDENTITY_PREFIX + JSON.stringify({ audience }), workspace, "identity", limit);
|
|
1189
1438
|
}
|
|
1190
1439
|
|
|
1191
1440
|
export function isIdentityValue(value: string): boolean {
|
|
@@ -1228,7 +1477,7 @@ export function basicAuthorization(username: string, password: string): string {
|
|
|
1228
1477
|
return `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`;
|
|
1229
1478
|
}
|
|
1230
1479
|
|
|
1231
|
-
export function setApiSecret(tenant: string, name: string, config: ApiConfig, workspace?: string) {
|
|
1480
|
+
export function setApiSecret(tenant: string, name: string, config: ApiConfig, workspace?: string, limit: SecretLimit = {}) {
|
|
1232
1481
|
let all = { ...(config.headers ?? {}) };
|
|
1233
1482
|
if (config.basic) {
|
|
1234
1483
|
if (Object.keys(all).some((h) => h.toLowerCase() === "authorization")) {
|
|
@@ -1260,7 +1509,7 @@ export function setApiSecret(tenant: string, name: string, config: ApiConfig, wo
|
|
|
1260
1509
|
setSecret(
|
|
1261
1510
|
tenant, name,
|
|
1262
1511
|
API_PREFIX + JSON.stringify({ ...(base ? { base_url: trimSlashes(base) } : {}), headers: config.headers }),
|
|
1263
|
-
workspace, "api",
|
|
1512
|
+
workspace, "api", limit,
|
|
1264
1513
|
);
|
|
1265
1514
|
}
|
|
1266
1515
|
|
|
@@ -1299,7 +1548,7 @@ export function sealVaultBindings(tenant: string): { sealed: number; unreadable:
|
|
|
1299
1548
|
const next: SecretsFile = { ...all };
|
|
1300
1549
|
for (const [name, value] of plain) {
|
|
1301
1550
|
const rec = all[name];
|
|
1302
|
-
next[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: rec.sites, updatedAt: rec.updatedAt }, true);
|
|
1551
|
+
next[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: rec.sites, workspaces: rec.workspaces, updatedAt: rec.updatedAt }, true);
|
|
1303
1552
|
}
|
|
1304
1553
|
sealed += plain.size;
|
|
1305
1554
|
return next;
|
|
@@ -1348,7 +1597,7 @@ export function rewrapTenantSecrets(tenant: string): { moved: number; unreadable
|
|
|
1348
1597
|
if (plain.size === 0 || unreadableHere.length) return null;
|
|
1349
1598
|
const next: SecretsFile = { ...all };
|
|
1350
1599
|
for (const [name, value] of plain) {
|
|
1351
|
-
next[name] = sealRecord(tenant, workspace, name, value, { kind: all[name].kind, sites: all[name].sites, updatedAt: all[name].updatedAt }, all[name].v === 2 || writeBound());
|
|
1600
|
+
next[name] = sealRecord(tenant, workspace, name, value, { kind: all[name].kind, sites: all[name].sites, workspaces: all[name].workspaces, updatedAt: all[name].updatedAt }, all[name].v === 2 || writeBound());
|
|
1352
1601
|
}
|
|
1353
1602
|
moved += plain.size;
|
|
1354
1603
|
return next;
|