@seekrit/mcp 0.6.0 → 0.7.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/index.js +2802 -126
- package/package.json +4 -2
package/dist/index.js
CHANGED
|
@@ -8,6 +8,2285 @@ import { homedir } from "node:os";
|
|
|
8
8
|
import { dirname, join, parse } from "node:path";
|
|
9
9
|
import { createInterface } from "node:readline";
|
|
10
10
|
import { Writable } from "node:stream";
|
|
11
|
+
//#region ../../packages/core/src/agent-policy.ts
|
|
12
|
+
/** A bare hostname: no scheme, no port, no path, no wildcard. */
|
|
13
|
+
const policyHostSchema = z.string().trim().min(1).max(253).toLowerCase().refine((h) => !/[:/\s*]/.test(h), { message: "host must be a bare hostname (no scheme, port, path, or wildcard)" }).refine((h) => /^[a-z0-9.-]+$/.test(h), { message: "host contains invalid characters" });
|
|
14
|
+
const policyMethodSchema = z.string().trim().toUpperCase().regex(/^[A-Z]{3,10}$/, "method must be an HTTP method name");
|
|
15
|
+
/**
|
|
16
|
+
* A path pattern. Must be absolute, because it is matched against a request
|
|
17
|
+
* path — a relative pattern is a mistake that would silently match nothing.
|
|
18
|
+
*/
|
|
19
|
+
const policyPathSchema = z.string().trim().min(1).max(512).startsWith("/", "path pattern must start with /").refine((p) => !p.includes("?"), { message: "path patterns match the path only, not the query" });
|
|
20
|
+
const policySecretNameSchema = z.string().trim().regex(/^[A-Za-z0-9_]+$/, "secret names are letters, digits, and underscores");
|
|
21
|
+
z.object({
|
|
22
|
+
host: policyHostSchema,
|
|
23
|
+
methods: z.array(policyMethodSchema).max(16).default([]),
|
|
24
|
+
paths: z.array(policyPathSchema).max(64).default([]),
|
|
25
|
+
allow: z.array(policySecretNameSchema).max(64).default([]),
|
|
26
|
+
label: z.string().trim().max(120).optional()
|
|
27
|
+
});
|
|
28
|
+
z.object({
|
|
29
|
+
/** `ap1.<body>.<signature>` — opaque to the server. */
|
|
30
|
+
bundle: z.string().min(16).max(256 * 1024) });
|
|
31
|
+
z.object({
|
|
32
|
+
name: z.string().trim().min(1).max(120),
|
|
33
|
+
slug: z.string().trim().min(1).max(64).regex(/^[a-z0-9][a-z0-9-]*$/, "slug is lowercase letters, digits, and dashes"),
|
|
34
|
+
/** The environment whose secrets this agent's policy may name. Optional. */
|
|
35
|
+
environmentId: z.string().trim().min(1).max(64).optional()
|
|
36
|
+
});
|
|
37
|
+
z.object({
|
|
38
|
+
name: z.string().trim().min(1).max(120).optional(),
|
|
39
|
+
enabled: z.boolean().optional(),
|
|
40
|
+
environmentId: z.string().trim().min(1).max(64).nullish()
|
|
41
|
+
});
|
|
42
|
+
z.object({
|
|
43
|
+
host: policyHostSchema,
|
|
44
|
+
method: policyMethodSchema,
|
|
45
|
+
path: z.string().trim().min(1).max(2048),
|
|
46
|
+
secret: policySecretNameSchema.optional()
|
|
47
|
+
});
|
|
48
|
+
/** All catalog keys as a runtime array (for iteration / zod enums). */
|
|
49
|
+
const ENTITLEMENT_KEYS = Object.keys({
|
|
50
|
+
"feature.kms": {
|
|
51
|
+
kind: "feature",
|
|
52
|
+
label: "Managed keys (KMS)",
|
|
53
|
+
description: "Client-side managed keys for application-layer encryption and signing.",
|
|
54
|
+
default: true
|
|
55
|
+
},
|
|
56
|
+
"feature.honey_tokens": {
|
|
57
|
+
kind: "feature",
|
|
58
|
+
label: "Honey tokens",
|
|
59
|
+
description: "Decoy credentials that alert the moment anyone tries to use them.",
|
|
60
|
+
default: true
|
|
61
|
+
},
|
|
62
|
+
"feature.leases": {
|
|
63
|
+
kind: "feature",
|
|
64
|
+
label: "Temporary access",
|
|
65
|
+
description: "Vault-style short-lived database and cloud credentials.",
|
|
66
|
+
default: true
|
|
67
|
+
},
|
|
68
|
+
"feature.rotation": {
|
|
69
|
+
kind: "feature",
|
|
70
|
+
label: "Secret rotation",
|
|
71
|
+
description: "Managed, scheduled rotation of stored credentials.",
|
|
72
|
+
default: true
|
|
73
|
+
},
|
|
74
|
+
"feature.log_sink": {
|
|
75
|
+
kind: "feature",
|
|
76
|
+
label: "Audit log export (SIEM)",
|
|
77
|
+
description: "Stream the audit log to an external OTLP collector.",
|
|
78
|
+
default: true
|
|
79
|
+
},
|
|
80
|
+
"feature.proxy": {
|
|
81
|
+
kind: "feature",
|
|
82
|
+
label: "Agent egress proxy",
|
|
83
|
+
description: "Substitute secrets into outbound requests for untrusted workloads.",
|
|
84
|
+
default: true
|
|
85
|
+
},
|
|
86
|
+
"feature.sso": {
|
|
87
|
+
kind: "feature",
|
|
88
|
+
label: "SSO / SAML",
|
|
89
|
+
description: "Single sign-on beyond the built-in providers.",
|
|
90
|
+
default: true
|
|
91
|
+
},
|
|
92
|
+
"feature.sync": {
|
|
93
|
+
kind: "feature",
|
|
94
|
+
label: "Third-party sync",
|
|
95
|
+
description: "Push environment secrets to external platforms like Vercel.",
|
|
96
|
+
default: true
|
|
97
|
+
},
|
|
98
|
+
"apps.max": {
|
|
99
|
+
kind: "limit",
|
|
100
|
+
label: "Applications",
|
|
101
|
+
description: "Maximum applications in the organization.",
|
|
102
|
+
default: null
|
|
103
|
+
},
|
|
104
|
+
"envs.per_app.max": {
|
|
105
|
+
kind: "limit",
|
|
106
|
+
label: "Environments per application",
|
|
107
|
+
description: "Maximum environments under a single application.",
|
|
108
|
+
default: null
|
|
109
|
+
},
|
|
110
|
+
"branches.per_app.max": {
|
|
111
|
+
kind: "limit",
|
|
112
|
+
label: "Branch configs per application",
|
|
113
|
+
description: "Maximum ephemeral branch environments under a single application.",
|
|
114
|
+
default: null
|
|
115
|
+
},
|
|
116
|
+
"secrets.per_env.max": {
|
|
117
|
+
kind: "limit",
|
|
118
|
+
label: "Secrets per environment",
|
|
119
|
+
description: "Maximum secrets in a single environment.",
|
|
120
|
+
default: null
|
|
121
|
+
},
|
|
122
|
+
"groups.max": {
|
|
123
|
+
kind: "limit",
|
|
124
|
+
label: "Groups",
|
|
125
|
+
description: "Maximum reusable secret groups in the organization.",
|
|
126
|
+
default: null
|
|
127
|
+
},
|
|
128
|
+
"tokens.max": {
|
|
129
|
+
kind: "limit",
|
|
130
|
+
label: "Service tokens",
|
|
131
|
+
description: "Maximum active service tokens in the organization.",
|
|
132
|
+
default: null
|
|
133
|
+
},
|
|
134
|
+
"kms.keys.max": {
|
|
135
|
+
kind: "limit",
|
|
136
|
+
label: "Managed keys",
|
|
137
|
+
description: "Maximum managed KMS keys in the organization.",
|
|
138
|
+
default: null
|
|
139
|
+
},
|
|
140
|
+
"lease.targets.max": {
|
|
141
|
+
kind: "limit",
|
|
142
|
+
label: "Lease targets",
|
|
143
|
+
description: "Maximum registered temporary-access targets.",
|
|
144
|
+
default: null
|
|
145
|
+
},
|
|
146
|
+
"sync.connections.max": {
|
|
147
|
+
kind: "limit",
|
|
148
|
+
label: "Sync connections",
|
|
149
|
+
description: "Maximum registered third-party sync destinations.",
|
|
150
|
+
default: null
|
|
151
|
+
},
|
|
152
|
+
"rotation.policies.max": {
|
|
153
|
+
kind: "limit",
|
|
154
|
+
label: "Rotation policies",
|
|
155
|
+
description: "Maximum secrets with managed rotation configured.",
|
|
156
|
+
default: null
|
|
157
|
+
},
|
|
158
|
+
members: {
|
|
159
|
+
kind: "metered",
|
|
160
|
+
label: "Members",
|
|
161
|
+
description: "Users in the organization. Included in the plan, then billed per seat.",
|
|
162
|
+
default: null,
|
|
163
|
+
metric: "member_count"
|
|
164
|
+
},
|
|
165
|
+
"resolves.monthly": {
|
|
166
|
+
kind: "metered",
|
|
167
|
+
label: "Monthly resolves",
|
|
168
|
+
description: "Secret resolutions per month. Included in the plan, then billed per unit.",
|
|
169
|
+
default: null,
|
|
170
|
+
metric: "monthly_resolves"
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
//#endregion
|
|
174
|
+
//#region ../../packages/core/src/plans.ts
|
|
175
|
+
const PLAN_FAMILIES = {
|
|
176
|
+
free: {
|
|
177
|
+
id: "free",
|
|
178
|
+
name: "Free",
|
|
179
|
+
description: "Get started with the essentials.",
|
|
180
|
+
current: 3,
|
|
181
|
+
hidden: false
|
|
182
|
+
},
|
|
183
|
+
team: {
|
|
184
|
+
id: "team",
|
|
185
|
+
name: "Team",
|
|
186
|
+
description: "For small teams collaborating on secrets.",
|
|
187
|
+
current: 1,
|
|
188
|
+
hidden: false
|
|
189
|
+
},
|
|
190
|
+
pro: {
|
|
191
|
+
id: "pro",
|
|
192
|
+
name: "Pro",
|
|
193
|
+
description: "For teams running secrets in production.",
|
|
194
|
+
current: 1,
|
|
195
|
+
hidden: true
|
|
196
|
+
},
|
|
197
|
+
enterprise: {
|
|
198
|
+
id: "enterprise",
|
|
199
|
+
name: "Enterprise",
|
|
200
|
+
description: "Unlimited scale with advanced governance.",
|
|
201
|
+
current: 1,
|
|
202
|
+
hidden: false
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
const PLAN_FAMILY_IDS = Object.keys(PLAN_FAMILIES);
|
|
206
|
+
PLAN_FAMILY_IDS.filter((family) => !PLAN_FAMILIES[family].hidden);
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region ../../packages/core/src/billing.ts
|
|
209
|
+
/**
|
|
210
|
+
* Lifecycle states a subscription can be in. Mirrors the biller's own states
|
|
211
|
+
* (Stripe) but is provider-neutral so a different biller could map onto it.
|
|
212
|
+
*/
|
|
213
|
+
const SUBSCRIPTION_STATUSES = [
|
|
214
|
+
"trialing",
|
|
215
|
+
"active",
|
|
216
|
+
"past_due",
|
|
217
|
+
"canceled",
|
|
218
|
+
"paused"
|
|
219
|
+
];
|
|
220
|
+
//#endregion
|
|
221
|
+
//#region ../../packages/core/src/branches.ts
|
|
222
|
+
/**
|
|
223
|
+
* Branch (ephemeral) environments — a per-PR/preview overlay on an existing
|
|
224
|
+
* application environment.
|
|
225
|
+
*
|
|
226
|
+
* A branch is an ordinary environment row with a parent and a TTL. It is an
|
|
227
|
+
* **overlay, not a copy**: resolve returns the parent's layers and then the
|
|
228
|
+
* branch's own on top, so a branch holds only the values that differ and
|
|
229
|
+
* tracks the parent live. Nothing is re-encrypted at creation — a secret's
|
|
230
|
+
* ciphertext is bound to `(environmentId, name)` as AAD, so copying blobs into
|
|
231
|
+
* a new environment could not decrypt anyway, and a snapshot would immediately
|
|
232
|
+
* drift from its base.
|
|
233
|
+
*
|
|
234
|
+
* Two rules keep the read path cheap and predictable, enforced here:
|
|
235
|
+
*
|
|
236
|
+
* - **Depth one.** A branch's parent must not itself be a branch, so resolve
|
|
237
|
+
* never recurses on the hot path.
|
|
238
|
+
* - **Application environments only.** Group environments are pulled in by
|
|
239
|
+
* composition (matched by slug) and have no single parent to overlay.
|
|
240
|
+
*/
|
|
241
|
+
/** Longest life a branch may be given. Bounds sprawl even if nobody cleans up. */
|
|
242
|
+
const MAX_BRANCH_TTL_SECONDS = 720 * 60 * 60;
|
|
243
|
+
/**
|
|
244
|
+
* Parse a human TTL — `30m`, `12h`, `7d`, `2w`, or bare seconds — into seconds.
|
|
245
|
+
* Returns null for anything unparseable, so callers can report the input back.
|
|
246
|
+
* `never` / `none` mean "no expiry" and yield `Infinity`, which
|
|
247
|
+
* {@link planBranchCreate} rejects unless passed as an explicit `null`.
|
|
248
|
+
*/
|
|
249
|
+
function parseBranchTtl(input) {
|
|
250
|
+
const raw = input.trim().toLowerCase();
|
|
251
|
+
if (raw === "never" || raw === "none") return Number.POSITIVE_INFINITY;
|
|
252
|
+
const match = /^(\d+)\s*(s|m|h|d|w)?$/.exec(raw);
|
|
253
|
+
if (!match) return null;
|
|
254
|
+
const value = Number(match[1]);
|
|
255
|
+
const multiplier = {
|
|
256
|
+
s: 1,
|
|
257
|
+
m: 60,
|
|
258
|
+
h: 3600,
|
|
259
|
+
d: 86400,
|
|
260
|
+
w: 604800
|
|
261
|
+
}[match[2] ?? "s"];
|
|
262
|
+
if (multiplier === void 0) return null;
|
|
263
|
+
return value * multiplier;
|
|
264
|
+
}
|
|
265
|
+
//#endregion
|
|
266
|
+
//#region ../../packages/core/src/dotenv.ts
|
|
267
|
+
/**
|
|
268
|
+
* Apply double-quote escapes in a single left-to-right pass.
|
|
269
|
+
*
|
|
270
|
+
* A pass per escape (`\n` → newline, then `\\` → `\`, …) is wrong: in `\\n` the
|
|
271
|
+
* first pass matches the trailing `\n` and yields a real newline, corrupting
|
|
272
|
+
* every literal backslash-n a JSON credential is made of. Scanning once means a
|
|
273
|
+
* backslash consumes the character after it and can never be re-read.
|
|
274
|
+
*/
|
|
275
|
+
function unescapeDoubleQuoted(text) {
|
|
276
|
+
let out = "";
|
|
277
|
+
for (let i = 0; i < text.length; i++) {
|
|
278
|
+
if (text[i] !== "\\" || i === text.length - 1) {
|
|
279
|
+
out += text[i];
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const next = text[++i];
|
|
283
|
+
if (next === "n") out += "\n";
|
|
284
|
+
else if (next === "r") out += "\r";
|
|
285
|
+
else if (next === "t") out += " ";
|
|
286
|
+
else if (next === "\"" || next === "\\") out += next;
|
|
287
|
+
else out += `\\${next}`;
|
|
288
|
+
}
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Find the index of the quote that closes a value opened at `start`.
|
|
293
|
+
*
|
|
294
|
+
* Inside double quotes a `\"` is an escaped quote, not the terminator (and a
|
|
295
|
+
* `\\` immediately before the quote *is* a terminator, since the backslash is
|
|
296
|
+
* itself escaped) — so the scan tracks escapes rather than searching for the
|
|
297
|
+
* next bare quote. Single quotes have no escapes: the next one closes. Returns
|
|
298
|
+
* -1 when the value is never closed.
|
|
299
|
+
*/
|
|
300
|
+
function findClosingQuote(content, start, quote) {
|
|
301
|
+
for (let i = start; i < content.length; i++) {
|
|
302
|
+
if (quote === "\"" && content[i] === "\\") {
|
|
303
|
+
i++;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (content[i] === quote) return i;
|
|
307
|
+
}
|
|
308
|
+
return -1;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Parse `.env` text into variables. Later assignments win, matching the
|
|
312
|
+
* object-assignment semantics every `.env` reader has.
|
|
313
|
+
*/
|
|
314
|
+
function parseDotenv(content) {
|
|
315
|
+
const out = {};
|
|
316
|
+
let cursor = 0;
|
|
317
|
+
while (cursor < content.length) {
|
|
318
|
+
const newline = content.indexOf("\n", cursor);
|
|
319
|
+
const lineEnd = newline === -1 ? content.length : newline;
|
|
320
|
+
const lineStart = cursor;
|
|
321
|
+
const trimmedEnd = lineEnd > lineStart && content[lineEnd - 1] === "\r" ? lineEnd - 1 : lineEnd;
|
|
322
|
+
cursor = lineEnd + 1;
|
|
323
|
+
let i = skipSpace(content, lineStart, trimmedEnd);
|
|
324
|
+
if (i === trimmedEnd || content[i] === "#") continue;
|
|
325
|
+
if (content.startsWith("export ", i)) i = skipSpace(content, i + 7, trimmedEnd);
|
|
326
|
+
const eq = content.indexOf("=", i);
|
|
327
|
+
if (eq === -1 || eq >= trimmedEnd) continue;
|
|
328
|
+
const key = content.slice(i, eq).trim();
|
|
329
|
+
if (!key) continue;
|
|
330
|
+
const valueStart = skipSpace(content, eq + 1, trimmedEnd);
|
|
331
|
+
const quote = content[valueStart];
|
|
332
|
+
if (valueStart === trimmedEnd || quote !== "\"" && quote !== "'") {
|
|
333
|
+
const rest = content.slice(valueStart, trimmedEnd).trimEnd();
|
|
334
|
+
const comment = rest.indexOf(" #");
|
|
335
|
+
out[key] = comment === -1 ? rest : rest.slice(0, comment).trimEnd();
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const close = findClosingQuote(content, valueStart + 1, quote);
|
|
339
|
+
const valueEnd = close === -1 ? content.length : close;
|
|
340
|
+
const value = content.slice(valueStart + 1, valueEnd);
|
|
341
|
+
out[key] = quote === "\"" ? unescapeDoubleQuoted(value) : value;
|
|
342
|
+
if (valueEnd >= cursor) {
|
|
343
|
+
const after = content.indexOf("\n", valueEnd);
|
|
344
|
+
cursor = after === -1 ? content.length : after + 1;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
/** First index at or after `from` (and before `end`) that isn't a space or tab. */
|
|
350
|
+
function skipSpace(content, from, end) {
|
|
351
|
+
let i = from;
|
|
352
|
+
while (i < end && (content[i] === " " || content[i] === " ")) i++;
|
|
353
|
+
return i;
|
|
354
|
+
}
|
|
355
|
+
//#endregion
|
|
356
|
+
//#region ../../packages/core/src/interpolate.ts
|
|
357
|
+
/**
|
|
358
|
+
* Secret references: `${OTHER_SECRET}` inside a secret value.
|
|
359
|
+
*
|
|
360
|
+
* This is the **canonical specification** of the expansion. It is pure string
|
|
361
|
+
* work over an already-decrypted variable set, so it runs wherever plaintext
|
|
362
|
+
* legitimately exists — the CLI, the browser, the language SDKs, and the Rust
|
|
363
|
+
* clients (`crates/seekrit-core/src/interpolate.rs` mirrors it, pinned by the
|
|
364
|
+
* shared golden fixture in `apps/run/testdata/vectors.json`).
|
|
365
|
+
*
|
|
366
|
+
* Expansion happens at **read time**, on the client, never on write and never
|
|
367
|
+
* on the server: the API only ever holds the ciphertext of the literal
|
|
368
|
+
* `${OTHER_SECRET}` text, so referencing costs nothing against the
|
|
369
|
+
* zero-knowledge invariant. It also means a reference stays live — rotating
|
|
370
|
+
* `DB_PASSWORD` updates every value that references it, with no re-encryption.
|
|
371
|
+
*
|
|
372
|
+
* The rules, in full:
|
|
373
|
+
*
|
|
374
|
+
* - `${NAME}` is replaced with the value of `NAME` in the *same fully-merged
|
|
375
|
+
* set* — after group → app-env → `.env` layering, so a reference always sees
|
|
376
|
+
* the value that layer precedence actually selected.
|
|
377
|
+
* - `NAME` must be a valid secret name (`[A-Za-z_][A-Za-z0-9_]*`, matching
|
|
378
|
+
* `secretNameSchema`). Anything else — `${1}`, `${FOO:-bar}`, `${a.b}` — is
|
|
379
|
+
* left exactly as written, so shell and CI template syntax passes through
|
|
380
|
+
* untouched.
|
|
381
|
+
* - A reference to a name that is not in the set is **left literal** and
|
|
382
|
+
* reported in {@link InterpolationResult.unresolved}. Erroring would mean a
|
|
383
|
+
* stored value that happens to contain `${GITHUB_SHA}` could break a whole
|
|
384
|
+
* environment's resolve; leaving it alone is the safe default, and the report
|
|
385
|
+
* is there to surface typos (`seekrit run --explain` prints it).
|
|
386
|
+
* - Expansion is recursive: a referenced value may itself contain references.
|
|
387
|
+
* - `$${NAME}` is an escape producing the literal text `${NAME}`. A `$$` not
|
|
388
|
+
* followed by `{` is ordinary text (passwords full of `$` are safe).
|
|
389
|
+
* - A reference **cycle** throws {@link InterpolationError}. Unlike an unknown
|
|
390
|
+
* name, a cycle can only be a configuration mistake — every name in it
|
|
391
|
+
* exists — and there is no value that could be correct to emit.
|
|
392
|
+
*
|
|
393
|
+
* `process.env` is deliberately *not* a reference source: `seekrit run` layers
|
|
394
|
+
* the live shell on top of the resolved set afterwards, and letting a stored
|
|
395
|
+
* secret pull in arbitrary host environment variables would be a surprising
|
|
396
|
+
* (and machine-dependent) way to change a secret's value.
|
|
397
|
+
*/
|
|
398
|
+
/** A reference name — the same grammar as `secretNameSchema`. */
|
|
399
|
+
const REFERENCE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
400
|
+
/**
|
|
401
|
+
* Cap on a single expanded value. Nested references can multiply length
|
|
402
|
+
* (`A=${B}${B}`, `B=${C}${C}`, …), which memoization makes fast but does not
|
|
403
|
+
* make small. A megabyte is far above any real secret and far below anything
|
|
404
|
+
* that would exhaust a container.
|
|
405
|
+
*/
|
|
406
|
+
const MAX_EXPANDED_LENGTH = 1048576;
|
|
407
|
+
/**
|
|
408
|
+
* Split a value into literal runs and references — the single tokenizer every
|
|
409
|
+
* rule above is expressed in terms of, so expansion and inspection can never
|
|
410
|
+
* disagree about what counts as a reference.
|
|
411
|
+
*/
|
|
412
|
+
function* scan(text) {
|
|
413
|
+
let i = 0;
|
|
414
|
+
while (i < text.length) {
|
|
415
|
+
const dollar = text.indexOf("$", i);
|
|
416
|
+
if (dollar === -1) {
|
|
417
|
+
yield { literal: text.slice(i) };
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (dollar > i) yield { literal: text.slice(i, dollar) };
|
|
421
|
+
if (text[dollar + 1] === "$" && text[dollar + 2] === "{") {
|
|
422
|
+
yield { literal: "${" };
|
|
423
|
+
i = dollar + 3;
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const close = text[dollar + 1] === "{" ? text.indexOf("}", dollar + 2) : -1;
|
|
427
|
+
const reference = close === -1 ? null : text.slice(dollar + 2, close);
|
|
428
|
+
if (reference !== null && REFERENCE_NAME.test(reference)) {
|
|
429
|
+
yield {
|
|
430
|
+
reference,
|
|
431
|
+
raw: text.slice(dollar, close + 1)
|
|
432
|
+
};
|
|
433
|
+
i = close + 1;
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
yield { literal: "$" };
|
|
437
|
+
i = dollar + 1;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
var InterpolationError = class extends Error {
|
|
441
|
+
code;
|
|
442
|
+
/**
|
|
443
|
+
* For `cycle`, the reference chain that closed on itself, starting and ending
|
|
444
|
+
* on the same name (`["A", "B", "A"]`). For `too_large`, the single name
|
|
445
|
+
* whose expansion blew the cap.
|
|
446
|
+
*/
|
|
447
|
+
chain;
|
|
448
|
+
constructor(code, message, chain) {
|
|
449
|
+
super(message);
|
|
450
|
+
this.name = "InterpolationError";
|
|
451
|
+
this.code = code;
|
|
452
|
+
this.chain = chain;
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
/**
|
|
456
|
+
* Expand `${NAME}` references throughout a decrypted variable set.
|
|
457
|
+
*
|
|
458
|
+
* Pure: the input is never mutated. Throws {@link InterpolationError} on a
|
|
459
|
+
* reference cycle (see the module comment for the complete rule set).
|
|
460
|
+
*/
|
|
461
|
+
function interpolateSecrets(values) {
|
|
462
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
463
|
+
const unresolved = /* @__PURE__ */ new Set();
|
|
464
|
+
const expanded = [];
|
|
465
|
+
/** The chain currently being expanded — the cycle detector. */
|
|
466
|
+
const stack = [];
|
|
467
|
+
/** Expand one present name's value, memoized so each is expanded once. */
|
|
468
|
+
function resolve(name) {
|
|
469
|
+
const cached = resolved.get(name);
|
|
470
|
+
if (cached !== void 0) return cached;
|
|
471
|
+
const cycleAt = stack.indexOf(name);
|
|
472
|
+
if (cycleAt !== -1) {
|
|
473
|
+
const chain = [...stack.slice(cycleAt), name];
|
|
474
|
+
throw new InterpolationError("cycle", `secret reference cycle: ${chain.join(" → ")}`, chain);
|
|
475
|
+
}
|
|
476
|
+
stack.push(name);
|
|
477
|
+
let out = "";
|
|
478
|
+
for (const segment of scan(values[name])) if ("literal" in segment) out += segment.literal;
|
|
479
|
+
else if (Object.hasOwn(values, segment.reference)) out += resolve(segment.reference);
|
|
480
|
+
else {
|
|
481
|
+
unresolved.add(segment.reference);
|
|
482
|
+
out += segment.raw;
|
|
483
|
+
}
|
|
484
|
+
stack.pop();
|
|
485
|
+
if (out.length > MAX_EXPANDED_LENGTH) throw new InterpolationError("too_large", `${name} expands to more than ${MAX_EXPANDED_LENGTH} bytes — check its references`, [name]);
|
|
486
|
+
resolved.set(name, out);
|
|
487
|
+
return out;
|
|
488
|
+
}
|
|
489
|
+
const result = {};
|
|
490
|
+
for (const name of Object.keys(values)) {
|
|
491
|
+
const value = resolve(name);
|
|
492
|
+
result[name] = value;
|
|
493
|
+
if (value !== values[name]) expanded.push(name);
|
|
494
|
+
}
|
|
495
|
+
return {
|
|
496
|
+
values: result,
|
|
497
|
+
expanded,
|
|
498
|
+
unresolved: [...unresolved].sort()
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
z.enum([
|
|
502
|
+
"postgres",
|
|
503
|
+
"mysql",
|
|
504
|
+
"ssh",
|
|
505
|
+
"redis",
|
|
506
|
+
"aws",
|
|
507
|
+
"gcp",
|
|
508
|
+
"mongodb"
|
|
509
|
+
]);
|
|
510
|
+
const executorModeSchema = z.enum(["in_do", "remote"]);
|
|
511
|
+
/**
|
|
512
|
+
* A Postgres role name we are willing to create. Deliberately strict — this
|
|
513
|
+
* value is interpolated into a SQL template, so it must be a bare identifier
|
|
514
|
+
* with no way to break out of quoting (no quotes, whitespace, or semicolons).
|
|
515
|
+
*/
|
|
516
|
+
const postgresRoleNameSchema = z.string().regex(/^[a-z_][a-z0-9_]{2,62}$/, "must be 3–63 chars, lowercase letters/digits/underscore, starting with a letter or underscore");
|
|
517
|
+
/**
|
|
518
|
+
* A SCRAM-SHA-256 verifier string as produced by @seekrit/crypto. Validated so
|
|
519
|
+
* it, too, is safe to interpolate into a quoted SQL literal (the alphabet is
|
|
520
|
+
* base64 + the fixed structural characters, none of which is a single quote).
|
|
521
|
+
*/
|
|
522
|
+
const scramVerifierSchema = z.string().regex(/^SCRAM-SHA-256\$\d{3,}:[A-Za-z0-9+/=]+\$[A-Za-z0-9+/=]+:[A-Za-z0-9+/=]+$/, "must be a SCRAM-SHA-256 verifier");
|
|
523
|
+
/**
|
|
524
|
+
* An SSH login principal (a Unix-style username the certificate authorizes).
|
|
525
|
+
* Bounded and restricted to a safe charset — principals are SSH-wire-encoded,
|
|
526
|
+
* not shell-interpolated, so this is sanity/DoS hardening, not an injection gate.
|
|
527
|
+
*/
|
|
528
|
+
const sshPrincipalSchema = z.string().regex(/^[A-Za-z0-9._-]{1,64}$/, "must be 1–64 chars of letters, digits, dot, dash, underscore");
|
|
529
|
+
/** An `ssh-ed25519 <base64> [comment]` public key line (deep-validated on sign). */
|
|
530
|
+
const sshPublicKeySchema = z.string().max(2048).regex(/^ssh-ed25519 [A-Za-z0-9+/=]+( .*)?$/, "must be an ssh-ed25519 public key");
|
|
531
|
+
/** An SSH certificate extension name, e.g. `permit-pty`. */
|
|
532
|
+
const sshExtensionSchema = z.string().regex(/^[a-z0-9-]{1,64}$/);
|
|
533
|
+
/**
|
|
534
|
+
* A MySQL/MariaDB user name we are willing to create. Interpolated into a
|
|
535
|
+
* quoted SQL literal (`'{{name}}'@'%'`), so it is kept strict — plain
|
|
536
|
+
* alphanumerics/underscore, no quotes/whitespace/semicolons to break out.
|
|
537
|
+
*/
|
|
538
|
+
const mysqlUserNameSchema = z.string().regex(/^[A-Za-z0-9_]{3,32}$/, "must be 3–32 chars, letters/digits/underscore");
|
|
539
|
+
/**
|
|
540
|
+
* A `mysql_native_password` authentication string — `*` followed by 40 upper
|
|
541
|
+
* hex chars (`UPPER(HEX(SHA1(SHA1(password))))`), as produced by
|
|
542
|
+
* @seekrit/crypto `mysqlNativePasswordVerifier`. Stored verbatim by
|
|
543
|
+
* `CREATE USER … IDENTIFIED WITH mysql_native_password AS '<str>'`, and its
|
|
544
|
+
* alphabet contains no single quote, so it is safe in a quoted SQL literal.
|
|
545
|
+
*/
|
|
546
|
+
const mysqlNativeVerifierSchema = z.string().regex(/^\*[0-9A-F]{40}$/, "must be a mysql_native_password hash (*<40 hex>)");
|
|
547
|
+
/**
|
|
548
|
+
* A Redis ACL user name we are willing to create. Interpolated into a Redis
|
|
549
|
+
* command line as a bare token (`ACL SETUSER <name> …`), so it is kept strict —
|
|
550
|
+
* plain alphanumerics/underscore, no whitespace to split the arg or ACL rule
|
|
551
|
+
* characters (`~ + @ # & %`) that could be read as a permission.
|
|
552
|
+
*/
|
|
553
|
+
const redisUserNameSchema = z.string().regex(/^[A-Za-z0-9_]{3,32}$/, "must be 3–32 chars, letters/digits/underscore");
|
|
554
|
+
/**
|
|
555
|
+
* A Redis password verifier — the lowercase-hex SHA-256 of the password, as
|
|
556
|
+
* produced by @seekrit/crypto `redisSha256Verifier`. `ACL SETUSER … on #<hex>`
|
|
557
|
+
* stores this digest verbatim, and it cannot authenticate: Redis `AUTH` hashes
|
|
558
|
+
* the *plaintext* it receives with SHA-256 and compares, so the stored digest
|
|
559
|
+
* is preimage-resistant (the password is high-entropy and machine-generated).
|
|
560
|
+
* The alphabet is bare hex, so it is a safe bare command token.
|
|
561
|
+
*/
|
|
562
|
+
const redisSha256VerifierSchema = z.string().regex(/^[0-9a-f]{64}$/, "must be a lowercase-hex SHA-256 digest (64 chars)");
|
|
563
|
+
/**
|
|
564
|
+
* An IAM role ARN the broker is allowed to assume. Bounded and structurally
|
|
565
|
+
* validated: `arn:<partition>:iam::<account>:role/<path-and-name>`. Partition
|
|
566
|
+
* covers commercial (`aws`), GovCloud (`aws-us-gov`), and China (`aws-cn`).
|
|
567
|
+
*/
|
|
568
|
+
const awsRoleArnSchema = z.string().regex(/^arn:aws(?:-us-gov|-cn)?:iam::\d{12}:role\/[\w+=,.@/-]{1,512}$/, "must be an IAM role ARN (arn:aws:iam::<account>:role/<name>)");
|
|
569
|
+
/** An AWS region id, e.g. `us-east-1`, `eu-west-2`, `us-gov-west-1`. */
|
|
570
|
+
const awsRegionSchema$1 = z.string().regex(/^[a-z]{2}(?:-[a-z]+)+-\d$/, "must be an AWS region id (e.g. us-east-1)");
|
|
571
|
+
/**
|
|
572
|
+
* An STS external id — the shared string a role's trust policy can require so a
|
|
573
|
+
* confused-deputy can't assume it. AWS allows a broad charset; we keep to the
|
|
574
|
+
* documented safe set and bound the length.
|
|
575
|
+
*/
|
|
576
|
+
const awsExternalIdSchema = z.string().regex(/^[\w+=,.@:/-]{2,1224}$/, "must be a valid STS external id");
|
|
577
|
+
z.string().regex(/^[\w+=,.@-]{2,64}$/, "must be 2–64 chars of [A-Za-z0-9_+=,.@-]");
|
|
578
|
+
/**
|
|
579
|
+
* A GCP service-account email the broker is allowed to impersonate (or that
|
|
580
|
+
* appears in a delegation chain). Structurally validated and bounded: it is
|
|
581
|
+
* interpolated into the IAM Credentials API URL path, so the charset excludes
|
|
582
|
+
* anything that could break out of a path segment. Covers user-managed
|
|
583
|
+
* (`name@<project>.iam.gserviceaccount.com`) and Google-managed
|
|
584
|
+
* (`<project-number>-compute@developer.gserviceaccount.com`) forms.
|
|
585
|
+
*/
|
|
586
|
+
const gcpServiceAccountEmailSchema = z.string().max(256).regex(/^[a-z0-9-]+@[a-z0-9.-]+\.gserviceaccount\.com$/, "must be a service-account email (…@….gserviceaccount.com)");
|
|
587
|
+
/**
|
|
588
|
+
* An OAuth 2.0 scope granted to the minted access token, e.g.
|
|
589
|
+
* `https://www.googleapis.com/auth/cloud-platform`. Bounded and whitespace-free
|
|
590
|
+
* (scopes are space-delimited); passed to the IAM Credentials API in a JSON body
|
|
591
|
+
* array, not a URL, so this is sanity/DoS hardening rather than an injection gate.
|
|
592
|
+
*/
|
|
593
|
+
const gcpOauthScopeSchema = z.string().min(1).max(256).regex(/^\S+$/, "must be a single OAuth scope with no whitespace");
|
|
594
|
+
/**
|
|
595
|
+
* The consumer's ephemeral P-256 public key (JWK-serialized) that a tier-2
|
|
596
|
+
* credential is wrapped to before it is returned. Validated structurally here;
|
|
597
|
+
* the executor imports it defensively before wrapping. Bounded so a giant blob
|
|
598
|
+
* can't be pushed through the control plane.
|
|
599
|
+
*/
|
|
600
|
+
const p256PublicKeyJwkSchema = z.string().max(2048).refine((s) => {
|
|
601
|
+
try {
|
|
602
|
+
const jwk = JSON.parse(s);
|
|
603
|
+
return jwk.kty === "EC" && jwk.crv === "P-256" && !!jwk.x && !!jwk.y;
|
|
604
|
+
} catch {
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
}, "must be a JWK-serialized P-256 public key");
|
|
608
|
+
z.string().regex(/^[A-Za-z0-9_-]{1,64}$/, "must be 1–64 chars of [A-Za-z0-9_-]");
|
|
609
|
+
/**
|
|
610
|
+
* A MongoDB database name — the db a preset role is granted on, or the
|
|
611
|
+
* authentication database a leased user is created in. MongoDB forbids
|
|
612
|
+
* `/\. "$*<>:|?` and the empty string in db names; we keep to a safe subset.
|
|
613
|
+
*/
|
|
614
|
+
const mongoDatabaseNameSchema = z.string().regex(/^[A-Za-z0-9_-]{1,63}$/, "must be 1–63 chars of [A-Za-z0-9_-]");
|
|
615
|
+
/**
|
|
616
|
+
* A single MongoDB role grant `{ role, db }` for a `custom` target — e.g.
|
|
617
|
+
* `{ role: "readWrite", db: "app" }` or a user-defined role. Admin-supplied
|
|
618
|
+
* trusted input, set once at registration; still bounded structurally.
|
|
619
|
+
*/
|
|
620
|
+
const mongoRoleSchema = z.object({
|
|
621
|
+
role: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/, "must be a role name"),
|
|
622
|
+
db: mongoDatabaseNameSchema
|
|
623
|
+
});
|
|
624
|
+
const postgresAccessLevelSchema = z.enum([
|
|
625
|
+
"readonly",
|
|
626
|
+
"readwrite",
|
|
627
|
+
"custom"
|
|
628
|
+
]);
|
|
629
|
+
const mysqlAccessLevelSchema = z.enum([
|
|
630
|
+
"readonly",
|
|
631
|
+
"readwrite",
|
|
632
|
+
"custom"
|
|
633
|
+
]);
|
|
634
|
+
const redisAccessLevelSchema = z.enum([
|
|
635
|
+
"readonly",
|
|
636
|
+
"readwrite",
|
|
637
|
+
"custom"
|
|
638
|
+
]);
|
|
639
|
+
const mongoAccessLevelSchema = z.enum([
|
|
640
|
+
"readonly",
|
|
641
|
+
"readwrite",
|
|
642
|
+
"custom"
|
|
643
|
+
]);
|
|
644
|
+
const connectionSchema = z.object({
|
|
645
|
+
host: z.string().min(1),
|
|
646
|
+
port: z.number().int().min(1).max(65535),
|
|
647
|
+
database: z.string().min(1)
|
|
648
|
+
});
|
|
649
|
+
/** A `{{name}}`/`{{verifier}}`/`{{valid_until}}` templated SQL statement. */
|
|
650
|
+
const statementSchema$1 = z.string().min(1).max(4e3);
|
|
651
|
+
/** A bare SQL identifier (schema name) — no quotes/whitespace/semicolons. */
|
|
652
|
+
const identifierSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,62}$/, "must be an identifier");
|
|
653
|
+
const postgresTargetConfigSchema = z.object({
|
|
654
|
+
provider: z.literal("postgres"),
|
|
655
|
+
executor: executorModeSchema,
|
|
656
|
+
accessLevel: postgresAccessLevelSchema.optional(),
|
|
657
|
+
schema: identifierSchema.optional(),
|
|
658
|
+
connection: connectionSchema,
|
|
659
|
+
provisionerUrl: z.url().optional(),
|
|
660
|
+
createStatements: z.array(statementSchema$1).max(16).optional(),
|
|
661
|
+
revokeStatements: z.array(statementSchema$1).max(16).optional()
|
|
662
|
+
});
|
|
663
|
+
/** A MySQL account host part (`'name'@'<host>'`) — no quotes/whitespace. */
|
|
664
|
+
const mysqlHostSchema$1 = z.string().regex(/^[A-Za-z0-9_.%:-]{1,255}$/, "must be a host pattern");
|
|
665
|
+
const mysqlTargetConfigSchema = z.object({
|
|
666
|
+
provider: z.literal("mysql"),
|
|
667
|
+
executor: executorModeSchema,
|
|
668
|
+
accessLevel: mysqlAccessLevelSchema.optional(),
|
|
669
|
+
connection: connectionSchema,
|
|
670
|
+
userHost: mysqlHostSchema$1.optional(),
|
|
671
|
+
provisionerUrl: z.url().optional(),
|
|
672
|
+
createStatements: z.array(statementSchema$1).max(16).optional(),
|
|
673
|
+
revokeStatements: z.array(statementSchema$1).max(16).optional()
|
|
674
|
+
});
|
|
675
|
+
const redisConnectionSchema = z.object({
|
|
676
|
+
host: z.string().min(1),
|
|
677
|
+
port: z.number().int().min(1).max(65535),
|
|
678
|
+
/** Redis logical database index (the `/<n>` in a connection URL). */
|
|
679
|
+
db: z.number().int().min(0).max(15).optional()
|
|
680
|
+
});
|
|
681
|
+
const redisTargetConfigSchema = z.object({
|
|
682
|
+
provider: z.literal("redis"),
|
|
683
|
+
executor: executorModeSchema,
|
|
684
|
+
accessLevel: redisAccessLevelSchema.optional(),
|
|
685
|
+
connection: redisConnectionSchema,
|
|
686
|
+
provisionerUrl: z.url().optional(),
|
|
687
|
+
createStatements: z.array(statementSchema$1).max(16).optional(),
|
|
688
|
+
revokeStatements: z.array(statementSchema$1).max(16).optional()
|
|
689
|
+
});
|
|
690
|
+
const sshTargetConfigSchema = z.object({
|
|
691
|
+
provider: z.literal("ssh"),
|
|
692
|
+
executor: z.literal("in_do"),
|
|
693
|
+
caPublicKey: sshPublicKeySchema,
|
|
694
|
+
allowedPrincipals: z.array(sshPrincipalSchema).max(64).optional(),
|
|
695
|
+
extensions: z.array(sshExtensionSchema).max(16).optional(),
|
|
696
|
+
maxTtlSeconds: z.number().int().min(60).max(3600 * 24 * 7).optional(),
|
|
697
|
+
connection: z.object({
|
|
698
|
+
host: z.string().min(1).optional(),
|
|
699
|
+
user: sshPrincipalSchema.optional()
|
|
700
|
+
}).optional()
|
|
701
|
+
});
|
|
702
|
+
const AWS_MAX_TTL_SECONDS = 3600 * 12;
|
|
703
|
+
const awsTargetConfigSchema = z.object({
|
|
704
|
+
provider: z.literal("aws"),
|
|
705
|
+
executor: z.literal("in_do"),
|
|
706
|
+
roleArn: awsRoleArnSchema,
|
|
707
|
+
region: awsRegionSchema$1,
|
|
708
|
+
externalId: awsExternalIdSchema.optional(),
|
|
709
|
+
sessionPolicy: z.string().min(1).max(4e3).optional(),
|
|
710
|
+
maxTtlSeconds: z.number().int().min(900).max(AWS_MAX_TTL_SECONDS).optional()
|
|
711
|
+
});
|
|
712
|
+
const GCP_MAX_TTL_SECONDS = 3600 * 12;
|
|
713
|
+
const gcpTargetConfigSchema = z.object({
|
|
714
|
+
provider: z.literal("gcp"),
|
|
715
|
+
executor: z.literal("in_do"),
|
|
716
|
+
serviceAccount: gcpServiceAccountEmailSchema,
|
|
717
|
+
scopes: z.array(gcpOauthScopeSchema).min(1).max(32).optional(),
|
|
718
|
+
delegates: z.array(gcpServiceAccountEmailSchema).max(8).optional(),
|
|
719
|
+
maxTtlSeconds: z.number().int().min(60).max(GCP_MAX_TTL_SECONDS).optional()
|
|
720
|
+
});
|
|
721
|
+
const mongoTargetConfigSchema = z.object({
|
|
722
|
+
provider: z.literal("mongodb"),
|
|
723
|
+
executor: z.literal("in_do"),
|
|
724
|
+
accessLevel: mongoAccessLevelSchema.optional(),
|
|
725
|
+
connection: connectionSchema,
|
|
726
|
+
authSource: mongoDatabaseNameSchema.optional(),
|
|
727
|
+
roles: z.array(mongoRoleSchema).min(1).max(32).optional(),
|
|
728
|
+
tls: z.boolean().optional(),
|
|
729
|
+
maxTtlSeconds: z.number().int().min(60).max(3600 * 24 * 7).optional()
|
|
730
|
+
});
|
|
731
|
+
const leaseTargetConfigSchema = z.discriminatedUnion("provider", [
|
|
732
|
+
postgresTargetConfigSchema,
|
|
733
|
+
mysqlTargetConfigSchema,
|
|
734
|
+
redisTargetConfigSchema,
|
|
735
|
+
sshTargetConfigSchema,
|
|
736
|
+
awsTargetConfigSchema,
|
|
737
|
+
gcpTargetConfigSchema,
|
|
738
|
+
mongoTargetConfigSchema
|
|
739
|
+
]);
|
|
740
|
+
z.object({
|
|
741
|
+
name: z.string().trim().min(1).max(128),
|
|
742
|
+
config: leaseTargetConfigSchema,
|
|
743
|
+
/**
|
|
744
|
+
* The admin/provisioning credential (e.g. a Postgres connection string),
|
|
745
|
+
* encrypted client-side to the broker's public key (a `wd1.` wrap). The
|
|
746
|
+
* control plane stores only this ciphertext — it never sees the plaintext.
|
|
747
|
+
*/
|
|
748
|
+
wrappedAdminSecret: z.string().min(1)
|
|
749
|
+
});
|
|
750
|
+
/** Requested lease lifetime, shared by all providers. */
|
|
751
|
+
const ttlSecondsSchema = z.number().int().min(60).max(3600 * 24 * 7);
|
|
752
|
+
/**
|
|
753
|
+
* Client → API: mint a Postgres lease. The client generates the password and
|
|
754
|
+
* its SCRAM verifier locally and sends only the verifier — the plaintext
|
|
755
|
+
* password never leaves the requesting machine.
|
|
756
|
+
*/
|
|
757
|
+
const mintPostgresLeaseSchema = z.object({
|
|
758
|
+
provider: z.literal("postgres"),
|
|
759
|
+
targetId: z.string().min(1),
|
|
760
|
+
roleName: postgresRoleNameSchema,
|
|
761
|
+
verifier: scramVerifierSchema,
|
|
762
|
+
ttlSeconds: ttlSecondsSchema
|
|
763
|
+
});
|
|
764
|
+
/**
|
|
765
|
+
* Client → API: mint a MySQL/MariaDB lease. The client generates the password
|
|
766
|
+
* and its `mysql_native_password` hash locally and sends only the hash — the
|
|
767
|
+
* plaintext password never leaves the requesting machine.
|
|
768
|
+
*/
|
|
769
|
+
const mintMysqlLeaseSchema = z.object({
|
|
770
|
+
provider: z.literal("mysql"),
|
|
771
|
+
targetId: z.string().min(1),
|
|
772
|
+
roleName: mysqlUserNameSchema,
|
|
773
|
+
verifier: mysqlNativeVerifierSchema,
|
|
774
|
+
ttlSeconds: ttlSecondsSchema
|
|
775
|
+
});
|
|
776
|
+
/**
|
|
777
|
+
* Client → API: mint a Redis lease. The client generates the password and its
|
|
778
|
+
* SHA-256 hex digest locally and sends only the digest — the plaintext password
|
|
779
|
+
* never leaves the requesting machine.
|
|
780
|
+
*/
|
|
781
|
+
const mintRedisLeaseSchema = z.object({
|
|
782
|
+
provider: z.literal("redis"),
|
|
783
|
+
targetId: z.string().min(1),
|
|
784
|
+
roleName: redisUserNameSchema,
|
|
785
|
+
verifier: redisSha256VerifierSchema,
|
|
786
|
+
ttlSeconds: ttlSecondsSchema
|
|
787
|
+
});
|
|
788
|
+
/**
|
|
789
|
+
* Client → API: mint an SSH lease. The client generates an ephemeral keypair
|
|
790
|
+
* locally and sends only the public key; the signed certificate comes back in
|
|
791
|
+
* the response. The private key never leaves the requesting machine.
|
|
792
|
+
*/
|
|
793
|
+
const mintSshLeaseSchema = z.object({
|
|
794
|
+
provider: z.literal("ssh"),
|
|
795
|
+
targetId: z.string().min(1),
|
|
796
|
+
publicKey: sshPublicKeySchema,
|
|
797
|
+
principals: z.array(sshPrincipalSchema).min(1).max(32),
|
|
798
|
+
ttlSeconds: ttlSecondsSchema
|
|
799
|
+
});
|
|
800
|
+
/**
|
|
801
|
+
* Client → API: mint an AWS lease. The client generates an ephemeral P-256
|
|
802
|
+
* keypair locally and sends only the public key; STS mints the credential and
|
|
803
|
+
* the broker returns it wrapped to that key. The private key never leaves the
|
|
804
|
+
* requesting machine, so the plaintext credential is only decryptable there.
|
|
805
|
+
*
|
|
806
|
+
* TTL bounds are STS's own `DurationSeconds` limits (15 min – 12 h), not the
|
|
807
|
+
* generic lease bounds — STS rejects anything below 900 seconds.
|
|
808
|
+
*/
|
|
809
|
+
const mintAwsLeaseSchema = z.object({
|
|
810
|
+
provider: z.literal("aws"),
|
|
811
|
+
targetId: z.string().min(1),
|
|
812
|
+
recipientPublicKey: p256PublicKeyJwkSchema,
|
|
813
|
+
ttlSeconds: z.number().int().min(900).max(AWS_MAX_TTL_SECONDS)
|
|
814
|
+
});
|
|
815
|
+
/**
|
|
816
|
+
* Client → API: mint a GCP lease. Like AWS (tier 2): the client generates an
|
|
817
|
+
* ephemeral P-256 keypair locally and sends only the public key; the IAM
|
|
818
|
+
* Credentials API mints the access token and the broker returns it wrapped to
|
|
819
|
+
* that key. The private key never leaves the requesting machine, so the plaintext
|
|
820
|
+
* token is only decryptable there.
|
|
821
|
+
*
|
|
822
|
+
* TTL bounds are GCP's `generateAccessToken` limits (1 min – 12 h); tokens over
|
|
823
|
+
* 1 h require the credential-lifetime-extension org policy.
|
|
824
|
+
*/
|
|
825
|
+
const mintGcpLeaseSchema = z.object({
|
|
826
|
+
provider: z.literal("gcp"),
|
|
827
|
+
targetId: z.string().min(1),
|
|
828
|
+
recipientPublicKey: p256PublicKeyJwkSchema,
|
|
829
|
+
ttlSeconds: z.number().int().min(60).max(GCP_MAX_TTL_SECONDS)
|
|
830
|
+
});
|
|
831
|
+
/**
|
|
832
|
+
* Client → API: mint a MongoDB lease. Like AWS (tier 2), the client generates
|
|
833
|
+
* an ephemeral P-256 keypair locally and sends only the public key; the broker
|
|
834
|
+
* generates the password, runs `createUser`, and returns the credential wrapped
|
|
835
|
+
* to that key. The private key never leaves the requesting machine, so the
|
|
836
|
+
* plaintext credential is only decryptable there.
|
|
837
|
+
*/
|
|
838
|
+
const mintMongoLeaseSchema = z.object({
|
|
839
|
+
provider: z.literal("mongodb"),
|
|
840
|
+
targetId: z.string().min(1),
|
|
841
|
+
recipientPublicKey: p256PublicKeyJwkSchema,
|
|
842
|
+
ttlSeconds: ttlSecondsSchema
|
|
843
|
+
});
|
|
844
|
+
z.discriminatedUnion("provider", [
|
|
845
|
+
mintPostgresLeaseSchema,
|
|
846
|
+
mintMysqlLeaseSchema,
|
|
847
|
+
mintRedisLeaseSchema,
|
|
848
|
+
mintSshLeaseSchema,
|
|
849
|
+
mintAwsLeaseSchema,
|
|
850
|
+
mintGcpLeaseSchema,
|
|
851
|
+
mintMongoLeaseSchema
|
|
852
|
+
]);
|
|
853
|
+
//#endregion
|
|
854
|
+
//#region ../../packages/core/src/types.ts
|
|
855
|
+
/**
|
|
856
|
+
* Transactional notification emails seekrit can send. Each id is one
|
|
857
|
+
* user-facing on/off toggle (see `NOTIFICATION_TYPE_META`). These carry only
|
|
858
|
+
* audit-grade metadata — never secret material — and every one is opt-out
|
|
859
|
+
* (defaults on). Kept as a const array so the API, api-client, and dashboard
|
|
860
|
+
* share a single source of truth (mirrors `AUDIT_ACTIONS`).
|
|
861
|
+
*/
|
|
862
|
+
const NOTIFICATION_TYPES = [
|
|
863
|
+
"token_created",
|
|
864
|
+
"token_revoked",
|
|
865
|
+
"env_access_granted",
|
|
866
|
+
"env_access_revoked",
|
|
867
|
+
"resolve_denied",
|
|
868
|
+
"org_welcome",
|
|
869
|
+
"token_expiring",
|
|
870
|
+
"lease_expired",
|
|
871
|
+
"sync_failed",
|
|
872
|
+
"honey_token_tripped",
|
|
873
|
+
"secret_rotation_failed"
|
|
874
|
+
];
|
|
875
|
+
//#endregion
|
|
876
|
+
//#region ../../packages/core/src/schemas.ts
|
|
877
|
+
/** URL-safe identifier segment: `my-app`, `production`, … */
|
|
878
|
+
const slugSchema = z.string().min(1).max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "must be lowercase alphanumeric with hyphens");
|
|
879
|
+
const nameSchema = z.string().trim().min(1).max(128);
|
|
880
|
+
/** An email address, normalized to trimmed lowercase before validation. */
|
|
881
|
+
const emailSchema = z.string().trim().toLowerCase().pipe(z.email().max(320));
|
|
882
|
+
/** Env-var style secret name: FOO, DATABASE_URL, apiKey2 … */
|
|
883
|
+
const secretNameSchema = z.string().min(1).max(256).regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "must be a valid environment variable name");
|
|
884
|
+
z.enum([
|
|
885
|
+
"owner",
|
|
886
|
+
"admin",
|
|
887
|
+
"member"
|
|
888
|
+
]);
|
|
889
|
+
/** Role a person may be invited at — never `owner` (ownership isn't invitable). */
|
|
890
|
+
const inviteRoleSchema = z.enum(["admin", "member"]);
|
|
891
|
+
const principalTypeSchema = z.enum(["user", "service_token"]);
|
|
892
|
+
/** Org-level capability a service token can hold (never `owner`). */
|
|
893
|
+
const serviceTokenRoleSchema = z.enum(["admin", "member"]);
|
|
894
|
+
z.object({
|
|
895
|
+
name: nameSchema,
|
|
896
|
+
slug: slugSchema
|
|
897
|
+
});
|
|
898
|
+
z.object({
|
|
899
|
+
name: nameSchema,
|
|
900
|
+
slug: slugSchema
|
|
901
|
+
});
|
|
902
|
+
z.object({ name: nameSchema });
|
|
903
|
+
z.object({ name: nameSchema });
|
|
904
|
+
z.object({ name: nameSchema });
|
|
905
|
+
z.object({ required: z.boolean() });
|
|
906
|
+
z.object({
|
|
907
|
+
email: emailSchema,
|
|
908
|
+
role: inviteRoleSchema.default("member")
|
|
909
|
+
});
|
|
910
|
+
z.object({
|
|
911
|
+
name: nameSchema,
|
|
912
|
+
slug: slugSchema
|
|
913
|
+
});
|
|
914
|
+
z.object({
|
|
915
|
+
groupId: z.string().min(1),
|
|
916
|
+
/** Precedence among an env's groups (higher wins). Appended if omitted. */
|
|
917
|
+
position: z.number().int().min(0).optional()
|
|
918
|
+
});
|
|
919
|
+
z.object({
|
|
920
|
+
name: nameSchema,
|
|
921
|
+
slug: slugSchema,
|
|
922
|
+
/** Environment DEK wrapped to the creator's public key — created client-side. */
|
|
923
|
+
wrappedDek: z.string().min(1),
|
|
924
|
+
/**
|
|
925
|
+
* When the org has recovery enabled, the same DEK additionally wrapped to the
|
|
926
|
+
* org recovery public key, so the environment is recovery-protected from
|
|
927
|
+
* creation. Omitted when recovery is off (backfilled later by `recovery sync`).
|
|
928
|
+
*/
|
|
929
|
+
recoveryWrappedDek: z.string().min(1).nullish()
|
|
930
|
+
});
|
|
931
|
+
z.object({
|
|
932
|
+
/** Opaque versioned ciphertext blob from @seekrit/crypto. */
|
|
933
|
+
ciphertext: z.string().min(1).max(65536) });
|
|
934
|
+
z.object({ version: z.number().int().positive() });
|
|
935
|
+
z.object({ limit: z.coerce.number().int().min(1).max(200).default(50) });
|
|
936
|
+
z.object({
|
|
937
|
+
publicKeyJwk: z.string().min(1),
|
|
938
|
+
/**
|
|
939
|
+
* Private key encrypted with a passphrase-derived KEK; opaque to the
|
|
940
|
+
* server. Self-contained blob (embeds KDF salt + iterations).
|
|
941
|
+
*/
|
|
942
|
+
encryptedPrivateKey: z.string().min(1)
|
|
943
|
+
});
|
|
944
|
+
const grantEnvironmentKeySchema = z.object({
|
|
945
|
+
principalType: principalTypeSchema,
|
|
946
|
+
principalId: z.string().min(1),
|
|
947
|
+
wrappedDek: z.string().min(1)
|
|
948
|
+
});
|
|
949
|
+
z.object({
|
|
950
|
+
slug: slugSchema,
|
|
951
|
+
/** Display name; defaults to the slug. */
|
|
952
|
+
name: nameSchema.optional(),
|
|
953
|
+
ttlSeconds: z.number().int().min(60).max(MAX_BRANCH_TTL_SECONDS).nullish(),
|
|
954
|
+
/** The branch's own DEK, wrapped to the creator — generated client-side. */
|
|
955
|
+
wrappedDek: z.string().min(1),
|
|
956
|
+
recoveryWrappedDek: z.string().min(1).nullish(),
|
|
957
|
+
/** The same DEK wrapped to each of the parent's existing grant-holders. */
|
|
958
|
+
grants: z.array(grantEnvironmentKeySchema).max(500).default([])
|
|
959
|
+
});
|
|
960
|
+
z.object({
|
|
961
|
+
name: nameSchema,
|
|
962
|
+
tokenId: z.string().regex(/^skt_[0-9A-Za-z]+$/),
|
|
963
|
+
/** SHA-256 hash (base64url) of the full token string. */
|
|
964
|
+
tokenHash: z.string().min(1),
|
|
965
|
+
publicKeyJwk: z.string().min(1),
|
|
966
|
+
/**
|
|
967
|
+
* Org-level capability. Defaults to `member` (a runtime credential); pass
|
|
968
|
+
* `admin` to mint a headless provisioning token. Only an admin caller may
|
|
969
|
+
* create an `admin` token, so capability cannot escalate itself.
|
|
970
|
+
*/
|
|
971
|
+
role: serviceTokenRoleSchema.default("member"),
|
|
972
|
+
/**
|
|
973
|
+
* The application environment this token is bound to (org + app + env).
|
|
974
|
+
* Optional so org-admin tokens can exist, but required for runtime tokens
|
|
975
|
+
* that resolve secrets via `GET /v1/resolve`.
|
|
976
|
+
*/
|
|
977
|
+
environmentId: z.string().min(1).nullish(),
|
|
978
|
+
expiresAt: z.iso.datetime().nullish()
|
|
979
|
+
});
|
|
980
|
+
z.object({
|
|
981
|
+
name: nameSchema,
|
|
982
|
+
tokenId: z.string().regex(/^skt_[0-9A-Za-z]+$/),
|
|
983
|
+
/** SHA-256 hash (base64url) of the full token string. */
|
|
984
|
+
tokenHash: z.string().min(1),
|
|
985
|
+
/** Where the decoy was planted, as a reminder for whoever reads the alert. */
|
|
986
|
+
placement: z.string().max(200).nullish()
|
|
987
|
+
});
|
|
988
|
+
const kmsKeyPurposeSchema = z.enum(["encrypt", "sign"]);
|
|
989
|
+
const kmsKeySpecSchema = z.enum(["aes-256-gcm", "ecdsa-p256"]);
|
|
990
|
+
/** A wrapped key grant supplied by the client (server never sees plaintext material). */
|
|
991
|
+
const kmsGrantInputSchema = z.object({
|
|
992
|
+
principalType: principalTypeSchema,
|
|
993
|
+
principalId: z.string().min(1),
|
|
994
|
+
/** Key material wrapped to the principal's public key (`wd1.` blob). */
|
|
995
|
+
wrappedKey: z.string().min(1)
|
|
996
|
+
});
|
|
997
|
+
z.object({
|
|
998
|
+
name: nameSchema,
|
|
999
|
+
purpose: kmsKeyPurposeSchema,
|
|
1000
|
+
spec: kmsKeySpecSchema,
|
|
1001
|
+
applicationId: z.string().min(1).nullish(),
|
|
1002
|
+
groupId: z.string().min(1).nullish(),
|
|
1003
|
+
/** ECDSA P-256 public key (JWK) for `sign` keys; omit for `encrypt` keys. */
|
|
1004
|
+
publicKeyJwk: z.string().min(1).nullish(),
|
|
1005
|
+
grants: z.array(kmsGrantInputSchema).min(1)
|
|
1006
|
+
}).refine((v) => !(v.applicationId && v.groupId), {
|
|
1007
|
+
message: "a key may be scoped to an application or a group, not both",
|
|
1008
|
+
path: ["groupId"]
|
|
1009
|
+
}).refine((v) => v.purpose === "sign" === (v.spec === "ecdsa-p256"), {
|
|
1010
|
+
message: "sign keys require spec ecdsa-p256; encrypt keys require aes-256-gcm",
|
|
1011
|
+
path: ["spec"]
|
|
1012
|
+
}).refine((v) => v.purpose === "sign" === (v.publicKeyJwk != null), {
|
|
1013
|
+
message: "sign keys require a publicKeyJwk; encrypt keys must omit it",
|
|
1014
|
+
path: ["publicKeyJwk"]
|
|
1015
|
+
});
|
|
1016
|
+
z.object({
|
|
1017
|
+
principalType: principalTypeSchema,
|
|
1018
|
+
principalId: z.string().min(1),
|
|
1019
|
+
/** Current-version key material wrapped to the principal's public key. */
|
|
1020
|
+
wrappedKey: z.string().min(1)
|
|
1021
|
+
});
|
|
1022
|
+
z.object({
|
|
1023
|
+
publicKeyJwk: z.string().min(1).nullish(),
|
|
1024
|
+
grants: z.array(kmsGrantInputSchema).min(1)
|
|
1025
|
+
});
|
|
1026
|
+
/**
|
|
1027
|
+
* One custodian's wrapped Shamir share of the org recovery private key. The
|
|
1028
|
+
* client generates the recovery keypair, splits the private half M-of-N, and
|
|
1029
|
+
* wraps each share to a custodian's public key — the server stores only the
|
|
1030
|
+
* opaque `wrappedShare` and can reconstruct nothing.
|
|
1031
|
+
*/
|
|
1032
|
+
const recoveryShareInputSchema = z.object({
|
|
1033
|
+
principalType: principalTypeSchema,
|
|
1034
|
+
principalId: z.string().min(1),
|
|
1035
|
+
/** Shamir x-coordinate carried by the share (1..255). */
|
|
1036
|
+
shareIndex: z.number().int().min(1).max(255),
|
|
1037
|
+
/** The recovery-key share wrapped to the custodian's public key (`wd1.`). */
|
|
1038
|
+
wrappedShare: z.string().min(1)
|
|
1039
|
+
});
|
|
1040
|
+
/** An environment DEK additionally wrapped to the org recovery public key. */
|
|
1041
|
+
const recoveryEnvGrantSchema = z.object({
|
|
1042
|
+
environmentId: z.string().min(1),
|
|
1043
|
+
/** The environment's DEK wrapped to the recovery public key (`wd1.`). */
|
|
1044
|
+
wrappedDek: z.string().min(1)
|
|
1045
|
+
});
|
|
1046
|
+
z.object({
|
|
1047
|
+
recoveryPublicKeyJwk: z.string().min(1),
|
|
1048
|
+
threshold: z.number().int().min(1).max(255),
|
|
1049
|
+
shares: z.array(recoveryShareInputSchema).min(1).max(255),
|
|
1050
|
+
grants: z.array(recoveryEnvGrantSchema).default([])
|
|
1051
|
+
}).refine((v) => v.threshold <= v.shares.length, {
|
|
1052
|
+
message: "threshold cannot exceed the number of custodians",
|
|
1053
|
+
path: ["threshold"]
|
|
1054
|
+
}).refine((v) => new Set(v.shares.map((s) => `${s.principalType}:${s.principalId}`)).size === v.shares.length, {
|
|
1055
|
+
message: "custodians must be distinct",
|
|
1056
|
+
path: ["shares"]
|
|
1057
|
+
});
|
|
1058
|
+
z.object({ grants: z.array(recoveryEnvGrantSchema).min(1).max(500) });
|
|
1059
|
+
z.object({
|
|
1060
|
+
targetPublicKeyJwk: z.string().min(1),
|
|
1061
|
+
targetType: principalTypeSchema.nullish(),
|
|
1062
|
+
targetId: z.string().min(1).nullish(),
|
|
1063
|
+
reason: z.string().max(500).nullish()
|
|
1064
|
+
});
|
|
1065
|
+
z.object({
|
|
1066
|
+
shareIndex: z.number().int().min(1).max(255),
|
|
1067
|
+
/** The custodian's share re-wrapped to the target public key (`wd1.`). */
|
|
1068
|
+
contributedShare: z.string().min(1)
|
|
1069
|
+
});
|
|
1070
|
+
z.object({
|
|
1071
|
+
principalType: principalTypeSchema,
|
|
1072
|
+
principalId: z.string().min(1),
|
|
1073
|
+
grants: z.array(recoveryEnvGrantSchema).min(1).max(500)
|
|
1074
|
+
});
|
|
1075
|
+
z.object({ prefs: z.partialRecord(z.enum(NOTIFICATION_TYPES), z.boolean()) });
|
|
1076
|
+
z.object({
|
|
1077
|
+
endpoint: z.url().max(2048),
|
|
1078
|
+
headers: z.record(z.string().min(1).max(256), z.string().max(4096)).optional(),
|
|
1079
|
+
enabled: z.boolean().default(true)
|
|
1080
|
+
});
|
|
1081
|
+
const planFamilySchema = z.enum(PLAN_FAMILY_IDS);
|
|
1082
|
+
const subscriptionStatusSchema = z.enum(SUBSCRIPTION_STATUSES);
|
|
1083
|
+
z.enum(ENTITLEMENT_KEYS);
|
|
1084
|
+
/** An entitlement value: boolean (features), number or null/unlimited (limits, metered). */
|
|
1085
|
+
const entitlementValueSchema = z.union([
|
|
1086
|
+
z.boolean(),
|
|
1087
|
+
z.number(),
|
|
1088
|
+
z.null()
|
|
1089
|
+
]);
|
|
1090
|
+
z.object({
|
|
1091
|
+
family: planFamilySchema,
|
|
1092
|
+
version: z.number().int().positive().optional(),
|
|
1093
|
+
status: subscriptionStatusSchema.default("active")
|
|
1094
|
+
});
|
|
1095
|
+
z.object({
|
|
1096
|
+
value: entitlementValueSchema,
|
|
1097
|
+
note: z.string().max(500).nullish(),
|
|
1098
|
+
expiresAt: z.iso.datetime().nullish()
|
|
1099
|
+
});
|
|
1100
|
+
z.object({
|
|
1101
|
+
code: z.string().min(4).max(40),
|
|
1102
|
+
family: planFamilySchema,
|
|
1103
|
+
version: z.number().int().positive().optional(),
|
|
1104
|
+
durationDays: z.number().int().positive().max(3650).nullish(),
|
|
1105
|
+
maxRedemptions: z.number().int().positive().nullish(),
|
|
1106
|
+
startsAt: z.iso.datetime().nullish(),
|
|
1107
|
+
endsAt: z.iso.datetime().nullish(),
|
|
1108
|
+
note: z.string().max(500).nullish()
|
|
1109
|
+
});
|
|
1110
|
+
z.object({
|
|
1111
|
+
maxRedemptions: z.number().int().positive().nullish(),
|
|
1112
|
+
startsAt: z.iso.datetime().nullish(),
|
|
1113
|
+
endsAt: z.iso.datetime().nullish(),
|
|
1114
|
+
note: z.string().max(500).nullish(),
|
|
1115
|
+
/** Kill switch. Disabling stops new redemptions; live grants are untouched. */
|
|
1116
|
+
disabled: z.boolean().optional()
|
|
1117
|
+
});
|
|
1118
|
+
z.object({ code: z.string().min(1).max(40) });
|
|
1119
|
+
z.object({ family: planFamilySchema });
|
|
1120
|
+
z.object({
|
|
1121
|
+
sessionId: z.string().regex(/^skc_[0-9A-Za-z]+$/),
|
|
1122
|
+
/** SHA-256 hash (base64url) of the full session token string. */
|
|
1123
|
+
tokenHash: z.string().min(1).max(128),
|
|
1124
|
+
/** Display-only, e.g. `miles@studio.local`. */
|
|
1125
|
+
deviceLabel: z.string().trim().min(1).max(120),
|
|
1126
|
+
/** Display-only, e.g. `cli/0.4.2`. */
|
|
1127
|
+
client: z.string().trim().max(60).optional()
|
|
1128
|
+
});
|
|
1129
|
+
z.object({ code: z.string().trim().min(1).max(32) });
|
|
1130
|
+
z.object({
|
|
1131
|
+
cursor: z.string().optional(),
|
|
1132
|
+
limit: z.coerce.number().int().min(1).max(200).default(50),
|
|
1133
|
+
action: z.string().optional(),
|
|
1134
|
+
resourceType: z.string().optional()
|
|
1135
|
+
});
|
|
1136
|
+
z.enum([
|
|
1137
|
+
"generated",
|
|
1138
|
+
"postgres",
|
|
1139
|
+
"mysql",
|
|
1140
|
+
"redis"
|
|
1141
|
+
]);
|
|
1142
|
+
z.enum([
|
|
1143
|
+
"active",
|
|
1144
|
+
"paused",
|
|
1145
|
+
"failed"
|
|
1146
|
+
]);
|
|
1147
|
+
/** Statuses an admin may set directly (`failed` is only reached by the sweep). */
|
|
1148
|
+
const settableRotationStatusSchema = z.enum(["active", "paused"]);
|
|
1149
|
+
const rotationAlphabetSchema = z.enum([
|
|
1150
|
+
"alphanumeric",
|
|
1151
|
+
"hex",
|
|
1152
|
+
"base64url",
|
|
1153
|
+
"printable"
|
|
1154
|
+
]);
|
|
1155
|
+
const rotationIntervalSchema = z.number().int().min(300).max(3600 * 24 * 365);
|
|
1156
|
+
/**
|
|
1157
|
+
* A database user name we are willing to *re-key*. Deliberately more permissive
|
|
1158
|
+
* than the lease providers' name schemas — those name accounts seekrit creates,
|
|
1159
|
+
* whereas this names an account the customer's DBA created years ago, which may
|
|
1160
|
+
* be mixed-case or contain dots or dashes.
|
|
1161
|
+
*
|
|
1162
|
+
* It stays injection-safe for every place it is interpolated: a double-quoted
|
|
1163
|
+
* Postgres identifier, a single-quoted MySQL literal, and a bare Redis command
|
|
1164
|
+
* token. The charset excludes both quote characters, backslash, whitespace, and
|
|
1165
|
+
* `;`, so there is no way out of the surrounding quoting, and no whitespace to
|
|
1166
|
+
* split one Redis argument into two.
|
|
1167
|
+
*/
|
|
1168
|
+
const rotationUsernameSchema = z.string().regex(/^[A-Za-z0-9_$.-]{1,63}$/, "must be 1–63 chars of letters, digits, underscore, dollar, dot or dash");
|
|
1169
|
+
/** A `{{name}}`/`{{host}}`/`{{verifier}}` templated statement or command line. */
|
|
1170
|
+
const statementSchema = z.string().min(1).max(4e3);
|
|
1171
|
+
const passwordLengthSchema = z.number().int().min(16).max(256);
|
|
1172
|
+
/** A MySQL account host part (`'name'@'<host>'`) — no quotes/whitespace. */
|
|
1173
|
+
const mysqlHostSchema = z.string().regex(/^[A-Za-z0-9_.%:-]{1,255}$/, "must be a host pattern");
|
|
1174
|
+
const generatedRotationConfigSchema = z.object({
|
|
1175
|
+
kind: z.literal("generated"),
|
|
1176
|
+
length: passwordLengthSchema.optional(),
|
|
1177
|
+
alphabet: rotationAlphabetSchema.optional()
|
|
1178
|
+
});
|
|
1179
|
+
const postgresRotationConfigSchema = z.object({
|
|
1180
|
+
kind: z.literal("postgres"),
|
|
1181
|
+
username: rotationUsernameSchema,
|
|
1182
|
+
passwordLength: passwordLengthSchema.optional(),
|
|
1183
|
+
statements: z.array(statementSchema).max(16).optional()
|
|
1184
|
+
});
|
|
1185
|
+
const mysqlRotationConfigSchema = z.object({
|
|
1186
|
+
kind: z.literal("mysql"),
|
|
1187
|
+
username: rotationUsernameSchema,
|
|
1188
|
+
userHost: mysqlHostSchema.optional(),
|
|
1189
|
+
passwordLength: passwordLengthSchema.optional(),
|
|
1190
|
+
statements: z.array(statementSchema).max(16).optional()
|
|
1191
|
+
});
|
|
1192
|
+
const redisRotationConfigSchema = z.object({
|
|
1193
|
+
kind: z.literal("redis"),
|
|
1194
|
+
username: rotationUsernameSchema,
|
|
1195
|
+
passwordLength: passwordLengthSchema.optional(),
|
|
1196
|
+
statements: z.array(statementSchema).max(16).optional()
|
|
1197
|
+
});
|
|
1198
|
+
const rotationConfigSchema = z.discriminatedUnion("kind", [
|
|
1199
|
+
generatedRotationConfigSchema,
|
|
1200
|
+
postgresRotationConfigSchema,
|
|
1201
|
+
mysqlRotationConfigSchema,
|
|
1202
|
+
redisRotationConfigSchema
|
|
1203
|
+
]);
|
|
1204
|
+
z.object({
|
|
1205
|
+
environmentId: z.string().min(1),
|
|
1206
|
+
/** The secret whose value rotates. It must already exist. */
|
|
1207
|
+
secretName: secretNameSchema,
|
|
1208
|
+
config: rotationConfigSchema,
|
|
1209
|
+
intervalSeconds: rotationIntervalSchema,
|
|
1210
|
+
/**
|
|
1211
|
+
* The registered lease target supplying the connection, executor mode, and
|
|
1212
|
+
* wrapped admin credential. Required for every kind but `generated`.
|
|
1213
|
+
*/
|
|
1214
|
+
targetId: z.string().min(1).optional(),
|
|
1215
|
+
/** Environment DEK wrapped to the rotator public key (`wd1.` blob). */
|
|
1216
|
+
wrappedDek: z.string().min(1).optional(),
|
|
1217
|
+
/** Rotate once immediately instead of waiting for the first interval. */
|
|
1218
|
+
rotateNow: z.boolean().optional()
|
|
1219
|
+
});
|
|
1220
|
+
z.object({
|
|
1221
|
+
intervalSeconds: rotationIntervalSchema.optional(),
|
|
1222
|
+
config: rotationConfigSchema.optional(),
|
|
1223
|
+
/**
|
|
1224
|
+
* `paused` stops the sweep; `active` resumes it and clears the failure
|
|
1225
|
+
* streak, which is also how a `failed` policy is recovered.
|
|
1226
|
+
*/
|
|
1227
|
+
status: settableRotationStatusSchema.optional()
|
|
1228
|
+
}).refine((v) => v.intervalSeconds !== void 0 || v.config !== void 0 || v.status !== void 0, "provide at least one of intervalSeconds, config, or status");
|
|
1229
|
+
z.enum([
|
|
1230
|
+
"vercel",
|
|
1231
|
+
"cloudflare-workers",
|
|
1232
|
+
"cloudflare-pages",
|
|
1233
|
+
"cloudflare-secrets-store",
|
|
1234
|
+
"railway",
|
|
1235
|
+
"aws-secrets-manager",
|
|
1236
|
+
"aws-parameter-store",
|
|
1237
|
+
"render",
|
|
1238
|
+
"fly",
|
|
1239
|
+
"northflank",
|
|
1240
|
+
"digitalocean",
|
|
1241
|
+
"heroku",
|
|
1242
|
+
"netlify",
|
|
1243
|
+
"bunnyshell",
|
|
1244
|
+
"github-actions",
|
|
1245
|
+
"gcp-secret-manager"
|
|
1246
|
+
]);
|
|
1247
|
+
/**
|
|
1248
|
+
* Vercel account scope. The API token itself is never here — it is wrapped to
|
|
1249
|
+
* the connection's public key and stored as ciphertext.
|
|
1250
|
+
*
|
|
1251
|
+
* `teamId` is required for tokens scoped to a Vercel Team; personal-account
|
|
1252
|
+
* tokens omit it. Vercel rejects team-owned project calls that lack it with a
|
|
1253
|
+
* bare 403, so we pass it through as `?teamId=` on every request.
|
|
1254
|
+
*/
|
|
1255
|
+
const vercelConnectionConfigSchema = z.object({
|
|
1256
|
+
provider: z.literal("vercel"),
|
|
1257
|
+
/** Vercel Team id (`team_…`). Omit for a personal account. */
|
|
1258
|
+
teamId: z.string().trim().min(1).max(128).optional()
|
|
1259
|
+
});
|
|
1260
|
+
/**
|
|
1261
|
+
* A Cloudflare account id — 32 lowercase hex characters, found in the sidebar
|
|
1262
|
+
* of any account's dashboard. Every Cloudflare endpoint seekrit calls is
|
|
1263
|
+
* account-scoped, so this is the account half of "which account, which thing".
|
|
1264
|
+
*
|
|
1265
|
+
* Validated by shape because the alternative is a bare 400 from Cloudflare
|
|
1266
|
+
* hours later inside an alarm, with nobody watching. It does not catch pasting
|
|
1267
|
+
* a *zone* id, which has the same shape — only the API can tell those apart.
|
|
1268
|
+
*/
|
|
1269
|
+
const cloudflareAccountIdSchema = z.string().trim().regex(/^[0-9a-f]{32}$/, "must be a 32-character Cloudflare account ID (lowercase hex)");
|
|
1270
|
+
/**
|
|
1271
|
+
* Cloudflare account scope, shared by all three Cloudflare providers. The API
|
|
1272
|
+
* token is never here — it is wrapped to the connection's public key and
|
|
1273
|
+
* stored as ciphertext, exactly as Vercel's is.
|
|
1274
|
+
*
|
|
1275
|
+
* The three providers are deliberately separate kinds rather than one
|
|
1276
|
+
* `cloudflare` with a mode field: they target different APIs, take different
|
|
1277
|
+
* destinations, and fail in different ways. Splitting them keeps the
|
|
1278
|
+
* exhaustiveness guard in `connectorFor` meaningful.
|
|
1279
|
+
*/
|
|
1280
|
+
const cloudflareWorkersConnectionConfigSchema = z.object({
|
|
1281
|
+
provider: z.literal("cloudflare-workers"),
|
|
1282
|
+
accountId: cloudflareAccountIdSchema
|
|
1283
|
+
});
|
|
1284
|
+
const cloudflarePagesConnectionConfigSchema = z.object({
|
|
1285
|
+
provider: z.literal("cloudflare-pages"),
|
|
1286
|
+
accountId: cloudflareAccountIdSchema
|
|
1287
|
+
});
|
|
1288
|
+
const cloudflareSecretsStoreConnectionConfigSchema = z.object({
|
|
1289
|
+
provider: z.literal("cloudflare-secrets-store"),
|
|
1290
|
+
accountId: cloudflareAccountIdSchema
|
|
1291
|
+
});
|
|
1292
|
+
/**
|
|
1293
|
+
* A Railway id — every project, environment, and service is a UUID. Validated
|
|
1294
|
+
* by shape for the same reason Cloudflare's account id is: the alternative is a
|
|
1295
|
+
* bare GraphQL "Problem processing request" hours later inside an alarm, with
|
|
1296
|
+
* nobody watching.
|
|
1297
|
+
*/
|
|
1298
|
+
const railwayIdSchema = z.string().trim().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, "must be a Railway UUID");
|
|
1299
|
+
/**
|
|
1300
|
+
* Railway account scope. The token is never here — it is wrapped to the
|
|
1301
|
+
* connection's public key and stored as ciphertext, exactly as Vercel's is.
|
|
1302
|
+
*
|
|
1303
|
+
* There is no workspace/team id to carry: Railway ids are globally unique and
|
|
1304
|
+
* a destination names its project outright, so the token plus the destination
|
|
1305
|
+
* is the whole address.
|
|
1306
|
+
*/
|
|
1307
|
+
const railwayConnectionConfigSchema = z.object({
|
|
1308
|
+
provider: z.literal("railway"),
|
|
1309
|
+
tokenKind: z.enum(["account", "project"]).default("account")
|
|
1310
|
+
});
|
|
1311
|
+
/**
|
|
1312
|
+
* An AWS region id (`us-east-1`, `eu-central-1`, `us-gov-west-1`).
|
|
1313
|
+
*
|
|
1314
|
+
* Validated by shape rather than against a list, because AWS adds regions
|
|
1315
|
+
* faster than we ship. The endpoint host is built from this string, so a typo
|
|
1316
|
+
* would otherwise surface as a DNS failure inside an alarm with nobody
|
|
1317
|
+
* watching — which is a much worse place to learn about it than this form.
|
|
1318
|
+
*/
|
|
1319
|
+
const awsRegionSchema = z.string().trim().regex(/^[a-z]{2}(-[a-z]+)+-\d$/, "must be an AWS region ID, e.g. us-east-1");
|
|
1320
|
+
/**
|
|
1321
|
+
* The IAM access key id seekrit signs with.
|
|
1322
|
+
*
|
|
1323
|
+
* This lives in `config` — the *non-secret* half — on purpose: an access key id
|
|
1324
|
+
* is an identifier, not a credential. It appears in CloudTrail, in the IAM
|
|
1325
|
+
* console, and in the `Authorization` header of every signed request; only the
|
|
1326
|
+
* **secret access key** is secret, and that is what gets wrapped to the
|
|
1327
|
+
* connection's public key. Keeping the id here also lets the dashboard say
|
|
1328
|
+
* which key a connection is using, which is the first thing you want to know
|
|
1329
|
+
* when a connection starts failing after a key rotation.
|
|
1330
|
+
*
|
|
1331
|
+
* Long-lived IAM user keys only. `ASIA…` session credentials from STS expire
|
|
1332
|
+
* within hours, and a sync connection has to keep working unattended.
|
|
1333
|
+
*/
|
|
1334
|
+
const awsAccessKeyIdSchema = z.string().trim().regex(/^[A-Z0-9]{16,128}$/, "must be an AWS access key ID, e.g. AKIAIOSFODNN7EXAMPLE");
|
|
1335
|
+
/**
|
|
1336
|
+
* AWS account scope, shared by both AWS providers: which region to call and
|
|
1337
|
+
* which key to sign with. There is no account id — every endpoint seekrit calls
|
|
1338
|
+
* is reached through the regional host and authorizes off the signature, so the
|
|
1339
|
+
* account is whichever one the key belongs to.
|
|
1340
|
+
*
|
|
1341
|
+
* Two providers rather than one `aws` with a mode field, for the same reason
|
|
1342
|
+
* the three Cloudflare kinds are separate: different APIs, different
|
|
1343
|
+
* destinations, different IAM actions.
|
|
1344
|
+
*/
|
|
1345
|
+
const awsSecretsManagerConnectionConfigSchema = z.object({
|
|
1346
|
+
provider: z.literal("aws-secrets-manager"),
|
|
1347
|
+
region: awsRegionSchema,
|
|
1348
|
+
accessKeyId: awsAccessKeyIdSchema
|
|
1349
|
+
});
|
|
1350
|
+
const awsParameterStoreConnectionConfigSchema = z.object({
|
|
1351
|
+
provider: z.literal("aws-parameter-store"),
|
|
1352
|
+
region: awsRegionSchema,
|
|
1353
|
+
accessKeyId: awsAccessKeyIdSchema
|
|
1354
|
+
});
|
|
1355
|
+
/**
|
|
1356
|
+
* Render account scope — deliberately empty.
|
|
1357
|
+
*
|
|
1358
|
+
* Like Railway's, and unlike Vercel (which 403s team-owned resources without
|
|
1359
|
+
* `teamId`) or Cloudflare (whose every endpoint is account-scoped): a Render
|
|
1360
|
+
* API key is issued to a user, and every endpoint seekrit calls addresses its
|
|
1361
|
+
* resource by id — `srv-…`, `crn-…`, `evg-…`. There is nothing to scope, so
|
|
1362
|
+
* nothing is stored. The connection's `name` is what tells an operator which Render
|
|
1363
|
+
* workspace it belongs to.
|
|
1364
|
+
*/
|
|
1365
|
+
const renderConnectionConfigSchema = z.object({ provider: z.literal("render") });
|
|
1366
|
+
/**
|
|
1367
|
+
* Fly.io account scope — empty, as Render's is.
|
|
1368
|
+
*
|
|
1369
|
+
* Neither half of "which account, which thing" needs stating: Fly app names are
|
|
1370
|
+
* globally unique, so the destination names its app and that is the whole
|
|
1371
|
+
* address. Nor is there a token kind to declare the way Railway's `tokenKind`
|
|
1372
|
+
* is — Fly's two token shapes do take different auth schemes, but
|
|
1373
|
+
* `flyAuthorization` in the connector reads which one from the token itself.
|
|
1374
|
+
*/
|
|
1375
|
+
const flyConnectionConfigSchema = z.object({ provider: z.literal("fly") });
|
|
1376
|
+
/**
|
|
1377
|
+
* A Northflank id — projects and secret groups are both slugs derived from the
|
|
1378
|
+
* name they were created with (`default-project`, `example-secret-group`), and
|
|
1379
|
+
* both appear in the resource's URL. Validated against Northflank's own pattern
|
|
1380
|
+
* so the common slip — pasting the *display name*, spaces and all — fails here
|
|
1381
|
+
* rather than as a bare 404 inside an alarm with nobody watching.
|
|
1382
|
+
*/
|
|
1383
|
+
const northflankIdSchema = z.string().trim().min(3).max(100).regex(/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/, "must be a Northflank ID — the slug in the resource's URL, not its display name");
|
|
1384
|
+
/**
|
|
1385
|
+
* Northflank account scope — deliberately empty.
|
|
1386
|
+
*
|
|
1387
|
+
* A Northflank API token is issued by exactly one team (or org-owned team) and
|
|
1388
|
+
* carries that scope itself; `GET /v1/auth` reports which. There is no team id
|
|
1389
|
+
* to disambiguate the way Vercel needs one, and no account id the way
|
|
1390
|
+
* Cloudflare does: the token plus the destination's project is the whole
|
|
1391
|
+
* address. The kind exists so the discriminated union stays uniform.
|
|
1392
|
+
*/
|
|
1393
|
+
const northflankConnectionConfigSchema = z.object({ provider: z.literal("northflank") });
|
|
1394
|
+
/**
|
|
1395
|
+
* DigitalOcean account scope — deliberately empty, as Render's and Fly's are.
|
|
1396
|
+
*
|
|
1397
|
+
* A DigitalOcean personal access token belongs to one account (or one team, if
|
|
1398
|
+
* it was issued inside one) and carries that scope itself, and every endpoint
|
|
1399
|
+
* this connector calls addresses its app by id. There is no team id to
|
|
1400
|
+
* disambiguate the way Vercel needs one: the token plus the destination's app
|
|
1401
|
+
* id is the whole address.
|
|
1402
|
+
*/
|
|
1403
|
+
const digitalOceanConnectionConfigSchema = z.object({ provider: z.literal("digitalocean") });
|
|
1404
|
+
/**
|
|
1405
|
+
* Heroku account scope — empty, as Fly's and Render's are.
|
|
1406
|
+
*
|
|
1407
|
+
* A Heroku API token carries its user's access to every app and team they can
|
|
1408
|
+
* reach, and app names are globally unique, so the destination's app is the
|
|
1409
|
+
* whole address. There is no team id to state: unlike Vercel, where a personal
|
|
1410
|
+
* token 403s a team-owned project without `teamId`, Heroku resolves
|
|
1411
|
+
* `/apps/{app_id_or_name}` against everything the token can see, team-owned or
|
|
1412
|
+
* not.
|
|
1413
|
+
*/
|
|
1414
|
+
const herokuConnectionConfigSchema = z.object({ provider: z.literal("heroku") });
|
|
1415
|
+
/**
|
|
1416
|
+
* Netlify team scope — the one thing a Netlify token cannot tell us itself.
|
|
1417
|
+
*
|
|
1418
|
+
* Every environment variable endpoint is account-scoped
|
|
1419
|
+
* (`/accounts/{account_id}/env`), and a personal access token belongs to a
|
|
1420
|
+
* *user*, who may sit in several teams. So unlike Fly's or Heroku's, this
|
|
1421
|
+
* config is not empty: the token says who you are, and this says which team's
|
|
1422
|
+
* variables to write.
|
|
1423
|
+
*
|
|
1424
|
+
* Netlify treats the team's id and its slug as interchangeable wherever
|
|
1425
|
+
* `{account_id}` appears, so both are accepted. The slug is the one an operator
|
|
1426
|
+
* can find without an API call — it is in the dashboard URL
|
|
1427
|
+
* (`app.netlify.com/teams/<slug>`) and under Team settings → General.
|
|
1428
|
+
*/
|
|
1429
|
+
const netlifyConnectionConfigSchema = z.object({
|
|
1430
|
+
provider: z.literal("netlify"),
|
|
1431
|
+
/** Netlify team slug (`acme`) or account id — `{account_id}` accepts either. */
|
|
1432
|
+
accountId: z.string().trim().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, "must be a Netlify team slug or account ID — no slashes or spaces")
|
|
1433
|
+
});
|
|
1434
|
+
/**
|
|
1435
|
+
* Bunnyshell account scope — empty, as Fly's, Heroku's, and Northflank's are.
|
|
1436
|
+
*
|
|
1437
|
+
* A Bunnyshell access token (from `environments.bunnyshell.com/access-token`)
|
|
1438
|
+
* belongs to a *user* and carries their access to every organization they are
|
|
1439
|
+
* in, exactly as a Heroku token does. Unlike Netlify's, that does not force an
|
|
1440
|
+
* organization onto the connection, because nothing here is addressed *through*
|
|
1441
|
+
* one: both variable collections name their parent by an opaque, globally
|
|
1442
|
+
* unique id (`environment` or `project`), so the token plus the destination's
|
|
1443
|
+
* id is the whole address. The API offers an `organization` filter, but it
|
|
1444
|
+
* narrows a listing — it is not part of an address.
|
|
1445
|
+
*/
|
|
1446
|
+
const bunnyshellConnectionConfigSchema = z.object({ provider: z.literal("bunnyshell") });
|
|
1447
|
+
/**
|
|
1448
|
+
* GitHub account scope — empty for github.com, which is the whole point.
|
|
1449
|
+
*
|
|
1450
|
+
* A GitHub token addresses everything by `{owner}/{repo}` or `{org}`, and those
|
|
1451
|
+
* are the destination's business, so there is no account half to state the way
|
|
1452
|
+
* Cloudflare and Netlify need one. `baseUrl` is the single exception, and it is
|
|
1453
|
+
* not an account scope at all: it names a **GitHub Enterprise Server** install,
|
|
1454
|
+
* whose API lives on the customer's own host rather than on `api.github.com`.
|
|
1455
|
+
*
|
|
1456
|
+
* Left unset for github.com and for Enterprise Cloud (which is `api.github.com`
|
|
1457
|
+
* with a different plan behind it). Set only for a self-hosted GHES appliance,
|
|
1458
|
+
* where the REST API is at `https://<host>/api/v3`.
|
|
1459
|
+
*/
|
|
1460
|
+
const githubActionsConnectionConfigSchema = z.object({
|
|
1461
|
+
provider: z.literal("github-actions"),
|
|
1462
|
+
/**
|
|
1463
|
+
* GitHub Enterprise Server API root, e.g. `https://github.acme.com/api/v3`.
|
|
1464
|
+
* Omit for github.com. Must be `https:` — this URL carries the token.
|
|
1465
|
+
*/
|
|
1466
|
+
baseUrl: z.string().trim().max(300).refine((value) => {
|
|
1467
|
+
let parsed;
|
|
1468
|
+
try {
|
|
1469
|
+
parsed = new URL(value);
|
|
1470
|
+
} catch {
|
|
1471
|
+
return false;
|
|
1472
|
+
}
|
|
1473
|
+
return parsed.protocol === "https:" && !parsed.username && !parsed.password;
|
|
1474
|
+
}, "must be an https:// URL — the GitHub Enterprise Server API root, e.g. https://github.acme.com/api/v3").optional()
|
|
1475
|
+
});
|
|
1476
|
+
/**
|
|
1477
|
+
* A Google Cloud project, as `projects/{project}` accepts one: either the
|
|
1478
|
+
* project **ID** (`acme-prod`, 6–30 characters, what the console shows) or the
|
|
1479
|
+
* project **number** (all digits). Both are accepted because both work, and
|
|
1480
|
+
* the id is the one an operator can read off their own dashboard.
|
|
1481
|
+
*
|
|
1482
|
+
* Validated by shape for the reason Cloudflare's account id is: every Secret
|
|
1483
|
+
* Manager URL is built from this string, and a typo would otherwise surface as
|
|
1484
|
+
* a 403 from Google hours later inside an alarm, with nobody watching.
|
|
1485
|
+
*/
|
|
1486
|
+
const gcpProjectSchema = z.string().trim().refine((value) => /^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(value) || /^\d{1,20}$/.test(value), "must be a Google Cloud project ID (e.g. acme-prod) or project number");
|
|
1487
|
+
/**
|
|
1488
|
+
* Google Cloud project scope — which project's Secret Manager to write.
|
|
1489
|
+
*
|
|
1490
|
+
* The service account is *not* here, unlike AWS's access key id: a GCP
|
|
1491
|
+
* credential is a key JSON that names its own `client_email`, so the identity
|
|
1492
|
+
* arrives with the credential the way a Vercel token's does. What the
|
|
1493
|
+
* credential cannot say is which project to write, because a service account
|
|
1494
|
+
* can be granted access to secrets in projects other than its own — so that is
|
|
1495
|
+
* this field, exactly as Cloudflare's account id is.
|
|
1496
|
+
*
|
|
1497
|
+
* One project per connection. Syncing an environment into two projects means
|
|
1498
|
+
* two connections, which also keeps their key grants separate.
|
|
1499
|
+
*
|
|
1500
|
+
* Global secrets only: v1 addresses `secretmanager.googleapis.com`, not the
|
|
1501
|
+
* per-location `secretmanager.<location>.rep.googleapis.com` endpoints that
|
|
1502
|
+
* regional secrets live behind. Data residency is expressed instead through the
|
|
1503
|
+
* destination's user-managed replication.
|
|
1504
|
+
*/
|
|
1505
|
+
const gcpSecretManagerConnectionConfigSchema = z.object({
|
|
1506
|
+
provider: z.literal("gcp-secret-manager"),
|
|
1507
|
+
/** Project ID (`acme-prod`) or project number. */
|
|
1508
|
+
projectId: gcpProjectSchema
|
|
1509
|
+
});
|
|
1510
|
+
const syncConnectionConfigSchema = z.discriminatedUnion("provider", [
|
|
1511
|
+
vercelConnectionConfigSchema,
|
|
1512
|
+
cloudflareWorkersConnectionConfigSchema,
|
|
1513
|
+
cloudflarePagesConnectionConfigSchema,
|
|
1514
|
+
cloudflareSecretsStoreConnectionConfigSchema,
|
|
1515
|
+
railwayConnectionConfigSchema,
|
|
1516
|
+
awsSecretsManagerConnectionConfigSchema,
|
|
1517
|
+
awsParameterStoreConnectionConfigSchema,
|
|
1518
|
+
renderConnectionConfigSchema,
|
|
1519
|
+
flyConnectionConfigSchema,
|
|
1520
|
+
northflankConnectionConfigSchema,
|
|
1521
|
+
digitalOceanConnectionConfigSchema,
|
|
1522
|
+
herokuConnectionConfigSchema,
|
|
1523
|
+
netlifyConnectionConfigSchema,
|
|
1524
|
+
bunnyshellConnectionConfigSchema,
|
|
1525
|
+
githubActionsConnectionConfigSchema,
|
|
1526
|
+
gcpSecretManagerConnectionConfigSchema
|
|
1527
|
+
]);
|
|
1528
|
+
const vercelDestinationSchema = z.object({
|
|
1529
|
+
provider: z.literal("vercel"),
|
|
1530
|
+
/** Vercel project id (`prj_…`) or project name. */
|
|
1531
|
+
projectId: z.string().trim().min(1).max(128),
|
|
1532
|
+
/** Which deployment targets receive these values. At least one. */
|
|
1533
|
+
targets: z.array(z.enum([
|
|
1534
|
+
"production",
|
|
1535
|
+
"preview",
|
|
1536
|
+
"development"
|
|
1537
|
+
])).min(1),
|
|
1538
|
+
/**
|
|
1539
|
+
* Restrict `preview` writes to one git branch. Vercel only honors this when
|
|
1540
|
+
* `targets` includes `preview`; ignored otherwise.
|
|
1541
|
+
*/
|
|
1542
|
+
gitBranch: z.string().trim().min(1).max(255).optional()
|
|
1543
|
+
});
|
|
1544
|
+
/**
|
|
1545
|
+
* The Worker whose secrets a binding owns. Wrangler *environments* are not a
|
|
1546
|
+
* separate field because they are not a separate concept at the API: deploying
|
|
1547
|
+
* `my-api` with `--env staging` creates a Worker literally named
|
|
1548
|
+
* `my-api-staging`, so pointing at an environment means naming that script.
|
|
1549
|
+
*/
|
|
1550
|
+
const cloudflareWorkersDestinationSchema = z.object({
|
|
1551
|
+
provider: z.literal("cloudflare-workers"),
|
|
1552
|
+
/** Worker script name, as shown in the dashboard (`my-api`). */
|
|
1553
|
+
scriptName: z.string().trim().min(1).max(63).regex(/^[A-Za-z0-9_][A-Za-z0-9_-]*$/, "must be a Worker script name")
|
|
1554
|
+
});
|
|
1555
|
+
const cloudflarePagesDestinationSchema = z.object({
|
|
1556
|
+
provider: z.literal("cloudflare-pages"),
|
|
1557
|
+
/** Pages project name (`my-site`) — Pages has no separate project id. */
|
|
1558
|
+
projectName: z.string().trim().min(1).max(58).regex(/^[A-Za-z0-9][A-Za-z0-9-]*$/, "must be a Pages project name"),
|
|
1559
|
+
/** Which deployment configs receive these values. At least one. */
|
|
1560
|
+
environments: z.array(z.enum(["production", "preview"])).min(1)
|
|
1561
|
+
});
|
|
1562
|
+
const cloudflareSecretsStoreDestinationSchema = z.object({
|
|
1563
|
+
provider: z.literal("cloudflare-secrets-store"),
|
|
1564
|
+
/** Store id (32 hex). An account has exactly one store today. */
|
|
1565
|
+
storeId: z.string().trim().regex(/^[0-9a-f]{32}$/, "must be a 32-character Secrets Store ID (lowercase hex)"),
|
|
1566
|
+
/** Scopes applied to secrets this binding creates. At least one. */
|
|
1567
|
+
scopes: z.array(z.enum([
|
|
1568
|
+
"workers",
|
|
1569
|
+
"ai_gateway",
|
|
1570
|
+
"dex",
|
|
1571
|
+
"access",
|
|
1572
|
+
"containers",
|
|
1573
|
+
"websearch"
|
|
1574
|
+
])).min(1)
|
|
1575
|
+
});
|
|
1576
|
+
/**
|
|
1577
|
+
* Where inside Railway a binding writes.
|
|
1578
|
+
*
|
|
1579
|
+
* Railway variables are addressed by (project, environment, service) — the
|
|
1580
|
+
* environment here is *Railway's* (`production`, `pr-42`), not the seekrit
|
|
1581
|
+
* environment the binding reads from; a binding is precisely the mapping
|
|
1582
|
+
* between the two.
|
|
1583
|
+
*
|
|
1584
|
+
* Omitting `serviceId` targets the project's **shared** variables for that
|
|
1585
|
+
* environment, which services opt into with `${{shared.NAME}}`. That is a
|
|
1586
|
+
* genuinely different destination from any one service's variables, so it is an
|
|
1587
|
+
* absent field rather than a sentinel.
|
|
1588
|
+
*/
|
|
1589
|
+
const railwayDestinationSchema = z.object({
|
|
1590
|
+
provider: z.literal("railway"),
|
|
1591
|
+
/** Railway project id (a UUID, from the project's Settings page or URL). */
|
|
1592
|
+
projectId: railwayIdSchema,
|
|
1593
|
+
/** Railway environment id (a UUID) — the deployment environment to write. */
|
|
1594
|
+
environmentId: railwayIdSchema,
|
|
1595
|
+
/** Service to write. Omit to write the environment's shared variables. */
|
|
1596
|
+
serviceId: railwayIdSchema.optional(),
|
|
1597
|
+
/**
|
|
1598
|
+
* Suppress the redeploy Railway triggers when a variable changes.
|
|
1599
|
+
*
|
|
1600
|
+
* Left off (the default), a sync that changes a value redeploys the service,
|
|
1601
|
+
* which is what makes the new value actually reach the running process —
|
|
1602
|
+
* Railway applies variables at deploy time. Turn it on when deploys are
|
|
1603
|
+
* gated behind a release process and a secrets push must not start one; the
|
|
1604
|
+
* values then sit staged until the next deploy.
|
|
1605
|
+
*/
|
|
1606
|
+
skipDeploys: z.boolean().optional()
|
|
1607
|
+
});
|
|
1608
|
+
/**
|
|
1609
|
+
* A customer-managed KMS key to encrypt with, as a key id, ARN, or alias
|
|
1610
|
+
* (`alias/seekrit`). Omitted means the AWS-managed default for that service
|
|
1611
|
+
* (`aws/secretsmanager`, `aws/ssm`), which is what most accounts want.
|
|
1612
|
+
*
|
|
1613
|
+
* Deliberately loose: a KMS key can be named five different ways, half of them
|
|
1614
|
+
* cross-account ARNs, and rejecting a valid one here would be worse than
|
|
1615
|
+
* letting KMS give its own (very clear) error.
|
|
1616
|
+
*/
|
|
1617
|
+
const awsKmsKeyIdSchema = z.string().trim().min(1).max(2048);
|
|
1618
|
+
/**
|
|
1619
|
+
* Where in Secrets Manager a binding writes.
|
|
1620
|
+
*
|
|
1621
|
+
* `pathPrefix` exists rather than reusing {@link NameTransform}'s `prefix`
|
|
1622
|
+
* because the two answer different questions: a name transform produces a
|
|
1623
|
+
* *variable name* (`[A-Za-z0-9_]`, no slashes), while this produces a
|
|
1624
|
+
* *namespace* — `prod/storefront/` — and slashes are the whole point of it.
|
|
1625
|
+
*/
|
|
1626
|
+
const awsSecretsManagerDestinationSchema = z.object({
|
|
1627
|
+
provider: z.literal("aws-secrets-manager"),
|
|
1628
|
+
layout: z.enum(["secret-per-name", "json-bundle"]).default("secret-per-name"),
|
|
1629
|
+
/**
|
|
1630
|
+
* `secret-per-name` only: prepended to every secret's name, e.g.
|
|
1631
|
+
* `prod/storefront/`. Optional, but strongly advised in an account that
|
|
1632
|
+
* holds anything else — without it a binding writes at the root of a
|
|
1633
|
+
* namespace it does not own.
|
|
1634
|
+
*/
|
|
1635
|
+
pathPrefix: z.string().trim().max(400).regex(/^[A-Za-z0-9/_+=.@-]*$/, "may contain letters, digits, and / _ + = . @ -").optional(),
|
|
1636
|
+
/** `json-bundle` only: the one secret that holds every value, e.g. `prod/storefront/env`. */
|
|
1637
|
+
secretName: z.string().trim().min(1).max(512).regex(/^[A-Za-z0-9/_+=.@-]+$/, "may contain letters, digits, and / _ + = . @ -").optional(),
|
|
1638
|
+
kmsKeyId: awsKmsKeyIdSchema.optional()
|
|
1639
|
+
}).refine((d) => d.layout !== "json-bundle" || d.secretName !== void 0, {
|
|
1640
|
+
message: "a json-bundle destination needs the name of the secret to write",
|
|
1641
|
+
path: ["secretName"]
|
|
1642
|
+
});
|
|
1643
|
+
/**
|
|
1644
|
+
* The Parameter Store hierarchy a binding owns, e.g. `/prod/storefront/`.
|
|
1645
|
+
*
|
|
1646
|
+
* A path rather than a free-form prefix because that is what the API is built
|
|
1647
|
+
* around: `GetParametersByPath` is how an application reads a whole
|
|
1648
|
+
* environment in one call, and it only works on `/`-delimited names. Leading
|
|
1649
|
+
* and trailing slashes are required so the binding's names concatenate
|
|
1650
|
+
* unambiguously — `/prod/storefront/` + `DB_URL`.
|
|
1651
|
+
*/
|
|
1652
|
+
const awsParameterStoreDestinationSchema = z.object({
|
|
1653
|
+
provider: z.literal("aws-parameter-store"),
|
|
1654
|
+
/** Must start and end with `/`. `aws`/`ssm` are reserved by AWS as the first segment. */
|
|
1655
|
+
path: z.string().trim().max(1011).regex(/^\/([A-Za-z0-9_.-]+\/)*$/, "must be a parameter path like /prod/storefront/"),
|
|
1656
|
+
type: z.enum(["SecureString", "String"]).default("SecureString"),
|
|
1657
|
+
/**
|
|
1658
|
+
* Standard caps a value at 4KB and costs nothing; Advanced raises that to 8KB
|
|
1659
|
+
* and is billed per parameter per month. `Intelligent-Tiering` lets AWS pick,
|
|
1660
|
+
* upgrading only the parameters that need it.
|
|
1661
|
+
*/
|
|
1662
|
+
tier: z.enum([
|
|
1663
|
+
"Standard",
|
|
1664
|
+
"Advanced",
|
|
1665
|
+
"Intelligent-Tiering"
|
|
1666
|
+
]).default("Standard"),
|
|
1667
|
+
kmsKeyId: awsKmsKeyIdSchema.optional()
|
|
1668
|
+
});
|
|
1669
|
+
/**
|
|
1670
|
+
* Render resource ids are `<prefix>-<slug>`, and the two prefixes below are the
|
|
1671
|
+
* documented ones: `srv-` for every service type, `crn-` for cron jobs, `evg-`
|
|
1672
|
+
* for an environment group.
|
|
1673
|
+
*
|
|
1674
|
+
* The patterns reject the *other* kind's prefix rather than requiring their own.
|
|
1675
|
+
* The mistake worth catching is pasting an env-group id into the service field
|
|
1676
|
+
* (or the reverse) — which is otherwise a 404 hours later inside an alarm, with
|
|
1677
|
+
* nobody watching. Requiring the positive prefix would also reject a valid id
|
|
1678
|
+
* the day Render introduces a new resource prefix, which is not our call to
|
|
1679
|
+
* make.
|
|
1680
|
+
*/
|
|
1681
|
+
const renderServiceIdSchema = z.string().trim().regex(/^(?!evg-)[A-Za-z0-9_-]{1,64}$/, "must be a Render service ID (`srv-…` or `crn-…`), not an environment group");
|
|
1682
|
+
const renderEnvGroupIdSchema = z.string().trim().regex(/^(?!srv-|crn-)[A-Za-z0-9_-]{1,64}$/, "must be a Render environment group ID (`evg-…`), not a service");
|
|
1683
|
+
/** Environment variables set directly on one service. */
|
|
1684
|
+
const renderServiceDestinationSchema = z.object({
|
|
1685
|
+
provider: z.literal("render"),
|
|
1686
|
+
kind: z.literal("service"),
|
|
1687
|
+
/** Service id (`srv-…`, or `crn-…` for a cron job), from its dashboard URL. */
|
|
1688
|
+
serviceId: renderServiceIdSchema
|
|
1689
|
+
});
|
|
1690
|
+
/**
|
|
1691
|
+
* Environment variables in a shared environment group. Every service linked to
|
|
1692
|
+
* the group sees them, which is the point — and the reason a group binding is
|
|
1693
|
+
* worth thinking about twice: its blast radius is the link list, not one
|
|
1694
|
+
* service.
|
|
1695
|
+
*/
|
|
1696
|
+
const renderEnvGroupDestinationSchema = z.object({
|
|
1697
|
+
provider: z.literal("render"),
|
|
1698
|
+
kind: z.literal("env-group"),
|
|
1699
|
+
/** Environment group id (`evg-…`), from its dashboard URL. */
|
|
1700
|
+
envGroupId: renderEnvGroupIdSchema
|
|
1701
|
+
});
|
|
1702
|
+
const renderDestinationSchema = z.discriminatedUnion("kind", [renderServiceDestinationSchema, renderEnvGroupDestinationSchema]);
|
|
1703
|
+
/**
|
|
1704
|
+
* The Fly app whose secret set a binding owns.
|
|
1705
|
+
*
|
|
1706
|
+
* A Fly app has **one** secret set, shared by every Machine in every region —
|
|
1707
|
+
* there is no per-target split to state, the way Vercel and Pages have one.
|
|
1708
|
+
* Fly's convention is that staging and production are separate *apps*
|
|
1709
|
+
* (`storefront`, `storefront-staging`), so pointing at an environment means
|
|
1710
|
+
* naming that app, exactly as a Wrangler environment means naming its own
|
|
1711
|
+
* Worker.
|
|
1712
|
+
*
|
|
1713
|
+
* Values land **staged**: Fly injects secrets when a Machine boots, so already
|
|
1714
|
+
* running Machines keep what they started with until the app is deployed or its
|
|
1715
|
+
* Machines are updated (`fly secrets deploy -a <app>`), while Machines created
|
|
1716
|
+
* after the push get them straight away. The connector deliberately restarts
|
|
1717
|
+
* nothing — see the note in `apps/api/src/lib/sync/connectors/fly.ts`.
|
|
1718
|
+
*/
|
|
1719
|
+
const flyDestinationSchema = z.object({
|
|
1720
|
+
provider: z.literal("fly"),
|
|
1721
|
+
/** Fly app name, as `fly apps list` prints it. */
|
|
1722
|
+
appName: z.string().trim().min(1).max(63).regex(/^[a-z0-9][a-z0-9-]*$/, "must be a Fly app name (lowercase letters, numbers, and dashes)")
|
|
1723
|
+
});
|
|
1724
|
+
/**
|
|
1725
|
+
* Where inside Northflank a binding writes: one **secret group** in one
|
|
1726
|
+
* project.
|
|
1727
|
+
*
|
|
1728
|
+
* A secret group is Northflank's unit of injection — services and jobs in the
|
|
1729
|
+
* project inherit its variables, subject to the group's own restrictions and
|
|
1730
|
+
* priority. Those settings belong to the operator, not to seekrit: a binding
|
|
1731
|
+
* names an existing group and only ever writes its `variables` map, so
|
|
1732
|
+
* restrictions, priority, secret type, and any secret *files* stay as they were
|
|
1733
|
+
* configured.
|
|
1734
|
+
*
|
|
1735
|
+
* There is no environment field. Northflank has no per-group environment axis —
|
|
1736
|
+
* separate environments are separate projects (or separate groups restricted to
|
|
1737
|
+
* a stage), so the binding's seekrit environment maps to a group, one to one.
|
|
1738
|
+
*/
|
|
1739
|
+
const northflankDestinationSchema = z.object({
|
|
1740
|
+
provider: z.literal("northflank"),
|
|
1741
|
+
/** Project id — the slug in the project URL (`default-project`). */
|
|
1742
|
+
projectId: northflankIdSchema,
|
|
1743
|
+
/** Secret group id — the slug in the group's URL (`example-secret-group`). */
|
|
1744
|
+
secretGroupId: northflankIdSchema
|
|
1745
|
+
});
|
|
1746
|
+
/**
|
|
1747
|
+
* When App Platform makes a variable visible. DigitalOcean's enum also has
|
|
1748
|
+
* `UNSET`, which is not offered: it means "no scope stated", and a secrets
|
|
1749
|
+
* manager that writes a value should say when that value applies.
|
|
1750
|
+
*
|
|
1751
|
+
* The default here is `RUN_TIME` rather than DigitalOcean's own
|
|
1752
|
+
* `RUN_AND_BUILD_TIME`, and the difference is deliberate. A build-time variable
|
|
1753
|
+
* is visible to every build command, every buildpack, and anything they print;
|
|
1754
|
+
* a secret only the running process needs has no business being there. Binding
|
|
1755
|
+
* a value a build genuinely needs — a private registry token, a sourcemap
|
|
1756
|
+
* upload key — is a decision worth making explicitly.
|
|
1757
|
+
*/
|
|
1758
|
+
const DIGITALOCEAN_ENV_SCOPES = [
|
|
1759
|
+
"RUN_TIME",
|
|
1760
|
+
"BUILD_TIME",
|
|
1761
|
+
"RUN_AND_BUILD_TIME"
|
|
1762
|
+
];
|
|
1763
|
+
/**
|
|
1764
|
+
* A DigitalOcean app id — the UUID in the app's dashboard URL
|
|
1765
|
+
* (`cloud.digitalocean.com/apps/<id>`), and what `doctl apps list` prints.
|
|
1766
|
+
*
|
|
1767
|
+
* DigitalOcean's own spec types this as a bare string, but every app id it
|
|
1768
|
+
* issues is a UUID, and the slip worth catching is the one the API cannot tell
|
|
1769
|
+
* from a typo: pasting the app's *name* (`storefront`), which `GET /v2/apps/{id}`
|
|
1770
|
+
* answers with a flat 404 hours later inside an alarm, with nobody watching.
|
|
1771
|
+
*/
|
|
1772
|
+
const digitalOceanAppIdSchema = z.string().trim().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, "must be a DigitalOcean app ID (the UUID in the app's URL), not its name");
|
|
1773
|
+
/**
|
|
1774
|
+
* A component name, matching App Platform's own pattern for one. Names are
|
|
1775
|
+
* unique within an app, which is what makes a name — rather than an index into
|
|
1776
|
+
* `services` — the stable way to address a component's variables.
|
|
1777
|
+
*/
|
|
1778
|
+
const digitalOceanComponentNameSchema = z.string().trim().regex(/^[a-z][a-z0-9-]{0,30}[a-z0-9]$/, "must be an App Platform component name (lowercase letters, numbers, and dashes)");
|
|
1779
|
+
/**
|
|
1780
|
+
* Where inside a DigitalOcean app a binding writes.
|
|
1781
|
+
*
|
|
1782
|
+
* ## A push is a deployment
|
|
1783
|
+
*
|
|
1784
|
+
* App Platform has no per-variable endpoint. Environment variables live in the
|
|
1785
|
+
* app spec, and the only way to change one is to submit a new spec — which
|
|
1786
|
+
* starts a **new deployment** of the app. That is not a side effect this
|
|
1787
|
+
* connector chose and there is no flag to suppress it; it is what "set an
|
|
1788
|
+
* environment variable" means on this platform, in the control panel and in
|
|
1789
|
+
* `doctl` alike.
|
|
1790
|
+
*
|
|
1791
|
+
* The deployment reuses each component's current source (seekrit never sends
|
|
1792
|
+
* `update_all_source_versions`), so it redeploys the code already running
|
|
1793
|
+
* rather than pulling a newer commit or image. It is still a real deployment:
|
|
1794
|
+
* a build, a health check, and a rollout. Bind an environment here knowing that
|
|
1795
|
+
* changing a secret in it will roll the app.
|
|
1796
|
+
*
|
|
1797
|
+
* ## Values are written encrypted
|
|
1798
|
+
*
|
|
1799
|
+
* Everything seekrit writes goes in as `type: SECRET`, so App Platform encrypts
|
|
1800
|
+
* it at rest and hands it back as an opaque `EV[1:…]` blob rather than as
|
|
1801
|
+
* plaintext. That is also why this connector cannot tell whether a value it is
|
|
1802
|
+
* about to write is already there — see
|
|
1803
|
+
* `apps/api/src/lib/sync/connectors/digitalocean.ts`.
|
|
1804
|
+
*/
|
|
1805
|
+
const digitalOceanAppDestinationSchema = z.object({
|
|
1806
|
+
provider: z.literal("digitalocean"),
|
|
1807
|
+
kind: z.literal("app"),
|
|
1808
|
+
/** App id — the UUID in `cloud.digitalocean.com/apps/<id>`. */
|
|
1809
|
+
appId: digitalOceanAppIdSchema,
|
|
1810
|
+
scope: z.enum(DIGITALOCEAN_ENV_SCOPES).default("RUN_TIME")
|
|
1811
|
+
});
|
|
1812
|
+
/**
|
|
1813
|
+
* One component's own environment variables. Narrower than the app-level list:
|
|
1814
|
+
* only this service, worker, job, static site, or function sees them, and a key
|
|
1815
|
+
* here wins over the same key at app level.
|
|
1816
|
+
*/
|
|
1817
|
+
const digitalOceanComponentDestinationSchema = z.object({
|
|
1818
|
+
provider: z.literal("digitalocean"),
|
|
1819
|
+
kind: z.literal("component"),
|
|
1820
|
+
appId: digitalOceanAppIdSchema,
|
|
1821
|
+
/** Component name, as it appears in the app spec — not its type. */
|
|
1822
|
+
componentName: digitalOceanComponentNameSchema,
|
|
1823
|
+
scope: z.enum(DIGITALOCEAN_ENV_SCOPES).default("RUN_TIME")
|
|
1824
|
+
});
|
|
1825
|
+
const digitalOceanDestinationSchema = z.discriminatedUnion("kind", [digitalOceanAppDestinationSchema, digitalOceanComponentDestinationSchema]);
|
|
1826
|
+
/**
|
|
1827
|
+
* A Heroku app, named the way `/apps/{app_id_or_name}` names one: either the
|
|
1828
|
+
* app name or its UUID id. Both are accepted because both work, and the id is
|
|
1829
|
+
* the durable one — renaming an app in the dashboard breaks a binding that
|
|
1830
|
+
* holds its name, and does not break one that holds its id.
|
|
1831
|
+
*
|
|
1832
|
+
* The name pattern is Heroku's own (`^[a-z][a-z0-9-]{1,28}[a-z0-9]$`): 3–30
|
|
1833
|
+
* characters, starting with a letter and ending alphanumeric. Checking it here
|
|
1834
|
+
* turns the habitual slip — pasting `example.herokuapp.com`, or a name with
|
|
1835
|
+
* capitals — into a message at the form rather than a bare 404 from an alarm
|
|
1836
|
+
* with nobody watching.
|
|
1837
|
+
*/
|
|
1838
|
+
const herokuAppSchema = z.string().trim().refine((value) => /^[a-z][a-z0-9-]{1,28}[a-z0-9]$/.test(value) || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value), "must be a Heroku app name (lowercase letters, numbers, and dashes) or its UUID");
|
|
1839
|
+
/**
|
|
1840
|
+
* The Heroku app whose config vars a binding owns.
|
|
1841
|
+
*
|
|
1842
|
+
* A Heroku app has **one** set of config vars, shared by every dyno and every
|
|
1843
|
+
* process type — there is no per-target split to state the way Vercel and Pages
|
|
1844
|
+
* have one. Heroku's convention is that staging and production are separate
|
|
1845
|
+
* *apps* (`storefront`, `storefront-staging`), so pointing at an environment
|
|
1846
|
+
* means naming that app, exactly as it does on Fly.
|
|
1847
|
+
*
|
|
1848
|
+
* Unlike Fly, values take effect **immediately**: setting config vars cuts a new
|
|
1849
|
+
* release and restarts the app's dynos, which is why a run sends exactly one
|
|
1850
|
+
* request — see the note in `apps/api/src/lib/sync/connectors/heroku.ts`.
|
|
1851
|
+
*/
|
|
1852
|
+
const herokuDestinationSchema = z.object({
|
|
1853
|
+
provider: z.literal("heroku"),
|
|
1854
|
+
/** App name as `heroku apps` prints it, or the app's UUID. */
|
|
1855
|
+
app: herokuAppSchema
|
|
1856
|
+
});
|
|
1857
|
+
/**
|
|
1858
|
+
* The deploy contexts a Netlify value can be set for.
|
|
1859
|
+
*
|
|
1860
|
+
* These are Netlify's own, minus two. `all` is missing deliberately: Netlify
|
|
1861
|
+
* requires a **secret** value to be set against explicit contexts, and its
|
|
1862
|
+
* `setEnvVarValue` endpoint is reported to fail outright on `context: "all"` —
|
|
1863
|
+
* so the union offers only contexts that work under both. Naming the contexts
|
|
1864
|
+
* you mean is what you want here anyway; a binding already exists to map one
|
|
1865
|
+
* seekrit environment onto one deploy context. `dev-server` (Preview Server) is
|
|
1866
|
+
* left out for want of anyone asking.
|
|
1867
|
+
*
|
|
1868
|
+
* `branch` is the odd one: it needs a branch name alongside it, which the
|
|
1869
|
+
* destination carries as {@link netlifyDestinationSchema}'s `branch`.
|
|
1870
|
+
*/
|
|
1871
|
+
const NETLIFY_CONTEXTS = [
|
|
1872
|
+
"production",
|
|
1873
|
+
"deploy-preview",
|
|
1874
|
+
"branch-deploy",
|
|
1875
|
+
"branch",
|
|
1876
|
+
"dev"
|
|
1877
|
+
];
|
|
1878
|
+
/**
|
|
1879
|
+
* A Netlify site, by its **API ID** — the UUID under Project configuration →
|
|
1880
|
+
* General → Project information.
|
|
1881
|
+
*
|
|
1882
|
+
* Netlify accepts a site's domain in place of its id where a site appears in a
|
|
1883
|
+
* *path* (`/sites/{site_id}`), but the environment variable endpoints take the
|
|
1884
|
+
* site as a `?site_id=` **query parameter** instead, and Netlify documents no
|
|
1885
|
+
* name resolution there. That asymmetry is why this is strict where the Heroku
|
|
1886
|
+
* and Fly destinations are permissive: a `site_id` Netlify does not resolve
|
|
1887
|
+
* does not 404 — the write lands on the *team*, as a shared variable inherited
|
|
1888
|
+
* by every site in it. Refusing anything but the UUID keeps a slip from turning
|
|
1889
|
+
* into a much wider blast radius than the operator asked for.
|
|
1890
|
+
*/
|
|
1891
|
+
const netlifySiteIdSchema = z.string().trim().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, "must be the site's API ID (a UUID), not its name or URL");
|
|
1892
|
+
/**
|
|
1893
|
+
* The Netlify site, and which of its deploy contexts a binding owns.
|
|
1894
|
+
*
|
|
1895
|
+
* Netlify keys a value by (site, context), so the destination is that pair.
|
|
1896
|
+
* Contexts are a list rather than a single value for the same reason Vercel's
|
|
1897
|
+
* `targets` is one: a binding is unique per (connection, environment), so an
|
|
1898
|
+
* environment that feeds both production and deploy previews has to say so in
|
|
1899
|
+
* one destination or not at all.
|
|
1900
|
+
*
|
|
1901
|
+
* A push writes **only** the contexts listed here. Other contexts of the same
|
|
1902
|
+
* variable — and every variable this binding does not manage — are left as they
|
|
1903
|
+
* are, which is what makes it safe to point at a site that already has
|
|
1904
|
+
* variables set by hand.
|
|
1905
|
+
*
|
|
1906
|
+
* `secret` marks what seekrit creates as a Netlify **secret**: write-only, and
|
|
1907
|
+
* unreadable afterwards through the UI, CLI, and API. On by default, since that
|
|
1908
|
+
* is the whole point of pushing from a secrets manager. It applies only to
|
|
1909
|
+
* variables seekrit *creates* — Netlify will not let a flag be added to an
|
|
1910
|
+
* existing variable, or removed from one ever — and it needs a plan that
|
|
1911
|
+
* includes Secrets Controller.
|
|
1912
|
+
*/
|
|
1913
|
+
const netlifyDestinationSchema = z.object({
|
|
1914
|
+
provider: z.literal("netlify"),
|
|
1915
|
+
/** Site API ID (a UUID), from Project configuration → General. */
|
|
1916
|
+
siteId: netlifySiteIdSchema,
|
|
1917
|
+
/** Which deploy contexts receive these values. At least one. */
|
|
1918
|
+
contexts: z.array(z.enum(NETLIFY_CONTEXTS)).min(1),
|
|
1919
|
+
/** Branch name, required when `contexts` includes `branch`; ignored otherwise. */
|
|
1920
|
+
branch: z.string().trim().min(1).max(255).optional(),
|
|
1921
|
+
/** Create variables as Netlify secrets (default true). */
|
|
1922
|
+
secret: z.boolean().optional()
|
|
1923
|
+
}).refine((d) => !d.contexts.includes("branch") || d.branch !== void 0, {
|
|
1924
|
+
message: "a branch context needs the branch name it applies to",
|
|
1925
|
+
path: ["branch"]
|
|
1926
|
+
});
|
|
1927
|
+
/**
|
|
1928
|
+
* A Bunnyshell resource id, as the platform hands it out.
|
|
1929
|
+
*
|
|
1930
|
+
* Deliberately loose. Bunnyshell documents no format for these — they are
|
|
1931
|
+
* opaque strings from `bns environments list` or the dashboard URL — so
|
|
1932
|
+
* asserting a shape here would be inventing a rule the platform never stated,
|
|
1933
|
+
* and the failure mode would be seekrit refusing an id that works.
|
|
1934
|
+
*
|
|
1935
|
+
* Being loose is affordable here in a way it is not on Netlify, where an
|
|
1936
|
+
* unresolved `site_id` silently widens a write to the whole team. Both
|
|
1937
|
+
* Bunnyshell variable collections name their parent in the **request body** of
|
|
1938
|
+
* a create, as a required relation: an id the platform cannot resolve is a 422
|
|
1939
|
+
* naming the field, not a write that lands somewhere broader. The listing side
|
|
1940
|
+
* is fenced separately — the connector re-checks every variable's own parent
|
|
1941
|
+
* before it touches it, so a filter that failed to bite cannot turn into an
|
|
1942
|
+
* edit of a neighbouring environment's variables.
|
|
1943
|
+
*/
|
|
1944
|
+
const bunnyshellIdSchema = z.string().trim().min(1).max(64).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, "must be a Bunnyshell ID — no slashes or spaces");
|
|
1945
|
+
/**
|
|
1946
|
+
* Variables on one Bunnyshell **environment** — the set every component in it
|
|
1947
|
+
* inherits, and the closest match to a seekrit environment.
|
|
1948
|
+
*
|
|
1949
|
+
* This is the destination for an environment that already exists and stays
|
|
1950
|
+
* around: a primary environment, or a long-lived ephemeral one.
|
|
1951
|
+
*/
|
|
1952
|
+
const bunnyshellEnvironmentDestinationSchema = z.object({
|
|
1953
|
+
provider: z.literal("bunnyshell"),
|
|
1954
|
+
kind: z.literal("environment"),
|
|
1955
|
+
/** Environment ID, as `bns environments list` prints it. */
|
|
1956
|
+
environmentId: bunnyshellIdSchema,
|
|
1957
|
+
/** Mark what seekrit creates as a Bunnyshell secret (default true). */
|
|
1958
|
+
secret: z.boolean().optional()
|
|
1959
|
+
});
|
|
1960
|
+
/**
|
|
1961
|
+
* Variables on a Bunnyshell **project** — inherited by every environment
|
|
1962
|
+
* created in it from then on.
|
|
1963
|
+
*
|
|
1964
|
+
* Worth thinking about twice, for the reason a Render environment group is:
|
|
1965
|
+
* the blast radius is the project, not one environment. It earns its place
|
|
1966
|
+
* anyway, because it is the only destination that reaches an environment which
|
|
1967
|
+
* *does not exist yet*. Bunnyshell's whole shape is ephemeral environments spun
|
|
1968
|
+
* up per branch or per pull request; pushing to the environment cannot seed one
|
|
1969
|
+
* that a webhook will create tomorrow, and pushing to the project can.
|
|
1970
|
+
*
|
|
1971
|
+
* An environment inherits the project's value at creation and may then be
|
|
1972
|
+
* overridden at its own scope — so a project binding does not fight an
|
|
1973
|
+
* environment binding pointed at the same name, it loses to it.
|
|
1974
|
+
*/
|
|
1975
|
+
const bunnyshellProjectDestinationSchema = z.object({
|
|
1976
|
+
provider: z.literal("bunnyshell"),
|
|
1977
|
+
kind: z.literal("project"),
|
|
1978
|
+
/** Project ID, as `bns projects list` prints it. */
|
|
1979
|
+
projectId: bunnyshellIdSchema,
|
|
1980
|
+
/** Mark what seekrit creates as a Bunnyshell secret (default true). */
|
|
1981
|
+
secret: z.boolean().optional()
|
|
1982
|
+
});
|
|
1983
|
+
/**
|
|
1984
|
+
* Where in Bunnyshell a binding writes.
|
|
1985
|
+
*
|
|
1986
|
+
* Split on `kind` rather than into two providers — the way Render's service and
|
|
1987
|
+
* environment group are, and unlike Cloudflare's three — because the two are the
|
|
1988
|
+
* same API twice over: `/v1/environment_variables` and `/v1/project_variables`
|
|
1989
|
+
* take the same fields, fail the same ways, and differ only in which parent they
|
|
1990
|
+
* name. One connector serves both, so one provider does too.
|
|
1991
|
+
*
|
|
1992
|
+
* `secret` is Bunnyshell's `isSecret`, and means less than Netlify's flag of the
|
|
1993
|
+
* same name: Bunnyshell encrypts every variable with an organization key whether
|
|
1994
|
+
* or not the flag is set, so this only decides whether the value is obscured in
|
|
1995
|
+
* the dashboard and stored encrypted in an exported definition. It is on by
|
|
1996
|
+
* default all the same — a value pushed from a secrets manager should not be
|
|
1997
|
+
* sitting in plain view of everyone with project access. It applies only to
|
|
1998
|
+
* variables seekrit **creates**: an update never sends the flag, so a variable
|
|
1999
|
+
* an operator deliberately un-secreted stays that way.
|
|
2000
|
+
*/
|
|
2001
|
+
const bunnyshellDestinationSchema = z.discriminatedUnion("kind", [bunnyshellEnvironmentDestinationSchema, bunnyshellProjectDestinationSchema]);
|
|
2002
|
+
/**
|
|
2003
|
+
* Which repositories in an organization can read an org-level secret.
|
|
2004
|
+
*
|
|
2005
|
+
* GitHub's own enum, unchanged. There is deliberately **no default**: `all` hands
|
|
2006
|
+
* the value to every repository in the organization — including ones added
|
|
2007
|
+
* tomorrow, and including forks' workflows to the extent the org allows them —
|
|
2008
|
+
* and that is not a blast radius a secrets manager should pick on an operator's
|
|
2009
|
+
* behalf. Naming it is the point.
|
|
2010
|
+
*/
|
|
2011
|
+
const GITHUB_ACTIONS_VISIBILITIES = [
|
|
2012
|
+
"all",
|
|
2013
|
+
"private",
|
|
2014
|
+
"selected"
|
|
2015
|
+
];
|
|
2016
|
+
/**
|
|
2017
|
+
* A GitHub account or organization login, matching GitHub's own rule:
|
|
2018
|
+
* alphanumeric with single internal hyphens, 39 characters at most.
|
|
2019
|
+
*
|
|
2020
|
+
* Checked here so the habitual slip — pasting a URL, or `owner/repo` into the
|
|
2021
|
+
* owner field — fails at the form rather than as a 404 from an alarm with nobody
|
|
2022
|
+
* watching.
|
|
2023
|
+
*/
|
|
2024
|
+
const githubOwnerSchema = z.string().trim().min(1).max(39).regex(/^[A-Za-z0-9](?:-?[A-Za-z0-9])*$/, "must be a GitHub user or organization login — not a URL or an owner/repo pair");
|
|
2025
|
+
/**
|
|
2026
|
+
* A repository name. GitHub's rules are looser than an owner's: letters,
|
|
2027
|
+
* numbers, hyphens, underscores, and periods, up to 100 characters. `.` and `..`
|
|
2028
|
+
* are refused outright — they would traverse the API path rather than name a
|
|
2029
|
+
* repository.
|
|
2030
|
+
*/
|
|
2031
|
+
const githubRepoSchema = z.string().trim().min(1).max(100).regex(/^[A-Za-z0-9._-]+$/, "must be a repository name alone (`api`), not `owner/repo` or a URL").refine((value) => value !== "." && value !== "..", "is not a repository name");
|
|
2032
|
+
/**
|
|
2033
|
+
* A deployment environment name.
|
|
2034
|
+
*
|
|
2035
|
+
* Deliberately permissive: GitHub allows spaces and most punctuation here, and
|
|
2036
|
+
* the dashboard shows names like `prod (eu-west)`. Only the two things that would
|
|
2037
|
+
* break the request are refused — an empty name, and the path separators that
|
|
2038
|
+
* would let a name escape its URL segment. Everything else is GitHub's to reject.
|
|
2039
|
+
*/
|
|
2040
|
+
const githubEnvironmentSchema = z.string().trim().min(1).max(255).refine((value) => !value.includes("/") && !value.includes("\\"), "cannot contain a slash — that is a path separator, not part of an environment name");
|
|
2041
|
+
/**
|
|
2042
|
+
* One repository's Actions secrets.
|
|
2043
|
+
*
|
|
2044
|
+
* Every workflow in the repository can read these, including one added by a pull
|
|
2045
|
+
* request from a collaborator with write access. That is GitHub's model, not a
|
|
2046
|
+
* choice this connector makes — but it is the reason `environment` exists below,
|
|
2047
|
+
* and the reason to prefer it for anything that touches production.
|
|
2048
|
+
*/
|
|
2049
|
+
const githubActionsRepoDestinationSchema = z.object({
|
|
2050
|
+
provider: z.literal("github-actions"),
|
|
2051
|
+
kind: z.literal("repo"),
|
|
2052
|
+
/** Repository owner — a user or organization login. */
|
|
2053
|
+
owner: githubOwnerSchema,
|
|
2054
|
+
/** Repository name, without the owner. */
|
|
2055
|
+
repo: githubRepoSchema
|
|
2056
|
+
});
|
|
2057
|
+
/**
|
|
2058
|
+
* One deployment environment's Actions secrets — the narrowest scope GitHub has.
|
|
2059
|
+
*
|
|
2060
|
+
* A job reads these only by declaring `environment: <name>`, which also subjects
|
|
2061
|
+
* it to that environment's protection rules: required reviewers, wait timers, and
|
|
2062
|
+
* the branch policy. That combination is the closest GitHub gets to "this secret
|
|
2063
|
+
* is for production, and reaching it requires approval", and it is the scope to
|
|
2064
|
+
* reach for by default.
|
|
2065
|
+
*
|
|
2066
|
+
* The environment must already exist. This connector will not create one: an
|
|
2067
|
+
* environment is a deployment gate, and silently creating an unprotected one
|
|
2068
|
+
* because a name was misspelled would quietly remove the protection the operator
|
|
2069
|
+
* was relying on.
|
|
2070
|
+
*/
|
|
2071
|
+
const githubActionsEnvironmentDestinationSchema = z.object({
|
|
2072
|
+
provider: z.literal("github-actions"),
|
|
2073
|
+
kind: z.literal("environment"),
|
|
2074
|
+
owner: githubOwnerSchema,
|
|
2075
|
+
repo: githubRepoSchema,
|
|
2076
|
+
/** Environment name, exactly as the repository's Settings → Environments shows it. */
|
|
2077
|
+
environment: githubEnvironmentSchema
|
|
2078
|
+
});
|
|
2079
|
+
/**
|
|
2080
|
+
* An organization's Actions secrets.
|
|
2081
|
+
*
|
|
2082
|
+
* The widest scope in the product, and the only destination on any provider that
|
|
2083
|
+
* can hand a value to repositories nobody named. Read {@link
|
|
2084
|
+
* GITHUB_ACTIONS_VISIBILITIES} before using it.
|
|
2085
|
+
*
|
|
2086
|
+
* `selectedRepositoryIds` takes numeric repository **ids**, not names, because
|
|
2087
|
+
* that is what GitHub's API takes. An id is visible at
|
|
2088
|
+
* `GET /repos/{owner}/{repo}` as `id`, and in the dashboard nowhere at all —
|
|
2089
|
+
* which is friction worth accepting rather than resolving names to ids here: name
|
|
2090
|
+
* resolution would mean this connector picking which repository an ambiguous
|
|
2091
|
+
* name meant, and getting that wrong widens a secret's reach silently.
|
|
2092
|
+
*/
|
|
2093
|
+
const githubActionsOrgDestinationSchema = z.object({
|
|
2094
|
+
provider: z.literal("github-actions"),
|
|
2095
|
+
kind: z.literal("org"),
|
|
2096
|
+
/** Organization login. */
|
|
2097
|
+
org: githubOwnerSchema,
|
|
2098
|
+
/** Which repositories may read these secrets. Stated, never defaulted. */
|
|
2099
|
+
visibility: z.enum(GITHUB_ACTIONS_VISIBILITIES),
|
|
2100
|
+
/** Numeric repository ids, required when `visibility` is `selected`. */
|
|
2101
|
+
selectedRepositoryIds: z.array(z.number().int().positive()).max(500).optional()
|
|
2102
|
+
}).refine((dest) => dest.visibility !== "selected" || dest.selectedRepositoryIds !== void 0 && dest.selectedRepositoryIds.length > 0, {
|
|
2103
|
+
message: "selected visibility needs at least one repository id",
|
|
2104
|
+
path: ["selectedRepositoryIds"]
|
|
2105
|
+
}).refine((dest) => dest.visibility === "selected" || dest.selectedRepositoryIds === void 0, {
|
|
2106
|
+
message: "repository ids only apply to selected visibility — remove them, or select it",
|
|
2107
|
+
path: ["selectedRepositoryIds"]
|
|
2108
|
+
});
|
|
2109
|
+
const githubActionsDestinationSchema = z.discriminatedUnion("kind", [
|
|
2110
|
+
githubActionsRepoDestinationSchema,
|
|
2111
|
+
githubActionsEnvironmentDestinationSchema,
|
|
2112
|
+
githubActionsOrgDestinationSchema
|
|
2113
|
+
]);
|
|
2114
|
+
/**
|
|
2115
|
+
* How a binding lays its secrets out in Secret Manager. The same two shapes the
|
|
2116
|
+
* AWS Secrets Manager destination offers, and for the same reasons:
|
|
2117
|
+
*
|
|
2118
|
+
* - `secret-per-name` — one GCP secret per seekrit secret. The direct
|
|
2119
|
+
* translation, and what Cloud Run's `--set-secrets` and GKE's Secret Manager
|
|
2120
|
+
* CSI driver mount one at a time.
|
|
2121
|
+
* - `json-bundle` — every value as one JSON object in a single secret. Costs one
|
|
2122
|
+
* active version instead of fifty, which is the whole billing unit here.
|
|
2123
|
+
*/
|
|
2124
|
+
const GCP_SECRET_MANAGER_LAYOUTS = ["secret-per-name", "json-bundle"];
|
|
2125
|
+
/**
|
|
2126
|
+
* Where Google keeps the copies of a secret. Chosen at creation and
|
|
2127
|
+
* **immutable** afterwards — changing it means deleting the secret and letting
|
|
2128
|
+
* the next run recreate it.
|
|
2129
|
+
*
|
|
2130
|
+
* - `automatic` — Google picks the locations. One billable replica, and what
|
|
2131
|
+
* you want unless a policy says otherwise.
|
|
2132
|
+
* - `user-managed` — the binding names the regions. This is how data residency
|
|
2133
|
+
* is expressed for global secrets, and each region is billed as its own
|
|
2134
|
+
* active version.
|
|
2135
|
+
*/
|
|
2136
|
+
const GCP_REPLICATION_POLICIES = ["automatic", "user-managed"];
|
|
2137
|
+
/**
|
|
2138
|
+
* A Secret Manager secret ID. Google's own rule, quoted from the API reference:
|
|
2139
|
+
* "a string with a maximum length of 255 characters and can contain uppercase
|
|
2140
|
+
* and lowercase letters, numerals, and the hyphen (`-`) and underscore (`_`)
|
|
2141
|
+
* characters."
|
|
2142
|
+
*
|
|
2143
|
+
* Notably **no slashes and no dots**, which is what makes this a different
|
|
2144
|
+
* field from AWS's `pathPrefix` rather than the same idea renamed: a Secret
|
|
2145
|
+
* Manager namespace is spelled `prod-storefront-DB_URL`, not
|
|
2146
|
+
* `prod/storefront/DB_URL`.
|
|
2147
|
+
*/
|
|
2148
|
+
const gcpSecretIdSchema = z.string().trim().min(1).max(255).regex(/^[A-Za-z0-9_-]+$/, "may contain letters, digits, hyphens, and underscores");
|
|
2149
|
+
/**
|
|
2150
|
+
* A GCP region for a user-managed replica (`us-east1`, `europe-west4`,
|
|
2151
|
+
* `northamerica-northeast1`). Validated by shape rather than against a list,
|
|
2152
|
+
* because Google adds regions faster than we ship — a name Secret Manager does
|
|
2153
|
+
* not know is refused by Google with a clear message at creation.
|
|
2154
|
+
*/
|
|
2155
|
+
const gcpLocationSchema = z.string().trim().regex(/^[a-z]+-[a-z]+\d+$/, "must be a GCP region ID, e.g. us-east1");
|
|
2156
|
+
/**
|
|
2157
|
+
* A Cloud KMS key, as its full resource name — the only form the API accepts:
|
|
2158
|
+
* `projects/p/locations/l/keyRings/r/cryptoKeys/k`.
|
|
2159
|
+
*
|
|
2160
|
+
* Stricter than AWS's `kmsKeyId` (which tolerates five spellings) because
|
|
2161
|
+
* Google tolerates exactly one, and because a key in the wrong *location* is
|
|
2162
|
+
* rejected at creation: an automatic-replication secret needs a `global` key,
|
|
2163
|
+
* and a user-managed replica needs one in its own region.
|
|
2164
|
+
*/
|
|
2165
|
+
const gcpKmsKeyNameSchema = z.string().trim().max(1024).regex(/^projects\/[^/]+\/locations\/[^/]+\/keyRings\/[^/]+\/cryptoKeys\/[^/]+$/, "must be a full Cloud KMS key name (projects/…/locations/…/keyRings/…/cryptoKeys/…)");
|
|
2166
|
+
/**
|
|
2167
|
+
* Where inside a project's Secret Manager a binding writes.
|
|
2168
|
+
*
|
|
2169
|
+
* ## Every push would otherwise cost a version
|
|
2170
|
+
*
|
|
2171
|
+
* Secret Manager has no "set the value" call — only `addVersion`, which appends.
|
|
2172
|
+
* A run pushes the whole environment (never a diff), so changing one secret in
|
|
2173
|
+
* an environment of fifty would leave fifty new versions behind, forty-nine of
|
|
2174
|
+
* them identical to their predecessors, each one billed for as long as it stays
|
|
2175
|
+
* active.
|
|
2176
|
+
*
|
|
2177
|
+
* So this connector writes a version only when the value actually changed,
|
|
2178
|
+
* decided from a keyed digest it keeps in the secret's own **annotations** — see
|
|
2179
|
+
* `apps/api/src/lib/sync/connectors/gcp-secret-manager.ts` for why it is keyed
|
|
2180
|
+
* and what that costs. `pruneVersions` is the other half of the bill: with it
|
|
2181
|
+
* on, the version a push supersedes is destroyed as soon as the new one lands,
|
|
2182
|
+
* so a secret keeps exactly one active version.
|
|
2183
|
+
*/
|
|
2184
|
+
const gcpSecretManagerDestinationSchema = z.object({
|
|
2185
|
+
provider: z.literal("gcp-secret-manager"),
|
|
2186
|
+
layout: z.enum(GCP_SECRET_MANAGER_LAYOUTS).default("secret-per-name"),
|
|
2187
|
+
/**
|
|
2188
|
+
* `secret-per-name` only: prepended to every secret ID, e.g.
|
|
2189
|
+
* `prod-storefront-`. Optional, but strongly advised in a project that holds
|
|
2190
|
+
* anything else — without it a binding writes at the root of a namespace it
|
|
2191
|
+
* does not own, and Secret Manager has no folders to hide behind.
|
|
2192
|
+
*/
|
|
2193
|
+
idPrefix: z.string().trim().max(200).regex(/^[A-Za-z0-9_-]*$/, "may contain letters, digits, hyphens, and underscores").optional(),
|
|
2194
|
+
/** `json-bundle` only: the one secret that holds every value, e.g. `prod-storefront-env`. */
|
|
2195
|
+
secretId: gcpSecretIdSchema.optional(),
|
|
2196
|
+
replication: z.enum(GCP_REPLICATION_POLICIES).default("automatic"),
|
|
2197
|
+
/** `user-managed` only: the regions to replicate to. At least one. */
|
|
2198
|
+
locations: z.array(gcpLocationSchema).min(1).max(16).optional(),
|
|
2199
|
+
/** Customer-managed encryption key. Omitted means Google-managed keys. */
|
|
2200
|
+
kmsKeyName: gcpKmsKeyNameSchema.optional(),
|
|
2201
|
+
/** Destroy the version each push supersedes, keeping one active version. */
|
|
2202
|
+
pruneVersions: z.boolean().optional()
|
|
2203
|
+
}).refine((d) => d.layout !== "json-bundle" || d.secretId !== void 0, {
|
|
2204
|
+
message: "a json-bundle destination needs the ID of the secret to write",
|
|
2205
|
+
path: ["secretId"]
|
|
2206
|
+
}).refine((d) => d.replication !== "user-managed" || (d.locations?.length ?? 0) > 0, {
|
|
2207
|
+
message: "user-managed replication needs at least one location",
|
|
2208
|
+
path: ["locations"]
|
|
2209
|
+
}).refine((d) => d.kmsKeyName === void 0 || d.replication === "automatic" || (d.locations?.length ?? 0) === 1, {
|
|
2210
|
+
message: "a customer-managed key covers one location — use automatic replication, or a single location",
|
|
2211
|
+
path: ["kmsKeyName"]
|
|
2212
|
+
});
|
|
2213
|
+
const syncDestinationSchema = z.discriminatedUnion("provider", [
|
|
2214
|
+
vercelDestinationSchema,
|
|
2215
|
+
cloudflareWorkersDestinationSchema,
|
|
2216
|
+
cloudflarePagesDestinationSchema,
|
|
2217
|
+
cloudflareSecretsStoreDestinationSchema,
|
|
2218
|
+
railwayDestinationSchema,
|
|
2219
|
+
awsSecretsManagerDestinationSchema,
|
|
2220
|
+
awsParameterStoreDestinationSchema,
|
|
2221
|
+
renderDestinationSchema,
|
|
2222
|
+
flyDestinationSchema,
|
|
2223
|
+
northflankDestinationSchema,
|
|
2224
|
+
digitalOceanDestinationSchema,
|
|
2225
|
+
herokuDestinationSchema,
|
|
2226
|
+
netlifyDestinationSchema,
|
|
2227
|
+
bunnyshellDestinationSchema,
|
|
2228
|
+
githubActionsDestinationSchema,
|
|
2229
|
+
gcpSecretManagerDestinationSchema
|
|
2230
|
+
]);
|
|
2231
|
+
/**
|
|
2232
|
+
* How seekrit secret names become destination key names. Applied in order:
|
|
2233
|
+
* explicit `rename` (wins outright), then `prefix`/`suffix`, then `case`.
|
|
2234
|
+
*/
|
|
2235
|
+
const nameTransformSchema = z.object({
|
|
2236
|
+
prefix: z.string().max(64).regex(/^[A-Za-z0-9_]*$/, "must be alphanumeric or underscore").optional(),
|
|
2237
|
+
suffix: z.string().max(64).regex(/^[A-Za-z0-9_]*$/, "must be alphanumeric or underscore").optional(),
|
|
2238
|
+
case: z.enum([
|
|
2239
|
+
"preserve",
|
|
2240
|
+
"upper",
|
|
2241
|
+
"lower"
|
|
2242
|
+
]).optional(),
|
|
2243
|
+
/** Exact per-secret overrides, seekrit name → destination name. */
|
|
2244
|
+
rename: z.record(secretNameSchema, secretNameSchema).optional()
|
|
2245
|
+
});
|
|
2246
|
+
z.object({
|
|
2247
|
+
/**
|
|
2248
|
+
* The id the client already fetched a public key for. Connections are a
|
|
2249
|
+
* two-step dance — mint the key, wrap the credential to it, then create the
|
|
2250
|
+
* row — so the id has to be chosen before the row exists. Omit it and the
|
|
2251
|
+
* server generates one (only useful when there is no credential to wrap yet).
|
|
2252
|
+
*/
|
|
2253
|
+
id: z.string().regex(/^syc_[A-Za-z0-9]{24}$/, "must be a connection id from the public-key call").optional(),
|
|
2254
|
+
name: z.string().trim().min(1).max(128),
|
|
2255
|
+
config: syncConnectionConfigSchema,
|
|
2256
|
+
/**
|
|
2257
|
+
* The destination's API credential (a Vercel token), encrypted client-side to
|
|
2258
|
+
* the connection's public key (a `wd1.` wrap). The control plane stores only
|
|
2259
|
+
* this ciphertext; it is unwrapped transiently inside the sync engine DO.
|
|
2260
|
+
*/
|
|
2261
|
+
wrappedCredential: z.string().min(1)
|
|
2262
|
+
});
|
|
2263
|
+
z.object({ destination: syncDestinationSchema });
|
|
2264
|
+
const globListSchema = z.array(z.string().trim().min(1).max(256)).max(100);
|
|
2265
|
+
z.object({
|
|
2266
|
+
connectionId: z.string().min(1),
|
|
2267
|
+
environmentId: z.string().min(1),
|
|
2268
|
+
destination: syncDestinationSchema,
|
|
2269
|
+
nameTransform: nameTransformSchema.optional(),
|
|
2270
|
+
include: globListSchema.optional(),
|
|
2271
|
+
exclude: globListSchema.optional(),
|
|
2272
|
+
onDelete: z.enum(["delete", "retain"]).default("delete"),
|
|
2273
|
+
mode: z.enum(["auto", "manual"]).default("auto"),
|
|
2274
|
+
wrappedDeks: z.array(z.object({
|
|
2275
|
+
environmentId: z.string().min(1),
|
|
2276
|
+
wrappedDek: z.string().min(1)
|
|
2277
|
+
})).min(1),
|
|
2278
|
+
acknowledgedDecryption: z.literal(true)
|
|
2279
|
+
});
|
|
2280
|
+
z.object({
|
|
2281
|
+
destination: syncDestinationSchema.optional(),
|
|
2282
|
+
nameTransform: nameTransformSchema.nullable().optional(),
|
|
2283
|
+
include: globListSchema.nullable().optional(),
|
|
2284
|
+
exclude: globListSchema.nullable().optional(),
|
|
2285
|
+
onDelete: z.enum(["delete", "retain"]).optional(),
|
|
2286
|
+
mode: z.enum(["auto", "manual"]).optional(),
|
|
2287
|
+
enabled: z.boolean().optional()
|
|
2288
|
+
});
|
|
2289
|
+
//#endregion
|
|
11
2290
|
//#region ../../packages/crypto/src/encoding.ts
|
|
12
2291
|
const CHUNK = 32768;
|
|
13
2292
|
/** Base64url (no padding) — portable across browsers, Workers, and Node. */
|
|
@@ -326,6 +2605,43 @@ async function generateDataKey(material, ref) {
|
|
|
326
2605
|
};
|
|
327
2606
|
}
|
|
328
2607
|
//#endregion
|
|
2608
|
+
//#region ../../packages/crypto/src/random.ts
|
|
2609
|
+
const ALPHABETS = {
|
|
2610
|
+
alphanumeric: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
|
|
2611
|
+
hex: "0123456789abcdef",
|
|
2612
|
+
base64url: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_",
|
|
2613
|
+
/**
|
|
2614
|
+
* Alphanumerics plus punctuation chosen to survive being pasted anywhere a
|
|
2615
|
+
* secret goes: no quote of either kind, no backslash, backtick, `$`, or
|
|
2616
|
+
* whitespace, so the value can't break out of a shell word, a SQL literal, a
|
|
2617
|
+
* URL component, or a `.env` line.
|
|
2618
|
+
*/
|
|
2619
|
+
printable: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._~!*+="
|
|
2620
|
+
};
|
|
2621
|
+
/**
|
|
2622
|
+
* A cryptographically random string of `length` characters drawn uniformly from
|
|
2623
|
+
* `alphabet` (default `alphanumeric`, ≈5.95 bits/char — 32 chars ≈ 190 bits).
|
|
2624
|
+
*
|
|
2625
|
+
* Uses rejection sampling: bytes at or above the largest multiple of the
|
|
2626
|
+
* alphabet size are discarded rather than folded, so `% n` introduces no modulo
|
|
2627
|
+
* bias toward the low end of the alphabet.
|
|
2628
|
+
*/
|
|
2629
|
+
function generateSecretValue(length, alphabet = "alphanumeric") {
|
|
2630
|
+
if (!Number.isInteger(length) || length < 1) throw new RangeError("length must be a positive integer");
|
|
2631
|
+
const chars = ALPHABETS[alphabet];
|
|
2632
|
+
const n = chars.length;
|
|
2633
|
+
const limit = 256 - 256 % n;
|
|
2634
|
+
let out = "";
|
|
2635
|
+
while (out.length < length) {
|
|
2636
|
+
const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
|
|
2637
|
+
for (const byte of bytes) {
|
|
2638
|
+
if (byte < limit) out += chars[byte % n];
|
|
2639
|
+
if (out.length === length) break;
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
return out;
|
|
2643
|
+
}
|
|
2644
|
+
//#endregion
|
|
329
2645
|
//#region ../../packages/crypto/src/mysql.ts
|
|
330
2646
|
/**
|
|
331
2647
|
* Client-side construction of a MySQL/MariaDB `mysql_native_password`
|
|
@@ -355,7 +2671,6 @@ async function generateDataKey(material, ref) {
|
|
|
355
2671
|
* SCRAM helper does, with no hand-rolled hash primitive.
|
|
356
2672
|
*/
|
|
357
2673
|
const DEFAULT_PASSWORD_LENGTH$1 = 32;
|
|
358
|
-
const PASSWORD_ALPHABET$1 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
359
2674
|
async function sha1(data) {
|
|
360
2675
|
return new Uint8Array(await crypto.subtle.digest("SHA-1", data));
|
|
361
2676
|
}
|
|
@@ -364,17 +2679,6 @@ function toUpperHex(bytes) {
|
|
|
364
2679
|
for (const b of bytes) hex += b.toString(16).padStart(2, "0");
|
|
365
2680
|
return hex.toUpperCase();
|
|
366
2681
|
}
|
|
367
|
-
function randomPassword$1(length) {
|
|
368
|
-
let out = "";
|
|
369
|
-
while (out.length < length) {
|
|
370
|
-
const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
|
|
371
|
-
for (const byte of bytes) {
|
|
372
|
-
if (byte < 248) out += PASSWORD_ALPHABET$1[byte % 62];
|
|
373
|
-
if (out.length === length) break;
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
return out;
|
|
377
|
-
}
|
|
378
2682
|
/**
|
|
379
2683
|
* Compute the `mysql_native_password` auth string `*<UPPER(HEX(SHA1(SHA1(pw))))>`
|
|
380
2684
|
* for a known password. Pass the result straight to
|
|
@@ -388,7 +2692,7 @@ async function mysqlNativePasswordVerifier(password) {
|
|
|
388
2692
|
* — the client-side half of a Vault-style dynamic MySQL credential.
|
|
389
2693
|
*/
|
|
390
2694
|
async function generateMysqlCredential(options = {}) {
|
|
391
|
-
const password =
|
|
2695
|
+
const password = generateSecretValue(options.length ?? DEFAULT_PASSWORD_LENGTH$1);
|
|
392
2696
|
return {
|
|
393
2697
|
password,
|
|
394
2698
|
verifier: await mysqlNativePasswordVerifier(password)
|
|
@@ -486,7 +2790,6 @@ const LOG = /* @__PURE__ */ new Uint8Array(256);
|
|
|
486
2790
|
}
|
|
487
2791
|
const SALT_LENGTH = 16;
|
|
488
2792
|
const DEFAULT_PASSWORD_LENGTH = 32;
|
|
489
|
-
const PASSWORD_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
490
2793
|
async function hmacSha256(key, message) {
|
|
491
2794
|
const k = await crypto.subtle.importKey("raw", key, {
|
|
492
2795
|
name: "HMAC",
|
|
@@ -507,17 +2810,6 @@ async function saltPassword(password, salt, iterations) {
|
|
|
507
2810
|
}, material, 256);
|
|
508
2811
|
return new Uint8Array(bits);
|
|
509
2812
|
}
|
|
510
|
-
function randomPassword(length) {
|
|
511
|
-
let out = "";
|
|
512
|
-
while (out.length < length) {
|
|
513
|
-
const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
|
|
514
|
-
for (const byte of bytes) {
|
|
515
|
-
if (byte < 248) out += PASSWORD_ALPHABET[byte % 62];
|
|
516
|
-
if (out.length === length) break;
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
return out;
|
|
520
|
-
}
|
|
521
2813
|
/**
|
|
522
2814
|
* Compute the `SCRAM-SHA-256$<i>:<salt>$<StoredKey>:<ServerKey>` verifier for a
|
|
523
2815
|
* known password. Pass the result straight to `CREATE ROLE … PASSWORD`.
|
|
@@ -535,7 +2827,7 @@ async function scramSha256Verifier(password, options = {}) {
|
|
|
535
2827
|
* client-side half of a Vault-style dynamic Postgres credential.
|
|
536
2828
|
*/
|
|
537
2829
|
async function generatePostgresCredential(options = {}) {
|
|
538
|
-
const password =
|
|
2830
|
+
const password = generateSecretValue(options.length ?? DEFAULT_PASSWORD_LENGTH);
|
|
539
2831
|
const iterations = options.iterations ?? 4096;
|
|
540
2832
|
return {
|
|
541
2833
|
password,
|
|
@@ -619,7 +2911,7 @@ function signatureKeyRef(signature) {
|
|
|
619
2911
|
const TOKEN_PREFIX = "skt";
|
|
620
2912
|
const TOKEN_ID_LENGTH = 22;
|
|
621
2913
|
const ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
622
|
-
function randomTokenId() {
|
|
2914
|
+
function randomTokenId(prefix = TOKEN_PREFIX) {
|
|
623
2915
|
let out = "";
|
|
624
2916
|
while (out.length < TOKEN_ID_LENGTH) {
|
|
625
2917
|
const bytes = crypto.getRandomValues(new Uint8Array(TOKEN_ID_LENGTH - out.length));
|
|
@@ -628,7 +2920,7 @@ function randomTokenId() {
|
|
|
628
2920
|
if (out.length === TOKEN_ID_LENGTH) break;
|
|
629
2921
|
}
|
|
630
2922
|
}
|
|
631
|
-
return `${
|
|
2923
|
+
return `${prefix}_${out}`;
|
|
632
2924
|
}
|
|
633
2925
|
async function hashToken(token) {
|
|
634
2926
|
const digest = await crypto.subtle.digest("SHA-256", utf8Encode(token));
|
|
@@ -664,7 +2956,7 @@ function isServiceToken(value) {
|
|
|
664
2956
|
}
|
|
665
2957
|
//#endregion
|
|
666
2958
|
//#region ../cli/package.json
|
|
667
|
-
var version$1 = "0.
|
|
2959
|
+
var version$1 = "0.42.0";
|
|
668
2960
|
const PROJECT_FILE = "seekrit.json";
|
|
669
2961
|
function globalConfigPath() {
|
|
670
2962
|
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "seekrit", "config.json");
|
|
@@ -674,6 +2966,10 @@ function readGlobalConfig() {
|
|
|
674
2966
|
if (!existsSync(path)) return {};
|
|
675
2967
|
return JSON.parse(readFileSync(path, "utf8"));
|
|
676
2968
|
}
|
|
2969
|
+
/**
|
|
2970
|
+
* Merge into the saved config. A key set to `undefined` is *removed* (JSON
|
|
2971
|
+
* drops it), which is how the login paths clear a credential they replace.
|
|
2972
|
+
*/
|
|
677
2973
|
function writeGlobalConfig(update) {
|
|
678
2974
|
const path = globalConfigPath();
|
|
679
2975
|
const merged = {
|
|
@@ -771,6 +3067,32 @@ var SeekritClient = class {
|
|
|
771
3067
|
getMyNotificationPrefs() {
|
|
772
3068
|
return this.request("GET", "/v1/me/notifications");
|
|
773
3069
|
}
|
|
3070
|
+
/**
|
|
3071
|
+
* Devices this user has authorized. `currentSessionId` is set when the caller
|
|
3072
|
+
* *is* a CLI session, so it can label (or revoke) itself.
|
|
3073
|
+
*/
|
|
3074
|
+
listCliSessions() {
|
|
3075
|
+
return this.request("GET", "/v1/me/cli-sessions");
|
|
3076
|
+
}
|
|
3077
|
+
/** Sign a device out. Its token stops authenticating immediately. */
|
|
3078
|
+
revokeCliSession(sessionId) {
|
|
3079
|
+
return this.request("DELETE", `/v1/me/cli-sessions/${sessionId}`);
|
|
3080
|
+
}
|
|
3081
|
+
/** What a pending login request is asking for — for the approval screen. */
|
|
3082
|
+
getCliLoginRequest(code) {
|
|
3083
|
+
return this.request("GET", `/v1/cli-login/${encodeURIComponent(code)}`);
|
|
3084
|
+
}
|
|
3085
|
+
/**
|
|
3086
|
+
* Authorize a device. Requires a browser session; members with a second
|
|
3087
|
+
* factor must have re-entered it just now, else this rejects with
|
|
3088
|
+
* `mfa_required` (recoverable — prompt for a code and retry).
|
|
3089
|
+
*/
|
|
3090
|
+
approveCliLogin(code) {
|
|
3091
|
+
return this.request("POST", `/v1/cli-login/${encodeURIComponent(code)}/approve`);
|
|
3092
|
+
}
|
|
3093
|
+
denyCliLogin(code) {
|
|
3094
|
+
return this.request("POST", `/v1/cli-login/${encodeURIComponent(code)}/deny`);
|
|
3095
|
+
}
|
|
774
3096
|
setMyNotificationPrefs(input) {
|
|
775
3097
|
return this.request("PUT", "/v1/me/notifications", input);
|
|
776
3098
|
}
|
|
@@ -838,6 +3160,28 @@ var SeekritClient = class {
|
|
|
838
3160
|
deleteEnv(orgId, envId) {
|
|
839
3161
|
return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}`);
|
|
840
3162
|
}
|
|
3163
|
+
/**
|
|
3164
|
+
* The public keys of an environment's grant-holders, so a client can wrap a
|
|
3165
|
+
* new DEK to each of them (see `createBranch`). No key material is returned.
|
|
3166
|
+
*/
|
|
3167
|
+
listGrantees(orgId, envId) {
|
|
3168
|
+
return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/grantees`);
|
|
3169
|
+
}
|
|
3170
|
+
listBranches(orgId, envId) {
|
|
3171
|
+
return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/branches`);
|
|
3172
|
+
}
|
|
3173
|
+
/** Every branch in an application, across all its environments. */
|
|
3174
|
+
listAppBranches(orgId, appId) {
|
|
3175
|
+
return this.request("GET", `/v1/orgs/${orgId}/apps/${appId}/branches`);
|
|
3176
|
+
}
|
|
3177
|
+
/** Fork `envId` into an ephemeral branch. `envId` is the parent, not the branch. */
|
|
3178
|
+
createBranch(orgId, envId, input) {
|
|
3179
|
+
return this.request("POST", `/v1/orgs/${orgId}/envs/${envId}/branches`, input);
|
|
3180
|
+
}
|
|
3181
|
+
/** Branches are environments, so tearing one down is `deleteEnv`. */
|
|
3182
|
+
deleteBranch(orgId, branchId) {
|
|
3183
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/envs/${branchId}`);
|
|
3184
|
+
}
|
|
841
3185
|
listGroups(orgId) {
|
|
842
3186
|
return this.request("GET", `/v1/orgs/${orgId}/groups`);
|
|
843
3187
|
}
|
|
@@ -873,6 +3217,7 @@ var SeekritClient = class {
|
|
|
873
3217
|
resolve(query = {}) {
|
|
874
3218
|
const params = new URLSearchParams();
|
|
875
3219
|
if (query.env) params.set("env", query.env);
|
|
3220
|
+
if (query.branch) params.set("branch", query.branch);
|
|
876
3221
|
for (const [group, slug] of Object.entries(query.with ?? {})) params.append("with", `${group}:${slug}`);
|
|
877
3222
|
const qs = params.size > 0 ? `?${params}` : "";
|
|
878
3223
|
return this.request("GET", `/v1/resolve${qs}`);
|
|
@@ -972,6 +3317,54 @@ var SeekritClient = class {
|
|
|
972
3317
|
deleteToken(orgId, tokenId) {
|
|
973
3318
|
return this.request("DELETE", `/v1/orgs/${orgId}/tokens/${tokenId}/permanent`);
|
|
974
3319
|
}
|
|
3320
|
+
listHoneyTokens(orgId) {
|
|
3321
|
+
return this.request("GET", `/v1/orgs/${orgId}/honey-tokens`);
|
|
3322
|
+
}
|
|
3323
|
+
createHoneyToken(orgId, input) {
|
|
3324
|
+
return this.request("POST", `/v1/orgs/${orgId}/honey-tokens`, input);
|
|
3325
|
+
}
|
|
3326
|
+
/** Delete a decoy outright — there is no access to revoke first. */
|
|
3327
|
+
deleteHoneyToken(orgId, honeyTokenId) {
|
|
3328
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/honey-tokens/${honeyTokenId}`);
|
|
3329
|
+
}
|
|
3330
|
+
listAgents(orgId) {
|
|
3331
|
+
return this.request("GET", `/v1/orgs/${orgId}/agents`);
|
|
3332
|
+
}
|
|
3333
|
+
createAgent(orgId, input) {
|
|
3334
|
+
return this.request("POST", `/v1/orgs/${orgId}/agents`, input);
|
|
3335
|
+
}
|
|
3336
|
+
getAgent(orgId, agentId) {
|
|
3337
|
+
return this.request("GET", `/v1/orgs/${orgId}/agents/${agentId}`);
|
|
3338
|
+
}
|
|
3339
|
+
updateAgent(orgId, agentId, input) {
|
|
3340
|
+
return this.request("PATCH", `/v1/orgs/${orgId}/agents/${agentId}`, input);
|
|
3341
|
+
}
|
|
3342
|
+
deleteAgent(orgId, agentId) {
|
|
3343
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/agents/${agentId}`);
|
|
3344
|
+
}
|
|
3345
|
+
/** Published versions, newest first. Append-only; nothing here is rewritten. */
|
|
3346
|
+
listAgentPolicies(orgId, agentId) {
|
|
3347
|
+
return this.request("GET", `/v1/orgs/${orgId}/agents/${agentId}/policies`);
|
|
3348
|
+
}
|
|
3349
|
+
/**
|
|
3350
|
+
* Publish a bundle signed in the browser.
|
|
3351
|
+
*
|
|
3352
|
+
* The signing happens client-side (`signAgentPolicy` in `@seekrit/core`) with
|
|
3353
|
+
* the publishing admin's own key, so the API receives an opaque envelope it
|
|
3354
|
+
* cannot forge. A version mismatch answers `409`: the version is inside the
|
|
3355
|
+
* signature, so a concurrent publish has to be re-signed, not patched.
|
|
3356
|
+
*/
|
|
3357
|
+
publishAgentPolicy(orgId, agentId, bundle) {
|
|
3358
|
+
return this.request("POST", `/v1/orgs/${orgId}/agents/${agentId}/policies`, { bundle });
|
|
3359
|
+
}
|
|
3360
|
+
/** Republish an earlier version's bundle as the newest version. */
|
|
3361
|
+
rollbackAgentPolicy(orgId, agentId, version) {
|
|
3362
|
+
return this.request("POST", `/v1/orgs/${orgId}/agents/${agentId}/policies/${version}/rollback`);
|
|
3363
|
+
}
|
|
3364
|
+
/** The caller's own signing thumbprint, for the trust-anchor snippet. */
|
|
3365
|
+
getMyPolicySigner(orgId) {
|
|
3366
|
+
return this.request("GET", `/v1/orgs/${orgId}/agents/signers/me`);
|
|
3367
|
+
}
|
|
975
3368
|
/** Keys the caller can see: all org keys for admins, granted keys otherwise. */
|
|
976
3369
|
listKmsKeys(orgId) {
|
|
977
3370
|
return this.request("GET", `/v1/orgs/${orgId}/kms/keys`);
|
|
@@ -1025,6 +3418,54 @@ var SeekritClient = class {
|
|
|
1025
3418
|
deleteLeaseTarget(orgId, targetId) {
|
|
1026
3419
|
return this.request("DELETE", `/v1/orgs/${orgId}/leases/targets/${targetId}`);
|
|
1027
3420
|
}
|
|
3421
|
+
listSyncConnections(orgId) {
|
|
3422
|
+
return this.request("GET", `/v1/orgs/${orgId}/sync/connections`);
|
|
3423
|
+
}
|
|
3424
|
+
/**
|
|
3425
|
+
* Mint (or re-read) the keypair for a connection id, *before* the connection
|
|
3426
|
+
* exists. Wrap the destination credential and every environment DEK to this
|
|
3427
|
+
* key, then pass the same id to {@link createSyncConnection}.
|
|
3428
|
+
*/
|
|
3429
|
+
getSyncConnectionKey(orgId, connectionId) {
|
|
3430
|
+
return this.request("GET", `/v1/orgs/${orgId}/sync/connections/${connectionId}/public-key`);
|
|
3431
|
+
}
|
|
3432
|
+
createSyncConnection(orgId, input) {
|
|
3433
|
+
return this.request("POST", `/v1/orgs/${orgId}/sync/connections`, input);
|
|
3434
|
+
}
|
|
3435
|
+
/** Test the stored credential against a destination. Never throws on a bad token. */
|
|
3436
|
+
verifySyncConnection(orgId, connectionId, destination) {
|
|
3437
|
+
return this.request("POST", `/v1/orgs/${orgId}/sync/connections/${connectionId}/verify`, { destination });
|
|
3438
|
+
}
|
|
3439
|
+
/** Deletes the connection, its bindings, its key grants, and its keypair. */
|
|
3440
|
+
deleteSyncConnection(orgId, connectionId) {
|
|
3441
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/sync/connections/${connectionId}`);
|
|
3442
|
+
}
|
|
3443
|
+
listSyncBindings(orgId) {
|
|
3444
|
+
return this.request("GET", `/v1/orgs/${orgId}/sync/bindings`);
|
|
3445
|
+
}
|
|
3446
|
+
/**
|
|
3447
|
+
* Enable sync for one environment. `wrappedDeks` must cover the target
|
|
3448
|
+
* environment *and* every group environment it composes, each wrapped to the
|
|
3449
|
+
* connection's public key — the API cannot compute these, which is what keeps
|
|
3450
|
+
* enabling sync a key-holder operation.
|
|
3451
|
+
*/
|
|
3452
|
+
createSyncBinding(orgId, input) {
|
|
3453
|
+
return this.request("POST", `/v1/orgs/${orgId}/sync/bindings`, input);
|
|
3454
|
+
}
|
|
3455
|
+
updateSyncBinding(orgId, bindingId, input) {
|
|
3456
|
+
return this.request("PATCH", `/v1/orgs/${orgId}/sync/bindings/${bindingId}`, input);
|
|
3457
|
+
}
|
|
3458
|
+
deleteSyncBinding(orgId, bindingId) {
|
|
3459
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/sync/bindings/${bindingId}`);
|
|
3460
|
+
}
|
|
3461
|
+
/** Push now, synchronously. */
|
|
3462
|
+
runSyncBinding(orgId, bindingId) {
|
|
3463
|
+
return this.request("POST", `/v1/orgs/${orgId}/sync/bindings/${bindingId}/run`);
|
|
3464
|
+
}
|
|
3465
|
+
listSyncRuns(orgId, bindingId) {
|
|
3466
|
+
const qs = bindingId ? `?bindingId=${encodeURIComponent(bindingId)}` : "";
|
|
3467
|
+
return this.request("GET", `/v1/orgs/${orgId}/sync/runs${qs}`);
|
|
3468
|
+
}
|
|
1028
3469
|
listLeases(orgId) {
|
|
1029
3470
|
return this.request("GET", `/v1/orgs/${orgId}/leases`);
|
|
1030
3471
|
}
|
|
@@ -1034,6 +3475,39 @@ var SeekritClient = class {
|
|
|
1034
3475
|
revokeLease(orgId, leaseId) {
|
|
1035
3476
|
return this.request("DELETE", `/v1/orgs/${orgId}/leases/${leaseId}`);
|
|
1036
3477
|
}
|
|
3478
|
+
/**
|
|
3479
|
+
* The rotator public key (the broker DO's), plus the environments that have
|
|
3480
|
+
* already granted it. Wrap an environment's DEK to this key client-side before
|
|
3481
|
+
* configuring rotation — that wrap IS the grant, and the server can't make it.
|
|
3482
|
+
*/
|
|
3483
|
+
getRotatorKey(orgId) {
|
|
3484
|
+
return this.request("GET", `/v1/orgs/${orgId}/rotation/rotator-key`);
|
|
3485
|
+
}
|
|
3486
|
+
listRotations(orgId) {
|
|
3487
|
+
return this.request("GET", `/v1/orgs/${orgId}/rotation`);
|
|
3488
|
+
}
|
|
3489
|
+
getRotation(orgId, rotationId) {
|
|
3490
|
+
return this.request("GET", `/v1/orgs/${orgId}/rotation/${rotationId}`);
|
|
3491
|
+
}
|
|
3492
|
+
/**
|
|
3493
|
+
* Configure (or replace) a secret's rotation policy. `version` comes back only
|
|
3494
|
+
* when `rotateNow` was set — a rotated secret's new version number, never its
|
|
3495
|
+
* value.
|
|
3496
|
+
*/
|
|
3497
|
+
configureRotation(orgId, input) {
|
|
3498
|
+
return this.request("POST", `/v1/orgs/${orgId}/rotation`, input);
|
|
3499
|
+
}
|
|
3500
|
+
updateRotation(orgId, rotationId, input) {
|
|
3501
|
+
return this.request("PATCH", `/v1/orgs/${orgId}/rotation/${rotationId}`, input);
|
|
3502
|
+
}
|
|
3503
|
+
/** Rotate now. Returns the new version — the value stays where it belongs. */
|
|
3504
|
+
rotateSecretNow(orgId, rotationId) {
|
|
3505
|
+
return this.request("POST", `/v1/orgs/${orgId}/rotation/${rotationId}/rotate`);
|
|
3506
|
+
}
|
|
3507
|
+
/** Disable rotation. `rotatorRevoked` reports whether the broker's key grant went too. */
|
|
3508
|
+
disableRotation(orgId, rotationId) {
|
|
3509
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/rotation/${rotationId}`);
|
|
3510
|
+
}
|
|
1037
3511
|
listAudit(orgId, query = {}) {
|
|
1038
3512
|
const params = new URLSearchParams();
|
|
1039
3513
|
if (query.cursor) params.set("cursor", query.cursor);
|
|
@@ -1090,6 +3564,19 @@ var SeekritClient = class {
|
|
|
1090
3564
|
cancelSubscription(orgId) {
|
|
1091
3565
|
return this.request("POST", `/v1/orgs/${orgId}/billing/cancel`);
|
|
1092
3566
|
}
|
|
3567
|
+
/**
|
|
3568
|
+
* Redeem a promo code, comping the org onto the plan the code grants.
|
|
3569
|
+
* Admin-only. Casing, spaces, and dashes are normalized server-side, so pass
|
|
3570
|
+
* the code as the user typed it. Returns the refreshed billing view.
|
|
3571
|
+
*
|
|
3572
|
+
* Every invalid code fails the same way regardless of why (unknown, expired,
|
|
3573
|
+
* fully redeemed, already used by this org) — the API deliberately won't
|
|
3574
|
+
* confirm that a code exists. Show the returned message as-is rather than
|
|
3575
|
+
* guessing at a more specific one.
|
|
3576
|
+
*/
|
|
3577
|
+
redeemPromoCode(orgId, input) {
|
|
3578
|
+
return this.request("POST", `/v1/orgs/${orgId}/billing/promo`, input);
|
|
3579
|
+
}
|
|
1093
3580
|
};
|
|
1094
3581
|
//#endregion
|
|
1095
3582
|
//#region ../cli/src/io.ts
|
|
@@ -1145,7 +3632,7 @@ function tryBuildContext(dotenvVars = {}) {
|
|
|
1145
3632
|
const config = readGlobalConfig();
|
|
1146
3633
|
const fromEnv = (key) => process.env[key] ?? dotenvVars[key];
|
|
1147
3634
|
const apiUrl = fromEnv("SEEKRIT_API_URL") ?? config.apiUrl ?? "https://api.seekrit.dev";
|
|
1148
|
-
const token = fromEnv("SEEKRIT_TOKEN") ?? config.token;
|
|
3635
|
+
const token = fromEnv("SEEKRIT_TOKEN") ?? config.token ?? config.sessionToken;
|
|
1149
3636
|
const devUser = fromEnv("SEEKRIT_DEV_USER") ?? config.devUser;
|
|
1150
3637
|
let auth;
|
|
1151
3638
|
if (token) auth = {
|
|
@@ -1163,7 +3650,8 @@ function tryBuildContext(dotenvVars = {}) {
|
|
|
1163
3650
|
auth,
|
|
1164
3651
|
client: CLI_CLIENT
|
|
1165
3652
|
}),
|
|
1166
|
-
auth
|
|
3653
|
+
auth,
|
|
3654
|
+
apiUrl
|
|
1167
3655
|
};
|
|
1168
3656
|
}
|
|
1169
3657
|
function isTokenAuth(ctx) {
|
|
@@ -1210,12 +3698,14 @@ async function resolveOrg(ctx, orgSlug) {
|
|
|
1210
3698
|
}
|
|
1211
3699
|
/**
|
|
1212
3700
|
* Resolve an environment to operate on — an application env (`--app --env`,
|
|
1213
|
-
* or the config's app + `--env`)
|
|
3701
|
+
* or the config's app + `--env`), a branch of one (`--branch`), or a group env
|
|
3702
|
+
* (`--group --env`).
|
|
1214
3703
|
*/
|
|
1215
3704
|
async function resolveEnvTarget(ctx, opts) {
|
|
1216
3705
|
const org = await resolveOrg(ctx, opts.org);
|
|
1217
3706
|
if (!opts.env) fail("specify --env");
|
|
1218
3707
|
if (opts.group) {
|
|
3708
|
+
if (opts.branch) fail("--branch applies to application environments, not groups");
|
|
1219
3709
|
const { groups } = await ctx.client.listGroups(org.id);
|
|
1220
3710
|
const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
|
|
1221
3711
|
if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
|
|
@@ -1228,34 +3718,59 @@ async function resolveEnvTarget(ctx, opts) {
|
|
|
1228
3718
|
label: `${group.slug}@${env.slug}`
|
|
1229
3719
|
};
|
|
1230
3720
|
}
|
|
1231
|
-
const
|
|
1232
|
-
if (!appSlug) fail("specify --app or --group (or run `seekrit init`)");
|
|
1233
|
-
const { apps } = await ctx.client.listApps(org.id);
|
|
1234
|
-
const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
|
|
1235
|
-
if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
|
|
3721
|
+
const app = await resolveApp(ctx, opts);
|
|
1236
3722
|
const { environments } = await ctx.client.listEnvs(org.id, app.id);
|
|
1237
3723
|
const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
|
|
1238
3724
|
if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
|
|
3725
|
+
if (opts.branch) {
|
|
3726
|
+
const branch = await resolveBranch(ctx, app, opts.branch);
|
|
3727
|
+
return {
|
|
3728
|
+
orgId: org.id,
|
|
3729
|
+
envId: branch.id,
|
|
3730
|
+
label: `${app.slug}/${env.slug}#${branch.slug}`
|
|
3731
|
+
};
|
|
3732
|
+
}
|
|
1239
3733
|
return {
|
|
1240
3734
|
orgId: org.id,
|
|
1241
3735
|
envId: env.id,
|
|
1242
3736
|
label: `${app.slug}/${env.slug}`
|
|
1243
3737
|
};
|
|
1244
3738
|
}
|
|
1245
|
-
/** Resolve
|
|
1246
|
-
async function
|
|
3739
|
+
/** Resolve the target application from a flag or the committed config. */
|
|
3740
|
+
async function resolveApp(ctx, opts) {
|
|
1247
3741
|
const org = await resolveOrg(ctx, opts.org);
|
|
1248
3742
|
const appSlug = opts.app ?? findProjectConfig()?.app;
|
|
1249
3743
|
if (!appSlug) fail("specify --app (or run `seekrit init`)");
|
|
1250
|
-
if (!opts.env) fail("specify --env");
|
|
1251
3744
|
const { apps } = await ctx.client.listApps(org.id);
|
|
1252
3745
|
const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
|
|
1253
3746
|
if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
|
|
1254
|
-
|
|
3747
|
+
return {
|
|
3748
|
+
orgId: org.id,
|
|
3749
|
+
orgSlug: org.slug,
|
|
3750
|
+
id: app.id,
|
|
3751
|
+
slug: app.slug
|
|
3752
|
+
};
|
|
3753
|
+
}
|
|
3754
|
+
/**
|
|
3755
|
+
* Find a branch by slug (or id) anywhere in an application. Branch slugs share
|
|
3756
|
+
* the application's environment namespace, so one lookup is unambiguous — no
|
|
3757
|
+
* need to name the parent environment.
|
|
3758
|
+
*/
|
|
3759
|
+
async function resolveBranch(ctx, app, branchRef) {
|
|
3760
|
+
const { branches } = await ctx.client.listAppBranches(app.orgId, app.id);
|
|
3761
|
+
const branch = branches.find((b) => b.slug === branchRef || b.id === branchRef);
|
|
3762
|
+
if (!branch) fail(`no branch "${branchRef}" in ${app.slug}`);
|
|
3763
|
+
return branch;
|
|
3764
|
+
}
|
|
3765
|
+
/** Resolve an application environment, keeping ids + slugs (for token binding). */
|
|
3766
|
+
async function resolveAppEnv(ctx, opts) {
|
|
3767
|
+
if (!opts.env) fail("specify --env");
|
|
3768
|
+
const app = await resolveApp(ctx, opts);
|
|
3769
|
+
const { environments } = await ctx.client.listEnvs(app.orgId, app.id);
|
|
1255
3770
|
const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
|
|
1256
3771
|
if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
|
|
1257
3772
|
return {
|
|
1258
|
-
orgId:
|
|
3773
|
+
orgId: app.orgId,
|
|
1259
3774
|
appId: app.id,
|
|
1260
3775
|
appSlug: app.slug,
|
|
1261
3776
|
envId: env.id,
|
|
@@ -1357,12 +3872,16 @@ function readM2mCreds(dotenvVars = {}) {
|
|
|
1357
3872
|
clientSecret
|
|
1358
3873
|
};
|
|
1359
3874
|
}
|
|
1360
|
-
/**
|
|
3875
|
+
/**
|
|
3876
|
+
* True when a service/session/dev credential is already configured explicitly.
|
|
3877
|
+
* A browser-authorized session counts: a human who ran `seekrit login` must not
|
|
3878
|
+
* be silently swapped onto a machine identity.
|
|
3879
|
+
*/
|
|
1361
3880
|
function hasExplicitCredential(dotenvVars) {
|
|
1362
3881
|
const fromEnv = (key) => process.env[key] ?? dotenvVars[key];
|
|
1363
3882
|
if (fromEnv("SEEKRIT_TOKEN") || fromEnv("SEEKRIT_DEV_USER")) return true;
|
|
1364
3883
|
const config = readGlobalConfig();
|
|
1365
|
-
return Boolean(config.token || config.devUser);
|
|
3884
|
+
return Boolean(config.token || config.sessionToken || config.devUser);
|
|
1366
3885
|
}
|
|
1367
3886
|
/** Mint a fresh org-scoped admin token using M2M credentials (keyless). */
|
|
1368
3887
|
async function mintAdminToken(apiUrl, creds) {
|
|
@@ -1412,41 +3931,59 @@ async function ensureM2mAdminToken(dotenvVars = {}) {
|
|
|
1412
3931
|
return token;
|
|
1413
3932
|
}
|
|
1414
3933
|
//#endregion
|
|
1415
|
-
//#region ../cli/src/
|
|
3934
|
+
//#region ../cli/src/cache.ts
|
|
3935
|
+
/** Render an age for a log line, rounded to its largest whole unit. */
|
|
3936
|
+
function humanize(ms) {
|
|
3937
|
+
const secs = Math.floor(ms / 1e3);
|
|
3938
|
+
if (secs < 60) return `${secs}s`;
|
|
3939
|
+
if (secs < 3600) return `${Math.floor(secs / 60)}m`;
|
|
3940
|
+
if (secs < 86400) return `${Math.floor(secs / 3600)}h`;
|
|
3941
|
+
return `${Math.floor(secs / 86400)}d`;
|
|
3942
|
+
}
|
|
1416
3943
|
/**
|
|
1417
|
-
*
|
|
1418
|
-
*
|
|
1419
|
-
*
|
|
1420
|
-
* not supported — keep those in seekrit itself.
|
|
3944
|
+
* Whether a failed resolve means the API was *unreachable* (the cache may stand
|
|
3945
|
+
* in) rather than *refusing us* (it must not). A refusal is an answer, and
|
|
3946
|
+
* revocation is supposed to take effect the moment it arrives.
|
|
1421
3947
|
*/
|
|
1422
|
-
function
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
let line = raw.trim();
|
|
1426
|
-
if (!line || line.startsWith("#")) continue;
|
|
1427
|
-
if (line.startsWith("export ")) line = line.slice(7).trimStart();
|
|
1428
|
-
const eq = line.indexOf("=");
|
|
1429
|
-
if (eq === -1) continue;
|
|
1430
|
-
const key = line.slice(0, eq).trim();
|
|
1431
|
-
if (!key) continue;
|
|
1432
|
-
let value = line.slice(eq + 1).trim();
|
|
1433
|
-
const quote = value[0];
|
|
1434
|
-
if (value.length >= 2 && (quote === "\"" || quote === "'") && value.at(-1) === quote) {
|
|
1435
|
-
value = value.slice(1, -1);
|
|
1436
|
-
if (quote === "\"") value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
|
|
1437
|
-
} else {
|
|
1438
|
-
const comment = value.indexOf(" #");
|
|
1439
|
-
if (comment !== -1) value = value.slice(0, comment).trim();
|
|
1440
|
-
}
|
|
1441
|
-
out[key] = value;
|
|
1442
|
-
}
|
|
1443
|
-
return out;
|
|
3948
|
+
function mayFallBack(err) {
|
|
3949
|
+
if (err instanceof SeekritApiError) return err.status >= 500 || err.status === 429;
|
|
3950
|
+
return true;
|
|
1444
3951
|
}
|
|
1445
|
-
/**
|
|
1446
|
-
|
|
3952
|
+
/**
|
|
3953
|
+
* Fetch + decrypt every secret in a single environment.
|
|
3954
|
+
*
|
|
3955
|
+
* `${OTHER_SECRET}` references are expanded (see `@seekrit/core`'s
|
|
3956
|
+
* `interpolate`) unless `raw` is set. Only this environment's own secrets are in
|
|
3957
|
+
* scope here — a reference to a secret inherited from a composed group is left
|
|
3958
|
+
* literal, because the group layers aren't fetched. `materializeEnv` is the
|
|
3959
|
+
* fully-layered view.
|
|
3960
|
+
*/
|
|
3961
|
+
async function fetchDecryptedSecrets(ctx, orgId, envId, opts = {}) {
|
|
1447
3962
|
const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
|
|
1448
3963
|
const entries = await Promise.all(secrets.map(async (secret) => [secret.name, await decryptSecret(dek, secret.ciphertext, secretAad(envId, secret.name))]));
|
|
1449
|
-
return Object.fromEntries(entries);
|
|
3964
|
+
return interpolateValues(Object.fromEntries(entries), !opts.raw).values;
|
|
3965
|
+
}
|
|
3966
|
+
/**
|
|
3967
|
+
* Expand `${OTHER_SECRET}` references in a merged variable set. A cycle becomes
|
|
3968
|
+
* the CLI's standard fatal exit — it is a config bug with no correct value to
|
|
3969
|
+
* emit. Pass `enabled: false` (`--no-interpolate`) to hand the set back as-is.
|
|
3970
|
+
*/
|
|
3971
|
+
function interpolateValues(values, enabled = true) {
|
|
3972
|
+
if (!enabled) return {
|
|
3973
|
+
values,
|
|
3974
|
+
interpolated: [],
|
|
3975
|
+
unresolvedRefs: []
|
|
3976
|
+
};
|
|
3977
|
+
try {
|
|
3978
|
+
const { values: expandedValues, expanded, unresolved } = interpolateSecrets(values);
|
|
3979
|
+
return {
|
|
3980
|
+
values: expandedValues,
|
|
3981
|
+
interpolated: expanded,
|
|
3982
|
+
unresolvedRefs: unresolved
|
|
3983
|
+
};
|
|
3984
|
+
} catch (err) {
|
|
3985
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
3986
|
+
}
|
|
1450
3987
|
}
|
|
1451
3988
|
/**
|
|
1452
3989
|
* Decrypt one historical version of a secret. Ciphertext is bound to
|
|
@@ -1469,34 +4006,82 @@ async function encryptAndSetSecret(ctx, orgId, envId, name, value) {
|
|
|
1469
4006
|
* Each layer's DEK is unwrapped once with the principal's private key and its
|
|
1470
4007
|
* ciphertext decrypted locally. `process.env` is NOT applied here — callers
|
|
1471
4008
|
* that spawn a process layer it on top so the live shell always wins.
|
|
4009
|
+
*
|
|
4010
|
+
* `${OTHER_SECRET}` references are expanded last, against the merged set, so a
|
|
4011
|
+
* reference always resolves to whichever layer won the name.
|
|
1472
4012
|
*/
|
|
1473
4013
|
async function materializeEnv(ctx, opts) {
|
|
1474
4014
|
const query = {};
|
|
1475
4015
|
if (opts.with && Object.keys(opts.with).length > 0) query.with = opts.with;
|
|
4016
|
+
if (opts.branch) query.branch = opts.branch;
|
|
1476
4017
|
if (!isTokenAuth(ctx)) {
|
|
1477
4018
|
if (!opts.envId) fail("specify --app and --env, or authenticate with a service token (SEEKRIT_TOKEN)");
|
|
1478
4019
|
query.env = opts.envId;
|
|
1479
4020
|
}
|
|
1480
|
-
const { scope, layers } = await ctx.
|
|
4021
|
+
const { scope, layers } = await resolveWithCache(ctx, query, opts.cache);
|
|
1481
4022
|
const privateKey = await getPrivateKey(ctx);
|
|
1482
4023
|
const values = {};
|
|
1483
4024
|
const provenance = {};
|
|
1484
4025
|
for (const layer of layers) {
|
|
1485
4026
|
const dek = await unwrapDek(layer.wrappedDek, privateKey);
|
|
1486
|
-
|
|
4027
|
+
let label;
|
|
4028
|
+
if (layer.source === "group") label = `group:${layer.groupSlug}@${layer.slug}`;
|
|
4029
|
+
else if (layer.source === "branch") label = `branch:${scope.appSlug}#${layer.slug}`;
|
|
4030
|
+
else label = `app:${scope.appSlug}/${layer.slug}`;
|
|
1487
4031
|
for (const secret of layer.secrets) {
|
|
1488
4032
|
values[secret.name] = await decryptSecret(dek, secret.ciphertext, secretAad(layer.environmentId, secret.name));
|
|
1489
4033
|
provenance[secret.name] = label;
|
|
1490
4034
|
}
|
|
1491
4035
|
}
|
|
4036
|
+
const loadedEnvFiles = overlayEnvFiles(values, provenance, opts.envFiles);
|
|
1492
4037
|
return {
|
|
1493
|
-
values,
|
|
4038
|
+
...interpolateValues(values, opts.interpolate !== false),
|
|
1494
4039
|
provenance,
|
|
1495
4040
|
scope,
|
|
1496
|
-
loadedEnvFiles
|
|
4041
|
+
loadedEnvFiles
|
|
1497
4042
|
};
|
|
1498
4043
|
}
|
|
1499
4044
|
/**
|
|
4045
|
+
* Resolve, going through the last-known-good cache when one is configured.
|
|
4046
|
+
*
|
|
4047
|
+
* Always live first: the cache exists for when the call cannot land, not to
|
|
4048
|
+
* save a round trip, so a recovered network is picked up on the very next
|
|
4049
|
+
* invocation. A *refused* resolve (401/403/…) drops the entry rather than
|
|
4050
|
+
* falling back to it — otherwise revoking a token would keep working offline
|
|
4051
|
+
* until the entry aged out.
|
|
4052
|
+
*/
|
|
4053
|
+
async function resolveWithCache(ctx, query, cache) {
|
|
4054
|
+
if (!cache) return ctx.client.resolve(query);
|
|
4055
|
+
try {
|
|
4056
|
+
const response = await ctx.client.resolve(query);
|
|
4057
|
+
try {
|
|
4058
|
+
cache.write(JSON.stringify(response));
|
|
4059
|
+
} catch (err) {
|
|
4060
|
+
warn(`could not update the cache: ${errorMessage(err)}`);
|
|
4061
|
+
}
|
|
4062
|
+
return response;
|
|
4063
|
+
} catch (err) {
|
|
4064
|
+
if (!mayFallBack(err)) {
|
|
4065
|
+
cache.invalidate();
|
|
4066
|
+
throw err;
|
|
4067
|
+
}
|
|
4068
|
+
const found = cache.read();
|
|
4069
|
+
if (found.kind === "hit") {
|
|
4070
|
+
warn(`${errorMessage(err)} — using cached secrets fetched ${humanize(found.ageMs)} ago`);
|
|
4071
|
+
return JSON.parse(found.body);
|
|
4072
|
+
}
|
|
4073
|
+
if (found.kind === "expired") warn(`cached secrets are ${humanize(found.ageMs)} old, past --cache-max-age`);
|
|
4074
|
+
else if (found.kind === "unusable") warn(`ignoring the cached secrets: ${found.reason}`);
|
|
4075
|
+
throw err;
|
|
4076
|
+
}
|
|
4077
|
+
}
|
|
4078
|
+
function warn(text) {
|
|
4079
|
+
process.stderr.write(`seekrit: ${text}\n`);
|
|
4080
|
+
}
|
|
4081
|
+
function errorMessage(err) {
|
|
4082
|
+
return err instanceof Error ? err.message : String(err);
|
|
4083
|
+
}
|
|
4084
|
+
/**
|
|
1500
4085
|
* Overlay `.env` files onto an existing value/provenance set (later files win).
|
|
1501
4086
|
* Missing files are skipped. Returns the files that were actually loaded. Used
|
|
1502
4087
|
* both by {@link materializeEnv} and by `seekrit run`'s degraded path, where
|
|
@@ -1538,6 +4123,16 @@ function errText(err) {
|
|
|
1538
4123
|
isError: true
|
|
1539
4124
|
};
|
|
1540
4125
|
}
|
|
4126
|
+
/** Read-only and safe to repeat — every list/inspect tool. */
|
|
4127
|
+
const ro = {
|
|
4128
|
+
readOnly: true,
|
|
4129
|
+
idempotent: true
|
|
4130
|
+
};
|
|
4131
|
+
/** Removes or overwrites something; repeating it lands in the same state. */
|
|
4132
|
+
const destructive = {
|
|
4133
|
+
destructive: true,
|
|
4134
|
+
idempotent: true
|
|
4135
|
+
};
|
|
1541
4136
|
/**
|
|
1542
4137
|
* Short primer surfaced as the MCP server `instructions`. Most clients show
|
|
1543
4138
|
* this to the model on connect, so it has to orient an agent that lands here
|
|
@@ -1671,6 +4266,7 @@ async function materializeFor(ctx, o) {
|
|
|
1671
4266
|
if (!isTokenAuth(ctx)) envId = (await resolveAppEnv(ctx, o)).envId;
|
|
1672
4267
|
return materializeEnv(ctx, {
|
|
1673
4268
|
envId,
|
|
4269
|
+
branch: o.branch,
|
|
1674
4270
|
with: o.with,
|
|
1675
4271
|
envFiles: o.envFile ?? [".env"]
|
|
1676
4272
|
});
|
|
@@ -1705,10 +4301,17 @@ async function runMcpServer(options = {}) {
|
|
|
1705
4301
|
version: options.version ?? version$1
|
|
1706
4302
|
}, { instructions: serverInstructions() });
|
|
1707
4303
|
/** Register a tool whose handler returns data (serialized) or throws (→ isError). */
|
|
1708
|
-
const tool = (name, description, shape, handler) => {
|
|
4304
|
+
const tool = (name, description, hints, shape, handler) => {
|
|
1709
4305
|
server.registerTool(name, {
|
|
1710
4306
|
description,
|
|
1711
|
-
inputSchema: shape
|
|
4307
|
+
inputSchema: shape,
|
|
4308
|
+
annotations: {
|
|
4309
|
+
title: name,
|
|
4310
|
+
readOnlyHint: hints.readOnly ?? false,
|
|
4311
|
+
destructiveHint: hints.destructive ?? false,
|
|
4312
|
+
idempotentHint: hints.idempotent ?? false,
|
|
4313
|
+
openWorldHint: true
|
|
4314
|
+
}
|
|
1712
4315
|
}, (async (args) => {
|
|
1713
4316
|
try {
|
|
1714
4317
|
return jsonText(await handler(args));
|
|
@@ -1726,7 +4329,7 @@ async function runMcpServer(options = {}) {
|
|
|
1726
4329
|
openWorldHint: false
|
|
1727
4330
|
}
|
|
1728
4331
|
}, async () => jsonText(getStartedText()));
|
|
1729
|
-
tool("whoami", "Show the authenticated identity, its role, and accessible orgs.", {}, async () => {
|
|
4332
|
+
tool("whoami", "Show the authenticated identity, its role, and accessible orgs.", ro, {}, async () => {
|
|
1730
4333
|
const ctx = getCtx();
|
|
1731
4334
|
if (isTokenAuth(ctx) && ctx.auth.type === "bearer") {
|
|
1732
4335
|
const { tokenId } = await parseServiceToken(ctx.auth.token);
|
|
@@ -1747,13 +4350,13 @@ async function runMcpServer(options = {}) {
|
|
|
1747
4350
|
...await ctx.client.me()
|
|
1748
4351
|
};
|
|
1749
4352
|
});
|
|
1750
|
-
tool("list_orgs", "List organizations the caller can access.", {}, async () => (await getCtx().client.listOrgs()).orgs);
|
|
1751
|
-
tool("list_apps", "List applications in an organization.", { org: z.string().optional() }, async ({ org }) => {
|
|
4353
|
+
tool("list_orgs", "List organizations the caller can access.", ro, {}, async () => (await getCtx().client.listOrgs()).orgs);
|
|
4354
|
+
tool("list_apps", "List applications in an organization.", ro, { org: z.string().optional() }, async ({ org }) => {
|
|
1752
4355
|
const ctx = getCtx();
|
|
1753
4356
|
const orgRef = await resolveOrg(ctx, org);
|
|
1754
4357
|
return (await ctx.client.listApps(orgRef.id)).apps;
|
|
1755
4358
|
});
|
|
1756
|
-
tool("list_envs", "List environments of an application.", {
|
|
4359
|
+
tool("list_envs", "List environments of an application.", ro, {
|
|
1757
4360
|
org: z.string().optional(),
|
|
1758
4361
|
app: z.string()
|
|
1759
4362
|
}, async ({ org, app }) => {
|
|
@@ -1764,12 +4367,30 @@ async function runMcpServer(options = {}) {
|
|
|
1764
4367
|
if (!appRow) throw new Error(`no app "${app}" in ${orgRef.slug}`);
|
|
1765
4368
|
return (await ctx.client.listEnvs(orgRef.id, appRow.id)).environments;
|
|
1766
4369
|
});
|
|
1767
|
-
tool("
|
|
4370
|
+
tool("list_branches", "List ephemeral branch configs in an application (optionally just one environment's).", ro, {
|
|
4371
|
+
org: z.string().optional(),
|
|
4372
|
+
app: z.string(),
|
|
4373
|
+
env: z.string().optional()
|
|
4374
|
+
}, async ({ org, app, env }) => {
|
|
4375
|
+
const ctx = getCtx();
|
|
4376
|
+
const appRef = await resolveApp(ctx, {
|
|
4377
|
+
org,
|
|
4378
|
+
app
|
|
4379
|
+
});
|
|
4380
|
+
if (!env) return (await ctx.client.listAppBranches(appRef.orgId, appRef.id)).branches;
|
|
4381
|
+
const parent = await resolveAppEnv(ctx, {
|
|
4382
|
+
org,
|
|
4383
|
+
app,
|
|
4384
|
+
env
|
|
4385
|
+
});
|
|
4386
|
+
return (await ctx.client.listBranches(parent.orgId, parent.envId)).branches;
|
|
4387
|
+
});
|
|
4388
|
+
tool("list_groups", "List shared groups (reusable secret bags) in an organization.", ro, { org: z.string().optional() }, async ({ org }) => {
|
|
1768
4389
|
const ctx = getCtx();
|
|
1769
4390
|
const orgRef = await resolveOrg(ctx, org);
|
|
1770
4391
|
return (await ctx.client.listGroups(orgRef.id)).groups;
|
|
1771
4392
|
});
|
|
1772
|
-
tool("list_group_envs", "List a group's environments (per-slug value sets).", {
|
|
4393
|
+
tool("list_group_envs", "List a group's environments (per-slug value sets).", ro, {
|
|
1773
4394
|
org: z.string().optional(),
|
|
1774
4395
|
group: z.string()
|
|
1775
4396
|
}, async ({ org, group }) => {
|
|
@@ -1780,7 +4401,7 @@ async function runMcpServer(options = {}) {
|
|
|
1780
4401
|
});
|
|
1781
4402
|
return (await ctx.client.listGroupEnvs(g.orgId, g.id)).environments;
|
|
1782
4403
|
});
|
|
1783
|
-
tool("list_env_groups", "List the groups composed into an application environment (precedence order).", {
|
|
4404
|
+
tool("list_env_groups", "List the groups composed into an application environment (precedence order).", ro, {
|
|
1784
4405
|
org: z.string().optional(),
|
|
1785
4406
|
app: z.string(),
|
|
1786
4407
|
env: z.string()
|
|
@@ -1793,17 +4414,17 @@ async function runMcpServer(options = {}) {
|
|
|
1793
4414
|
});
|
|
1794
4415
|
return (await ctx.client.listEnvGroups(target.orgId, target.envId)).groups;
|
|
1795
4416
|
});
|
|
1796
|
-
tool("list_members", "List organization members and their public keys (for granting access).", { org: z.string().optional() }, async ({ org }) => {
|
|
4417
|
+
tool("list_members", "List organization members and their public keys (for granting access).", ro, { org: z.string().optional() }, async ({ org }) => {
|
|
1797
4418
|
const ctx = getCtx();
|
|
1798
4419
|
const orgRef = await resolveOrg(ctx, org);
|
|
1799
4420
|
return (await ctx.client.listMembers(orgRef.id)).members;
|
|
1800
4421
|
});
|
|
1801
|
-
tool("kms_list_keys", "List managed KMS keys the caller can see (metadata only).", { org: z.string().optional() }, async ({ org }) => {
|
|
4422
|
+
tool("kms_list_keys", "List managed KMS keys the caller can see (metadata only).", ro, { org: z.string().optional() }, async ({ org }) => {
|
|
1802
4423
|
const ctx = getCtx();
|
|
1803
4424
|
const orgRef = await resolveOrg(ctx, org);
|
|
1804
4425
|
return (await ctx.client.listKmsKeys(orgRef.id)).keys;
|
|
1805
4426
|
});
|
|
1806
|
-
tool("kms_create_key", "Create an org-scoped managed key. Material is generated locally and wrapped to each grantee (self plus any listed users/tokens); the server never sees it. Use the CLI for app/group-scoped keys.", {
|
|
4427
|
+
tool("kms_create_key", "Create an org-scoped managed key. Material is generated locally and wrapped to each grantee (self plus any listed users/tokens); the server never sees it. Use the CLI for app/group-scoped keys.", { idempotent: false }, {
|
|
1807
4428
|
org: z.string().optional(),
|
|
1808
4429
|
name: z.string(),
|
|
1809
4430
|
purpose: z.enum(["encrypt", "sign"]),
|
|
@@ -1844,7 +4465,7 @@ async function runMcpServer(options = {}) {
|
|
|
1844
4465
|
});
|
|
1845
4466
|
return key;
|
|
1846
4467
|
});
|
|
1847
|
-
tool("kms_grant", "Grant a principal (user email or token id) use of a key's current version.", {
|
|
4468
|
+
tool("kms_grant", "Grant a principal (user email or token id) use of a key's current version.", { idempotent: true }, {
|
|
1848
4469
|
org: z.string().optional(),
|
|
1849
4470
|
key: z.string(),
|
|
1850
4471
|
user: z.string().optional(),
|
|
@@ -1869,7 +4490,7 @@ async function runMcpServer(options = {}) {
|
|
|
1869
4490
|
key: k.name
|
|
1870
4491
|
};
|
|
1871
4492
|
});
|
|
1872
|
-
tool("kms_encrypt", "Encrypt a value under a managed encrypt key; returns a ce1 ciphertext blob. `context` (if given) is bound as AAD and must be supplied identically to decrypt.", {
|
|
4493
|
+
tool("kms_encrypt", "Encrypt a value under a managed encrypt key; returns a ce1 ciphertext blob. `context` (if given) is bound as AAD and must be supplied identically to decrypt.", { readOnly: true }, {
|
|
1873
4494
|
org: z.string().optional(),
|
|
1874
4495
|
key: z.string(),
|
|
1875
4496
|
plaintext: z.string(),
|
|
@@ -1886,7 +4507,7 @@ async function runMcpServer(options = {}) {
|
|
|
1886
4507
|
version: currentVersion
|
|
1887
4508
|
}, plaintext, context ?? "") };
|
|
1888
4509
|
});
|
|
1889
|
-
tool("kms_decrypt", "Decrypt a ce1 blob. Supply the same `context` used to encrypt.", {
|
|
4510
|
+
tool("kms_decrypt", "Decrypt a ce1 blob. Supply the same `context` used to encrypt.", ro, {
|
|
1890
4511
|
org: z.string().optional(),
|
|
1891
4512
|
key: z.string(),
|
|
1892
4513
|
ciphertext: z.string(),
|
|
@@ -1900,7 +4521,7 @@ async function runMcpServer(options = {}) {
|
|
|
1900
4521
|
const { material } = await kmsRecoverMaterial(ctx, orgRef.id, k.id, ref.version);
|
|
1901
4522
|
return { plaintext: await kmsDecrypt(material, ciphertext, context ?? "") };
|
|
1902
4523
|
});
|
|
1903
|
-
tool("kms_generate_data_key", "Generate a data key under a managed encrypt key (envelope encryption). Returns the plaintext key (base64) and its wrapped form to store.", {
|
|
4524
|
+
tool("kms_generate_data_key", "Generate a data key under a managed encrypt key (envelope encryption). Returns the plaintext key (base64) and its wrapped form to store.", { readOnly: true }, {
|
|
1904
4525
|
org: z.string().optional(),
|
|
1905
4526
|
key: z.string()
|
|
1906
4527
|
}, async ({ org, key }) => {
|
|
@@ -1919,7 +4540,7 @@ async function runMcpServer(options = {}) {
|
|
|
1919
4540
|
wrapped: dk.wrapped
|
|
1920
4541
|
};
|
|
1921
4542
|
});
|
|
1922
|
-
tool("kms_sign", "Sign a message with a managed signing key; returns an sg1 signature blob.", {
|
|
4543
|
+
tool("kms_sign", "Sign a message with a managed signing key; returns an sg1 signature blob.", { readOnly: true }, {
|
|
1923
4544
|
org: z.string().optional(),
|
|
1924
4545
|
key: z.string(),
|
|
1925
4546
|
message: z.string()
|
|
@@ -1935,7 +4556,7 @@ async function runMcpServer(options = {}) {
|
|
|
1935
4556
|
version: currentVersion
|
|
1936
4557
|
}, message) };
|
|
1937
4558
|
});
|
|
1938
|
-
tool("kms_verify", "Verify an sg1 signature over a message using a signing key's published public key (no grant needed).", {
|
|
4559
|
+
tool("kms_verify", "Verify an sg1 signature over a message using a signing key's published public key (no grant needed).", ro, {
|
|
1939
4560
|
org: z.string().optional(),
|
|
1940
4561
|
key: z.string(),
|
|
1941
4562
|
signature: z.string(),
|
|
@@ -1950,7 +4571,7 @@ async function runMcpServer(options = {}) {
|
|
|
1950
4571
|
if (!pub) throw new Error(`no published public key for version ${ref.version}`);
|
|
1951
4572
|
return { valid: await verifyMessage(await importVerifyingKey(pub), signature, message) };
|
|
1952
4573
|
});
|
|
1953
|
-
tool("list_secrets", "List secret names + versions in an environment (never values).", targetShape, async (o) => {
|
|
4574
|
+
tool("list_secrets", "List secret names + versions in an environment (never values).", ro, targetShape, async (o) => {
|
|
1954
4575
|
const ctx = getCtx();
|
|
1955
4576
|
const { orgId, envId } = await resolveTargetEnv(ctx, o);
|
|
1956
4577
|
const { secrets } = await ctx.client.listSecrets(orgId, envId);
|
|
@@ -1960,12 +4581,12 @@ async function runMcpServer(options = {}) {
|
|
|
1960
4581
|
updatedAt: s.updatedAt
|
|
1961
4582
|
}));
|
|
1962
4583
|
});
|
|
1963
|
-
tool("list_tokens", "List an organization's service tokens (never the secret token strings).", { org: z.string().optional() }, async ({ org }) => {
|
|
4584
|
+
tool("list_tokens", "List an organization's service tokens (never the secret token strings).", ro, { org: z.string().optional() }, async ({ org }) => {
|
|
1964
4585
|
const ctx = getCtx();
|
|
1965
4586
|
const orgRef = await resolveOrg(ctx, org);
|
|
1966
4587
|
return (await ctx.client.listTokens(orgRef.id)).tokens;
|
|
1967
4588
|
});
|
|
1968
|
-
tool("audit", "Read the organization's audit trail (most recent first).", {
|
|
4589
|
+
tool("audit", "Read the organization's audit trail (most recent first).", ro, {
|
|
1969
4590
|
org: z.string().optional(),
|
|
1970
4591
|
limit: z.number().int().min(1).max(200).optional(),
|
|
1971
4592
|
action: z.string().optional().describe("filter by action, e.g. secret.updated")
|
|
@@ -1977,14 +4598,14 @@ async function runMcpServer(options = {}) {
|
|
|
1977
4598
|
action
|
|
1978
4599
|
})).entries;
|
|
1979
4600
|
});
|
|
1980
|
-
tool("create_org", "Create an organization. Requires a user session — service tokens cannot own a Stytch org.", {
|
|
4601
|
+
tool("create_org", "Create an organization. Requires a user session — service tokens cannot own a Stytch org.", { idempotent: false }, {
|
|
1981
4602
|
name: z.string(),
|
|
1982
4603
|
slug: z.string()
|
|
1983
4604
|
}, async ({ name, slug }) => (await getCtx().client.createOrg({
|
|
1984
4605
|
name,
|
|
1985
4606
|
slug
|
|
1986
4607
|
})).org);
|
|
1987
|
-
tool("create_app", "Create an application in an organization.", {
|
|
4608
|
+
tool("create_app", "Create an application in an organization.", { idempotent: false }, {
|
|
1988
4609
|
org: z.string().optional(),
|
|
1989
4610
|
name: z.string(),
|
|
1990
4611
|
slug: z.string()
|
|
@@ -1996,7 +4617,7 @@ async function runMcpServer(options = {}) {
|
|
|
1996
4617
|
slug
|
|
1997
4618
|
})).app;
|
|
1998
4619
|
});
|
|
1999
|
-
tool("create_group", "Create a shared group (reusable secret bag) in an organization.", {
|
|
4620
|
+
tool("create_group", "Create a shared group (reusable secret bag) in an organization.", { idempotent: false }, {
|
|
2000
4621
|
org: z.string().optional(),
|
|
2001
4622
|
name: z.string(),
|
|
2002
4623
|
slug: z.string()
|
|
@@ -2008,7 +4629,7 @@ async function runMcpServer(options = {}) {
|
|
|
2008
4629
|
slug
|
|
2009
4630
|
})).group;
|
|
2010
4631
|
});
|
|
2011
|
-
tool("create_env", "Create an application environment. Generates the data key locally and grants it to the caller.", {
|
|
4632
|
+
tool("create_env", "Create an application environment. Generates the data key locally and grants it to the caller.", { idempotent: false }, {
|
|
2012
4633
|
org: z.string().optional(),
|
|
2013
4634
|
app: z.string(),
|
|
2014
4635
|
name: z.string(),
|
|
@@ -2026,7 +4647,56 @@ async function runMcpServer(options = {}) {
|
|
|
2026
4647
|
wrappedDek
|
|
2027
4648
|
})).environment;
|
|
2028
4649
|
});
|
|
2029
|
-
tool("
|
|
4650
|
+
tool("create_branch", "Fork an environment into an ephemeral branch (a per-PR / preview config). The branch inherits the parent's secrets by layering at read time — nothing is copied or re-encrypted — and holds only the values you override on it. Generates the branch's data key locally, grants it to the caller, and shares it with the parent's other readers.", { idempotent: false }, {
|
|
4651
|
+
org: z.string().optional(),
|
|
4652
|
+
app: z.string(),
|
|
4653
|
+
from: z.string().describe("the environment to branch"),
|
|
4654
|
+
slug: z.string().describe("the branch name, e.g. pr-142"),
|
|
4655
|
+
ttl: z.string().optional().describe("lifetime: 12h, 7d, 2w, … or `never` (default 7d)")
|
|
4656
|
+
}, async ({ org, app, from, slug, ttl }) => {
|
|
4657
|
+
const ctx = getCtx();
|
|
4658
|
+
const parent = await resolveAppEnv(ctx, {
|
|
4659
|
+
org,
|
|
4660
|
+
app,
|
|
4661
|
+
env: from
|
|
4662
|
+
});
|
|
4663
|
+
const parsedTtl = parseBranchTtl(ttl ?? "7d");
|
|
4664
|
+
if (parsedTtl === null) throw new Error(`invalid ttl "${ttl}" (try 12h, 7d, 2w, or never)`);
|
|
4665
|
+
const me = await kmsCallerIdentity(ctx);
|
|
4666
|
+
const dek = generateDek();
|
|
4667
|
+
const wrappedDek = await wrapDek(dek, me.publicKeyJwk);
|
|
4668
|
+
const grants = [];
|
|
4669
|
+
const { grantees } = await ctx.client.listGrantees(parent.orgId, parent.envId);
|
|
4670
|
+
for (const grantee of grantees) {
|
|
4671
|
+
if (grantee.principalType === me.principalType && grantee.principalId === me.principalId) continue;
|
|
4672
|
+
grants.push({
|
|
4673
|
+
principalType: grantee.principalType,
|
|
4674
|
+
principalId: grantee.principalId,
|
|
4675
|
+
wrappedDek: await wrapDek(dek, grantee.publicKeyJwk)
|
|
4676
|
+
});
|
|
4677
|
+
}
|
|
4678
|
+
return (await ctx.client.createBranch(parent.orgId, parent.envId, {
|
|
4679
|
+
slug,
|
|
4680
|
+
ttlSeconds: Number.isFinite(parsedTtl) ? parsedTtl : null,
|
|
4681
|
+
wrappedDek,
|
|
4682
|
+
grants
|
|
4683
|
+
})).branch;
|
|
4684
|
+
});
|
|
4685
|
+
tool("delete_branch", "Tear down a branch config and every value it overrode. The parent environment is untouched.", destructive, {
|
|
4686
|
+
org: z.string().optional(),
|
|
4687
|
+
app: z.string(),
|
|
4688
|
+
branch: z.string()
|
|
4689
|
+
}, async ({ org, app, branch }) => {
|
|
4690
|
+
const ctx = getCtx();
|
|
4691
|
+
const appRef = await resolveApp(ctx, {
|
|
4692
|
+
org,
|
|
4693
|
+
app
|
|
4694
|
+
});
|
|
4695
|
+
const target = await resolveBranch(ctx, appRef, branch);
|
|
4696
|
+
await ctx.client.deleteBranch(appRef.orgId, target.id);
|
|
4697
|
+
return { deleted: target.slug };
|
|
4698
|
+
});
|
|
4699
|
+
tool("create_group_env", "Create a group environment. Generates the data key locally and grants it to the caller.", { idempotent: false }, {
|
|
2030
4700
|
org: z.string().optional(),
|
|
2031
4701
|
group: z.string(),
|
|
2032
4702
|
name: z.string(),
|
|
@@ -2044,7 +4714,7 @@ async function runMcpServer(options = {}) {
|
|
|
2044
4714
|
wrappedDek
|
|
2045
4715
|
})).environment;
|
|
2046
4716
|
});
|
|
2047
|
-
tool("compose_group", "Compose a group into an application environment (higher position wins on name clashes).", {
|
|
4717
|
+
tool("compose_group", "Compose a group into an application environment (higher position wins on name clashes).", { idempotent: true }, {
|
|
2048
4718
|
org: z.string().optional(),
|
|
2049
4719
|
app: z.string(),
|
|
2050
4720
|
env: z.string(),
|
|
@@ -2066,7 +4736,7 @@ async function runMcpServer(options = {}) {
|
|
|
2066
4736
|
position
|
|
2067
4737
|
})).group;
|
|
2068
4738
|
});
|
|
2069
|
-
tool("uncompose_group", "Remove a composed group from an application environment.", {
|
|
4739
|
+
tool("uncompose_group", "Remove a composed group from an application environment.", { idempotent: true }, {
|
|
2070
4740
|
org: z.string().optional(),
|
|
2071
4741
|
app: z.string(),
|
|
2072
4742
|
env: z.string(),
|
|
@@ -2085,7 +4755,7 @@ async function runMcpServer(options = {}) {
|
|
|
2085
4755
|
await ctx.client.unlinkEnvGroup(target.orgId, target.envId, g.id);
|
|
2086
4756
|
return { ok: true };
|
|
2087
4757
|
});
|
|
2088
|
-
tool("set_secret", "Encrypt a value locally and store it in an environment.", {
|
|
4758
|
+
tool("set_secret", "Encrypt a value locally and store it in an environment. A value may reference another secret as ${OTHER_SECRET}: the reference is stored literally and expanded whenever the secret is read, so it tracks the referenced value. Write $${OTHER_SECRET} for a literal.", { idempotent: false }, {
|
|
2089
4759
|
...targetShape,
|
|
2090
4760
|
name: z.string(),
|
|
2091
4761
|
value: z.string()
|
|
@@ -2099,10 +4769,11 @@ async function runMcpServer(options = {}) {
|
|
|
2099
4769
|
name: o.name
|
|
2100
4770
|
};
|
|
2101
4771
|
});
|
|
2102
|
-
tool("get_secret", "Return one secret. By default only reports presence + version; pass reveal:true to decrypt the plaintext into this response (avoid unless the value is actually needed — prefer run_command). Pass `version` to read an earlier version instead of the current one.", {
|
|
4772
|
+
tool("get_secret", "Return one secret. By default only reports presence + version; pass reveal:true to decrypt the plaintext into this response (avoid unless the value is actually needed — prefer run_command). A revealed current value has its ${OTHER_SECRET} references expanded against this environment's own secrets; pass raw:true for the stored text instead. Pass `version` to read an earlier version instead of the current one (always as stored, never expanded).", ro, {
|
|
2103
4773
|
...targetShape,
|
|
2104
4774
|
name: z.string(),
|
|
2105
4775
|
reveal: z.boolean().optional(),
|
|
4776
|
+
raw: z.boolean().optional().describe("skip ${OTHER_SECRET} expansion (with reveal)"),
|
|
2106
4777
|
version: z.number().int().positive().optional().describe("an earlier version from list_secret_versions (default: current)")
|
|
2107
4778
|
}, async (o) => {
|
|
2108
4779
|
const ctx = getCtx();
|
|
@@ -2127,7 +4798,7 @@ async function runMcpServer(options = {}) {
|
|
|
2127
4798
|
revealed: true
|
|
2128
4799
|
};
|
|
2129
4800
|
}
|
|
2130
|
-
const values = await fetchDecryptedSecrets(ctx, orgId, envId);
|
|
4801
|
+
const values = await fetchDecryptedSecrets(ctx, orgId, envId, { raw: o.raw });
|
|
2131
4802
|
if (!(o.name in values)) throw new Error(`no secret named ${o.name}`);
|
|
2132
4803
|
return {
|
|
2133
4804
|
name: o.name,
|
|
@@ -2135,7 +4806,7 @@ async function runMcpServer(options = {}) {
|
|
|
2135
4806
|
revealed: true
|
|
2136
4807
|
};
|
|
2137
4808
|
});
|
|
2138
|
-
tool("list_secret_versions", "List a secret's version history: who wrote each version, when, and which ones were restores. Never returns values — pair it with restore_secret to roll back, or get_secret(version, reveal:true) to inspect one.", {
|
|
4809
|
+
tool("list_secret_versions", "List a secret's version history: who wrote each version, when, and which ones were restores. Never returns values — pair it with restore_secret to roll back, or get_secret(version, reveal:true) to inspect one.", ro, {
|
|
2139
4810
|
...targetShape,
|
|
2140
4811
|
name: z.string(),
|
|
2141
4812
|
limit: z.number().int().min(1).max(200).optional().describe("default 20")
|
|
@@ -2153,7 +4824,7 @@ async function runMcpServer(options = {}) {
|
|
|
2153
4824
|
}))
|
|
2154
4825
|
};
|
|
2155
4826
|
});
|
|
2156
|
-
tool("restore_secret", "Roll a secret back to an earlier version. The stored ciphertext is replayed as a NEW version (history is append-only, nothing is overwritten). Keyless — no decryption happens, so this works even without a key.", {
|
|
4827
|
+
tool("restore_secret", "Roll a secret back to an earlier version. The stored ciphertext is replayed as a NEW version (history is append-only, nothing is overwritten). Keyless — no decryption happens, so this works even without a key.", { idempotent: false }, {
|
|
2157
4828
|
...targetShape,
|
|
2158
4829
|
name: z.string(),
|
|
2159
4830
|
version: z.number().int().positive()
|
|
@@ -2168,7 +4839,7 @@ async function runMcpServer(options = {}) {
|
|
|
2168
4839
|
version: secret.version
|
|
2169
4840
|
};
|
|
2170
4841
|
});
|
|
2171
|
-
tool("delete_secret", "Delete a secret from an environment.", {
|
|
4842
|
+
tool("delete_secret", "Delete a secret from an environment.", destructive, {
|
|
2172
4843
|
...targetShape,
|
|
2173
4844
|
name: z.string()
|
|
2174
4845
|
}, async (o) => {
|
|
@@ -2181,11 +4852,15 @@ async function runMcpServer(options = {}) {
|
|
|
2181
4852
|
};
|
|
2182
4853
|
});
|
|
2183
4854
|
tool("run_command", "Run a command with the resolved secrets injected as environment variables, and return its exit code + captured output. Secret VALUES are never returned — this is the preferred way to use secrets. process env > .env > app env > groups.", {
|
|
4855
|
+
destructive: true,
|
|
4856
|
+
idempotent: false
|
|
4857
|
+
}, {
|
|
2184
4858
|
command: z.string().describe("executable to run"),
|
|
2185
4859
|
args: z.array(z.string()).optional(),
|
|
2186
4860
|
org: z.string().optional(),
|
|
2187
4861
|
app: z.string().optional(),
|
|
2188
4862
|
env: z.string().optional().describe("environment slug (token auth infers this)"),
|
|
4863
|
+
branch: z.string().optional().describe("read an ephemeral branch of that environment"),
|
|
2189
4864
|
with: z.record(z.string(), z.string()).optional().describe("group=env slice overrides"),
|
|
2190
4865
|
envFile: z.array(z.string()).optional().describe(".env files to overlay (default [.env])"),
|
|
2191
4866
|
cwd: z.string().optional()
|
|
@@ -2202,11 +4877,12 @@ async function runMcpServer(options = {}) {
|
|
|
2202
4877
|
injectedVarCount: Object.keys(values).length
|
|
2203
4878
|
};
|
|
2204
4879
|
});
|
|
2205
|
-
tool("export_env", "Write the resolved secrets to a dotenv file on disk and return the variable names written (never the values). Use to materialize a .env for local tooling.", {
|
|
4880
|
+
tool("export_env", "Write the resolved secrets to a dotenv file on disk and return the variable names written (never the values). Use to materialize a .env for local tooling.", destructive, {
|
|
2206
4881
|
file: z.string().describe("path to write, e.g. .env"),
|
|
2207
4882
|
org: z.string().optional(),
|
|
2208
4883
|
app: z.string().optional(),
|
|
2209
4884
|
env: z.string().optional(),
|
|
4885
|
+
branch: z.string().optional().describe("read an ephemeral branch of that environment"),
|
|
2210
4886
|
with: z.record(z.string(), z.string()).optional()
|
|
2211
4887
|
}, async (o) => {
|
|
2212
4888
|
const ctx = getCtx();
|
|
@@ -2220,7 +4896,7 @@ async function runMcpServer(options = {}) {
|
|
|
2220
4896
|
names: Object.keys(values).sort()
|
|
2221
4897
|
};
|
|
2222
4898
|
});
|
|
2223
|
-
tool("create_token", "Mint a service token, printed once. Runtime tokens bind to one app environment (auto-granted its keys, so a command/agent can decrypt it). Pass admin:true for an org-scoped provisioning token (create apps/groups/envs, grant, mint tokens) — admin tokens need no env binding.", {
|
|
4899
|
+
tool("create_token", "Mint a service token, printed once. Runtime tokens bind to one app environment (auto-granted its keys, so a command/agent can decrypt it). Pass admin:true for an org-scoped provisioning token (create apps/groups/envs, grant, mint tokens) — admin tokens need no env binding.", { idempotent: false }, {
|
|
2224
4900
|
name: z.string().describe("display name, e.g. ci-deploy or agent-session"),
|
|
2225
4901
|
org: z.string().optional(),
|
|
2226
4902
|
app: z.string().optional().describe("bind to this app (runtime tokens)"),
|
|
@@ -2271,7 +4947,7 @@ async function runMcpServer(options = {}) {
|
|
|
2271
4947
|
note: "save this now — the secret token string is not stored and cannot be retrieved"
|
|
2272
4948
|
};
|
|
2273
4949
|
});
|
|
2274
|
-
tool("revoke_token", "Revoke a service token by id.", {
|
|
4950
|
+
tool("revoke_token", "Revoke a service token by id.", destructive, {
|
|
2275
4951
|
org: z.string().optional(),
|
|
2276
4952
|
tokenId: z.string()
|
|
2277
4953
|
}, async ({ org, tokenId }) => {
|
|
@@ -2283,7 +4959,7 @@ async function runMcpServer(options = {}) {
|
|
|
2283
4959
|
tokenId
|
|
2284
4960
|
};
|
|
2285
4961
|
});
|
|
2286
|
-
tool("grant_env", "Grant a member (by email) or service token (by id) access to an environment's data key. Re-wraps the DEK to the grantee — the caller must already hold the key.", {
|
|
4962
|
+
tool("grant_env", "Grant a member (by email) or service token (by id) access to an environment's data key. Re-wraps the DEK to the grantee — the caller must already hold the key.", { idempotent: true }, {
|
|
2287
4963
|
...targetShape,
|
|
2288
4964
|
user: z.string().optional().describe("org member email"),
|
|
2289
4965
|
token: z.string().optional().describe("service token id (skt_…)")
|
|
@@ -2328,12 +5004,12 @@ async function runMcpServer(options = {}) {
|
|
|
2328
5004
|
principalId
|
|
2329
5005
|
};
|
|
2330
5006
|
});
|
|
2331
|
-
tool("list_pg_targets", "List registered Postgres provisioning targets for temporary credentials.", { org: z.string().optional() }, async ({ org }) => {
|
|
5007
|
+
tool("list_pg_targets", "List registered Postgres provisioning targets for temporary credentials.", ro, { org: z.string().optional() }, async ({ org }) => {
|
|
2332
5008
|
const ctx = getCtx();
|
|
2333
5009
|
const orgRef = await resolveOrg(ctx, org);
|
|
2334
5010
|
return (await ctx.client.listLeaseTargets(orgRef.id)).targets;
|
|
2335
5011
|
});
|
|
2336
|
-
tool("create_pg_lease", "Mint a short-lived Postgres credential against a target and return a ready-to-use connection URL. The password is generated on THIS machine and only its SCRAM verifier is sent to the API — the plaintext never reaches seekrit or Postgres at rest. The role auto-expires; revoke early with revoke_pg_lease.", {
|
|
5012
|
+
tool("create_pg_lease", "Mint a short-lived Postgres credential against a target and return a ready-to-use connection URL. The password is generated on THIS machine and only its SCRAM verifier is sent to the API — the plaintext never reaches seekrit or Postgres at rest. The role auto-expires; revoke early with revoke_pg_lease.", { idempotent: false }, {
|
|
2337
5013
|
org: z.string().optional(),
|
|
2338
5014
|
target: z.string().describe("target id or name"),
|
|
2339
5015
|
role: z.string().optional().describe("role name to create (default: random tmp_ name)"),
|
|
@@ -2362,12 +5038,12 @@ async function runMcpServer(options = {}) {
|
|
|
2362
5038
|
note: "short-lived credential; it auto-expires and the plaintext is not stored anywhere"
|
|
2363
5039
|
};
|
|
2364
5040
|
});
|
|
2365
|
-
tool("list_pg_leases", "List Postgres leases (the ledger — never secret material).", { org: z.string().optional() }, async ({ org }) => {
|
|
5041
|
+
tool("list_pg_leases", "List Postgres leases (the ledger — never secret material).", ro, { org: z.string().optional() }, async ({ org }) => {
|
|
2366
5042
|
const ctx = getCtx();
|
|
2367
5043
|
const orgRef = await resolveOrg(ctx, org);
|
|
2368
5044
|
return (await ctx.client.listLeases(orgRef.id)).leases;
|
|
2369
5045
|
});
|
|
2370
|
-
tool("revoke_pg_lease", "Revoke a Postgres lease now (drops the role immediately).", {
|
|
5046
|
+
tool("revoke_pg_lease", "Revoke a Postgres lease now (drops the role immediately).", destructive, {
|
|
2371
5047
|
org: z.string().optional(),
|
|
2372
5048
|
leaseId: z.string()
|
|
2373
5049
|
}, async ({ org, leaseId }) => {
|
|
@@ -2379,12 +5055,12 @@ async function runMcpServer(options = {}) {
|
|
|
2379
5055
|
leaseId
|
|
2380
5056
|
};
|
|
2381
5057
|
});
|
|
2382
|
-
tool("list_mysql_targets", "List registered MySQL/MariaDB provisioning targets for temporary credentials.", { org: z.string().optional() }, async ({ org }) => {
|
|
5058
|
+
tool("list_mysql_targets", "List registered MySQL/MariaDB provisioning targets for temporary credentials.", ro, { org: z.string().optional() }, async ({ org }) => {
|
|
2383
5059
|
const ctx = getCtx();
|
|
2384
5060
|
const orgRef = await resolveOrg(ctx, org);
|
|
2385
5061
|
return (await ctx.client.listLeaseTargets(orgRef.id)).targets.filter((t) => t.provider === "mysql");
|
|
2386
5062
|
});
|
|
2387
|
-
tool("create_mysql_lease", "Mint a short-lived MySQL/MariaDB credential against a target and return a ready-to-use connection URL. The password is generated on THIS machine and only its mysql_native_password hash is sent to the API — the plaintext never reaches seekrit or MySQL at rest. The user auto-expires; revoke early with revoke_mysql_lease.", {
|
|
5063
|
+
tool("create_mysql_lease", "Mint a short-lived MySQL/MariaDB credential against a target and return a ready-to-use connection URL. The password is generated on THIS machine and only its mysql_native_password hash is sent to the API — the plaintext never reaches seekrit or MySQL at rest. The user auto-expires; revoke early with revoke_mysql_lease.", { idempotent: false }, {
|
|
2388
5064
|
org: z.string().optional(),
|
|
2389
5065
|
target: z.string().describe("target id or name"),
|
|
2390
5066
|
user: z.string().optional().describe("user name to create (default: random tmp_ name)"),
|
|
@@ -2414,12 +5090,12 @@ async function runMcpServer(options = {}) {
|
|
|
2414
5090
|
note: "short-lived credential; it auto-expires and the plaintext is not stored anywhere"
|
|
2415
5091
|
};
|
|
2416
5092
|
});
|
|
2417
|
-
tool("list_mysql_leases", "List MySQL/MariaDB leases (the ledger — never secret material).", { org: z.string().optional() }, async ({ org }) => {
|
|
5093
|
+
tool("list_mysql_leases", "List MySQL/MariaDB leases (the ledger — never secret material).", ro, { org: z.string().optional() }, async ({ org }) => {
|
|
2418
5094
|
const ctx = getCtx();
|
|
2419
5095
|
const orgRef = await resolveOrg(ctx, org);
|
|
2420
5096
|
return (await ctx.client.listLeases(orgRef.id)).leases.filter((l) => l.provider === "mysql");
|
|
2421
5097
|
});
|
|
2422
|
-
tool("revoke_mysql_lease", "Revoke a MySQL/MariaDB lease now (drops the user immediately).", {
|
|
5098
|
+
tool("revoke_mysql_lease", "Revoke a MySQL/MariaDB lease now (drops the user immediately).", destructive, {
|
|
2423
5099
|
org: z.string().optional(),
|
|
2424
5100
|
leaseId: z.string()
|
|
2425
5101
|
}, async ({ org, leaseId }) => {
|
|
@@ -2431,7 +5107,7 @@ async function runMcpServer(options = {}) {
|
|
|
2431
5107
|
leaseId
|
|
2432
5108
|
};
|
|
2433
5109
|
});
|
|
2434
|
-
tool("configure_project", "Link a directory to an org/app by writing seekrit.json (like `seekrit init`). The environment is chosen by the service token at runtime.", {
|
|
5110
|
+
tool("configure_project", "Link a directory to an org/app by writing seekrit.json (like `seekrit init`). The environment is chosen by the service token at runtime.", destructive, {
|
|
2435
5111
|
org: z.string(),
|
|
2436
5112
|
app: z.string(),
|
|
2437
5113
|
dir: z.string().optional()
|
|
@@ -2469,7 +5145,7 @@ async function runMcpServer(options = {}) {
|
|
|
2469
5145
|
* `seekrit mcp`. tsdown bundles the shared server source in at build time, so the
|
|
2470
5146
|
* published package is self-contained and needs no `@seekrit/cli` install.
|
|
2471
5147
|
*/
|
|
2472
|
-
runMcpServer({ version: "0.
|
|
5148
|
+
runMcpServer({ version: "0.7.0" }).catch((err) => {
|
|
2473
5149
|
const message = err instanceof Error ? err.message : String(err);
|
|
2474
5150
|
process.stderr.write(`seekrit-mcp: fatal: ${message}\n`);
|
|
2475
5151
|
process.exit(1);
|