@secrefs/node 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,749 @@
1
+ // src/providers/aws.ts
2
+ import {
3
+ GetSecretValueCommand,
4
+ ListSecretsCommand,
5
+ SecretsManagerClient
6
+ } from "@aws-sdk/client-secrets-manager";
7
+
8
+ // src/providers/base.ts
9
+ var SecretFetchError = class extends Error {
10
+ constructor(provider, path2, cause) {
11
+ super(`[${provider}] failed to fetch secret at "${path2}": ${errorMessage(cause)}`);
12
+ this.provider = provider;
13
+ this.path = path2;
14
+ this.name = "SecretFetchError";
15
+ }
16
+ provider;
17
+ path;
18
+ };
19
+ var BaseSecretProvider = class {
20
+ async fetchBatch(requests) {
21
+ const settled = await Promise.allSettled(requests.map((r) => this.fetchOne(r)));
22
+ return settled.map((result, i) => {
23
+ const request = requests[i];
24
+ if (result.status === "fulfilled") {
25
+ return result.value;
26
+ }
27
+ throw new SecretFetchError(this.name, request?.path ?? "<unknown>", result.reason);
28
+ });
29
+ }
30
+ };
31
+ function errorMessage(err) {
32
+ if (err instanceof Error) return err.message;
33
+ return String(err);
34
+ }
35
+ function extractField(raw, field, context) {
36
+ if (!field) return raw;
37
+ let parsed;
38
+ try {
39
+ parsed = JSON.parse(raw);
40
+ } catch {
41
+ throw new Error(
42
+ `[${context.provider}] secret at "${context.path}" is not JSON, cannot extract field "${field}"`
43
+ );
44
+ }
45
+ let current = parsed;
46
+ for (const part of field.split(".")) {
47
+ if (current === null || typeof current !== "object") {
48
+ throw new Error(
49
+ `[${context.provider}] field "${field}" not found in secret at "${context.path}"`
50
+ );
51
+ }
52
+ current = current[part];
53
+ }
54
+ if (current === void 0) {
55
+ throw new Error(
56
+ `[${context.provider}] field "${field}" not found in secret at "${context.path}"`
57
+ );
58
+ }
59
+ return typeof current === "object" ? JSON.stringify(current) : String(current);
60
+ }
61
+
62
+ // src/ttlCache.ts
63
+ var TtlCache = class {
64
+ /** Settled values, only populated when a TTL is configured. */
65
+ entries = /* @__PURE__ */ new Map();
66
+ /** Requests currently in flight, tracked separately from `entries`
67
+ * because coalescing and caching are different things: sharing an
68
+ * unsettled request holds no value past the moment it resolves, so it
69
+ * stays correct even with caching fully disabled. */
70
+ inFlight = /* @__PURE__ */ new Map();
71
+ ttlMs;
72
+ now;
73
+ constructor(options = {}) {
74
+ this.ttlMs = options.ttlMs ?? 0;
75
+ this.now = options.now ?? Date.now;
76
+ }
77
+ /**
78
+ * Returns the cached value for `key` if it's still fresh, otherwise
79
+ * calls `load` and caches that. In-flight promises are shared, so N
80
+ * concurrent expansions of the same reference make one request rather
81
+ * than N even when the TTL is zero - that's request coalescing, not
82
+ * caching, and it doesn't hold a value past its use.
83
+ *
84
+ * A rejected load is evicted rather than remembered, so a transient
85
+ * failure doesn't become a sticky one.
86
+ */
87
+ async fetch(key, load) {
88
+ const pendingExisting = this.inFlight.get(key);
89
+ if (pendingExisting) return pendingExisting;
90
+ const cached = this.entries.get(key);
91
+ if (cached && this.ttlMs > 0 && this.now() - cached.storedAt < this.ttlMs) {
92
+ return cached.value;
93
+ }
94
+ const pending = load();
95
+ this.inFlight.set(key, pending);
96
+ try {
97
+ const value = await pending;
98
+ if (this.ttlMs > 0) {
99
+ this.entries.set(key, { value: Promise.resolve(value), storedAt: this.now() });
100
+ }
101
+ return value;
102
+ } catch (err) {
103
+ this.entries.delete(key);
104
+ throw err;
105
+ } finally {
106
+ this.inFlight.delete(key);
107
+ }
108
+ }
109
+ /** Drops everything - used when a credential changes underneath the
110
+ * cache and anything fetched with the old one is suspect. */
111
+ clear() {
112
+ this.entries.clear();
113
+ }
114
+ };
115
+
116
+ // src/controlPlaneClient.ts
117
+ var ControlPlaneRequestError = class extends Error {
118
+ constructor(status, message) {
119
+ super(message);
120
+ this.status = status;
121
+ this.name = "ControlPlaneRequestError";
122
+ }
123
+ status;
124
+ };
125
+ var ControlPlaneClient = class {
126
+ baseUrl;
127
+ token;
128
+ fetchImpl;
129
+ constructor(options) {
130
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
131
+ this.token = options.token;
132
+ this.fetchImpl = options.fetchImpl ?? fetch;
133
+ }
134
+ /** Authenticates, authorizes, and resolves a credential for `alias`/`path`
135
+ * - see the control plane's `POST /v1/credentials/mint`. Throws
136
+ * `ControlPlaneRequestError` for any non-2xx response. */
137
+ async mintCredential(alias, path2) {
138
+ let response;
139
+ try {
140
+ response = await this.fetchImpl(`${this.baseUrl}/v1/credentials/mint`, {
141
+ method: "POST",
142
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
143
+ body: JSON.stringify({ alias, path: path2 })
144
+ });
145
+ } catch (err) {
146
+ throw new Error(
147
+ `could not reach control plane at ${this.baseUrl}: ${err instanceof Error ? err.message : String(err)}`
148
+ );
149
+ }
150
+ if (!response.ok) {
151
+ const body = await response.json().catch(() => ({}));
152
+ throw new ControlPlaneRequestError(
153
+ response.status,
154
+ body.error ?? `control plane returned ${response.status} for alias "${alias}" path "${path2}"`
155
+ );
156
+ }
157
+ return await response.json();
158
+ }
159
+ };
160
+
161
+ // src/providers/aws.ts
162
+ var AwsSecretsManagerProvider = class extends BaseSecretProvider {
163
+ name = "aws";
164
+ explicitClient;
165
+ region;
166
+ controlPlane;
167
+ controlPlaneClient;
168
+ ambientClient = null;
169
+ rawCache;
170
+ constructor(options = {}) {
171
+ super();
172
+ this.explicitClient = options.client;
173
+ this.region = options.region;
174
+ this.controlPlane = options.controlPlane;
175
+ this.rawCache = new TtlCache({ ttlMs: options.cacheTtlMs });
176
+ if (this.controlPlane) {
177
+ this.controlPlaneClient = this.controlPlane.client ?? new ControlPlaneClient({ baseUrl: this.controlPlane.baseUrl, token: this.controlPlane.token });
178
+ }
179
+ }
180
+ /** Resolves the `SecretsManagerClient` to use for one `path` - lazily
181
+ * built and reused in ambient mode, freshly minted per call in
182
+ * control-plane mode. `explicitClient` (test injection) always wins. */
183
+ async clientFor(path2) {
184
+ if (this.explicitClient) return this.explicitClient;
185
+ if (this.controlPlane && this.controlPlaneClient) {
186
+ const minted = await this.controlPlaneClient.mintCredential(this.controlPlane.alias, path2);
187
+ if (minted.provider !== "aws") {
188
+ throw new Error(
189
+ `control plane returned a "${minted.provider}" credential for alias "${this.controlPlane.alias}", expected "aws"`
190
+ );
191
+ }
192
+ return this.buildClientFromMintedCredentials(minted.credentials);
193
+ }
194
+ if (!this.ambientClient) this.ambientClient = new SecretsManagerClient({ region: this.region });
195
+ return this.ambientClient;
196
+ }
197
+ buildClientFromMintedCredentials(credentials) {
198
+ return new SecretsManagerClient({
199
+ region: this.region,
200
+ credentials: {
201
+ accessKeyId: credentials.accessKeyId,
202
+ secretAccessKey: credentials.secretAccessKey,
203
+ sessionToken: credentials.sessionToken
204
+ }
205
+ });
206
+ }
207
+ getRaw(path2) {
208
+ return this.rawCache.fetch(path2, async () => {
209
+ try {
210
+ const client = await this.clientFor(path2);
211
+ const response = await client.send(new GetSecretValueCommand({ SecretId: path2 }));
212
+ if (typeof response.SecretString === "string") {
213
+ return response.SecretString;
214
+ }
215
+ if (response.SecretBinary) {
216
+ return Buffer.from(response.SecretBinary).toString("utf8");
217
+ }
218
+ throw new Error(`secret "${path2}" has no SecretString or SecretBinary payload`);
219
+ } catch (err) {
220
+ throw new Error(`could not fetch secret "${path2}": ${errorMessage(err)}`);
221
+ }
222
+ });
223
+ }
224
+ async fetchOne(request) {
225
+ const raw = await this.getRaw(request.path);
226
+ return extractField(raw, request.field, { provider: this.name, path: request.path });
227
+ }
228
+ async healthCheck() {
229
+ try {
230
+ if (this.controlPlane) {
231
+ const controlPlaneClient = this.controlPlane.client ?? new ControlPlaneClient({ baseUrl: this.controlPlane.baseUrl, token: this.controlPlane.token });
232
+ try {
233
+ await controlPlaneClient.mintCredential(this.controlPlane.alias, "__secrefs_health_check__");
234
+ } catch (err) {
235
+ if (err instanceof ControlPlaneRequestError) {
236
+ return { provider: this.name, ok: true, message: "control plane reachable" };
237
+ }
238
+ throw err;
239
+ }
240
+ return { provider: this.name, ok: true };
241
+ }
242
+ const client = await this.clientFor("__secrefs_health_check__");
243
+ await client.send(new ListSecretsCommand({ MaxResults: 1 }));
244
+ return { provider: this.name, ok: true };
245
+ } catch (err) {
246
+ return { provider: this.name, ok: false, message: errorMessage(err) };
247
+ }
248
+ }
249
+ };
250
+
251
+ // src/providers/vault.ts
252
+ import vaultFactory from "node-vault";
253
+ var VaultProvider = class extends BaseSecretProvider {
254
+ name = "vault";
255
+ explicitClient;
256
+ endpoint;
257
+ token;
258
+ client = null;
259
+ dataCache;
260
+ constructor(options = {}) {
261
+ super();
262
+ this.explicitClient = options.client;
263
+ this.endpoint = options.endpoint ?? process.env.VAULT_ADDR;
264
+ this.token = options.token ?? process.env.VAULT_TOKEN;
265
+ this.dataCache = new TtlCache({ ttlMs: options.cacheTtlMs });
266
+ }
267
+ getClient() {
268
+ if (this.explicitClient) return this.explicitClient;
269
+ if (this.client) return this.client;
270
+ if (!this.endpoint) {
271
+ throw new Error("VAULT_ADDR is not set (required for sec://vault/... references)");
272
+ }
273
+ if (!this.token) {
274
+ throw new Error("VAULT_TOKEN is not set (required for sec://vault/... references)");
275
+ }
276
+ this.client = vaultFactory({ endpoint: this.endpoint, token: this.token });
277
+ return this.client;
278
+ }
279
+ getData(path2) {
280
+ return this.dataCache.fetch(
281
+ path2,
282
+ () => this.getClient().read(path2).then((response) => {
283
+ const outer = response.data;
284
+ if (outer === void 0 || outer === null) {
285
+ throw new Error(`no data returned for path "${path2}"`);
286
+ }
287
+ if (typeof outer === "object" && "data" in outer && "metadata" in outer) {
288
+ return outer.data;
289
+ }
290
+ return outer;
291
+ }).catch((err) => {
292
+ throw new Error(`could not read Vault path "${path2}": ${errorMessage(err)}`);
293
+ })
294
+ );
295
+ }
296
+ async fetchOne(request) {
297
+ const data = await this.getData(request.path);
298
+ if (!request.field) {
299
+ const keys = Object.keys(data);
300
+ if (keys.length === 1) {
301
+ const only = data[keys[0]];
302
+ return typeof only === "string" ? only : JSON.stringify(only);
303
+ }
304
+ return JSON.stringify(data);
305
+ }
306
+ return extractField(JSON.stringify(data), request.field, {
307
+ provider: this.name,
308
+ path: request.path
309
+ });
310
+ }
311
+ async healthCheck() {
312
+ try {
313
+ await this.getClient().health();
314
+ return { provider: this.name, ok: true };
315
+ } catch (err) {
316
+ return { provider: this.name, ok: false, message: errorMessage(err) };
317
+ }
318
+ }
319
+ };
320
+
321
+ // src/providers/local.ts
322
+ import { readFile } from "fs/promises";
323
+ import path from "path";
324
+ var DEFAULT_FILENAME = ".secrefs.local.json";
325
+ var LocalProvider = class extends BaseSecretProvider {
326
+ name = "local";
327
+ filePath;
328
+ /** Re-read on every fetch. The file is local and tiny, and caching
329
+ * it meant editing it mid-session silently did nothing. */
330
+ cache = null;
331
+ cacheFile;
332
+ constructor(options = {}) {
333
+ super();
334
+ this.filePath = options.filePath ?? process.env.SECREFS_LOCAL_FILE ?? path.join(process.cwd(), DEFAULT_FILENAME);
335
+ this.cacheFile = options.cacheFile ?? false;
336
+ }
337
+ async load() {
338
+ if (this.cache && this.cacheFile) return this.cache;
339
+ let raw;
340
+ try {
341
+ raw = await readFile(this.filePath, "utf8");
342
+ } catch (err) {
343
+ throw new Error(
344
+ `[local] could not read local secrets file at "${this.filePath}": ${err instanceof Error ? err.message : String(err)}. This file is gitignored by convention - see .secrefs.local.json in .gitignore.`
345
+ );
346
+ }
347
+ let parsed;
348
+ try {
349
+ parsed = JSON.parse(raw);
350
+ } catch (err) {
351
+ throw new Error(
352
+ `[local] "${this.filePath}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
353
+ );
354
+ }
355
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
356
+ throw new Error(`[local] "${this.filePath}" must contain a top-level JSON object`);
357
+ }
358
+ this.cache = parsed;
359
+ return this.cache;
360
+ }
361
+ async fetchOne(request) {
362
+ const data = await this.load();
363
+ if (!(request.path in data)) {
364
+ throw new Error(`[local] no entry for path "${request.path}" in ${this.filePath}`);
365
+ }
366
+ const entry = data[request.path];
367
+ const raw = typeof entry === "string" ? entry : JSON.stringify(entry);
368
+ return extractField(raw, request.field, { provider: this.name, path: request.path });
369
+ }
370
+ async healthCheck() {
371
+ try {
372
+ await this.load();
373
+ return { provider: this.name, ok: true, message: this.filePath };
374
+ } catch (err) {
375
+ return {
376
+ provider: this.name,
377
+ ok: false,
378
+ message: err instanceof Error ? err.message : String(err)
379
+ };
380
+ }
381
+ }
382
+ };
383
+
384
+ // src/providers/bitwarden.ts
385
+ import { BitwardenClient } from "@bitwarden/sdk-napi";
386
+ var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
387
+ var BitwardenProvider = class extends BaseSecretProvider {
388
+ name = "bitwarden";
389
+ explicitClient;
390
+ ambientAccessToken;
391
+ ambientOrganizationId;
392
+ apiUrl;
393
+ identityUrl;
394
+ stateFile;
395
+ controlPlane;
396
+ controlPlaneClient;
397
+ client = null;
398
+ loggedInAccessToken = null;
399
+ loggedIn = null;
400
+ organizationId;
401
+ /** Secret name -> id, populated by one `list()` call the first time a
402
+ * non-UUID path is requested. Invalidated if `organizationId` ever
403
+ * changes (control-plane mode, defensively - static in practice). */
404
+ nameToId = null;
405
+ constructor(options = {}) {
406
+ super();
407
+ this.explicitClient = options.client;
408
+ this.apiUrl = options.apiUrl ?? process.env.BWS_API_URL;
409
+ this.identityUrl = options.identityUrl ?? process.env.BWS_IDENTITY_URL;
410
+ this.stateFile = options.stateFile;
411
+ this.controlPlane = options.controlPlane;
412
+ if (this.controlPlane) {
413
+ this.controlPlaneClient = this.controlPlane.client ?? new ControlPlaneClient({ baseUrl: this.controlPlane.baseUrl, token: this.controlPlane.token });
414
+ } else {
415
+ this.ambientAccessToken = options.accessToken ?? process.env.BWS_ACCESS_TOKEN;
416
+ this.ambientOrganizationId = options.organizationId ?? process.env.BWS_ORGANIZATION_ID;
417
+ this.organizationId = this.ambientOrganizationId;
418
+ }
419
+ }
420
+ getClient() {
421
+ if (this.explicitClient) return this.explicitClient;
422
+ if (this.client) return this.client;
423
+ this.client = new BitwardenClient({
424
+ apiUrl: this.apiUrl,
425
+ identityUrl: this.identityUrl
426
+ });
427
+ return this.client;
428
+ }
429
+ async loginWith(accessToken, organizationId) {
430
+ if (organizationId !== this.organizationId) {
431
+ this.nameToId = null;
432
+ this.organizationId = organizationId;
433
+ }
434
+ if (this.loggedInAccessToken === accessToken && this.loggedIn) return this.loggedIn;
435
+ this.loggedInAccessToken = accessToken;
436
+ this.loggedIn = this.getClient().auth().loginAccessToken(accessToken, this.stateFile).catch((err) => {
437
+ this.loggedIn = null;
438
+ this.loggedInAccessToken = null;
439
+ throw new Error(`could not authenticate with the given access token: ${errorMessage(err)}`);
440
+ });
441
+ return this.loggedIn;
442
+ }
443
+ /** Ensures a session exists for `path`. Ambient mode logs in once
444
+ * (memoized) with the ambient token; control-plane mode requests a
445
+ * distribution for this specific `path` every call - see the
446
+ * `controlPlane` option's docs for why that RBAC check has to be
447
+ * per-path even though the token it returns doesn't vary. */
448
+ async ensureLoggedInFor(path2) {
449
+ if (!this.controlPlane) {
450
+ if (!this.ambientAccessToken) {
451
+ throw new Error("BWS_ACCESS_TOKEN is not set (required for sec://bitwarden/... references)");
452
+ }
453
+ return this.loginWith(this.ambientAccessToken, this.ambientOrganizationId);
454
+ }
455
+ const minted = await this.controlPlaneClient.mintCredential(this.controlPlane.alias, path2);
456
+ if (minted.provider !== "bitwarden") {
457
+ throw new Error(
458
+ `control plane returned a "${minted.provider}" credential for alias "${this.controlPlane.alias}", expected "bitwarden"`
459
+ );
460
+ }
461
+ return this.loginWith(minted.credentials.accessToken, minted.credentials.organizationId);
462
+ }
463
+ /** Assumes `ensureLoggedInFor(path)` has already run for this exact
464
+ * `path` - callers always do that first, so `this.organizationId` is
465
+ * already whatever this path's session resolved to. */
466
+ async resolveSecretId(path2) {
467
+ if (UUID_PATTERN.test(path2)) return path2;
468
+ if (!this.organizationId) {
469
+ throw new Error(
470
+ `"${path2}" is not a secret UUID, and no organizationId is available to look up a secret by name (set BWS_ORGANIZATION_ID, use the UUID directly, or - in control-plane mode - the distributed credential didn't include one)`
471
+ );
472
+ }
473
+ if (!this.nameToId) {
474
+ const organizationId = this.organizationId;
475
+ this.nameToId = (async () => {
476
+ const { data } = await this.getClient().secrets().list(organizationId);
477
+ return new Map(data.map((s) => [s.key, s.id]));
478
+ })();
479
+ }
480
+ const map = await this.nameToId;
481
+ const id = map.get(path2);
482
+ if (!id) {
483
+ throw new Error(`no secret named "${path2}" found in organization "${this.organizationId}"`);
484
+ }
485
+ return id;
486
+ }
487
+ async fetchOne(request) {
488
+ let id;
489
+ let secret;
490
+ try {
491
+ await this.ensureLoggedInFor(request.path);
492
+ id = await this.resolveSecretId(request.path);
493
+ secret = await this.getClient().secrets().get(id);
494
+ } catch (err) {
495
+ throw new Error(`could not fetch secret "${request.path}": ${errorMessage(err)}`);
496
+ }
497
+ return extractField(secret.value, request.field, { provider: this.name, path: request.path });
498
+ }
499
+ async healthCheck() {
500
+ try {
501
+ await this.ensureLoggedInFor("__secrefs_health_check__");
502
+ return { provider: this.name, ok: true };
503
+ } catch (err) {
504
+ return { provider: this.name, ok: false, message: errorMessage(err) };
505
+ }
506
+ }
507
+ };
508
+
509
+ // src/parser.ts
510
+ var SEC_REF_PATTERN = /^sec:\/\/([a-zA-Z0-9][a-zA-Z0-9_-]*)\/([^\s#]+)(?:#([^\s#]+))?$/;
511
+ var SecRefParseError = class extends Error {
512
+ constructor(raw, reason) {
513
+ super(`Invalid secret reference "${raw}": ${reason}`);
514
+ this.raw = raw;
515
+ this.reason = reason;
516
+ this.name = "SecRefParseError";
517
+ }
518
+ raw;
519
+ reason;
520
+ };
521
+ function isSecretRef(value) {
522
+ return typeof value === "string" && value.startsWith("sec://");
523
+ }
524
+ function parseSecretRef(raw) {
525
+ if (typeof raw !== "string") {
526
+ throw new SecRefParseError(String(raw), "reference must be a string");
527
+ }
528
+ const trimmed = raw.trim();
529
+ if (!trimmed.startsWith("sec://")) {
530
+ throw new SecRefParseError(raw, 'must start with "sec://"');
531
+ }
532
+ const match = SEC_REF_PATTERN.exec(trimmed);
533
+ if (!match) {
534
+ throw new SecRefParseError(
535
+ raw,
536
+ "does not match sec://<provider>/<path>[#field] format"
537
+ );
538
+ }
539
+ const [, provider, path2, field] = match;
540
+ if (!provider) {
541
+ throw new SecRefParseError(raw, "missing provider alias");
542
+ }
543
+ if (!path2) {
544
+ throw new SecRefParseError(raw, "missing secret path");
545
+ }
546
+ return {
547
+ raw,
548
+ provider: provider.toLowerCase(),
549
+ path: path2,
550
+ field: field || void 0
551
+ };
552
+ }
553
+ function tryParseSecretRef(raw) {
554
+ try {
555
+ return parseSecretRef(raw);
556
+ } catch {
557
+ return null;
558
+ }
559
+ }
560
+
561
+ // src/resolver.ts
562
+ var SecRefsResolutionError = class extends Error {
563
+ constructor(errors) {
564
+ super(
565
+ `Failed to resolve ${errors.length} secret reference(s):
566
+ ` + errors.map((e) => ` - ${e.key}: ${e.ref} -> ${e.message}`).join("\n")
567
+ );
568
+ this.errors = errors;
569
+ this.name = "SecRefsResolutionError";
570
+ }
571
+ errors;
572
+ };
573
+ async function resolveOne(ref, providers) {
574
+ const provider = providers[ref.provider];
575
+ if (!provider) {
576
+ const available = Object.keys(providers).join(", ") || "none configured";
577
+ throw new Error(`unknown provider "${ref.provider}" (available: ${available})`);
578
+ }
579
+ const request = { path: ref.path, field: ref.field };
580
+ return provider.fetchOne(request);
581
+ }
582
+ async function expandKeyValueMap(input, options) {
583
+ const strict = options.strict ?? true;
584
+ const output = {};
585
+ const pending = [];
586
+ for (const [key, value] of Object.entries(input)) {
587
+ if (value === void 0) continue;
588
+ if (!isSecretRef(value)) {
589
+ output[key] = value;
590
+ continue;
591
+ }
592
+ try {
593
+ pending.push({ key, ref: parseSecretRef(value) });
594
+ } catch (err) {
595
+ if (strict) throw err;
596
+ output[key] = value;
597
+ }
598
+ }
599
+ if (pending.length === 0) {
600
+ return output;
601
+ }
602
+ const settled = await Promise.allSettled(
603
+ pending.map(({ ref }) => resolveOne(ref, options.providers))
604
+ );
605
+ const errors = [];
606
+ settled.forEach((result, i) => {
607
+ const { key, ref } = pending[i];
608
+ if (result.status === "fulfilled") {
609
+ output[key] = result.value;
610
+ } else {
611
+ errors.push({ key, ref: ref.raw, message: errorMessage(result.reason) });
612
+ }
613
+ });
614
+ if (errors.length > 0) {
615
+ throw new SecRefsResolutionError(errors);
616
+ }
617
+ return output;
618
+ }
619
+ async function expandProcessEnv(options) {
620
+ const resolved = await expandKeyValueMap(process.env, options);
621
+ const changedKeys = [];
622
+ for (const [key, value] of Object.entries(resolved)) {
623
+ if (process.env[key] !== value) {
624
+ process.env[key] = value;
625
+ changedKeys.push(key);
626
+ }
627
+ }
628
+ return changedKeys;
629
+ }
630
+ async function checkReferences(input, options) {
631
+ const results = [];
632
+ const parsedEntries = [];
633
+ for (const [key, value] of Object.entries(input)) {
634
+ if (value === void 0 || !isSecretRef(value)) continue;
635
+ try {
636
+ parsedEntries.push({ key, ref: parseSecretRef(value) });
637
+ } catch (err) {
638
+ results.push({ key, ref: value, provider: "unknown", ok: false, message: errorMessage(err) });
639
+ }
640
+ }
641
+ const settled = await Promise.allSettled(
642
+ parsedEntries.map(({ ref }) => resolveOne(ref, options.providers))
643
+ );
644
+ settled.forEach((result, i) => {
645
+ const { key, ref } = parsedEntries[i];
646
+ results.push({
647
+ key,
648
+ ref: ref.raw,
649
+ provider: ref.provider,
650
+ ok: result.status === "fulfilled",
651
+ message: result.status === "rejected" ? errorMessage(result.reason) : void 0
652
+ });
653
+ });
654
+ return results;
655
+ }
656
+
657
+ // src/envFile.ts
658
+ import { parse as parseDotenv } from "dotenv";
659
+ var UNQUOTED_SEC_REF_LINE = /^[ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(sec:\/\/\S.*)$/;
660
+ function recoverTruncatedSecRefs(rawText, parsed) {
661
+ const result = { ...parsed };
662
+ for (const line of rawText.split(/\r?\n/)) {
663
+ const match = UNQUOTED_SEC_REF_LINE.exec(line);
664
+ if (!match) continue;
665
+ const [, key, value] = match;
666
+ if (!key || value === void 0) continue;
667
+ result[key] = value.trimEnd();
668
+ }
669
+ return result;
670
+ }
671
+ function parseEnvFileText(rawText) {
672
+ const parsed = parseDotenv(rawText);
673
+ return recoverTruncatedSecRefs(rawText, parsed);
674
+ }
675
+
676
+ // src/index.ts
677
+ function createDefaultProviders() {
678
+ return {
679
+ aws: new AwsSecretsManagerProvider(),
680
+ vault: new VaultProvider(),
681
+ local: new LocalProvider(),
682
+ bitwarden: new BitwardenProvider()
683
+ };
684
+ }
685
+ var SecRefs = class {
686
+ providers;
687
+ strict;
688
+ constructor(options = {}) {
689
+ this.providers = options.providers ?? createDefaultProviders();
690
+ this.strict = options.strict ?? true;
691
+ }
692
+ get expandOptions() {
693
+ return { providers: this.providers, strict: this.strict };
694
+ }
695
+ /**
696
+ * Expands every `sec://` value found in `process.env`, mutating it in
697
+ * place. Returns the list of env var names that were rewritten.
698
+ */
699
+ async init() {
700
+ return expandProcessEnv(this.expandOptions);
701
+ }
702
+ /**
703
+ * Expands `sec://` values in an arbitrary key/value map (e.g. a parsed
704
+ * `.env` file) without touching `process.env`.
705
+ */
706
+ async expandEnv(env) {
707
+ return expandKeyValueMap(env, this.expandOptions);
708
+ }
709
+ /** Expands a single string if it's a `sec://` reference; otherwise returns it unchanged. */
710
+ async expandString(value) {
711
+ if (!isSecretRef(value)) return value;
712
+ const resolved = await expandKeyValueMap({ __value__: value }, this.expandOptions);
713
+ return resolved.__value__;
714
+ }
715
+ /**
716
+ * Dry-run validation of every `sec://` reference in `env` (defaults to
717
+ * `process.env`). Never returns plaintext secret values.
718
+ */
719
+ async check(env = process.env) {
720
+ return checkReferences(env, this.expandOptions);
721
+ }
722
+ };
723
+ var secRefs = new SecRefs();
724
+ export {
725
+ AwsSecretsManagerProvider,
726
+ BaseSecretProvider,
727
+ BitwardenProvider,
728
+ ControlPlaneClient,
729
+ ControlPlaneRequestError,
730
+ LocalProvider,
731
+ SecRefParseError,
732
+ SecRefs,
733
+ SecRefsResolutionError,
734
+ SecretFetchError,
735
+ TtlCache,
736
+ VaultProvider,
737
+ checkReferences,
738
+ createDefaultProviders,
739
+ expandKeyValueMap,
740
+ expandProcessEnv,
741
+ extractField,
742
+ isSecretRef,
743
+ parseEnvFileText,
744
+ parseSecretRef,
745
+ recoverTruncatedSecRefs,
746
+ secRefs,
747
+ tryParseSecretRef
748
+ };
749
+ //# sourceMappingURL=index.js.map