@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.
- package/dist/index.js +1592 -103
- 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 `${
|
|
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.
|
|
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`)
|
|
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
|
|
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
|
|
1233
|
-
async function
|
|
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
|
-
|
|
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:
|
|
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
|
-
/**
|
|
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/
|
|
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
|
-
*
|
|
1405
|
-
*
|
|
1406
|
-
*
|
|
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
|
|
1410
|
-
|
|
1411
|
-
|
|
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
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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("
|
|
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("
|
|
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
|
-
|
|
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("
|
|
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 (
|
|
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.
|
|
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);
|