@seekrit/mcp 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +1522 -100
  2. package/package.json +4 -2
package/dist/index.js CHANGED
@@ -8,6 +8,1138 @@ 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
+ /** All catalog keys as a runtime array (for iteration / zod enums). */
12
+ const ENTITLEMENT_KEYS = Object.keys({
13
+ "feature.kms": {
14
+ kind: "feature",
15
+ label: "Managed keys (KMS)",
16
+ description: "Client-side managed keys for application-layer encryption and signing.",
17
+ default: true
18
+ },
19
+ "feature.leases": {
20
+ kind: "feature",
21
+ label: "Temporary access",
22
+ description: "Vault-style short-lived database and cloud credentials.",
23
+ default: true
24
+ },
25
+ "feature.log_sink": {
26
+ kind: "feature",
27
+ label: "Audit log export (SIEM)",
28
+ description: "Stream the audit log to an external OTLP collector.",
29
+ default: true
30
+ },
31
+ "feature.proxy": {
32
+ kind: "feature",
33
+ label: "Agent egress proxy",
34
+ description: "Substitute secrets into outbound requests for untrusted workloads.",
35
+ default: true
36
+ },
37
+ "feature.sso": {
38
+ kind: "feature",
39
+ label: "SSO / SAML",
40
+ description: "Single sign-on beyond the built-in providers.",
41
+ default: true
42
+ },
43
+ "feature.sync": {
44
+ kind: "feature",
45
+ label: "Third-party sync",
46
+ description: "Push environment secrets to external platforms like Vercel.",
47
+ default: true
48
+ },
49
+ "apps.max": {
50
+ kind: "limit",
51
+ label: "Applications",
52
+ description: "Maximum applications in the organization.",
53
+ default: null
54
+ },
55
+ "envs.per_app.max": {
56
+ kind: "limit",
57
+ label: "Environments per application",
58
+ description: "Maximum environments under a single application.",
59
+ default: null
60
+ },
61
+ "branches.per_app.max": {
62
+ kind: "limit",
63
+ label: "Branch configs per application",
64
+ description: "Maximum ephemeral branch environments under a single application.",
65
+ default: null
66
+ },
67
+ "secrets.per_env.max": {
68
+ kind: "limit",
69
+ label: "Secrets per environment",
70
+ description: "Maximum secrets in a single environment.",
71
+ default: null
72
+ },
73
+ "groups.max": {
74
+ kind: "limit",
75
+ label: "Groups",
76
+ description: "Maximum reusable secret groups in the organization.",
77
+ default: null
78
+ },
79
+ "tokens.max": {
80
+ kind: "limit",
81
+ label: "Service tokens",
82
+ description: "Maximum active service tokens in the organization.",
83
+ default: null
84
+ },
85
+ "kms.keys.max": {
86
+ kind: "limit",
87
+ label: "Managed keys",
88
+ description: "Maximum managed KMS keys in the organization.",
89
+ default: null
90
+ },
91
+ "lease.targets.max": {
92
+ kind: "limit",
93
+ label: "Lease targets",
94
+ description: "Maximum registered temporary-access targets.",
95
+ default: null
96
+ },
97
+ "sync.connections.max": {
98
+ kind: "limit",
99
+ label: "Sync connections",
100
+ description: "Maximum registered third-party sync destinations.",
101
+ default: null
102
+ },
103
+ members: {
104
+ kind: "metered",
105
+ label: "Members",
106
+ description: "Users in the organization. Included in the plan, then billed per seat.",
107
+ default: null,
108
+ metric: "member_count"
109
+ },
110
+ "resolves.monthly": {
111
+ kind: "metered",
112
+ label: "Monthly resolves",
113
+ description: "Secret resolutions per month. Included in the plan, then billed per unit.",
114
+ default: null,
115
+ metric: "monthly_resolves"
116
+ }
117
+ });
118
+ //#endregion
119
+ //#region ../../packages/core/src/plans.ts
120
+ const PLAN_FAMILIES = {
121
+ free: {
122
+ id: "free",
123
+ name: "Free",
124
+ description: "Get started with the essentials.",
125
+ current: 1,
126
+ hidden: false
127
+ },
128
+ team: {
129
+ id: "team",
130
+ name: "Team",
131
+ description: "For small teams collaborating on secrets.",
132
+ current: 1,
133
+ hidden: false
134
+ },
135
+ pro: {
136
+ id: "pro",
137
+ name: "Pro",
138
+ description: "For teams running secrets in production.",
139
+ current: 1,
140
+ hidden: true
141
+ },
142
+ enterprise: {
143
+ id: "enterprise",
144
+ name: "Enterprise",
145
+ description: "Unlimited scale with advanced governance.",
146
+ current: 1,
147
+ hidden: false
148
+ }
149
+ };
150
+ const PLAN_FAMILY_IDS = Object.keys(PLAN_FAMILIES);
151
+ PLAN_FAMILY_IDS.filter((family) => !PLAN_FAMILIES[family].hidden);
152
+ //#endregion
153
+ //#region ../../packages/core/src/billing.ts
154
+ /**
155
+ * Lifecycle states a subscription can be in. Mirrors the biller's own states
156
+ * (Stripe) but is provider-neutral so a different biller could map onto it.
157
+ */
158
+ const SUBSCRIPTION_STATUSES = [
159
+ "trialing",
160
+ "active",
161
+ "past_due",
162
+ "canceled",
163
+ "paused"
164
+ ];
165
+ //#endregion
166
+ //#region ../../packages/core/src/branches.ts
167
+ /**
168
+ * Branch (ephemeral) environments — a per-PR/preview overlay on an existing
169
+ * application environment.
170
+ *
171
+ * A branch is an ordinary environment row with a parent and a TTL. It is an
172
+ * **overlay, not a copy**: resolve returns the parent's layers and then the
173
+ * branch's own on top, so a branch holds only the values that differ and
174
+ * tracks the parent live. Nothing is re-encrypted at creation — a secret's
175
+ * ciphertext is bound to `(environmentId, name)` as AAD, so copying blobs into
176
+ * a new environment could not decrypt anyway, and a snapshot would immediately
177
+ * drift from its base.
178
+ *
179
+ * Two rules keep the read path cheap and predictable, enforced here:
180
+ *
181
+ * - **Depth one.** A branch's parent must not itself be a branch, so resolve
182
+ * never recurses on the hot path.
183
+ * - **Application environments only.** Group environments are pulled in by
184
+ * composition (matched by slug) and have no single parent to overlay.
185
+ */
186
+ /** Longest life a branch may be given. Bounds sprawl even if nobody cleans up. */
187
+ const MAX_BRANCH_TTL_SECONDS = 720 * 60 * 60;
188
+ /**
189
+ * Parse a human TTL — `30m`, `12h`, `7d`, `2w`, or bare seconds — into seconds.
190
+ * Returns null for anything unparseable, so callers can report the input back.
191
+ * `never` / `none` mean "no expiry" and yield `Infinity`, which
192
+ * {@link planBranchCreate} rejects unless passed as an explicit `null`.
193
+ */
194
+ function parseBranchTtl(input) {
195
+ const raw = input.trim().toLowerCase();
196
+ if (raw === "never" || raw === "none") return Number.POSITIVE_INFINITY;
197
+ const match = /^(\d+)\s*(s|m|h|d|w)?$/.exec(raw);
198
+ if (!match) return null;
199
+ const value = Number(match[1]);
200
+ const multiplier = {
201
+ s: 1,
202
+ m: 60,
203
+ h: 3600,
204
+ d: 86400,
205
+ w: 604800
206
+ }[match[2] ?? "s"];
207
+ if (multiplier === void 0) return null;
208
+ return value * multiplier;
209
+ }
210
+ //#endregion
211
+ //#region ../../packages/core/src/dotenv.ts
212
+ /**
213
+ * Apply double-quote escapes in a single left-to-right pass.
214
+ *
215
+ * A pass per escape (`\n` → newline, then `\\` → `\`, …) is wrong: in `\\n` the
216
+ * first pass matches the trailing `\n` and yields a real newline, corrupting
217
+ * every literal backslash-n a JSON credential is made of. Scanning once means a
218
+ * backslash consumes the character after it and can never be re-read.
219
+ */
220
+ function unescapeDoubleQuoted(text) {
221
+ let out = "";
222
+ for (let i = 0; i < text.length; i++) {
223
+ if (text[i] !== "\\" || i === text.length - 1) {
224
+ out += text[i];
225
+ continue;
226
+ }
227
+ const next = text[++i];
228
+ if (next === "n") out += "\n";
229
+ else if (next === "r") out += "\r";
230
+ else if (next === "t") out += " ";
231
+ else if (next === "\"" || next === "\\") out += next;
232
+ else out += `\\${next}`;
233
+ }
234
+ return out;
235
+ }
236
+ /**
237
+ * Find the index of the quote that closes a value opened at `start`.
238
+ *
239
+ * Inside double quotes a `\"` is an escaped quote, not the terminator (and a
240
+ * `\\` immediately before the quote *is* a terminator, since the backslash is
241
+ * itself escaped) — so the scan tracks escapes rather than searching for the
242
+ * next bare quote. Single quotes have no escapes: the next one closes. Returns
243
+ * -1 when the value is never closed.
244
+ */
245
+ function findClosingQuote(content, start, quote) {
246
+ for (let i = start; i < content.length; i++) {
247
+ if (quote === "\"" && content[i] === "\\") {
248
+ i++;
249
+ continue;
250
+ }
251
+ if (content[i] === quote) return i;
252
+ }
253
+ return -1;
254
+ }
255
+ /**
256
+ * Parse `.env` text into variables. Later assignments win, matching the
257
+ * object-assignment semantics every `.env` reader has.
258
+ */
259
+ function parseDotenv(content) {
260
+ const out = {};
261
+ let cursor = 0;
262
+ while (cursor < content.length) {
263
+ const newline = content.indexOf("\n", cursor);
264
+ const lineEnd = newline === -1 ? content.length : newline;
265
+ const lineStart = cursor;
266
+ const trimmedEnd = lineEnd > lineStart && content[lineEnd - 1] === "\r" ? lineEnd - 1 : lineEnd;
267
+ cursor = lineEnd + 1;
268
+ let i = skipSpace(content, lineStart, trimmedEnd);
269
+ if (i === trimmedEnd || content[i] === "#") continue;
270
+ if (content.startsWith("export ", i)) i = skipSpace(content, i + 7, trimmedEnd);
271
+ const eq = content.indexOf("=", i);
272
+ if (eq === -1 || eq >= trimmedEnd) continue;
273
+ const key = content.slice(i, eq).trim();
274
+ if (!key) continue;
275
+ const valueStart = skipSpace(content, eq + 1, trimmedEnd);
276
+ const quote = content[valueStart];
277
+ if (valueStart === trimmedEnd || quote !== "\"" && quote !== "'") {
278
+ const rest = content.slice(valueStart, trimmedEnd).trimEnd();
279
+ const comment = rest.indexOf(" #");
280
+ out[key] = comment === -1 ? rest : rest.slice(0, comment).trimEnd();
281
+ continue;
282
+ }
283
+ const close = findClosingQuote(content, valueStart + 1, quote);
284
+ const valueEnd = close === -1 ? content.length : close;
285
+ const value = content.slice(valueStart + 1, valueEnd);
286
+ out[key] = quote === "\"" ? unescapeDoubleQuoted(value) : value;
287
+ if (valueEnd >= cursor) {
288
+ const after = content.indexOf("\n", valueEnd);
289
+ cursor = after === -1 ? content.length : after + 1;
290
+ }
291
+ }
292
+ return out;
293
+ }
294
+ /** First index at or after `from` (and before `end`) that isn't a space or tab. */
295
+ function skipSpace(content, from, end) {
296
+ let i = from;
297
+ while (i < end && (content[i] === " " || content[i] === " ")) i++;
298
+ return i;
299
+ }
300
+ //#endregion
301
+ //#region ../../packages/core/src/interpolate.ts
302
+ /**
303
+ * Secret references: `${OTHER_SECRET}` inside a secret value.
304
+ *
305
+ * This is the **canonical specification** of the expansion. It is pure string
306
+ * work over an already-decrypted variable set, so it runs wherever plaintext
307
+ * legitimately exists — the CLI, the browser, the language SDKs, and the Rust
308
+ * clients (`crates/seekrit-core/src/interpolate.rs` mirrors it, pinned by the
309
+ * shared golden fixture in `apps/run/testdata/vectors.json`).
310
+ *
311
+ * Expansion happens at **read time**, on the client, never on write and never
312
+ * on the server: the API only ever holds the ciphertext of the literal
313
+ * `${OTHER_SECRET}` text, so referencing costs nothing against the
314
+ * zero-knowledge invariant. It also means a reference stays live — rotating
315
+ * `DB_PASSWORD` updates every value that references it, with no re-encryption.
316
+ *
317
+ * The rules, in full:
318
+ *
319
+ * - `${NAME}` is replaced with the value of `NAME` in the *same fully-merged
320
+ * set* — after group → app-env → `.env` layering, so a reference always sees
321
+ * the value that layer precedence actually selected.
322
+ * - `NAME` must be a valid secret name (`[A-Za-z_][A-Za-z0-9_]*`, matching
323
+ * `secretNameSchema`). Anything else — `${1}`, `${FOO:-bar}`, `${a.b}` — is
324
+ * left exactly as written, so shell and CI template syntax passes through
325
+ * untouched.
326
+ * - A reference to a name that is not in the set is **left literal** and
327
+ * reported in {@link InterpolationResult.unresolved}. Erroring would mean a
328
+ * stored value that happens to contain `${GITHUB_SHA}` could break a whole
329
+ * environment's resolve; leaving it alone is the safe default, and the report
330
+ * is there to surface typos (`seekrit run --explain` prints it).
331
+ * - Expansion is recursive: a referenced value may itself contain references.
332
+ * - `$${NAME}` is an escape producing the literal text `${NAME}`. A `$$` not
333
+ * followed by `{` is ordinary text (passwords full of `$` are safe).
334
+ * - A reference **cycle** throws {@link InterpolationError}. Unlike an unknown
335
+ * name, a cycle can only be a configuration mistake — every name in it
336
+ * exists — and there is no value that could be correct to emit.
337
+ *
338
+ * `process.env` is deliberately *not* a reference source: `seekrit run` layers
339
+ * the live shell on top of the resolved set afterwards, and letting a stored
340
+ * secret pull in arbitrary host environment variables would be a surprising
341
+ * (and machine-dependent) way to change a secret's value.
342
+ */
343
+ /** A reference name — the same grammar as `secretNameSchema`. */
344
+ const REFERENCE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
345
+ /**
346
+ * Cap on a single expanded value. Nested references can multiply length
347
+ * (`A=${B}${B}`, `B=${C}${C}`, …), which memoization makes fast but does not
348
+ * make small. A megabyte is far above any real secret and far below anything
349
+ * that would exhaust a container.
350
+ */
351
+ const MAX_EXPANDED_LENGTH = 1048576;
352
+ /**
353
+ * Split a value into literal runs and references — the single tokenizer every
354
+ * rule above is expressed in terms of, so expansion and inspection can never
355
+ * disagree about what counts as a reference.
356
+ */
357
+ function* scan(text) {
358
+ let i = 0;
359
+ while (i < text.length) {
360
+ const dollar = text.indexOf("$", i);
361
+ if (dollar === -1) {
362
+ yield { literal: text.slice(i) };
363
+ return;
364
+ }
365
+ if (dollar > i) yield { literal: text.slice(i, dollar) };
366
+ if (text[dollar + 1] === "$" && text[dollar + 2] === "{") {
367
+ yield { literal: "${" };
368
+ i = dollar + 3;
369
+ continue;
370
+ }
371
+ const close = text[dollar + 1] === "{" ? text.indexOf("}", dollar + 2) : -1;
372
+ const reference = close === -1 ? null : text.slice(dollar + 2, close);
373
+ if (reference !== null && REFERENCE_NAME.test(reference)) {
374
+ yield {
375
+ reference,
376
+ raw: text.slice(dollar, close + 1)
377
+ };
378
+ i = close + 1;
379
+ continue;
380
+ }
381
+ yield { literal: "$" };
382
+ i = dollar + 1;
383
+ }
384
+ }
385
+ var InterpolationError = class extends Error {
386
+ code;
387
+ /**
388
+ * For `cycle`, the reference chain that closed on itself, starting and ending
389
+ * on the same name (`["A", "B", "A"]`). For `too_large`, the single name
390
+ * whose expansion blew the cap.
391
+ */
392
+ chain;
393
+ constructor(code, message, chain) {
394
+ super(message);
395
+ this.name = "InterpolationError";
396
+ this.code = code;
397
+ this.chain = chain;
398
+ }
399
+ };
400
+ /**
401
+ * Expand `${NAME}` references throughout a decrypted variable set.
402
+ *
403
+ * Pure: the input is never mutated. Throws {@link InterpolationError} on a
404
+ * reference cycle (see the module comment for the complete rule set).
405
+ */
406
+ function interpolateSecrets(values) {
407
+ const resolved = /* @__PURE__ */ new Map();
408
+ const unresolved = /* @__PURE__ */ new Set();
409
+ const expanded = [];
410
+ /** The chain currently being expanded — the cycle detector. */
411
+ const stack = [];
412
+ /** Expand one present name's value, memoized so each is expanded once. */
413
+ function resolve(name) {
414
+ const cached = resolved.get(name);
415
+ if (cached !== void 0) return cached;
416
+ const cycleAt = stack.indexOf(name);
417
+ if (cycleAt !== -1) {
418
+ const chain = [...stack.slice(cycleAt), name];
419
+ throw new InterpolationError("cycle", `secret reference cycle: ${chain.join(" → ")}`, chain);
420
+ }
421
+ stack.push(name);
422
+ let out = "";
423
+ for (const segment of scan(values[name])) if ("literal" in segment) out += segment.literal;
424
+ else if (Object.hasOwn(values, segment.reference)) out += resolve(segment.reference);
425
+ else {
426
+ unresolved.add(segment.reference);
427
+ out += segment.raw;
428
+ }
429
+ stack.pop();
430
+ if (out.length > MAX_EXPANDED_LENGTH) throw new InterpolationError("too_large", `${name} expands to more than ${MAX_EXPANDED_LENGTH} bytes — check its references`, [name]);
431
+ resolved.set(name, out);
432
+ return out;
433
+ }
434
+ const result = {};
435
+ for (const name of Object.keys(values)) {
436
+ const value = resolve(name);
437
+ result[name] = value;
438
+ if (value !== values[name]) expanded.push(name);
439
+ }
440
+ return {
441
+ values: result,
442
+ expanded,
443
+ unresolved: [...unresolved].sort()
444
+ };
445
+ }
446
+ z.enum([
447
+ "postgres",
448
+ "mysql",
449
+ "ssh",
450
+ "redis",
451
+ "aws",
452
+ "gcp",
453
+ "mongodb"
454
+ ]);
455
+ const executorModeSchema = z.enum(["in_do", "remote"]);
456
+ /**
457
+ * A Postgres role name we are willing to create. Deliberately strict — this
458
+ * value is interpolated into a SQL template, so it must be a bare identifier
459
+ * with no way to break out of quoting (no quotes, whitespace, or semicolons).
460
+ */
461
+ 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");
462
+ /**
463
+ * A SCRAM-SHA-256 verifier string as produced by @seekrit/crypto. Validated so
464
+ * it, too, is safe to interpolate into a quoted SQL literal (the alphabet is
465
+ * base64 + the fixed structural characters, none of which is a single quote).
466
+ */
467
+ 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");
468
+ /**
469
+ * An SSH login principal (a Unix-style username the certificate authorizes).
470
+ * Bounded and restricted to a safe charset — principals are SSH-wire-encoded,
471
+ * not shell-interpolated, so this is sanity/DoS hardening, not an injection gate.
472
+ */
473
+ const sshPrincipalSchema = z.string().regex(/^[A-Za-z0-9._-]{1,64}$/, "must be 1–64 chars of letters, digits, dot, dash, underscore");
474
+ /** An `ssh-ed25519 <base64> [comment]` public key line (deep-validated on sign). */
475
+ const sshPublicKeySchema = z.string().max(2048).regex(/^ssh-ed25519 [A-Za-z0-9+/=]+( .*)?$/, "must be an ssh-ed25519 public key");
476
+ /** An SSH certificate extension name, e.g. `permit-pty`. */
477
+ const sshExtensionSchema = z.string().regex(/^[a-z0-9-]{1,64}$/);
478
+ /**
479
+ * A MySQL/MariaDB user name we are willing to create. Interpolated into a
480
+ * quoted SQL literal (`'{{name}}'@'%'`), so it is kept strict — plain
481
+ * alphanumerics/underscore, no quotes/whitespace/semicolons to break out.
482
+ */
483
+ const mysqlUserNameSchema = z.string().regex(/^[A-Za-z0-9_]{3,32}$/, "must be 3–32 chars, letters/digits/underscore");
484
+ /**
485
+ * A `mysql_native_password` authentication string — `*` followed by 40 upper
486
+ * hex chars (`UPPER(HEX(SHA1(SHA1(password))))`), as produced by
487
+ * @seekrit/crypto `mysqlNativePasswordVerifier`. Stored verbatim by
488
+ * `CREATE USER … IDENTIFIED WITH mysql_native_password AS '<str>'`, and its
489
+ * alphabet contains no single quote, so it is safe in a quoted SQL literal.
490
+ */
491
+ const mysqlNativeVerifierSchema = z.string().regex(/^\*[0-9A-F]{40}$/, "must be a mysql_native_password hash (*<40 hex>)");
492
+ /**
493
+ * A Redis ACL user name we are willing to create. Interpolated into a Redis
494
+ * command line as a bare token (`ACL SETUSER <name> …`), so it is kept strict —
495
+ * plain alphanumerics/underscore, no whitespace to split the arg or ACL rule
496
+ * characters (`~ + @ # & %`) that could be read as a permission.
497
+ */
498
+ const redisUserNameSchema = z.string().regex(/^[A-Za-z0-9_]{3,32}$/, "must be 3–32 chars, letters/digits/underscore");
499
+ /**
500
+ * A Redis password verifier — the lowercase-hex SHA-256 of the password, as
501
+ * produced by @seekrit/crypto `redisSha256Verifier`. `ACL SETUSER … on #<hex>`
502
+ * stores this digest verbatim, and it cannot authenticate: Redis `AUTH` hashes
503
+ * the *plaintext* it receives with SHA-256 and compares, so the stored digest
504
+ * is preimage-resistant (the password is high-entropy and machine-generated).
505
+ * The alphabet is bare hex, so it is a safe bare command token.
506
+ */
507
+ const redisSha256VerifierSchema = z.string().regex(/^[0-9a-f]{64}$/, "must be a lowercase-hex SHA-256 digest (64 chars)");
508
+ /**
509
+ * An IAM role ARN the broker is allowed to assume. Bounded and structurally
510
+ * validated: `arn:<partition>:iam::<account>:role/<path-and-name>`. Partition
511
+ * covers commercial (`aws`), GovCloud (`aws-us-gov`), and China (`aws-cn`).
512
+ */
513
+ 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>)");
514
+ /** An AWS region id, e.g. `us-east-1`, `eu-west-2`, `us-gov-west-1`. */
515
+ const awsRegionSchema = z.string().regex(/^[a-z]{2}(?:-[a-z]+)+-\d$/, "must be an AWS region id (e.g. us-east-1)");
516
+ /**
517
+ * An STS external id — the shared string a role's trust policy can require so a
518
+ * confused-deputy can't assume it. AWS allows a broad charset; we keep to the
519
+ * documented safe set and bound the length.
520
+ */
521
+ const awsExternalIdSchema = z.string().regex(/^[\w+=,.@:/-]{2,1224}$/, "must be a valid STS external id");
522
+ z.string().regex(/^[\w+=,.@-]{2,64}$/, "must be 2–64 chars of [A-Za-z0-9_+=,.@-]");
523
+ /**
524
+ * A GCP service-account email the broker is allowed to impersonate (or that
525
+ * appears in a delegation chain). Structurally validated and bounded: it is
526
+ * interpolated into the IAM Credentials API URL path, so the charset excludes
527
+ * anything that could break out of a path segment. Covers user-managed
528
+ * (`name@<project>.iam.gserviceaccount.com`) and Google-managed
529
+ * (`<project-number>-compute@developer.gserviceaccount.com`) forms.
530
+ */
531
+ const gcpServiceAccountEmailSchema = z.string().max(256).regex(/^[a-z0-9-]+@[a-z0-9.-]+\.gserviceaccount\.com$/, "must be a service-account email (…@….gserviceaccount.com)");
532
+ /**
533
+ * An OAuth 2.0 scope granted to the minted access token, e.g.
534
+ * `https://www.googleapis.com/auth/cloud-platform`. Bounded and whitespace-free
535
+ * (scopes are space-delimited); passed to the IAM Credentials API in a JSON body
536
+ * array, not a URL, so this is sanity/DoS hardening rather than an injection gate.
537
+ */
538
+ const gcpOauthScopeSchema = z.string().min(1).max(256).regex(/^\S+$/, "must be a single OAuth scope with no whitespace");
539
+ /**
540
+ * The consumer's ephemeral P-256 public key (JWK-serialized) that a tier-2
541
+ * credential is wrapped to before it is returned. Validated structurally here;
542
+ * the executor imports it defensively before wrapping. Bounded so a giant blob
543
+ * can't be pushed through the control plane.
544
+ */
545
+ const p256PublicKeyJwkSchema = z.string().max(2048).refine((s) => {
546
+ try {
547
+ const jwk = JSON.parse(s);
548
+ return jwk.kty === "EC" && jwk.crv === "P-256" && !!jwk.x && !!jwk.y;
549
+ } catch {
550
+ return false;
551
+ }
552
+ }, "must be a JWK-serialized P-256 public key");
553
+ z.string().regex(/^[A-Za-z0-9_-]{1,64}$/, "must be 1–64 chars of [A-Za-z0-9_-]");
554
+ /**
555
+ * A MongoDB database name — the db a preset role is granted on, or the
556
+ * authentication database a leased user is created in. MongoDB forbids
557
+ * `/\. "$*<>:|?` and the empty string in db names; we keep to a safe subset.
558
+ */
559
+ const mongoDatabaseNameSchema = z.string().regex(/^[A-Za-z0-9_-]{1,63}$/, "must be 1–63 chars of [A-Za-z0-9_-]");
560
+ /**
561
+ * A single MongoDB role grant `{ role, db }` for a `custom` target — e.g.
562
+ * `{ role: "readWrite", db: "app" }` or a user-defined role. Admin-supplied
563
+ * trusted input, set once at registration; still bounded structurally.
564
+ */
565
+ const mongoRoleSchema = z.object({
566
+ role: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/, "must be a role name"),
567
+ db: mongoDatabaseNameSchema
568
+ });
569
+ const postgresAccessLevelSchema = z.enum([
570
+ "readonly",
571
+ "readwrite",
572
+ "custom"
573
+ ]);
574
+ const mysqlAccessLevelSchema = z.enum([
575
+ "readonly",
576
+ "readwrite",
577
+ "custom"
578
+ ]);
579
+ const redisAccessLevelSchema = z.enum([
580
+ "readonly",
581
+ "readwrite",
582
+ "custom"
583
+ ]);
584
+ const mongoAccessLevelSchema = z.enum([
585
+ "readonly",
586
+ "readwrite",
587
+ "custom"
588
+ ]);
589
+ const connectionSchema = z.object({
590
+ host: z.string().min(1),
591
+ port: z.number().int().min(1).max(65535),
592
+ database: z.string().min(1)
593
+ });
594
+ /** A `{{name}}`/`{{verifier}}`/`{{valid_until}}` templated SQL statement. */
595
+ const statementSchema = z.string().min(1).max(4e3);
596
+ /** A bare SQL identifier (schema name) — no quotes/whitespace/semicolons. */
597
+ const identifierSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,62}$/, "must be an identifier");
598
+ const postgresTargetConfigSchema = z.object({
599
+ provider: z.literal("postgres"),
600
+ executor: executorModeSchema,
601
+ accessLevel: postgresAccessLevelSchema.optional(),
602
+ schema: identifierSchema.optional(),
603
+ connection: connectionSchema,
604
+ provisionerUrl: z.url().optional(),
605
+ createStatements: z.array(statementSchema).max(16).optional(),
606
+ revokeStatements: z.array(statementSchema).max(16).optional()
607
+ });
608
+ /** A MySQL account host part (`'name'@'<host>'`) — no quotes/whitespace. */
609
+ const mysqlHostSchema = z.string().regex(/^[A-Za-z0-9_.%:-]{1,255}$/, "must be a host pattern");
610
+ const mysqlTargetConfigSchema = z.object({
611
+ provider: z.literal("mysql"),
612
+ executor: executorModeSchema,
613
+ accessLevel: mysqlAccessLevelSchema.optional(),
614
+ connection: connectionSchema,
615
+ userHost: mysqlHostSchema.optional(),
616
+ provisionerUrl: z.url().optional(),
617
+ createStatements: z.array(statementSchema).max(16).optional(),
618
+ revokeStatements: z.array(statementSchema).max(16).optional()
619
+ });
620
+ const redisConnectionSchema = z.object({
621
+ host: z.string().min(1),
622
+ port: z.number().int().min(1).max(65535),
623
+ /** Redis logical database index (the `/<n>` in a connection URL). */
624
+ db: z.number().int().min(0).max(15).optional()
625
+ });
626
+ const redisTargetConfigSchema = z.object({
627
+ provider: z.literal("redis"),
628
+ executor: executorModeSchema,
629
+ accessLevel: redisAccessLevelSchema.optional(),
630
+ connection: redisConnectionSchema,
631
+ provisionerUrl: z.url().optional(),
632
+ createStatements: z.array(statementSchema).max(16).optional(),
633
+ revokeStatements: z.array(statementSchema).max(16).optional()
634
+ });
635
+ const sshTargetConfigSchema = z.object({
636
+ provider: z.literal("ssh"),
637
+ executor: z.literal("in_do"),
638
+ caPublicKey: sshPublicKeySchema,
639
+ allowedPrincipals: z.array(sshPrincipalSchema).max(64).optional(),
640
+ extensions: z.array(sshExtensionSchema).max(16).optional(),
641
+ maxTtlSeconds: z.number().int().min(60).max(3600 * 24 * 7).optional(),
642
+ connection: z.object({
643
+ host: z.string().min(1).optional(),
644
+ user: sshPrincipalSchema.optional()
645
+ }).optional()
646
+ });
647
+ const AWS_MAX_TTL_SECONDS = 3600 * 12;
648
+ const awsTargetConfigSchema = z.object({
649
+ provider: z.literal("aws"),
650
+ executor: z.literal("in_do"),
651
+ roleArn: awsRoleArnSchema,
652
+ region: awsRegionSchema,
653
+ externalId: awsExternalIdSchema.optional(),
654
+ sessionPolicy: z.string().min(1).max(4e3).optional(),
655
+ maxTtlSeconds: z.number().int().min(900).max(AWS_MAX_TTL_SECONDS).optional()
656
+ });
657
+ const GCP_MAX_TTL_SECONDS = 3600 * 12;
658
+ const gcpTargetConfigSchema = z.object({
659
+ provider: z.literal("gcp"),
660
+ executor: z.literal("in_do"),
661
+ serviceAccount: gcpServiceAccountEmailSchema,
662
+ scopes: z.array(gcpOauthScopeSchema).min(1).max(32).optional(),
663
+ delegates: z.array(gcpServiceAccountEmailSchema).max(8).optional(),
664
+ maxTtlSeconds: z.number().int().min(60).max(GCP_MAX_TTL_SECONDS).optional()
665
+ });
666
+ const mongoTargetConfigSchema = z.object({
667
+ provider: z.literal("mongodb"),
668
+ executor: z.literal("in_do"),
669
+ accessLevel: mongoAccessLevelSchema.optional(),
670
+ connection: connectionSchema,
671
+ authSource: mongoDatabaseNameSchema.optional(),
672
+ roles: z.array(mongoRoleSchema).min(1).max(32).optional(),
673
+ tls: z.boolean().optional(),
674
+ maxTtlSeconds: z.number().int().min(60).max(3600 * 24 * 7).optional()
675
+ });
676
+ const leaseTargetConfigSchema = z.discriminatedUnion("provider", [
677
+ postgresTargetConfigSchema,
678
+ mysqlTargetConfigSchema,
679
+ redisTargetConfigSchema,
680
+ sshTargetConfigSchema,
681
+ awsTargetConfigSchema,
682
+ gcpTargetConfigSchema,
683
+ mongoTargetConfigSchema
684
+ ]);
685
+ z.object({
686
+ name: z.string().trim().min(1).max(128),
687
+ config: leaseTargetConfigSchema,
688
+ /**
689
+ * The admin/provisioning credential (e.g. a Postgres connection string),
690
+ * encrypted client-side to the broker's public key (a `wd1.` wrap). The
691
+ * control plane stores only this ciphertext — it never sees the plaintext.
692
+ */
693
+ wrappedAdminSecret: z.string().min(1)
694
+ });
695
+ /** Requested lease lifetime, shared by all providers. */
696
+ const ttlSecondsSchema = z.number().int().min(60).max(3600 * 24 * 7);
697
+ /**
698
+ * Client → API: mint a Postgres lease. The client generates the password and
699
+ * its SCRAM verifier locally and sends only the verifier — the plaintext
700
+ * password never leaves the requesting machine.
701
+ */
702
+ const mintPostgresLeaseSchema = z.object({
703
+ provider: z.literal("postgres"),
704
+ targetId: z.string().min(1),
705
+ roleName: postgresRoleNameSchema,
706
+ verifier: scramVerifierSchema,
707
+ ttlSeconds: ttlSecondsSchema
708
+ });
709
+ /**
710
+ * Client → API: mint a MySQL/MariaDB lease. The client generates the password
711
+ * and its `mysql_native_password` hash locally and sends only the hash — the
712
+ * plaintext password never leaves the requesting machine.
713
+ */
714
+ const mintMysqlLeaseSchema = z.object({
715
+ provider: z.literal("mysql"),
716
+ targetId: z.string().min(1),
717
+ roleName: mysqlUserNameSchema,
718
+ verifier: mysqlNativeVerifierSchema,
719
+ ttlSeconds: ttlSecondsSchema
720
+ });
721
+ /**
722
+ * Client → API: mint a Redis lease. The client generates the password and its
723
+ * SHA-256 hex digest locally and sends only the digest — the plaintext password
724
+ * never leaves the requesting machine.
725
+ */
726
+ const mintRedisLeaseSchema = z.object({
727
+ provider: z.literal("redis"),
728
+ targetId: z.string().min(1),
729
+ roleName: redisUserNameSchema,
730
+ verifier: redisSha256VerifierSchema,
731
+ ttlSeconds: ttlSecondsSchema
732
+ });
733
+ /**
734
+ * Client → API: mint an SSH lease. The client generates an ephemeral keypair
735
+ * locally and sends only the public key; the signed certificate comes back in
736
+ * the response. The private key never leaves the requesting machine.
737
+ */
738
+ const mintSshLeaseSchema = z.object({
739
+ provider: z.literal("ssh"),
740
+ targetId: z.string().min(1),
741
+ publicKey: sshPublicKeySchema,
742
+ principals: z.array(sshPrincipalSchema).min(1).max(32),
743
+ ttlSeconds: ttlSecondsSchema
744
+ });
745
+ /**
746
+ * Client → API: mint an AWS lease. The client generates an ephemeral P-256
747
+ * keypair locally and sends only the public key; STS mints the credential and
748
+ * the broker returns it wrapped to that key. The private key never leaves the
749
+ * requesting machine, so the plaintext credential is only decryptable there.
750
+ *
751
+ * TTL bounds are STS's own `DurationSeconds` limits (15 min – 12 h), not the
752
+ * generic lease bounds — STS rejects anything below 900 seconds.
753
+ */
754
+ const mintAwsLeaseSchema = z.object({
755
+ provider: z.literal("aws"),
756
+ targetId: z.string().min(1),
757
+ recipientPublicKey: p256PublicKeyJwkSchema,
758
+ ttlSeconds: z.number().int().min(900).max(AWS_MAX_TTL_SECONDS)
759
+ });
760
+ /**
761
+ * Client → API: mint a GCP lease. Like AWS (tier 2): the client generates an
762
+ * ephemeral P-256 keypair locally and sends only the public key; the IAM
763
+ * Credentials API mints the access token and the broker returns it wrapped to
764
+ * that key. The private key never leaves the requesting machine, so the plaintext
765
+ * token is only decryptable there.
766
+ *
767
+ * TTL bounds are GCP's `generateAccessToken` limits (1 min – 12 h); tokens over
768
+ * 1 h require the credential-lifetime-extension org policy.
769
+ */
770
+ const mintGcpLeaseSchema = z.object({
771
+ provider: z.literal("gcp"),
772
+ targetId: z.string().min(1),
773
+ recipientPublicKey: p256PublicKeyJwkSchema,
774
+ ttlSeconds: z.number().int().min(60).max(GCP_MAX_TTL_SECONDS)
775
+ });
776
+ /**
777
+ * Client → API: mint a MongoDB lease. Like AWS (tier 2), the client generates
778
+ * an ephemeral P-256 keypair locally and sends only the public key; the broker
779
+ * generates the password, runs `createUser`, and returns the credential wrapped
780
+ * to that key. The private key never leaves the requesting machine, so the
781
+ * plaintext credential is only decryptable there.
782
+ */
783
+ const mintMongoLeaseSchema = z.object({
784
+ provider: z.literal("mongodb"),
785
+ targetId: z.string().min(1),
786
+ recipientPublicKey: p256PublicKeyJwkSchema,
787
+ ttlSeconds: ttlSecondsSchema
788
+ });
789
+ z.discriminatedUnion("provider", [
790
+ mintPostgresLeaseSchema,
791
+ mintMysqlLeaseSchema,
792
+ mintRedisLeaseSchema,
793
+ mintSshLeaseSchema,
794
+ mintAwsLeaseSchema,
795
+ mintGcpLeaseSchema,
796
+ mintMongoLeaseSchema
797
+ ]);
798
+ //#endregion
799
+ //#region ../../packages/core/src/types.ts
800
+ /**
801
+ * Transactional notification emails seekrit can send. Each id is one
802
+ * user-facing on/off toggle (see `NOTIFICATION_TYPE_META`). These carry only
803
+ * audit-grade metadata — never secret material — and every one is opt-out
804
+ * (defaults on). Kept as a const array so the API, api-client, and dashboard
805
+ * share a single source of truth (mirrors `AUDIT_ACTIONS`).
806
+ */
807
+ const NOTIFICATION_TYPES = [
808
+ "token_created",
809
+ "token_revoked",
810
+ "env_access_granted",
811
+ "env_access_revoked",
812
+ "resolve_denied",
813
+ "org_welcome",
814
+ "token_expiring",
815
+ "lease_expired",
816
+ "sync_failed"
817
+ ];
818
+ //#endregion
819
+ //#region ../../packages/core/src/schemas.ts
820
+ /** URL-safe identifier segment: `my-app`, `production`, … */
821
+ const slugSchema = z.string().min(1).max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "must be lowercase alphanumeric with hyphens");
822
+ const nameSchema = z.string().trim().min(1).max(128);
823
+ /** An email address, normalized to trimmed lowercase before validation. */
824
+ const emailSchema = z.string().trim().toLowerCase().pipe(z.email().max(320));
825
+ /** Env-var style secret name: FOO, DATABASE_URL, apiKey2 … */
826
+ const secretNameSchema = z.string().min(1).max(256).regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "must be a valid environment variable name");
827
+ z.enum([
828
+ "owner",
829
+ "admin",
830
+ "member"
831
+ ]);
832
+ /** Role a person may be invited at — never `owner` (ownership isn't invitable). */
833
+ const inviteRoleSchema = z.enum(["admin", "member"]);
834
+ const principalTypeSchema = z.enum(["user", "service_token"]);
835
+ /** Org-level capability a service token can hold (never `owner`). */
836
+ const serviceTokenRoleSchema = z.enum(["admin", "member"]);
837
+ z.object({
838
+ name: nameSchema,
839
+ slug: slugSchema
840
+ });
841
+ z.object({
842
+ name: nameSchema,
843
+ slug: slugSchema
844
+ });
845
+ z.object({ name: nameSchema });
846
+ z.object({ name: nameSchema });
847
+ z.object({ name: nameSchema });
848
+ z.object({ required: z.boolean() });
849
+ z.object({
850
+ email: emailSchema,
851
+ role: inviteRoleSchema.default("member")
852
+ });
853
+ z.object({
854
+ name: nameSchema,
855
+ slug: slugSchema
856
+ });
857
+ z.object({
858
+ groupId: z.string().min(1),
859
+ /** Precedence among an env's groups (higher wins). Appended if omitted. */
860
+ position: z.number().int().min(0).optional()
861
+ });
862
+ z.object({
863
+ name: nameSchema,
864
+ slug: slugSchema,
865
+ /** Environment DEK wrapped to the creator's public key — created client-side. */
866
+ wrappedDek: z.string().min(1),
867
+ /**
868
+ * When the org has recovery enabled, the same DEK additionally wrapped to the
869
+ * org recovery public key, so the environment is recovery-protected from
870
+ * creation. Omitted when recovery is off (backfilled later by `recovery sync`).
871
+ */
872
+ recoveryWrappedDek: z.string().min(1).nullish()
873
+ });
874
+ z.object({
875
+ /** Opaque versioned ciphertext blob from @seekrit/crypto. */
876
+ ciphertext: z.string().min(1).max(65536) });
877
+ z.object({ version: z.number().int().positive() });
878
+ z.object({ limit: z.coerce.number().int().min(1).max(200).default(50) });
879
+ z.object({
880
+ publicKeyJwk: z.string().min(1),
881
+ /**
882
+ * Private key encrypted with a passphrase-derived KEK; opaque to the
883
+ * server. Self-contained blob (embeds KDF salt + iterations).
884
+ */
885
+ encryptedPrivateKey: z.string().min(1)
886
+ });
887
+ const grantEnvironmentKeySchema = z.object({
888
+ principalType: principalTypeSchema,
889
+ principalId: z.string().min(1),
890
+ wrappedDek: z.string().min(1)
891
+ });
892
+ z.object({
893
+ slug: slugSchema,
894
+ /** Display name; defaults to the slug. */
895
+ name: nameSchema.optional(),
896
+ ttlSeconds: z.number().int().min(60).max(MAX_BRANCH_TTL_SECONDS).nullish(),
897
+ /** The branch's own DEK, wrapped to the creator — generated client-side. */
898
+ wrappedDek: z.string().min(1),
899
+ recoveryWrappedDek: z.string().min(1).nullish(),
900
+ /** The same DEK wrapped to each of the parent's existing grant-holders. */
901
+ grants: z.array(grantEnvironmentKeySchema).max(500).default([])
902
+ });
903
+ z.object({
904
+ name: nameSchema,
905
+ tokenId: z.string().regex(/^skt_[0-9A-Za-z]+$/),
906
+ /** SHA-256 hash (base64url) of the full token string. */
907
+ tokenHash: z.string().min(1),
908
+ publicKeyJwk: z.string().min(1),
909
+ /**
910
+ * Org-level capability. Defaults to `member` (a runtime credential); pass
911
+ * `admin` to mint a headless provisioning token. Only an admin caller may
912
+ * create an `admin` token, so capability cannot escalate itself.
913
+ */
914
+ role: serviceTokenRoleSchema.default("member"),
915
+ /**
916
+ * The application environment this token is bound to (org + app + env).
917
+ * Optional so org-admin tokens can exist, but required for runtime tokens
918
+ * that resolve secrets via `GET /v1/resolve`.
919
+ */
920
+ environmentId: z.string().min(1).nullish(),
921
+ expiresAt: z.iso.datetime().nullish()
922
+ });
923
+ const kmsKeyPurposeSchema = z.enum(["encrypt", "sign"]);
924
+ const kmsKeySpecSchema = z.enum(["aes-256-gcm", "ecdsa-p256"]);
925
+ /** A wrapped key grant supplied by the client (server never sees plaintext material). */
926
+ const kmsGrantInputSchema = z.object({
927
+ principalType: principalTypeSchema,
928
+ principalId: z.string().min(1),
929
+ /** Key material wrapped to the principal's public key (`wd1.` blob). */
930
+ wrappedKey: z.string().min(1)
931
+ });
932
+ z.object({
933
+ name: nameSchema,
934
+ purpose: kmsKeyPurposeSchema,
935
+ spec: kmsKeySpecSchema,
936
+ applicationId: z.string().min(1).nullish(),
937
+ groupId: z.string().min(1).nullish(),
938
+ /** ECDSA P-256 public key (JWK) for `sign` keys; omit for `encrypt` keys. */
939
+ publicKeyJwk: z.string().min(1).nullish(),
940
+ grants: z.array(kmsGrantInputSchema).min(1)
941
+ }).refine((v) => !(v.applicationId && v.groupId), {
942
+ message: "a key may be scoped to an application or a group, not both",
943
+ path: ["groupId"]
944
+ }).refine((v) => v.purpose === "sign" === (v.spec === "ecdsa-p256"), {
945
+ message: "sign keys require spec ecdsa-p256; encrypt keys require aes-256-gcm",
946
+ path: ["spec"]
947
+ }).refine((v) => v.purpose === "sign" === (v.publicKeyJwk != null), {
948
+ message: "sign keys require a publicKeyJwk; encrypt keys must omit it",
949
+ path: ["publicKeyJwk"]
950
+ });
951
+ z.object({
952
+ principalType: principalTypeSchema,
953
+ principalId: z.string().min(1),
954
+ /** Current-version key material wrapped to the principal's public key. */
955
+ wrappedKey: z.string().min(1)
956
+ });
957
+ z.object({
958
+ publicKeyJwk: z.string().min(1).nullish(),
959
+ grants: z.array(kmsGrantInputSchema).min(1)
960
+ });
961
+ /**
962
+ * One custodian's wrapped Shamir share of the org recovery private key. The
963
+ * client generates the recovery keypair, splits the private half M-of-N, and
964
+ * wraps each share to a custodian's public key — the server stores only the
965
+ * opaque `wrappedShare` and can reconstruct nothing.
966
+ */
967
+ const recoveryShareInputSchema = z.object({
968
+ principalType: principalTypeSchema,
969
+ principalId: z.string().min(1),
970
+ /** Shamir x-coordinate carried by the share (1..255). */
971
+ shareIndex: z.number().int().min(1).max(255),
972
+ /** The recovery-key share wrapped to the custodian's public key (`wd1.`). */
973
+ wrappedShare: z.string().min(1)
974
+ });
975
+ /** An environment DEK additionally wrapped to the org recovery public key. */
976
+ const recoveryEnvGrantSchema = z.object({
977
+ environmentId: z.string().min(1),
978
+ /** The environment's DEK wrapped to the recovery public key (`wd1.`). */
979
+ wrappedDek: z.string().min(1)
980
+ });
981
+ z.object({
982
+ recoveryPublicKeyJwk: z.string().min(1),
983
+ threshold: z.number().int().min(1).max(255),
984
+ shares: z.array(recoveryShareInputSchema).min(1).max(255),
985
+ grants: z.array(recoveryEnvGrantSchema).default([])
986
+ }).refine((v) => v.threshold <= v.shares.length, {
987
+ message: "threshold cannot exceed the number of custodians",
988
+ path: ["threshold"]
989
+ }).refine((v) => new Set(v.shares.map((s) => `${s.principalType}:${s.principalId}`)).size === v.shares.length, {
990
+ message: "custodians must be distinct",
991
+ path: ["shares"]
992
+ });
993
+ z.object({ grants: z.array(recoveryEnvGrantSchema).min(1).max(500) });
994
+ z.object({
995
+ targetPublicKeyJwk: z.string().min(1),
996
+ targetType: principalTypeSchema.nullish(),
997
+ targetId: z.string().min(1).nullish(),
998
+ reason: z.string().max(500).nullish()
999
+ });
1000
+ z.object({
1001
+ shareIndex: z.number().int().min(1).max(255),
1002
+ /** The custodian's share re-wrapped to the target public key (`wd1.`). */
1003
+ contributedShare: z.string().min(1)
1004
+ });
1005
+ z.object({
1006
+ principalType: principalTypeSchema,
1007
+ principalId: z.string().min(1),
1008
+ grants: z.array(recoveryEnvGrantSchema).min(1).max(500)
1009
+ });
1010
+ z.object({ prefs: z.partialRecord(z.enum(NOTIFICATION_TYPES), z.boolean()) });
1011
+ z.object({
1012
+ endpoint: z.url().max(2048),
1013
+ headers: z.record(z.string().min(1).max(256), z.string().max(4096)).optional(),
1014
+ enabled: z.boolean().default(true)
1015
+ });
1016
+ const planFamilySchema = z.enum(PLAN_FAMILY_IDS);
1017
+ const subscriptionStatusSchema = z.enum(SUBSCRIPTION_STATUSES);
1018
+ z.enum(ENTITLEMENT_KEYS);
1019
+ /** An entitlement value: boolean (features), number or null/unlimited (limits, metered). */
1020
+ const entitlementValueSchema = z.union([
1021
+ z.boolean(),
1022
+ z.number(),
1023
+ z.null()
1024
+ ]);
1025
+ z.object({
1026
+ family: planFamilySchema,
1027
+ version: z.number().int().positive().optional(),
1028
+ status: subscriptionStatusSchema.default("active")
1029
+ });
1030
+ z.object({
1031
+ value: entitlementValueSchema,
1032
+ note: z.string().max(500).nullish(),
1033
+ expiresAt: z.iso.datetime().nullish()
1034
+ });
1035
+ z.object({ family: planFamilySchema });
1036
+ z.object({
1037
+ sessionId: z.string().regex(/^skc_[0-9A-Za-z]+$/),
1038
+ /** SHA-256 hash (base64url) of the full session token string. */
1039
+ tokenHash: z.string().min(1).max(128),
1040
+ /** Display-only, e.g. `miles@studio.local`. */
1041
+ deviceLabel: z.string().trim().min(1).max(120),
1042
+ /** Display-only, e.g. `cli/0.4.2`. */
1043
+ client: z.string().trim().max(60).optional()
1044
+ });
1045
+ z.object({ code: z.string().trim().min(1).max(32) });
1046
+ z.object({
1047
+ cursor: z.string().optional(),
1048
+ limit: z.coerce.number().int().min(1).max(200).default(50),
1049
+ action: z.string().optional(),
1050
+ resourceType: z.string().optional()
1051
+ });
1052
+ z.enum(["vercel"]);
1053
+ /**
1054
+ * Vercel account scope. The API token itself is never here — it is wrapped to
1055
+ * the connection's public key and stored as ciphertext.
1056
+ *
1057
+ * `teamId` is required for tokens scoped to a Vercel Team; personal-account
1058
+ * tokens omit it. Vercel rejects team-owned project calls that lack it with a
1059
+ * bare 403, so we pass it through as `?teamId=` on every request.
1060
+ */
1061
+ const vercelConnectionConfigSchema = z.object({
1062
+ provider: z.literal("vercel"),
1063
+ /** Vercel Team id (`team_…`). Omit for a personal account. */
1064
+ teamId: z.string().trim().min(1).max(128).optional()
1065
+ });
1066
+ const syncConnectionConfigSchema = z.discriminatedUnion("provider", [vercelConnectionConfigSchema]);
1067
+ const vercelDestinationSchema = z.object({
1068
+ provider: z.literal("vercel"),
1069
+ /** Vercel project id (`prj_…`) or project name. */
1070
+ projectId: z.string().trim().min(1).max(128),
1071
+ /** Which deployment targets receive these values. At least one. */
1072
+ targets: z.array(z.enum([
1073
+ "production",
1074
+ "preview",
1075
+ "development"
1076
+ ])).min(1),
1077
+ /**
1078
+ * Restrict `preview` writes to one git branch. Vercel only honors this when
1079
+ * `targets` includes `preview`; ignored otherwise.
1080
+ */
1081
+ gitBranch: z.string().trim().min(1).max(255).optional()
1082
+ });
1083
+ const syncDestinationSchema = z.discriminatedUnion("provider", [vercelDestinationSchema]);
1084
+ /**
1085
+ * How seekrit secret names become destination key names. Applied in order:
1086
+ * explicit `rename` (wins outright), then `prefix`/`suffix`, then `case`.
1087
+ */
1088
+ const nameTransformSchema = z.object({
1089
+ prefix: z.string().max(64).regex(/^[A-Za-z0-9_]*$/, "must be alphanumeric or underscore").optional(),
1090
+ suffix: z.string().max(64).regex(/^[A-Za-z0-9_]*$/, "must be alphanumeric or underscore").optional(),
1091
+ case: z.enum([
1092
+ "preserve",
1093
+ "upper",
1094
+ "lower"
1095
+ ]).optional(),
1096
+ /** Exact per-secret overrides, seekrit name → destination name. */
1097
+ rename: z.record(secretNameSchema, secretNameSchema).optional()
1098
+ });
1099
+ z.object({
1100
+ /**
1101
+ * The id the client already fetched a public key for. Connections are a
1102
+ * two-step dance — mint the key, wrap the credential to it, then create the
1103
+ * row — so the id has to be chosen before the row exists. Omit it and the
1104
+ * server generates one (only useful when there is no credential to wrap yet).
1105
+ */
1106
+ id: z.string().regex(/^syc_[A-Za-z0-9]{24}$/, "must be a connection id from the public-key call").optional(),
1107
+ name: z.string().trim().min(1).max(128),
1108
+ config: syncConnectionConfigSchema,
1109
+ /**
1110
+ * The destination's API credential (a Vercel token), encrypted client-side to
1111
+ * the connection's public key (a `wd1.` wrap). The control plane stores only
1112
+ * this ciphertext; it is unwrapped transiently inside the sync engine DO.
1113
+ */
1114
+ wrappedCredential: z.string().min(1)
1115
+ });
1116
+ z.object({ destination: syncDestinationSchema });
1117
+ const globListSchema = z.array(z.string().trim().min(1).max(256)).max(100);
1118
+ z.object({
1119
+ connectionId: z.string().min(1),
1120
+ environmentId: z.string().min(1),
1121
+ destination: syncDestinationSchema,
1122
+ nameTransform: nameTransformSchema.optional(),
1123
+ include: globListSchema.optional(),
1124
+ exclude: globListSchema.optional(),
1125
+ onDelete: z.enum(["delete", "retain"]).default("delete"),
1126
+ mode: z.enum(["auto", "manual"]).default("auto"),
1127
+ wrappedDeks: z.array(z.object({
1128
+ environmentId: z.string().min(1),
1129
+ wrappedDek: z.string().min(1)
1130
+ })).min(1),
1131
+ acknowledgedDecryption: z.literal(true)
1132
+ });
1133
+ z.object({
1134
+ destination: syncDestinationSchema.optional(),
1135
+ nameTransform: nameTransformSchema.nullable().optional(),
1136
+ include: globListSchema.nullable().optional(),
1137
+ exclude: globListSchema.nullable().optional(),
1138
+ onDelete: z.enum(["delete", "retain"]).optional(),
1139
+ mode: z.enum(["auto", "manual"]).optional(),
1140
+ enabled: z.boolean().optional()
1141
+ });
1142
+ //#endregion
11
1143
  //#region ../../packages/crypto/src/encoding.ts
12
1144
  const CHUNK = 32768;
13
1145
  /** Base64url (no padding) — portable across browsers, Workers, and Node. */
@@ -619,7 +1751,7 @@ function signatureKeyRef(signature) {
619
1751
  const TOKEN_PREFIX = "skt";
620
1752
  const TOKEN_ID_LENGTH = 22;
621
1753
  const ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
622
- function randomTokenId() {
1754
+ function randomTokenId(prefix = TOKEN_PREFIX) {
623
1755
  let out = "";
624
1756
  while (out.length < TOKEN_ID_LENGTH) {
625
1757
  const bytes = crypto.getRandomValues(new Uint8Array(TOKEN_ID_LENGTH - out.length));
@@ -628,7 +1760,7 @@ function randomTokenId() {
628
1760
  if (out.length === TOKEN_ID_LENGTH) break;
629
1761
  }
630
1762
  }
631
- return `${TOKEN_PREFIX}_${out}`;
1763
+ return `${prefix}_${out}`;
632
1764
  }
633
1765
  async function hashToken(token) {
634
1766
  const digest = await crypto.subtle.digest("SHA-256", utf8Encode(token));
@@ -664,7 +1796,7 @@ function isServiceToken(value) {
664
1796
  }
665
1797
  //#endregion
666
1798
  //#region ../cli/package.json
667
- var version$1 = "0.24.0";
1799
+ var version$1 = "0.32.0";
668
1800
  const PROJECT_FILE = "seekrit.json";
669
1801
  function globalConfigPath() {
670
1802
  return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "seekrit", "config.json");
@@ -674,6 +1806,10 @@ function readGlobalConfig() {
674
1806
  if (!existsSync(path)) return {};
675
1807
  return JSON.parse(readFileSync(path, "utf8"));
676
1808
  }
1809
+ /**
1810
+ * Merge into the saved config. A key set to `undefined` is *removed* (JSON
1811
+ * drops it), which is how the login paths clear a credential they replace.
1812
+ */
677
1813
  function writeGlobalConfig(update) {
678
1814
  const path = globalConfigPath();
679
1815
  const merged = {
@@ -771,6 +1907,32 @@ var SeekritClient = class {
771
1907
  getMyNotificationPrefs() {
772
1908
  return this.request("GET", "/v1/me/notifications");
773
1909
  }
1910
+ /**
1911
+ * Devices this user has authorized. `currentSessionId` is set when the caller
1912
+ * *is* a CLI session, so it can label (or revoke) itself.
1913
+ */
1914
+ listCliSessions() {
1915
+ return this.request("GET", "/v1/me/cli-sessions");
1916
+ }
1917
+ /** Sign a device out. Its token stops authenticating immediately. */
1918
+ revokeCliSession(sessionId) {
1919
+ return this.request("DELETE", `/v1/me/cli-sessions/${sessionId}`);
1920
+ }
1921
+ /** What a pending login request is asking for — for the approval screen. */
1922
+ getCliLoginRequest(code) {
1923
+ return this.request("GET", `/v1/cli-login/${encodeURIComponent(code)}`);
1924
+ }
1925
+ /**
1926
+ * Authorize a device. Requires a browser session; members with a second
1927
+ * factor must have re-entered it just now, else this rejects with
1928
+ * `mfa_required` (recoverable — prompt for a code and retry).
1929
+ */
1930
+ approveCliLogin(code) {
1931
+ return this.request("POST", `/v1/cli-login/${encodeURIComponent(code)}/approve`);
1932
+ }
1933
+ denyCliLogin(code) {
1934
+ return this.request("POST", `/v1/cli-login/${encodeURIComponent(code)}/deny`);
1935
+ }
774
1936
  setMyNotificationPrefs(input) {
775
1937
  return this.request("PUT", "/v1/me/notifications", input);
776
1938
  }
@@ -838,6 +2000,28 @@ var SeekritClient = class {
838
2000
  deleteEnv(orgId, envId) {
839
2001
  return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}`);
840
2002
  }
2003
+ /**
2004
+ * The public keys of an environment's grant-holders, so a client can wrap a
2005
+ * new DEK to each of them (see `createBranch`). No key material is returned.
2006
+ */
2007
+ listGrantees(orgId, envId) {
2008
+ return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/grantees`);
2009
+ }
2010
+ listBranches(orgId, envId) {
2011
+ return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/branches`);
2012
+ }
2013
+ /** Every branch in an application, across all its environments. */
2014
+ listAppBranches(orgId, appId) {
2015
+ return this.request("GET", `/v1/orgs/${orgId}/apps/${appId}/branches`);
2016
+ }
2017
+ /** Fork `envId` into an ephemeral branch. `envId` is the parent, not the branch. */
2018
+ createBranch(orgId, envId, input) {
2019
+ return this.request("POST", `/v1/orgs/${orgId}/envs/${envId}/branches`, input);
2020
+ }
2021
+ /** Branches are environments, so tearing one down is `deleteEnv`. */
2022
+ deleteBranch(orgId, branchId) {
2023
+ return this.request("DELETE", `/v1/orgs/${orgId}/envs/${branchId}`);
2024
+ }
841
2025
  listGroups(orgId) {
842
2026
  return this.request("GET", `/v1/orgs/${orgId}/groups`);
843
2027
  }
@@ -873,6 +2057,7 @@ var SeekritClient = class {
873
2057
  resolve(query = {}) {
874
2058
  const params = new URLSearchParams();
875
2059
  if (query.env) params.set("env", query.env);
2060
+ if (query.branch) params.set("branch", query.branch);
876
2061
  for (const [group, slug] of Object.entries(query.with ?? {})) params.append("with", `${group}:${slug}`);
877
2062
  const qs = params.size > 0 ? `?${params}` : "";
878
2063
  return this.request("GET", `/v1/resolve${qs}`);
@@ -1025,6 +2210,54 @@ var SeekritClient = class {
1025
2210
  deleteLeaseTarget(orgId, targetId) {
1026
2211
  return this.request("DELETE", `/v1/orgs/${orgId}/leases/targets/${targetId}`);
1027
2212
  }
2213
+ listSyncConnections(orgId) {
2214
+ return this.request("GET", `/v1/orgs/${orgId}/sync/connections`);
2215
+ }
2216
+ /**
2217
+ * Mint (or re-read) the keypair for a connection id, *before* the connection
2218
+ * exists. Wrap the destination credential and every environment DEK to this
2219
+ * key, then pass the same id to {@link createSyncConnection}.
2220
+ */
2221
+ getSyncConnectionKey(orgId, connectionId) {
2222
+ return this.request("GET", `/v1/orgs/${orgId}/sync/connections/${connectionId}/public-key`);
2223
+ }
2224
+ createSyncConnection(orgId, input) {
2225
+ return this.request("POST", `/v1/orgs/${orgId}/sync/connections`, input);
2226
+ }
2227
+ /** Test the stored credential against a destination. Never throws on a bad token. */
2228
+ verifySyncConnection(orgId, connectionId, destination) {
2229
+ return this.request("POST", `/v1/orgs/${orgId}/sync/connections/${connectionId}/verify`, { destination });
2230
+ }
2231
+ /** Deletes the connection, its bindings, its key grants, and its keypair. */
2232
+ deleteSyncConnection(orgId, connectionId) {
2233
+ return this.request("DELETE", `/v1/orgs/${orgId}/sync/connections/${connectionId}`);
2234
+ }
2235
+ listSyncBindings(orgId) {
2236
+ return this.request("GET", `/v1/orgs/${orgId}/sync/bindings`);
2237
+ }
2238
+ /**
2239
+ * Enable sync for one environment. `wrappedDeks` must cover the target
2240
+ * environment *and* every group environment it composes, each wrapped to the
2241
+ * connection's public key — the API cannot compute these, which is what keeps
2242
+ * enabling sync a key-holder operation.
2243
+ */
2244
+ createSyncBinding(orgId, input) {
2245
+ return this.request("POST", `/v1/orgs/${orgId}/sync/bindings`, input);
2246
+ }
2247
+ updateSyncBinding(orgId, bindingId, input) {
2248
+ return this.request("PATCH", `/v1/orgs/${orgId}/sync/bindings/${bindingId}`, input);
2249
+ }
2250
+ deleteSyncBinding(orgId, bindingId) {
2251
+ return this.request("DELETE", `/v1/orgs/${orgId}/sync/bindings/${bindingId}`);
2252
+ }
2253
+ /** Push now, synchronously. */
2254
+ runSyncBinding(orgId, bindingId) {
2255
+ return this.request("POST", `/v1/orgs/${orgId}/sync/bindings/${bindingId}/run`);
2256
+ }
2257
+ listSyncRuns(orgId, bindingId) {
2258
+ const qs = bindingId ? `?bindingId=${encodeURIComponent(bindingId)}` : "";
2259
+ return this.request("GET", `/v1/orgs/${orgId}/sync/runs${qs}`);
2260
+ }
1028
2261
  listLeases(orgId) {
1029
2262
  return this.request("GET", `/v1/orgs/${orgId}/leases`);
1030
2263
  }
@@ -1145,7 +2378,7 @@ function tryBuildContext(dotenvVars = {}) {
1145
2378
  const config = readGlobalConfig();
1146
2379
  const fromEnv = (key) => process.env[key] ?? dotenvVars[key];
1147
2380
  const apiUrl = fromEnv("SEEKRIT_API_URL") ?? config.apiUrl ?? "https://api.seekrit.dev";
1148
- const token = fromEnv("SEEKRIT_TOKEN") ?? config.token;
2381
+ const token = fromEnv("SEEKRIT_TOKEN") ?? config.token ?? config.sessionToken;
1149
2382
  const devUser = fromEnv("SEEKRIT_DEV_USER") ?? config.devUser;
1150
2383
  let auth;
1151
2384
  if (token) auth = {
@@ -1163,7 +2396,8 @@ function tryBuildContext(dotenvVars = {}) {
1163
2396
  auth,
1164
2397
  client: CLI_CLIENT
1165
2398
  }),
1166
- auth
2399
+ auth,
2400
+ apiUrl
1167
2401
  };
1168
2402
  }
1169
2403
  function isTokenAuth(ctx) {
@@ -1210,12 +2444,14 @@ async function resolveOrg(ctx, orgSlug) {
1210
2444
  }
1211
2445
  /**
1212
2446
  * Resolve an environment to operate on — an application env (`--app --env`,
1213
- * or the config's app + `--env`) or a group env (`--group --env`).
2447
+ * or the config's app + `--env`), a branch of one (`--branch`), or a group env
2448
+ * (`--group --env`).
1214
2449
  */
1215
2450
  async function resolveEnvTarget(ctx, opts) {
1216
2451
  const org = await resolveOrg(ctx, opts.org);
1217
2452
  if (!opts.env) fail("specify --env");
1218
2453
  if (opts.group) {
2454
+ if (opts.branch) fail("--branch applies to application environments, not groups");
1219
2455
  const { groups } = await ctx.client.listGroups(org.id);
1220
2456
  const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
1221
2457
  if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
@@ -1228,34 +2464,59 @@ async function resolveEnvTarget(ctx, opts) {
1228
2464
  label: `${group.slug}@${env.slug}`
1229
2465
  };
1230
2466
  }
1231
- const appSlug = opts.app ?? findProjectConfig()?.app;
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}`);
2467
+ const app = await resolveApp(ctx, opts);
1236
2468
  const { environments } = await ctx.client.listEnvs(org.id, app.id);
1237
2469
  const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
1238
2470
  if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
2471
+ if (opts.branch) {
2472
+ const branch = await resolveBranch(ctx, app, opts.branch);
2473
+ return {
2474
+ orgId: org.id,
2475
+ envId: branch.id,
2476
+ label: `${app.slug}/${env.slug}#${branch.slug}`
2477
+ };
2478
+ }
1239
2479
  return {
1240
2480
  orgId: org.id,
1241
2481
  envId: env.id,
1242
2482
  label: `${app.slug}/${env.slug}`
1243
2483
  };
1244
2484
  }
1245
- /** Resolve an application environment, keeping ids + slugs (for token binding). */
1246
- async function resolveAppEnv(ctx, opts) {
2485
+ /** Resolve the target application from a flag or the committed config. */
2486
+ async function resolveApp(ctx, opts) {
1247
2487
  const org = await resolveOrg(ctx, opts.org);
1248
2488
  const appSlug = opts.app ?? findProjectConfig()?.app;
1249
2489
  if (!appSlug) fail("specify --app (or run `seekrit init`)");
1250
- if (!opts.env) fail("specify --env");
1251
2490
  const { apps } = await ctx.client.listApps(org.id);
1252
2491
  const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
1253
2492
  if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
1254
- const { environments } = await ctx.client.listEnvs(org.id, app.id);
2493
+ return {
2494
+ orgId: org.id,
2495
+ orgSlug: org.slug,
2496
+ id: app.id,
2497
+ slug: app.slug
2498
+ };
2499
+ }
2500
+ /**
2501
+ * Find a branch by slug (or id) anywhere in an application. Branch slugs share
2502
+ * the application's environment namespace, so one lookup is unambiguous — no
2503
+ * need to name the parent environment.
2504
+ */
2505
+ async function resolveBranch(ctx, app, branchRef) {
2506
+ const { branches } = await ctx.client.listAppBranches(app.orgId, app.id);
2507
+ const branch = branches.find((b) => b.slug === branchRef || b.id === branchRef);
2508
+ if (!branch) fail(`no branch "${branchRef}" in ${app.slug}`);
2509
+ return branch;
2510
+ }
2511
+ /** Resolve an application environment, keeping ids + slugs (for token binding). */
2512
+ async function resolveAppEnv(ctx, opts) {
2513
+ if (!opts.env) fail("specify --env");
2514
+ const app = await resolveApp(ctx, opts);
2515
+ const { environments } = await ctx.client.listEnvs(app.orgId, app.id);
1255
2516
  const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
1256
2517
  if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
1257
2518
  return {
1258
- orgId: org.id,
2519
+ orgId: app.orgId,
1259
2520
  appId: app.id,
1260
2521
  appSlug: app.slug,
1261
2522
  envId: env.id,
@@ -1357,12 +2618,16 @@ function readM2mCreds(dotenvVars = {}) {
1357
2618
  clientSecret
1358
2619
  };
1359
2620
  }
1360
- /** True when a service/dev credential is already configured explicitly. */
2621
+ /**
2622
+ * True when a service/session/dev credential is already configured explicitly.
2623
+ * A browser-authorized session counts: a human who ran `seekrit login` must not
2624
+ * be silently swapped onto a machine identity.
2625
+ */
1361
2626
  function hasExplicitCredential(dotenvVars) {
1362
2627
  const fromEnv = (key) => process.env[key] ?? dotenvVars[key];
1363
2628
  if (fromEnv("SEEKRIT_TOKEN") || fromEnv("SEEKRIT_DEV_USER")) return true;
1364
2629
  const config = readGlobalConfig();
1365
- return Boolean(config.token || config.devUser);
2630
+ return Boolean(config.token || config.sessionToken || config.devUser);
1366
2631
  }
1367
2632
  /** Mint a fresh org-scoped admin token using M2M credentials (keyless). */
1368
2633
  async function mintAdminToken(apiUrl, creds) {
@@ -1412,41 +2677,59 @@ async function ensureM2mAdminToken(dotenvVars = {}) {
1412
2677
  return token;
1413
2678
  }
1414
2679
  //#endregion
1415
- //#region ../cli/src/dotenv.ts
2680
+ //#region ../cli/src/cache.ts
2681
+ /** Render an age for a log line, rounded to its largest whole unit. */
2682
+ function humanize(ms) {
2683
+ const secs = Math.floor(ms / 1e3);
2684
+ if (secs < 60) return `${secs}s`;
2685
+ if (secs < 3600) return `${Math.floor(secs / 60)}m`;
2686
+ if (secs < 86400) return `${Math.floor(secs / 3600)}h`;
2687
+ return `${Math.floor(secs / 86400)}d`;
2688
+ }
1416
2689
  /**
1417
- * Minimal `.env` parser: `KEY=VALUE`, `#` comments, an optional `export`
1418
- * prefix, and single/double-quoted values (double quotes honor `\n \t \r \" \\`
1419
- * escapes; unquoted values drop trailing ` # comments`). Multiline values are
1420
- * not supported — keep those in seekrit itself.
2690
+ * Whether a failed resolve means the API was *unreachable* (the cache may stand
2691
+ * in) rather than *refusing us* (it must not). A refusal is an answer, and
2692
+ * revocation is supposed to take effect the moment it arrives.
1421
2693
  */
1422
- function parseDotenv(content) {
1423
- const out = {};
1424
- for (const raw of content.split(/\r?\n/)) {
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;
2694
+ function mayFallBack(err) {
2695
+ if (err instanceof SeekritApiError) return err.status >= 500 || err.status === 429;
2696
+ return true;
1444
2697
  }
1445
- /** Fetch + decrypt every secret in a single environment. */
1446
- async function fetchDecryptedSecrets(ctx, orgId, envId) {
2698
+ /**
2699
+ * Fetch + decrypt every secret in a single environment.
2700
+ *
2701
+ * `${OTHER_SECRET}` references are expanded (see `@seekrit/core`'s
2702
+ * `interpolate`) unless `raw` is set. Only this environment's own secrets are in
2703
+ * scope here — a reference to a secret inherited from a composed group is left
2704
+ * literal, because the group layers aren't fetched. `materializeEnv` is the
2705
+ * fully-layered view.
2706
+ */
2707
+ async function fetchDecryptedSecrets(ctx, orgId, envId, opts = {}) {
1447
2708
  const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
1448
2709
  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);
2710
+ return interpolateValues(Object.fromEntries(entries), !opts.raw).values;
2711
+ }
2712
+ /**
2713
+ * Expand `${OTHER_SECRET}` references in a merged variable set. A cycle becomes
2714
+ * the CLI's standard fatal exit — it is a config bug with no correct value to
2715
+ * emit. Pass `enabled: false` (`--no-interpolate`) to hand the set back as-is.
2716
+ */
2717
+ function interpolateValues(values, enabled = true) {
2718
+ if (!enabled) return {
2719
+ values,
2720
+ interpolated: [],
2721
+ unresolvedRefs: []
2722
+ };
2723
+ try {
2724
+ const { values: expandedValues, expanded, unresolved } = interpolateSecrets(values);
2725
+ return {
2726
+ values: expandedValues,
2727
+ interpolated: expanded,
2728
+ unresolvedRefs: unresolved
2729
+ };
2730
+ } catch (err) {
2731
+ return fail(err instanceof Error ? err.message : String(err));
2732
+ }
1450
2733
  }
1451
2734
  /**
1452
2735
  * Decrypt one historical version of a secret. Ciphertext is bound to
@@ -1469,34 +2752,82 @@ async function encryptAndSetSecret(ctx, orgId, envId, name, value) {
1469
2752
  * Each layer's DEK is unwrapped once with the principal's private key and its
1470
2753
  * ciphertext decrypted locally. `process.env` is NOT applied here — callers
1471
2754
  * that spawn a process layer it on top so the live shell always wins.
2755
+ *
2756
+ * `${OTHER_SECRET}` references are expanded last, against the merged set, so a
2757
+ * reference always resolves to whichever layer won the name.
1472
2758
  */
1473
2759
  async function materializeEnv(ctx, opts) {
1474
2760
  const query = {};
1475
2761
  if (opts.with && Object.keys(opts.with).length > 0) query.with = opts.with;
2762
+ if (opts.branch) query.branch = opts.branch;
1476
2763
  if (!isTokenAuth(ctx)) {
1477
2764
  if (!opts.envId) fail("specify --app and --env, or authenticate with a service token (SEEKRIT_TOKEN)");
1478
2765
  query.env = opts.envId;
1479
2766
  }
1480
- const { scope, layers } = await ctx.client.resolve(query);
2767
+ const { scope, layers } = await resolveWithCache(ctx, query, opts.cache);
1481
2768
  const privateKey = await getPrivateKey(ctx);
1482
2769
  const values = {};
1483
2770
  const provenance = {};
1484
2771
  for (const layer of layers) {
1485
2772
  const dek = await unwrapDek(layer.wrappedDek, privateKey);
1486
- const label = layer.source === "group" ? `group:${layer.groupSlug}@${layer.slug}` : `app:${scope.appSlug}/${layer.slug}`;
2773
+ let label;
2774
+ if (layer.source === "group") label = `group:${layer.groupSlug}@${layer.slug}`;
2775
+ else if (layer.source === "branch") label = `branch:${scope.appSlug}#${layer.slug}`;
2776
+ else label = `app:${scope.appSlug}/${layer.slug}`;
1487
2777
  for (const secret of layer.secrets) {
1488
2778
  values[secret.name] = await decryptSecret(dek, secret.ciphertext, secretAad(layer.environmentId, secret.name));
1489
2779
  provenance[secret.name] = label;
1490
2780
  }
1491
2781
  }
2782
+ const loadedEnvFiles = overlayEnvFiles(values, provenance, opts.envFiles);
1492
2783
  return {
1493
- values,
2784
+ ...interpolateValues(values, opts.interpolate !== false),
1494
2785
  provenance,
1495
2786
  scope,
1496
- loadedEnvFiles: overlayEnvFiles(values, provenance, opts.envFiles)
2787
+ loadedEnvFiles
1497
2788
  };
1498
2789
  }
1499
2790
  /**
2791
+ * Resolve, going through the last-known-good cache when one is configured.
2792
+ *
2793
+ * Always live first: the cache exists for when the call cannot land, not to
2794
+ * save a round trip, so a recovered network is picked up on the very next
2795
+ * invocation. A *refused* resolve (401/403/…) drops the entry rather than
2796
+ * falling back to it — otherwise revoking a token would keep working offline
2797
+ * until the entry aged out.
2798
+ */
2799
+ async function resolveWithCache(ctx, query, cache) {
2800
+ if (!cache) return ctx.client.resolve(query);
2801
+ try {
2802
+ const response = await ctx.client.resolve(query);
2803
+ try {
2804
+ cache.write(JSON.stringify(response));
2805
+ } catch (err) {
2806
+ warn(`could not update the cache: ${errorMessage(err)}`);
2807
+ }
2808
+ return response;
2809
+ } catch (err) {
2810
+ if (!mayFallBack(err)) {
2811
+ cache.invalidate();
2812
+ throw err;
2813
+ }
2814
+ const found = cache.read();
2815
+ if (found.kind === "hit") {
2816
+ warn(`${errorMessage(err)} — using cached secrets fetched ${humanize(found.ageMs)} ago`);
2817
+ return JSON.parse(found.body);
2818
+ }
2819
+ if (found.kind === "expired") warn(`cached secrets are ${humanize(found.ageMs)} old, past --cache-max-age`);
2820
+ else if (found.kind === "unusable") warn(`ignoring the cached secrets: ${found.reason}`);
2821
+ throw err;
2822
+ }
2823
+ }
2824
+ function warn(text) {
2825
+ process.stderr.write(`seekrit: ${text}\n`);
2826
+ }
2827
+ function errorMessage(err) {
2828
+ return err instanceof Error ? err.message : String(err);
2829
+ }
2830
+ /**
1500
2831
  * Overlay `.env` files onto an existing value/provenance set (later files win).
1501
2832
  * Missing files are skipped. Returns the files that were actually loaded. Used
1502
2833
  * both by {@link materializeEnv} and by `seekrit run`'s degraded path, where
@@ -1538,6 +2869,16 @@ function errText(err) {
1538
2869
  isError: true
1539
2870
  };
1540
2871
  }
2872
+ /** Read-only and safe to repeat — every list/inspect tool. */
2873
+ const ro = {
2874
+ readOnly: true,
2875
+ idempotent: true
2876
+ };
2877
+ /** Removes or overwrites something; repeating it lands in the same state. */
2878
+ const destructive = {
2879
+ destructive: true,
2880
+ idempotent: true
2881
+ };
1541
2882
  /**
1542
2883
  * Short primer surfaced as the MCP server `instructions`. Most clients show
1543
2884
  * this to the model on connect, so it has to orient an agent that lands here
@@ -1671,6 +3012,7 @@ async function materializeFor(ctx, o) {
1671
3012
  if (!isTokenAuth(ctx)) envId = (await resolveAppEnv(ctx, o)).envId;
1672
3013
  return materializeEnv(ctx, {
1673
3014
  envId,
3015
+ branch: o.branch,
1674
3016
  with: o.with,
1675
3017
  envFiles: o.envFile ?? [".env"]
1676
3018
  });
@@ -1705,10 +3047,17 @@ async function runMcpServer(options = {}) {
1705
3047
  version: options.version ?? version$1
1706
3048
  }, { instructions: serverInstructions() });
1707
3049
  /** Register a tool whose handler returns data (serialized) or throws (→ isError). */
1708
- const tool = (name, description, shape, handler) => {
3050
+ const tool = (name, description, hints, shape, handler) => {
1709
3051
  server.registerTool(name, {
1710
3052
  description,
1711
- inputSchema: shape
3053
+ inputSchema: shape,
3054
+ annotations: {
3055
+ title: name,
3056
+ readOnlyHint: hints.readOnly ?? false,
3057
+ destructiveHint: hints.destructive ?? false,
3058
+ idempotentHint: hints.idempotent ?? false,
3059
+ openWorldHint: true
3060
+ }
1712
3061
  }, (async (args) => {
1713
3062
  try {
1714
3063
  return jsonText(await handler(args));
@@ -1726,7 +3075,7 @@ async function runMcpServer(options = {}) {
1726
3075
  openWorldHint: false
1727
3076
  }
1728
3077
  }, async () => jsonText(getStartedText()));
1729
- tool("whoami", "Show the authenticated identity, its role, and accessible orgs.", {}, async () => {
3078
+ tool("whoami", "Show the authenticated identity, its role, and accessible orgs.", ro, {}, async () => {
1730
3079
  const ctx = getCtx();
1731
3080
  if (isTokenAuth(ctx) && ctx.auth.type === "bearer") {
1732
3081
  const { tokenId } = await parseServiceToken(ctx.auth.token);
@@ -1747,13 +3096,13 @@ async function runMcpServer(options = {}) {
1747
3096
  ...await ctx.client.me()
1748
3097
  };
1749
3098
  });
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 }) => {
3099
+ tool("list_orgs", "List organizations the caller can access.", ro, {}, async () => (await getCtx().client.listOrgs()).orgs);
3100
+ tool("list_apps", "List applications in an organization.", ro, { org: z.string().optional() }, async ({ org }) => {
1752
3101
  const ctx = getCtx();
1753
3102
  const orgRef = await resolveOrg(ctx, org);
1754
3103
  return (await ctx.client.listApps(orgRef.id)).apps;
1755
3104
  });
1756
- tool("list_envs", "List environments of an application.", {
3105
+ tool("list_envs", "List environments of an application.", ro, {
1757
3106
  org: z.string().optional(),
1758
3107
  app: z.string()
1759
3108
  }, async ({ org, app }) => {
@@ -1764,12 +3113,30 @@ async function runMcpServer(options = {}) {
1764
3113
  if (!appRow) throw new Error(`no app "${app}" in ${orgRef.slug}`);
1765
3114
  return (await ctx.client.listEnvs(orgRef.id, appRow.id)).environments;
1766
3115
  });
1767
- tool("list_groups", "List shared groups (reusable secret bags) in an organization.", { org: z.string().optional() }, async ({ org }) => {
3116
+ tool("list_branches", "List ephemeral branch configs in an application (optionally just one environment's).", ro, {
3117
+ org: z.string().optional(),
3118
+ app: z.string(),
3119
+ env: z.string().optional()
3120
+ }, async ({ org, app, env }) => {
3121
+ const ctx = getCtx();
3122
+ const appRef = await resolveApp(ctx, {
3123
+ org,
3124
+ app
3125
+ });
3126
+ if (!env) return (await ctx.client.listAppBranches(appRef.orgId, appRef.id)).branches;
3127
+ const parent = await resolveAppEnv(ctx, {
3128
+ org,
3129
+ app,
3130
+ env
3131
+ });
3132
+ return (await ctx.client.listBranches(parent.orgId, parent.envId)).branches;
3133
+ });
3134
+ tool("list_groups", "List shared groups (reusable secret bags) in an organization.", ro, { org: z.string().optional() }, async ({ org }) => {
1768
3135
  const ctx = getCtx();
1769
3136
  const orgRef = await resolveOrg(ctx, org);
1770
3137
  return (await ctx.client.listGroups(orgRef.id)).groups;
1771
3138
  });
1772
- tool("list_group_envs", "List a group's environments (per-slug value sets).", {
3139
+ tool("list_group_envs", "List a group's environments (per-slug value sets).", ro, {
1773
3140
  org: z.string().optional(),
1774
3141
  group: z.string()
1775
3142
  }, async ({ org, group }) => {
@@ -1780,7 +3147,7 @@ async function runMcpServer(options = {}) {
1780
3147
  });
1781
3148
  return (await ctx.client.listGroupEnvs(g.orgId, g.id)).environments;
1782
3149
  });
1783
- tool("list_env_groups", "List the groups composed into an application environment (precedence order).", {
3150
+ tool("list_env_groups", "List the groups composed into an application environment (precedence order).", ro, {
1784
3151
  org: z.string().optional(),
1785
3152
  app: z.string(),
1786
3153
  env: z.string()
@@ -1793,17 +3160,17 @@ async function runMcpServer(options = {}) {
1793
3160
  });
1794
3161
  return (await ctx.client.listEnvGroups(target.orgId, target.envId)).groups;
1795
3162
  });
1796
- tool("list_members", "List organization members and their public keys (for granting access).", { org: z.string().optional() }, async ({ org }) => {
3163
+ tool("list_members", "List organization members and their public keys (for granting access).", ro, { org: z.string().optional() }, async ({ org }) => {
1797
3164
  const ctx = getCtx();
1798
3165
  const orgRef = await resolveOrg(ctx, org);
1799
3166
  return (await ctx.client.listMembers(orgRef.id)).members;
1800
3167
  });
1801
- tool("kms_list_keys", "List managed KMS keys the caller can see (metadata only).", { org: z.string().optional() }, async ({ org }) => {
3168
+ tool("kms_list_keys", "List managed KMS keys the caller can see (metadata only).", ro, { org: z.string().optional() }, async ({ org }) => {
1802
3169
  const ctx = getCtx();
1803
3170
  const orgRef = await resolveOrg(ctx, org);
1804
3171
  return (await ctx.client.listKmsKeys(orgRef.id)).keys;
1805
3172
  });
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.", {
3173
+ 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
3174
  org: z.string().optional(),
1808
3175
  name: z.string(),
1809
3176
  purpose: z.enum(["encrypt", "sign"]),
@@ -1844,7 +3211,7 @@ async function runMcpServer(options = {}) {
1844
3211
  });
1845
3212
  return key;
1846
3213
  });
1847
- tool("kms_grant", "Grant a principal (user email or token id) use of a key's current version.", {
3214
+ tool("kms_grant", "Grant a principal (user email or token id) use of a key's current version.", { idempotent: true }, {
1848
3215
  org: z.string().optional(),
1849
3216
  key: z.string(),
1850
3217
  user: z.string().optional(),
@@ -1869,7 +3236,7 @@ async function runMcpServer(options = {}) {
1869
3236
  key: k.name
1870
3237
  };
1871
3238
  });
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.", {
3239
+ 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
3240
  org: z.string().optional(),
1874
3241
  key: z.string(),
1875
3242
  plaintext: z.string(),
@@ -1886,7 +3253,7 @@ async function runMcpServer(options = {}) {
1886
3253
  version: currentVersion
1887
3254
  }, plaintext, context ?? "") };
1888
3255
  });
1889
- tool("kms_decrypt", "Decrypt a ce1 blob. Supply the same `context` used to encrypt.", {
3256
+ tool("kms_decrypt", "Decrypt a ce1 blob. Supply the same `context` used to encrypt.", ro, {
1890
3257
  org: z.string().optional(),
1891
3258
  key: z.string(),
1892
3259
  ciphertext: z.string(),
@@ -1900,7 +3267,7 @@ async function runMcpServer(options = {}) {
1900
3267
  const { material } = await kmsRecoverMaterial(ctx, orgRef.id, k.id, ref.version);
1901
3268
  return { plaintext: await kmsDecrypt(material, ciphertext, context ?? "") };
1902
3269
  });
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.", {
3270
+ 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
3271
  org: z.string().optional(),
1905
3272
  key: z.string()
1906
3273
  }, async ({ org, key }) => {
@@ -1919,7 +3286,7 @@ async function runMcpServer(options = {}) {
1919
3286
  wrapped: dk.wrapped
1920
3287
  };
1921
3288
  });
1922
- tool("kms_sign", "Sign a message with a managed signing key; returns an sg1 signature blob.", {
3289
+ tool("kms_sign", "Sign a message with a managed signing key; returns an sg1 signature blob.", { readOnly: true }, {
1923
3290
  org: z.string().optional(),
1924
3291
  key: z.string(),
1925
3292
  message: z.string()
@@ -1935,7 +3302,7 @@ async function runMcpServer(options = {}) {
1935
3302
  version: currentVersion
1936
3303
  }, message) };
1937
3304
  });
1938
- tool("kms_verify", "Verify an sg1 signature over a message using a signing key's published public key (no grant needed).", {
3305
+ tool("kms_verify", "Verify an sg1 signature over a message using a signing key's published public key (no grant needed).", ro, {
1939
3306
  org: z.string().optional(),
1940
3307
  key: z.string(),
1941
3308
  signature: z.string(),
@@ -1950,7 +3317,7 @@ async function runMcpServer(options = {}) {
1950
3317
  if (!pub) throw new Error(`no published public key for version ${ref.version}`);
1951
3318
  return { valid: await verifyMessage(await importVerifyingKey(pub), signature, message) };
1952
3319
  });
1953
- tool("list_secrets", "List secret names + versions in an environment (never values).", targetShape, async (o) => {
3320
+ tool("list_secrets", "List secret names + versions in an environment (never values).", ro, targetShape, async (o) => {
1954
3321
  const ctx = getCtx();
1955
3322
  const { orgId, envId } = await resolveTargetEnv(ctx, o);
1956
3323
  const { secrets } = await ctx.client.listSecrets(orgId, envId);
@@ -1960,12 +3327,12 @@ async function runMcpServer(options = {}) {
1960
3327
  updatedAt: s.updatedAt
1961
3328
  }));
1962
3329
  });
1963
- tool("list_tokens", "List an organization's service tokens (never the secret token strings).", { org: z.string().optional() }, async ({ org }) => {
3330
+ tool("list_tokens", "List an organization's service tokens (never the secret token strings).", ro, { org: z.string().optional() }, async ({ org }) => {
1964
3331
  const ctx = getCtx();
1965
3332
  const orgRef = await resolveOrg(ctx, org);
1966
3333
  return (await ctx.client.listTokens(orgRef.id)).tokens;
1967
3334
  });
1968
- tool("audit", "Read the organization's audit trail (most recent first).", {
3335
+ tool("audit", "Read the organization's audit trail (most recent first).", ro, {
1969
3336
  org: z.string().optional(),
1970
3337
  limit: z.number().int().min(1).max(200).optional(),
1971
3338
  action: z.string().optional().describe("filter by action, e.g. secret.updated")
@@ -1977,14 +3344,14 @@ async function runMcpServer(options = {}) {
1977
3344
  action
1978
3345
  })).entries;
1979
3346
  });
1980
- tool("create_org", "Create an organization. Requires a user session — service tokens cannot own a Stytch org.", {
3347
+ tool("create_org", "Create an organization. Requires a user session — service tokens cannot own a Stytch org.", { idempotent: false }, {
1981
3348
  name: z.string(),
1982
3349
  slug: z.string()
1983
3350
  }, async ({ name, slug }) => (await getCtx().client.createOrg({
1984
3351
  name,
1985
3352
  slug
1986
3353
  })).org);
1987
- tool("create_app", "Create an application in an organization.", {
3354
+ tool("create_app", "Create an application in an organization.", { idempotent: false }, {
1988
3355
  org: z.string().optional(),
1989
3356
  name: z.string(),
1990
3357
  slug: z.string()
@@ -1996,7 +3363,7 @@ async function runMcpServer(options = {}) {
1996
3363
  slug
1997
3364
  })).app;
1998
3365
  });
1999
- tool("create_group", "Create a shared group (reusable secret bag) in an organization.", {
3366
+ tool("create_group", "Create a shared group (reusable secret bag) in an organization.", { idempotent: false }, {
2000
3367
  org: z.string().optional(),
2001
3368
  name: z.string(),
2002
3369
  slug: z.string()
@@ -2008,7 +3375,7 @@ async function runMcpServer(options = {}) {
2008
3375
  slug
2009
3376
  })).group;
2010
3377
  });
2011
- tool("create_env", "Create an application environment. Generates the data key locally and grants it to the caller.", {
3378
+ tool("create_env", "Create an application environment. Generates the data key locally and grants it to the caller.", { idempotent: false }, {
2012
3379
  org: z.string().optional(),
2013
3380
  app: z.string(),
2014
3381
  name: z.string(),
@@ -2026,7 +3393,56 @@ async function runMcpServer(options = {}) {
2026
3393
  wrappedDek
2027
3394
  })).environment;
2028
3395
  });
2029
- tool("create_group_env", "Create a group environment. Generates the data key locally and grants it to the caller.", {
3396
+ 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 }, {
3397
+ org: z.string().optional(),
3398
+ app: z.string(),
3399
+ from: z.string().describe("the environment to branch"),
3400
+ slug: z.string().describe("the branch name, e.g. pr-142"),
3401
+ ttl: z.string().optional().describe("lifetime: 12h, 7d, 2w, … or `never` (default 7d)")
3402
+ }, async ({ org, app, from, slug, ttl }) => {
3403
+ const ctx = getCtx();
3404
+ const parent = await resolveAppEnv(ctx, {
3405
+ org,
3406
+ app,
3407
+ env: from
3408
+ });
3409
+ const parsedTtl = parseBranchTtl(ttl ?? "7d");
3410
+ if (parsedTtl === null) throw new Error(`invalid ttl "${ttl}" (try 12h, 7d, 2w, or never)`);
3411
+ const me = await kmsCallerIdentity(ctx);
3412
+ const dek = generateDek();
3413
+ const wrappedDek = await wrapDek(dek, me.publicKeyJwk);
3414
+ const grants = [];
3415
+ const { grantees } = await ctx.client.listGrantees(parent.orgId, parent.envId);
3416
+ for (const grantee of grantees) {
3417
+ if (grantee.principalType === me.principalType && grantee.principalId === me.principalId) continue;
3418
+ grants.push({
3419
+ principalType: grantee.principalType,
3420
+ principalId: grantee.principalId,
3421
+ wrappedDek: await wrapDek(dek, grantee.publicKeyJwk)
3422
+ });
3423
+ }
3424
+ return (await ctx.client.createBranch(parent.orgId, parent.envId, {
3425
+ slug,
3426
+ ttlSeconds: Number.isFinite(parsedTtl) ? parsedTtl : null,
3427
+ wrappedDek,
3428
+ grants
3429
+ })).branch;
3430
+ });
3431
+ tool("delete_branch", "Tear down a branch config and every value it overrode. The parent environment is untouched.", destructive, {
3432
+ org: z.string().optional(),
3433
+ app: z.string(),
3434
+ branch: z.string()
3435
+ }, async ({ org, app, branch }) => {
3436
+ const ctx = getCtx();
3437
+ const appRef = await resolveApp(ctx, {
3438
+ org,
3439
+ app
3440
+ });
3441
+ const target = await resolveBranch(ctx, appRef, branch);
3442
+ await ctx.client.deleteBranch(appRef.orgId, target.id);
3443
+ return { deleted: target.slug };
3444
+ });
3445
+ tool("create_group_env", "Create a group environment. Generates the data key locally and grants it to the caller.", { idempotent: false }, {
2030
3446
  org: z.string().optional(),
2031
3447
  group: z.string(),
2032
3448
  name: z.string(),
@@ -2044,7 +3460,7 @@ async function runMcpServer(options = {}) {
2044
3460
  wrappedDek
2045
3461
  })).environment;
2046
3462
  });
2047
- tool("compose_group", "Compose a group into an application environment (higher position wins on name clashes).", {
3463
+ tool("compose_group", "Compose a group into an application environment (higher position wins on name clashes).", { idempotent: true }, {
2048
3464
  org: z.string().optional(),
2049
3465
  app: z.string(),
2050
3466
  env: z.string(),
@@ -2066,7 +3482,7 @@ async function runMcpServer(options = {}) {
2066
3482
  position
2067
3483
  })).group;
2068
3484
  });
2069
- tool("uncompose_group", "Remove a composed group from an application environment.", {
3485
+ tool("uncompose_group", "Remove a composed group from an application environment.", { idempotent: true }, {
2070
3486
  org: z.string().optional(),
2071
3487
  app: z.string(),
2072
3488
  env: z.string(),
@@ -2085,7 +3501,7 @@ async function runMcpServer(options = {}) {
2085
3501
  await ctx.client.unlinkEnvGroup(target.orgId, target.envId, g.id);
2086
3502
  return { ok: true };
2087
3503
  });
2088
- tool("set_secret", "Encrypt a value locally and store it in an environment.", {
3504
+ 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
3505
  ...targetShape,
2090
3506
  name: z.string(),
2091
3507
  value: z.string()
@@ -2099,10 +3515,11 @@ async function runMcpServer(options = {}) {
2099
3515
  name: o.name
2100
3516
  };
2101
3517
  });
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.", {
3518
+ 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
3519
  ...targetShape,
2104
3520
  name: z.string(),
2105
3521
  reveal: z.boolean().optional(),
3522
+ raw: z.boolean().optional().describe("skip ${OTHER_SECRET} expansion (with reveal)"),
2106
3523
  version: z.number().int().positive().optional().describe("an earlier version from list_secret_versions (default: current)")
2107
3524
  }, async (o) => {
2108
3525
  const ctx = getCtx();
@@ -2127,7 +3544,7 @@ async function runMcpServer(options = {}) {
2127
3544
  revealed: true
2128
3545
  };
2129
3546
  }
2130
- const values = await fetchDecryptedSecrets(ctx, orgId, envId);
3547
+ const values = await fetchDecryptedSecrets(ctx, orgId, envId, { raw: o.raw });
2131
3548
  if (!(o.name in values)) throw new Error(`no secret named ${o.name}`);
2132
3549
  return {
2133
3550
  name: o.name,
@@ -2135,7 +3552,7 @@ async function runMcpServer(options = {}) {
2135
3552
  revealed: true
2136
3553
  };
2137
3554
  });
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.", {
3555
+ 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
3556
  ...targetShape,
2140
3557
  name: z.string(),
2141
3558
  limit: z.number().int().min(1).max(200).optional().describe("default 20")
@@ -2153,7 +3570,7 @@ async function runMcpServer(options = {}) {
2153
3570
  }))
2154
3571
  };
2155
3572
  });
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.", {
3573
+ 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
3574
  ...targetShape,
2158
3575
  name: z.string(),
2159
3576
  version: z.number().int().positive()
@@ -2168,7 +3585,7 @@ async function runMcpServer(options = {}) {
2168
3585
  version: secret.version
2169
3586
  };
2170
3587
  });
2171
- tool("delete_secret", "Delete a secret from an environment.", {
3588
+ tool("delete_secret", "Delete a secret from an environment.", destructive, {
2172
3589
  ...targetShape,
2173
3590
  name: z.string()
2174
3591
  }, async (o) => {
@@ -2181,11 +3598,15 @@ async function runMcpServer(options = {}) {
2181
3598
  };
2182
3599
  });
2183
3600
  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.", {
3601
+ destructive: true,
3602
+ idempotent: false
3603
+ }, {
2184
3604
  command: z.string().describe("executable to run"),
2185
3605
  args: z.array(z.string()).optional(),
2186
3606
  org: z.string().optional(),
2187
3607
  app: z.string().optional(),
2188
3608
  env: z.string().optional().describe("environment slug (token auth infers this)"),
3609
+ branch: z.string().optional().describe("read an ephemeral branch of that environment"),
2189
3610
  with: z.record(z.string(), z.string()).optional().describe("group=env slice overrides"),
2190
3611
  envFile: z.array(z.string()).optional().describe(".env files to overlay (default [.env])"),
2191
3612
  cwd: z.string().optional()
@@ -2202,11 +3623,12 @@ async function runMcpServer(options = {}) {
2202
3623
  injectedVarCount: Object.keys(values).length
2203
3624
  };
2204
3625
  });
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.", {
3626
+ 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
3627
  file: z.string().describe("path to write, e.g. .env"),
2207
3628
  org: z.string().optional(),
2208
3629
  app: z.string().optional(),
2209
3630
  env: z.string().optional(),
3631
+ branch: z.string().optional().describe("read an ephemeral branch of that environment"),
2210
3632
  with: z.record(z.string(), z.string()).optional()
2211
3633
  }, async (o) => {
2212
3634
  const ctx = getCtx();
@@ -2220,7 +3642,7 @@ async function runMcpServer(options = {}) {
2220
3642
  names: Object.keys(values).sort()
2221
3643
  };
2222
3644
  });
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.", {
3645
+ 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
3646
  name: z.string().describe("display name, e.g. ci-deploy or agent-session"),
2225
3647
  org: z.string().optional(),
2226
3648
  app: z.string().optional().describe("bind to this app (runtime tokens)"),
@@ -2271,7 +3693,7 @@ async function runMcpServer(options = {}) {
2271
3693
  note: "save this now — the secret token string is not stored and cannot be retrieved"
2272
3694
  };
2273
3695
  });
2274
- tool("revoke_token", "Revoke a service token by id.", {
3696
+ tool("revoke_token", "Revoke a service token by id.", destructive, {
2275
3697
  org: z.string().optional(),
2276
3698
  tokenId: z.string()
2277
3699
  }, async ({ org, tokenId }) => {
@@ -2283,7 +3705,7 @@ async function runMcpServer(options = {}) {
2283
3705
  tokenId
2284
3706
  };
2285
3707
  });
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.", {
3708
+ 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
3709
  ...targetShape,
2288
3710
  user: z.string().optional().describe("org member email"),
2289
3711
  token: z.string().optional().describe("service token id (skt_…)")
@@ -2328,12 +3750,12 @@ async function runMcpServer(options = {}) {
2328
3750
  principalId
2329
3751
  };
2330
3752
  });
2331
- tool("list_pg_targets", "List registered Postgres provisioning targets for temporary credentials.", { org: z.string().optional() }, async ({ org }) => {
3753
+ tool("list_pg_targets", "List registered Postgres provisioning targets for temporary credentials.", ro, { org: z.string().optional() }, async ({ org }) => {
2332
3754
  const ctx = getCtx();
2333
3755
  const orgRef = await resolveOrg(ctx, org);
2334
3756
  return (await ctx.client.listLeaseTargets(orgRef.id)).targets;
2335
3757
  });
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.", {
3758
+ 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
3759
  org: z.string().optional(),
2338
3760
  target: z.string().describe("target id or name"),
2339
3761
  role: z.string().optional().describe("role name to create (default: random tmp_ name)"),
@@ -2362,12 +3784,12 @@ async function runMcpServer(options = {}) {
2362
3784
  note: "short-lived credential; it auto-expires and the plaintext is not stored anywhere"
2363
3785
  };
2364
3786
  });
2365
- tool("list_pg_leases", "List Postgres leases (the ledger — never secret material).", { org: z.string().optional() }, async ({ org }) => {
3787
+ tool("list_pg_leases", "List Postgres leases (the ledger — never secret material).", ro, { org: z.string().optional() }, async ({ org }) => {
2366
3788
  const ctx = getCtx();
2367
3789
  const orgRef = await resolveOrg(ctx, org);
2368
3790
  return (await ctx.client.listLeases(orgRef.id)).leases;
2369
3791
  });
2370
- tool("revoke_pg_lease", "Revoke a Postgres lease now (drops the role immediately).", {
3792
+ tool("revoke_pg_lease", "Revoke a Postgres lease now (drops the role immediately).", destructive, {
2371
3793
  org: z.string().optional(),
2372
3794
  leaseId: z.string()
2373
3795
  }, async ({ org, leaseId }) => {
@@ -2379,12 +3801,12 @@ async function runMcpServer(options = {}) {
2379
3801
  leaseId
2380
3802
  };
2381
3803
  });
2382
- tool("list_mysql_targets", "List registered MySQL/MariaDB provisioning targets for temporary credentials.", { org: z.string().optional() }, async ({ org }) => {
3804
+ tool("list_mysql_targets", "List registered MySQL/MariaDB provisioning targets for temporary credentials.", ro, { org: z.string().optional() }, async ({ org }) => {
2383
3805
  const ctx = getCtx();
2384
3806
  const orgRef = await resolveOrg(ctx, org);
2385
3807
  return (await ctx.client.listLeaseTargets(orgRef.id)).targets.filter((t) => t.provider === "mysql");
2386
3808
  });
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.", {
3809
+ 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
3810
  org: z.string().optional(),
2389
3811
  target: z.string().describe("target id or name"),
2390
3812
  user: z.string().optional().describe("user name to create (default: random tmp_ name)"),
@@ -2414,12 +3836,12 @@ async function runMcpServer(options = {}) {
2414
3836
  note: "short-lived credential; it auto-expires and the plaintext is not stored anywhere"
2415
3837
  };
2416
3838
  });
2417
- tool("list_mysql_leases", "List MySQL/MariaDB leases (the ledger — never secret material).", { org: z.string().optional() }, async ({ org }) => {
3839
+ tool("list_mysql_leases", "List MySQL/MariaDB leases (the ledger — never secret material).", ro, { org: z.string().optional() }, async ({ org }) => {
2418
3840
  const ctx = getCtx();
2419
3841
  const orgRef = await resolveOrg(ctx, org);
2420
3842
  return (await ctx.client.listLeases(orgRef.id)).leases.filter((l) => l.provider === "mysql");
2421
3843
  });
2422
- tool("revoke_mysql_lease", "Revoke a MySQL/MariaDB lease now (drops the user immediately).", {
3844
+ tool("revoke_mysql_lease", "Revoke a MySQL/MariaDB lease now (drops the user immediately).", destructive, {
2423
3845
  org: z.string().optional(),
2424
3846
  leaseId: z.string()
2425
3847
  }, async ({ org, leaseId }) => {
@@ -2431,7 +3853,7 @@ async function runMcpServer(options = {}) {
2431
3853
  leaseId
2432
3854
  };
2433
3855
  });
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.", {
3856
+ 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
3857
  org: z.string(),
2436
3858
  app: z.string(),
2437
3859
  dir: z.string().optional()
@@ -2469,7 +3891,7 @@ async function runMcpServer(options = {}) {
2469
3891
  * `seekrit mcp`. tsdown bundles the shared server source in at build time, so the
2470
3892
  * published package is self-contained and needs no `@seekrit/cli` install.
2471
3893
  */
2472
- runMcpServer({ version: "0.6.0" }).catch((err) => {
3894
+ runMcpServer({ version: "0.6.1" }).catch((err) => {
2473
3895
  const message = err instanceof Error ? err.message : String(err);
2474
3896
  process.stderr.write(`seekrit-mcp: fatal: ${message}\n`);
2475
3897
  process.exit(1);