@luckystack/secret-manager 0.1.8 → 0.2.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/CLAUDE.md +34 -11
- package/dist/index.d.ts +100 -5
- package/dist/index.js +272 -40
- package/dist/index.js.map +1 -1
- package/package.json +6 -2
package/CLAUDE.md
CHANGED
|
@@ -24,14 +24,17 @@ Three modes: `'remote'` (default — missing pointer / unreachable server throws
|
|
|
24
24
|
|
|
25
25
|
| Function / Export | One-liner | Deep doc |
|
|
26
26
|
| --- | --- | --- |
|
|
27
|
-
| `initSecretManager(config)` | Boot-time entry. Scans `process.env` for pointer-shaped values, `POST /resolve`s them, overwrites `process.env` with real values. No-op in `'local'`. Starts dev hot reload when `config.dev` is set. | -> docs/architecture.md |
|
|
28
|
-
| `refreshSecretManager()` | Re-resolve the captured pointers against the server (the
|
|
29
|
-
| `reloadSecretManagerFromFiles()` | Re-parse the configured env files (`dev.envFiles`, default `.env` + `.env.local`) and apply them: plain values injected into `process.env` (live config reload), pointer-shaped values re-resolved. The dev **file-watch** channel; callable manually. | -> docs/architecture.md |
|
|
30
|
-
| `
|
|
31
|
-
| `
|
|
32
|
-
|
|
|
27
|
+
| `initSecretManager(config)` | Boot-time entry. Scans `process.env` for pointer-shaped values, `POST /resolve`s them, overwrites `process.env` with real values. No-op in `'local'`. Starts dev hot reload when `config.dev` is set. Starts the production rotation poll when `config.pollIntervalMs` is set. | -> docs/architecture.md |
|
|
28
|
+
| `refreshSecretManager()` | Re-resolve the captured pointers against the server (the production rotation poll channel). Call manually after an admin rotates a secret on a long-running process. No-op in `'local'` mode. | -> docs/architecture.md |
|
|
29
|
+
| `reloadSecretManagerFromFiles()` | Re-parse the configured env files (`dev.envFiles`, default `.env` + `.env.local`) and apply them: plain values injected into `process.env` (live config reload), pointer-shaped values re-resolved. The dev **file-watch** channel; callable manually. No-op before init or in `'local'` mode. | -> docs/architecture.md |
|
|
30
|
+
| `stopSecretManager()` | Tear down all dev watchers, debounce timers, and rotation-poll intervals started by `initSecretManager`. Call on process shutdown if you need deterministic cleanup; otherwise timers are `unref`'d and won't block exit. | -> docs/architecture.md |
|
|
31
|
+
| `getCachedResolution()` | Returns a shallow copy of the last `{ fetchedAt, values }` (pointer -> resolved secret) for diagnostics, or `null`. **Sensitive** — never serialize into HTTP responses, health payloads, or logs. | -> docs/architecture.md |
|
|
32
|
+
| `getCachedResolutionMeta()` | Values-free diagnostic view: `{ fetchedAt, pointerNames, pointerCount }` — the resolved pointer names only, never the secret values. Safe for logs and health endpoints. | -> docs/architecture.md |
|
|
33
|
+
| `resetSecretManagerForTests()` | Test-only — clears all module state and tears down dev watchers / timers. | -> docs/architecture.md |
|
|
34
|
+
| Type `SecretManagerConfig` | `{ url; token; source?; pointerPattern?; envNames?; allowInsecureHttp?; timeoutMs?; retries?; resolvePath?; headers?; onApplied?; onResolveError?; fetchImpl?; pollIntervalMs?; dev? }` where `dev` is `{ watch?; pollIntervalMs?; envFiles? }`. | -> docs/architecture.md |
|
|
33
35
|
| Type `SecretManagerToken` | `string \| { fromFile: string }`. | -> docs/architecture.md |
|
|
34
36
|
| Type `CachedResolution` | `{ fetchedAt: number; values: Record<string, string> }`. | -> docs/architecture.md |
|
|
37
|
+
| Type `CachedResolutionMeta` | `{ fetchedAt: number; pointerNames: string[]; pointerCount: number }`. | -> docs/architecture.md |
|
|
35
38
|
|
|
36
39
|
### Internal helpers (not exported, listed for AI context)
|
|
37
40
|
|
|
@@ -57,15 +60,35 @@ Three modes: `'remote'` (default — missing pointer / unreachable server throws
|
|
|
57
60
|
|
|
58
61
|
This package reads **no** env vars itself — it consumes `SecretManagerConfig` (typically built in `config.ts` and passed in `server.ts`). The values you commonly source from env / a file:
|
|
59
62
|
|
|
60
|
-
|
|
|
63
|
+
| Key | Purpose | Notes |
|
|
64
|
+
| --- | --- | --- |
|
|
65
|
+
| `url` | Base URL of the secret-manager server (trailing slash optional). | Required (except `source:'local'`). |
|
|
66
|
+
| `token` | Shared bearer token: literal string or `{ fromFile }` (gitignored single-line file). | Read at resolve time — file rotation picked up on next poll. A `Bearer ` prefix is stripped + warned. |
|
|
67
|
+
| `source` | `'remote'` (default) / `'local'` / `'hybrid'`. | `'remote'` = hard boot stop on failure; `'hybrid'` = warn + keep local env. |
|
|
68
|
+
| `envNames` | Allowlist of env-var NAMES eligible for resolution: `string[]` or `(name) => boolean`. **Secure default: unset resolves NOTHING off-host** (a boot warning is emitted so the deny-all is never silent). Pass `() => true` to scan every name deliberately. | Required to actually resolve anything. |
|
|
69
|
+
| `dev` | Opt-in dev hot reload: `{ watch?, pollIntervalMs?, envFiles? }`. Ignored in production (`NODE_ENV !== 'development'|'test'`). | — |
|
|
70
|
+
|
|
71
|
+
### Advanced keys (direct-call-only — not needed for typical boot wiring)
|
|
72
|
+
|
|
73
|
+
| Key | Purpose |
|
|
61
74
|
| --- | --- |
|
|
62
|
-
| `
|
|
63
|
-
| `
|
|
64
|
-
| `
|
|
65
|
-
| `
|
|
75
|
+
| `pointerPattern` | Override the pointer-shape detector (default `/^(.+)_V(\d+)$/`). Stateful `g`/`y` flags are stripped automatically. |
|
|
76
|
+
| `allowInsecureHttp` | Permit `http:` to a non-loopback host. A loud warning is still emitted. Loopback is always permitted. |
|
|
77
|
+
| `timeoutMs` | Abort a black-hole server after N ms (default `10_000`). Set `0` to disable. |
|
|
78
|
+
| `retries` | `{ count, delayMs? }` — retry on transport error / non-2xx before giving up. Default `{ count: 0 }`. |
|
|
79
|
+
| `resolvePath` | Override the resolve endpoint path (default `'/resolve'`). |
|
|
80
|
+
| `headers` | Extra request headers merged onto every resolve request (cannot override `Authorization`). |
|
|
81
|
+
| `onApplied` | Called after secrets are written to `process.env` — receives changed env NAMES only (never the values). Use to re-create pools/SDK clients on rotation. |
|
|
82
|
+
| `onResolveError` | Called on resolve failure (alongside the existing `console.warn`). Route to Sentry/metrics; useful for `'hybrid'` where a silent warn is the default. |
|
|
83
|
+
| `fetchImpl` | Override the global `fetch`. For non-Node-20 hosts or test injection. |
|
|
84
|
+
| `pollIntervalMs` | Production rotation poll interval in ms (re-resolves in ALL environments). Default `0` (disabled). Timers are `unref`'d. |
|
|
66
85
|
|
|
67
86
|
`process.env` values matching `pointerPattern` (default `/^(.+)_V(\d+)$/`) are the pointers this client resolves and overwrites.
|
|
68
87
|
|
|
88
|
+
## Design note — single resolver per process
|
|
89
|
+
|
|
90
|
+
`@luckystack/secret-manager` uses module-level state (`activeConfig`, `pointerMap`, `cachedResolution`, `resolveChain`). This is intentional: there is one canonical view of `process.env`, so a second parallel resolver would race against the first. If you need to resolve different configs separately (e.g. a multi-stage bootstrap), call `stopSecretManager()` + `resetSecretManagerForTests()` between them (the latter is exported but named for test clarity — it is safe to call in non-test bootstrap code too). Running two resolvers concurrently against the same `process.env` is unsupported.
|
|
91
|
+
|
|
69
92
|
## Peer dependencies
|
|
70
93
|
|
|
71
94
|
- **None required.** Speaks plain HTTP via global `fetch`; reads the token file with Node's built-in `fs`.
|
package/dist/index.d.ts
CHANGED
|
@@ -16,11 +16,75 @@ interface SecretManagerConfig {
|
|
|
16
16
|
/**
|
|
17
17
|
* Override the pointer-shape detector. Any `process.env` value matching this
|
|
18
18
|
* is treated as a pointer; anything else is a literal and left untouched.
|
|
19
|
-
* Default `/^(.+)_V(\d+)$/`.
|
|
19
|
+
* Default `/^(.+)_V(\d+)$/`. A `g`/`y` flag is stripped (a stateful regex would
|
|
20
|
+
* misclassify alternating entries).
|
|
20
21
|
*/
|
|
21
22
|
pointerPattern?: RegExp;
|
|
23
|
+
/**
|
|
24
|
+
* Scope which `process.env` entries are pointer-eligible by NAME. This allowlist
|
|
25
|
+
* is REQUIRED to resolve anything off-host: an array of names or a predicate.
|
|
26
|
+
*
|
|
27
|
+
* SECURE DEFAULT: when unset, NOTHING is resolved off-host (a clear boot warning
|
|
28
|
+
* is emitted instead). Scanning the entire inherited environment would POST any
|
|
29
|
+
* unrelated, pointer-shaped inherited value (`RELEASE_TAG=build_2024_V2`) to the
|
|
30
|
+
* secret-manager server — so an explicit allowlist is mandatory. To opt back into
|
|
31
|
+
* scanning every name, pass `() => true` as a deliberate, auditable choice.
|
|
32
|
+
*/
|
|
33
|
+
envNames?: string[] | ((name: string) => boolean);
|
|
34
|
+
/**
|
|
35
|
+
* Allow a plain-`http:` server `url`. By default only `https:` is accepted for
|
|
36
|
+
* non-loopback hosts (the channel carries the bearer token + plaintext secrets);
|
|
37
|
+
* loopback (`localhost`/`127.0.0.1`/`[::1]`) is always permitted. Set `true` to
|
|
38
|
+
* permit `http:` to any host — a loud warning is still emitted.
|
|
39
|
+
*/
|
|
40
|
+
allowInsecureHttp?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Abort a resolve request that has not responded within this many ms. Prevents a
|
|
43
|
+
* black-hole server (accepts the TCP connection, never responds) from hanging boot
|
|
44
|
+
* until undici's ~300s default — and, in `'hybrid'`, from never reaching the
|
|
45
|
+
* warn-and-keep-local fallback (a hang never rejects). Default `10_000`. Set `0`
|
|
46
|
+
* to disable the timeout.
|
|
47
|
+
*/
|
|
48
|
+
timeoutMs?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Retry a failed resolve before giving up (transport error or non-2xx). Default
|
|
51
|
+
* `{ count: 0 }` (no retry). After exhaustion `'remote'` still throws and
|
|
52
|
+
* `'hybrid'` still warns-and-keeps-local.
|
|
53
|
+
*/
|
|
54
|
+
retries?: {
|
|
55
|
+
count: number;
|
|
56
|
+
delayMs?: number;
|
|
57
|
+
};
|
|
58
|
+
/** Override the resolve path appended to `url`. Default `'/resolve'`. */
|
|
59
|
+
resolvePath?: string;
|
|
60
|
+
/** Extra request headers merged onto every resolve request (cannot override `Authorization`). */
|
|
61
|
+
headers?: Record<string, string>;
|
|
62
|
+
/**
|
|
63
|
+
* Called after a resolve writes new values into `process.env`, with ONLY the env
|
|
64
|
+
* NAMES whose value actually changed (never the secret values). A client that
|
|
65
|
+
* captured a secret at construction time (Prisma from `DATABASE_URL`, a Redis /
|
|
66
|
+
* Stripe / OpenAI SDK) can re-create its pool/client here so a rotation lands.
|
|
67
|
+
*/
|
|
68
|
+
onApplied?: (changes: {
|
|
69
|
+
name: string;
|
|
70
|
+
pointer: string;
|
|
71
|
+
}[]) => void | Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* Called when a resolve fails, alongside the existing `console.warn` (current
|
|
74
|
+
* behavior is unchanged when unset). Route the failure to Sentry/metrics —
|
|
75
|
+
* useful for `'hybrid'`/dev where a failure otherwise silently keeps stale env.
|
|
76
|
+
*/
|
|
77
|
+
onResolveError?: (error: unknown, context: {
|
|
78
|
+
phase: 'boot' | 'refresh' | 'file-reload';
|
|
79
|
+
}) => void;
|
|
22
80
|
/** Override the global `fetch` (tests / non-Node-20 hosts). */
|
|
23
81
|
fetchImpl?: typeof fetch;
|
|
82
|
+
/**
|
|
83
|
+
* Re-resolve the current pointers every N ms in ALL environments (the production
|
|
84
|
+
* rotation poll), distinct from the dev-only `dev.pollIntervalMs` file-watch
|
|
85
|
+
* channel. Default `0` (disabled). Unref'd, so it never blocks process exit.
|
|
86
|
+
*/
|
|
87
|
+
pollIntervalMs?: number;
|
|
24
88
|
/**
|
|
25
89
|
* Opt-in dev-only hot reload. Ignored when `NODE_ENV === 'production'`.
|
|
26
90
|
* Provide an (even empty) object to enable it.
|
|
@@ -30,8 +94,7 @@ interface SecretManagerConfig {
|
|
|
30
94
|
* Watch the env files and hot-reload on change. Default `true`. On change
|
|
31
95
|
* the files are re-parsed and applied: plain (non-pointer) values are
|
|
32
96
|
* injected straight into `process.env` (live config reload), and
|
|
33
|
-
* pointer-shaped values are re-resolved against the server.
|
|
34
|
-
* optional `dotenv` peer to parse the files.
|
|
97
|
+
* pointer-shaped values are re-resolved against the server.
|
|
35
98
|
*/
|
|
36
99
|
watch?: boolean;
|
|
37
100
|
/** Re-resolve the current pointers every N ms (server-rotation poll). Default `0` (disabled). */
|
|
@@ -67,9 +130,41 @@ declare const refreshSecretManager: () => Promise<void>;
|
|
|
67
130
|
* `.env` / `.env.local` file watch; also callable manually.
|
|
68
131
|
*/
|
|
69
132
|
declare const reloadSecretManagerFromFiles: () => Promise<void>;
|
|
70
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* Read the last `{ fetchedAt, values }` resolution (pointer -> value), or `null`.
|
|
135
|
+
*
|
|
136
|
+
* ⚠️ SENSITIVE: `values` maps each pointer to its RAW resolved secret. The result
|
|
137
|
+
* is a shallow copy (values are flat strings, so mutating the returned object does
|
|
138
|
+
* not corrupt the cache), but the values are still the real secrets — NEVER
|
|
139
|
+
* serialize the result into an HTTP response, a `/health` payload, or a log line.
|
|
140
|
+
* For a safe diagnostic use {@link getCachedResolutionMeta}, which never exposes
|
|
141
|
+
* the values.
|
|
142
|
+
*/
|
|
71
143
|
declare const getCachedResolution: () => CachedResolution | null;
|
|
144
|
+
/** Values-free view of {@link getCachedResolution}: the timestamp + resolved pointer NAMES only. */
|
|
145
|
+
interface CachedResolutionMeta {
|
|
146
|
+
/** `Date.now()` of the last successful resolve. */
|
|
147
|
+
fetchedAt: number;
|
|
148
|
+
/** The resolved pointer strings (never the secret values). */
|
|
149
|
+
pointerNames: string[];
|
|
150
|
+
/** How many pointers were resolved. */
|
|
151
|
+
pointerCount: number;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Safe diagnostic accessor: the last resolution's timestamp + resolved pointer
|
|
155
|
+
* NAMES, with the secret values stripped. Use this on any surface that might be
|
|
156
|
+
* logged or served (`/health`, metrics) so the convenient path is also the safe
|
|
157
|
+
* one — {@link getCachedResolution} is the values-carrying escape hatch.
|
|
158
|
+
*/
|
|
159
|
+
declare const getCachedResolutionMeta: () => CachedResolutionMeta | null;
|
|
160
|
+
/**
|
|
161
|
+
* Tear down the dev file watchers, the dev poll, the rotation poll, and the
|
|
162
|
+
* debounce timer WITHOUT wiping the resolved cache / active config. Use for a
|
|
163
|
+
* graceful shutdown of an embedded resolver (worker / CLI) so no timers keep
|
|
164
|
+
* firing; the last resolved `process.env` values stay in place.
|
|
165
|
+
*/
|
|
166
|
+
declare const stopSecretManager: () => void;
|
|
72
167
|
/** Test-only — clear module state and tear down any dev watchers / timers. */
|
|
73
168
|
declare const resetSecretManagerForTests: () => void;
|
|
74
169
|
|
|
75
|
-
export { type CachedResolution, type SecretManagerConfig, type SecretManagerToken, getCachedResolution, initSecretManager, refreshSecretManager, reloadSecretManagerFromFiles, resetSecretManagerForTests };
|
|
170
|
+
export { type CachedResolution, type CachedResolutionMeta, type SecretManagerConfig, type SecretManagerToken, getCachedResolution, getCachedResolutionMeta, initSecretManager, refreshSecretManager, reloadSecretManagerFromFiles, resetSecretManagerForTests, stopSecretManager };
|
package/dist/index.js
CHANGED
|
@@ -1,44 +1,206 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { readFileSync, watch as fsWatch } from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { sleep, tryCatchSync } from "@luckystack/core";
|
|
3
5
|
var DEFAULT_POINTER_PATTERN = /^(.+)_V(\d+)$/;
|
|
4
6
|
var DEFAULT_ENV_FILES = [".env", ".env.local"];
|
|
7
|
+
var isSafeEnvFile = (file) => {
|
|
8
|
+
if (path.isAbsolute(file)) return true;
|
|
9
|
+
const rel = path.relative(process.cwd(), path.resolve(process.cwd(), file));
|
|
10
|
+
return !rel.startsWith("..");
|
|
11
|
+
};
|
|
5
12
|
var cachedResolution = null;
|
|
6
13
|
var pointerMap = null;
|
|
7
14
|
var activeConfig = null;
|
|
15
|
+
var resolveChain = Promise.resolve();
|
|
8
16
|
var devReloadStarted = false;
|
|
9
17
|
var pollTimer = null;
|
|
18
|
+
var rotationPollTimer = null;
|
|
10
19
|
var debounceTimer = null;
|
|
11
20
|
var fileWatchers = [];
|
|
12
|
-
var
|
|
13
|
-
const
|
|
21
|
+
var stripStatefulFlags = (pattern) => {
|
|
22
|
+
const flags = pattern.flags.replaceAll(/[gy]/g, "");
|
|
23
|
+
return flags === pattern.flags ? pattern : new RegExp(pattern.source, flags);
|
|
24
|
+
};
|
|
25
|
+
var noop = () => {
|
|
26
|
+
};
|
|
27
|
+
var errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
28
|
+
var runHook = (fn, label) => {
|
|
29
|
+
const [error] = tryCatchSync(fn);
|
|
30
|
+
if (error) console.warn(`[secret-manager] ${label} callback threw (ignored):`, errorMessage(error));
|
|
31
|
+
};
|
|
32
|
+
var runHookAsync = async (fn, label) => {
|
|
33
|
+
try {
|
|
34
|
+
await fn();
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.warn(`[secret-manager] ${label} callback threw (ignored):`, errorMessage(error));
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
var isLoopbackHost = (hostname) => hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
|
|
40
|
+
var validateUrl = (url, allowInsecureHttp) => {
|
|
41
|
+
const [parseError, parsed] = tryCatchSync(() => new URL(url));
|
|
42
|
+
if (parseError || !parsed) {
|
|
43
|
+
throw new Error(`[secret-manager] Invalid \`url\`: "${url}" is not an absolute URL.`);
|
|
44
|
+
}
|
|
45
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
46
|
+
throw new Error(`[secret-manager] Invalid \`url\` scheme "${parsed.protocol}": only http(s) is supported.`);
|
|
47
|
+
}
|
|
48
|
+
if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname)) {
|
|
49
|
+
if (!allowInsecureHttp) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`[secret-manager] Refusing plain-http \`url\` "${url}": the bearer token and resolved secrets would travel in cleartext. Use https, point at a loopback host, or set \`allowInsecureHttp: true\` to override.`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
console.warn(
|
|
55
|
+
`[secret-manager] Using plain-http transport to a non-loopback host ("${parsed.hostname}"): the bearer token and resolved secrets travel in CLEARTEXT. Prefer https.`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
var toNameFilter = (envNames) => {
|
|
60
|
+
if (envNames === void 0) return () => false;
|
|
61
|
+
if (typeof envNames === "function") return envNames;
|
|
62
|
+
const allow = new Set(envNames);
|
|
63
|
+
return (name) => allow.has(name);
|
|
64
|
+
};
|
|
65
|
+
var warnedEnvNamesUnset = false;
|
|
66
|
+
var warnIfEnvNamesUnset = (envNames) => {
|
|
67
|
+
if (envNames !== void 0 || warnedEnvNamesUnset) return;
|
|
68
|
+
warnedEnvNamesUnset = true;
|
|
69
|
+
console.warn(
|
|
70
|
+
"[secret-manager] `envNames` is not set: NO environment values will be resolved off-host. Set `envNames` to an allowlist of the env names to resolve (or `() => true` to deliberately scan every name)."
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
var splitPointers = (entries, pattern, envNames) => {
|
|
74
|
+
const nameAllowed = toNameFilter(envNames);
|
|
75
|
+
const pointers = {};
|
|
76
|
+
const plain = {};
|
|
77
|
+
for (const [name, value] of entries) {
|
|
78
|
+
if (!nameAllowed(name)) continue;
|
|
79
|
+
if (pattern.test(value)) pointers[name] = value;
|
|
80
|
+
else plain[name] = value;
|
|
81
|
+
}
|
|
82
|
+
return { pointers, plain };
|
|
83
|
+
};
|
|
84
|
+
var capturePointers = (pattern, envNames) => {
|
|
85
|
+
const entries = [];
|
|
14
86
|
for (const [name, value] of Object.entries(process.env)) {
|
|
15
|
-
if (typeof value === "string"
|
|
16
|
-
|
|
87
|
+
if (typeof value === "string") entries.push([name, value]);
|
|
88
|
+
}
|
|
89
|
+
return splitPointers(entries, pattern, envNames).pointers;
|
|
90
|
+
};
|
|
91
|
+
var validateToken = (token) => {
|
|
92
|
+
const trimmed = token.trim();
|
|
93
|
+
if (trimmed.length === 0) {
|
|
94
|
+
throw new Error("[secret-manager] Bearer token is empty or whitespace-only.");
|
|
95
|
+
}
|
|
96
|
+
if (/^bearer\s/i.test(trimmed)) {
|
|
97
|
+
const stripped = trimmed.replace(/^bearer\s+/i, "");
|
|
98
|
+
console.warn(
|
|
99
|
+
'[secret-manager] Token starts with a "Bearer " prefix; the scheme is added automatically \u2014 the prefix was stripped. Drop it from your configured token to silence this warning.'
|
|
100
|
+
);
|
|
101
|
+
return stripped;
|
|
102
|
+
}
|
|
103
|
+
return trimmed;
|
|
104
|
+
};
|
|
105
|
+
var resolveToken = (token) => {
|
|
106
|
+
if (typeof token === "string") return validateToken(token);
|
|
107
|
+
let raw;
|
|
108
|
+
try {
|
|
109
|
+
raw = readFileSync(token.fromFile, "utf8");
|
|
110
|
+
} catch (error) {
|
|
111
|
+
const code = error?.code;
|
|
112
|
+
if (code === "ENOENT") {
|
|
113
|
+
throw new Error(`[secret-manager] Token file not found: "${token.fromFile}".`);
|
|
17
114
|
}
|
|
115
|
+
throw new Error(`[secret-manager] Failed to read token file "${token.fromFile}": ${String(error?.message ?? error)}`);
|
|
18
116
|
}
|
|
19
|
-
return
|
|
117
|
+
return validateToken(raw);
|
|
20
118
|
};
|
|
21
|
-
var
|
|
22
|
-
var
|
|
119
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
120
|
+
var MAX_RESOLVE_BODY_BYTES = 1048576;
|
|
121
|
+
var readBodyCapped = async (response, maxBytes) => {
|
|
122
|
+
const body = response.body;
|
|
123
|
+
if (!body) return response.text();
|
|
124
|
+
const reader = body.getReader();
|
|
125
|
+
const decoder = new TextDecoder();
|
|
126
|
+
let received = 0;
|
|
127
|
+
let out = "";
|
|
128
|
+
for (; ; ) {
|
|
129
|
+
const { done, value } = await reader.read();
|
|
130
|
+
if (done) break;
|
|
131
|
+
received += value.byteLength;
|
|
132
|
+
if (received > maxBytes) {
|
|
133
|
+
void reader.cancel();
|
|
134
|
+
throw new Error(`[secret-manager] Resolve response exceeded the ${String(maxBytes)}-byte cap.`);
|
|
135
|
+
}
|
|
136
|
+
out += decoder.decode(value, { stream: true });
|
|
137
|
+
}
|
|
138
|
+
out += decoder.decode();
|
|
139
|
+
return out;
|
|
140
|
+
};
|
|
141
|
+
var fetchResolveOnce = async (config, pointers) => {
|
|
23
142
|
const fetchFn = config.fetchImpl ?? globalThis.fetch;
|
|
24
|
-
const
|
|
143
|
+
const resolvePath = config.resolvePath ?? "/resolve";
|
|
144
|
+
const suffix = resolvePath.startsWith("/") ? resolvePath : `/${resolvePath}`;
|
|
145
|
+
const endpoint = `${config.url.replace(/\/+$/, "")}${suffix}`;
|
|
146
|
+
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
147
|
+
const signal = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : void 0;
|
|
25
148
|
const response = await fetchFn(endpoint, {
|
|
26
149
|
method: "POST",
|
|
27
150
|
headers: {
|
|
151
|
+
...config.headers,
|
|
28
152
|
"Authorization": `Bearer ${resolveToken(config.token)}`,
|
|
29
153
|
"Content-Type": "application/json",
|
|
30
154
|
"Accept": "application/json"
|
|
31
155
|
},
|
|
32
|
-
body: JSON.stringify({ keys: pointers })
|
|
156
|
+
body: JSON.stringify({ keys: pointers }),
|
|
157
|
+
signal
|
|
33
158
|
});
|
|
34
159
|
if (!response.ok) {
|
|
160
|
+
void response.body?.cancel();
|
|
35
161
|
throw new Error(`[secret-manager] Resolve request failed: ${String(response.status)} ${response.statusText}`);
|
|
36
162
|
}
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
163
|
+
const declaredLength = Number(response.headers.get("content-length"));
|
|
164
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_RESOLVE_BODY_BYTES) {
|
|
165
|
+
void response.body?.cancel();
|
|
166
|
+
throw new Error(`[secret-manager] Resolve response too large (${String(declaredLength)} bytes > ${String(MAX_RESOLVE_BODY_BYTES)} cap).`);
|
|
167
|
+
}
|
|
168
|
+
const raw = await readBodyCapped(response, MAX_RESOLVE_BODY_BYTES);
|
|
169
|
+
const [parseError, body] = tryCatchSync(() => JSON.parse(raw));
|
|
170
|
+
if (parseError) {
|
|
171
|
+
throw new Error("[secret-manager] Resolve response was not valid JSON.");
|
|
172
|
+
}
|
|
173
|
+
const values = body?.values;
|
|
174
|
+
if (values === null || typeof values !== "object") {
|
|
39
175
|
throw new Error("[secret-manager] Resolve response missing `values` object.");
|
|
40
176
|
}
|
|
41
|
-
|
|
177
|
+
const requested = new Set(pointers);
|
|
178
|
+
const filtered = {};
|
|
179
|
+
for (const [key, value] of Object.entries(values)) {
|
|
180
|
+
if (!requested.has(key)) continue;
|
|
181
|
+
if (typeof value !== "string") {
|
|
182
|
+
console.warn(`[secret-manager] Pointer "${key}" resolved to a non-string value (${typeof value}); ignoring.`);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
filtered[key] = value;
|
|
186
|
+
}
|
|
187
|
+
return filtered;
|
|
188
|
+
};
|
|
189
|
+
var fetchResolve = async (config, pointers) => {
|
|
190
|
+
const rawRetryCount = config.retries?.count ?? 0;
|
|
191
|
+
const retryCount = Number.isFinite(rawRetryCount) ? Math.max(0, rawRetryCount) : 0;
|
|
192
|
+
const rawDelayMs = config.retries?.delayMs ?? 0;
|
|
193
|
+
const delayMs = Number.isFinite(rawDelayMs) ? Math.max(0, rawDelayMs) : 0;
|
|
194
|
+
let lastError = new Error("[secret-manager] resolve failed: no attempt was made");
|
|
195
|
+
for (let attempt = 0; attempt <= retryCount; attempt++) {
|
|
196
|
+
try {
|
|
197
|
+
return await fetchResolveOnce(config, pointers);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
lastError = error;
|
|
200
|
+
if (attempt < retryCount && delayMs > 0) await sleep(delayMs);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
throw lastError;
|
|
42
204
|
};
|
|
43
205
|
var applyResolved = (map, values, source) => {
|
|
44
206
|
if (source === "remote") {
|
|
@@ -48,35 +210,57 @@ var applyResolved = (map, values, source) => {
|
|
|
48
210
|
throw new Error(`[secret-manager] Server did not resolve: ${detail}.`);
|
|
49
211
|
}
|
|
50
212
|
}
|
|
213
|
+
const changes = [];
|
|
51
214
|
for (const [name, pointer] of Object.entries(map)) {
|
|
52
215
|
const value = values[pointer];
|
|
53
216
|
if (value === void 0) {
|
|
54
217
|
console.warn(`[secret-manager] Pointer "${pointer}" (referenced by ${name}) not resolved; leaving "${name}" as-is.`);
|
|
55
218
|
continue;
|
|
56
219
|
}
|
|
220
|
+
if (process.env[name] !== value) changes.push({ name, pointer });
|
|
57
221
|
process.env[name] = value;
|
|
58
222
|
}
|
|
223
|
+
return changes;
|
|
59
224
|
};
|
|
60
|
-
var doResolve =
|
|
225
|
+
var doResolve = (config, phase) => {
|
|
226
|
+
const run = resolveChain.then(
|
|
227
|
+
() => doResolveInner(config, phase),
|
|
228
|
+
() => doResolveInner(config, phase)
|
|
229
|
+
);
|
|
230
|
+
resolveChain = run.then(noop, noop);
|
|
231
|
+
return run;
|
|
232
|
+
};
|
|
233
|
+
var doResolveInner = async (config, phase) => {
|
|
61
234
|
const source = config.source ?? "remote";
|
|
62
235
|
if (source === "local") return;
|
|
63
|
-
|
|
64
|
-
|
|
236
|
+
warnIfEnvNamesUnset(config.envNames);
|
|
237
|
+
if (pointerMap === null || Object.keys(pointerMap).length === 0) {
|
|
238
|
+
pointerMap = capturePointers(
|
|
239
|
+
stripStatefulFlags(config.pointerPattern ?? DEFAULT_POINTER_PATTERN),
|
|
240
|
+
config.envNames
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
const activePointerMap = pointerMap;
|
|
244
|
+
const pointers = [...new Set(Object.values(activePointerMap))];
|
|
65
245
|
if (pointers.length === 0) {
|
|
66
246
|
cachedResolution = { fetchedAt: Date.now(), values: {} };
|
|
67
247
|
return;
|
|
68
248
|
}
|
|
249
|
+
let values;
|
|
250
|
+
let changes;
|
|
69
251
|
try {
|
|
70
|
-
|
|
71
|
-
applyResolved(
|
|
252
|
+
values = await fetchResolve(config, pointers);
|
|
253
|
+
changes = applyResolved(activePointerMap, values, source);
|
|
72
254
|
cachedResolution = { fetchedAt: Date.now(), values };
|
|
73
255
|
} catch (error) {
|
|
256
|
+
runHook(() => config.onResolveError?.(error, { phase }), "onResolveError");
|
|
74
257
|
if (source === "hybrid") {
|
|
75
|
-
console.warn("[secret-manager] Resolve failed, leaving local env as-is:", error);
|
|
258
|
+
console.warn("[secret-manager] Resolve failed, leaving local env as-is:", errorMessage(error));
|
|
76
259
|
return;
|
|
77
260
|
}
|
|
78
261
|
throw error;
|
|
79
262
|
}
|
|
263
|
+
if (changes.length > 0) await runHookAsync(() => config.onApplied?.(changes), "onApplied");
|
|
80
264
|
};
|
|
81
265
|
var parseEnvFile = (content) => {
|
|
82
266
|
const out = {};
|
|
@@ -86,7 +270,10 @@ var parseEnvFile = (content) => {
|
|
|
86
270
|
const eq = line.indexOf("=");
|
|
87
271
|
if (eq <= 0) continue;
|
|
88
272
|
const key = line.slice(0, eq).trim();
|
|
89
|
-
if (!/^[
|
|
273
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
274
|
+
console.warn(`[secret-manager] Ignoring env key "${key}": not a valid environment variable name (^[A-Za-z_][A-Za-z0-9_]*$).`);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
90
277
|
let value = line.slice(eq + 1).trim();
|
|
91
278
|
const quote = value[0];
|
|
92
279
|
if ((quote === '"' || quote === "'") && value.length >= 2 && value.endsWith(quote)) {
|
|
@@ -104,20 +291,25 @@ var scheduleReload = () => {
|
|
|
104
291
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
105
292
|
debounceTimer = setTimeout(() => {
|
|
106
293
|
void reloadSecretManagerFromFiles().catch((error) => {
|
|
107
|
-
console.warn("[secret-manager] dev reload failed:", error);
|
|
294
|
+
console.warn("[secret-manager] dev reload failed:", errorMessage(error));
|
|
108
295
|
});
|
|
109
296
|
}, 200);
|
|
110
297
|
debounceTimer.unref();
|
|
111
298
|
};
|
|
112
299
|
var startDevReload = (config) => {
|
|
113
300
|
if (devReloadStarted || config.dev === void 0) return;
|
|
114
|
-
|
|
301
|
+
const nodeEnv = process.env.NODE_ENV;
|
|
302
|
+
if (nodeEnv !== "development" && nodeEnv !== "test") return;
|
|
115
303
|
const watch = config.dev.watch ?? true;
|
|
116
304
|
const pollIntervalMs = config.dev.pollIntervalMs ?? 0;
|
|
117
305
|
if (!watch && pollIntervalMs <= 0) return;
|
|
118
306
|
devReloadStarted = true;
|
|
119
307
|
if (watch) {
|
|
120
308
|
for (const file of config.dev.envFiles ?? DEFAULT_ENV_FILES) {
|
|
309
|
+
if (!isSafeEnvFile(file)) {
|
|
310
|
+
console.warn(`[secret-manager] ignoring unsafe dev envFile path (must be relative + within the project): ${file}`);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
121
313
|
try {
|
|
122
314
|
const watcher = fsWatch(file, () => {
|
|
123
315
|
scheduleReload();
|
|
@@ -131,29 +323,47 @@ var startDevReload = (config) => {
|
|
|
131
323
|
if (pollIntervalMs > 0) {
|
|
132
324
|
pollTimer = setInterval(() => {
|
|
133
325
|
void refreshSecretManager().catch((error) => {
|
|
134
|
-
console.warn("[secret-manager] poll refresh failed:", error);
|
|
326
|
+
console.warn("[secret-manager] poll refresh failed:", errorMessage(error));
|
|
135
327
|
});
|
|
136
328
|
}, pollIntervalMs);
|
|
137
329
|
pollTimer.unref();
|
|
138
330
|
}
|
|
139
331
|
};
|
|
140
332
|
var initSecretManager = async (config) => {
|
|
333
|
+
if ((config.source ?? "remote") !== "local") {
|
|
334
|
+
validateUrl(config.url, config.allowInsecureHttp ?? false);
|
|
335
|
+
}
|
|
141
336
|
activeConfig = config;
|
|
142
337
|
if ((config.source ?? "remote") === "local") return;
|
|
143
|
-
await doResolve(config);
|
|
338
|
+
await doResolve(config, "boot");
|
|
339
|
+
startRotationPoll(config);
|
|
144
340
|
startDevReload(config);
|
|
145
341
|
};
|
|
342
|
+
var startRotationPoll = (config) => {
|
|
343
|
+
const intervalMs = config.pollIntervalMs ?? 0;
|
|
344
|
+
if (intervalMs <= 0 || rotationPollTimer) return;
|
|
345
|
+
rotationPollTimer = setInterval(() => {
|
|
346
|
+
void refreshSecretManager().catch((error) => {
|
|
347
|
+
console.warn("[secret-manager] rotation poll refresh failed:", errorMessage(error));
|
|
348
|
+
});
|
|
349
|
+
}, intervalMs);
|
|
350
|
+
rotationPollTimer.unref();
|
|
351
|
+
};
|
|
146
352
|
var refreshSecretManager = async () => {
|
|
147
353
|
if (!activeConfig || (activeConfig.source ?? "remote") === "local") return;
|
|
148
|
-
await doResolve(activeConfig);
|
|
354
|
+
await doResolve(activeConfig, "refresh");
|
|
149
355
|
};
|
|
150
356
|
var reloadSecretManagerFromFiles = async () => {
|
|
151
357
|
const config = activeConfig;
|
|
152
358
|
if (!config || (config.source ?? "remote") === "local") return;
|
|
153
359
|
const files = config.dev?.envFiles ?? DEFAULT_ENV_FILES;
|
|
154
|
-
const pattern = config.pointerPattern ?? DEFAULT_POINTER_PATTERN;
|
|
360
|
+
const pattern = stripStatefulFlags(config.pointerPattern ?? DEFAULT_POINTER_PATTERN);
|
|
155
361
|
const merged = {};
|
|
156
362
|
for (const file of files) {
|
|
363
|
+
if (!isSafeEnvFile(file)) {
|
|
364
|
+
console.warn(`[secret-manager] ignoring unsafe dev envFile path: ${file}`);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
157
367
|
let content;
|
|
158
368
|
try {
|
|
159
369
|
content = readFileSync(file, "utf8");
|
|
@@ -162,27 +372,39 @@ var reloadSecretManagerFromFiles = async () => {
|
|
|
162
372
|
}
|
|
163
373
|
Object.assign(merged, parseEnvFile(content));
|
|
164
374
|
}
|
|
165
|
-
const freshPointerMap =
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
375
|
+
const { pointers: freshPointerMap, plain: plainValues } = splitPointers(
|
|
376
|
+
Object.entries(merged),
|
|
377
|
+
pattern,
|
|
378
|
+
config.envNames
|
|
379
|
+
);
|
|
380
|
+
const previousPointerMap = pointerMap;
|
|
381
|
+
pointerMap = { ...pointerMap, ...freshPointerMap };
|
|
382
|
+
try {
|
|
383
|
+
await doResolve(config, "file-reload");
|
|
384
|
+
} catch (error) {
|
|
385
|
+
pointerMap = previousPointerMap;
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
for (const [name, value] of Object.entries(plainValues)) {
|
|
389
|
+
process.env[name] = value;
|
|
172
390
|
}
|
|
173
|
-
pointerMap = freshPointerMap;
|
|
174
|
-
await doResolve(config);
|
|
175
391
|
};
|
|
176
|
-
var getCachedResolution = () => cachedResolution;
|
|
177
|
-
var
|
|
178
|
-
cachedResolution
|
|
179
|
-
|
|
180
|
-
|
|
392
|
+
var getCachedResolution = () => cachedResolution === null ? null : { fetchedAt: cachedResolution.fetchedAt, values: { ...cachedResolution.values } };
|
|
393
|
+
var getCachedResolutionMeta = () => {
|
|
394
|
+
if (cachedResolution === null) return null;
|
|
395
|
+
const pointerNames = Object.keys(cachedResolution.values);
|
|
396
|
+
return { fetchedAt: cachedResolution.fetchedAt, pointerNames, pointerCount: pointerNames.length };
|
|
397
|
+
};
|
|
398
|
+
var stopSecretManager = () => {
|
|
181
399
|
devReloadStarted = false;
|
|
182
400
|
if (pollTimer) {
|
|
183
401
|
clearInterval(pollTimer);
|
|
184
402
|
pollTimer = null;
|
|
185
403
|
}
|
|
404
|
+
if (rotationPollTimer) {
|
|
405
|
+
clearInterval(rotationPollTimer);
|
|
406
|
+
rotationPollTimer = null;
|
|
407
|
+
}
|
|
186
408
|
if (debounceTimer) {
|
|
187
409
|
clearTimeout(debounceTimer);
|
|
188
410
|
debounceTimer = null;
|
|
@@ -192,11 +414,21 @@ var resetSecretManagerForTests = () => {
|
|
|
192
414
|
}
|
|
193
415
|
fileWatchers.length = 0;
|
|
194
416
|
};
|
|
417
|
+
var resetSecretManagerForTests = () => {
|
|
418
|
+
stopSecretManager();
|
|
419
|
+
cachedResolution = null;
|
|
420
|
+
pointerMap = null;
|
|
421
|
+
activeConfig = null;
|
|
422
|
+
resolveChain = Promise.resolve();
|
|
423
|
+
warnedEnvNamesUnset = false;
|
|
424
|
+
};
|
|
195
425
|
export {
|
|
196
426
|
getCachedResolution,
|
|
427
|
+
getCachedResolutionMeta,
|
|
197
428
|
initSecretManager,
|
|
198
429
|
refreshSecretManager,
|
|
199
430
|
reloadSecretManagerFromFiles,
|
|
200
|
-
resetSecretManagerForTests
|
|
431
|
+
resetSecretManagerForTests,
|
|
432
|
+
stopSecretManager
|
|
201
433
|
};
|
|
202
434
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["//? @luckystack/secret-manager — rotation-aware secret resolver client.\n//?\n//? Idea: secrets live in a central secret-manager server (append-only,\n//? versioned, one shared bearer token). Apps commit their `.env` to git, but\n//? instead of real secrets it holds POINTERS:\n//?\n//? OPENAI_KEY=OPENAI_AUTHORIZATION_KEY_V5\n//?\n//? A pointer has the shape `<BASE>_V<number>`. At boot this client scans\n//? `process.env`, collects every pointer-shaped value, asks the server to\n//? resolve them in ONE request, and overwrites each `process.env` entry with\n//? the real secret — so downstream code reads `process.env.OPENAI_KEY` and gets\n//? the resolved value, not the pointer. Rotating a secret means publishing a\n//? new version (`..._V6`) on the server, never editing an old one, so old git\n//? branches that still point at `..._V5` keep booting.\n//?\n//? Three modes:\n//? 1. `source: 'remote'` (default) — resolve from the server. A missing\n//? pointer or an unreachable server throws — production hard-stop.\n//? 2. `source: 'local'` — no network. Pointers are left untouched. Use in\n//? tests / offline dev when the server isn't reachable.\n//? 3. `source: 'hybrid'` — try the server, but on failure warn and leave\n//? whatever `process.env` already holds. Use for staging / canary.\n//?\n//? Optional dev-only hot reload (`config.dev`) re-resolves on `.env` file\n//? changes and/or on an interval, so server-side rotations are picked up\n//? without restarting a long-running dev process. It is a no-op in production.\n\nimport { readFileSync, watch as fsWatch, type FSWatcher } from 'node:fs';\n\n/** Bearer token: a literal string, or a file whose entire contents are the token. */\nexport type SecretManagerToken = string | { fromFile: string };\n\nexport interface SecretManagerConfig {\n /** Base URL of the secret-manager server (trailing slash optional). */\n url: string;\n /**\n * Shared bearer token. Either the literal string or `{ fromFile }` pointing\n * at a gitignored file whose entire contents are the token (read at resolve\n * time, so rotating the file is picked up by the next poll / refresh).\n */\n token: SecretManagerToken;\n /** Resolution mode. Default `'remote'`. */\n source?: 'remote' | 'local' | 'hybrid';\n /**\n * Override the pointer-shape detector. Any `process.env` value matching this\n * is treated as a pointer; anything else is a literal and left untouched.\n * Default `/^(.+)_V(\\d+)$/`.\n */\n pointerPattern?: RegExp;\n /** Override the global `fetch` (tests / non-Node-20 hosts). */\n fetchImpl?: typeof fetch;\n /**\n * Opt-in dev-only hot reload. Ignored when `NODE_ENV === 'production'`.\n * Provide an (even empty) object to enable it.\n */\n dev?: {\n /**\n * Watch the env files and hot-reload on change. Default `true`. On change\n * the files are re-parsed and applied: plain (non-pointer) values are\n * injected straight into `process.env` (live config reload), and\n * pointer-shaped values are re-resolved against the server. Requires the\n * optional `dotenv` peer to parse the files.\n */\n watch?: boolean;\n /** Re-resolve the current pointers every N ms (server-rotation poll). Default `0` (disabled). */\n pollIntervalMs?: number;\n /** Env files to watch + reparse, in load order (later overrides earlier). Default `['.env', '.env.local']`. */\n envFiles?: string[];\n };\n}\n\nexport interface CachedResolution {\n /** `Date.now()` of the last successful resolve. */\n fetchedAt: number;\n /** Map of pointer string -> resolved value (what the server returned). */\n values: Record<string, string>;\n}\n\nconst DEFAULT_POINTER_PATTERN = /^(.+)_V(\\d+)$/;\nconst DEFAULT_ENV_FILES = ['.env', '.env.local'];\n\n//? Module state. The resolver is meant to run once per process at boot; these\n//? bindings keep it idempotent and let dev hot-reload re-resolve later.\nlet cachedResolution: CachedResolution | null = null;\n//? envName -> pointer string, captured once on first resolve. Reused on every\n//? refresh because the first resolve OVERWRITES the env value with the real\n//? secret, after which it no longer looks like a pointer.\nlet pointerMap: Record<string, string> | null = null;\nlet activeConfig: SecretManagerConfig | null = null;\n\n//? Dev hot-reload handles, torn down by resetSecretManagerForTests.\nlet devReloadStarted = false;\nlet pollTimer: ReturnType<typeof setInterval> | null = null;\nlet debounceTimer: ReturnType<typeof setTimeout> | null = null;\nconst fileWatchers: FSWatcher[] = [];\n\nconst capturePointers = (pattern: RegExp): Record<string, string> => {\n const map: Record<string, string> = {};\n for (const [name, value] of Object.entries(process.env)) {\n if (typeof value === 'string' && pattern.test(value)) {\n map[name] = value;\n }\n }\n return map;\n};\n\nconst resolveToken = (token: SecretManagerToken): string =>\n typeof token === 'string' ? token : readFileSync(token.fromFile, 'utf8').trim();\n\nconst fetchResolve = async (\n config: SecretManagerConfig,\n pointers: string[],\n): Promise<Record<string, string>> => {\n //? Defaults to the Node 20+ global fetch; pass fetchImpl for older hosts.\n const fetchFn = config.fetchImpl ?? globalThis.fetch;\n const endpoint = `${config.url.replace(/\\/+$/, '')}/resolve`;\n const response = await fetchFn(endpoint, {\n method: 'POST',\n headers: {\n 'Authorization': `Bearer ${resolveToken(config.token)}`,\n 'Content-Type': 'application/json',\n 'Accept': 'application/json',\n },\n body: JSON.stringify({ keys: pointers }),\n });\n\n if (!response.ok) {\n throw new Error(`[secret-manager] Resolve request failed: ${String(response.status)} ${response.statusText}`);\n }\n\n const body = await response.json() as { values?: Record<string, string> };\n if (!body.values || typeof body.values !== 'object') {\n throw new Error('[secret-manager] Resolve response missing `values` object.');\n }\n\n return body.values;\n};\n\nconst applyResolved = (\n map: Record<string, string>,\n values: Record<string, string>,\n source: 'remote' | 'hybrid',\n): void => {\n //? In remote mode a single unresolved pointer is a hard boot failure. Check\n //? everything BEFORE mutating process.env so the failure is atomic.\n if (source === 'remote') {\n const missing = Object.entries(map).filter(([, pointer]) => values[pointer] === undefined);\n if (missing.length > 0) {\n const detail = missing.map(([name, pointer]) => `${pointer} (referenced by ${name})`).join(', ');\n throw new Error(`[secret-manager] Server did not resolve: ${detail}.`);\n }\n }\n\n for (const [name, pointer] of Object.entries(map)) {\n const value = values[pointer];\n if (value === undefined) {\n //? hybrid only — leave the pointer in place and warn so the operator sees it.\n console.warn(`[secret-manager] Pointer \"${pointer}\" (referenced by ${name}) not resolved; leaving \"${name}\" as-is.`);\n continue;\n }\n process.env[name] = value;\n }\n};\n\nconst doResolve = async (config: SecretManagerConfig): Promise<void> => {\n const source = config.source ?? 'remote';\n if (source === 'local') return;\n\n pointerMap ??= capturePointers(config.pointerPattern ?? DEFAULT_POINTER_PATTERN);\n const pointers = [...new Set(Object.values(pointerMap))];\n if (pointers.length === 0) {\n cachedResolution = { fetchedAt: Date.now(), values: {} };\n return;\n }\n\n try {\n const values = await fetchResolve(config, pointers);\n applyResolved(pointerMap, values, source);\n cachedResolution = { fetchedAt: Date.now(), values };\n } catch (error) {\n if (source === 'hybrid') {\n console.warn('[secret-manager] Resolve failed, leaving local env as-is:', error);\n return;\n }\n throw error;\n }\n};\n\n//? Minimal .env parser kept in-package so the resolver stays dependency-free.\n//? Handles standard `KEY=VALUE` lines, full-line + inline (` #`) comments, and\n//? single/double-quoted values. Not multi-line values or escape sequences.\nconst parseEnvFile = (content: string): Record<string, string> => {\n const out: Record<string, string> = {};\n for (const rawLine of content.split(/\\r?\\n/)) {\n const line = rawLine.trim();\n if (!line || line.startsWith('#')) continue;\n const eq = line.indexOf('=');\n if (eq <= 0) continue;\n const key = line.slice(0, eq).trim();\n if (!/^[\\w.-]+$/.test(key)) continue;\n let value = line.slice(eq + 1).trim();\n const quote = value[0];\n if ((quote === '\"' || quote === \"'\") && value.length >= 2 && value.endsWith(quote)) {\n value = value.slice(1, -1);\n } else {\n const commentAt = value.indexOf(' #');\n if (commentAt !== -1) value = value.slice(0, commentAt).trim();\n if (value.startsWith('#')) value = '';\n }\n out[key] = value;\n }\n return out;\n};\n\nconst scheduleReload = (): void => {\n //? Debounce — fs.watch can fire several events for one save.\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n void reloadSecretManagerFromFiles().catch((error: unknown) => {\n console.warn('[secret-manager] dev reload failed:', error);\n });\n }, 200);\n debounceTimer.unref();\n};\n\nconst startDevReload = (config: SecretManagerConfig): void => {\n if (devReloadStarted || config.dev === undefined) return;\n if (process.env.NODE_ENV === 'production') return;\n\n const watch = config.dev.watch ?? true;\n const pollIntervalMs = config.dev.pollIntervalMs ?? 0;\n if (!watch && pollIntervalMs <= 0) return;\n devReloadStarted = true;\n\n if (watch) {\n for (const file of config.dev.envFiles ?? DEFAULT_ENV_FILES) {\n try {\n const watcher = fsWatch(file, () => {\n scheduleReload();\n });\n watcher.unref();\n fileWatchers.push(watcher);\n } catch {\n //? The file may not exist (e.g. no .env.local) — nothing to watch.\n }\n }\n }\n\n if (pollIntervalMs > 0) {\n pollTimer = setInterval(() => {\n void refreshSecretManager().catch((error: unknown) => {\n console.warn('[secret-manager] poll refresh failed:', error);\n });\n }, pollIntervalMs);\n pollTimer.unref();\n }\n};\n\n/**\n * Initialize the secret-manager resolver. Call once as the very first line of\n * `server.ts`, BEFORE any other framework code reads `process.env`. In\n * `'remote'` / `'hybrid'` mode the resolved secrets are written into\n * `process.env` before this resolves, so downstream code sees them via the\n * standard `process.env.FOO` lookup. In `'local'` mode it is a no-op.\n */\nexport const initSecretManager = async (config: SecretManagerConfig): Promise<void> => {\n activeConfig = config;\n if ((config.source ?? 'remote') === 'local') return;\n await doResolve(config);\n startDevReload(config);\n};\n\n/**\n * Re-resolve against the server, ignoring nothing — used by the dev hot-reload\n * watch/poll and callable manually when an admin rotates a secret and you want\n * a long-running process to pick it up without a restart.\n */\nexport const refreshSecretManager = async (): Promise<void> => {\n if (!activeConfig || (activeConfig.source ?? 'remote') === 'local') return;\n await doResolve(activeConfig);\n};\n\n/**\n * Dev hot-reload entry: re-parse the configured env files and apply them — plain\n * (non-pointer) values are injected straight into `process.env` (live config\n * reload), pointer-shaped values are re-resolved against the server. Wired to the\n * `.env` / `.env.local` file watch; also callable manually.\n */\nexport const reloadSecretManagerFromFiles = async (): Promise<void> => {\n const config = activeConfig;\n if (!config || (config.source ?? 'remote') === 'local') return;\n\n const files = config.dev?.envFiles ?? DEFAULT_ENV_FILES;\n const pattern = config.pointerPattern ?? DEFAULT_POINTER_PATTERN;\n\n //? Re-read every file in load order; later files (e.g. .env.local) override.\n const merged: Record<string, string> = {};\n for (const file of files) {\n let content: string;\n try {\n content = readFileSync(file, 'utf8');\n } catch {\n continue; //? file may not exist (e.g. no .env.local)\n }\n Object.assign(merged, parseEnvFile(content));\n }\n\n //? Plain values inject directly (live config reload); pointer-shaped values go\n //? to the server. Swap in the fresh pointer set so this resolve + later polls\n //? use it (this is also how a pointer added after boot gets picked up).\n const freshPointerMap: Record<string, string> = {};\n for (const [name, value] of Object.entries(merged)) {\n if (pattern.test(value)) {\n freshPointerMap[name] = value;\n } else {\n process.env[name] = value;\n }\n }\n pointerMap = freshPointerMap;\n await doResolve(config);\n};\n\n/** Read the last `{ fetchedAt, values }` resolution (pointer -> value), or `null`. */\nexport const getCachedResolution = (): CachedResolution | null => cachedResolution;\n\n/** Test-only — clear module state and tear down any dev watchers / timers. */\nexport const resetSecretManagerForTests = (): void => {\n cachedResolution = null;\n pointerMap = null;\n activeConfig = null;\n devReloadStarted = false;\n if (pollTimer) {\n clearInterval(pollTimer);\n pollTimer = null;\n }\n if (debounceTimer) {\n clearTimeout(debounceTimer);\n debounceTimer = null;\n }\n for (const watcher of fileWatchers) {\n watcher.close();\n }\n fileWatchers.length = 0;\n};\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["//? @luckystack/secret-manager — rotation-aware secret resolver client.\n//?\n//? Idea: secrets live in a central secret-manager server (append-only,\n//? versioned, one shared bearer token). Apps commit their `.env` to git, but\n//? instead of real secrets it holds POINTERS:\n//?\n//? OPENAI_KEY=OPENAI_AUTHORIZATION_KEY_V5\n//?\n//? A pointer has the shape `<BASE>_V<number>`. At boot this client scans\n//? `process.env`, collects every pointer-shaped value, asks the server to\n//? resolve them in ONE request, and overwrites each `process.env` entry with\n//? the real secret — so downstream code reads `process.env.OPENAI_KEY` and gets\n//? the resolved value, not the pointer. Rotating a secret means publishing a\n//? new version (`..._V6`) on the server, never editing an old one, so old git\n//? branches that still point at `..._V5` keep booting.\n//?\n//? Three modes:\n//? 1. `source: 'remote'` (default) — resolve from the server. A missing\n//? pointer or an unreachable server throws — production hard-stop.\n//? 2. `source: 'local'` — no network. Pointers are left untouched. Use in\n//? tests / offline dev when the server isn't reachable.\n//? 3. `source: 'hybrid'` — try the server, but on failure warn and leave\n//? whatever `process.env` already holds. Use for staging / canary.\n//?\n//? Optional dev-only hot reload (`config.dev`) re-resolves on `.env` file\n//? changes and/or on an interval, so server-side rotations are picked up\n//? without restarting a long-running dev process. It is a no-op in production.\n\nimport { readFileSync, watch as fsWatch, type FSWatcher } from 'node:fs';\nimport path from 'node:path';\n\nimport { sleep, tryCatchSync } from '@luckystack/core';\n\n/** Bearer token: a literal string, or a file whose entire contents are the token. */\nexport type SecretManagerToken = string | { fromFile: string };\n\nexport interface SecretManagerConfig {\n /** Base URL of the secret-manager server (trailing slash optional). */\n url: string;\n /**\n * Shared bearer token. Either the literal string or `{ fromFile }` pointing\n * at a gitignored file whose entire contents are the token (read at resolve\n * time, so rotating the file is picked up by the next poll / refresh).\n */\n token: SecretManagerToken;\n /** Resolution mode. Default `'remote'`. */\n source?: 'remote' | 'local' | 'hybrid';\n /**\n * Override the pointer-shape detector. Any `process.env` value matching this\n * is treated as a pointer; anything else is a literal and left untouched.\n * Default `/^(.+)_V(\\d+)$/`. A `g`/`y` flag is stripped (a stateful regex would\n * misclassify alternating entries).\n */\n pointerPattern?: RegExp;\n /**\n * Scope which `process.env` entries are pointer-eligible by NAME. This allowlist\n * is REQUIRED to resolve anything off-host: an array of names or a predicate.\n *\n * SECURE DEFAULT: when unset, NOTHING is resolved off-host (a clear boot warning\n * is emitted instead). Scanning the entire inherited environment would POST any\n * unrelated, pointer-shaped inherited value (`RELEASE_TAG=build_2024_V2`) to the\n * secret-manager server — so an explicit allowlist is mandatory. To opt back into\n * scanning every name, pass `() => true` as a deliberate, auditable choice.\n */\n envNames?: string[] | ((name: string) => boolean);\n /**\n * Allow a plain-`http:` server `url`. By default only `https:` is accepted for\n * non-loopback hosts (the channel carries the bearer token + plaintext secrets);\n * loopback (`localhost`/`127.0.0.1`/`[::1]`) is always permitted. Set `true` to\n * permit `http:` to any host — a loud warning is still emitted.\n */\n allowInsecureHttp?: boolean;\n /**\n * Abort a resolve request that has not responded within this many ms. Prevents a\n * black-hole server (accepts the TCP connection, never responds) from hanging boot\n * until undici's ~300s default — and, in `'hybrid'`, from never reaching the\n * warn-and-keep-local fallback (a hang never rejects). Default `10_000`. Set `0`\n * to disable the timeout.\n */\n timeoutMs?: number;\n /**\n * Retry a failed resolve before giving up (transport error or non-2xx). Default\n * `{ count: 0 }` (no retry). After exhaustion `'remote'` still throws and\n * `'hybrid'` still warns-and-keeps-local.\n */\n retries?: { count: number; delayMs?: number };\n /** Override the resolve path appended to `url`. Default `'/resolve'`. */\n resolvePath?: string;\n /** Extra request headers merged onto every resolve request (cannot override `Authorization`). */\n headers?: Record<string, string>;\n /**\n * Called after a resolve writes new values into `process.env`, with ONLY the env\n * NAMES whose value actually changed (never the secret values). A client that\n * captured a secret at construction time (Prisma from `DATABASE_URL`, a Redis /\n * Stripe / OpenAI SDK) can re-create its pool/client here so a rotation lands.\n */\n onApplied?: (changes: { name: string; pointer: string }[]) => void | Promise<void>;\n /**\n * Called when a resolve fails, alongside the existing `console.warn` (current\n * behavior is unchanged when unset). Route the failure to Sentry/metrics —\n * useful for `'hybrid'`/dev where a failure otherwise silently keeps stale env.\n */\n onResolveError?: (error: unknown, context: { phase: 'boot' | 'refresh' | 'file-reload' }) => void;\n /** Override the global `fetch` (tests / non-Node-20 hosts). */\n fetchImpl?: typeof fetch;\n /**\n * Re-resolve the current pointers every N ms in ALL environments (the production\n * rotation poll), distinct from the dev-only `dev.pollIntervalMs` file-watch\n * channel. Default `0` (disabled). Unref'd, so it never blocks process exit.\n */\n pollIntervalMs?: number;\n /**\n * Opt-in dev-only hot reload. Ignored when `NODE_ENV === 'production'`.\n * Provide an (even empty) object to enable it.\n */\n dev?: {\n /**\n * Watch the env files and hot-reload on change. Default `true`. On change\n * the files are re-parsed and applied: plain (non-pointer) values are\n * injected straight into `process.env` (live config reload), and\n * pointer-shaped values are re-resolved against the server.\n */\n watch?: boolean;\n /** Re-resolve the current pointers every N ms (server-rotation poll). Default `0` (disabled). */\n pollIntervalMs?: number;\n /** Env files to watch + reparse, in load order (later overrides earlier). Default `['.env', '.env.local']`. */\n envFiles?: string[];\n };\n}\n\nexport interface CachedResolution {\n /** `Date.now()` of the last successful resolve. */\n fetchedAt: number;\n /** Map of pointer string -> resolved value (what the server returned). */\n values: Record<string, string>;\n}\n\nconst DEFAULT_POINTER_PATTERN = /^(.+)_V(\\d+)$/;\nconst DEFAULT_ENV_FILES = ['.env', '.env.local'];\n\n//? Dev hot-reload reads these files and injects their values into `process.env`.\n//? `dev.envFiles` is consumer config (not runtime user input), so an ABSOLUTE\n//? path is treated as an explicit, allowed choice (e.g. a shared secrets file).\n//? A RELATIVE path, however, must stay within the project root — reject `..`\n//? traversal (the plausible \"injected via a relative path\" escape). The caller\n//? skips + warns on a rejected entry (fail-open, consistent with the package's\n//? swallow-on-missing-file behaviour).\nconst isSafeEnvFile = (file: string): boolean => {\n if (path.isAbsolute(file)) return true;\n const rel = path.relative(process.cwd(), path.resolve(process.cwd(), file));\n return !rel.startsWith('..');\n};\n\n//? Module state. The resolver is meant to run once per process at boot; these\n//? bindings keep it idempotent and let dev hot-reload re-resolve later.\nlet cachedResolution: CachedResolution | null = null;\n//? envName -> pointer string, captured once on first resolve. Reused on every\n//? refresh because the first resolve OVERWRITES the env value with the real\n//? secret, after which it no longer looks like a pointer.\nlet pointerMap: Record<string, string> | null = null;\nlet activeConfig: SecretManagerConfig | null = null;\n\n//? In-flight resolve, used to SERIALIZE concurrent resolves. Four channels can\n//? funnel into `doResolve` (boot, the production rotation poll, the dev poll, the\n//? dev file-watch + a manual `refreshSecretManager()`); without a guard a slow\n//? in-flight resolve can land AFTER a newer one, leaving `process.env` and\n//? `cachedResolution` disagreeing (stale secrets). Each call awaits the previous\n//? one's settlement before starting, so resolves apply strictly in order.\nlet resolveChain: Promise<void> = Promise.resolve();\n\n//? Dev hot-reload + rotation-poll handles, torn down by stopSecretManager.\nlet devReloadStarted = false;\nlet pollTimer: ReturnType<typeof setInterval> | null = null;\nlet rotationPollTimer: ReturnType<typeof setInterval> | null = null;\nlet debounceTimer: ReturnType<typeof setTimeout> | null = null;\nconst fileWatchers: FSWatcher[] = [];\n\n//? A consumer-supplied `pointerPattern` with a `g`/`y` flag makes `.test()`\n//? stateful (advances `lastIndex`), silently misclassifying alternating entries.\n//? Strip those flags up front; everything else is preserved.\nconst stripStatefulFlags = (pattern: RegExp): RegExp => {\n const flags = pattern.flags.replaceAll(/[gy]/g, '');\n return flags === pattern.flags ? pattern : new RegExp(pattern.source, flags);\n};\n\n//? No-op used to settle the in-flight resolve chain tail regardless of outcome.\nconst noop = (): void => {\n /* intentionally empty */\n};\n\n//? Reduce any thrown value to a safe message string for logging — never log the\n//? raw error object (its `cause`/own-properties can carry a URL/token/PII string).\nconst errorMessage = (error: unknown): string =>\n error instanceof Error ? error.message : String(error);\n\n//? Invoke a consumer callback in isolation: a throwing hook must never abort an\n//? otherwise-successful resolve or mask the original error. Failures are warned,\n//? not propagated.\nconst runHook = (fn: () => void, label: string): void => {\n const [error] = tryCatchSync(fn);\n if (error) console.warn(`[secret-manager] ${label} callback threw (ignored):`, errorMessage(error));\n};\n\n//? Async sibling of `runHook` for a callback that may return a Promise (onApplied\n//? re-creates pools/SDK clients). Awaited but isolated — a rejection/throw is\n//? warned, never propagated, so the resolve that already applied stays successful.\n//? CC-7 exemption (no-raw-try-catch): `tryCatchSync` can't wrap an `await`, and\n//? the async framework `tryCatch` would auto-capture to the error tracker — a\n//? deliberate non-goal here (a consumer hook failure must stay a local warn in\n//? this dependency-light client, not a tracked event).\nconst runHookAsync = async (fn: () => void | Promise<void>, label: string): Promise<void> => {\n try {\n await fn();\n } catch (error) {\n console.warn(`[secret-manager] ${label} callback threw (ignored):`, errorMessage(error));\n }\n};\n\n//? RFC-style loopback hosts: plain http to these never leaves the machine, so it\n//? is always permitted regardless of `allowInsecureHttp`.\nconst isLoopbackHost = (hostname: string): boolean =>\n hostname === 'localhost' ||\n hostname === '127.0.0.1' ||\n hostname === '::1' ||\n hostname === '[::1]';\n\nconst validateUrl = (url: string, allowInsecureHttp: boolean): void => {\n //? Reject relative / non-http(s) URLs (e.g. `file://`) up front so the resolve\n //? endpoint can't be pointed at the local filesystem or another protocol.\n const [parseError, parsed] = tryCatchSync(() => new URL(url));\n if (parseError || !parsed) {\n throw new Error(`[secret-manager] Invalid \\`url\\`: \"${url}\" is not an absolute URL.`);\n }\n if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {\n throw new Error(`[secret-manager] Invalid \\`url\\` scheme \"${parsed.protocol}\": only http(s) is supported.`);\n }\n //? The channel carries the bearer token + plaintext secrets, so plain http is a\n //? cleartext leak. Permit it only for loopback, or behind an explicit opt-in\n //? (still warned loudly) — otherwise reject before any secret is sent.\n if (parsed.protocol === 'http:' && !isLoopbackHost(parsed.hostname)) {\n if (!allowInsecureHttp) {\n throw new Error(\n `[secret-manager] Refusing plain-http \\`url\\` \"${url}\": the bearer token and resolved secrets would travel in cleartext. Use https, point at a loopback host, or set \\`allowInsecureHttp: true\\` to override.`,\n );\n }\n console.warn(\n `[secret-manager] Using plain-http transport to a non-loopback host (\"${parsed.hostname}\"): the bearer token and resolved secrets travel in CLEARTEXT. Prefer https.`,\n );\n }\n};\n\n//? Build the name-eligibility predicate from `envNames` (allowlist array or\n//? predicate). SECURE DEFAULT: when `envNames` is unset, NOTHING is eligible —\n//? the resolver must never scan the whole inherited environment and POST every\n//? pointer-shaped value off-host. An explicit allowlist (or `() => true`) is\n//? required to opt in. The boot-time warning for the unset case is emitted by\n//? `warnIfEnvNamesUnset` at the resolve path.\nconst toNameFilter = (\n envNames: SecretManagerConfig['envNames'],\n): ((name: string) => boolean) => {\n if (envNames === undefined) return () => false;\n if (typeof envNames === 'function') return envNames;\n const allow = new Set(envNames);\n return (name) => allow.has(name);\n};\n\n//? Warn ONCE per process when `envNames` is unset: the resolver is deny-all in\n//? that state, so an operator who expected secrets to resolve gets a clear,\n//? actionable boot message instead of a silent no-op. Guarded so the production\n//? rotation poll (`refreshSecretManager` on an interval) doesn't re-emit it every\n//? cycle and flood the log sink.\nlet warnedEnvNamesUnset = false;\nconst warnIfEnvNamesUnset = (envNames: SecretManagerConfig['envNames']): void => {\n if (envNames !== undefined || warnedEnvNamesUnset) return;\n warnedEnvNamesUnset = true;\n console.warn(\n '[secret-manager] `envNames` is not set: NO environment values will be resolved off-host. Set `envNames` to an allowlist of the env names to resolve (or `() => true` to deliberately scan every name).',\n );\n};\n\n//? Split a set of `{ name -> value }` entries into pointer-shaped vs plain\n//? values, applying the `envNames` allowlist FIRST so a name excluded by\n//? `envNames` is treated as neither a pointer nor a plain value (it is dropped\n//? entirely). Shared by both ingest paths (boot `capturePointers` + the dev\n//? file-reload split) so the scoping rule can never drift between them.\nconst splitPointers = (\n entries: Iterable<[string, string]>,\n pattern: RegExp,\n envNames: SecretManagerConfig['envNames'],\n): { pointers: Record<string, string>; plain: Record<string, string> } => {\n const nameAllowed = toNameFilter(envNames);\n const pointers: Record<string, string> = {};\n const plain: Record<string, string> = {};\n for (const [name, value] of entries) {\n if (!nameAllowed(name)) continue;\n if (pattern.test(value)) pointers[name] = value;\n else plain[name] = value;\n }\n return { pointers, plain };\n};\n\nconst capturePointers = (\n pattern: RegExp,\n envNames: SecretManagerConfig['envNames'],\n): Record<string, string> => {\n const entries: [string, string][] = [];\n for (const [name, value] of Object.entries(process.env)) {\n if (typeof value === 'string') entries.push([name, value]);\n }\n return splitPointers(entries, pattern, envNames).pointers;\n};\n\nconst validateToken = (token: string): string => {\n //? An empty/whitespace token yields an `Authorization: Bearer ` header that\n //? silently auth-fails (and in hybrid mode falls back to local env) — reject it.\n const trimmed = token.trim();\n if (trimmed.length === 0) {\n throw new Error('[secret-manager] Bearer token is empty or whitespace-only.');\n }\n //? The `Bearer ` scheme is added at the call site. A token that already carries\n //? it would produce a malformed double-prefix `Bearer Bearer <...>` header —\n //? strip the redundant prefix (case-insensitive) and warn so the operator knows\n //? their config is wrong. Stripping is the forgiving path: a warn-but-pass-through\n //? would silently break every request, making this a very hard-to-debug boot issue.\n if (/^bearer\\s/i.test(trimmed)) {\n const stripped = trimmed.replace(/^bearer\\s+/i, '');\n console.warn(\n '[secret-manager] Token starts with a \"Bearer \" prefix; the scheme is added automatically — the prefix was stripped. Drop it from your configured token to silence this warning.',\n );\n return stripped;\n }\n return trimmed;\n};\n\nconst resolveToken = (token: SecretManagerToken): string => {\n if (typeof token === 'string') return validateToken(token);\n let raw: string;\n try {\n raw = readFileSync(token.fromFile, 'utf8');\n } catch (error) {\n //? Distinguish a missing/deleted file from other I/O errors so a dev\n //? hot-reload poll over a transiently-absent token file gives a clear log.\n const code = (error as NodeJS.ErrnoException | undefined)?.code;\n if (code === 'ENOENT') {\n throw new Error(`[secret-manager] Token file not found: \"${token.fromFile}\".`);\n }\n throw new Error(`[secret-manager] Failed to read token file \"${token.fromFile}\": ${String((error as Error | undefined)?.message ?? error)}`);\n }\n return validateToken(raw);\n};\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\n\n//? Hard cap on the resolve response body. The server returns a flat\n//? `{ pointer -> secret }` map; even hundreds of secrets stay well under 1 MB.\n//? A response larger than this is a compromised/buggy server (or a MITM on an\n//? `allowInsecureHttp` channel) and is rejected BEFORE it can OOM the client.\nconst MAX_RESOLVE_BODY_BYTES = 1_048_576;\n\n//? Read the response body as text, aborting once more than `maxBytes` have been\n//? received — so a server that lies about (or omits) Content-Length still can't\n//? stream an unbounded body into memory. Falls back to `response.text()` when the\n//? body isn't a readable stream (e.g. a mocked Response).\nconst readBodyCapped = async (response: Response, maxBytes: number): Promise<string> => {\n const body = response.body;\n if (!body) return response.text();\n const reader = body.getReader();\n const decoder = new TextDecoder();\n let received = 0;\n let out = '';\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n received += value.byteLength;\n if (received > maxBytes) {\n void reader.cancel();\n throw new Error(`[secret-manager] Resolve response exceeded the ${String(maxBytes)}-byte cap.`);\n }\n out += decoder.decode(value, { stream: true });\n }\n out += decoder.decode();\n return out;\n};\n\n//? One resolve round-trip: POST the pointers, validate the response, return the\n//? filtered `{ pointer -> value }` map. No retry/timeout orchestration here — the\n//? caller (`fetchResolve`) owns that so a hang can't slip past the abort signal.\nconst fetchResolveOnce = async (\n config: SecretManagerConfig,\n pointers: string[],\n): Promise<Record<string, string>> => {\n //? Defaults to the Node 20+ global fetch; pass fetchImpl for older hosts.\n const fetchFn = config.fetchImpl ?? globalThis.fetch;\n const resolvePath = config.resolvePath ?? '/resolve';\n const suffix = resolvePath.startsWith('/') ? resolvePath : `/${resolvePath}`;\n const endpoint = `${config.url.replace(/\\/+$/, '')}${suffix}`;\n\n //? Abort a black-hole server (accepts the TCP connection, never responds) so a\n //? hang surfaces as a rejection — boot can't freeze, and 'hybrid' reaches its\n //? warn-and-keep-local fallback. `timeoutMs: 0` disables the abort entirely.\n const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const signal = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined;\n\n //? Consumer `headers` are merged first so they can never override Authorization.\n const response = await fetchFn(endpoint, {\n method: 'POST',\n headers: {\n ...config.headers,\n 'Authorization': `Bearer ${resolveToken(config.token)}`,\n 'Content-Type': 'application/json',\n 'Accept': 'application/json',\n },\n body: JSON.stringify({ keys: pointers }),\n signal,\n });\n\n if (!response.ok) {\n //? Discard the unconsumed error body so a long-lived poll loop can't leave\n //? response bodies pending GC; failures carry only status text upward.\n void response.body?.cancel();\n throw new Error(`[secret-manager] Resolve request failed: ${String(response.status)} ${response.statusText}`);\n }\n\n //? The resolve response is the network input this client trusts least (a\n //? compromised/buggy server, or a MITM on an `allowInsecureHttp` channel).\n //? Reject an oversized body BEFORE buffering/parsing it so a hostile server\n //? can't OOM the booting client. A Content-Length header is advisory (can be\n //? absent/lying), so it is a fast pre-check only — the real guard is the\n //? streamed byte cap below.\n const declaredLength = Number(response.headers.get('content-length'));\n if (Number.isFinite(declaredLength) && declaredLength > MAX_RESOLVE_BODY_BYTES) {\n void response.body?.cancel();\n throw new Error(`[secret-manager] Resolve response too large (${String(declaredLength)} bytes > ${String(MAX_RESOLVE_BODY_BYTES)} cap).`);\n }\n const raw = await readBodyCapped(response, MAX_RESOLVE_BODY_BYTES);\n\n //? Parse as `unknown`, then narrow `values` with a runtime guard rather than\n //? trusting an up-front cast — the response is attacker-influenced (a\n //? compromised/buggy server) so its shape is not assumed.\n const [parseError, body] = tryCatchSync((): unknown => JSON.parse(raw));\n if (parseError) {\n throw new Error('[secret-manager] Resolve response was not valid JSON.');\n }\n const values = (body as { values?: unknown } | null)?.values;\n if (values === null || typeof values !== 'object') {\n throw new Error('[secret-manager] Resolve response missing `values` object.');\n }\n\n //? Filter the response down to the pointers we actually requested, and require\n //? each value to be a string. A compromised/buggy server could otherwise inject\n //? extra keys (cached + surfaced via getCachedResolution) or a non-string that\n //? coerces to '123' / '[object Object]' once written into process.env — only\n //? requested, string-valued pointers are trusted; a non-string is dropped (and\n //? thus treated as an unresolved pointer downstream: fatal in 'remote', warned\n //? in 'hybrid').\n const requested = new Set(pointers);\n const filtered: Record<string, string> = {};\n for (const [key, value] of Object.entries(values as Record<string, unknown>)) {\n if (!requested.has(key)) continue;\n if (typeof value !== 'string') {\n console.warn(`[secret-manager] Pointer \"${key}\" resolved to a non-string value (${typeof value}); ignoring.`);\n continue;\n }\n filtered[key] = value;\n }\n return filtered;\n};\n\nconst fetchResolve = async (\n config: SecretManagerConfig,\n pointers: string[],\n): Promise<Record<string, string>> => {\n //? `?? 0` only guards null/undefined — a configured `NaN` would survive and make\n //? `0 <= NaN` false, so the loop body never runs, `lastError` stays undefined,\n //? and we'd `throw undefined`. Coerce to a finite, non-negative count/delay.\n const rawRetryCount = config.retries?.count ?? 0;\n const retryCount = Number.isFinite(rawRetryCount) ? Math.max(0, rawRetryCount) : 0;\n const rawDelayMs = config.retries?.delayMs ?? 0;\n const delayMs = Number.isFinite(rawDelayMs) ? Math.max(0, rawDelayMs) : 0;\n\n let lastError: unknown = new Error('[secret-manager] resolve failed: no attempt was made');\n for (let attempt = 0; attempt <= retryCount; attempt++) {\n try {\n return await fetchResolveOnce(config, pointers);\n } catch (error) {\n lastError = error;\n if (attempt < retryCount && delayMs > 0) await sleep(delayMs);\n }\n }\n throw lastError;\n};\n\nconst applyResolved = (\n map: Record<string, string>,\n values: Record<string, string>,\n source: 'remote' | 'hybrid',\n): { name: string; pointer: string }[] => {\n //? In remote mode a single unresolved pointer is a hard boot failure. Check\n //? everything BEFORE mutating process.env so the failure is atomic.\n if (source === 'remote') {\n const missing = Object.entries(map).filter(([, pointer]) => values[pointer] === undefined);\n if (missing.length > 0) {\n const detail = missing.map(([name, pointer]) => `${pointer} (referenced by ${name})`).join(', ');\n throw new Error(`[secret-manager] Server did not resolve: ${detail}.`);\n }\n }\n\n //? Track only the env NAMES whose value actually changed — surfaced to\n //? `onApplied` so a client that captured a secret at construction time can\n //? re-create its pool/SDK client. Never carries the secret values themselves.\n const changes: { name: string; pointer: string }[] = [];\n for (const [name, pointer] of Object.entries(map)) {\n const value = values[pointer];\n if (value === undefined) {\n //? hybrid only — leave the pointer in place and warn so the operator sees it.\n console.warn(`[secret-manager] Pointer \"${pointer}\" (referenced by ${name}) not resolved; leaving \"${name}\" as-is.`);\n continue;\n }\n if (process.env[name] !== value) changes.push({ name, pointer });\n process.env[name] = value;\n }\n return changes;\n};\n\n//? Public resolve entry: SERIALIZE every resolve behind a single in-flight chain\n//? so a slow resolve can never land after a newer one (TOCTOU on `process.env` /\n//? `cachedResolution`). We chain off `resolveChain.then(...)` regardless of whether\n//? the prior resolve resolved or rejected, then re-publish the tail so the next\n//? caller waits on THIS one. The returned promise mirrors the inner outcome so\n//? `'remote'` still rejects the caller on a hard failure.\nconst doResolve = (\n config: SecretManagerConfig,\n phase: 'boot' | 'refresh' | 'file-reload',\n): Promise<void> => {\n const run = resolveChain.then(\n () => doResolveInner(config, phase),\n () => doResolveInner(config, phase),\n );\n //? Keep the chain alive even if this resolve rejects (swallow on the tail copy\n //? only — the returned `run` keeps the original rejection for the caller).\n resolveChain = run.then(noop, noop);\n return run;\n};\n\nconst doResolveInner = async (\n config: SecretManagerConfig,\n phase: 'boot' | 'refresh' | 'file-reload',\n): Promise<void> => {\n const source = config.source ?? 'remote';\n if (source === 'local') return;\n\n //? Secure default: an unset `envNames` resolves NOTHING off-host — warn loudly\n //? on every resolve so the deny-all state is never silent (an operator who\n //? expected secrets to resolve sees exactly what to set).\n warnIfEnvNamesUnset(config.envNames);\n\n //? Capture once on first resolve and reuse: the first resolve OVERWRITES the\n //? env value with the real secret, after which it no longer looks like a pointer.\n //? Re-capture when the map is empty so a pointer that wasn't present at boot\n //? (e.g. set into `process.env` after init) is still picked up by a later\n //? refresh — a non-empty `{}` would otherwise pin the resolver to zero pointers.\n if (pointerMap === null || Object.keys(pointerMap).length === 0) {\n pointerMap = capturePointers(\n stripStatefulFlags(config.pointerPattern ?? DEFAULT_POINTER_PATTERN),\n config.envNames,\n );\n }\n const activePointerMap = pointerMap;\n const pointers = [...new Set(Object.values(activePointerMap))];\n if (pointers.length === 0) {\n cachedResolution = { fetchedAt: Date.now(), values: {} };\n return;\n }\n\n //? CC-7 exemption (no-raw-try-catch): the async framework `tryCatch` auto-captures\n //? to the error tracker, but this is a deliberate fail-OPEN boot-time guard in an\n //? intentionally dependency-light client — a 'hybrid' resolve failure must stay a\n //? silent warn with NO error-tracker side-effect (and 'remote' re-throws untouched\n //? for a hard boot stop). `tryCatchSync` can't wrap the `await`, so the raw\n //? try/catch is kept on purpose; the fail-OPEN contract is the load-bearing detail.\n let values: Record<string, string>;\n let changes: { name: string; pointer: string }[];\n try {\n values = await fetchResolve(config, pointers);\n changes = applyResolved(activePointerMap, values, source);\n cachedResolution = { fetchedAt: Date.now(), values };\n } catch (error) {\n //? Opt-in observability seam: route the failure to Sentry/metrics. Kept\n //? alongside (not replacing) the warn below so the fail-open default is unchanged.\n //? Hooks run isolated: a throwing/hanging `onResolveError` must not mask the\n //? original resolve error (it is re-thrown below in 'remote').\n runHook(() => config.onResolveError?.(error, { phase }), 'onResolveError');\n if (source === 'hybrid') {\n //? Log the message only — never the raw error object: error objects are the\n //? classic accidental channel for a URL/token/PII string reaching a log sink.\n //? Full-fidelity routing is the `onResolveError` hook's job.\n console.warn('[secret-manager] Resolve failed, leaving local env as-is:', errorMessage(error));\n return;\n }\n throw error;\n }\n\n //? Notify AFTER the cache + process.env are coherent so a consumer that reads\n //? process.env inside the callback sees the applied values. Isolated so a\n //? throwing/hanging `onApplied` can't abort an otherwise-successful resolve\n //? (it has already written process.env + the cache).\n if (changes.length > 0) await runHookAsync(() => config.onApplied?.(changes), 'onApplied');\n};\n\n//? Minimal .env parser kept in-package so the resolver stays dependency-free.\n//? Handles standard `KEY=VALUE` lines, full-line + inline (` #`) comments, and\n//? single/double-quoted values. Not multi-line values or escape sequences.\nconst parseEnvFile = (content: string): Record<string, string> => {\n const out: Record<string, string> = {};\n for (const rawLine of content.split(/\\r?\\n/)) {\n const line = rawLine.trim();\n if (!line || line.startsWith('#')) continue;\n const eq = line.indexOf('=');\n if (eq <= 0) continue;\n const key = line.slice(0, eq).trim();\n //? Restrict to POSIX-shell env-var names. `.`/`-` keys can't be read via the\n //? normal `process.env.NAME` lookup, so accepting them is a silent footgun.\n if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {\n console.warn(`[secret-manager] Ignoring env key \"${key}\": not a valid environment variable name (^[A-Za-z_][A-Za-z0-9_]*$).`);\n continue;\n }\n let value = line.slice(eq + 1).trim();\n const quote = value[0];\n if ((quote === '\"' || quote === \"'\") && value.length >= 2 && value.endsWith(quote)) {\n value = value.slice(1, -1);\n } else {\n const commentAt = value.indexOf(' #');\n if (commentAt !== -1) value = value.slice(0, commentAt).trim();\n if (value.startsWith('#')) value = '';\n }\n out[key] = value;\n }\n return out;\n};\n\nconst scheduleReload = (): void => {\n //? Debounce — fs.watch can fire several events for one save.\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n void reloadSecretManagerFromFiles().catch((error: unknown) => {\n console.warn('[secret-manager] dev reload failed:', errorMessage(error));\n });\n }, 200);\n debounceTimer.unref();\n};\n\nconst startDevReload = (config: SecretManagerConfig): void => {\n if (devReloadStarted || config.dev === undefined) return;\n //? Allowlist the dev environments rather than exact-matching 'production': an\n //? unset / 'prod' / 'staging' NODE_ENV must NOT silently start fs watchers + a\n //? poll on a host the operator believes is production. Only an explicit\n //? 'development' or 'test' enables dev hot reload.\n const nodeEnv = process.env.NODE_ENV;\n if (nodeEnv !== 'development' && nodeEnv !== 'test') return;\n\n const watch = config.dev.watch ?? true;\n const pollIntervalMs = config.dev.pollIntervalMs ?? 0;\n if (!watch && pollIntervalMs <= 0) return;\n devReloadStarted = true;\n\n if (watch) {\n for (const file of config.dev.envFiles ?? DEFAULT_ENV_FILES) {\n if (!isSafeEnvFile(file)) {\n console.warn(`[secret-manager] ignoring unsafe dev envFile path (must be relative + within the project): ${file}`);\n continue;\n }\n try {\n const watcher = fsWatch(file, () => {\n scheduleReload();\n });\n watcher.unref();\n fileWatchers.push(watcher);\n } catch {\n //? The file may not exist (e.g. no .env.local) — nothing to watch.\n }\n }\n }\n\n if (pollIntervalMs > 0) {\n pollTimer = setInterval(() => {\n void refreshSecretManager().catch((error: unknown) => {\n console.warn('[secret-manager] poll refresh failed:', errorMessage(error));\n });\n }, pollIntervalMs);\n pollTimer.unref();\n }\n};\n\n/**\n * Initialize the secret-manager resolver. Call once as the very first line of\n * `server.ts`, BEFORE any other framework code reads `process.env`. In\n * `'remote'` / `'hybrid'` mode the resolved secrets are written into\n * `process.env` before this resolves, so downstream code sees them via the\n * standard `process.env.FOO` lookup. In `'local'` mode it is a no-op.\n */\nexport const initSecretManager = async (config: SecretManagerConfig): Promise<void> => {\n //? Validate the URL BEFORE recording activeConfig, so a failed init can't leave\n //? a later refreshSecretManager() resolving against an invalid config.\n if ((config.source ?? 'remote') !== 'local') {\n //? Only validate the URL when we'll actually hit the network ('remote' /\n //? 'hybrid'); 'local' may carry a placeholder url.\n validateUrl(config.url, config.allowInsecureHttp ?? false);\n }\n activeConfig = config;\n if ((config.source ?? 'remote') === 'local') return;\n await doResolve(config, 'boot');\n startRotationPoll(config);\n startDevReload(config);\n};\n\n//? Production-capable rotation poll: re-resolve every `config.pollIntervalMs` ms\n//? in ALL environments (distinct from the dev-only `dev.pollIntervalMs` file-watch\n//? channel, which is gated off in production). Unref'd so it never blocks exit.\nconst startRotationPoll = (config: SecretManagerConfig): void => {\n const intervalMs = config.pollIntervalMs ?? 0;\n if (intervalMs <= 0 || rotationPollTimer) return;\n rotationPollTimer = setInterval(() => {\n void refreshSecretManager().catch((error: unknown) => {\n console.warn('[secret-manager] rotation poll refresh failed:', errorMessage(error));\n });\n }, intervalMs);\n rotationPollTimer.unref();\n};\n\n/**\n * Re-resolve against the server, ignoring nothing — used by the dev hot-reload\n * watch/poll and callable manually when an admin rotates a secret and you want\n * a long-running process to pick it up without a restart.\n */\nexport const refreshSecretManager = async (): Promise<void> => {\n if (!activeConfig || (activeConfig.source ?? 'remote') === 'local') return;\n await doResolve(activeConfig, 'refresh');\n};\n\n/**\n * Dev hot-reload entry: re-parse the configured env files and apply them — plain\n * (non-pointer) values are injected straight into `process.env` (live config\n * reload), pointer-shaped values are re-resolved against the server. Wired to the\n * `.env` / `.env.local` file watch; also callable manually.\n */\nexport const reloadSecretManagerFromFiles = async (): Promise<void> => {\n const config = activeConfig;\n if (!config || (config.source ?? 'remote') === 'local') return;\n\n const files = config.dev?.envFiles ?? DEFAULT_ENV_FILES;\n const pattern = stripStatefulFlags(config.pointerPattern ?? DEFAULT_POINTER_PATTERN);\n\n //? Re-read every file in load order; later files (e.g. .env.local) override.\n const merged: Record<string, string> = {};\n for (const file of files) {\n if (!isSafeEnvFile(file)) {\n console.warn(`[secret-manager] ignoring unsafe dev envFile path: ${file}`);\n continue;\n }\n let content: string;\n try {\n content = readFileSync(file, 'utf8');\n } catch {\n continue; //? file may not exist (e.g. no .env.local)\n }\n Object.assign(merged, parseEnvFile(content));\n }\n\n //? Split plain (live config reload) from pointer-shaped values through the SAME\n //? `envNames` allowlist the boot path uses (`capturePointers`), so a name excluded\n //? by `envNames` is dropped on BOTH channels — never POSTed off-host as a pointer,\n //? never injected into `process.env` as a plain value. This closes the scoping\n //? drift where a file-reload could send/apply names the boot scan rejected.\n const { pointers: freshPointerMap, plain: plainValues } = splitPointers(\n Object.entries(merged),\n pattern,\n config.envNames,\n );\n\n //? MERGE the fresh file-sourced pointers over the existing map (don't replace):\n //? a pointer captured at boot from the inherited shell/CI env that isn't in a\n //? watched file would otherwise be dropped and stop rotating. A file-sourced\n //? pointer with the same name still wins.\n //? Build the merged map locally and commit it ONLY after a successful resolve so\n //? a failed remote reload can't permanently poison the in-memory pointer map and\n //? cause every subsequent refreshSecretManager / poll to resolve the bad set.\n const previousPointerMap = pointerMap;\n pointerMap = { ...pointerMap, ...freshPointerMap };\n\n //? Resolve the pointers FIRST. In 'remote' mode an unresolved pointer throws —\n //? mirror the atomic boot path and inject the plain values only AFTER a\n //? successful resolve, so a throw never leaves half-applied state. On failure\n //? roll back the pointer map to its pre-reload snapshot.\n try {\n await doResolve(config, 'file-reload');\n } catch (error) {\n pointerMap = previousPointerMap;\n throw error;\n }\n for (const [name, value] of Object.entries(plainValues)) {\n process.env[name] = value;\n }\n};\n\n/**\n * Read the last `{ fetchedAt, values }` resolution (pointer -> value), or `null`.\n *\n * ⚠️ SENSITIVE: `values` maps each pointer to its RAW resolved secret. The result\n * is a shallow copy (values are flat strings, so mutating the returned object does\n * not corrupt the cache), but the values are still the real secrets — NEVER\n * serialize the result into an HTTP response, a `/health` payload, or a log line.\n * For a safe diagnostic use {@link getCachedResolutionMeta}, which never exposes\n * the values.\n */\nexport const getCachedResolution = (): CachedResolution | null =>\n cachedResolution === null\n ? null\n : { fetchedAt: cachedResolution.fetchedAt, values: { ...cachedResolution.values } };\n\n/** Values-free view of {@link getCachedResolution}: the timestamp + resolved pointer NAMES only. */\nexport interface CachedResolutionMeta {\n /** `Date.now()` of the last successful resolve. */\n fetchedAt: number;\n /** The resolved pointer strings (never the secret values). */\n pointerNames: string[];\n /** How many pointers were resolved. */\n pointerCount: number;\n}\n\n/**\n * Safe diagnostic accessor: the last resolution's timestamp + resolved pointer\n * NAMES, with the secret values stripped. Use this on any surface that might be\n * logged or served (`/health`, metrics) so the convenient path is also the safe\n * one — {@link getCachedResolution} is the values-carrying escape hatch.\n */\nexport const getCachedResolutionMeta = (): CachedResolutionMeta | null => {\n if (cachedResolution === null) return null;\n const pointerNames = Object.keys(cachedResolution.values);\n return { fetchedAt: cachedResolution.fetchedAt, pointerNames, pointerCount: pointerNames.length };\n};\n\n/**\n * Tear down the dev file watchers, the dev poll, the rotation poll, and the\n * debounce timer WITHOUT wiping the resolved cache / active config. Use for a\n * graceful shutdown of an embedded resolver (worker / CLI) so no timers keep\n * firing; the last resolved `process.env` values stay in place.\n */\nexport const stopSecretManager = (): void => {\n devReloadStarted = false;\n if (pollTimer) {\n clearInterval(pollTimer);\n pollTimer = null;\n }\n if (rotationPollTimer) {\n clearInterval(rotationPollTimer);\n rotationPollTimer = null;\n }\n if (debounceTimer) {\n clearTimeout(debounceTimer);\n debounceTimer = null;\n }\n for (const watcher of fileWatchers) {\n watcher.close();\n }\n fileWatchers.length = 0;\n};\n\n/** Test-only — clear module state and tear down any dev watchers / timers. */\nexport const resetSecretManagerForTests = (): void => {\n stopSecretManager();\n cachedResolution = null;\n pointerMap = null;\n activeConfig = null;\n resolveChain = Promise.resolve();\n warnedEnvNamesUnset = false;\n};\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luckystack/secret-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
|
-
"access": "public"
|
|
6
|
+
"access": "public",
|
|
7
|
+
"provenance": true
|
|
7
8
|
},
|
|
8
9
|
"type": "module",
|
|
9
10
|
"description": "Rotation-aware secret resolver client for LuckyStack. Commit `.env` pointers (e.g. OPENAI_KEY=OPENAI_AUTHORIZATION_KEY_V5) instead of real secrets; at boot this client resolves them against an external append-only secret-manager server and writes the real values into process.env. Supports local / remote / hybrid modes + opt-in dev hot reload.",
|
|
@@ -50,5 +51,8 @@
|
|
|
50
51
|
"build": "tsup",
|
|
51
52
|
"build:watch": "tsup --watch",
|
|
52
53
|
"test": "vitest run"
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"@luckystack/core": "^0.2.0"
|
|
53
57
|
}
|
|
54
58
|
}
|