@seekrit/mcp 0.6.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1522 -100
- 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}`);
|
|
@@ -1025,6 +2210,54 @@ var SeekritClient = class {
|
|
|
1025
2210
|
deleteLeaseTarget(orgId, targetId) {
|
|
1026
2211
|
return this.request("DELETE", `/v1/orgs/${orgId}/leases/targets/${targetId}`);
|
|
1027
2212
|
}
|
|
2213
|
+
listSyncConnections(orgId) {
|
|
2214
|
+
return this.request("GET", `/v1/orgs/${orgId}/sync/connections`);
|
|
2215
|
+
}
|
|
2216
|
+
/**
|
|
2217
|
+
* Mint (or re-read) the keypair for a connection id, *before* the connection
|
|
2218
|
+
* exists. Wrap the destination credential and every environment DEK to this
|
|
2219
|
+
* key, then pass the same id to {@link createSyncConnection}.
|
|
2220
|
+
*/
|
|
2221
|
+
getSyncConnectionKey(orgId, connectionId) {
|
|
2222
|
+
return this.request("GET", `/v1/orgs/${orgId}/sync/connections/${connectionId}/public-key`);
|
|
2223
|
+
}
|
|
2224
|
+
createSyncConnection(orgId, input) {
|
|
2225
|
+
return this.request("POST", `/v1/orgs/${orgId}/sync/connections`, input);
|
|
2226
|
+
}
|
|
2227
|
+
/** Test the stored credential against a destination. Never throws on a bad token. */
|
|
2228
|
+
verifySyncConnection(orgId, connectionId, destination) {
|
|
2229
|
+
return this.request("POST", `/v1/orgs/${orgId}/sync/connections/${connectionId}/verify`, { destination });
|
|
2230
|
+
}
|
|
2231
|
+
/** Deletes the connection, its bindings, its key grants, and its keypair. */
|
|
2232
|
+
deleteSyncConnection(orgId, connectionId) {
|
|
2233
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/sync/connections/${connectionId}`);
|
|
2234
|
+
}
|
|
2235
|
+
listSyncBindings(orgId) {
|
|
2236
|
+
return this.request("GET", `/v1/orgs/${orgId}/sync/bindings`);
|
|
2237
|
+
}
|
|
2238
|
+
/**
|
|
2239
|
+
* Enable sync for one environment. `wrappedDeks` must cover the target
|
|
2240
|
+
* environment *and* every group environment it composes, each wrapped to the
|
|
2241
|
+
* connection's public key — the API cannot compute these, which is what keeps
|
|
2242
|
+
* enabling sync a key-holder operation.
|
|
2243
|
+
*/
|
|
2244
|
+
createSyncBinding(orgId, input) {
|
|
2245
|
+
return this.request("POST", `/v1/orgs/${orgId}/sync/bindings`, input);
|
|
2246
|
+
}
|
|
2247
|
+
updateSyncBinding(orgId, bindingId, input) {
|
|
2248
|
+
return this.request("PATCH", `/v1/orgs/${orgId}/sync/bindings/${bindingId}`, input);
|
|
2249
|
+
}
|
|
2250
|
+
deleteSyncBinding(orgId, bindingId) {
|
|
2251
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/sync/bindings/${bindingId}`);
|
|
2252
|
+
}
|
|
2253
|
+
/** Push now, synchronously. */
|
|
2254
|
+
runSyncBinding(orgId, bindingId) {
|
|
2255
|
+
return this.request("POST", `/v1/orgs/${orgId}/sync/bindings/${bindingId}/run`);
|
|
2256
|
+
}
|
|
2257
|
+
listSyncRuns(orgId, bindingId) {
|
|
2258
|
+
const qs = bindingId ? `?bindingId=${encodeURIComponent(bindingId)}` : "";
|
|
2259
|
+
return this.request("GET", `/v1/orgs/${orgId}/sync/runs${qs}`);
|
|
2260
|
+
}
|
|
1028
2261
|
listLeases(orgId) {
|
|
1029
2262
|
return this.request("GET", `/v1/orgs/${orgId}/leases`);
|
|
1030
2263
|
}
|
|
@@ -1145,7 +2378,7 @@ function tryBuildContext(dotenvVars = {}) {
|
|
|
1145
2378
|
const config = readGlobalConfig();
|
|
1146
2379
|
const fromEnv = (key) => process.env[key] ?? dotenvVars[key];
|
|
1147
2380
|
const apiUrl = fromEnv("SEEKRIT_API_URL") ?? config.apiUrl ?? "https://api.seekrit.dev";
|
|
1148
|
-
const token = fromEnv("SEEKRIT_TOKEN") ?? config.token;
|
|
2381
|
+
const token = fromEnv("SEEKRIT_TOKEN") ?? config.token ?? config.sessionToken;
|
|
1149
2382
|
const devUser = fromEnv("SEEKRIT_DEV_USER") ?? config.devUser;
|
|
1150
2383
|
let auth;
|
|
1151
2384
|
if (token) auth = {
|
|
@@ -1163,7 +2396,8 @@ function tryBuildContext(dotenvVars = {}) {
|
|
|
1163
2396
|
auth,
|
|
1164
2397
|
client: CLI_CLIENT
|
|
1165
2398
|
}),
|
|
1166
|
-
auth
|
|
2399
|
+
auth,
|
|
2400
|
+
apiUrl
|
|
1167
2401
|
};
|
|
1168
2402
|
}
|
|
1169
2403
|
function isTokenAuth(ctx) {
|
|
@@ -1210,12 +2444,14 @@ async function resolveOrg(ctx, orgSlug) {
|
|
|
1210
2444
|
}
|
|
1211
2445
|
/**
|
|
1212
2446
|
* Resolve an environment to operate on — an application env (`--app --env`,
|
|
1213
|
-
* or the config's app + `--env`)
|
|
2447
|
+
* or the config's app + `--env`), a branch of one (`--branch`), or a group env
|
|
2448
|
+
* (`--group --env`).
|
|
1214
2449
|
*/
|
|
1215
2450
|
async function resolveEnvTarget(ctx, opts) {
|
|
1216
2451
|
const org = await resolveOrg(ctx, opts.org);
|
|
1217
2452
|
if (!opts.env) fail("specify --env");
|
|
1218
2453
|
if (opts.group) {
|
|
2454
|
+
if (opts.branch) fail("--branch applies to application environments, not groups");
|
|
1219
2455
|
const { groups } = await ctx.client.listGroups(org.id);
|
|
1220
2456
|
const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
|
|
1221
2457
|
if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
|
|
@@ -1228,34 +2464,59 @@ async function resolveEnvTarget(ctx, opts) {
|
|
|
1228
2464
|
label: `${group.slug}@${env.slug}`
|
|
1229
2465
|
};
|
|
1230
2466
|
}
|
|
1231
|
-
const
|
|
1232
|
-
if (!appSlug) fail("specify --app or --group (or run `seekrit init`)");
|
|
1233
|
-
const { apps } = await ctx.client.listApps(org.id);
|
|
1234
|
-
const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
|
|
1235
|
-
if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
|
|
2467
|
+
const app = await resolveApp(ctx, opts);
|
|
1236
2468
|
const { environments } = await ctx.client.listEnvs(org.id, app.id);
|
|
1237
2469
|
const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
|
|
1238
2470
|
if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
|
|
2471
|
+
if (opts.branch) {
|
|
2472
|
+
const branch = await resolveBranch(ctx, app, opts.branch);
|
|
2473
|
+
return {
|
|
2474
|
+
orgId: org.id,
|
|
2475
|
+
envId: branch.id,
|
|
2476
|
+
label: `${app.slug}/${env.slug}#${branch.slug}`
|
|
2477
|
+
};
|
|
2478
|
+
}
|
|
1239
2479
|
return {
|
|
1240
2480
|
orgId: org.id,
|
|
1241
2481
|
envId: env.id,
|
|
1242
2482
|
label: `${app.slug}/${env.slug}`
|
|
1243
2483
|
};
|
|
1244
2484
|
}
|
|
1245
|
-
/** Resolve
|
|
1246
|
-
async function
|
|
2485
|
+
/** Resolve the target application from a flag or the committed config. */
|
|
2486
|
+
async function resolveApp(ctx, opts) {
|
|
1247
2487
|
const org = await resolveOrg(ctx, opts.org);
|
|
1248
2488
|
const appSlug = opts.app ?? findProjectConfig()?.app;
|
|
1249
2489
|
if (!appSlug) fail("specify --app (or run `seekrit init`)");
|
|
1250
|
-
if (!opts.env) fail("specify --env");
|
|
1251
2490
|
const { apps } = await ctx.client.listApps(org.id);
|
|
1252
2491
|
const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
|
|
1253
2492
|
if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
|
|
1254
|
-
|
|
2493
|
+
return {
|
|
2494
|
+
orgId: org.id,
|
|
2495
|
+
orgSlug: org.slug,
|
|
2496
|
+
id: app.id,
|
|
2497
|
+
slug: app.slug
|
|
2498
|
+
};
|
|
2499
|
+
}
|
|
2500
|
+
/**
|
|
2501
|
+
* Find a branch by slug (or id) anywhere in an application. Branch slugs share
|
|
2502
|
+
* the application's environment namespace, so one lookup is unambiguous — no
|
|
2503
|
+
* need to name the parent environment.
|
|
2504
|
+
*/
|
|
2505
|
+
async function resolveBranch(ctx, app, branchRef) {
|
|
2506
|
+
const { branches } = await ctx.client.listAppBranches(app.orgId, app.id);
|
|
2507
|
+
const branch = branches.find((b) => b.slug === branchRef || b.id === branchRef);
|
|
2508
|
+
if (!branch) fail(`no branch "${branchRef}" in ${app.slug}`);
|
|
2509
|
+
return branch;
|
|
2510
|
+
}
|
|
2511
|
+
/** Resolve an application environment, keeping ids + slugs (for token binding). */
|
|
2512
|
+
async function resolveAppEnv(ctx, opts) {
|
|
2513
|
+
if (!opts.env) fail("specify --env");
|
|
2514
|
+
const app = await resolveApp(ctx, opts);
|
|
2515
|
+
const { environments } = await ctx.client.listEnvs(app.orgId, app.id);
|
|
1255
2516
|
const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
|
|
1256
2517
|
if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
|
|
1257
2518
|
return {
|
|
1258
|
-
orgId:
|
|
2519
|
+
orgId: app.orgId,
|
|
1259
2520
|
appId: app.id,
|
|
1260
2521
|
appSlug: app.slug,
|
|
1261
2522
|
envId: env.id,
|
|
@@ -1357,12 +2618,16 @@ function readM2mCreds(dotenvVars = {}) {
|
|
|
1357
2618
|
clientSecret
|
|
1358
2619
|
};
|
|
1359
2620
|
}
|
|
1360
|
-
/**
|
|
2621
|
+
/**
|
|
2622
|
+
* True when a service/session/dev credential is already configured explicitly.
|
|
2623
|
+
* A browser-authorized session counts: a human who ran `seekrit login` must not
|
|
2624
|
+
* be silently swapped onto a machine identity.
|
|
2625
|
+
*/
|
|
1361
2626
|
function hasExplicitCredential(dotenvVars) {
|
|
1362
2627
|
const fromEnv = (key) => process.env[key] ?? dotenvVars[key];
|
|
1363
2628
|
if (fromEnv("SEEKRIT_TOKEN") || fromEnv("SEEKRIT_DEV_USER")) return true;
|
|
1364
2629
|
const config = readGlobalConfig();
|
|
1365
|
-
return Boolean(config.token || config.devUser);
|
|
2630
|
+
return Boolean(config.token || config.sessionToken || config.devUser);
|
|
1366
2631
|
}
|
|
1367
2632
|
/** Mint a fresh org-scoped admin token using M2M credentials (keyless). */
|
|
1368
2633
|
async function mintAdminToken(apiUrl, creds) {
|
|
@@ -1412,41 +2677,59 @@ async function ensureM2mAdminToken(dotenvVars = {}) {
|
|
|
1412
2677
|
return token;
|
|
1413
2678
|
}
|
|
1414
2679
|
//#endregion
|
|
1415
|
-
//#region ../cli/src/
|
|
2680
|
+
//#region ../cli/src/cache.ts
|
|
2681
|
+
/** Render an age for a log line, rounded to its largest whole unit. */
|
|
2682
|
+
function humanize(ms) {
|
|
2683
|
+
const secs = Math.floor(ms / 1e3);
|
|
2684
|
+
if (secs < 60) return `${secs}s`;
|
|
2685
|
+
if (secs < 3600) return `${Math.floor(secs / 60)}m`;
|
|
2686
|
+
if (secs < 86400) return `${Math.floor(secs / 3600)}h`;
|
|
2687
|
+
return `${Math.floor(secs / 86400)}d`;
|
|
2688
|
+
}
|
|
1416
2689
|
/**
|
|
1417
|
-
*
|
|
1418
|
-
*
|
|
1419
|
-
*
|
|
1420
|
-
* not supported — keep those in seekrit itself.
|
|
2690
|
+
* Whether a failed resolve means the API was *unreachable* (the cache may stand
|
|
2691
|
+
* in) rather than *refusing us* (it must not). A refusal is an answer, and
|
|
2692
|
+
* revocation is supposed to take effect the moment it arrives.
|
|
1421
2693
|
*/
|
|
1422
|
-
function
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
let line = raw.trim();
|
|
1426
|
-
if (!line || line.startsWith("#")) continue;
|
|
1427
|
-
if (line.startsWith("export ")) line = line.slice(7).trimStart();
|
|
1428
|
-
const eq = line.indexOf("=");
|
|
1429
|
-
if (eq === -1) continue;
|
|
1430
|
-
const key = line.slice(0, eq).trim();
|
|
1431
|
-
if (!key) continue;
|
|
1432
|
-
let value = line.slice(eq + 1).trim();
|
|
1433
|
-
const quote = value[0];
|
|
1434
|
-
if (value.length >= 2 && (quote === "\"" || quote === "'") && value.at(-1) === quote) {
|
|
1435
|
-
value = value.slice(1, -1);
|
|
1436
|
-
if (quote === "\"") value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
|
|
1437
|
-
} else {
|
|
1438
|
-
const comment = value.indexOf(" #");
|
|
1439
|
-
if (comment !== -1) value = value.slice(0, comment).trim();
|
|
1440
|
-
}
|
|
1441
|
-
out[key] = value;
|
|
1442
|
-
}
|
|
1443
|
-
return out;
|
|
2694
|
+
function mayFallBack(err) {
|
|
2695
|
+
if (err instanceof SeekritApiError) return err.status >= 500 || err.status === 429;
|
|
2696
|
+
return true;
|
|
1444
2697
|
}
|
|
1445
|
-
/**
|
|
1446
|
-
|
|
2698
|
+
/**
|
|
2699
|
+
* Fetch + decrypt every secret in a single environment.
|
|
2700
|
+
*
|
|
2701
|
+
* `${OTHER_SECRET}` references are expanded (see `@seekrit/core`'s
|
|
2702
|
+
* `interpolate`) unless `raw` is set. Only this environment's own secrets are in
|
|
2703
|
+
* scope here — a reference to a secret inherited from a composed group is left
|
|
2704
|
+
* literal, because the group layers aren't fetched. `materializeEnv` is the
|
|
2705
|
+
* fully-layered view.
|
|
2706
|
+
*/
|
|
2707
|
+
async function fetchDecryptedSecrets(ctx, orgId, envId, opts = {}) {
|
|
1447
2708
|
const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
|
|
1448
2709
|
const entries = await Promise.all(secrets.map(async (secret) => [secret.name, await decryptSecret(dek, secret.ciphertext, secretAad(envId, secret.name))]));
|
|
1449
|
-
return Object.fromEntries(entries);
|
|
2710
|
+
return interpolateValues(Object.fromEntries(entries), !opts.raw).values;
|
|
2711
|
+
}
|
|
2712
|
+
/**
|
|
2713
|
+
* Expand `${OTHER_SECRET}` references in a merged variable set. A cycle becomes
|
|
2714
|
+
* the CLI's standard fatal exit — it is a config bug with no correct value to
|
|
2715
|
+
* emit. Pass `enabled: false` (`--no-interpolate`) to hand the set back as-is.
|
|
2716
|
+
*/
|
|
2717
|
+
function interpolateValues(values, enabled = true) {
|
|
2718
|
+
if (!enabled) return {
|
|
2719
|
+
values,
|
|
2720
|
+
interpolated: [],
|
|
2721
|
+
unresolvedRefs: []
|
|
2722
|
+
};
|
|
2723
|
+
try {
|
|
2724
|
+
const { values: expandedValues, expanded, unresolved } = interpolateSecrets(values);
|
|
2725
|
+
return {
|
|
2726
|
+
values: expandedValues,
|
|
2727
|
+
interpolated: expanded,
|
|
2728
|
+
unresolvedRefs: unresolved
|
|
2729
|
+
};
|
|
2730
|
+
} catch (err) {
|
|
2731
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
2732
|
+
}
|
|
1450
2733
|
}
|
|
1451
2734
|
/**
|
|
1452
2735
|
* Decrypt one historical version of a secret. Ciphertext is bound to
|
|
@@ -1469,34 +2752,82 @@ async function encryptAndSetSecret(ctx, orgId, envId, name, value) {
|
|
|
1469
2752
|
* Each layer's DEK is unwrapped once with the principal's private key and its
|
|
1470
2753
|
* ciphertext decrypted locally. `process.env` is NOT applied here — callers
|
|
1471
2754
|
* that spawn a process layer it on top so the live shell always wins.
|
|
2755
|
+
*
|
|
2756
|
+
* `${OTHER_SECRET}` references are expanded last, against the merged set, so a
|
|
2757
|
+
* reference always resolves to whichever layer won the name.
|
|
1472
2758
|
*/
|
|
1473
2759
|
async function materializeEnv(ctx, opts) {
|
|
1474
2760
|
const query = {};
|
|
1475
2761
|
if (opts.with && Object.keys(opts.with).length > 0) query.with = opts.with;
|
|
2762
|
+
if (opts.branch) query.branch = opts.branch;
|
|
1476
2763
|
if (!isTokenAuth(ctx)) {
|
|
1477
2764
|
if (!opts.envId) fail("specify --app and --env, or authenticate with a service token (SEEKRIT_TOKEN)");
|
|
1478
2765
|
query.env = opts.envId;
|
|
1479
2766
|
}
|
|
1480
|
-
const { scope, layers } = await ctx.
|
|
2767
|
+
const { scope, layers } = await resolveWithCache(ctx, query, opts.cache);
|
|
1481
2768
|
const privateKey = await getPrivateKey(ctx);
|
|
1482
2769
|
const values = {};
|
|
1483
2770
|
const provenance = {};
|
|
1484
2771
|
for (const layer of layers) {
|
|
1485
2772
|
const dek = await unwrapDek(layer.wrappedDek, privateKey);
|
|
1486
|
-
|
|
2773
|
+
let label;
|
|
2774
|
+
if (layer.source === "group") label = `group:${layer.groupSlug}@${layer.slug}`;
|
|
2775
|
+
else if (layer.source === "branch") label = `branch:${scope.appSlug}#${layer.slug}`;
|
|
2776
|
+
else label = `app:${scope.appSlug}/${layer.slug}`;
|
|
1487
2777
|
for (const secret of layer.secrets) {
|
|
1488
2778
|
values[secret.name] = await decryptSecret(dek, secret.ciphertext, secretAad(layer.environmentId, secret.name));
|
|
1489
2779
|
provenance[secret.name] = label;
|
|
1490
2780
|
}
|
|
1491
2781
|
}
|
|
2782
|
+
const loadedEnvFiles = overlayEnvFiles(values, provenance, opts.envFiles);
|
|
1492
2783
|
return {
|
|
1493
|
-
values,
|
|
2784
|
+
...interpolateValues(values, opts.interpolate !== false),
|
|
1494
2785
|
provenance,
|
|
1495
2786
|
scope,
|
|
1496
|
-
loadedEnvFiles
|
|
2787
|
+
loadedEnvFiles
|
|
1497
2788
|
};
|
|
1498
2789
|
}
|
|
1499
2790
|
/**
|
|
2791
|
+
* Resolve, going through the last-known-good cache when one is configured.
|
|
2792
|
+
*
|
|
2793
|
+
* Always live first: the cache exists for when the call cannot land, not to
|
|
2794
|
+
* save a round trip, so a recovered network is picked up on the very next
|
|
2795
|
+
* invocation. A *refused* resolve (401/403/…) drops the entry rather than
|
|
2796
|
+
* falling back to it — otherwise revoking a token would keep working offline
|
|
2797
|
+
* until the entry aged out.
|
|
2798
|
+
*/
|
|
2799
|
+
async function resolveWithCache(ctx, query, cache) {
|
|
2800
|
+
if (!cache) return ctx.client.resolve(query);
|
|
2801
|
+
try {
|
|
2802
|
+
const response = await ctx.client.resolve(query);
|
|
2803
|
+
try {
|
|
2804
|
+
cache.write(JSON.stringify(response));
|
|
2805
|
+
} catch (err) {
|
|
2806
|
+
warn(`could not update the cache: ${errorMessage(err)}`);
|
|
2807
|
+
}
|
|
2808
|
+
return response;
|
|
2809
|
+
} catch (err) {
|
|
2810
|
+
if (!mayFallBack(err)) {
|
|
2811
|
+
cache.invalidate();
|
|
2812
|
+
throw err;
|
|
2813
|
+
}
|
|
2814
|
+
const found = cache.read();
|
|
2815
|
+
if (found.kind === "hit") {
|
|
2816
|
+
warn(`${errorMessage(err)} — using cached secrets fetched ${humanize(found.ageMs)} ago`);
|
|
2817
|
+
return JSON.parse(found.body);
|
|
2818
|
+
}
|
|
2819
|
+
if (found.kind === "expired") warn(`cached secrets are ${humanize(found.ageMs)} old, past --cache-max-age`);
|
|
2820
|
+
else if (found.kind === "unusable") warn(`ignoring the cached secrets: ${found.reason}`);
|
|
2821
|
+
throw err;
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
function warn(text) {
|
|
2825
|
+
process.stderr.write(`seekrit: ${text}\n`);
|
|
2826
|
+
}
|
|
2827
|
+
function errorMessage(err) {
|
|
2828
|
+
return err instanceof Error ? err.message : String(err);
|
|
2829
|
+
}
|
|
2830
|
+
/**
|
|
1500
2831
|
* Overlay `.env` files onto an existing value/provenance set (later files win).
|
|
1501
2832
|
* Missing files are skipped. Returns the files that were actually loaded. Used
|
|
1502
2833
|
* both by {@link materializeEnv} and by `seekrit run`'s degraded path, where
|
|
@@ -1538,6 +2869,16 @@ function errText(err) {
|
|
|
1538
2869
|
isError: true
|
|
1539
2870
|
};
|
|
1540
2871
|
}
|
|
2872
|
+
/** Read-only and safe to repeat — every list/inspect tool. */
|
|
2873
|
+
const ro = {
|
|
2874
|
+
readOnly: true,
|
|
2875
|
+
idempotent: true
|
|
2876
|
+
};
|
|
2877
|
+
/** Removes or overwrites something; repeating it lands in the same state. */
|
|
2878
|
+
const destructive = {
|
|
2879
|
+
destructive: true,
|
|
2880
|
+
idempotent: true
|
|
2881
|
+
};
|
|
1541
2882
|
/**
|
|
1542
2883
|
* Short primer surfaced as the MCP server `instructions`. Most clients show
|
|
1543
2884
|
* this to the model on connect, so it has to orient an agent that lands here
|
|
@@ -1671,6 +3012,7 @@ async function materializeFor(ctx, o) {
|
|
|
1671
3012
|
if (!isTokenAuth(ctx)) envId = (await resolveAppEnv(ctx, o)).envId;
|
|
1672
3013
|
return materializeEnv(ctx, {
|
|
1673
3014
|
envId,
|
|
3015
|
+
branch: o.branch,
|
|
1674
3016
|
with: o.with,
|
|
1675
3017
|
envFiles: o.envFile ?? [".env"]
|
|
1676
3018
|
});
|
|
@@ -1705,10 +3047,17 @@ async function runMcpServer(options = {}) {
|
|
|
1705
3047
|
version: options.version ?? version$1
|
|
1706
3048
|
}, { instructions: serverInstructions() });
|
|
1707
3049
|
/** Register a tool whose handler returns data (serialized) or throws (→ isError). */
|
|
1708
|
-
const tool = (name, description, shape, handler) => {
|
|
3050
|
+
const tool = (name, description, hints, shape, handler) => {
|
|
1709
3051
|
server.registerTool(name, {
|
|
1710
3052
|
description,
|
|
1711
|
-
inputSchema: shape
|
|
3053
|
+
inputSchema: shape,
|
|
3054
|
+
annotations: {
|
|
3055
|
+
title: name,
|
|
3056
|
+
readOnlyHint: hints.readOnly ?? false,
|
|
3057
|
+
destructiveHint: hints.destructive ?? false,
|
|
3058
|
+
idempotentHint: hints.idempotent ?? false,
|
|
3059
|
+
openWorldHint: true
|
|
3060
|
+
}
|
|
1712
3061
|
}, (async (args) => {
|
|
1713
3062
|
try {
|
|
1714
3063
|
return jsonText(await handler(args));
|
|
@@ -1726,7 +3075,7 @@ async function runMcpServer(options = {}) {
|
|
|
1726
3075
|
openWorldHint: false
|
|
1727
3076
|
}
|
|
1728
3077
|
}, async () => jsonText(getStartedText()));
|
|
1729
|
-
tool("whoami", "Show the authenticated identity, its role, and accessible orgs.", {}, async () => {
|
|
3078
|
+
tool("whoami", "Show the authenticated identity, its role, and accessible orgs.", ro, {}, async () => {
|
|
1730
3079
|
const ctx = getCtx();
|
|
1731
3080
|
if (isTokenAuth(ctx) && ctx.auth.type === "bearer") {
|
|
1732
3081
|
const { tokenId } = await parseServiceToken(ctx.auth.token);
|
|
@@ -1747,13 +3096,13 @@ async function runMcpServer(options = {}) {
|
|
|
1747
3096
|
...await ctx.client.me()
|
|
1748
3097
|
};
|
|
1749
3098
|
});
|
|
1750
|
-
tool("list_orgs", "List organizations the caller can access.", {}, async () => (await getCtx().client.listOrgs()).orgs);
|
|
1751
|
-
tool("list_apps", "List applications in an organization.", { org: z.string().optional() }, async ({ org }) => {
|
|
3099
|
+
tool("list_orgs", "List organizations the caller can access.", ro, {}, async () => (await getCtx().client.listOrgs()).orgs);
|
|
3100
|
+
tool("list_apps", "List applications in an organization.", ro, { org: z.string().optional() }, async ({ org }) => {
|
|
1752
3101
|
const ctx = getCtx();
|
|
1753
3102
|
const orgRef = await resolveOrg(ctx, org);
|
|
1754
3103
|
return (await ctx.client.listApps(orgRef.id)).apps;
|
|
1755
3104
|
});
|
|
1756
|
-
tool("list_envs", "List environments of an application.", {
|
|
3105
|
+
tool("list_envs", "List environments of an application.", ro, {
|
|
1757
3106
|
org: z.string().optional(),
|
|
1758
3107
|
app: z.string()
|
|
1759
3108
|
}, async ({ org, app }) => {
|
|
@@ -1764,12 +3113,30 @@ async function runMcpServer(options = {}) {
|
|
|
1764
3113
|
if (!appRow) throw new Error(`no app "${app}" in ${orgRef.slug}`);
|
|
1765
3114
|
return (await ctx.client.listEnvs(orgRef.id, appRow.id)).environments;
|
|
1766
3115
|
});
|
|
1767
|
-
tool("
|
|
3116
|
+
tool("list_branches", "List ephemeral branch configs in an application (optionally just one environment's).", ro, {
|
|
3117
|
+
org: z.string().optional(),
|
|
3118
|
+
app: z.string(),
|
|
3119
|
+
env: z.string().optional()
|
|
3120
|
+
}, async ({ org, app, env }) => {
|
|
3121
|
+
const ctx = getCtx();
|
|
3122
|
+
const appRef = await resolveApp(ctx, {
|
|
3123
|
+
org,
|
|
3124
|
+
app
|
|
3125
|
+
});
|
|
3126
|
+
if (!env) return (await ctx.client.listAppBranches(appRef.orgId, appRef.id)).branches;
|
|
3127
|
+
const parent = await resolveAppEnv(ctx, {
|
|
3128
|
+
org,
|
|
3129
|
+
app,
|
|
3130
|
+
env
|
|
3131
|
+
});
|
|
3132
|
+
return (await ctx.client.listBranches(parent.orgId, parent.envId)).branches;
|
|
3133
|
+
});
|
|
3134
|
+
tool("list_groups", "List shared groups (reusable secret bags) in an organization.", ro, { org: z.string().optional() }, async ({ org }) => {
|
|
1768
3135
|
const ctx = getCtx();
|
|
1769
3136
|
const orgRef = await resolveOrg(ctx, org);
|
|
1770
3137
|
return (await ctx.client.listGroups(orgRef.id)).groups;
|
|
1771
3138
|
});
|
|
1772
|
-
tool("list_group_envs", "List a group's environments (per-slug value sets).", {
|
|
3139
|
+
tool("list_group_envs", "List a group's environments (per-slug value sets).", ro, {
|
|
1773
3140
|
org: z.string().optional(),
|
|
1774
3141
|
group: z.string()
|
|
1775
3142
|
}, async ({ org, group }) => {
|
|
@@ -1780,7 +3147,7 @@ async function runMcpServer(options = {}) {
|
|
|
1780
3147
|
});
|
|
1781
3148
|
return (await ctx.client.listGroupEnvs(g.orgId, g.id)).environments;
|
|
1782
3149
|
});
|
|
1783
|
-
tool("list_env_groups", "List the groups composed into an application environment (precedence order).", {
|
|
3150
|
+
tool("list_env_groups", "List the groups composed into an application environment (precedence order).", ro, {
|
|
1784
3151
|
org: z.string().optional(),
|
|
1785
3152
|
app: z.string(),
|
|
1786
3153
|
env: z.string()
|
|
@@ -1793,17 +3160,17 @@ async function runMcpServer(options = {}) {
|
|
|
1793
3160
|
});
|
|
1794
3161
|
return (await ctx.client.listEnvGroups(target.orgId, target.envId)).groups;
|
|
1795
3162
|
});
|
|
1796
|
-
tool("list_members", "List organization members and their public keys (for granting access).", { org: z.string().optional() }, async ({ org }) => {
|
|
3163
|
+
tool("list_members", "List organization members and their public keys (for granting access).", ro, { org: z.string().optional() }, async ({ org }) => {
|
|
1797
3164
|
const ctx = getCtx();
|
|
1798
3165
|
const orgRef = await resolveOrg(ctx, org);
|
|
1799
3166
|
return (await ctx.client.listMembers(orgRef.id)).members;
|
|
1800
3167
|
});
|
|
1801
|
-
tool("kms_list_keys", "List managed KMS keys the caller can see (metadata only).", { org: z.string().optional() }, async ({ org }) => {
|
|
3168
|
+
tool("kms_list_keys", "List managed KMS keys the caller can see (metadata only).", ro, { org: z.string().optional() }, async ({ org }) => {
|
|
1802
3169
|
const ctx = getCtx();
|
|
1803
3170
|
const orgRef = await resolveOrg(ctx, org);
|
|
1804
3171
|
return (await ctx.client.listKmsKeys(orgRef.id)).keys;
|
|
1805
3172
|
});
|
|
1806
|
-
tool("kms_create_key", "Create an org-scoped managed key. Material is generated locally and wrapped to each grantee (self plus any listed users/tokens); the server never sees it. Use the CLI for app/group-scoped keys.", {
|
|
3173
|
+
tool("kms_create_key", "Create an org-scoped managed key. Material is generated locally and wrapped to each grantee (self plus any listed users/tokens); the server never sees it. Use the CLI for app/group-scoped keys.", { idempotent: false }, {
|
|
1807
3174
|
org: z.string().optional(),
|
|
1808
3175
|
name: z.string(),
|
|
1809
3176
|
purpose: z.enum(["encrypt", "sign"]),
|
|
@@ -1844,7 +3211,7 @@ async function runMcpServer(options = {}) {
|
|
|
1844
3211
|
});
|
|
1845
3212
|
return key;
|
|
1846
3213
|
});
|
|
1847
|
-
tool("kms_grant", "Grant a principal (user email or token id) use of a key's current version.", {
|
|
3214
|
+
tool("kms_grant", "Grant a principal (user email or token id) use of a key's current version.", { idempotent: true }, {
|
|
1848
3215
|
org: z.string().optional(),
|
|
1849
3216
|
key: z.string(),
|
|
1850
3217
|
user: z.string().optional(),
|
|
@@ -1869,7 +3236,7 @@ async function runMcpServer(options = {}) {
|
|
|
1869
3236
|
key: k.name
|
|
1870
3237
|
};
|
|
1871
3238
|
});
|
|
1872
|
-
tool("kms_encrypt", "Encrypt a value under a managed encrypt key; returns a ce1 ciphertext blob. `context` (if given) is bound as AAD and must be supplied identically to decrypt.", {
|
|
3239
|
+
tool("kms_encrypt", "Encrypt a value under a managed encrypt key; returns a ce1 ciphertext blob. `context` (if given) is bound as AAD and must be supplied identically to decrypt.", { readOnly: true }, {
|
|
1873
3240
|
org: z.string().optional(),
|
|
1874
3241
|
key: z.string(),
|
|
1875
3242
|
plaintext: z.string(),
|
|
@@ -1886,7 +3253,7 @@ async function runMcpServer(options = {}) {
|
|
|
1886
3253
|
version: currentVersion
|
|
1887
3254
|
}, plaintext, context ?? "") };
|
|
1888
3255
|
});
|
|
1889
|
-
tool("kms_decrypt", "Decrypt a ce1 blob. Supply the same `context` used to encrypt.", {
|
|
3256
|
+
tool("kms_decrypt", "Decrypt a ce1 blob. Supply the same `context` used to encrypt.", ro, {
|
|
1890
3257
|
org: z.string().optional(),
|
|
1891
3258
|
key: z.string(),
|
|
1892
3259
|
ciphertext: z.string(),
|
|
@@ -1900,7 +3267,7 @@ async function runMcpServer(options = {}) {
|
|
|
1900
3267
|
const { material } = await kmsRecoverMaterial(ctx, orgRef.id, k.id, ref.version);
|
|
1901
3268
|
return { plaintext: await kmsDecrypt(material, ciphertext, context ?? "") };
|
|
1902
3269
|
});
|
|
1903
|
-
tool("kms_generate_data_key", "Generate a data key under a managed encrypt key (envelope encryption). Returns the plaintext key (base64) and its wrapped form to store.", {
|
|
3270
|
+
tool("kms_generate_data_key", "Generate a data key under a managed encrypt key (envelope encryption). Returns the plaintext key (base64) and its wrapped form to store.", { readOnly: true }, {
|
|
1904
3271
|
org: z.string().optional(),
|
|
1905
3272
|
key: z.string()
|
|
1906
3273
|
}, async ({ org, key }) => {
|
|
@@ -1919,7 +3286,7 @@ async function runMcpServer(options = {}) {
|
|
|
1919
3286
|
wrapped: dk.wrapped
|
|
1920
3287
|
};
|
|
1921
3288
|
});
|
|
1922
|
-
tool("kms_sign", "Sign a message with a managed signing key; returns an sg1 signature blob.", {
|
|
3289
|
+
tool("kms_sign", "Sign a message with a managed signing key; returns an sg1 signature blob.", { readOnly: true }, {
|
|
1923
3290
|
org: z.string().optional(),
|
|
1924
3291
|
key: z.string(),
|
|
1925
3292
|
message: z.string()
|
|
@@ -1935,7 +3302,7 @@ async function runMcpServer(options = {}) {
|
|
|
1935
3302
|
version: currentVersion
|
|
1936
3303
|
}, message) };
|
|
1937
3304
|
});
|
|
1938
|
-
tool("kms_verify", "Verify an sg1 signature over a message using a signing key's published public key (no grant needed).", {
|
|
3305
|
+
tool("kms_verify", "Verify an sg1 signature over a message using a signing key's published public key (no grant needed).", ro, {
|
|
1939
3306
|
org: z.string().optional(),
|
|
1940
3307
|
key: z.string(),
|
|
1941
3308
|
signature: z.string(),
|
|
@@ -1950,7 +3317,7 @@ async function runMcpServer(options = {}) {
|
|
|
1950
3317
|
if (!pub) throw new Error(`no published public key for version ${ref.version}`);
|
|
1951
3318
|
return { valid: await verifyMessage(await importVerifyingKey(pub), signature, message) };
|
|
1952
3319
|
});
|
|
1953
|
-
tool("list_secrets", "List secret names + versions in an environment (never values).", targetShape, async (o) => {
|
|
3320
|
+
tool("list_secrets", "List secret names + versions in an environment (never values).", ro, targetShape, async (o) => {
|
|
1954
3321
|
const ctx = getCtx();
|
|
1955
3322
|
const { orgId, envId } = await resolveTargetEnv(ctx, o);
|
|
1956
3323
|
const { secrets } = await ctx.client.listSecrets(orgId, envId);
|
|
@@ -1960,12 +3327,12 @@ async function runMcpServer(options = {}) {
|
|
|
1960
3327
|
updatedAt: s.updatedAt
|
|
1961
3328
|
}));
|
|
1962
3329
|
});
|
|
1963
|
-
tool("list_tokens", "List an organization's service tokens (never the secret token strings).", { org: z.string().optional() }, async ({ org }) => {
|
|
3330
|
+
tool("list_tokens", "List an organization's service tokens (never the secret token strings).", ro, { org: z.string().optional() }, async ({ org }) => {
|
|
1964
3331
|
const ctx = getCtx();
|
|
1965
3332
|
const orgRef = await resolveOrg(ctx, org);
|
|
1966
3333
|
return (await ctx.client.listTokens(orgRef.id)).tokens;
|
|
1967
3334
|
});
|
|
1968
|
-
tool("audit", "Read the organization's audit trail (most recent first).", {
|
|
3335
|
+
tool("audit", "Read the organization's audit trail (most recent first).", ro, {
|
|
1969
3336
|
org: z.string().optional(),
|
|
1970
3337
|
limit: z.number().int().min(1).max(200).optional(),
|
|
1971
3338
|
action: z.string().optional().describe("filter by action, e.g. secret.updated")
|
|
@@ -1977,14 +3344,14 @@ async function runMcpServer(options = {}) {
|
|
|
1977
3344
|
action
|
|
1978
3345
|
})).entries;
|
|
1979
3346
|
});
|
|
1980
|
-
tool("create_org", "Create an organization. Requires a user session — service tokens cannot own a Stytch org.", {
|
|
3347
|
+
tool("create_org", "Create an organization. Requires a user session — service tokens cannot own a Stytch org.", { idempotent: false }, {
|
|
1981
3348
|
name: z.string(),
|
|
1982
3349
|
slug: z.string()
|
|
1983
3350
|
}, async ({ name, slug }) => (await getCtx().client.createOrg({
|
|
1984
3351
|
name,
|
|
1985
3352
|
slug
|
|
1986
3353
|
})).org);
|
|
1987
|
-
tool("create_app", "Create an application in an organization.", {
|
|
3354
|
+
tool("create_app", "Create an application in an organization.", { idempotent: false }, {
|
|
1988
3355
|
org: z.string().optional(),
|
|
1989
3356
|
name: z.string(),
|
|
1990
3357
|
slug: z.string()
|
|
@@ -1996,7 +3363,7 @@ async function runMcpServer(options = {}) {
|
|
|
1996
3363
|
slug
|
|
1997
3364
|
})).app;
|
|
1998
3365
|
});
|
|
1999
|
-
tool("create_group", "Create a shared group (reusable secret bag) in an organization.", {
|
|
3366
|
+
tool("create_group", "Create a shared group (reusable secret bag) in an organization.", { idempotent: false }, {
|
|
2000
3367
|
org: z.string().optional(),
|
|
2001
3368
|
name: z.string(),
|
|
2002
3369
|
slug: z.string()
|
|
@@ -2008,7 +3375,7 @@ async function runMcpServer(options = {}) {
|
|
|
2008
3375
|
slug
|
|
2009
3376
|
})).group;
|
|
2010
3377
|
});
|
|
2011
|
-
tool("create_env", "Create an application environment. Generates the data key locally and grants it to the caller.", {
|
|
3378
|
+
tool("create_env", "Create an application environment. Generates the data key locally and grants it to the caller.", { idempotent: false }, {
|
|
2012
3379
|
org: z.string().optional(),
|
|
2013
3380
|
app: z.string(),
|
|
2014
3381
|
name: z.string(),
|
|
@@ -2026,7 +3393,56 @@ async function runMcpServer(options = {}) {
|
|
|
2026
3393
|
wrappedDek
|
|
2027
3394
|
})).environment;
|
|
2028
3395
|
});
|
|
2029
|
-
tool("
|
|
3396
|
+
tool("create_branch", "Fork an environment into an ephemeral branch (a per-PR / preview config). The branch inherits the parent's secrets by layering at read time — nothing is copied or re-encrypted — and holds only the values you override on it. Generates the branch's data key locally, grants it to the caller, and shares it with the parent's other readers.", { idempotent: false }, {
|
|
3397
|
+
org: z.string().optional(),
|
|
3398
|
+
app: z.string(),
|
|
3399
|
+
from: z.string().describe("the environment to branch"),
|
|
3400
|
+
slug: z.string().describe("the branch name, e.g. pr-142"),
|
|
3401
|
+
ttl: z.string().optional().describe("lifetime: 12h, 7d, 2w, … or `never` (default 7d)")
|
|
3402
|
+
}, async ({ org, app, from, slug, ttl }) => {
|
|
3403
|
+
const ctx = getCtx();
|
|
3404
|
+
const parent = await resolveAppEnv(ctx, {
|
|
3405
|
+
org,
|
|
3406
|
+
app,
|
|
3407
|
+
env: from
|
|
3408
|
+
});
|
|
3409
|
+
const parsedTtl = parseBranchTtl(ttl ?? "7d");
|
|
3410
|
+
if (parsedTtl === null) throw new Error(`invalid ttl "${ttl}" (try 12h, 7d, 2w, or never)`);
|
|
3411
|
+
const me = await kmsCallerIdentity(ctx);
|
|
3412
|
+
const dek = generateDek();
|
|
3413
|
+
const wrappedDek = await wrapDek(dek, me.publicKeyJwk);
|
|
3414
|
+
const grants = [];
|
|
3415
|
+
const { grantees } = await ctx.client.listGrantees(parent.orgId, parent.envId);
|
|
3416
|
+
for (const grantee of grantees) {
|
|
3417
|
+
if (grantee.principalType === me.principalType && grantee.principalId === me.principalId) continue;
|
|
3418
|
+
grants.push({
|
|
3419
|
+
principalType: grantee.principalType,
|
|
3420
|
+
principalId: grantee.principalId,
|
|
3421
|
+
wrappedDek: await wrapDek(dek, grantee.publicKeyJwk)
|
|
3422
|
+
});
|
|
3423
|
+
}
|
|
3424
|
+
return (await ctx.client.createBranch(parent.orgId, parent.envId, {
|
|
3425
|
+
slug,
|
|
3426
|
+
ttlSeconds: Number.isFinite(parsedTtl) ? parsedTtl : null,
|
|
3427
|
+
wrappedDek,
|
|
3428
|
+
grants
|
|
3429
|
+
})).branch;
|
|
3430
|
+
});
|
|
3431
|
+
tool("delete_branch", "Tear down a branch config and every value it overrode. The parent environment is untouched.", destructive, {
|
|
3432
|
+
org: z.string().optional(),
|
|
3433
|
+
app: z.string(),
|
|
3434
|
+
branch: z.string()
|
|
3435
|
+
}, async ({ org, app, branch }) => {
|
|
3436
|
+
const ctx = getCtx();
|
|
3437
|
+
const appRef = await resolveApp(ctx, {
|
|
3438
|
+
org,
|
|
3439
|
+
app
|
|
3440
|
+
});
|
|
3441
|
+
const target = await resolveBranch(ctx, appRef, branch);
|
|
3442
|
+
await ctx.client.deleteBranch(appRef.orgId, target.id);
|
|
3443
|
+
return { deleted: target.slug };
|
|
3444
|
+
});
|
|
3445
|
+
tool("create_group_env", "Create a group environment. Generates the data key locally and grants it to the caller.", { idempotent: false }, {
|
|
2030
3446
|
org: z.string().optional(),
|
|
2031
3447
|
group: z.string(),
|
|
2032
3448
|
name: z.string(),
|
|
@@ -2044,7 +3460,7 @@ async function runMcpServer(options = {}) {
|
|
|
2044
3460
|
wrappedDek
|
|
2045
3461
|
})).environment;
|
|
2046
3462
|
});
|
|
2047
|
-
tool("compose_group", "Compose a group into an application environment (higher position wins on name clashes).", {
|
|
3463
|
+
tool("compose_group", "Compose a group into an application environment (higher position wins on name clashes).", { idempotent: true }, {
|
|
2048
3464
|
org: z.string().optional(),
|
|
2049
3465
|
app: z.string(),
|
|
2050
3466
|
env: z.string(),
|
|
@@ -2066,7 +3482,7 @@ async function runMcpServer(options = {}) {
|
|
|
2066
3482
|
position
|
|
2067
3483
|
})).group;
|
|
2068
3484
|
});
|
|
2069
|
-
tool("uncompose_group", "Remove a composed group from an application environment.", {
|
|
3485
|
+
tool("uncompose_group", "Remove a composed group from an application environment.", { idempotent: true }, {
|
|
2070
3486
|
org: z.string().optional(),
|
|
2071
3487
|
app: z.string(),
|
|
2072
3488
|
env: z.string(),
|
|
@@ -2085,7 +3501,7 @@ async function runMcpServer(options = {}) {
|
|
|
2085
3501
|
await ctx.client.unlinkEnvGroup(target.orgId, target.envId, g.id);
|
|
2086
3502
|
return { ok: true };
|
|
2087
3503
|
});
|
|
2088
|
-
tool("set_secret", "Encrypt a value locally and store it in an environment.", {
|
|
3504
|
+
tool("set_secret", "Encrypt a value locally and store it in an environment. A value may reference another secret as ${OTHER_SECRET}: the reference is stored literally and expanded whenever the secret is read, so it tracks the referenced value. Write $${OTHER_SECRET} for a literal.", { idempotent: false }, {
|
|
2089
3505
|
...targetShape,
|
|
2090
3506
|
name: z.string(),
|
|
2091
3507
|
value: z.string()
|
|
@@ -2099,10 +3515,11 @@ async function runMcpServer(options = {}) {
|
|
|
2099
3515
|
name: o.name
|
|
2100
3516
|
};
|
|
2101
3517
|
});
|
|
2102
|
-
tool("get_secret", "Return one secret. By default only reports presence + version; pass reveal:true to decrypt the plaintext into this response (avoid unless the value is actually needed — prefer run_command). Pass `version` to read an earlier version instead of the current one.", {
|
|
3518
|
+
tool("get_secret", "Return one secret. By default only reports presence + version; pass reveal:true to decrypt the plaintext into this response (avoid unless the value is actually needed — prefer run_command). A revealed current value has its ${OTHER_SECRET} references expanded against this environment's own secrets; pass raw:true for the stored text instead. Pass `version` to read an earlier version instead of the current one (always as stored, never expanded).", ro, {
|
|
2103
3519
|
...targetShape,
|
|
2104
3520
|
name: z.string(),
|
|
2105
3521
|
reveal: z.boolean().optional(),
|
|
3522
|
+
raw: z.boolean().optional().describe("skip ${OTHER_SECRET} expansion (with reveal)"),
|
|
2106
3523
|
version: z.number().int().positive().optional().describe("an earlier version from list_secret_versions (default: current)")
|
|
2107
3524
|
}, async (o) => {
|
|
2108
3525
|
const ctx = getCtx();
|
|
@@ -2127,7 +3544,7 @@ async function runMcpServer(options = {}) {
|
|
|
2127
3544
|
revealed: true
|
|
2128
3545
|
};
|
|
2129
3546
|
}
|
|
2130
|
-
const values = await fetchDecryptedSecrets(ctx, orgId, envId);
|
|
3547
|
+
const values = await fetchDecryptedSecrets(ctx, orgId, envId, { raw: o.raw });
|
|
2131
3548
|
if (!(o.name in values)) throw new Error(`no secret named ${o.name}`);
|
|
2132
3549
|
return {
|
|
2133
3550
|
name: o.name,
|
|
@@ -2135,7 +3552,7 @@ async function runMcpServer(options = {}) {
|
|
|
2135
3552
|
revealed: true
|
|
2136
3553
|
};
|
|
2137
3554
|
});
|
|
2138
|
-
tool("list_secret_versions", "List a secret's version history: who wrote each version, when, and which ones were restores. Never returns values — pair it with restore_secret to roll back, or get_secret(version, reveal:true) to inspect one.", {
|
|
3555
|
+
tool("list_secret_versions", "List a secret's version history: who wrote each version, when, and which ones were restores. Never returns values — pair it with restore_secret to roll back, or get_secret(version, reveal:true) to inspect one.", ro, {
|
|
2139
3556
|
...targetShape,
|
|
2140
3557
|
name: z.string(),
|
|
2141
3558
|
limit: z.number().int().min(1).max(200).optional().describe("default 20")
|
|
@@ -2153,7 +3570,7 @@ async function runMcpServer(options = {}) {
|
|
|
2153
3570
|
}))
|
|
2154
3571
|
};
|
|
2155
3572
|
});
|
|
2156
|
-
tool("restore_secret", "Roll a secret back to an earlier version. The stored ciphertext is replayed as a NEW version (history is append-only, nothing is overwritten). Keyless — no decryption happens, so this works even without a key.", {
|
|
3573
|
+
tool("restore_secret", "Roll a secret back to an earlier version. The stored ciphertext is replayed as a NEW version (history is append-only, nothing is overwritten). Keyless — no decryption happens, so this works even without a key.", { idempotent: false }, {
|
|
2157
3574
|
...targetShape,
|
|
2158
3575
|
name: z.string(),
|
|
2159
3576
|
version: z.number().int().positive()
|
|
@@ -2168,7 +3585,7 @@ async function runMcpServer(options = {}) {
|
|
|
2168
3585
|
version: secret.version
|
|
2169
3586
|
};
|
|
2170
3587
|
});
|
|
2171
|
-
tool("delete_secret", "Delete a secret from an environment.", {
|
|
3588
|
+
tool("delete_secret", "Delete a secret from an environment.", destructive, {
|
|
2172
3589
|
...targetShape,
|
|
2173
3590
|
name: z.string()
|
|
2174
3591
|
}, async (o) => {
|
|
@@ -2181,11 +3598,15 @@ async function runMcpServer(options = {}) {
|
|
|
2181
3598
|
};
|
|
2182
3599
|
});
|
|
2183
3600
|
tool("run_command", "Run a command with the resolved secrets injected as environment variables, and return its exit code + captured output. Secret VALUES are never returned — this is the preferred way to use secrets. process env > .env > app env > groups.", {
|
|
3601
|
+
destructive: true,
|
|
3602
|
+
idempotent: false
|
|
3603
|
+
}, {
|
|
2184
3604
|
command: z.string().describe("executable to run"),
|
|
2185
3605
|
args: z.array(z.string()).optional(),
|
|
2186
3606
|
org: z.string().optional(),
|
|
2187
3607
|
app: z.string().optional(),
|
|
2188
3608
|
env: z.string().optional().describe("environment slug (token auth infers this)"),
|
|
3609
|
+
branch: z.string().optional().describe("read an ephemeral branch of that environment"),
|
|
2189
3610
|
with: z.record(z.string(), z.string()).optional().describe("group=env slice overrides"),
|
|
2190
3611
|
envFile: z.array(z.string()).optional().describe(".env files to overlay (default [.env])"),
|
|
2191
3612
|
cwd: z.string().optional()
|
|
@@ -2202,11 +3623,12 @@ async function runMcpServer(options = {}) {
|
|
|
2202
3623
|
injectedVarCount: Object.keys(values).length
|
|
2203
3624
|
};
|
|
2204
3625
|
});
|
|
2205
|
-
tool("export_env", "Write the resolved secrets to a dotenv file on disk and return the variable names written (never the values). Use to materialize a .env for local tooling.", {
|
|
3626
|
+
tool("export_env", "Write the resolved secrets to a dotenv file on disk and return the variable names written (never the values). Use to materialize a .env for local tooling.", destructive, {
|
|
2206
3627
|
file: z.string().describe("path to write, e.g. .env"),
|
|
2207
3628
|
org: z.string().optional(),
|
|
2208
3629
|
app: z.string().optional(),
|
|
2209
3630
|
env: z.string().optional(),
|
|
3631
|
+
branch: z.string().optional().describe("read an ephemeral branch of that environment"),
|
|
2210
3632
|
with: z.record(z.string(), z.string()).optional()
|
|
2211
3633
|
}, async (o) => {
|
|
2212
3634
|
const ctx = getCtx();
|
|
@@ -2220,7 +3642,7 @@ async function runMcpServer(options = {}) {
|
|
|
2220
3642
|
names: Object.keys(values).sort()
|
|
2221
3643
|
};
|
|
2222
3644
|
});
|
|
2223
|
-
tool("create_token", "Mint a service token, printed once. Runtime tokens bind to one app environment (auto-granted its keys, so a command/agent can decrypt it). Pass admin:true for an org-scoped provisioning token (create apps/groups/envs, grant, mint tokens) — admin tokens need no env binding.", {
|
|
3645
|
+
tool("create_token", "Mint a service token, printed once. Runtime tokens bind to one app environment (auto-granted its keys, so a command/agent can decrypt it). Pass admin:true for an org-scoped provisioning token (create apps/groups/envs, grant, mint tokens) — admin tokens need no env binding.", { idempotent: false }, {
|
|
2224
3646
|
name: z.string().describe("display name, e.g. ci-deploy or agent-session"),
|
|
2225
3647
|
org: z.string().optional(),
|
|
2226
3648
|
app: z.string().optional().describe("bind to this app (runtime tokens)"),
|
|
@@ -2271,7 +3693,7 @@ async function runMcpServer(options = {}) {
|
|
|
2271
3693
|
note: "save this now — the secret token string is not stored and cannot be retrieved"
|
|
2272
3694
|
};
|
|
2273
3695
|
});
|
|
2274
|
-
tool("revoke_token", "Revoke a service token by id.", {
|
|
3696
|
+
tool("revoke_token", "Revoke a service token by id.", destructive, {
|
|
2275
3697
|
org: z.string().optional(),
|
|
2276
3698
|
tokenId: z.string()
|
|
2277
3699
|
}, async ({ org, tokenId }) => {
|
|
@@ -2283,7 +3705,7 @@ async function runMcpServer(options = {}) {
|
|
|
2283
3705
|
tokenId
|
|
2284
3706
|
};
|
|
2285
3707
|
});
|
|
2286
|
-
tool("grant_env", "Grant a member (by email) or service token (by id) access to an environment's data key. Re-wraps the DEK to the grantee — the caller must already hold the key.", {
|
|
3708
|
+
tool("grant_env", "Grant a member (by email) or service token (by id) access to an environment's data key. Re-wraps the DEK to the grantee — the caller must already hold the key.", { idempotent: true }, {
|
|
2287
3709
|
...targetShape,
|
|
2288
3710
|
user: z.string().optional().describe("org member email"),
|
|
2289
3711
|
token: z.string().optional().describe("service token id (skt_…)")
|
|
@@ -2328,12 +3750,12 @@ async function runMcpServer(options = {}) {
|
|
|
2328
3750
|
principalId
|
|
2329
3751
|
};
|
|
2330
3752
|
});
|
|
2331
|
-
tool("list_pg_targets", "List registered Postgres provisioning targets for temporary credentials.", { org: z.string().optional() }, async ({ org }) => {
|
|
3753
|
+
tool("list_pg_targets", "List registered Postgres provisioning targets for temporary credentials.", ro, { org: z.string().optional() }, async ({ org }) => {
|
|
2332
3754
|
const ctx = getCtx();
|
|
2333
3755
|
const orgRef = await resolveOrg(ctx, org);
|
|
2334
3756
|
return (await ctx.client.listLeaseTargets(orgRef.id)).targets;
|
|
2335
3757
|
});
|
|
2336
|
-
tool("create_pg_lease", "Mint a short-lived Postgres credential against a target and return a ready-to-use connection URL. The password is generated on THIS machine and only its SCRAM verifier is sent to the API — the plaintext never reaches seekrit or Postgres at rest. The role auto-expires; revoke early with revoke_pg_lease.", {
|
|
3758
|
+
tool("create_pg_lease", "Mint a short-lived Postgres credential against a target and return a ready-to-use connection URL. The password is generated on THIS machine and only its SCRAM verifier is sent to the API — the plaintext never reaches seekrit or Postgres at rest. The role auto-expires; revoke early with revoke_pg_lease.", { idempotent: false }, {
|
|
2337
3759
|
org: z.string().optional(),
|
|
2338
3760
|
target: z.string().describe("target id or name"),
|
|
2339
3761
|
role: z.string().optional().describe("role name to create (default: random tmp_ name)"),
|
|
@@ -2362,12 +3784,12 @@ async function runMcpServer(options = {}) {
|
|
|
2362
3784
|
note: "short-lived credential; it auto-expires and the plaintext is not stored anywhere"
|
|
2363
3785
|
};
|
|
2364
3786
|
});
|
|
2365
|
-
tool("list_pg_leases", "List Postgres leases (the ledger — never secret material).", { org: z.string().optional() }, async ({ org }) => {
|
|
3787
|
+
tool("list_pg_leases", "List Postgres leases (the ledger — never secret material).", ro, { org: z.string().optional() }, async ({ org }) => {
|
|
2366
3788
|
const ctx = getCtx();
|
|
2367
3789
|
const orgRef = await resolveOrg(ctx, org);
|
|
2368
3790
|
return (await ctx.client.listLeases(orgRef.id)).leases;
|
|
2369
3791
|
});
|
|
2370
|
-
tool("revoke_pg_lease", "Revoke a Postgres lease now (drops the role immediately).", {
|
|
3792
|
+
tool("revoke_pg_lease", "Revoke a Postgres lease now (drops the role immediately).", destructive, {
|
|
2371
3793
|
org: z.string().optional(),
|
|
2372
3794
|
leaseId: z.string()
|
|
2373
3795
|
}, async ({ org, leaseId }) => {
|
|
@@ -2379,12 +3801,12 @@ async function runMcpServer(options = {}) {
|
|
|
2379
3801
|
leaseId
|
|
2380
3802
|
};
|
|
2381
3803
|
});
|
|
2382
|
-
tool("list_mysql_targets", "List registered MySQL/MariaDB provisioning targets for temporary credentials.", { org: z.string().optional() }, async ({ org }) => {
|
|
3804
|
+
tool("list_mysql_targets", "List registered MySQL/MariaDB provisioning targets for temporary credentials.", ro, { org: z.string().optional() }, async ({ org }) => {
|
|
2383
3805
|
const ctx = getCtx();
|
|
2384
3806
|
const orgRef = await resolveOrg(ctx, org);
|
|
2385
3807
|
return (await ctx.client.listLeaseTargets(orgRef.id)).targets.filter((t) => t.provider === "mysql");
|
|
2386
3808
|
});
|
|
2387
|
-
tool("create_mysql_lease", "Mint a short-lived MySQL/MariaDB credential against a target and return a ready-to-use connection URL. The password is generated on THIS machine and only its mysql_native_password hash is sent to the API — the plaintext never reaches seekrit or MySQL at rest. The user auto-expires; revoke early with revoke_mysql_lease.", {
|
|
3809
|
+
tool("create_mysql_lease", "Mint a short-lived MySQL/MariaDB credential against a target and return a ready-to-use connection URL. The password is generated on THIS machine and only its mysql_native_password hash is sent to the API — the plaintext never reaches seekrit or MySQL at rest. The user auto-expires; revoke early with revoke_mysql_lease.", { idempotent: false }, {
|
|
2388
3810
|
org: z.string().optional(),
|
|
2389
3811
|
target: z.string().describe("target id or name"),
|
|
2390
3812
|
user: z.string().optional().describe("user name to create (default: random tmp_ name)"),
|
|
@@ -2414,12 +3836,12 @@ async function runMcpServer(options = {}) {
|
|
|
2414
3836
|
note: "short-lived credential; it auto-expires and the plaintext is not stored anywhere"
|
|
2415
3837
|
};
|
|
2416
3838
|
});
|
|
2417
|
-
tool("list_mysql_leases", "List MySQL/MariaDB leases (the ledger — never secret material).", { org: z.string().optional() }, async ({ org }) => {
|
|
3839
|
+
tool("list_mysql_leases", "List MySQL/MariaDB leases (the ledger — never secret material).", ro, { org: z.string().optional() }, async ({ org }) => {
|
|
2418
3840
|
const ctx = getCtx();
|
|
2419
3841
|
const orgRef = await resolveOrg(ctx, org);
|
|
2420
3842
|
return (await ctx.client.listLeases(orgRef.id)).leases.filter((l) => l.provider === "mysql");
|
|
2421
3843
|
});
|
|
2422
|
-
tool("revoke_mysql_lease", "Revoke a MySQL/MariaDB lease now (drops the user immediately).", {
|
|
3844
|
+
tool("revoke_mysql_lease", "Revoke a MySQL/MariaDB lease now (drops the user immediately).", destructive, {
|
|
2423
3845
|
org: z.string().optional(),
|
|
2424
3846
|
leaseId: z.string()
|
|
2425
3847
|
}, async ({ org, leaseId }) => {
|
|
@@ -2431,7 +3853,7 @@ async function runMcpServer(options = {}) {
|
|
|
2431
3853
|
leaseId
|
|
2432
3854
|
};
|
|
2433
3855
|
});
|
|
2434
|
-
tool("configure_project", "Link a directory to an org/app by writing seekrit.json (like `seekrit init`). The environment is chosen by the service token at runtime.", {
|
|
3856
|
+
tool("configure_project", "Link a directory to an org/app by writing seekrit.json (like `seekrit init`). The environment is chosen by the service token at runtime.", destructive, {
|
|
2435
3857
|
org: z.string(),
|
|
2436
3858
|
app: z.string(),
|
|
2437
3859
|
dir: z.string().optional()
|
|
@@ -2469,7 +3891,7 @@ async function runMcpServer(options = {}) {
|
|
|
2469
3891
|
* `seekrit mcp`. tsdown bundles the shared server source in at build time, so the
|
|
2470
3892
|
* published package is self-contained and needs no `@seekrit/cli` install.
|
|
2471
3893
|
*/
|
|
2472
|
-
runMcpServer({ version: "0.6.
|
|
3894
|
+
runMcpServer({ version: "0.6.1" }).catch((err) => {
|
|
2473
3895
|
const message = err instanceof Error ? err.message : String(err);
|
|
2474
3896
|
process.stderr.write(`seekrit-mcp: fatal: ${message}\n`);
|
|
2475
3897
|
process.exit(1);
|