@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/dist/src/secrets.js
CHANGED
|
@@ -106,11 +106,43 @@ function secretsFile(tenant, workspace) {
|
|
|
106
106
|
// every rename a re-seal racing other writers. What that leaves open — a
|
|
107
107
|
// ciphertext moved between two workspaces of the same account by someone who
|
|
108
108
|
// can already write the volume — is the residual, and stays inside one account.
|
|
109
|
+
//
|
|
110
|
+
// Rolling out (30 Oct 2026, accepted, no switch): a pod from before this
|
|
111
|
+
// release cannot open a limited record (its AAD lacks the list), and one that
|
|
112
|
+
// sets a new value over it writes the record back without the list — open to
|
|
113
|
+
// every workspace again. So limits are set after every pod, worker and the
|
|
114
|
+
// egress proxy run this version; the secrets docs say so.
|
|
115
|
+
//
|
|
116
|
+
// The workspaces an account secret is limited to are in the seal too (30 Oct
|
|
117
|
+
// 2026), appended only when the record has the list: every record sealed
|
|
118
|
+
// before keeps the AAD it was sealed with, and a list added to, widened in or
|
|
119
|
+
// stripped from a sealed record on disk makes it fail to open — "not set",
|
|
120
|
+
// never "every workspace". These ARE workspace names, so a rename re-seals
|
|
121
|
+
// them (retargetSecretWorkspaces), under the vault lock.
|
|
109
122
|
function vaultAad(tenant, workspace, name, rec) {
|
|
110
123
|
if (rec.sites !== undefined && (!Array.isArray(rec.sites) || rec.sites.some((x) => typeof x !== "string"))) {
|
|
111
124
|
throw new Error("malformed sites");
|
|
112
125
|
}
|
|
113
|
-
|
|
126
|
+
if (rec.workspaces !== undefined && (!Array.isArray(rec.workspaces) || rec.workspaces.some((x) => typeof x !== "string"))) {
|
|
127
|
+
throw new Error("malformed workspaces");
|
|
128
|
+
}
|
|
129
|
+
return Buffer.from(JSON.stringify([
|
|
130
|
+
"foldrun-vault/2",
|
|
131
|
+
tenant,
|
|
132
|
+
workspace ? "w" : "a",
|
|
133
|
+
name,
|
|
134
|
+
rec.kind ?? "",
|
|
135
|
+
rec.k ?? "install",
|
|
136
|
+
[...(rec.sites ?? [])].sort(),
|
|
137
|
+
...(rec.workspaces !== undefined ? [["workspaces", [...rec.workspaces].sort()]] : []),
|
|
138
|
+
]), "utf8");
|
|
139
|
+
}
|
|
140
|
+
/** May an account record be used in this workspace? No list: everywhere. A
|
|
141
|
+
* list that is not a list of names (rewritten on disk) allows nowhere. */
|
|
142
|
+
function usableIn(rec, workspace) {
|
|
143
|
+
if (!rec || rec.workspaces === undefined)
|
|
144
|
+
return true;
|
|
145
|
+
return Array.isArray(rec.workspaces) && rec.workspaces.includes(workspace);
|
|
114
146
|
}
|
|
115
147
|
const writeBound = () => process.env.FOLDRUN_VAULT_WRITE_BOUND === "1";
|
|
116
148
|
const requireBound = () => process.env.FOLDRUN_VAULT_REQUIRE_BOUND === "1";
|
|
@@ -126,6 +158,8 @@ function sealRecord(tenant, workspace, name, value, meta, bound = writeBound())
|
|
|
126
158
|
...(meta.kind ? { kind: meta.kind } : {}),
|
|
127
159
|
...(mark ? { k: mark } : {}),
|
|
128
160
|
...(meta.sites?.length ? { sites: [...meta.sites] } : {}),
|
|
161
|
+
// An empty list is a choice (no workspace may use it), kept as written.
|
|
162
|
+
...(meta.workspaces !== undefined ? { workspaces: [...meta.workspaces] } : {}),
|
|
129
163
|
};
|
|
130
164
|
const iv = crypto.randomBytes(12);
|
|
131
165
|
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv, GCM);
|
|
@@ -237,30 +271,84 @@ export function mutateVaultFile(file, change) {
|
|
|
237
271
|
}
|
|
238
272
|
}
|
|
239
273
|
export const SECRET_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
|
|
240
|
-
|
|
241
|
-
|
|
274
|
+
/**
|
|
275
|
+
* How a `signing_secret:` is shown in an answer, a log line or a delivery
|
|
276
|
+
* record: the name it holds, and only a name (30 Oct 2026). It is meant to
|
|
277
|
+
* name a vault entry, but nothing stopped the value itself being written
|
|
278
|
+
* there, and notify's test send echoed it back to the dashboard as "signed
|
|
279
|
+
* with <it>". Anything that is not a secret name is described, never
|
|
280
|
+
* repeated (foldrun check flags it as secret/signing-secret-value).
|
|
281
|
+
*/
|
|
282
|
+
export function signingSecretShown(name) {
|
|
283
|
+
return SECRET_NAME.test(name) ? name : "the signing_secret: (not a secret name — see foldrun check)";
|
|
284
|
+
}
|
|
285
|
+
/** A list as stored: trimmed, de-duplicated, sorted; every name a safe one
|
|
286
|
+
* and, when the caller can say, one that exists. */
|
|
287
|
+
function cleanWorkspaceList(workspaces) {
|
|
288
|
+
if (workspaces === null)
|
|
289
|
+
return null;
|
|
290
|
+
if (!Array.isArray(workspaces) || workspaces.some((w) => typeof w !== "string")) {
|
|
291
|
+
throw new Error("workspaces is a list of workspace names, or null for every workspace");
|
|
292
|
+
}
|
|
293
|
+
const clean = [...new Set(workspaces.map((w) => w.trim()).filter(Boolean))].sort();
|
|
294
|
+
for (const w of clean)
|
|
295
|
+
assertSafeName(w, "workspace");
|
|
296
|
+
return clean;
|
|
297
|
+
}
|
|
298
|
+
function assertWorkspacesExist(list, exists) {
|
|
299
|
+
if (!list || !exists)
|
|
300
|
+
return;
|
|
301
|
+
const missing = list.filter((w) => !exists(w));
|
|
302
|
+
if (missing.length)
|
|
303
|
+
throw Object.assign(new Error(`no workspace ${missing.join(", ")} in this account`), { status: 400 });
|
|
304
|
+
}
|
|
305
|
+
export function setSecret(tenant, name, value, workspace, kind, limit = {}) {
|
|
306
|
+
// A string, not anything that prints as one: RegExp.test and the vault's
|
|
307
|
+
// keys both coerce, so ["RESEND_API_KEY"] from a JSON body was stored as
|
|
308
|
+
// RESEND_API_KEY past a caller's check on the name (30 Oct 2026).
|
|
309
|
+
if (typeof name !== "string" || !SECRET_NAME.test(name)) {
|
|
242
310
|
throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
|
|
243
311
|
}
|
|
244
312
|
if (value.length > 8192)
|
|
245
313
|
throw new Error("secret too long");
|
|
314
|
+
const wanted = limitFor(workspace, limit);
|
|
246
315
|
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
247
316
|
// A new value keeps the sites the old one was bound to: binding is a
|
|
248
317
|
// property of the credential's use, not of its current value, and a
|
|
249
318
|
// rotation that silently unbound it would put the new value in pods.
|
|
250
319
|
// Sealed here, under the lock: a bound record's AAD names those sites.
|
|
251
320
|
const sites = all[name]?.sites;
|
|
321
|
+
// The workspaces it is limited to stay too, for the same reason: a new
|
|
322
|
+
// value that every workspace could suddenly read is a widening nobody
|
|
323
|
+
// asked for — unless this call says otherwise, in the same write.
|
|
324
|
+
if (wanted !== undefined)
|
|
325
|
+
assertWorkspacesExist(wanted, limit.exists);
|
|
326
|
+
const workspaces = wanted !== undefined ? (wanted ?? undefined) : all[name]?.workspaces;
|
|
252
327
|
// Once bound, always bound: a re-save with the switch off must not
|
|
253
|
-
// quietly drop the record's authentication.
|
|
254
|
-
|
|
328
|
+
// quietly drop the record's authentication. A limited record is always
|
|
329
|
+
// bound, so its list is in its seal (see setSecretWorkspaces).
|
|
330
|
+
all[name] = sealRecord(tenant, workspace, name, value, { kind, sites, workspaces }, all[name]?.v === 2 || workspaces !== undefined || writeBound());
|
|
255
331
|
return all;
|
|
256
332
|
});
|
|
257
333
|
}
|
|
334
|
+
/** The list a write was asked to store, checked: undefined when it was not
|
|
335
|
+
* asked to change it. Only the account's store has lists. */
|
|
336
|
+
function limitFor(workspace, limit) {
|
|
337
|
+
if (limit.workspaces === undefined)
|
|
338
|
+
return undefined;
|
|
339
|
+
if (workspace)
|
|
340
|
+
throw new Error("a workspace's own secret is that workspace's already — only an account secret is limited to workspaces");
|
|
341
|
+
return cleanWorkspaceList(limit.workspaces);
|
|
342
|
+
}
|
|
258
343
|
/**
|
|
259
344
|
* Bind a secret to sites, or unbind it (an empty list). The secret must
|
|
260
345
|
* exist in that scope. Sites are `api.example.com` or `*.example.com` — no
|
|
261
346
|
* scheme, path or port. Returns the list as stored.
|
|
262
347
|
*/
|
|
263
348
|
export function setSecretSites(tenant, name, sites, workspace) {
|
|
349
|
+
// all[name] would find ["NAME"] as NAME: see setSecret.
|
|
350
|
+
if (typeof name !== "string")
|
|
351
|
+
throw new Error("a secret's name is a string");
|
|
264
352
|
const clean = [...new Set(sites.map((x) => x.trim().toLowerCase()).filter(Boolean))];
|
|
265
353
|
for (const site of clean) {
|
|
266
354
|
if (!SITE.test(site))
|
|
@@ -290,7 +378,7 @@ export function setSecretSites(tenant, name, sites, workspace) {
|
|
|
290
378
|
const value = openRecord(tenant, workspace, name, rec);
|
|
291
379
|
if (value === null)
|
|
292
380
|
throw new Error(`${name} cannot be read with this install's keys — set its value again before binding it`);
|
|
293
|
-
all[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: clean, updatedAt: rec.updatedAt }, true);
|
|
381
|
+
all[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: clean, workspaces: rec.workspaces, updatedAt: rec.updatedAt }, true);
|
|
294
382
|
return all;
|
|
295
383
|
}
|
|
296
384
|
if (clean.length)
|
|
@@ -317,7 +405,11 @@ export function secretSites(tenant, names, workspace) {
|
|
|
317
405
|
// when it opens, else the account's. Picking by presence paired an
|
|
318
406
|
// account value with a workspace record's (unauthenticated) sites.
|
|
319
407
|
const ownRec = own[name];
|
|
320
|
-
const
|
|
408
|
+
const ownOpens = Boolean(ownRec && openRecord(tenant, workspace, name, ownRec) !== null);
|
|
409
|
+
// An account record limited to other workspaces is not this one's.
|
|
410
|
+
if (!ownOpens && workspace && !usableIn(account[name], workspace))
|
|
411
|
+
continue;
|
|
412
|
+
const rec = ownOpens ? ownRec : account[name];
|
|
321
413
|
if (rec?.sites?.length)
|
|
322
414
|
out[name] = [...rec.sites];
|
|
323
415
|
}
|
|
@@ -392,16 +484,141 @@ function decrypt(tenant, rec, aad) {
|
|
|
392
484
|
return null; // key rotated or file tampered with
|
|
393
485
|
}
|
|
394
486
|
}
|
|
395
|
-
/** Nearest wins: a workspace secret shadows an account one of the same name.
|
|
487
|
+
/** Nearest wins: a workspace secret shadows an account one of the same name.
|
|
488
|
+
* An account secret limited to some workspaces (setSecretWorkspaces) is
|
|
489
|
+
* absent for every other — the answer a secret that was never set gets, on
|
|
490
|
+
* every path that resolves one for a workspace: runs, tool tests, notify,
|
|
491
|
+
* the egress proxy, MCP servers. Without a workspace it is the account's
|
|
492
|
+
* own use (its model key, its mail sender), which no list limits. */
|
|
396
493
|
export function getSecret(tenant, name, workspace) {
|
|
397
494
|
if (workspace) {
|
|
398
495
|
const own = openRecord(tenant, workspace, name, read(tenant, workspace)[name]);
|
|
399
496
|
if (own !== null)
|
|
400
497
|
return { value: own, scope: "workspace" };
|
|
401
498
|
}
|
|
402
|
-
const
|
|
499
|
+
const rec = read(tenant)[name];
|
|
500
|
+
if (workspace && !usableIn(rec, workspace))
|
|
501
|
+
return null;
|
|
502
|
+
const shared = openRecord(tenant, undefined, name, rec);
|
|
403
503
|
return shared === null ? null : { value: shared, scope: "account" };
|
|
404
504
|
}
|
|
505
|
+
/** May the ACCOUNT's secret `name` be used for this workspace? True when it
|
|
506
|
+
* is not limited (or not set); for callers that read the account's copy on
|
|
507
|
+
* a workspace's behalf without getSecret's workspace-first lookup — the
|
|
508
|
+
* account's mail sender for a workspace's notifications (notify.ts). */
|
|
509
|
+
export function accountSecretUsableIn(tenant, name, workspace) {
|
|
510
|
+
return usableIn(read(tenant)[name], workspace);
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Limit an account secret to some workspaces, or lift the limit (null).
|
|
514
|
+
* An empty list means no workspace may use it. Returns the list as stored,
|
|
515
|
+
* sorted, or null.
|
|
516
|
+
*
|
|
517
|
+
* `exists` is asked about each name under the vault lock (the API passes
|
|
518
|
+
* "is there such a workspace"), so it cannot grant a name a delete has just
|
|
519
|
+
* taken off every list. `ifWorkspaces`, when given, is the list the caller
|
|
520
|
+
* saw (null: every workspace): a list changed since is refused (409), so
|
|
521
|
+
* two admins editing it at once do not silently overwrite each other.
|
|
522
|
+
*
|
|
523
|
+
* The record is sealed (v2) whatever it was, whatever
|
|
524
|
+
* FOLDRUN_VAULT_WRITE_BOUND says: a list in the clear on an unbound record
|
|
525
|
+
* could be stripped on disk to open the secret to every workspace; sealed,
|
|
526
|
+
* a stripped list makes it unreadable. Every reader since 29 Oct 2026 reads v2.
|
|
527
|
+
*/
|
|
528
|
+
export function setSecretWorkspaces(tenant, name, workspaces, opts = {}) {
|
|
529
|
+
if (typeof name !== "string")
|
|
530
|
+
throw new Error("a secret's name is a string");
|
|
531
|
+
const clean = cleanWorkspaceList(workspaces);
|
|
532
|
+
const expected = opts.ifWorkspaces === undefined ? undefined : cleanWorkspaceList(opts.ifWorkspaces);
|
|
533
|
+
let found = false;
|
|
534
|
+
mutateVaultFile(secretsFile(tenant), (all) => {
|
|
535
|
+
const rec = all[name];
|
|
536
|
+
if (!rec)
|
|
537
|
+
return null;
|
|
538
|
+
found = true;
|
|
539
|
+
if (expected !== undefined) {
|
|
540
|
+
const now = Array.isArray(rec.workspaces) ? [...rec.workspaces].sort() : null;
|
|
541
|
+
if (JSON.stringify(now) !== JSON.stringify(expected)) {
|
|
542
|
+
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 });
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
assertWorkspacesExist(clean, opts.exists);
|
|
546
|
+
all[name] = withWorkspaces(tenant, name, rec, clean);
|
|
547
|
+
return all;
|
|
548
|
+
});
|
|
549
|
+
if (!found)
|
|
550
|
+
throw Object.assign(new Error(`no secret ${name} in the account`), { status: 404 });
|
|
551
|
+
return clean;
|
|
552
|
+
}
|
|
553
|
+
/** An account record with its workspace list replaced (null: removed),
|
|
554
|
+
* opened and sealed again as a bound record with the new list in its seal.
|
|
555
|
+
* One that will not open is refused, never relabelled (setSecretSites). */
|
|
556
|
+
function withWorkspaces(tenant, name, rec, workspaces) {
|
|
557
|
+
const value = openRecord(tenant, undefined, name, rec);
|
|
558
|
+
if (value === null)
|
|
559
|
+
throw new Error(`${name} cannot be read with this install's keys — set its value again before changing its workspaces`);
|
|
560
|
+
return sealRecord(tenant, undefined, name, value, { kind: rec.kind, sites: rec.sites, workspaces: workspaces ?? undefined, updatedAt: rec.updatedAt }, true);
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* A workspace is being renamed (to: its new name) or deleted (to: null):
|
|
564
|
+
* every account secret limited to it follows. Without this a rename quietly
|
|
565
|
+
* cut the workspace off its secrets, and a delete left the name on the list
|
|
566
|
+
* for the next workspace given it.
|
|
567
|
+
*
|
|
568
|
+
* `failed` names the records that hold the name but will not open with this
|
|
569
|
+
* install's keys: they are left exactly as they are, so the old name is
|
|
570
|
+
* STILL on their list — a key restored later would bring the grant back.
|
|
571
|
+
* The caller must not report the rename or delete as clean while any are
|
|
572
|
+
* left (the API refuses it, naming them: set them again, or delete them).
|
|
573
|
+
*
|
|
574
|
+
* Names, not ids: a grant naming a workspace's immutable id would survive a
|
|
575
|
+
* rename untouched and could never pass to a new workspace given an old
|
|
576
|
+
* name — the stronger design, not built yet (30 Oct 2026).
|
|
577
|
+
*/
|
|
578
|
+
export function retargetSecretWorkspaces(tenant, from, to) {
|
|
579
|
+
const changed = [];
|
|
580
|
+
const failed = [];
|
|
581
|
+
const file = secretsFile(tenant);
|
|
582
|
+
if (!fs.existsSync(file))
|
|
583
|
+
return { changed, failed };
|
|
584
|
+
mutateVaultFile(file, (all) => {
|
|
585
|
+
changed.length = 0;
|
|
586
|
+
failed.length = 0;
|
|
587
|
+
const staged = {};
|
|
588
|
+
for (const [name, rec] of Object.entries(all)) {
|
|
589
|
+
const list = rec.workspaces;
|
|
590
|
+
if (list === undefined)
|
|
591
|
+
continue;
|
|
592
|
+
if (!Array.isArray(list)) {
|
|
593
|
+
// A list rewritten on disk into something else allows nowhere
|
|
594
|
+
// (usableIn) — nothing for this rename to carry.
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
if (!list.includes(from))
|
|
598
|
+
continue;
|
|
599
|
+
const next = [...new Set(list.map((w) => (w === from ? to : w)).filter((w) => typeof w === "string"))].sort();
|
|
600
|
+
try {
|
|
601
|
+
staged[name] = withWorkspaces(tenant, name, rec, next);
|
|
602
|
+
changed.push(name);
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
failed.push(name);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// All or nothing (Codex, 30 Oct 2026): writing the readable records and
|
|
609
|
+
// refusing the rename for the unreadable ones moved grants to a name the
|
|
610
|
+
// workspace never took. One failure leaves the vault exactly as it was.
|
|
611
|
+
if (failed.length) {
|
|
612
|
+
changed.length = 0;
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
if (!changed.length)
|
|
616
|
+
return null;
|
|
617
|
+
Object.assign(all, staged);
|
|
618
|
+
return all;
|
|
619
|
+
});
|
|
620
|
+
return { changed, failed };
|
|
621
|
+
}
|
|
405
622
|
export function deleteSecret(tenant, name, workspace) {
|
|
406
623
|
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
407
624
|
if (!(name in all))
|
|
@@ -414,12 +631,18 @@ export function deleteSecret(tenant, name, workspace) {
|
|
|
414
631
|
// store; with one it lists what that workspace actually resolves, marking
|
|
415
632
|
// which scope each name comes from.
|
|
416
633
|
export function listSecrets(tenant, workspace) {
|
|
417
|
-
const
|
|
634
|
+
const records = Object.entries(read(tenant));
|
|
635
|
+
const account = records
|
|
636
|
+
// What a workspace resolves leaves out an account secret limited to
|
|
637
|
+
// others: it is not set, as far as that workspace is concerned.
|
|
638
|
+
.filter(([, rec]) => !workspace || usableIn(rec, workspace))
|
|
639
|
+
.map(([name, rec]) => ({
|
|
418
640
|
name,
|
|
419
641
|
updatedAt: rec.updatedAt,
|
|
420
642
|
scope: "account",
|
|
421
643
|
...(rec.kind ? { kind: rec.kind } : {}),
|
|
422
644
|
...(rec.sites?.length ? { sites: [...rec.sites] } : {}),
|
|
645
|
+
...(!workspace && Array.isArray(rec.workspaces) ? { workspaces: [...rec.workspaces] } : {}),
|
|
423
646
|
}));
|
|
424
647
|
if (!workspace)
|
|
425
648
|
return account.sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -553,6 +776,9 @@ export function rotateMasterKey(tenant, from, to) {
|
|
|
553
776
|
// Kept too (it was dropped until 29 Oct 2026): a rotation that
|
|
554
777
|
// unbinds every bound secret puts their values into pods.
|
|
555
778
|
...(rec.sites?.length ? { sites: rec.sites } : {}),
|
|
779
|
+
// And the workspaces it is limited to: dropped, it would read as
|
|
780
|
+
// "every workspace" (and fail its seal).
|
|
781
|
+
...(rec.workspaces !== undefined ? { workspaces: rec.workspaces } : {}),
|
|
556
782
|
...(aad ? { v: 2 } : {}),
|
|
557
783
|
};
|
|
558
784
|
count++;
|
|
@@ -572,7 +798,7 @@ export function oauthRetryDelayMs(n) {
|
|
|
572
798
|
const base = Number(process.env.FOLDRUN_OAUTH_RETRY_MS ?? 1000);
|
|
573
799
|
return base * (n === 1 ? 1 : 3);
|
|
574
800
|
}
|
|
575
|
-
export function setOAuth2Secret(tenant, name, config, workspace) {
|
|
801
|
+
export function setOAuth2Secret(tenant, name, config, workspace, limit = {}) {
|
|
576
802
|
for (const field of ["token_url", "client_id"]) {
|
|
577
803
|
if (!config[field]?.trim())
|
|
578
804
|
throw new Error(`oauth2 secret needs ${field}`);
|
|
@@ -590,7 +816,7 @@ export function setOAuth2Secret(tenant, name, config, workspace) {
|
|
|
590
816
|
if (!/^https:\/\//.test(config.token_url) && !/^http:\/\/(127\.0\.0\.1|localhost)[:/]/.test(config.token_url)) {
|
|
591
817
|
throw new Error("token_url must be https — a refresh token over http is a leaked one");
|
|
592
818
|
}
|
|
593
|
-
setSecret(tenant, name, OAUTH2_PREFIX + JSON.stringify(config), workspace, "oauth2");
|
|
819
|
+
setSecret(tenant, name, OAUTH2_PREFIX + JSON.stringify(config), workspace, "oauth2", limit);
|
|
594
820
|
}
|
|
595
821
|
export function isOAuth2Value(value) {
|
|
596
822
|
return value.startsWith(OAUTH2_PREFIX);
|
|
@@ -632,7 +858,7 @@ export function replaceOAuth2RefreshToken(tenant, name, workspace, usedValue, re
|
|
|
632
858
|
// Built whole, not spread over the old record: a spread kept a stale
|
|
633
859
|
// `k: "tenant"` when this seal fell back to the install key, and the
|
|
634
860
|
// record then opened with neither.
|
|
635
|
-
all[name] = sealRecord(tenant, scope, name, next, { kind: rec.kind, sites: rec.sites }, rec.v === 2 || writeBound());
|
|
861
|
+
all[name] = sealRecord(tenant, scope, name, next, { kind: rec.kind, sites: rec.sites, workspaces: rec.workspaces }, rec.v === 2 || writeBound());
|
|
636
862
|
replaced = true;
|
|
637
863
|
}
|
|
638
864
|
return all;
|
|
@@ -802,7 +1028,7 @@ ctx) {
|
|
|
802
1028
|
// Stored as the JSON key, encrypted. Materialised the same way oauth2 is: a
|
|
803
1029
|
// live access token appears at the last host-side moment, cached until expiry.
|
|
804
1030
|
const SERVICE_ACCOUNT_PREFIX = "@service-account ";
|
|
805
|
-
export function setServiceAccountSecret(tenant, name, config, workspace) {
|
|
1031
|
+
export function setServiceAccountSecret(tenant, name, config, workspace, limit = {}) {
|
|
806
1032
|
for (const field of ["token_url", "issuer", "private_key", "scope"]) {
|
|
807
1033
|
if (!config[field]?.trim())
|
|
808
1034
|
throw new Error(`service account secret needs ${field}`);
|
|
@@ -817,7 +1043,7 @@ export function setServiceAccountSecret(tenant, name, config, workspace) {
|
|
|
817
1043
|
throw new Error("subject is one identifier, no spaces or control characters");
|
|
818
1044
|
}
|
|
819
1045
|
const stored = { ...config, ...(config.subject !== undefined ? { subject: config.subject.trim() } : {}) };
|
|
820
|
-
setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(stored), workspace, "service-account");
|
|
1046
|
+
setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(stored), workspace, "service-account", limit);
|
|
821
1047
|
}
|
|
822
1048
|
function base64url(input) {
|
|
823
1049
|
return Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
@@ -880,10 +1106,10 @@ async function exchangeServiceAccount(config, cacheKey) {
|
|
|
880
1106
|
// the only place that knows where a run's writable scratch is; here we only
|
|
881
1107
|
// mark the kind and carry the content.
|
|
882
1108
|
const FILE_PREFIX = "@file ";
|
|
883
|
-
export function setFileSecret(tenant, name, content, workspace) {
|
|
1109
|
+
export function setFileSecret(tenant, name, content, workspace, limit = {}) {
|
|
884
1110
|
if (!content)
|
|
885
1111
|
throw new Error("a file secret needs content");
|
|
886
|
-
setSecret(tenant, name, FILE_PREFIX + content, workspace, "file");
|
|
1112
|
+
setSecret(tenant, name, FILE_PREFIX + content, workspace, "file", limit);
|
|
887
1113
|
}
|
|
888
1114
|
export function isFileValue(value) {
|
|
889
1115
|
return value.startsWith(FILE_PREFIX);
|
|
@@ -899,7 +1125,7 @@ export function fileContent(value) {
|
|
|
899
1125
|
// config into an executable wrapper, so the agent runs `"$NAME" 'uptime'`
|
|
900
1126
|
// and never learns which auth flavour it is.
|
|
901
1127
|
const SSH_PREFIX = "@ssh ";
|
|
902
|
-
export function setSshSecret(tenant, name, config, workspace) {
|
|
1128
|
+
export function setSshSecret(tenant, name, config, workspace, limit = {}) {
|
|
903
1129
|
const host = config.host?.trim();
|
|
904
1130
|
const user = config.user?.trim();
|
|
905
1131
|
if (!host || /[\s'"`$\\]/.test(host))
|
|
@@ -922,7 +1148,7 @@ export function setSshSecret(tenant, name, config, workspace) {
|
|
|
922
1148
|
host, user, port,
|
|
923
1149
|
...(hasKey ? { private_key: config.private_key } : { password: config.password }),
|
|
924
1150
|
};
|
|
925
|
-
setSecret(tenant, name, SSH_PREFIX + JSON.stringify(stored), workspace, "ssh");
|
|
1151
|
+
setSecret(tenant, name, SSH_PREFIX + JSON.stringify(stored), workspace, "ssh", limit);
|
|
926
1152
|
}
|
|
927
1153
|
export function isSshValue(value) {
|
|
928
1154
|
return value.startsWith(SSH_PREFIX);
|
|
@@ -938,7 +1164,7 @@ export function sshConfigOf(value) {
|
|
|
938
1164
|
// never appears in the bash command at all.
|
|
939
1165
|
const API_PREFIX = "@api ";
|
|
940
1166
|
const CLIENT_CERT_PREFIX = "@client-cert ";
|
|
941
|
-
export function setClientCertSecret(tenant, name, config, workspace) {
|
|
1167
|
+
export function setClientCertSecret(tenant, name, config, workspace, limit = {}) {
|
|
942
1168
|
if (!/-----BEGIN CERTIFICATE-----/.test(config.cert ?? ""))
|
|
943
1169
|
throw new Error("cert must be a PEM certificate");
|
|
944
1170
|
if (!/-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----/.test(config.key ?? ""))
|
|
@@ -961,29 +1187,33 @@ export function setClientCertSecret(tenant, name, config, workspace) {
|
|
|
961
1187
|
...(config.passphrase ? { passphrase: config.passphrase } : {}),
|
|
962
1188
|
...(config.export ? { export: true } : {}),
|
|
963
1189
|
};
|
|
964
|
-
if (!SECRET_NAME.test(name))
|
|
1190
|
+
if (typeof name !== "string" || !SECRET_NAME.test(name))
|
|
965
1191
|
throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
|
|
966
1192
|
const plainCert = CLIENT_CERT_PREFIX + JSON.stringify(stored);
|
|
967
1193
|
// Written under the vault's own lock, with the binding checked in the same
|
|
968
1194
|
// step: a value set over a secret keeps its site binding, and a wildcard
|
|
969
1195
|
// one would present the certificate to a whole domain.
|
|
1196
|
+
const wanted = limitFor(workspace, limit);
|
|
970
1197
|
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
971
1198
|
const sites = all[name]?.sites;
|
|
1199
|
+
if (wanted !== undefined)
|
|
1200
|
+
assertWorkspacesExist(wanted, limit.exists);
|
|
1201
|
+
const workspaces = wanted !== undefined ? (wanted ?? undefined) : all[name]?.workspaces;
|
|
972
1202
|
if (sites?.some((site) => site.startsWith("*."))) {
|
|
973
1203
|
throw new Error(`${name} is bound to ${sites.join(", ")} — a client certificate is bound to exact hosts; unbind the wildcard first`);
|
|
974
1204
|
}
|
|
975
|
-
all[name] = sealRecord(tenant, workspace, name, plainCert, { kind: "client-cert", sites }, all[name]?.v === 2 || writeBound());
|
|
1205
|
+
all[name] = sealRecord(tenant, workspace, name, plainCert, { kind: "client-cert", sites, workspaces }, all[name]?.v === 2 || workspaces !== undefined || writeBound());
|
|
976
1206
|
return all;
|
|
977
1207
|
});
|
|
978
1208
|
}
|
|
979
1209
|
const IDENTITY_PREFIX = "@identity ";
|
|
980
|
-
export function setIdentitySecret(tenant, name, config, workspace) {
|
|
1210
|
+
export function setIdentitySecret(tenant, name, config, workspace, limit = {}) {
|
|
981
1211
|
const audience = typeof config.audience === "string" ? config.audience.trim() : "";
|
|
982
1212
|
if (!audience)
|
|
983
1213
|
throw new Error("an identity needs an audience — the value your cloud's trust expects in aud");
|
|
984
1214
|
if (audience.length > 600 || /[\u0000-\u001f\u007f]/.test(audience))
|
|
985
1215
|
throw new Error("audience is one value, at most 600 characters, no control characters");
|
|
986
|
-
setSecret(tenant, name, IDENTITY_PREFIX + JSON.stringify({ audience }), workspace, "identity");
|
|
1216
|
+
setSecret(tenant, name, IDENTITY_PREFIX + JSON.stringify({ audience }), workspace, "identity", limit);
|
|
987
1217
|
}
|
|
988
1218
|
export function isIdentityValue(value) {
|
|
989
1219
|
return value.startsWith(IDENTITY_PREFIX);
|
|
@@ -1015,7 +1245,7 @@ export function basicAuthorization(username, password) {
|
|
|
1015
1245
|
throw new Error("Basic credentials cannot contain control characters");
|
|
1016
1246
|
return `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`;
|
|
1017
1247
|
}
|
|
1018
|
-
export function setApiSecret(tenant, name, config, workspace) {
|
|
1248
|
+
export function setApiSecret(tenant, name, config, workspace, limit = {}) {
|
|
1019
1249
|
let all = { ...(config.headers ?? {}) };
|
|
1020
1250
|
if (config.basic) {
|
|
1021
1251
|
if (Object.keys(all).some((h) => h.toLowerCase() === "authorization")) {
|
|
@@ -1050,7 +1280,7 @@ export function setApiSecret(tenant, name, config, workspace) {
|
|
|
1050
1280
|
const base = config.base_url?.trim();
|
|
1051
1281
|
if (base && !/^https?:\/\//.test(base))
|
|
1052
1282
|
throw new Error("base_url must be http(s)");
|
|
1053
|
-
setSecret(tenant, name, API_PREFIX + JSON.stringify({ ...(base ? { base_url: trimSlashes(base) } : {}), headers: config.headers }), workspace, "api");
|
|
1283
|
+
setSecret(tenant, name, API_PREFIX + JSON.stringify({ ...(base ? { base_url: trimSlashes(base) } : {}), headers: config.headers }), workspace, "api", limit);
|
|
1054
1284
|
}
|
|
1055
1285
|
export function isApiValue(value) {
|
|
1056
1286
|
return value.startsWith(API_PREFIX);
|
|
@@ -1088,7 +1318,7 @@ export function sealVaultBindings(tenant) {
|
|
|
1088
1318
|
const next = { ...all };
|
|
1089
1319
|
for (const [name, value] of plain) {
|
|
1090
1320
|
const rec = all[name];
|
|
1091
|
-
next[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: rec.sites, updatedAt: rec.updatedAt }, true);
|
|
1321
|
+
next[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: rec.sites, workspaces: rec.workspaces, updatedAt: rec.updatedAt }, true);
|
|
1092
1322
|
}
|
|
1093
1323
|
sealed += plain.size;
|
|
1094
1324
|
return next;
|
|
@@ -1136,7 +1366,7 @@ export function rewrapTenantSecrets(tenant) {
|
|
|
1136
1366
|
return null;
|
|
1137
1367
|
const next = { ...all };
|
|
1138
1368
|
for (const [name, value] of plain) {
|
|
1139
|
-
next[name] = sealRecord(tenant, workspace, name, value, { kind: all[name].kind, sites: all[name].sites, updatedAt: all[name].updatedAt }, all[name].v === 2 || writeBound());
|
|
1369
|
+
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());
|
|
1140
1370
|
}
|
|
1141
1371
|
moved += plain.size;
|
|
1142
1372
|
return next;
|
package/dist/src/storage.d.ts
CHANGED
|
@@ -22,6 +22,15 @@ interface Index {
|
|
|
22
22
|
version: 1;
|
|
23
23
|
files: FileRecord[];
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Refuse a workspace that is not there, as a 404 (30 Oct 2026).
|
|
27
|
+
*
|
|
28
|
+
* The store is keyed by workspace *name* and used to be created for any safe
|
|
29
|
+
* name on first write, so an editor got a fresh quota for every name they
|
|
30
|
+
* invented — stores no workspace list shows and no delete reaches. Every
|
|
31
|
+
* write here, and the routes that read, ask this first.
|
|
32
|
+
*/
|
|
33
|
+
export declare function requireWorkspace(tenant: string, workspace: string): void;
|
|
25
34
|
/**
|
|
26
35
|
* Hosted only. On a laptop `storage/` is just a folder in the user's own
|
|
27
36
|
* project — they can see it, open it, and put a PDF in it with Finder — and
|
|
@@ -57,6 +66,18 @@ export interface Driver {
|
|
|
57
66
|
put(key: string, body: Buffer, mime: string): Promise<void>;
|
|
58
67
|
get(key: string): Promise<Buffer>;
|
|
59
68
|
has(key: string): Promise<boolean>;
|
|
69
|
+
/** The object's real length, or null when there is none — what the store
|
|
70
|
+
* holds, never what a browser said it sent (30 Oct 2026). */
|
|
71
|
+
size(key: string): Promise<number | null>;
|
|
72
|
+
/** The bytes as a stream, optionally one inclusive byte range — so a route
|
|
73
|
+
* hands a file on without holding all of it in memory. */
|
|
74
|
+
open(key: string, range?: {
|
|
75
|
+
start: number;
|
|
76
|
+
end: number;
|
|
77
|
+
}): Promise<ReadableStream<Uint8Array>>;
|
|
78
|
+
/** Server-side copy to another key (s3 only — the fs driver's blobs move
|
|
79
|
+
* with their directory). Used when a workspace is renamed. */
|
|
80
|
+
copy?(from: string, to: string): Promise<void>;
|
|
60
81
|
remove(key: string): Promise<void>;
|
|
61
82
|
/** A URL a browser can GET directly, or null when the driver has no such
|
|
62
83
|
* thing (fs) and the route must stream the bytes itself. */
|
|
@@ -65,7 +86,9 @@ export interface Driver {
|
|
|
65
86
|
presignGet(key: string, filename: string, mime: string, ttlSec?: number, inlineType?: string): Promise<string | null>;
|
|
66
87
|
/** A URL a browser can PUT directly to, so upload bytes never transit the
|
|
67
88
|
* dashboard process. Null on fs, where the route takes the body. */
|
|
68
|
-
|
|
89
|
+
/** `size`: sign the length in, so the store refuses a PUT of any other
|
|
90
|
+
* length (30 Oct 2026) — the per-file limit then holds at the bucket. */
|
|
91
|
+
presignPut(key: string, mime: string, ttlSec?: number, size?: number): Promise<string | null>;
|
|
69
92
|
}
|
|
70
93
|
/** Where the fs driver keeps a blob. Exported because the run mirror
|
|
71
94
|
* hardlinks straight to it rather than reading and rewriting the bytes. */
|
|
@@ -101,9 +124,33 @@ export declare function contentDisposition(type: "inline" | "attachment", filena
|
|
|
101
124
|
export declare function driverFor(tenant: string, workspace: string): Driver;
|
|
102
125
|
export declare function putFile(tenant: string, workspace: string, rel: string, body: Buffer, by: string): Promise<FileRecord>;
|
|
103
126
|
/** Register bytes a browser PUT straight to the bucket. Same bookkeeping as
|
|
104
|
-
* putFile, minus the bytes — the platform never saw them.
|
|
105
|
-
|
|
127
|
+
* putFile, minus the bytes — the platform never saw them.
|
|
128
|
+
*
|
|
129
|
+
* `_claimed` is the size the browser says it sent, kept for the callers that
|
|
130
|
+
* pass it and never believed (30 Oct 2026): the record, the per-file limit
|
|
131
|
+
* and the quota all use the length the store reports. It used to store the
|
|
132
|
+
* claim, so an object registered as 0 bytes (or -1e12) sat outside every
|
|
133
|
+
* limit and lowered the quota line for everything after it. */
|
|
134
|
+
export declare function registerUpload(tenant: string, workspace: string, rel: string, sha: string, _claimed: number, by: string): Promise<FileRecord>;
|
|
106
135
|
export declare function readFileBytes(tenant: string, workspace: string, rel: string): Promise<Buffer | null>;
|
|
136
|
+
/** A stored file's real length, from the store — a stat or a HEAD, never a
|
|
137
|
+
* read. Null when there is no such record or no object behind it. */
|
|
138
|
+
export declare function storedSize(tenant: string, workspace: string, rel: string): Promise<number | null>;
|
|
139
|
+
/**
|
|
140
|
+
* A stored file as a stream, with its real length — for a route that passes
|
|
141
|
+
* the bytes on (a download, a public link) rather than reading them whole
|
|
142
|
+
* into memory. Null when there is no such record or no object behind it.
|
|
143
|
+
* `range` is inclusive and must lie inside the file (storedSize first, then
|
|
144
|
+
* byteRange, when the caller honours a Range header).
|
|
145
|
+
*/
|
|
146
|
+
export declare function openFile(tenant: string, workspace: string, rel: string, range?: {
|
|
147
|
+
start: number;
|
|
148
|
+
end: number;
|
|
149
|
+
} | null): Promise<{
|
|
150
|
+
record: FileRecord;
|
|
151
|
+
size: number;
|
|
152
|
+
body: ReadableStream<Uint8Array>;
|
|
153
|
+
} | null>;
|
|
107
154
|
export declare function deleteFile(tenant: string, workspace: string, rel: string): Promise<boolean>;
|
|
108
155
|
/** A link the browser can follow, or null when the route must stream it. */
|
|
109
156
|
export declare function downloadUrl(tenant: string, workspace: string, record: FileRecord, opts?: {
|
|
@@ -111,7 +158,32 @@ export declare function downloadUrl(tenant: string, workspace: string, record: F
|
|
|
111
158
|
}): Promise<string | null>;
|
|
112
159
|
export declare function previewType(rel: string): string | null;
|
|
113
160
|
/** A link the browser can PUT to, plus the key it will land at. Null on fs. */
|
|
114
|
-
export declare function uploadUrl(tenant: string, workspace: string, rel: string, sha: string): Promise<string | null>;
|
|
161
|
+
export declare function uploadUrl(tenant: string, workspace: string, rel: string, sha: string, size?: number): Promise<string | null>;
|
|
162
|
+
/**
|
|
163
|
+
* Drop everything a workspace stored: every object its index names, then the
|
|
164
|
+
* index (30 Oct 2026). Called when a workspace is deleted — the store is kept
|
|
165
|
+
* by name beside the folder, so it used to outlive it: the files stayed
|
|
166
|
+
* downloadable under the old name, public links kept serving them, and a new
|
|
167
|
+
* workspace given the same name inherited them. Best effort per object (a
|
|
168
|
+
* leaked object costs storage; the index going is what makes them
|
|
169
|
+
* unreachable), and a no-op when there is no store.
|
|
170
|
+
*/
|
|
171
|
+
export declare function purgeWorkspaceStorage(tenant: string, workspace: string): Promise<void>;
|
|
172
|
+
/**
|
|
173
|
+
* Move a workspace's stored files to its new name (30 Oct 2026). A rename
|
|
174
|
+
* used to move the folder only: the renamed workspace lost its files, and
|
|
175
|
+
* the old name kept them for whoever took it next.
|
|
176
|
+
*
|
|
177
|
+
* On fs the blobs live under the store's directory, so moving the directory
|
|
178
|
+
* is the whole move. On s3 an object's key carries the workspace name, so
|
|
179
|
+
* every object is copied first; only once all copies landed does the index
|
|
180
|
+
* move and the old objects go. A copy that fails leaves the store under the
|
|
181
|
+
* old name, whole, and throws — the caller can put the folder back.
|
|
182
|
+
*
|
|
183
|
+
* Whatever a store under the new name held is dropped first: it belonged to a
|
|
184
|
+
* workspace of that name that no longer exists, never to this one.
|
|
185
|
+
*/
|
|
186
|
+
export declare function moveWorkspaceStorage(tenant: string, from: string, to: string): Promise<void>;
|
|
115
187
|
/**
|
|
116
188
|
* Put the workspace's files on disk under `<workspace>/storage/`, where a run
|
|
117
189
|
* copies them into its container like any other directory.
|