@foldrun/core 8.0.0 → 9.0.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.
@@ -35,6 +35,11 @@ export interface RevisionSummary {
35
35
  commit: string | null;
36
36
  paths: string[];
37
37
  }
38
+ /** Refuse (400) a deploy's `commit` that cannot be a revision id. A writer
39
+ * calls this BEFORE it writes: checked only in recordRevision, after the
40
+ * files were replaced, a bad commit left the new source live with no
41
+ * revision, no snapshot and no audit entry (30 Oct 2026). */
42
+ export declare function assertCommitId(commit: string): void;
38
43
  /** A local revision id: sortable by time, unique enough, visibly not a sha. */
39
44
  export declare function newRevisionId(now?: Date): string;
40
45
  /**
@@ -55,6 +55,15 @@ const historyDir = (tenant, scope) => {
55
55
  assertSafeName(scope, "workspace");
56
56
  return path.join(dataRoot(), tenant, ".history", scope);
57
57
  };
58
+ /** Refuse (400) a deploy's `commit` that cannot be a revision id. A writer
59
+ * calls this BEFORE it writes: checked only in recordRevision, after the
60
+ * files were replaced, a bad commit left the new source live with no
61
+ * revision, no snapshot and no audit entry (30 Oct 2026). */
62
+ export function assertCommitId(commit) {
63
+ if (typeof commit !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._@-]{0,120}$/.test(commit)) {
64
+ throw Object.assign(new Error(`commit "${String(commit).slice(0, 130)}" is not a commit id — letters, digits and . _ @ -, at most 121 characters`), { status: 400 });
65
+ }
66
+ }
58
67
  /** A local revision id: sortable by time, unique enough, visibly not a sha. */
59
68
  export function newRevisionId(now = new Date()) {
60
69
  return `r-${now.getTime().toString(36)}-${crypto.randomBytes(3).toString("hex")}`;
@@ -72,8 +81,17 @@ export function recordRevision(tenant, scope, files, meta = {}) {
72
81
  return null;
73
82
  const dir = historyDir(tenant, scope);
74
83
  fs.mkdirSync(dir, { recursive: true });
84
+ // A commit is the revision's id only the first time it is recorded. A
85
+ // second deploy of one commit (a CI re-run after a dashboard edit), or a
86
+ // `commit` that names a revision already here, overwrote that revision's
87
+ // record and its snapshot (30 Oct 2026) — History could no longer restore
88
+ // to it. It gets its own id now; `commit` still says which one it was,
89
+ // and a restore by commit (resolveRevision) still finds it.
90
+ if (meta.commit)
91
+ assertCommitId(meta.commit);
92
+ const commitId = meta.commit && !fs.existsSync(path.join(dir, `${meta.commit}.json`)) ? meta.commit : null;
75
93
  const rev = {
76
- id: meta.id ?? meta.commit ?? newRevisionId(),
94
+ id: meta.id ?? commitId ?? newRevisionId(),
77
95
  at: new Date().toISOString(),
78
96
  by: meta.by ?? "system",
79
97
  message: meta.message ?? describe(changed),
@@ -252,7 +270,11 @@ export function resolveRevision(tenant, scope, to, now = Date.now()) {
252
270
  else {
253
271
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/.test(t))
254
272
  throw Object.assign(new Error(`not a revision or a time: ${to}`), { status: 400 });
255
- hit = all.find((r) => r.id === t || r.commit === t || (t.length >= 7 && r.commit?.startsWith(t)));
273
+ // An exact id first, then a commit: a commit deployed twice is recorded
274
+ // once under its own id and again under a fresh one naming it (30 Oct
275
+ // 2026, recordRevision), and newest-first with one predicate answered
276
+ // the first revision's id with the second's snapshot.
277
+ hit = all.find((r) => r.id === t) ?? all.find((r) => r.commit === t || (t.length >= 7 && r.commit?.startsWith(t)));
256
278
  }
257
279
  return hit ? { id: hit.id, at: hit.at, by: hit.by, message: hit.message } : null;
258
280
  }
@@ -353,8 +375,13 @@ export function changedPathsBetween(tenant, scope, from, to) {
353
375
  if (from === to)
354
376
  return [];
355
377
  const revs = listRevisions(tenant, scope, { limit: 500 }); // newest first
356
- const ti = revs.findIndex((r) => r.id === to || r.commit === to);
357
- const fi = revs.findIndex((r) => r.id === from || r.commit === from);
378
+ // An exact id before a commit, as resolveRevision.
379
+ const at = (x) => {
380
+ const byId = revs.findIndex((r) => r.id === x);
381
+ return byId >= 0 ? byId : revs.findIndex((r) => r.commit === x);
382
+ };
383
+ const ti = at(to);
384
+ const fi = at(from);
358
385
  if (ti < 0 || fi < 0 || ti > fi)
359
386
  return null;
360
387
  return [...new Set(revs.slice(ti, fi).flatMap((r) => r.paths))].sort();
@@ -6,6 +6,8 @@ export declare const HOOK_TIMEOUT_DEFAULT: {
6
6
  };
7
7
  export declare const HOOK_TIMEOUT_MAX = 120;
8
8
  export declare const HOOK_CODE_MAX: number;
9
+ /** A hook's name: its folder's name, never a path. */
10
+ export declare function isHookName(name: unknown): name is string;
9
11
  export interface HookDef {
10
12
  name: string;
11
13
  /** The folder holding hook.md and run.mjs. */
package/dist/src/hooks.js CHANGED
@@ -38,6 +38,10 @@ export const HOOK_TIMEOUT_DEFAULT = { gate: 30, watch: 60 };
38
38
  export const HOOK_TIMEOUT_MAX = 120;
39
39
  export const HOOK_CODE_MAX = 100 * 1024;
40
40
  const NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
41
+ /** A hook's name: its folder's name, never a path. */
42
+ export function isHookName(name) {
43
+ return typeof name === "string" && NAME.test(name);
44
+ }
41
45
  /** Read hook.md, or say in one sentence why it is not a hook. */
42
46
  export function readHookDef(dir, scope) {
43
47
  const name = path.basename(dir);
@@ -117,6 +121,15 @@ export function resolveHooks(args) {
117
121
  if (seen.has(name))
118
122
  continue;
119
123
  seen.add(name);
124
+ // Checked before the name is joined to anything (30 Oct 2026): readHookDef
125
+ // tested only basename(dir), so `../../<tenant>/workspaces/<ws>/hooks/<h>`
126
+ // passed, and the hook test route answered which folders exist anywhere
127
+ // on the server — on a laptop it would have run that run.mjs. An error,
128
+ // not missing: a step refuses either way, and the name is the problem.
129
+ if (!isHookName(name)) {
130
+ errors.push(`"${name}" is not a hook name — lowercase letters, digits and dashes (the hook's folder name)`);
131
+ continue;
132
+ }
120
133
  const own = path.join(args.workspaceRoot, "hooks", name);
121
134
  const shared = path.join(args.libraryRoot, "hooks", name);
122
135
  const dir = fs.existsSync(own) ? own : fs.existsSync(shared) ? shared : null;
@@ -234,6 +234,10 @@ export function deleteLibraryPath(tenant, kind, rel, meta = {}) {
234
234
  const norm = path.normalize(rel);
235
235
  if (norm.startsWith("..") || path.isAbsolute(norm))
236
236
  throw new Error(`illegal path: ${rel}`);
237
+ // "." (or "./", "a/..") is the shelf itself: one call took every shared
238
+ // tool or skill from every workspace (30 Oct 2026). Name what goes.
239
+ if (norm === "." || norm === "./")
240
+ throw new Error(`illegal path: ${rel} — name the file or folder to delete, not the whole ${kind} shelf`);
237
241
  const target = path.join(libraryDir(tenant, kind), norm);
238
242
  const gone = [];
239
243
  if (fs.existsSync(target)) {
@@ -78,10 +78,24 @@ export declare function nextAttemptAt(n: number, at: number): number | null;
78
78
  * delivery, redeliveries included, so a receiver dedupes on it. */
79
79
  export declare function newDeliveryId(): string;
80
80
  /** The URL with ${SECRET}s resolved, and how it may be shown: a Slack hook
81
- * URL is itself a credential, so it is never echoed resolved. */
81
+ * URL is itself a credential, so it is never echoed resolved.
82
+ *
83
+ * A secret bound to sites (`foldrun secrets sites`) is filled only when the
84
+ * resolved URL's host is one of them — the rule the egress proxy and the
85
+ * tool tester keep (30 Oct 2026). Before, notify.url filled any secret into
86
+ * any host, so an editor could write `https://their.server/?k=${STRIPE_KEY}`
87
+ * and press Test. The host is read AFTER filling, because the usual shape is
88
+ * the whole URL as one secret (`${SLACK_WEBHOOK_URL}`, bound to
89
+ * hooks.slack.com). An unbound secret fills as it does for an agent's
90
+ * `secrets:` and a tool test — workspace first, then the account — since
91
+ * binding is how an account says where a value may go. `refused` says why
92
+ * `url` is null when a binding stopped it; `filled` maps each value put
93
+ * into the URL to its name, so an error that echoes the URL can be scrubbed. */
82
94
  export declare function webhookTarget(tenant: string, workspace: string, config?: NotifyConfig | null): {
83
95
  url: string | null;
84
96
  shown: string;
97
+ refused?: string;
98
+ filled: Record<string, string>;
85
99
  };
86
100
  export interface WebhookAttempt {
87
101
  /** 1-based. */
@@ -33,7 +33,8 @@
33
33
  import crypto from "node:crypto";
34
34
  import { readAgentsMd } from "./runner.js";
35
35
  import { accountDir, workspaceDir, runCost, readFlow, runVerdict } from "./store.js";
36
- import { getSecret } from "./secrets.js";
36
+ import { getSecret, secretSites } from "./secrets.js";
37
+ import { siteCovers } from "./network.js";
37
38
  import { publicUrl } from "./webhook.js";
38
39
  import { approveLinkPath, approveLinkTtlMs } from "./approvals.js";
39
40
  import { noteSecretUse, healthKey } from "./secret-health.js";
@@ -180,15 +181,80 @@ export function newDeliveryId() {
180
181
  return `dlv_${crypto.randomUUID().replace(/-/g, "")}`;
181
182
  }
182
183
  /** The URL with ${SECRET}s resolved, and how it may be shown: a Slack hook
183
- * URL is itself a credential, so it is never echoed resolved. */
184
+ * URL is itself a credential, so it is never echoed resolved.
185
+ *
186
+ * A secret bound to sites (`foldrun secrets sites`) is filled only when the
187
+ * resolved URL's host is one of them — the rule the egress proxy and the
188
+ * tool tester keep (30 Oct 2026). Before, notify.url filled any secret into
189
+ * any host, so an editor could write `https://their.server/?k=${STRIPE_KEY}`
190
+ * and press Test. The host is read AFTER filling, because the usual shape is
191
+ * the whole URL as one secret (`${SLACK_WEBHOOK_URL}`, bound to
192
+ * hooks.slack.com). An unbound secret fills as it does for an agent's
193
+ * `secrets:` and a tool test — workspace first, then the account — since
194
+ * binding is how an account says where a value may go. `refused` says why
195
+ * `url` is null when a binding stopped it; `filled` maps each value put
196
+ * into the URL to its name, so an error that echoes the URL can be scrubbed. */
184
197
  export function webhookTarget(tenant, workspace, config = notifyConfig(tenant, workspace)) {
185
198
  const raw = config?.url ?? "";
199
+ const filled = {};
200
+ // Every name put in, kept apart from `filled` (value → name, for
201
+ // scrubbing): two secrets with the same value would otherwise leave only
202
+ // the last name there, and the first one's binding would go unchecked.
203
+ const used = new Set();
186
204
  const url = raw.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => {
187
205
  const hit = getSecret(tenant, name, workspace);
206
+ if (hit) {
207
+ filled[hit.value] = name;
208
+ used.add(name);
209
+ }
188
210
  return hit ? hit.value : whole;
189
211
  });
190
212
  const shown = raw.includes("${") ? raw : raw ? `${raw.slice(0, 40)}${raw.length > 40 ? "…" : ""}` : "none";
191
- return { url: !url || url.includes("${") ? null : url, shown };
213
+ if (!url || url.includes("${"))
214
+ return { url: null, shown, filled: {} };
215
+ const bound = secretSites(tenant, [...used], workspace);
216
+ if (Object.keys(bound).length) {
217
+ let host = "";
218
+ try {
219
+ host = new URL(url).hostname;
220
+ }
221
+ catch {
222
+ // Not a URL: no host can be one of its sites.
223
+ }
224
+ const offSite = Object.entries(bound).filter(([, sites]) => !host || !sites.some((site) => siteCovers(site, host))).map(([n]) => n);
225
+ if (offSite.length) {
226
+ return {
227
+ url: null,
228
+ shown,
229
+ filled: {},
230
+ // The host is not echoed: it is read from the FILLED url, so
231
+ // `https://${KEY}.their.server/` would print the key back.
232
+ refused: `${offSite.join(", ")} ${offSite.length === 1 ? "is" : "are"} bound to other sites than notify.url's host — not sent`,
233
+ };
234
+ }
235
+ }
236
+ return { url, shown, filled };
237
+ }
238
+ /** An error about a request to a resolved notify.url, with every secret value
239
+ * put back as its ${NAME} — a redirect refusal quotes the URL it was sent to,
240
+ * and the delivery log that keeps it is read by every viewer. */
241
+ function scrubTarget(text, filled) {
242
+ let out = text;
243
+ // Longest first, so a value inside another is not half-replaced.
244
+ for (const value of Object.keys(filled).sort((a, b) => b.length - a.length)) {
245
+ if (!value)
246
+ continue;
247
+ const variants = new Set([value]);
248
+ try {
249
+ variants.add(encodeURI(value)).add(encodeURIComponent(value));
250
+ }
251
+ catch {
252
+ // A lone surrogate cannot be encoded, so it cannot appear encoded.
253
+ }
254
+ for (const v of variants)
255
+ out = out.split(v).join(`\${${filled[value]}}`);
256
+ }
257
+ return out;
192
258
  }
193
259
  /**
194
260
  * One attempt at one delivery, against the workspace's notify: as it reads
@@ -204,10 +270,13 @@ export async function attemptWebhook(tenant, workspace, d) {
204
270
  if (!config?.url) {
205
271
  return { ...base, ok: false, statusCode: null, durationMs: 0, response: null, error: "this workspace's notify: has no url any more" };
206
272
  }
207
- const { url } = webhookTarget(tenant, workspace, config);
273
+ const { url, refused, filled } = webhookTarget(tenant, workspace, config);
208
274
  if (!url) {
209
- return { ...base, ok: false, statusCode: null, durationMs: 0, response: null, error: "the secret named in notify.url is not in this account's vault" };
275
+ return { ...base, ok: false, statusCode: null, durationMs: 0, response: null, error: refused ?? "the secret named in notify.url is not in this account's vault" };
210
276
  }
277
+ // A URL that carries a secret is a credential: a redirect is followed only
278
+ // within the origin it was sent to, as the tool tester does.
279
+ const carriesSecret = Object.keys(filled).length > 0;
211
280
  try {
212
281
  const res = await fetchUntrusted(url, {
213
282
  method: "POST",
@@ -222,7 +291,7 @@ export async function attemptWebhook(tenant, workspace, d) {
222
291
  },
223
292
  body: d.body,
224
293
  signal: AbortSignal.timeout(Number(process.env.FOLDRUN_WEBHOOK_TIMEOUT_MS) || 8000),
225
- });
294
+ }, { sameOrigin: carriesSecret });
226
295
  const text = (await res.text().catch(() => "")).slice(0, 500);
227
296
  return {
228
297
  ...base,
@@ -235,7 +304,7 @@ export async function attemptWebhook(tenant, workspace, d) {
235
304
  }
236
305
  catch (err) {
237
306
  const why = err instanceof Error ? (err.name === "TimeoutError" ? "timed out" : err.message) : String(err);
238
- return { ...base, ok: false, statusCode: null, durationMs: Date.now() - started, response: null, error: why.slice(0, 500) };
307
+ return { ...base, ok: false, statusCode: null, durationMs: Date.now() - started, response: null, error: scrubTarget(why, filled).slice(0, 500) };
239
308
  }
240
309
  }
241
310
  /**
@@ -411,9 +480,9 @@ export async function sendTestNotification(tenant, workspace) {
411
480
  // The destination is echoed back with the secret still unresolved: a
412
481
  // Slack webhook URL is itself a credential, and a page that prints it is
413
482
  // a page that leaks it into a screenshot.
414
- const { url, shown } = webhookTarget(tenant, workspace, config);
483
+ const { url, shown, refused } = webhookTarget(tenant, workspace, config);
415
484
  if (!url) {
416
- return { ok: false, destination: shown, detail: `the secret named in notify.url is not in this account's vault` };
485
+ return { ok: false, destination: shown, detail: refused ?? `the secret named in notify.url is not in this account's vault` };
417
486
  }
418
487
  const payload = JSON.stringify({ text: `${headline} — ${detail}`, workspace, status: "test", summary: detail });
419
488
  // One attempt, recorded in the delivery log like any other — and the way
@@ -554,8 +623,9 @@ export async function sendRunNotification(tenant, workspace, run) {
554
623
  }
555
624
  // ${SECRET} so the Slack URL — itself a credential — can live in the
556
625
  // vault instead of in a file that gets committed.
557
- if (!webhookTarget(tenant, workspace, config).url) {
558
- console.error(`[foldrun] notify: secret in URL not set for ${tenant}/${workspace}`);
626
+ const target = webhookTarget(tenant, workspace, config);
627
+ if (!target.url) {
628
+ console.error(`[foldrun] notify: ${target.refused ?? "secret in URL not set"} for ${tenant}/${workspace}`);
559
629
  return false;
560
630
  }
561
631
  const event = blocked ? "blocked" : run.status;
@@ -41,7 +41,7 @@ import { ASK_TOOL, PEER_TOOLS, applyOperatorEvent, askPromptLine, askTimeoutSec,
41
41
  import { sendPlainNotification } from "./notify.js";
42
42
  import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.js";
43
43
  import { refNames } from "./refs.js";
44
- import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, isSplicedRecord, runSummary, verdictOf, sumAttempts, tokensOf, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
44
+ import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, isSplicedRecord, runSummary, verdictOf, sumAttempts, tokensOf, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, isOneSegment, } from "./store.js";
45
45
  import { canReadBack } from "./tool-output.js";
46
46
  import { PromptLayers, promptCacheKey } from "./prompt-layers.js";
47
47
  import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, tokenPriceOf } from "./catalog.js";
@@ -4694,8 +4694,12 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4694
4694
  // resuming around.
4695
4695
  if (step.status !== "pending")
4696
4696
  continue;
4697
- const aDir = path.join(pDir, "agents", step.agent);
4698
- if (!fs.existsSync(path.join(aDir, "agent.md"))) {
4697
+ // One folder under agents/, never a path (30 Oct 2026): an ad-hoc
4698
+ // run's agent came from the URL, and `../../<tenant>/…` joined here
4699
+ // ran another account's agent. Refused as not found, before the
4700
+ // existence check could answer for a path outside.
4701
+ const aDir = isOneSegment(step.agent) ? path.join(pDir, "agents", step.agent) : null;
4702
+ if (!aDir || !fs.existsSync(path.join(aDir, "agent.md"))) {
4699
4703
  step.status = "failed";
4700
4704
  step.events.push({
4701
4705
  t: new Date().toISOString(),
@@ -4918,8 +4922,9 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4918
4922
  // step of the flow (isSplicedRecord). It gets no delegateJob of its
4919
4923
  // own: depth one.
4920
4924
  const delegateFor = (parent) => async (agent, job, cancelled) => {
4921
- const dir = path.join(pDir, "agents", agent);
4922
- if (!fs.existsSync(path.join(dir, "agent.md")))
4925
+ // One folder under agents/, never a path — as the group check above.
4926
+ const dir = isOneSegment(agent) ? path.join(pDir, "agents", agent) : null;
4927
+ if (!dir || !fs.existsSync(path.join(dir, "agent.md")))
4923
4928
  return { status: "failed", result: `${agent} is not an agent in this workspace` };
4924
4929
  // A stable id on both records: an index into run.steps moves when a
4925
4930
  // rescue or a fan-out is spliced in before the child.
@@ -5335,8 +5340,8 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
5335
5340
  const rescueStep = async (step) => {
5336
5341
  if (!step.onFail)
5337
5342
  return;
5338
- const rescuerDir = path.join(pDir, "agents", step.onFail);
5339
- if (!fs.existsSync(path.join(rescuerDir, "agent.md"))) {
5343
+ const rescuerDir = isOneSegment(step.onFail) ? path.join(pDir, "agents", step.onFail) : null;
5344
+ if (!rescuerDir || !fs.existsSync(path.join(rescuerDir, "agent.md"))) {
5340
5345
  step.events.push({
5341
5346
  t: new Date().toISOString(),
5342
5347
  type: "error",
@@ -830,7 +830,6 @@ function prepareLanguageRuntime(tenant, spec) {
830
830
  if (stale)
831
831
  log.push(`runtime ${fp}: cached entry unusable (${stale}); rebuilding`);
832
832
  const venvPython = path.join(root, "venv", "bin", "python");
833
- const nodeModules = path.join(root, "node_modules");
834
833
  const wantsPy = wantsPython(spec);
835
834
  const wantsNd = wantsNode(spec);
836
835
  const built = { built: new Date().toISOString() };
@@ -238,7 +238,10 @@ export function mutateVaultFile(file, change) {
238
238
  }
239
239
  export const SECRET_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
240
240
  export function setSecret(tenant, name, value, workspace, kind) {
241
- if (!SECRET_NAME.test(name)) {
241
+ // A string, not anything that prints as one: RegExp.test and the vault's
242
+ // keys both coerce, so ["RESEND_API_KEY"] from a JSON body was stored as
243
+ // RESEND_API_KEY past a caller's check on the name (30 Oct 2026).
244
+ if (typeof name !== "string" || !SECRET_NAME.test(name)) {
242
245
  throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
243
246
  }
244
247
  if (value.length > 8192)
@@ -261,6 +264,9 @@ export function setSecret(tenant, name, value, workspace, kind) {
261
264
  * scheme, path or port. Returns the list as stored.
262
265
  */
263
266
  export function setSecretSites(tenant, name, sites, workspace) {
267
+ // all[name] would find ["NAME"] as NAME: see setSecret.
268
+ if (typeof name !== "string")
269
+ throw new Error("a secret's name is a string");
264
270
  const clean = [...new Set(sites.map((x) => x.trim().toLowerCase()).filter(Boolean))];
265
271
  for (const site of clean) {
266
272
  if (!SITE.test(site))
@@ -961,7 +967,7 @@ export function setClientCertSecret(tenant, name, config, workspace) {
961
967
  ...(config.passphrase ? { passphrase: config.passphrase } : {}),
962
968
  ...(config.export ? { export: true } : {}),
963
969
  };
964
- if (!SECRET_NAME.test(name))
970
+ if (typeof name !== "string" || !SECRET_NAME.test(name))
965
971
  throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
966
972
  const plainCert = CLIENT_CERT_PREFIX + JSON.stringify(stored);
967
973
  // Written under the vault's own lock, with the binding checked in the same
@@ -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.