@seekrit/mcp 0.5.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 +1592 -103
  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.23.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}`);
@@ -886,6 +2071,19 @@ var SeekritClient = class {
886
2071
  deleteSecret(orgId, envId, name) {
887
2072
  return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}`);
888
2073
  }
2074
+ /** A secret's append-only history, newest version first. */
2075
+ listSecretVersions(orgId, envId, name, query = {}) {
2076
+ const qs = query.limit === void 0 ? "" : `?limit=${query.limit}`;
2077
+ return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}/versions${qs}`);
2078
+ }
2079
+ /**
2080
+ * Roll a secret back to an earlier version. Keyless — the server copies the
2081
+ * ciphertext it already stores, so this appends a new version rather than
2082
+ * rewinding, and needs no DEK on the caller's side.
2083
+ */
2084
+ restoreSecret(orgId, envId, name, version) {
2085
+ return this.request("POST", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}/restore`, { version });
2086
+ }
889
2087
  /** The calling principal's wrapped DEK for this environment. */
890
2088
  getMyEnvKey(orgId, envId) {
891
2089
  return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/key`);
@@ -1012,6 +2210,54 @@ var SeekritClient = class {
1012
2210
  deleteLeaseTarget(orgId, targetId) {
1013
2211
  return this.request("DELETE", `/v1/orgs/${orgId}/leases/targets/${targetId}`);
1014
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
+ }
1015
2261
  listLeases(orgId) {
1016
2262
  return this.request("GET", `/v1/orgs/${orgId}/leases`);
1017
2263
  }
@@ -1132,7 +2378,7 @@ function tryBuildContext(dotenvVars = {}) {
1132
2378
  const config = readGlobalConfig();
1133
2379
  const fromEnv = (key) => process.env[key] ?? dotenvVars[key];
1134
2380
  const apiUrl = fromEnv("SEEKRIT_API_URL") ?? config.apiUrl ?? "https://api.seekrit.dev";
1135
- const token = fromEnv("SEEKRIT_TOKEN") ?? config.token;
2381
+ const token = fromEnv("SEEKRIT_TOKEN") ?? config.token ?? config.sessionToken;
1136
2382
  const devUser = fromEnv("SEEKRIT_DEV_USER") ?? config.devUser;
1137
2383
  let auth;
1138
2384
  if (token) auth = {
@@ -1150,7 +2396,8 @@ function tryBuildContext(dotenvVars = {}) {
1150
2396
  auth,
1151
2397
  client: CLI_CLIENT
1152
2398
  }),
1153
- auth
2399
+ auth,
2400
+ apiUrl
1154
2401
  };
1155
2402
  }
1156
2403
  function isTokenAuth(ctx) {
@@ -1197,12 +2444,14 @@ async function resolveOrg(ctx, orgSlug) {
1197
2444
  }
1198
2445
  /**
1199
2446
  * Resolve an environment to operate on — an application env (`--app --env`,
1200
- * 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`).
1201
2449
  */
1202
2450
  async function resolveEnvTarget(ctx, opts) {
1203
2451
  const org = await resolveOrg(ctx, opts.org);
1204
2452
  if (!opts.env) fail("specify --env");
1205
2453
  if (opts.group) {
2454
+ if (opts.branch) fail("--branch applies to application environments, not groups");
1206
2455
  const { groups } = await ctx.client.listGroups(org.id);
1207
2456
  const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
1208
2457
  if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
@@ -1215,34 +2464,59 @@ async function resolveEnvTarget(ctx, opts) {
1215
2464
  label: `${group.slug}@${env.slug}`
1216
2465
  };
1217
2466
  }
1218
- const appSlug = opts.app ?? findProjectConfig()?.app;
1219
- if (!appSlug) fail("specify --app or --group (or run `seekrit init`)");
1220
- const { apps } = await ctx.client.listApps(org.id);
1221
- const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
1222
- if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
2467
+ const app = await resolveApp(ctx, opts);
1223
2468
  const { environments } = await ctx.client.listEnvs(org.id, app.id);
1224
2469
  const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
1225
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
+ }
1226
2479
  return {
1227
2480
  orgId: org.id,
1228
2481
  envId: env.id,
1229
2482
  label: `${app.slug}/${env.slug}`
1230
2483
  };
1231
2484
  }
1232
- /** Resolve an application environment, keeping ids + slugs (for token binding). */
1233
- async function resolveAppEnv(ctx, opts) {
2485
+ /** Resolve the target application from a flag or the committed config. */
2486
+ async function resolveApp(ctx, opts) {
1234
2487
  const org = await resolveOrg(ctx, opts.org);
1235
2488
  const appSlug = opts.app ?? findProjectConfig()?.app;
1236
2489
  if (!appSlug) fail("specify --app (or run `seekrit init`)");
1237
- if (!opts.env) fail("specify --env");
1238
2490
  const { apps } = await ctx.client.listApps(org.id);
1239
2491
  const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
1240
2492
  if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
1241
- 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);
1242
2516
  const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
1243
2517
  if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
1244
2518
  return {
1245
- orgId: org.id,
2519
+ orgId: app.orgId,
1246
2520
  appId: app.id,
1247
2521
  appSlug: app.slug,
1248
2522
  envId: env.id,
@@ -1344,12 +2618,16 @@ function readM2mCreds(dotenvVars = {}) {
1344
2618
  clientSecret
1345
2619
  };
1346
2620
  }
1347
- /** 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
+ */
1348
2626
  function hasExplicitCredential(dotenvVars) {
1349
2627
  const fromEnv = (key) => process.env[key] ?? dotenvVars[key];
1350
2628
  if (fromEnv("SEEKRIT_TOKEN") || fromEnv("SEEKRIT_DEV_USER")) return true;
1351
2629
  const config = readGlobalConfig();
1352
- return Boolean(config.token || config.devUser);
2630
+ return Boolean(config.token || config.sessionToken || config.devUser);
1353
2631
  }
1354
2632
  /** Mint a fresh org-scoped admin token using M2M credentials (keyless). */
1355
2633
  async function mintAdminToken(apiUrl, creds) {
@@ -1399,43 +2677,70 @@ async function ensureM2mAdminToken(dotenvVars = {}) {
1399
2677
  return token;
1400
2678
  }
1401
2679
  //#endregion
1402
- //#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
+ }
1403
2689
  /**
1404
- * Minimal `.env` parser: `KEY=VALUE`, `#` comments, an optional `export`
1405
- * prefix, and single/double-quoted values (double quotes honor `\n \t \r \" \\`
1406
- * escapes; unquoted values drop trailing ` # comments`). Multiline values are
1407
- * 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.
1408
2693
  */
1409
- function parseDotenv(content) {
1410
- const out = {};
1411
- for (const raw of content.split(/\r?\n/)) {
1412
- let line = raw.trim();
1413
- if (!line || line.startsWith("#")) continue;
1414
- if (line.startsWith("export ")) line = line.slice(7).trimStart();
1415
- const eq = line.indexOf("=");
1416
- if (eq === -1) continue;
1417
- const key = line.slice(0, eq).trim();
1418
- if (!key) continue;
1419
- let value = line.slice(eq + 1).trim();
1420
- const quote = value[0];
1421
- if (value.length >= 2 && (quote === "\"" || quote === "'") && value.at(-1) === quote) {
1422
- value = value.slice(1, -1);
1423
- if (quote === "\"") value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
1424
- } else {
1425
- const comment = value.indexOf(" #");
1426
- if (comment !== -1) value = value.slice(0, comment).trim();
1427
- }
1428
- out[key] = value;
1429
- }
1430
- return out;
2694
+ function mayFallBack(err) {
2695
+ if (err instanceof SeekritApiError) return err.status >= 500 || err.status === 429;
2696
+ return true;
1431
2697
  }
1432
- //#endregion
1433
- //#region ../cli/src/secrets.ts
1434
- /** Fetch + decrypt every secret in a single environment. */
1435
- 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 = {}) {
1436
2708
  const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
1437
2709
  const entries = await Promise.all(secrets.map(async (secret) => [secret.name, await decryptSecret(dek, secret.ciphertext, secretAad(envId, secret.name))]));
1438
- 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
+ }
2733
+ }
2734
+ /**
2735
+ * Decrypt one historical version of a secret. Ciphertext is bound to
2736
+ * `(envId, name)` as AAD and neither changes across versions, so an old blob
2737
+ * opens with the environment's current data key — no special handling needed.
2738
+ */
2739
+ async function fetchDecryptedVersion(ctx, orgId, envId, name, version) {
2740
+ const [dek, { versions }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecretVersions(orgId, envId, name, { limit: 200 })]);
2741
+ const row = versions.find((v) => v.version === version);
2742
+ if (!row) fail(`${name} has no version ${version} in its ${versions.length} newest versions`);
2743
+ return decryptSecret(dek, row.ciphertext, secretAad(envId, name));
1439
2744
  }
1440
2745
  async function encryptAndSetSecret(ctx, orgId, envId, name, value) {
1441
2746
  const ciphertext = await encryptSecret(await getDek(ctx, orgId, envId), value, secretAad(envId, name));
@@ -1447,34 +2752,82 @@ async function encryptAndSetSecret(ctx, orgId, envId, name, value) {
1447
2752
  * Each layer's DEK is unwrapped once with the principal's private key and its
1448
2753
  * ciphertext decrypted locally. `process.env` is NOT applied here — callers
1449
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.
1450
2758
  */
1451
2759
  async function materializeEnv(ctx, opts) {
1452
2760
  const query = {};
1453
2761
  if (opts.with && Object.keys(opts.with).length > 0) query.with = opts.with;
2762
+ if (opts.branch) query.branch = opts.branch;
1454
2763
  if (!isTokenAuth(ctx)) {
1455
2764
  if (!opts.envId) fail("specify --app and --env, or authenticate with a service token (SEEKRIT_TOKEN)");
1456
2765
  query.env = opts.envId;
1457
2766
  }
1458
- const { scope, layers } = await ctx.client.resolve(query);
2767
+ const { scope, layers } = await resolveWithCache(ctx, query, opts.cache);
1459
2768
  const privateKey = await getPrivateKey(ctx);
1460
2769
  const values = {};
1461
2770
  const provenance = {};
1462
2771
  for (const layer of layers) {
1463
2772
  const dek = await unwrapDek(layer.wrappedDek, privateKey);
1464
- 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}`;
1465
2777
  for (const secret of layer.secrets) {
1466
2778
  values[secret.name] = await decryptSecret(dek, secret.ciphertext, secretAad(layer.environmentId, secret.name));
1467
2779
  provenance[secret.name] = label;
1468
2780
  }
1469
2781
  }
2782
+ const loadedEnvFiles = overlayEnvFiles(values, provenance, opts.envFiles);
1470
2783
  return {
1471
- values,
2784
+ ...interpolateValues(values, opts.interpolate !== false),
1472
2785
  provenance,
1473
2786
  scope,
1474
- loadedEnvFiles: overlayEnvFiles(values, provenance, opts.envFiles)
2787
+ loadedEnvFiles
1475
2788
  };
1476
2789
  }
1477
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
+ /**
1478
2831
  * Overlay `.env` files onto an existing value/provenance set (later files win).
1479
2832
  * Missing files are skipped. Returns the files that were actually loaded. Used
1480
2833
  * both by {@link materializeEnv} and by `seekrit run`'s degraded path, where
@@ -1516,6 +2869,16 @@ function errText(err) {
1516
2869
  isError: true
1517
2870
  };
1518
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
+ };
1519
2882
  /**
1520
2883
  * Short primer surfaced as the MCP server `instructions`. Most clients show
1521
2884
  * this to the model on connect, so it has to orient an agent that lands here
@@ -1563,6 +2926,8 @@ function getStartedText() {
1563
2926
  "## 3. Store secrets",
1564
2927
  "- `set_secret` — encrypts a value locally and stores the ciphertext.",
1565
2928
  "- `list_secrets` — confirm names + versions (never returns values).",
2929
+ "- `list_secret_versions` + `restore_secret` — undo a bad write by rolling",
2930
+ " back to an earlier version (keyless, and history is append-only).",
1566
2931
  "",
1567
2932
  "## 4. Use secrets without exposing them",
1568
2933
  "- `run_command -- <cmd>` — inject secrets into a subprocess; prefer this.",
@@ -1647,6 +3012,7 @@ async function materializeFor(ctx, o) {
1647
3012
  if (!isTokenAuth(ctx)) envId = (await resolveAppEnv(ctx, o)).envId;
1648
3013
  return materializeEnv(ctx, {
1649
3014
  envId,
3015
+ branch: o.branch,
1650
3016
  with: o.with,
1651
3017
  envFiles: o.envFile ?? [".env"]
1652
3018
  });
@@ -1681,10 +3047,17 @@ async function runMcpServer(options = {}) {
1681
3047
  version: options.version ?? version$1
1682
3048
  }, { instructions: serverInstructions() });
1683
3049
  /** Register a tool whose handler returns data (serialized) or throws (→ isError). */
1684
- const tool = (name, description, shape, handler) => {
3050
+ const tool = (name, description, hints, shape, handler) => {
1685
3051
  server.registerTool(name, {
1686
3052
  description,
1687
- 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
+ }
1688
3061
  }, (async (args) => {
1689
3062
  try {
1690
3063
  return jsonText(await handler(args));
@@ -1702,7 +3075,7 @@ async function runMcpServer(options = {}) {
1702
3075
  openWorldHint: false
1703
3076
  }
1704
3077
  }, async () => jsonText(getStartedText()));
1705
- 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 () => {
1706
3079
  const ctx = getCtx();
1707
3080
  if (isTokenAuth(ctx) && ctx.auth.type === "bearer") {
1708
3081
  const { tokenId } = await parseServiceToken(ctx.auth.token);
@@ -1723,13 +3096,13 @@ async function runMcpServer(options = {}) {
1723
3096
  ...await ctx.client.me()
1724
3097
  };
1725
3098
  });
1726
- tool("list_orgs", "List organizations the caller can access.", {}, async () => (await getCtx().client.listOrgs()).orgs);
1727
- 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 }) => {
1728
3101
  const ctx = getCtx();
1729
3102
  const orgRef = await resolveOrg(ctx, org);
1730
3103
  return (await ctx.client.listApps(orgRef.id)).apps;
1731
3104
  });
1732
- tool("list_envs", "List environments of an application.", {
3105
+ tool("list_envs", "List environments of an application.", ro, {
1733
3106
  org: z.string().optional(),
1734
3107
  app: z.string()
1735
3108
  }, async ({ org, app }) => {
@@ -1740,12 +3113,30 @@ async function runMcpServer(options = {}) {
1740
3113
  if (!appRow) throw new Error(`no app "${app}" in ${orgRef.slug}`);
1741
3114
  return (await ctx.client.listEnvs(orgRef.id, appRow.id)).environments;
1742
3115
  });
1743
- 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 }) => {
1744
3135
  const ctx = getCtx();
1745
3136
  const orgRef = await resolveOrg(ctx, org);
1746
3137
  return (await ctx.client.listGroups(orgRef.id)).groups;
1747
3138
  });
1748
- 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, {
1749
3140
  org: z.string().optional(),
1750
3141
  group: z.string()
1751
3142
  }, async ({ org, group }) => {
@@ -1756,7 +3147,7 @@ async function runMcpServer(options = {}) {
1756
3147
  });
1757
3148
  return (await ctx.client.listGroupEnvs(g.orgId, g.id)).environments;
1758
3149
  });
1759
- 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, {
1760
3151
  org: z.string().optional(),
1761
3152
  app: z.string(),
1762
3153
  env: z.string()
@@ -1769,17 +3160,17 @@ async function runMcpServer(options = {}) {
1769
3160
  });
1770
3161
  return (await ctx.client.listEnvGroups(target.orgId, target.envId)).groups;
1771
3162
  });
1772
- 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 }) => {
1773
3164
  const ctx = getCtx();
1774
3165
  const orgRef = await resolveOrg(ctx, org);
1775
3166
  return (await ctx.client.listMembers(orgRef.id)).members;
1776
3167
  });
1777
- 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 }) => {
1778
3169
  const ctx = getCtx();
1779
3170
  const orgRef = await resolveOrg(ctx, org);
1780
3171
  return (await ctx.client.listKmsKeys(orgRef.id)).keys;
1781
3172
  });
1782
- 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 }, {
1783
3174
  org: z.string().optional(),
1784
3175
  name: z.string(),
1785
3176
  purpose: z.enum(["encrypt", "sign"]),
@@ -1820,7 +3211,7 @@ async function runMcpServer(options = {}) {
1820
3211
  });
1821
3212
  return key;
1822
3213
  });
1823
- 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 }, {
1824
3215
  org: z.string().optional(),
1825
3216
  key: z.string(),
1826
3217
  user: z.string().optional(),
@@ -1845,7 +3236,7 @@ async function runMcpServer(options = {}) {
1845
3236
  key: k.name
1846
3237
  };
1847
3238
  });
1848
- 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 }, {
1849
3240
  org: z.string().optional(),
1850
3241
  key: z.string(),
1851
3242
  plaintext: z.string(),
@@ -1862,7 +3253,7 @@ async function runMcpServer(options = {}) {
1862
3253
  version: currentVersion
1863
3254
  }, plaintext, context ?? "") };
1864
3255
  });
1865
- 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, {
1866
3257
  org: z.string().optional(),
1867
3258
  key: z.string(),
1868
3259
  ciphertext: z.string(),
@@ -1876,7 +3267,7 @@ async function runMcpServer(options = {}) {
1876
3267
  const { material } = await kmsRecoverMaterial(ctx, orgRef.id, k.id, ref.version);
1877
3268
  return { plaintext: await kmsDecrypt(material, ciphertext, context ?? "") };
1878
3269
  });
1879
- 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 }, {
1880
3271
  org: z.string().optional(),
1881
3272
  key: z.string()
1882
3273
  }, async ({ org, key }) => {
@@ -1895,7 +3286,7 @@ async function runMcpServer(options = {}) {
1895
3286
  wrapped: dk.wrapped
1896
3287
  };
1897
3288
  });
1898
- 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 }, {
1899
3290
  org: z.string().optional(),
1900
3291
  key: z.string(),
1901
3292
  message: z.string()
@@ -1911,7 +3302,7 @@ async function runMcpServer(options = {}) {
1911
3302
  version: currentVersion
1912
3303
  }, message) };
1913
3304
  });
1914
- 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, {
1915
3306
  org: z.string().optional(),
1916
3307
  key: z.string(),
1917
3308
  signature: z.string(),
@@ -1926,7 +3317,7 @@ async function runMcpServer(options = {}) {
1926
3317
  if (!pub) throw new Error(`no published public key for version ${ref.version}`);
1927
3318
  return { valid: await verifyMessage(await importVerifyingKey(pub), signature, message) };
1928
3319
  });
1929
- 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) => {
1930
3321
  const ctx = getCtx();
1931
3322
  const { orgId, envId } = await resolveTargetEnv(ctx, o);
1932
3323
  const { secrets } = await ctx.client.listSecrets(orgId, envId);
@@ -1936,12 +3327,12 @@ async function runMcpServer(options = {}) {
1936
3327
  updatedAt: s.updatedAt
1937
3328
  }));
1938
3329
  });
1939
- 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 }) => {
1940
3331
  const ctx = getCtx();
1941
3332
  const orgRef = await resolveOrg(ctx, org);
1942
3333
  return (await ctx.client.listTokens(orgRef.id)).tokens;
1943
3334
  });
1944
- tool("audit", "Read the organization's audit trail (most recent first).", {
3335
+ tool("audit", "Read the organization's audit trail (most recent first).", ro, {
1945
3336
  org: z.string().optional(),
1946
3337
  limit: z.number().int().min(1).max(200).optional(),
1947
3338
  action: z.string().optional().describe("filter by action, e.g. secret.updated")
@@ -1953,14 +3344,14 @@ async function runMcpServer(options = {}) {
1953
3344
  action
1954
3345
  })).entries;
1955
3346
  });
1956
- 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 }, {
1957
3348
  name: z.string(),
1958
3349
  slug: z.string()
1959
3350
  }, async ({ name, slug }) => (await getCtx().client.createOrg({
1960
3351
  name,
1961
3352
  slug
1962
3353
  })).org);
1963
- tool("create_app", "Create an application in an organization.", {
3354
+ tool("create_app", "Create an application in an organization.", { idempotent: false }, {
1964
3355
  org: z.string().optional(),
1965
3356
  name: z.string(),
1966
3357
  slug: z.string()
@@ -1972,7 +3363,7 @@ async function runMcpServer(options = {}) {
1972
3363
  slug
1973
3364
  })).app;
1974
3365
  });
1975
- 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 }, {
1976
3367
  org: z.string().optional(),
1977
3368
  name: z.string(),
1978
3369
  slug: z.string()
@@ -1984,7 +3375,7 @@ async function runMcpServer(options = {}) {
1984
3375
  slug
1985
3376
  })).group;
1986
3377
  });
1987
- 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 }, {
1988
3379
  org: z.string().optional(),
1989
3380
  app: z.string(),
1990
3381
  name: z.string(),
@@ -2002,7 +3393,56 @@ async function runMcpServer(options = {}) {
2002
3393
  wrappedDek
2003
3394
  })).environment;
2004
3395
  });
2005
- 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 }, {
2006
3446
  org: z.string().optional(),
2007
3447
  group: z.string(),
2008
3448
  name: z.string(),
@@ -2020,7 +3460,7 @@ async function runMcpServer(options = {}) {
2020
3460
  wrappedDek
2021
3461
  })).environment;
2022
3462
  });
2023
- 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 }, {
2024
3464
  org: z.string().optional(),
2025
3465
  app: z.string(),
2026
3466
  env: z.string(),
@@ -2042,7 +3482,7 @@ async function runMcpServer(options = {}) {
2042
3482
  position
2043
3483
  })).group;
2044
3484
  });
2045
- 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 }, {
2046
3486
  org: z.string().optional(),
2047
3487
  app: z.string(),
2048
3488
  env: z.string(),
@@ -2061,7 +3501,7 @@ async function runMcpServer(options = {}) {
2061
3501
  await ctx.client.unlinkEnvGroup(target.orgId, target.envId, g.id);
2062
3502
  return { ok: true };
2063
3503
  });
2064
- 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 }, {
2065
3505
  ...targetShape,
2066
3506
  name: z.string(),
2067
3507
  value: z.string()
@@ -2075,10 +3515,12 @@ async function runMcpServer(options = {}) {
2075
3515
  name: o.name
2076
3516
  };
2077
3517
  });
2078
- 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).", {
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, {
2079
3519
  ...targetShape,
2080
3520
  name: z.string(),
2081
- reveal: z.boolean().optional()
3521
+ reveal: z.boolean().optional(),
3522
+ raw: z.boolean().optional().describe("skip ${OTHER_SECRET} expansion (with reveal)"),
3523
+ version: z.number().int().positive().optional().describe("an earlier version from list_secret_versions (default: current)")
2082
3524
  }, async (o) => {
2083
3525
  const ctx = getCtx();
2084
3526
  const { orgId, envId } = await resolveTargetEnv(ctx, o);
@@ -2088,12 +3530,21 @@ async function runMcpServer(options = {}) {
2088
3530
  if (!row) throw new Error(`no secret named ${o.name}`);
2089
3531
  return {
2090
3532
  name: row.name,
2091
- version: row.version,
3533
+ version: o.version ?? row.version,
2092
3534
  revealed: false
2093
3535
  };
2094
3536
  }
2095
3537
  ensureDecryptable(ctx);
2096
- const values = await fetchDecryptedSecrets(ctx, orgId, envId);
3538
+ if (o.version !== void 0) {
3539
+ const value = await fetchDecryptedVersion(ctx, orgId, envId, o.name, o.version);
3540
+ return {
3541
+ name: o.name,
3542
+ version: o.version,
3543
+ value,
3544
+ revealed: true
3545
+ };
3546
+ }
3547
+ const values = await fetchDecryptedSecrets(ctx, orgId, envId, { raw: o.raw });
2097
3548
  if (!(o.name in values)) throw new Error(`no secret named ${o.name}`);
2098
3549
  return {
2099
3550
  name: o.name,
@@ -2101,7 +3552,40 @@ async function runMcpServer(options = {}) {
2101
3552
  revealed: true
2102
3553
  };
2103
3554
  });
2104
- tool("delete_secret", "Delete a secret from an environment.", {
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, {
3556
+ ...targetShape,
3557
+ name: z.string(),
3558
+ limit: z.number().int().min(1).max(200).optional().describe("default 20")
3559
+ }, async (o) => {
3560
+ const ctx = getCtx();
3561
+ const { orgId, envId } = await resolveTargetEnv(ctx, o);
3562
+ const { versions, currentVersion } = await ctx.client.listSecretVersions(orgId, envId, o.name, { limit: o.limit ?? 20 });
3563
+ return {
3564
+ currentVersion,
3565
+ versions: versions.map((v) => ({
3566
+ version: v.version,
3567
+ createdAt: v.createdAt,
3568
+ createdBy: `${v.createdByType}:${v.createdById}`,
3569
+ restoredFromVersion: v.restoredFromVersion
3570
+ }))
3571
+ };
3572
+ });
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 }, {
3574
+ ...targetShape,
3575
+ name: z.string(),
3576
+ version: z.number().int().positive()
3577
+ }, async (o) => {
3578
+ const ctx = getCtx();
3579
+ const { orgId, envId } = await resolveTargetEnv(ctx, o);
3580
+ const { secret, restoredFrom } = await ctx.client.restoreSecret(orgId, envId, o.name, o.version);
3581
+ return {
3582
+ ok: true,
3583
+ name: o.name,
3584
+ restoredFrom,
3585
+ version: secret.version
3586
+ };
3587
+ });
3588
+ tool("delete_secret", "Delete a secret from an environment.", destructive, {
2105
3589
  ...targetShape,
2106
3590
  name: z.string()
2107
3591
  }, async (o) => {
@@ -2114,11 +3598,15 @@ async function runMcpServer(options = {}) {
2114
3598
  };
2115
3599
  });
2116
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
+ }, {
2117
3604
  command: z.string().describe("executable to run"),
2118
3605
  args: z.array(z.string()).optional(),
2119
3606
  org: z.string().optional(),
2120
3607
  app: z.string().optional(),
2121
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"),
2122
3610
  with: z.record(z.string(), z.string()).optional().describe("group=env slice overrides"),
2123
3611
  envFile: z.array(z.string()).optional().describe(".env files to overlay (default [.env])"),
2124
3612
  cwd: z.string().optional()
@@ -2135,11 +3623,12 @@ async function runMcpServer(options = {}) {
2135
3623
  injectedVarCount: Object.keys(values).length
2136
3624
  };
2137
3625
  });
2138
- 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, {
2139
3627
  file: z.string().describe("path to write, e.g. .env"),
2140
3628
  org: z.string().optional(),
2141
3629
  app: z.string().optional(),
2142
3630
  env: z.string().optional(),
3631
+ branch: z.string().optional().describe("read an ephemeral branch of that environment"),
2143
3632
  with: z.record(z.string(), z.string()).optional()
2144
3633
  }, async (o) => {
2145
3634
  const ctx = getCtx();
@@ -2153,7 +3642,7 @@ async function runMcpServer(options = {}) {
2153
3642
  names: Object.keys(values).sort()
2154
3643
  };
2155
3644
  });
2156
- 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 }, {
2157
3646
  name: z.string().describe("display name, e.g. ci-deploy or agent-session"),
2158
3647
  org: z.string().optional(),
2159
3648
  app: z.string().optional().describe("bind to this app (runtime tokens)"),
@@ -2204,7 +3693,7 @@ async function runMcpServer(options = {}) {
2204
3693
  note: "save this now — the secret token string is not stored and cannot be retrieved"
2205
3694
  };
2206
3695
  });
2207
- tool("revoke_token", "Revoke a service token by id.", {
3696
+ tool("revoke_token", "Revoke a service token by id.", destructive, {
2208
3697
  org: z.string().optional(),
2209
3698
  tokenId: z.string()
2210
3699
  }, async ({ org, tokenId }) => {
@@ -2216,7 +3705,7 @@ async function runMcpServer(options = {}) {
2216
3705
  tokenId
2217
3706
  };
2218
3707
  });
2219
- 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 }, {
2220
3709
  ...targetShape,
2221
3710
  user: z.string().optional().describe("org member email"),
2222
3711
  token: z.string().optional().describe("service token id (skt_…)")
@@ -2261,12 +3750,12 @@ async function runMcpServer(options = {}) {
2261
3750
  principalId
2262
3751
  };
2263
3752
  });
2264
- 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 }) => {
2265
3754
  const ctx = getCtx();
2266
3755
  const orgRef = await resolveOrg(ctx, org);
2267
3756
  return (await ctx.client.listLeaseTargets(orgRef.id)).targets;
2268
3757
  });
2269
- 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 }, {
2270
3759
  org: z.string().optional(),
2271
3760
  target: z.string().describe("target id or name"),
2272
3761
  role: z.string().optional().describe("role name to create (default: random tmp_ name)"),
@@ -2295,12 +3784,12 @@ async function runMcpServer(options = {}) {
2295
3784
  note: "short-lived credential; it auto-expires and the plaintext is not stored anywhere"
2296
3785
  };
2297
3786
  });
2298
- 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 }) => {
2299
3788
  const ctx = getCtx();
2300
3789
  const orgRef = await resolveOrg(ctx, org);
2301
3790
  return (await ctx.client.listLeases(orgRef.id)).leases;
2302
3791
  });
2303
- 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, {
2304
3793
  org: z.string().optional(),
2305
3794
  leaseId: z.string()
2306
3795
  }, async ({ org, leaseId }) => {
@@ -2312,12 +3801,12 @@ async function runMcpServer(options = {}) {
2312
3801
  leaseId
2313
3802
  };
2314
3803
  });
2315
- 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 }) => {
2316
3805
  const ctx = getCtx();
2317
3806
  const orgRef = await resolveOrg(ctx, org);
2318
3807
  return (await ctx.client.listLeaseTargets(orgRef.id)).targets.filter((t) => t.provider === "mysql");
2319
3808
  });
2320
- 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 }, {
2321
3810
  org: z.string().optional(),
2322
3811
  target: z.string().describe("target id or name"),
2323
3812
  user: z.string().optional().describe("user name to create (default: random tmp_ name)"),
@@ -2347,12 +3836,12 @@ async function runMcpServer(options = {}) {
2347
3836
  note: "short-lived credential; it auto-expires and the plaintext is not stored anywhere"
2348
3837
  };
2349
3838
  });
2350
- 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 }) => {
2351
3840
  const ctx = getCtx();
2352
3841
  const orgRef = await resolveOrg(ctx, org);
2353
3842
  return (await ctx.client.listLeases(orgRef.id)).leases.filter((l) => l.provider === "mysql");
2354
3843
  });
2355
- 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, {
2356
3845
  org: z.string().optional(),
2357
3846
  leaseId: z.string()
2358
3847
  }, async ({ org, leaseId }) => {
@@ -2364,7 +3853,7 @@ async function runMcpServer(options = {}) {
2364
3853
  leaseId
2365
3854
  };
2366
3855
  });
2367
- 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, {
2368
3857
  org: z.string(),
2369
3858
  app: z.string(),
2370
3859
  dir: z.string().optional()
@@ -2389,7 +3878,7 @@ async function runMcpServer(options = {}) {
2389
3878
  /**
2390
3879
  * `@seekrit/mcp` — a standalone, `npx`-able entrypoint for seekrit's MCP server.
2391
3880
  *
2392
- * This is a thin wrapper: the server itself (all 27 tools, the crypto plane of
3881
+ * This is a thin wrapper: the server itself (every tool, the crypto plane of
2393
3882
  * the two-server design) lives in `@seekrit/cli` and is shared with the
2394
3883
  * `seekrit mcp` subcommand — this package just publishes it as its own binary so
2395
3884
  * an agent can run it with zero prior install:
@@ -2402,7 +3891,7 @@ async function runMcpServer(options = {}) {
2402
3891
  * `seekrit mcp`. tsdown bundles the shared server source in at build time, so the
2403
3892
  * published package is self-contained and needs no `@seekrit/cli` install.
2404
3893
  */
2405
- runMcpServer({ version: "0.5.0" }).catch((err) => {
3894
+ runMcpServer({ version: "0.6.1" }).catch((err) => {
2406
3895
  const message = err instanceof Error ? err.message : String(err);
2407
3896
  process.stderr.write(`seekrit-mcp: fatal: ${message}\n`);
2408
3897
  process.exit(1);