@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/dist/src/approvals.d.ts +18 -10
- package/dist/src/approvals.js +51 -1
- package/dist/src/catalog.d.ts +14 -0
- package/dist/src/catalog.js +28 -0
- package/dist/src/cron.d.ts +9 -0
- package/dist/src/cron.js +20 -0
- package/dist/src/frontmatter-fields.js +1 -1
- package/dist/src/frontmatter-schema.d.ts +5 -1
- package/dist/src/frontmatter-schema.js +40 -1
- package/dist/src/notify.d.ts +6 -3
- package/dist/src/notify.js +25 -12
- package/dist/src/package.d.ts +5 -1
- package/dist/src/package.js +5 -3
- package/dist/src/runner.js +5 -2
- package/dist/src/scan.js +21 -0
- package/dist/src/secrets.d.ts +92 -10
- package/dist/src/secrets.js +250 -26
- package/dist/src/triggers.js +2 -2
- package/dist/src/webhook.d.ts +19 -3
- package/dist/src/webhook.js +14 -5
- package/package.json +1 -1
- package/src/approvals.ts +55 -1
- package/src/catalog.ts +27 -0
- package/src/cron.ts +19 -0
- package/src/frontmatter-fields.ts +1 -1
- package/src/frontmatter-schema.ts +38 -2
- package/src/notify.ts +23 -12
- package/src/package.ts +5 -3
- package/src/runner.ts +5 -2
- package/src/scan.ts +23 -0
- package/src/secrets.ts +276 -32
- package/src/triggers.ts +2 -2
- package/src/webhook.ts +22 -5
package/dist/src/secrets.d.ts
CHANGED
|
@@ -31,8 +31,15 @@ interface StoredSecret {
|
|
|
31
31
|
* these sites alone. Metadata, not a value, so it is stored in the clear
|
|
32
32
|
* beside the ciphertext and survives the value being set again. */
|
|
33
33
|
sites?: string[];
|
|
34
|
+
/** An ACCOUNT secret limited to these workspaces (GitHub's "selected
|
|
35
|
+
* repositories" for an organisation secret). Absent: every workspace may
|
|
36
|
+
* use it, as before. Every other workspace is answered as if it were not
|
|
37
|
+
* set (getSecret). Metadata like `sites`: in the clear, and in the seal
|
|
38
|
+
* of a bound record. Never on a workspace's own record. */
|
|
39
|
+
workspaces?: string[];
|
|
34
40
|
/** 2: sealed with the record's place as AAD — account, scope, name, kind,
|
|
35
|
-
* key and
|
|
41
|
+
* key, sites and, when limited, workspaces (vaultAad). Absent: the older,
|
|
42
|
+
* unbound form. */
|
|
36
43
|
v?: 2;
|
|
37
44
|
}
|
|
38
45
|
type SecretsFile = Record<string, StoredSecret>;
|
|
@@ -44,7 +51,30 @@ declare const writeBound: () => boolean;
|
|
|
44
51
|
*/
|
|
45
52
|
export declare function mutateVaultFile(file: string, change: (all: SecretsFile) => SecretsFile | null): SecretsFile;
|
|
46
53
|
export declare const SECRET_NAME: RegExp;
|
|
47
|
-
|
|
54
|
+
/**
|
|
55
|
+
* How a `signing_secret:` is shown in an answer, a log line or a delivery
|
|
56
|
+
* record: the name it holds, and only a name (30 Oct 2026). It is meant to
|
|
57
|
+
* name a vault entry, but nothing stopped the value itself being written
|
|
58
|
+
* there, and notify's test send echoed it back to the dashboard as "signed
|
|
59
|
+
* with <it>". Anything that is not a secret name is described, never
|
|
60
|
+
* repeated (foldrun check flags it as secret/signing-secret-value).
|
|
61
|
+
*/
|
|
62
|
+
export declare function signingSecretShown(name: string): string;
|
|
63
|
+
/**
|
|
64
|
+
* Which workspaces an ACCOUNT secret may be used in, given with the value so
|
|
65
|
+
* both are sealed in one locked write (30 Oct 2026): a new secret created
|
|
66
|
+
* and limited in two calls was open to every workspace between them, and
|
|
67
|
+
* stayed open if the second failed. `workspaces` undefined keeps what the
|
|
68
|
+
* record has; null opens it to every workspace; a list limits it.
|
|
69
|
+
* `exists` is asked about each name under the vault lock, so a workspace
|
|
70
|
+
* deleted meanwhile is not granted (the delete takes its name off every
|
|
71
|
+
* list under the same lock — retargetSecretWorkspaces).
|
|
72
|
+
*/
|
|
73
|
+
export interface SecretLimit {
|
|
74
|
+
workspaces?: string[] | null;
|
|
75
|
+
exists?: (workspace: string) => boolean;
|
|
76
|
+
}
|
|
77
|
+
export declare function setSecret(tenant: string, name: string, value: string, workspace?: string, kind?: SecretKind, limit?: SecretLimit): void;
|
|
48
78
|
/**
|
|
49
79
|
* Bind a secret to sites, or unbind it (an empty list). The secret must
|
|
50
80
|
* exist in that scope. Sites are `api.example.com` or `*.example.com` — no
|
|
@@ -71,11 +101,61 @@ export declare function decryptValue(tenant: string, rec: {
|
|
|
71
101
|
data: string;
|
|
72
102
|
k?: "tenant";
|
|
73
103
|
} | undefined): string | null;
|
|
74
|
-
/** Nearest wins: a workspace secret shadows an account one of the same name.
|
|
104
|
+
/** Nearest wins: a workspace secret shadows an account one of the same name.
|
|
105
|
+
* An account secret limited to some workspaces (setSecretWorkspaces) is
|
|
106
|
+
* absent for every other — the answer a secret that was never set gets, on
|
|
107
|
+
* every path that resolves one for a workspace: runs, tool tests, notify,
|
|
108
|
+
* the egress proxy, MCP servers. Without a workspace it is the account's
|
|
109
|
+
* own use (its model key, its mail sender), which no list limits. */
|
|
75
110
|
export declare function getSecret(tenant: string, name: string, workspace?: string): {
|
|
76
111
|
value: string;
|
|
77
112
|
scope: SecretScope;
|
|
78
113
|
} | null;
|
|
114
|
+
/** May the ACCOUNT's secret `name` be used for this workspace? True when it
|
|
115
|
+
* is not limited (or not set); for callers that read the account's copy on
|
|
116
|
+
* a workspace's behalf without getSecret's workspace-first lookup — the
|
|
117
|
+
* account's mail sender for a workspace's notifications (notify.ts). */
|
|
118
|
+
export declare function accountSecretUsableIn(tenant: string, name: string, workspace: string): boolean;
|
|
119
|
+
/**
|
|
120
|
+
* Limit an account secret to some workspaces, or lift the limit (null).
|
|
121
|
+
* An empty list means no workspace may use it. Returns the list as stored,
|
|
122
|
+
* sorted, or null.
|
|
123
|
+
*
|
|
124
|
+
* `exists` is asked about each name under the vault lock (the API passes
|
|
125
|
+
* "is there such a workspace"), so it cannot grant a name a delete has just
|
|
126
|
+
* taken off every list. `ifWorkspaces`, when given, is the list the caller
|
|
127
|
+
* saw (null: every workspace): a list changed since is refused (409), so
|
|
128
|
+
* two admins editing it at once do not silently overwrite each other.
|
|
129
|
+
*
|
|
130
|
+
* The record is sealed (v2) whatever it was, whatever
|
|
131
|
+
* FOLDRUN_VAULT_WRITE_BOUND says: a list in the clear on an unbound record
|
|
132
|
+
* could be stripped on disk to open the secret to every workspace; sealed,
|
|
133
|
+
* a stripped list makes it unreadable. Every reader since 29 Oct 2026 reads v2.
|
|
134
|
+
*/
|
|
135
|
+
export declare function setSecretWorkspaces(tenant: string, name: string, workspaces: string[] | null, opts?: {
|
|
136
|
+
exists?: (workspace: string) => boolean;
|
|
137
|
+
ifWorkspaces?: string[] | null;
|
|
138
|
+
}): string[] | null;
|
|
139
|
+
/**
|
|
140
|
+
* A workspace is being renamed (to: its new name) or deleted (to: null):
|
|
141
|
+
* every account secret limited to it follows. Without this a rename quietly
|
|
142
|
+
* cut the workspace off its secrets, and a delete left the name on the list
|
|
143
|
+
* for the next workspace given it.
|
|
144
|
+
*
|
|
145
|
+
* `failed` names the records that hold the name but will not open with this
|
|
146
|
+
* install's keys: they are left exactly as they are, so the old name is
|
|
147
|
+
* STILL on their list — a key restored later would bring the grant back.
|
|
148
|
+
* The caller must not report the rename or delete as clean while any are
|
|
149
|
+
* left (the API refuses it, naming them: set them again, or delete them).
|
|
150
|
+
*
|
|
151
|
+
* Names, not ids: a grant naming a workspace's immutable id would survive a
|
|
152
|
+
* rename untouched and could never pass to a new workspace given an old
|
|
153
|
+
* name — the stronger design, not built yet (30 Oct 2026).
|
|
154
|
+
*/
|
|
155
|
+
export declare function retargetSecretWorkspaces(tenant: string, from: string, to: string | null): {
|
|
156
|
+
changed: string[];
|
|
157
|
+
failed: string[];
|
|
158
|
+
};
|
|
79
159
|
export declare function deleteSecret(tenant: string, name: string, workspace?: string): void;
|
|
80
160
|
export interface SecretEntry {
|
|
81
161
|
name: string;
|
|
@@ -89,6 +169,8 @@ export interface SecretEntry {
|
|
|
89
169
|
/** The sites it is bound to — the proxy fills it for these and no other,
|
|
90
170
|
* and it never enters a sandbox. Absent when unbound. */
|
|
91
171
|
sites?: string[];
|
|
172
|
+
/** An account secret limited to these workspaces; absent: every one. */
|
|
173
|
+
workspaces?: string[];
|
|
92
174
|
}
|
|
93
175
|
export declare function listSecrets(tenant: string, workspace?: string): SecretEntry[];
|
|
94
176
|
export declare function resolveSecrets(tenant: string, names: string[], workspace?: string): {
|
|
@@ -154,7 +236,7 @@ export interface OAuth2Config {
|
|
|
154
236
|
export declare const OAUTH_REFRESH_TRIES = 3;
|
|
155
237
|
/** Wait before try n (1-based retry): 1s, 3s — FOLDRUN_OAUTH_RETRY_MS scales it for tests. */
|
|
156
238
|
export declare function oauthRetryDelayMs(n: number): number;
|
|
157
|
-
export declare function setOAuth2Secret(tenant: string, name: string, config: OAuth2Config, workspace?: string): void;
|
|
239
|
+
export declare function setOAuth2Secret(tenant: string, name: string, config: OAuth2Config, workspace?: string, limit?: SecretLimit): void;
|
|
158
240
|
export declare function isOAuth2Value(value: string): boolean;
|
|
159
241
|
/**
|
|
160
242
|
* The cache key for a live token: SHA-256 over the kind, the account and the
|
|
@@ -205,8 +287,8 @@ export interface ServiceAccountConfig {
|
|
|
205
287
|
* a Workspace admin grants it. Absent, the account acts as itself. */
|
|
206
288
|
subject?: string;
|
|
207
289
|
}
|
|
208
|
-
export declare function setServiceAccountSecret(tenant: string, name: string, config: ServiceAccountConfig, workspace?: string): void;
|
|
209
|
-
export declare function setFileSecret(tenant: string, name: string, content: string, workspace?: string): void;
|
|
290
|
+
export declare function setServiceAccountSecret(tenant: string, name: string, config: ServiceAccountConfig, workspace?: string, limit?: SecretLimit): void;
|
|
291
|
+
export declare function setFileSecret(tenant: string, name: string, content: string, workspace?: string, limit?: SecretLimit): void;
|
|
210
292
|
export declare function isFileValue(value: string): boolean;
|
|
211
293
|
export declare function fileContent(value: string): string;
|
|
212
294
|
export interface SshConfig {
|
|
@@ -217,7 +299,7 @@ export interface SshConfig {
|
|
|
217
299
|
private_key?: string;
|
|
218
300
|
password?: string;
|
|
219
301
|
}
|
|
220
|
-
export declare function setSshSecret(tenant: string, name: string, config: SshConfig, workspace?: string): void;
|
|
302
|
+
export declare function setSshSecret(tenant: string, name: string, config: SshConfig, workspace?: string, limit?: SecretLimit): void;
|
|
221
303
|
export declare function isSshValue(value: string): boolean;
|
|
222
304
|
export declare function sshConfigOf(value: string): SshConfig;
|
|
223
305
|
/** A client certificate for mutual TLS: presented by the egress proxy, on its
|
|
@@ -232,14 +314,14 @@ export interface ClientCertConfig {
|
|
|
232
314
|
passphrase?: string;
|
|
233
315
|
export?: boolean;
|
|
234
316
|
}
|
|
235
|
-
export declare function setClientCertSecret(tenant: string, name: string, config: ClientCertConfig, workspace?: string): void;
|
|
317
|
+
export declare function setClientCertSecret(tenant: string, name: string, config: ClientCertConfig, workspace?: string, limit?: SecretLimit): void;
|
|
236
318
|
/** A keyless cloud identity: no credential at all, only the audience the
|
|
237
319
|
* platform's signed tokens are for. A step that declares it gets a token
|
|
238
320
|
* file the sandbox driver keeps fresh through the egress proxy. */
|
|
239
321
|
export interface IdentityConfig {
|
|
240
322
|
audience: string;
|
|
241
323
|
}
|
|
242
|
-
export declare function setIdentitySecret(tenant: string, name: string, config: IdentityConfig, workspace?: string): void;
|
|
324
|
+
export declare function setIdentitySecret(tenant: string, name: string, config: IdentityConfig, workspace?: string, limit?: SecretLimit): void;
|
|
243
325
|
export declare function isIdentityValue(value: string): boolean;
|
|
244
326
|
export declare function identityOf(value: string): IdentityConfig;
|
|
245
327
|
export declare function isClientCertValue(value: string): boolean;
|
|
@@ -260,7 +342,7 @@ export interface ApiConfig {
|
|
|
260
342
|
/** `Authorization: Basic …` from a username and password (RFC 7617 §2):
|
|
261
343
|
* UTF-8, no colon in the username, no control characters in either. */
|
|
262
344
|
export declare function basicAuthorization(username: string, password: string): string;
|
|
263
|
-
export declare function setApiSecret(tenant: string, name: string, config: ApiConfig, workspace?: string): void;
|
|
345
|
+
export declare function setApiSecret(tenant: string, name: string, config: ApiConfig, workspace?: string, limit?: SecretLimit): void;
|
|
264
346
|
export declare function isApiValue(value: string): boolean;
|
|
265
347
|
export declare function apiConfigOf(value: string): ApiConfig;
|
|
266
348
|
/**
|
package/dist/src/secrets.js
CHANGED
|
@@ -106,11 +106,43 @@ function secretsFile(tenant, workspace) {
|
|
|
106
106
|
// every rename a re-seal racing other writers. What that leaves open — a
|
|
107
107
|
// ciphertext moved between two workspaces of the same account by someone who
|
|
108
108
|
// can already write the volume — is the residual, and stays inside one account.
|
|
109
|
+
//
|
|
110
|
+
// Rolling out (30 Oct 2026, accepted, no switch): a pod from before this
|
|
111
|
+
// release cannot open a limited record (its AAD lacks the list), and one that
|
|
112
|
+
// sets a new value over it writes the record back without the list — open to
|
|
113
|
+
// every workspace again. So limits are set after every pod, worker and the
|
|
114
|
+
// egress proxy run this version; the secrets docs say so.
|
|
115
|
+
//
|
|
116
|
+
// The workspaces an account secret is limited to are in the seal too (30 Oct
|
|
117
|
+
// 2026), appended only when the record has the list: every record sealed
|
|
118
|
+
// before keeps the AAD it was sealed with, and a list added to, widened in or
|
|
119
|
+
// stripped from a sealed record on disk makes it fail to open — "not set",
|
|
120
|
+
// never "every workspace". These ARE workspace names, so a rename re-seals
|
|
121
|
+
// them (retargetSecretWorkspaces), under the vault lock.
|
|
109
122
|
function vaultAad(tenant, workspace, name, rec) {
|
|
110
123
|
if (rec.sites !== undefined && (!Array.isArray(rec.sites) || rec.sites.some((x) => typeof x !== "string"))) {
|
|
111
124
|
throw new Error("malformed sites");
|
|
112
125
|
}
|
|
113
|
-
|
|
126
|
+
if (rec.workspaces !== undefined && (!Array.isArray(rec.workspaces) || rec.workspaces.some((x) => typeof x !== "string"))) {
|
|
127
|
+
throw new Error("malformed workspaces");
|
|
128
|
+
}
|
|
129
|
+
return Buffer.from(JSON.stringify([
|
|
130
|
+
"foldrun-vault/2",
|
|
131
|
+
tenant,
|
|
132
|
+
workspace ? "w" : "a",
|
|
133
|
+
name,
|
|
134
|
+
rec.kind ?? "",
|
|
135
|
+
rec.k ?? "install",
|
|
136
|
+
[...(rec.sites ?? [])].sort(),
|
|
137
|
+
...(rec.workspaces !== undefined ? [["workspaces", [...rec.workspaces].sort()]] : []),
|
|
138
|
+
]), "utf8");
|
|
139
|
+
}
|
|
140
|
+
/** May an account record be used in this workspace? No list: everywhere. A
|
|
141
|
+
* list that is not a list of names (rewritten on disk) allows nowhere. */
|
|
142
|
+
function usableIn(rec, workspace) {
|
|
143
|
+
if (!rec || rec.workspaces === undefined)
|
|
144
|
+
return true;
|
|
145
|
+
return Array.isArray(rec.workspaces) && rec.workspaces.includes(workspace);
|
|
114
146
|
}
|
|
115
147
|
const writeBound = () => process.env.FOLDRUN_VAULT_WRITE_BOUND === "1";
|
|
116
148
|
const requireBound = () => process.env.FOLDRUN_VAULT_REQUIRE_BOUND === "1";
|
|
@@ -126,6 +158,8 @@ function sealRecord(tenant, workspace, name, value, meta, bound = writeBound())
|
|
|
126
158
|
...(meta.kind ? { kind: meta.kind } : {}),
|
|
127
159
|
...(mark ? { k: mark } : {}),
|
|
128
160
|
...(meta.sites?.length ? { sites: [...meta.sites] } : {}),
|
|
161
|
+
// An empty list is a choice (no workspace may use it), kept as written.
|
|
162
|
+
...(meta.workspaces !== undefined ? { workspaces: [...meta.workspaces] } : {}),
|
|
129
163
|
};
|
|
130
164
|
const iv = crypto.randomBytes(12);
|
|
131
165
|
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv, GCM);
|
|
@@ -237,7 +271,38 @@ export function mutateVaultFile(file, change) {
|
|
|
237
271
|
}
|
|
238
272
|
}
|
|
239
273
|
export const SECRET_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
|
|
240
|
-
|
|
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 = {}) {
|
|
241
306
|
// A string, not anything that prints as one: RegExp.test and the vault's
|
|
242
307
|
// keys both coerce, so ["RESEND_API_KEY"] from a JSON body was stored as
|
|
243
308
|
// RESEND_API_KEY past a caller's check on the name (30 Oct 2026).
|
|
@@ -246,18 +311,35 @@ export function setSecret(tenant, name, value, workspace, kind) {
|
|
|
246
311
|
}
|
|
247
312
|
if (value.length > 8192)
|
|
248
313
|
throw new Error("secret too long");
|
|
314
|
+
const wanted = limitFor(workspace, limit);
|
|
249
315
|
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
250
316
|
// A new value keeps the sites the old one was bound to: binding is a
|
|
251
317
|
// property of the credential's use, not of its current value, and a
|
|
252
318
|
// rotation that silently unbound it would put the new value in pods.
|
|
253
319
|
// Sealed here, under the lock: a bound record's AAD names those sites.
|
|
254
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;
|
|
255
327
|
// Once bound, always bound: a re-save with the switch off must not
|
|
256
|
-
// quietly drop the record's authentication.
|
|
257
|
-
|
|
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());
|
|
258
331
|
return all;
|
|
259
332
|
});
|
|
260
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
|
+
}
|
|
261
343
|
/**
|
|
262
344
|
* Bind a secret to sites, or unbind it (an empty list). The secret must
|
|
263
345
|
* exist in that scope. Sites are `api.example.com` or `*.example.com` — no
|
|
@@ -296,7 +378,7 @@ export function setSecretSites(tenant, name, sites, workspace) {
|
|
|
296
378
|
const value = openRecord(tenant, workspace, name, rec);
|
|
297
379
|
if (value === null)
|
|
298
380
|
throw new Error(`${name} cannot be read with this install's keys — set its value again before binding it`);
|
|
299
|
-
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);
|
|
300
382
|
return all;
|
|
301
383
|
}
|
|
302
384
|
if (clean.length)
|
|
@@ -323,7 +405,11 @@ export function secretSites(tenant, names, workspace) {
|
|
|
323
405
|
// when it opens, else the account's. Picking by presence paired an
|
|
324
406
|
// account value with a workspace record's (unauthenticated) sites.
|
|
325
407
|
const ownRec = own[name];
|
|
326
|
-
const
|
|
408
|
+
const ownOpens = Boolean(ownRec && openRecord(tenant, workspace, name, ownRec) !== null);
|
|
409
|
+
// An account record limited to other workspaces is not this one's.
|
|
410
|
+
if (!ownOpens && workspace && !usableIn(account[name], workspace))
|
|
411
|
+
continue;
|
|
412
|
+
const rec = ownOpens ? ownRec : account[name];
|
|
327
413
|
if (rec?.sites?.length)
|
|
328
414
|
out[name] = [...rec.sites];
|
|
329
415
|
}
|
|
@@ -398,16 +484,141 @@ function decrypt(tenant, rec, aad) {
|
|
|
398
484
|
return null; // key rotated or file tampered with
|
|
399
485
|
}
|
|
400
486
|
}
|
|
401
|
-
/** 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. */
|
|
402
493
|
export function getSecret(tenant, name, workspace) {
|
|
403
494
|
if (workspace) {
|
|
404
495
|
const own = openRecord(tenant, workspace, name, read(tenant, workspace)[name]);
|
|
405
496
|
if (own !== null)
|
|
406
497
|
return { value: own, scope: "workspace" };
|
|
407
498
|
}
|
|
408
|
-
const
|
|
499
|
+
const rec = read(tenant)[name];
|
|
500
|
+
if (workspace && !usableIn(rec, workspace))
|
|
501
|
+
return null;
|
|
502
|
+
const shared = openRecord(tenant, undefined, name, rec);
|
|
409
503
|
return shared === null ? null : { value: shared, scope: "account" };
|
|
410
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
|
+
}
|
|
411
622
|
export function deleteSecret(tenant, name, workspace) {
|
|
412
623
|
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
413
624
|
if (!(name in all))
|
|
@@ -420,12 +631,18 @@ export function deleteSecret(tenant, name, workspace) {
|
|
|
420
631
|
// store; with one it lists what that workspace actually resolves, marking
|
|
421
632
|
// which scope each name comes from.
|
|
422
633
|
export function listSecrets(tenant, workspace) {
|
|
423
|
-
const
|
|
634
|
+
const records = Object.entries(read(tenant));
|
|
635
|
+
const account = records
|
|
636
|
+
// What a workspace resolves leaves out an account secret limited to
|
|
637
|
+
// others: it is not set, as far as that workspace is concerned.
|
|
638
|
+
.filter(([, rec]) => !workspace || usableIn(rec, workspace))
|
|
639
|
+
.map(([name, rec]) => ({
|
|
424
640
|
name,
|
|
425
641
|
updatedAt: rec.updatedAt,
|
|
426
642
|
scope: "account",
|
|
427
643
|
...(rec.kind ? { kind: rec.kind } : {}),
|
|
428
644
|
...(rec.sites?.length ? { sites: [...rec.sites] } : {}),
|
|
645
|
+
...(!workspace && Array.isArray(rec.workspaces) ? { workspaces: [...rec.workspaces] } : {}),
|
|
429
646
|
}));
|
|
430
647
|
if (!workspace)
|
|
431
648
|
return account.sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -559,6 +776,9 @@ export function rotateMasterKey(tenant, from, to) {
|
|
|
559
776
|
// Kept too (it was dropped until 29 Oct 2026): a rotation that
|
|
560
777
|
// unbinds every bound secret puts their values into pods.
|
|
561
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 } : {}),
|
|
562
782
|
...(aad ? { v: 2 } : {}),
|
|
563
783
|
};
|
|
564
784
|
count++;
|
|
@@ -578,7 +798,7 @@ export function oauthRetryDelayMs(n) {
|
|
|
578
798
|
const base = Number(process.env.FOLDRUN_OAUTH_RETRY_MS ?? 1000);
|
|
579
799
|
return base * (n === 1 ? 1 : 3);
|
|
580
800
|
}
|
|
581
|
-
export function setOAuth2Secret(tenant, name, config, workspace) {
|
|
801
|
+
export function setOAuth2Secret(tenant, name, config, workspace, limit = {}) {
|
|
582
802
|
for (const field of ["token_url", "client_id"]) {
|
|
583
803
|
if (!config[field]?.trim())
|
|
584
804
|
throw new Error(`oauth2 secret needs ${field}`);
|
|
@@ -596,7 +816,7 @@ export function setOAuth2Secret(tenant, name, config, workspace) {
|
|
|
596
816
|
if (!/^https:\/\//.test(config.token_url) && !/^http:\/\/(127\.0\.0\.1|localhost)[:/]/.test(config.token_url)) {
|
|
597
817
|
throw new Error("token_url must be https — a refresh token over http is a leaked one");
|
|
598
818
|
}
|
|
599
|
-
setSecret(tenant, name, OAUTH2_PREFIX + JSON.stringify(config), workspace, "oauth2");
|
|
819
|
+
setSecret(tenant, name, OAUTH2_PREFIX + JSON.stringify(config), workspace, "oauth2", limit);
|
|
600
820
|
}
|
|
601
821
|
export function isOAuth2Value(value) {
|
|
602
822
|
return value.startsWith(OAUTH2_PREFIX);
|
|
@@ -638,7 +858,7 @@ export function replaceOAuth2RefreshToken(tenant, name, workspace, usedValue, re
|
|
|
638
858
|
// Built whole, not spread over the old record: a spread kept a stale
|
|
639
859
|
// `k: "tenant"` when this seal fell back to the install key, and the
|
|
640
860
|
// record then opened with neither.
|
|
641
|
-
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());
|
|
642
862
|
replaced = true;
|
|
643
863
|
}
|
|
644
864
|
return all;
|
|
@@ -808,7 +1028,7 @@ ctx) {
|
|
|
808
1028
|
// Stored as the JSON key, encrypted. Materialised the same way oauth2 is: a
|
|
809
1029
|
// live access token appears at the last host-side moment, cached until expiry.
|
|
810
1030
|
const SERVICE_ACCOUNT_PREFIX = "@service-account ";
|
|
811
|
-
export function setServiceAccountSecret(tenant, name, config, workspace) {
|
|
1031
|
+
export function setServiceAccountSecret(tenant, name, config, workspace, limit = {}) {
|
|
812
1032
|
for (const field of ["token_url", "issuer", "private_key", "scope"]) {
|
|
813
1033
|
if (!config[field]?.trim())
|
|
814
1034
|
throw new Error(`service account secret needs ${field}`);
|
|
@@ -823,7 +1043,7 @@ export function setServiceAccountSecret(tenant, name, config, workspace) {
|
|
|
823
1043
|
throw new Error("subject is one identifier, no spaces or control characters");
|
|
824
1044
|
}
|
|
825
1045
|
const stored = { ...config, ...(config.subject !== undefined ? { subject: config.subject.trim() } : {}) };
|
|
826
|
-
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);
|
|
827
1047
|
}
|
|
828
1048
|
function base64url(input) {
|
|
829
1049
|
return Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
@@ -886,10 +1106,10 @@ async function exchangeServiceAccount(config, cacheKey) {
|
|
|
886
1106
|
// the only place that knows where a run's writable scratch is; here we only
|
|
887
1107
|
// mark the kind and carry the content.
|
|
888
1108
|
const FILE_PREFIX = "@file ";
|
|
889
|
-
export function setFileSecret(tenant, name, content, workspace) {
|
|
1109
|
+
export function setFileSecret(tenant, name, content, workspace, limit = {}) {
|
|
890
1110
|
if (!content)
|
|
891
1111
|
throw new Error("a file secret needs content");
|
|
892
|
-
setSecret(tenant, name, FILE_PREFIX + content, workspace, "file");
|
|
1112
|
+
setSecret(tenant, name, FILE_PREFIX + content, workspace, "file", limit);
|
|
893
1113
|
}
|
|
894
1114
|
export function isFileValue(value) {
|
|
895
1115
|
return value.startsWith(FILE_PREFIX);
|
|
@@ -905,7 +1125,7 @@ export function fileContent(value) {
|
|
|
905
1125
|
// config into an executable wrapper, so the agent runs `"$NAME" 'uptime'`
|
|
906
1126
|
// and never learns which auth flavour it is.
|
|
907
1127
|
const SSH_PREFIX = "@ssh ";
|
|
908
|
-
export function setSshSecret(tenant, name, config, workspace) {
|
|
1128
|
+
export function setSshSecret(tenant, name, config, workspace, limit = {}) {
|
|
909
1129
|
const host = config.host?.trim();
|
|
910
1130
|
const user = config.user?.trim();
|
|
911
1131
|
if (!host || /[\s'"`$\\]/.test(host))
|
|
@@ -928,7 +1148,7 @@ export function setSshSecret(tenant, name, config, workspace) {
|
|
|
928
1148
|
host, user, port,
|
|
929
1149
|
...(hasKey ? { private_key: config.private_key } : { password: config.password }),
|
|
930
1150
|
};
|
|
931
|
-
setSecret(tenant, name, SSH_PREFIX + JSON.stringify(stored), workspace, "ssh");
|
|
1151
|
+
setSecret(tenant, name, SSH_PREFIX + JSON.stringify(stored), workspace, "ssh", limit);
|
|
932
1152
|
}
|
|
933
1153
|
export function isSshValue(value) {
|
|
934
1154
|
return value.startsWith(SSH_PREFIX);
|
|
@@ -944,7 +1164,7 @@ export function sshConfigOf(value) {
|
|
|
944
1164
|
// never appears in the bash command at all.
|
|
945
1165
|
const API_PREFIX = "@api ";
|
|
946
1166
|
const CLIENT_CERT_PREFIX = "@client-cert ";
|
|
947
|
-
export function setClientCertSecret(tenant, name, config, workspace) {
|
|
1167
|
+
export function setClientCertSecret(tenant, name, config, workspace, limit = {}) {
|
|
948
1168
|
if (!/-----BEGIN CERTIFICATE-----/.test(config.cert ?? ""))
|
|
949
1169
|
throw new Error("cert must be a PEM certificate");
|
|
950
1170
|
if (!/-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----/.test(config.key ?? ""))
|
|
@@ -973,23 +1193,27 @@ export function setClientCertSecret(tenant, name, config, workspace) {
|
|
|
973
1193
|
// Written under the vault's own lock, with the binding checked in the same
|
|
974
1194
|
// step: a value set over a secret keeps its site binding, and a wildcard
|
|
975
1195
|
// one would present the certificate to a whole domain.
|
|
1196
|
+
const wanted = limitFor(workspace, limit);
|
|
976
1197
|
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
977
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;
|
|
978
1202
|
if (sites?.some((site) => site.startsWith("*."))) {
|
|
979
1203
|
throw new Error(`${name} is bound to ${sites.join(", ")} — a client certificate is bound to exact hosts; unbind the wildcard first`);
|
|
980
1204
|
}
|
|
981
|
-
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());
|
|
982
1206
|
return all;
|
|
983
1207
|
});
|
|
984
1208
|
}
|
|
985
1209
|
const IDENTITY_PREFIX = "@identity ";
|
|
986
|
-
export function setIdentitySecret(tenant, name, config, workspace) {
|
|
1210
|
+
export function setIdentitySecret(tenant, name, config, workspace, limit = {}) {
|
|
987
1211
|
const audience = typeof config.audience === "string" ? config.audience.trim() : "";
|
|
988
1212
|
if (!audience)
|
|
989
1213
|
throw new Error("an identity needs an audience — the value your cloud's trust expects in aud");
|
|
990
1214
|
if (audience.length > 600 || /[\u0000-\u001f\u007f]/.test(audience))
|
|
991
1215
|
throw new Error("audience is one value, at most 600 characters, no control characters");
|
|
992
|
-
setSecret(tenant, name, IDENTITY_PREFIX + JSON.stringify({ audience }), workspace, "identity");
|
|
1216
|
+
setSecret(tenant, name, IDENTITY_PREFIX + JSON.stringify({ audience }), workspace, "identity", limit);
|
|
993
1217
|
}
|
|
994
1218
|
export function isIdentityValue(value) {
|
|
995
1219
|
return value.startsWith(IDENTITY_PREFIX);
|
|
@@ -1021,7 +1245,7 @@ export function basicAuthorization(username, password) {
|
|
|
1021
1245
|
throw new Error("Basic credentials cannot contain control characters");
|
|
1022
1246
|
return `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`;
|
|
1023
1247
|
}
|
|
1024
|
-
export function setApiSecret(tenant, name, config, workspace) {
|
|
1248
|
+
export function setApiSecret(tenant, name, config, workspace, limit = {}) {
|
|
1025
1249
|
let all = { ...(config.headers ?? {}) };
|
|
1026
1250
|
if (config.basic) {
|
|
1027
1251
|
if (Object.keys(all).some((h) => h.toLowerCase() === "authorization")) {
|
|
@@ -1056,7 +1280,7 @@ export function setApiSecret(tenant, name, config, workspace) {
|
|
|
1056
1280
|
const base = config.base_url?.trim();
|
|
1057
1281
|
if (base && !/^https?:\/\//.test(base))
|
|
1058
1282
|
throw new Error("base_url must be http(s)");
|
|
1059
|
-
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);
|
|
1060
1284
|
}
|
|
1061
1285
|
export function isApiValue(value) {
|
|
1062
1286
|
return value.startsWith(API_PREFIX);
|
|
@@ -1094,7 +1318,7 @@ export function sealVaultBindings(tenant) {
|
|
|
1094
1318
|
const next = { ...all };
|
|
1095
1319
|
for (const [name, value] of plain) {
|
|
1096
1320
|
const rec = all[name];
|
|
1097
|
-
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);
|
|
1098
1322
|
}
|
|
1099
1323
|
sealed += plain.size;
|
|
1100
1324
|
return next;
|
|
@@ -1142,7 +1366,7 @@ export function rewrapTenantSecrets(tenant) {
|
|
|
1142
1366
|
return null;
|
|
1143
1367
|
const next = { ...all };
|
|
1144
1368
|
for (const [name, value] of plain) {
|
|
1145
|
-
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());
|
|
1146
1370
|
}
|
|
1147
1371
|
moved += plain.size;
|
|
1148
1372
|
return next;
|