@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.
Files changed (47) hide show
  1. package/dist/src/approvals.d.ts +18 -10
  2. package/dist/src/approvals.js +51 -1
  3. package/dist/src/catalog.d.ts +14 -0
  4. package/dist/src/catalog.js +28 -0
  5. package/dist/src/cron.d.ts +9 -0
  6. package/dist/src/cron.js +20 -0
  7. package/dist/src/frontmatter-fields.js +1 -1
  8. package/dist/src/frontmatter-schema.d.ts +5 -1
  9. package/dist/src/frontmatter-schema.js +40 -1
  10. package/dist/src/history.d.ts +5 -0
  11. package/dist/src/history.js +31 -4
  12. package/dist/src/hooks.d.ts +2 -0
  13. package/dist/src/hooks.js +13 -0
  14. package/dist/src/library.js +4 -0
  15. package/dist/src/notify.d.ts +21 -4
  16. package/dist/src/notify.js +105 -22
  17. package/dist/src/package.d.ts +5 -1
  18. package/dist/src/package.js +5 -3
  19. package/dist/src/runner.js +17 -9
  20. package/dist/src/scan.js +21 -0
  21. package/dist/src/secrets.d.ts +92 -10
  22. package/dist/src/secrets.js +258 -28
  23. package/dist/src/storage.d.ts +76 -4
  24. package/dist/src/storage.js +219 -22
  25. package/dist/src/store.d.ts +20 -2
  26. package/dist/src/store.js +50 -15
  27. package/dist/src/triggers.js +2 -2
  28. package/dist/src/webhook.d.ts +19 -3
  29. package/dist/src/webhook.js +19 -6
  30. package/package.json +1 -1
  31. package/src/approvals.ts +55 -1
  32. package/src/catalog.ts +27 -0
  33. package/src/cron.ts +19 -0
  34. package/src/frontmatter-fields.ts +1 -1
  35. package/src/frontmatter-schema.ts +38 -2
  36. package/src/history.ts +31 -4
  37. package/src/hooks.ts +14 -0
  38. package/src/library.ts +3 -0
  39. package/src/notify.ts +104 -23
  40. package/src/package.ts +5 -3
  41. package/src/runner.ts +17 -8
  42. package/src/scan.ts +23 -0
  43. package/src/secrets.ts +283 -34
  44. package/src/storage.ts +223 -19
  45. package/src/store.ts +49 -14
  46. package/src/triggers.ts +2 -2
  47. package/src/webhook.ts +26 -6
@@ -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
- return Buffer.from(JSON.stringify(["foldrun-vault/2", tenant, workspace ? "w" : "a", name, rec.kind ?? "", rec.k ?? "install", [...(rec.sites ?? [])].sort()]), "utf8");
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
- export function setSecret(tenant, name, value, workspace, kind) {
241
- if (!SECRET_NAME.test(name)) {
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
- all[name] = sealRecord(tenant, workspace, name, value, { kind, sites }, all[name]?.v === 2 || writeBound());
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 rec = ownRec && openRecord(tenant, workspace, name, ownRec) !== null ? ownRec : account[name];
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 shared = openRecord(tenant, undefined, name, read(tenant)[name]);
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 account = Object.entries(read(tenant)).map(([name, rec]) => ({
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;
@@ -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
- presignPut(key: string, mime: string, ttlSec?: number): Promise<string | null>;
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
- export declare function registerUpload(tenant: string, workspace: string, rel: string, sha: string, size: number, by: string): Promise<FileRecord>;
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.