@foldrun/core 9.0.0 → 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/src/secrets.ts CHANGED
@@ -111,8 +111,15 @@ interface StoredSecret {
111
111
  * these sites alone. Metadata, not a value, so it is stored in the clear
112
112
  * beside the ciphertext and survives the value being set again. */
113
113
  sites?: string[];
114
+ /** An ACCOUNT secret limited to these workspaces (GitHub's "selected
115
+ * repositories" for an organisation secret). Absent: every workspace may
116
+ * use it, as before. Every other workspace is answered as if it were not
117
+ * set (getSecret). Metadata like `sites`: in the clear, and in the seal
118
+ * of a bound record. Never on a workspace's own record. */
119
+ workspaces?: string[];
114
120
  /** 2: sealed with the record's place as AAD — account, scope, name, kind,
115
- * key and sites (vaultAad). Absent: the older, unbound form. */
121
+ * key, sites and, when limited, workspaces (vaultAad). Absent: the older,
122
+ * unbound form. */
116
123
  v?: 2;
117
124
  }
118
125
 
@@ -143,15 +150,47 @@ type SecretsFile = Record<string, StoredSecret>;
143
150
  // every rename a re-seal racing other writers. What that leaves open — a
144
151
  // ciphertext moved between two workspaces of the same account by someone who
145
152
  // can already write the volume — is the residual, and stays inside one account.
146
- function vaultAad(tenant: string, workspace: string | undefined, name: string, rec: Pick<StoredSecret, "kind" | "k" | "sites">): Buffer {
153
+ //
154
+ // Rolling out (30 Oct 2026, accepted, no switch): a pod from before this
155
+ // release cannot open a limited record (its AAD lacks the list), and one that
156
+ // sets a new value over it writes the record back without the list — open to
157
+ // every workspace again. So limits are set after every pod, worker and the
158
+ // egress proxy run this version; the secrets docs say so.
159
+ //
160
+ // The workspaces an account secret is limited to are in the seal too (30 Oct
161
+ // 2026), appended only when the record has the list: every record sealed
162
+ // before keeps the AAD it was sealed with, and a list added to, widened in or
163
+ // stripped from a sealed record on disk makes it fail to open — "not set",
164
+ // never "every workspace". These ARE workspace names, so a rename re-seals
165
+ // them (retargetSecretWorkspaces), under the vault lock.
166
+ function vaultAad(tenant: string, workspace: string | undefined, name: string, rec: Pick<StoredSecret, "kind" | "k" | "sites" | "workspaces">): Buffer {
147
167
  if (rec.sites !== undefined && (!Array.isArray(rec.sites) || rec.sites.some((x) => typeof x !== "string"))) {
148
168
  throw new Error("malformed sites");
149
169
  }
170
+ if (rec.workspaces !== undefined && (!Array.isArray(rec.workspaces) || rec.workspaces.some((x) => typeof x !== "string"))) {
171
+ throw new Error("malformed workspaces");
172
+ }
150
173
  return Buffer.from(
151
- JSON.stringify(["foldrun-vault/2", tenant, workspace ? "w" : "a", name, rec.kind ?? "", rec.k ?? "install", [...(rec.sites ?? [])].sort()]),
174
+ JSON.stringify([
175
+ "foldrun-vault/2",
176
+ tenant,
177
+ workspace ? "w" : "a",
178
+ name,
179
+ rec.kind ?? "",
180
+ rec.k ?? "install",
181
+ [...(rec.sites ?? [])].sort(),
182
+ ...(rec.workspaces !== undefined ? [["workspaces", [...rec.workspaces].sort()]] : []),
183
+ ]),
152
184
  "utf8",
153
185
  );
154
186
  }
187
+
188
+ /** May an account record be used in this workspace? No list: everywhere. A
189
+ * list that is not a list of names (rewritten on disk) allows nowhere. */
190
+ function usableIn(rec: Pick<StoredSecret, "workspaces"> | undefined, workspace: string): boolean {
191
+ if (!rec || rec.workspaces === undefined) return true;
192
+ return Array.isArray(rec.workspaces) && rec.workspaces.includes(workspace);
193
+ }
155
194
  const writeBound = () => process.env.FOLDRUN_VAULT_WRITE_BOUND === "1";
156
195
  const requireBound = () => process.env.FOLDRUN_VAULT_REQUIRE_BOUND === "1";
157
196
 
@@ -162,7 +201,7 @@ function sealRecord(
162
201
  workspace: string | undefined,
163
202
  name: string,
164
203
  value: string,
165
- meta: { kind?: SecretKind; sites?: string[]; updatedAt?: string },
204
+ meta: { kind?: SecretKind; sites?: string[]; workspaces?: string[]; updatedAt?: string },
166
205
  bound = writeBound(),
167
206
  ): StoredSecret {
168
207
  const { key, mark } = keyFor(tenant);
@@ -174,6 +213,8 @@ function sealRecord(
174
213
  ...(meta.kind ? { kind: meta.kind } : {}),
175
214
  ...(mark ? { k: mark } : {}),
176
215
  ...(meta.sites?.length ? { sites: [...meta.sites] } : {}),
216
+ // An empty list is a choice (no workspace may use it), kept as written.
217
+ ...(meta.workspaces !== undefined ? { workspaces: [...meta.workspaces] } : {}),
177
218
  };
178
219
  const iv = crypto.randomBytes(12);
179
220
  const cipher = crypto.createCipheriv("aes-256-gcm", key, iv, GCM);
@@ -284,12 +325,57 @@ export function mutateVaultFile(file: string, change: (all: SecretsFile) => Secr
284
325
 
285
326
  export const SECRET_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
286
327
 
328
+ /**
329
+ * How a `signing_secret:` is shown in an answer, a log line or a delivery
330
+ * record: the name it holds, and only a name (30 Oct 2026). It is meant to
331
+ * name a vault entry, but nothing stopped the value itself being written
332
+ * there, and notify's test send echoed it back to the dashboard as "signed
333
+ * with <it>". Anything that is not a secret name is described, never
334
+ * repeated (foldrun check flags it as secret/signing-secret-value).
335
+ */
336
+ export function signingSecretShown(name: string): string {
337
+ return SECRET_NAME.test(name) ? name : "the signing_secret: (not a secret name — see foldrun check)";
338
+ }
339
+
340
+ /**
341
+ * Which workspaces an ACCOUNT secret may be used in, given with the value so
342
+ * both are sealed in one locked write (30 Oct 2026): a new secret created
343
+ * and limited in two calls was open to every workspace between them, and
344
+ * stayed open if the second failed. `workspaces` undefined keeps what the
345
+ * record has; null opens it to every workspace; a list limits it.
346
+ * `exists` is asked about each name under the vault lock, so a workspace
347
+ * deleted meanwhile is not granted (the delete takes its name off every
348
+ * list under the same lock — retargetSecretWorkspaces).
349
+ */
350
+ export interface SecretLimit {
351
+ workspaces?: string[] | null;
352
+ exists?: (workspace: string) => boolean;
353
+ }
354
+
355
+ /** A list as stored: trimmed, de-duplicated, sorted; every name a safe one
356
+ * and, when the caller can say, one that exists. */
357
+ function cleanWorkspaceList(workspaces: unknown): string[] | null {
358
+ if (workspaces === null) return null;
359
+ if (!Array.isArray(workspaces) || workspaces.some((w) => typeof w !== "string")) {
360
+ throw new Error("workspaces is a list of workspace names, or null for every workspace");
361
+ }
362
+ const clean = [...new Set((workspaces as string[]).map((w) => w.trim()).filter(Boolean))].sort();
363
+ for (const w of clean) assertSafeName(w, "workspace");
364
+ return clean;
365
+ }
366
+ function assertWorkspacesExist(list: string[] | null, exists?: (w: string) => boolean) {
367
+ if (!list || !exists) return;
368
+ const missing = list.filter((w) => !exists(w));
369
+ if (missing.length) throw Object.assign(new Error(`no workspace ${missing.join(", ")} in this account`), { status: 400 });
370
+ }
371
+
287
372
  export function setSecret(
288
373
  tenant: string,
289
374
  name: string,
290
375
  value: string,
291
376
  workspace?: string,
292
377
  kind?: SecretKind,
378
+ limit: SecretLimit = {},
293
379
  ) {
294
380
  // A string, not anything that prints as one: RegExp.test and the vault's
295
381
  // keys both coerce, so ["RESEND_API_KEY"] from a JSON body was stored as
@@ -298,19 +384,34 @@ export function setSecret(
298
384
  throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
299
385
  }
300
386
  if (value.length > 8192) throw new Error("secret too long");
387
+ const wanted = limitFor(workspace, limit);
301
388
  mutateVaultFile(secretsFile(tenant, workspace), (all) => {
302
389
  // A new value keeps the sites the old one was bound to: binding is a
303
390
  // property of the credential's use, not of its current value, and a
304
391
  // rotation that silently unbound it would put the new value in pods.
305
392
  // Sealed here, under the lock: a bound record's AAD names those sites.
306
393
  const sites = all[name]?.sites;
394
+ // The workspaces it is limited to stay too, for the same reason: a new
395
+ // value that every workspace could suddenly read is a widening nobody
396
+ // asked for — unless this call says otherwise, in the same write.
397
+ if (wanted !== undefined) assertWorkspacesExist(wanted, limit.exists);
398
+ const workspaces = wanted !== undefined ? (wanted ?? undefined) : all[name]?.workspaces;
307
399
  // Once bound, always bound: a re-save with the switch off must not
308
- // quietly drop the record's authentication.
309
- all[name] = sealRecord(tenant, workspace, name, value, { kind, sites }, all[name]?.v === 2 || writeBound());
400
+ // quietly drop the record's authentication. A limited record is always
401
+ // bound, so its list is in its seal (see setSecretWorkspaces).
402
+ all[name] = sealRecord(tenant, workspace, name, value, { kind, sites, workspaces }, all[name]?.v === 2 || workspaces !== undefined || writeBound());
310
403
  return all;
311
404
  });
312
405
  }
313
406
 
407
+ /** The list a write was asked to store, checked: undefined when it was not
408
+ * asked to change it. Only the account's store has lists. */
409
+ function limitFor(workspace: string | undefined, limit: SecretLimit): string[] | null | undefined {
410
+ if (limit.workspaces === undefined) return undefined;
411
+ if (workspace) throw new Error("a workspace's own secret is that workspace's already — only an account secret is limited to workspaces");
412
+ return cleanWorkspaceList(limit.workspaces);
413
+ }
414
+
314
415
  /**
315
416
  * Bind a secret to sites, or unbind it (an empty list). The secret must
316
417
  * exist in that scope. Sites are `api.example.com` or `*.example.com` — no
@@ -345,7 +446,7 @@ export function setSecretSites(tenant: string, name: string, sites: string[], wo
345
446
  if (rec.v === 2) {
346
447
  const value = openRecord(tenant, workspace, name, rec);
347
448
  if (value === null) throw new Error(`${name} cannot be read with this install's keys — set its value again before binding it`);
348
- all[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: clean, updatedAt: rec.updatedAt }, true);
449
+ all[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: clean, workspaces: rec.workspaces, updatedAt: rec.updatedAt }, true);
349
450
  return all;
350
451
  }
351
452
  if (clean.length) rec.sites = clean;
@@ -370,7 +471,10 @@ export function secretSites(tenant: string, names: string[], workspace?: string)
370
471
  // when it opens, else the account's. Picking by presence paired an
371
472
  // account value with a workspace record's (unauthenticated) sites.
372
473
  const ownRec = own[name];
373
- const rec = ownRec && openRecord(tenant, workspace, name, ownRec) !== null ? ownRec : account[name];
474
+ const ownOpens = Boolean(ownRec && openRecord(tenant, workspace, name, ownRec) !== null);
475
+ // An account record limited to other workspaces is not this one's.
476
+ if (!ownOpens && workspace && !usableIn(account[name], workspace)) continue;
477
+ const rec = ownOpens ? ownRec : account[name];
374
478
  if (rec?.sites?.length) out[name] = [...rec.sites];
375
479
  }
376
480
  return out;
@@ -457,7 +561,12 @@ function decrypt(tenant: string, rec: StoredSecret | undefined, aad?: Buffer): s
457
561
  }
458
562
  }
459
563
 
460
- /** Nearest wins: a workspace secret shadows an account one of the same name. */
564
+ /** Nearest wins: a workspace secret shadows an account one of the same name.
565
+ * An account secret limited to some workspaces (setSecretWorkspaces) is
566
+ * absent for every other — the answer a secret that was never set gets, on
567
+ * every path that resolves one for a workspace: runs, tool tests, notify,
568
+ * the egress proxy, MCP servers. Without a workspace it is the account's
569
+ * own use (its model key, its mail sender), which no list limits. */
461
570
  export function getSecret(
462
571
  tenant: string,
463
572
  name: string,
@@ -467,10 +576,129 @@ export function getSecret(
467
576
  const own = openRecord(tenant, workspace, name, read(tenant, workspace)[name]);
468
577
  if (own !== null) return { value: own, scope: "workspace" };
469
578
  }
470
- const shared = openRecord(tenant, undefined, name, read(tenant)[name]);
579
+ const rec = read(tenant)[name];
580
+ if (workspace && !usableIn(rec, workspace)) return null;
581
+ const shared = openRecord(tenant, undefined, name, rec);
471
582
  return shared === null ? null : { value: shared, scope: "account" };
472
583
  }
473
584
 
585
+ /** May the ACCOUNT's secret `name` be used for this workspace? True when it
586
+ * is not limited (or not set); for callers that read the account's copy on
587
+ * a workspace's behalf without getSecret's workspace-first lookup — the
588
+ * account's mail sender for a workspace's notifications (notify.ts). */
589
+ export function accountSecretUsableIn(tenant: string, name: string, workspace: string): boolean {
590
+ return usableIn(read(tenant)[name], workspace);
591
+ }
592
+
593
+ /**
594
+ * Limit an account secret to some workspaces, or lift the limit (null).
595
+ * An empty list means no workspace may use it. Returns the list as stored,
596
+ * sorted, or null.
597
+ *
598
+ * `exists` is asked about each name under the vault lock (the API passes
599
+ * "is there such a workspace"), so it cannot grant a name a delete has just
600
+ * taken off every list. `ifWorkspaces`, when given, is the list the caller
601
+ * saw (null: every workspace): a list changed since is refused (409), so
602
+ * two admins editing it at once do not silently overwrite each other.
603
+ *
604
+ * The record is sealed (v2) whatever it was, whatever
605
+ * FOLDRUN_VAULT_WRITE_BOUND says: a list in the clear on an unbound record
606
+ * could be stripped on disk to open the secret to every workspace; sealed,
607
+ * a stripped list makes it unreadable. Every reader since 29 Oct 2026 reads v2.
608
+ */
609
+ export function setSecretWorkspaces(
610
+ tenant: string,
611
+ name: string,
612
+ workspaces: string[] | null,
613
+ opts: { exists?: (workspace: string) => boolean; ifWorkspaces?: string[] | null } = {},
614
+ ): string[] | null {
615
+ if (typeof name !== "string") throw new Error("a secret's name is a string");
616
+ const clean = cleanWorkspaceList(workspaces);
617
+ const expected = opts.ifWorkspaces === undefined ? undefined : cleanWorkspaceList(opts.ifWorkspaces);
618
+ let found = false;
619
+ mutateVaultFile(secretsFile(tenant), (all) => {
620
+ const rec = all[name];
621
+ if (!rec) return null;
622
+ found = true;
623
+ if (expected !== undefined) {
624
+ const now = Array.isArray(rec.workspaces) ? [...rec.workspaces].sort() : null;
625
+ if (JSON.stringify(now) !== JSON.stringify(expected)) {
626
+ throw Object.assign(new Error(`${name}'s workspaces changed since you loaded them (now ${now === null ? "every workspace" : now.length ? now.join(", ") : "none"}) — reload and try again`), { status: 409 });
627
+ }
628
+ }
629
+ assertWorkspacesExist(clean, opts.exists);
630
+ all[name] = withWorkspaces(tenant, name, rec, clean);
631
+ return all;
632
+ });
633
+ if (!found) throw Object.assign(new Error(`no secret ${name} in the account`), { status: 404 });
634
+ return clean;
635
+ }
636
+
637
+ /** An account record with its workspace list replaced (null: removed),
638
+ * opened and sealed again as a bound record with the new list in its seal.
639
+ * One that will not open is refused, never relabelled (setSecretSites). */
640
+ function withWorkspaces(tenant: string, name: string, rec: StoredSecret, workspaces: string[] | null): StoredSecret {
641
+ const value = openRecord(tenant, undefined, name, rec);
642
+ if (value === null) throw new Error(`${name} cannot be read with this install's keys — set its value again before changing its workspaces`);
643
+ return sealRecord(tenant, undefined, name, value, { kind: rec.kind, sites: rec.sites, workspaces: workspaces ?? undefined, updatedAt: rec.updatedAt }, true);
644
+ }
645
+
646
+ /**
647
+ * A workspace is being renamed (to: its new name) or deleted (to: null):
648
+ * every account secret limited to it follows. Without this a rename quietly
649
+ * cut the workspace off its secrets, and a delete left the name on the list
650
+ * for the next workspace given it.
651
+ *
652
+ * `failed` names the records that hold the name but will not open with this
653
+ * install's keys: they are left exactly as they are, so the old name is
654
+ * STILL on their list — a key restored later would bring the grant back.
655
+ * The caller must not report the rename or delete as clean while any are
656
+ * left (the API refuses it, naming them: set them again, or delete them).
657
+ *
658
+ * Names, not ids: a grant naming a workspace's immutable id would survive a
659
+ * rename untouched and could never pass to a new workspace given an old
660
+ * name — the stronger design, not built yet (30 Oct 2026).
661
+ */
662
+ export function retargetSecretWorkspaces(tenant: string, from: string, to: string | null): { changed: string[]; failed: string[] } {
663
+ const changed: string[] = [];
664
+ const failed: string[] = [];
665
+ const file = secretsFile(tenant);
666
+ if (!fs.existsSync(file)) return { changed, failed };
667
+ mutateVaultFile(file, (all) => {
668
+ changed.length = 0;
669
+ failed.length = 0;
670
+ const staged: typeof all = {};
671
+ for (const [name, rec] of Object.entries(all)) {
672
+ const list = rec.workspaces as unknown;
673
+ if (list === undefined) continue;
674
+ if (!Array.isArray(list)) {
675
+ // A list rewritten on disk into something else allows nowhere
676
+ // (usableIn) — nothing for this rename to carry.
677
+ continue;
678
+ }
679
+ if (!list.includes(from)) continue;
680
+ const next = [...new Set(list.map((w) => (w === from ? to : w)).filter((w): w is string => typeof w === "string"))].sort();
681
+ try {
682
+ staged[name] = withWorkspaces(tenant, name, rec, next);
683
+ changed.push(name);
684
+ } catch {
685
+ failed.push(name);
686
+ }
687
+ }
688
+ // All or nothing (Codex, 30 Oct 2026): writing the readable records and
689
+ // refusing the rename for the unreadable ones moved grants to a name the
690
+ // workspace never took. One failure leaves the vault exactly as it was.
691
+ if (failed.length) {
692
+ changed.length = 0;
693
+ return null;
694
+ }
695
+ if (!changed.length) return null;
696
+ Object.assign(all, staged);
697
+ return all;
698
+ });
699
+ return { changed, failed };
700
+ }
701
+
474
702
  export function deleteSecret(tenant: string, name: string, workspace?: string) {
475
703
  mutateVaultFile(secretsFile(tenant, workspace), (all) => {
476
704
  if (!(name in all)) return null;
@@ -491,19 +719,27 @@ export interface SecretEntry {
491
719
  /** The sites it is bound to — the proxy fills it for these and no other,
492
720
  * and it never enters a sandbox. Absent when unbound. */
493
721
  sites?: string[];
722
+ /** An account secret limited to these workspaces; absent: every one. */
723
+ workspaces?: string[];
494
724
  }
495
725
 
496
726
  // Metadata only — never values. Without a workspace this lists the account
497
727
  // store; with one it lists what that workspace actually resolves, marking
498
728
  // which scope each name comes from.
499
729
  export function listSecrets(tenant: string, workspace?: string): SecretEntry[] {
500
- const account = Object.entries(read(tenant)).map(([name, rec]) => ({
501
- name,
502
- updatedAt: rec.updatedAt,
503
- scope: "account" as const,
504
- ...(rec.kind ? { kind: rec.kind } : {}),
505
- ...(rec.sites?.length ? { sites: [...rec.sites] } : {}),
506
- }));
730
+ const records = Object.entries(read(tenant));
731
+ const account = records
732
+ // What a workspace resolves leaves out an account secret limited to
733
+ // others: it is not set, as far as that workspace is concerned.
734
+ .filter(([, rec]) => !workspace || usableIn(rec, workspace))
735
+ .map(([name, rec]) => ({
736
+ name,
737
+ updatedAt: rec.updatedAt,
738
+ scope: "account" as const,
739
+ ...(rec.kind ? { kind: rec.kind } : {}),
740
+ ...(rec.sites?.length ? { sites: [...rec.sites] } : {}),
741
+ ...(!workspace && Array.isArray(rec.workspaces) ? { workspaces: [...rec.workspaces] } : {}),
742
+ }));
507
743
  if (!workspace) return account.sort((a, b) => a.name.localeCompare(b.name));
508
744
 
509
745
  const own = Object.entries(read(tenant, workspace)).map(([name, rec]) => ({
@@ -648,6 +884,9 @@ export function rotateMasterKey(
648
884
  // Kept too (it was dropped until 29 Oct 2026): a rotation that
649
885
  // unbinds every bound secret puts their values into pods.
650
886
  ...(rec.sites?.length ? { sites: rec.sites } : {}),
887
+ // And the workspaces it is limited to: dropped, it would read as
888
+ // "every workspace" (and fail its seal).
889
+ ...(rec.workspaces !== undefined ? { workspaces: rec.workspaces } : {}),
651
890
  ...(aad ? { v: 2 as const } : {}),
652
891
  };
653
892
  count++;
@@ -702,6 +941,7 @@ export function setOAuth2Secret(
702
941
  name: string,
703
942
  config: OAuth2Config,
704
943
  workspace?: string,
944
+ limit: SecretLimit = {},
705
945
  ) {
706
946
  for (const field of ["token_url", "client_id"] as const) {
707
947
  if (!config[field]?.trim()) throw new Error(`oauth2 secret needs ${field}`);
@@ -719,7 +959,7 @@ export function setOAuth2Secret(
719
959
  if (!/^https:\/\//.test(config.token_url) && !/^http:\/\/(127\.0\.0\.1|localhost)[:/]/.test(config.token_url)) {
720
960
  throw new Error("token_url must be https — a refresh token over http is a leaked one");
721
961
  }
722
- setSecret(tenant, name, OAUTH2_PREFIX + JSON.stringify(config), workspace, "oauth2");
962
+ setSecret(tenant, name, OAUTH2_PREFIX + JSON.stringify(config), workspace, "oauth2", limit);
723
963
  }
724
964
 
725
965
  export function isOAuth2Value(value: string): boolean {
@@ -764,7 +1004,7 @@ export function replaceOAuth2RefreshToken(tenant: string, name: string, workspac
764
1004
  // Built whole, not spread over the old record: a spread kept a stale
765
1005
  // `k: "tenant"` when this seal fell back to the install key, and the
766
1006
  // record then opened with neither.
767
- all[name] = sealRecord(tenant, scope, name, next, { kind: rec.kind, sites: rec.sites }, rec.v === 2 || writeBound());
1007
+ all[name] = sealRecord(tenant, scope, name, next, { kind: rec.kind, sites: rec.sites, workspaces: rec.workspaces }, rec.v === 2 || writeBound());
768
1008
  replaced = true;
769
1009
  }
770
1010
  return all;
@@ -966,6 +1206,7 @@ export function setServiceAccountSecret(
966
1206
  name: string,
967
1207
  config: ServiceAccountConfig,
968
1208
  workspace?: string,
1209
+ limit: SecretLimit = {},
969
1210
  ) {
970
1211
  for (const field of ["token_url", "issuer", "private_key", "scope"] as const) {
971
1212
  if (!config[field]?.trim()) throw new Error(`service account secret needs ${field}`);
@@ -978,7 +1219,7 @@ export function setServiceAccountSecret(
978
1219
  if (config.subject.length > 320 || /[\u0000-\u001f\u007f\s]/.test(config.subject.trim())) throw new Error("subject is one identifier, no spaces or control characters");
979
1220
  }
980
1221
  const stored: ServiceAccountConfig = { ...config, ...(config.subject !== undefined ? { subject: config.subject.trim() } : {}) };
981
- setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(stored), workspace, "service-account");
1222
+ setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(stored), workspace, "service-account", limit);
982
1223
  }
983
1224
 
984
1225
  function base64url(input: Buffer | string): string {
@@ -1056,9 +1297,9 @@ async function exchangeServiceAccount(config: ServiceAccountConfig, cacheKey: st
1056
1297
 
1057
1298
  const FILE_PREFIX = "@file ";
1058
1299
 
1059
- export function setFileSecret(tenant: string, name: string, content: string, workspace?: string) {
1300
+ export function setFileSecret(tenant: string, name: string, content: string, workspace?: string, limit: SecretLimit = {}) {
1060
1301
  if (!content) throw new Error("a file secret needs content");
1061
- setSecret(tenant, name, FILE_PREFIX + content, workspace, "file");
1302
+ setSecret(tenant, name, FILE_PREFIX + content, workspace, "file", limit);
1062
1303
  }
1063
1304
 
1064
1305
  export function isFileValue(value: string): boolean {
@@ -1089,7 +1330,7 @@ export interface SshConfig {
1089
1330
  password?: string;
1090
1331
  }
1091
1332
 
1092
- export function setSshSecret(tenant: string, name: string, config: SshConfig, workspace?: string) {
1333
+ export function setSshSecret(tenant: string, name: string, config: SshConfig, workspace?: string, limit: SecretLimit = {}) {
1093
1334
  const host = config.host?.trim();
1094
1335
  const user = config.user?.trim();
1095
1336
  if (!host || /[\s'"`$\\]/.test(host)) throw new Error("ssh secret needs a host (no spaces or quotes)");
@@ -1107,7 +1348,7 @@ export function setSshSecret(tenant: string, name: string, config: SshConfig, wo
1107
1348
  host, user, port,
1108
1349
  ...(hasKey ? { private_key: config.private_key } : { password: config.password }),
1109
1350
  };
1110
- setSecret(tenant, name, SSH_PREFIX + JSON.stringify(stored), workspace, "ssh");
1351
+ setSecret(tenant, name, SSH_PREFIX + JSON.stringify(stored), workspace, "ssh", limit);
1111
1352
  }
1112
1353
 
1113
1354
  export function isSshValue(value: string): boolean {
@@ -1142,7 +1383,7 @@ export interface ClientCertConfig {
1142
1383
  export?: boolean;
1143
1384
  }
1144
1385
 
1145
- export function setClientCertSecret(tenant: string, name: string, config: ClientCertConfig, workspace?: string) {
1386
+ export function setClientCertSecret(tenant: string, name: string, config: ClientCertConfig, workspace?: string, limit: SecretLimit = {}) {
1146
1387
  if (!/-----BEGIN CERTIFICATE-----/.test(config.cert ?? "")) throw new Error("cert must be a PEM certificate");
1147
1388
  if (!/-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----/.test(config.key ?? "")) throw new Error("key must be a PEM private key");
1148
1389
  // The pair must match: a certificate presented with another key's proof
@@ -1167,12 +1408,15 @@ export function setClientCertSecret(tenant: string, name: string, config: Client
1167
1408
  // Written under the vault's own lock, with the binding checked in the same
1168
1409
  // step: a value set over a secret keeps its site binding, and a wildcard
1169
1410
  // one would present the certificate to a whole domain.
1411
+ const wanted = limitFor(workspace, limit);
1170
1412
  mutateVaultFile(secretsFile(tenant, workspace), (all) => {
1171
1413
  const sites = all[name]?.sites;
1414
+ if (wanted !== undefined) assertWorkspacesExist(wanted, limit.exists);
1415
+ const workspaces = wanted !== undefined ? (wanted ?? undefined) : all[name]?.workspaces;
1172
1416
  if (sites?.some((site) => site.startsWith("*."))) {
1173
1417
  throw new Error(`${name} is bound to ${sites.join(", ")} — a client certificate is bound to exact hosts; unbind the wildcard first`);
1174
1418
  }
1175
- all[name] = sealRecord(tenant, workspace, name, plainCert, { kind: "client-cert", sites }, all[name]?.v === 2 || writeBound());
1419
+ all[name] = sealRecord(tenant, workspace, name, plainCert, { kind: "client-cert", sites, workspaces }, all[name]?.v === 2 || workspaces !== undefined || writeBound());
1176
1420
  return all;
1177
1421
  });
1178
1422
  }
@@ -1186,11 +1430,11 @@ export interface IdentityConfig {
1186
1430
  audience: string;
1187
1431
  }
1188
1432
 
1189
- export function setIdentitySecret(tenant: string, name: string, config: IdentityConfig, workspace?: string) {
1433
+ export function setIdentitySecret(tenant: string, name: string, config: IdentityConfig, workspace?: string, limit: SecretLimit = {}) {
1190
1434
  const audience = typeof config.audience === "string" ? config.audience.trim() : "";
1191
1435
  if (!audience) throw new Error("an identity needs an audience — the value your cloud's trust expects in aud");
1192
1436
  if (audience.length > 600 || /[\u0000-\u001f\u007f]/.test(audience)) throw new Error("audience is one value, at most 600 characters, no control characters");
1193
- setSecret(tenant, name, IDENTITY_PREFIX + JSON.stringify({ audience }), workspace, "identity");
1437
+ setSecret(tenant, name, IDENTITY_PREFIX + JSON.stringify({ audience }), workspace, "identity", limit);
1194
1438
  }
1195
1439
 
1196
1440
  export function isIdentityValue(value: string): boolean {
@@ -1233,7 +1477,7 @@ export function basicAuthorization(username: string, password: string): string {
1233
1477
  return `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`;
1234
1478
  }
1235
1479
 
1236
- export function setApiSecret(tenant: string, name: string, config: ApiConfig, workspace?: string) {
1480
+ export function setApiSecret(tenant: string, name: string, config: ApiConfig, workspace?: string, limit: SecretLimit = {}) {
1237
1481
  let all = { ...(config.headers ?? {}) };
1238
1482
  if (config.basic) {
1239
1483
  if (Object.keys(all).some((h) => h.toLowerCase() === "authorization")) {
@@ -1265,7 +1509,7 @@ export function setApiSecret(tenant: string, name: string, config: ApiConfig, wo
1265
1509
  setSecret(
1266
1510
  tenant, name,
1267
1511
  API_PREFIX + JSON.stringify({ ...(base ? { base_url: trimSlashes(base) } : {}), headers: config.headers }),
1268
- workspace, "api",
1512
+ workspace, "api", limit,
1269
1513
  );
1270
1514
  }
1271
1515
 
@@ -1304,7 +1548,7 @@ export function sealVaultBindings(tenant: string): { sealed: number; unreadable:
1304
1548
  const next: SecretsFile = { ...all };
1305
1549
  for (const [name, value] of plain) {
1306
1550
  const rec = all[name];
1307
- next[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: rec.sites, updatedAt: rec.updatedAt }, true);
1551
+ next[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: rec.sites, workspaces: rec.workspaces, updatedAt: rec.updatedAt }, true);
1308
1552
  }
1309
1553
  sealed += plain.size;
1310
1554
  return next;
@@ -1353,7 +1597,7 @@ export function rewrapTenantSecrets(tenant: string): { moved: number; unreadable
1353
1597
  if (plain.size === 0 || unreadableHere.length) return null;
1354
1598
  const next: SecretsFile = { ...all };
1355
1599
  for (const [name, value] of plain) {
1356
- next[name] = sealRecord(tenant, workspace, name, value, { kind: all[name].kind, sites: all[name].sites, updatedAt: all[name].updatedAt }, all[name].v === 2 || writeBound());
1600
+ next[name] = sealRecord(tenant, workspace, name, value, { kind: all[name].kind, sites: all[name].sites, workspaces: all[name].workspaces, updatedAt: all[name].updatedAt }, all[name].v === 2 || writeBound());
1357
1601
  }
1358
1602
  moved += plain.size;
1359
1603
  return next;
package/src/triggers.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  import crypto from "node:crypto";
16
16
  import { platform } from "./platform.ts";
17
17
  import { listFlows, readRun, runVerdict, type FlowInfo, type FlowStep, type RunRecord } from "./store.ts";
18
- import { getSecret } from "./secrets.ts";
18
+ import { getSecret, signingSecretShown } from "./secrets.ts";
19
19
 
20
20
  /** The last step's result — the same reading runner.ts makes, kept local so
21
21
  * this module never imports the runner (the queue imports both). */
@@ -273,7 +273,7 @@ export function verifyWebhookSignature(
273
273
  if (!flow.signature) return { ok: true, reason: "no signature required" };
274
274
  if (!flow.signingSecret) return { ok: false, reason: `signature: ${flow.signature} needs signing_secret: \${NAME}` };
275
275
  const secret = getSecret(tenant, flow.signingSecret, workspace);
276
- if (!secret) return { ok: false, reason: `signing secret ${flow.signingSecret} is not set` };
276
+ if (!secret) return { ok: false, reason: `signing secret ${signingSecretShown(flow.signingSecret)} is not set` };
277
277
  const hmac = (data: string) => crypto.createHmac("sha256", secret.value).update(data).digest("hex");
278
278
  const fresh = (ts: number) => Math.abs(now / 1000 - ts) <= 300;
279
279
 
package/src/webhook.ts CHANGED
@@ -96,22 +96,39 @@ export function approveToken(tenant: string, workspace: string, runId: string):
96
96
  .slice(0, 32);
97
97
  }
98
98
 
99
+ /** One parked `wait: event` gate: the step's index in the run record and
100
+ * the moment it parked (the `t` of its "waiting for an external event"
101
+ * trace line). */
102
+ export interface EventGate {
103
+ step: number;
104
+ at: string;
105
+ }
106
+
99
107
  /**
100
108
  * The token that releases a run parked on `wait: event`. Its own HMAC
101
109
  * family, so an approval link cannot double as an event URL or the reverse.
110
+ *
111
+ * With `gate`, the token is for that one park of that one step (30 Oct
112
+ * 2026). The per-run token released every later `wait: event` of the run,
113
+ * so a URL handed to a third party for the first gate also opened the
114
+ * second, and never expired. A gate's token dies with its park: the step
115
+ * released is no longer waiting, and parking again (a loop) stamps a new
116
+ * `at`. The per-run form, without `gate`, is what URLs printed before then
117
+ * carry — the events route still takes it for a step parked under it.
102
118
  */
103
- export function eventToken(tenant: string, workspace: string, runId: string): string {
119
+ export function eventToken(tenant: string, workspace: string, runId: string, gate?: EventGate): string {
104
120
  return crypto
105
121
  .createHmac("sha256", installKey())
106
- .update(`event:${tenant}/${workspace}/${runId}`)
122
+ .update(gate ? `event:${tenant}/${workspace}/${runId}#${gate.step}@${gate.at}` : `event:${tenant}/${workspace}/${runId}`)
107
123
  .digest("hex")
108
124
  .slice(0, 32);
109
125
  }
110
126
 
111
127
  /** The URL a `wait: event` step is released at — absolute when the install
112
- * knows its origin, a path otherwise. */
113
- export function eventUrl(tenant: string, workspace: string, runId: string): string {
114
- const p = `/api/events/${tenant}/${workspace}/${runId}?token=${eventToken(tenant, workspace, runId)}`;
128
+ * knows its origin, a path otherwise. With `gate`, that step's own URL
129
+ * (`&step=<index>`); the route finds the park's moment on the record. */
130
+ export function eventUrl(tenant: string, workspace: string, runId: string, gate?: EventGate): string {
131
+ const p = `/api/events/${tenant}/${workspace}/${runId}?token=${eventToken(tenant, workspace, runId, gate)}${gate ? `&step=${gate.step}` : ""}`;
115
132
  const base = publicUrl();
116
133
  return base ? `${base}${p}` : p;
117
134
  }