@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 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 dev **poll** channel). Call manually after an admin rotates a secret on a long-running process. | -> 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. | -> docs/architecture.md |
30
- | `getCachedResolution()` | Returns the last `{ fetchedAt, values }` (pointer -> value) for diagnostics, or `null`. | -> docs/architecture.md |
31
- | `resetSecretManagerForTests()` | Test-onlyclears module state and tears down dev watchers / timers. | -> docs/architecture.md |
32
- | Type `SecretManagerConfig` | `{ url; token; source?; pointerPattern?; fetchImpl?; dev? }` where `dev` is `{ watch?; pollIntervalMs?; envFiles? }`. | -> docs/architecture.md |
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
- | Source | Purpose |
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
- | `config.url` | Base URL of the secret-manager server (trailing slash optional). |
63
- | `config.token` | Shared bearer token: literal string or `{ fromFile }` (gitignored single-line file). |
64
- | `config.source` | `'remote'` (default) / `'local'` / `'hybrid'`. |
65
- | `config.dev` | Opt-in dev hot reload: `{ watch?, pollIntervalMs? }`. Ignored in production. |
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. Requires the
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
- /** Read the last `{ fetchedAt, values }` resolution (pointer -> value), or `null`. */
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 capturePointers = (pattern) => {
13
- const map = {};
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" && pattern.test(value)) {
16
- map[name] = value;
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 map;
117
+ return validateToken(raw);
20
118
  };
21
- var resolveToken = (token) => typeof token === "string" ? token : readFileSync(token.fromFile, "utf8").trim();
22
- var fetchResolve = async (config, pointers) => {
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 endpoint = `${config.url.replace(/\/+$/, "")}/resolve`;
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 body = await response.json();
38
- if (!body.values || typeof body.values !== "object") {
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
- return body.values;
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 = async (config) => {
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
- pointerMap ??= capturePointers(config.pointerPattern ?? DEFAULT_POINTER_PATTERN);
64
- const pointers = [...new Set(Object.values(pointerMap))];
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
- const values = await fetchResolve(config, pointers);
71
- applyResolved(pointerMap, values, source);
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 (!/^[\w.-]+$/.test(key)) continue;
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
- if (process.env.NODE_ENV === "production") return;
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
- for (const [name, value] of Object.entries(merged)) {
167
- if (pattern.test(value)) {
168
- freshPointerMap[name] = value;
169
- } else {
170
- process.env[name] = value;
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 resetSecretManagerForTests = () => {
178
- cachedResolution = null;
179
- pointerMap = null;
180
- activeConfig = null;
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.1.8",
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
  }