@luckystack/secret-manager 0.6.4 → 0.6.5

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/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Added
11
+
12
+ - **Fires the framework "secrets resolved" channel automatically** (ADR 0026). After
13
+ every resolve that changes `process.env`, the client now notifies every listener
14
+ published on the global-symbol array `Symbol.for('luckystack.secretsResolved.listeners')`
15
+ with the changed env NAMES. This lets `@luckystack/core` rebuild clients that captured a
16
+ now-stale secret at construction (most importantly the default Redis client — ioredis
17
+ bakes the password in at `new Redis(...)` time) with ZERO consumer code and no
18
+ `onApplied` wiring. Decoupled via the global symbol so this package keeps no import of
19
+ core (its "zero required deps" contract is unchanged); best-effort + isolated (a missing
20
+ core or a throwing listener never breaks the resolve path). The consumer `onApplied`
21
+ callback still fires alongside it.
22
+
10
23
  ## [0.1.0]
11
24
 
12
25
  ### Added
package/dist/index.js CHANGED
@@ -240,6 +240,18 @@ var fetchResolve = async (config, pointers) => {
240
240
  }
241
241
  throw lastError;
242
242
  };
243
+ var GLOBAL_SECRETS_RESOLVED_LISTENERS = /* @__PURE__ */ Symbol.for("luckystack.secretsResolved.listeners");
244
+ var fireFrameworkSecretsResolved = (changedNames) => {
245
+ const listeners = Reflect.get(globalThis, GLOBAL_SECRETS_RESOLVED_LISTENERS);
246
+ if (!Array.isArray(listeners)) return;
247
+ for (const listener of listeners) {
248
+ if (typeof listener !== "function") continue;
249
+ try {
250
+ listener(changedNames);
251
+ } catch {
252
+ }
253
+ }
254
+ };
243
255
  var applyResolved = (map, values, source) => {
244
256
  if (source === "remote") {
245
257
  const missing = Object.entries(map).filter(([, pointer]) => values[pointer] === void 0);
@@ -298,7 +310,10 @@ var doResolveInner = async (config, phase) => {
298
310
  }
299
311
  throw error;
300
312
  }
301
- if (changes.length > 0) await runHookAsync(() => config.onApplied?.(changes), "onApplied");
313
+ if (changes.length > 0) {
314
+ fireFrameworkSecretsResolved(changes.map((change) => change.name));
315
+ await runHookAsync(() => config.onApplied?.(changes), "onApplied");
316
+ }
302
317
  };
303
318
  var parseEnvFile = (content) => {
304
319
  const out = {};
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["//? @luckystack/secret-manager — rotation-aware secret resolver client.\r\n//?\r\n//? Idea: secrets live in a central secret-manager server (append-only,\r\n//? versioned, one shared bearer token). Apps commit their `.env` to git, but\r\n//? instead of real secrets it holds POINTERS:\r\n//?\r\n//? OPENAI_KEY=OPENAI_AUTHORIZATION_KEY_V5\r\n//?\r\n//? A pointer has the shape `<BASE>_V<number>`. At boot this client scans\r\n//? `process.env`, collects every pointer-shaped value, asks the server to\r\n//? resolve them in ONE request, and overwrites each `process.env` entry with\r\n//? the real secret — so downstream code reads `process.env.OPENAI_KEY` and gets\r\n//? the resolved value, not the pointer. Rotating a secret means publishing a\r\n//? new version (`..._V6`) on the server, never editing an old one, so old git\r\n//? branches that still point at `..._V5` keep booting.\r\n//?\r\n//? Three modes:\r\n//? 1. `source: 'remote'` (default) — resolve from the server. A missing\r\n//? pointer or an unreachable server throws — production hard-stop.\r\n//? 2. `source: 'local'` — no network. Pointers are left untouched. Use in\r\n//? tests / offline dev when the server isn't reachable.\r\n//? 3. `source: 'hybrid'` — try the server, but on failure warn and leave\r\n//? whatever `process.env` already holds. Use for staging / canary.\r\n//?\r\n//? Optional dev-only hot reload (`config.dev`) re-resolves on `.env` file\r\n//? changes and/or on an interval, so server-side rotations are picked up\r\n//? without restarting a long-running dev process. It is a no-op in production.\r\n\r\nimport { readFileSync, watch as fsWatch, type FSWatcher } from 'node:fs';\r\nimport path from 'node:path';\r\n\r\nimport { resolveEnvKey, sleep, tryCatchSync } from '@luckystack/core';\r\n\r\n/**\r\n * Bearer token: a literal string, or a file whose entire contents are the token.\r\n *\r\n * When using `{ fromFile }`, the path MUST NOT be derived from untrusted input.\r\n * No path-traversal check is applied — the caller is responsible for ensuring\r\n * the path is a fixed, gitignored file (e.g. `.secret-manager-token`) next to\r\n * the project root. Paths like `/etc/passwd` or `../../.ssh/id_rsa` would be\r\n * read without error.\r\n */\r\nexport type SecretManagerToken = string | { fromFile: string };\r\n\r\nexport interface SecretManagerConfig {\r\n /** Base URL of the secret-manager server (trailing slash optional). */\r\n url: string;\r\n /**\r\n * Shared bearer token. Either the literal string or `{ fromFile }` pointing\r\n * at a gitignored file whose entire contents are the token (read at resolve\r\n * time, so rotating the file is picked up by the next poll / refresh).\r\n */\r\n token: SecretManagerToken;\r\n /** Resolution mode. Default `'remote'`. */\r\n source?: 'remote' | 'local' | 'hybrid';\r\n /**\r\n * Override the pointer-shape detector. Any `process.env` value matching this\r\n * is treated as a pointer; anything else is a literal and left untouched.\r\n * Default `/^(.+)_V(\\d+)$/`. A `g`/`y` flag is stripped (a stateful regex would\r\n * misclassify alternating entries).\r\n */\r\n pointerPattern?: RegExp;\r\n /**\r\n * Scope which `process.env` entries are pointer-eligible by NAME. This allowlist\r\n * is REQUIRED to resolve anything off-host: an array of names or a predicate.\r\n *\r\n * SECURE DEFAULT: when unset, NOTHING is resolved off-host (a clear boot warning\r\n * is emitted instead). Scanning the entire inherited environment would POST any\r\n * unrelated, pointer-shaped inherited value (`RELEASE_TAG=build_2024_V2`) to the\r\n * secret-manager server — so an explicit allowlist is mandatory. To opt back into\r\n * scanning every name, pass `() => true` as a deliberate, auditable choice.\r\n */\r\n envNames?: string[] | ((name: string) => boolean);\r\n /**\r\n * Allow a plain-`http:` server `url`. By default only `https:` is accepted for\r\n * non-loopback hosts (the channel carries the bearer token + plaintext secrets);\r\n * loopback (`localhost`/`127.0.0.1`/`[::1]`) is always permitted. Set `true` to\r\n * permit `http:` to any host — a loud warning is still emitted.\r\n */\r\n allowInsecureHttp?: boolean;\r\n /**\r\n * Abort a resolve request that has not responded within this many ms. Prevents a\r\n * black-hole server (accepts the TCP connection, never responds) from hanging boot\r\n * until undici's ~300s default — and, in `'hybrid'`, from never reaching the\r\n * warn-and-keep-local fallback (a hang never rejects). Default `10_000`. Set `0`\r\n * to disable the timeout.\r\n */\r\n timeoutMs?: number;\r\n /**\r\n * Retry a failed resolve before giving up (transport error or non-2xx). Default\r\n * `{ count: 0 }` (no retry). After exhaustion `'remote'` still throws and\r\n * `'hybrid'` still warns-and-keeps-local.\r\n */\r\n retries?: { count: number; delayMs?: number };\r\n /** Override the resolve path appended to `url`. Default `'/resolve'`. */\r\n resolvePath?: string;\r\n /** Extra request headers merged onto every resolve request (cannot override `Authorization`). */\r\n headers?: Record<string, string>;\r\n /**\r\n * Called after a resolve writes new values into `process.env`, with ONLY the env\r\n * NAMES whose value actually changed (never the secret values). A client that\r\n * captured a secret at construction time (Prisma from `DATABASE_URL`, a Redis /\r\n * Stripe / OpenAI SDK) can re-create its pool/client here so a rotation lands.\r\n */\r\n onApplied?: (changes: { name: string; pointer: string }[]) => void | Promise<void>;\r\n /**\r\n * Called when a resolve fails, alongside the existing `console.warn` (current\r\n * behavior is unchanged when unset). Route the failure to Sentry/metrics —\r\n * useful for `'hybrid'`/dev where a failure otherwise silently keeps stale env.\r\n */\r\n onResolveError?: (error: unknown, context: { phase: 'boot' | 'refresh' | 'file-reload' }) => void;\r\n /** Override the global `fetch` (tests / non-Node-20 hosts). */\r\n fetchImpl?: typeof fetch;\r\n /**\r\n * Re-resolve the current pointers every N ms in ALL environments (the production\r\n * rotation poll), distinct from the dev-only `dev.pollIntervalMs` file-watch\r\n * channel. Default `0` (disabled). Unref'd, so it never blocks process exit.\r\n */\r\n pollIntervalMs?: number;\r\n /**\r\n * Opt-in dev-only hot reload. Ignored when `NODE_ENV === 'production'`.\r\n * Provide an (even empty) object to enable it.\r\n */\r\n dev?: {\r\n /**\r\n * Watch the env files and hot-reload on change. Default `true`. On change\r\n * the files are re-parsed and applied: plain (non-pointer) values are\r\n * injected straight into `process.env` (live config reload), and\r\n * pointer-shaped values are re-resolved against the server.\r\n */\r\n watch?: boolean;\r\n /** Re-resolve the current pointers every N ms (server-rotation poll). Default `0` (disabled). */\r\n pollIntervalMs?: number;\r\n /** Env files to watch + reparse, in load order (later overrides earlier). Default `['.env', '.env.local']`. */\r\n envFiles?: string[];\r\n };\r\n}\r\n\r\nexport interface CachedResolution {\r\n /** `Date.now()` of the last successful resolve. */\r\n fetchedAt: number;\r\n /** Map of pointer string -> resolved value (what the server returned). */\r\n values: Record<string, string>;\r\n}\r\n\r\nconst DEFAULT_POINTER_PATTERN = /^(.+)_V(\\d+)$/;\r\nconst DEFAULT_ENV_FILES = ['.env', '.env.local'];\r\n\r\n//? Dev hot-reload reads these files and injects their values into `process.env`.\r\n//? `dev.envFiles` is consumer config (not runtime user input), so an ABSOLUTE\r\n//? path is treated as an explicit, allowed choice (e.g. a shared secrets file on\r\n//? a developer machine). A RELATIVE path, however, must stay within the project\r\n//? root — reject `..` traversal (the plausible \"injected via a relative path\"\r\n//? escape). The caller skips + warns on a rejected entry (fail-open, consistent\r\n//? with the package's swallow-on-missing-file behaviour).\r\n//? Note: absolute paths are accepted without further validation. Consumers who\r\n//? configure `dev.envFiles` with absolute paths take responsibility for ensuring\r\n//? those paths are appropriate (e.g. a shared developer-machine secrets file).\r\n//? A loud warn is emitted so the choice is never silent in logs.\r\nconst isSafeEnvFile = (file: string, warnAbsolute = false): boolean => {\r\n if (path.isAbsolute(file)) {\r\n if (warnAbsolute) {\r\n console.warn(\r\n `[secret-manager] dev.envFiles contains an absolute path: \"${file}\". Absolute paths are permitted as an explicit choice (e.g. a shared secrets file) but are not checked for safety. Ensure this path is intentional.`,\r\n );\r\n }\r\n return true;\r\n }\r\n const rel = path.relative(process.cwd(), path.resolve(process.cwd(), file));\r\n return !rel.startsWith('..');\r\n};\r\n\r\n//? Module state. The resolver is meant to run once per process at boot; these\r\n//? bindings keep it idempotent and let dev hot-reload re-resolve later.\r\nlet cachedResolution: CachedResolution | null = null;\r\n//? envName -> pointer string, captured once on first resolve. Reused on every\r\n//? refresh because the first resolve OVERWRITES the env value with the real\r\n//? secret, after which it no longer looks like a pointer.\r\nlet pointerMap: Record<string, string> | null = null;\r\nlet activeConfig: SecretManagerConfig | null = null;\r\n\r\n//? In-flight resolve, used to SERIALIZE concurrent resolves. Four channels can\r\n//? funnel into `doResolve` (boot, the production rotation poll, the dev poll, the\r\n//? dev file-watch + a manual `refreshSecretManager()`); without a guard a slow\r\n//? in-flight resolve can land AFTER a newer one, leaving `process.env` and\r\n//? `cachedResolution` disagreeing (stale secrets). Each call awaits the previous\r\n//? one's settlement before starting, so resolves apply strictly in order.\r\nlet resolveChain: Promise<void> = Promise.resolve();\r\n\r\n//? Dev hot-reload + rotation-poll handles, torn down by stopSecretManager.\r\nlet devReloadStarted = false;\r\nlet pollTimer: ReturnType<typeof setInterval> | null = null;\r\nlet rotationPollTimer: ReturnType<typeof setInterval> | null = null;\r\nlet debounceTimer: ReturnType<typeof setTimeout> | null = null;\r\nconst fileWatchers: FSWatcher[] = [];\r\n\r\n//? A consumer-supplied `pointerPattern` with a `g`/`y` flag makes `.test()`\r\n//? stateful (advances `lastIndex`), silently misclassifying alternating entries.\r\n//? Strip those flags up front; everything else is preserved.\r\nconst stripStatefulFlags = (pattern: RegExp): RegExp => {\r\n const flags = pattern.flags.replaceAll(/[gy]/g, '');\r\n return flags === pattern.flags ? pattern : new RegExp(pattern.source, flags);\r\n};\r\n\r\n//? No-op used to settle the in-flight resolve chain tail regardless of outcome.\r\nconst noop = (): void => {\r\n /* intentionally empty */\r\n};\r\n\r\n//? Reduce any thrown value to a safe message string for logging — never log the\r\n//? raw error object (its `cause`/own-properties can carry a URL/token/PII string).\r\nconst errorMessage = (error: unknown): string =>\r\n error instanceof Error ? error.message : String(error);\r\n\r\n//? Invoke a consumer callback in isolation: a throwing hook must never abort an\r\n//? otherwise-successful resolve or mask the original error. Failures are warned,\r\n//? not propagated.\r\nconst runHook = (fn: () => void, label: string): void => {\r\n const [error] = tryCatchSync(fn);\r\n if (error) console.warn(`[secret-manager] ${label} callback threw (ignored):`, errorMessage(error));\r\n};\r\n\r\n//? Bound how long an async consumer hook may run before the resolve chain stops\r\n//? waiting on it. A hook that NEVER resolves (a stuck `onApplied` awaiting a dead\r\n//? pool) would otherwise wedge `resolveChain` forever, so every later resolve\r\n//? (boot poll, dev watch, manual refresh) deadlocks behind it.\r\nconst HOOK_TIMEOUT_MS = 30_000;\r\n\r\n//? Async sibling of `runHook` for a callback that may return a Promise (onApplied\r\n//? re-creates pools/SDK clients). Awaited but isolated AND time-bounded — a\r\n//? rejection/throw is warned (never propagated, so the resolve that already\r\n//? applied stays successful), and a hook that HANGS is abandoned after\r\n//? `timeoutMs` via a `Promise.race` (mirrors the fetch `AbortSignal.timeout`\r\n//? black-hole guard) so a stuck hook can't deadlock the serialized resolve chain.\r\n//? The race only stops AWAITING the hook — it cannot cancel the consumer's\r\n//? promise, so a still-running hook keeps going in the background; we just no\r\n//? longer block on it. `timeoutMs <= 0` disables the bound.\r\n//? CC-7 exemption (no-raw-try-catch): `tryCatchSync` can't wrap an `await`, and\r\n//? the async framework `tryCatch` would auto-capture to the error tracker — a\r\n//? deliberate non-goal here (a consumer hook failure must stay a local warn in\r\n//? this dependency-light client, not a tracked event).\r\nconst runHookAsync = async (\r\n fn: () => void | Promise<void>,\r\n label: string,\r\n timeoutMs = HOOK_TIMEOUT_MS,\r\n): Promise<void> => {\r\n try {\r\n if (timeoutMs <= 0) {\r\n await fn();\r\n return;\r\n }\r\n let timer: ReturnType<typeof setTimeout> | undefined;\r\n const timeout = new Promise<void>((resolve) => {\r\n timer = setTimeout(() => {\r\n console.warn(\r\n `[secret-manager] ${label} callback did not settle within ${String(timeoutMs)}ms; continuing without waiting (the hook may still be running in the background).`,\r\n );\r\n resolve();\r\n }, timeoutMs);\r\n timer.unref();\r\n });\r\n try {\r\n await Promise.race([Promise.resolve(fn()), timeout]);\r\n } finally {\r\n if (timer) clearTimeout(timer);\r\n }\r\n } catch (error) {\r\n console.warn(`[secret-manager] ${label} callback threw (ignored):`, errorMessage(error));\r\n }\r\n};\r\n\r\n//? RFC-style loopback hosts: plain http to these never leaves the machine, so it\r\n//? is always permitted regardless of `allowInsecureHttp`.\r\nconst isLoopbackHost = (hostname: string): boolean =>\r\n hostname === 'localhost' ||\r\n hostname === '127.0.0.1' ||\r\n hostname === '::1' ||\r\n hostname === '[::1]';\r\n\r\nconst validateUrl = (url: string, allowInsecureHttp: boolean): void => {\r\n //? Reject relative / non-http(s) URLs (e.g. `file://`) up front so the resolve\r\n //? endpoint can't be pointed at the local filesystem or another protocol.\r\n const [parseError, parsed] = tryCatchSync(() => new URL(url));\r\n if (parseError || !parsed) {\r\n throw new Error(`[secret-manager] Invalid \\`url\\`: \"${url}\" is not an absolute URL.`);\r\n }\r\n if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {\r\n throw new Error(`[secret-manager] Invalid \\`url\\` scheme \"${parsed.protocol}\": only http(s) is supported.`);\r\n }\r\n //? The channel carries the bearer token + plaintext secrets, so plain http is a\r\n //? cleartext leak. Permit it only for loopback, or behind an explicit opt-in\r\n //? (still warned loudly) — otherwise reject before any secret is sent.\r\n if (parsed.protocol === 'http:' && !isLoopbackHost(parsed.hostname)) {\r\n if (!allowInsecureHttp) {\r\n throw new Error(\r\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.`,\r\n );\r\n }\r\n console.warn(\r\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.`,\r\n );\r\n }\r\n};\r\n\r\n//? Build the name-eligibility predicate from `envNames` (allowlist array or\r\n//? predicate). SECURE DEFAULT: when `envNames` is unset, NOTHING is eligible —\r\n//? the resolver must never scan the whole inherited environment and POST every\r\n//? pointer-shaped value off-host. An explicit allowlist (or `() => true`) is\r\n//? required to opt in. The boot-time warning for the unset case is emitted by\r\n//? `warnIfEnvNamesUnset` at the resolve path.\r\nconst toNameFilter = (\r\n envNames: SecretManagerConfig['envNames'],\r\n): ((name: string) => boolean) => {\r\n if (envNames === undefined) return () => false;\r\n if (typeof envNames === 'function') return envNames;\r\n const allow = new Set(envNames);\r\n return (name) => allow.has(name);\r\n};\r\n\r\n//? Warn ONCE per process when `envNames` is unset: the resolver is deny-all in\r\n//? that state, so an operator who expected secrets to resolve gets a clear,\r\n//? actionable boot message instead of a silent no-op. Guarded so the production\r\n//? rotation poll (`refreshSecretManager` on an interval) doesn't re-emit it every\r\n//? cycle and flood the log sink.\r\nlet warnedEnvNamesUnset = false;\r\nconst warnIfEnvNamesUnset = (envNames: SecretManagerConfig['envNames']): void => {\r\n if (envNames !== undefined || warnedEnvNamesUnset) return;\r\n warnedEnvNamesUnset = true;\r\n console.warn(\r\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).',\r\n );\r\n};\r\n\r\n//? Split a set of `{ name -> value }` entries into pointer-shaped vs plain\r\n//? values, applying the `envNames` allowlist FIRST so a name excluded by\r\n//? `envNames` is treated as neither a pointer nor a plain value (it is dropped\r\n//? entirely). Shared by both ingest paths (boot `capturePointers` + the dev\r\n//? file-reload split) so the scoping rule can never drift between them.\r\nconst splitPointers = (\r\n entries: Iterable<[string, string]>,\r\n pattern: RegExp,\r\n envNames: SecretManagerConfig['envNames'],\r\n): { pointers: Record<string, string>; plain: Record<string, string> } => {\r\n const nameAllowed = toNameFilter(envNames);\r\n const pointers: Record<string, string> = {};\r\n const plain: Record<string, string> = {};\r\n for (const [name, value] of entries) {\r\n if (!nameAllowed(name)) continue;\r\n if (pattern.test(value)) pointers[name] = value;\r\n else plain[name] = value;\r\n }\r\n return { pointers, plain };\r\n};\r\n\r\nconst capturePointers = (\r\n pattern: RegExp,\r\n envNames: SecretManagerConfig['envNames'],\r\n): Record<string, string> => {\r\n const entries: [string, string][] = [];\r\n for (const [name, value] of Object.entries(process.env)) {\r\n if (typeof value === 'string') entries.push([name, value]);\r\n }\r\n return splitPointers(entries, pattern, envNames).pointers;\r\n};\r\n\r\nconst validateToken = (token: string): string => {\r\n //? An empty/whitespace token yields an `Authorization: Bearer ` header that\r\n //? silently auth-fails (and in hybrid mode falls back to local env) — reject it.\r\n const trimmed = token.trim();\r\n if (trimmed.length === 0) {\r\n throw new Error('[secret-manager] Bearer token is empty or whitespace-only.');\r\n }\r\n //? The `Bearer ` scheme is added at the call site. A token that already carries\r\n //? it would produce a malformed double-prefix `Bearer Bearer <...>` header —\r\n //? strip the redundant prefix (case-insensitive) and warn so the operator knows\r\n //? their config is wrong. Stripping is the forgiving path: a warn-but-pass-through\r\n //? would silently break every request, making this a very hard-to-debug boot issue.\r\n if (/^bearer\\s/i.test(trimmed)) {\r\n const stripped = trimmed.replace(/^bearer\\s+/i, '');\r\n console.warn(\r\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.',\r\n );\r\n return stripped;\r\n }\r\n return trimmed;\r\n};\r\n\r\nconst resolveToken = (token: SecretManagerToken): string => {\r\n if (typeof token === 'string') return validateToken(token);\r\n //? Distinguish a missing/deleted file from other I/O errors so a dev\r\n //? hot-reload poll over a transiently-absent token file gives a clear log.\r\n //? Note: error messages include `token.fromFile`. In hybrid mode these propagate\r\n //? to console.warn via `errorMessage(error)` (line ~597). If the path contains\r\n //? sensitive directory names (e.g. /home/admin/.keys/prod-token), those names\r\n //? will appear in log aggregators. Treat the token file path as infrastructure\r\n //? metadata and ensure your log sink is appropriately access-controlled.\r\n const [readError, raw] = tryCatchSync(() => readFileSync(token.fromFile, 'utf8'));\r\n if (readError) {\r\n const code = readError instanceof Error && 'code' in readError ? (readError as { code?: string }).code : undefined;\r\n if (code === 'ENOENT') {\r\n throw new Error(`[secret-manager] Token file not found: \"${token.fromFile}\".`);\r\n }\r\n throw new Error(`[secret-manager] Failed to read token file \"${token.fromFile}\": ${errorMessage(readError)}`);\r\n }\r\n if (raw === null) {\r\n throw new Error(`[secret-manager] Token file \"${token.fromFile}\" could not be read.`);\r\n }\r\n return validateToken(raw);\r\n};\r\n\r\nconst DEFAULT_TIMEOUT_MS = 10_000;\r\n\r\n//? Hard cap on the resolve response body. The server returns a flat\r\n//? `{ pointer -> secret }` map; even hundreds of secrets stay well under 1 MB.\r\n//? A response larger than this is a compromised/buggy server (or a MITM on an\r\n//? `allowInsecureHttp` channel) and is rejected BEFORE it can OOM the client.\r\nconst MAX_RESOLVE_BODY_BYTES = 1_048_576;\r\n\r\n//? Read the response body as text, aborting once more than `maxBytes` have been\r\n//? received — so a server that lies about (or omits) Content-Length still can't\r\n//? stream an unbounded body into memory. Falls back to `response.text()` when the\r\n//? body isn't a readable stream (e.g. a mocked Response).\r\nconst readBodyCapped = async (response: Response, maxBytes: number): Promise<string> => {\r\n const body = response.body;\r\n if (!body) return response.text();\r\n const reader = body.getReader();\r\n const decoder = new TextDecoder();\r\n let received = 0;\r\n let out = '';\r\n for (;;) {\r\n const { done, value } = await reader.read();\r\n if (done) break;\r\n received += value.byteLength;\r\n if (received > maxBytes) {\r\n void reader.cancel();\r\n throw new Error(`[secret-manager] Resolve response exceeded the ${String(maxBytes)}-byte cap.`);\r\n }\r\n out += decoder.decode(value, { stream: true });\r\n }\r\n out += decoder.decode();\r\n return out;\r\n};\r\n\r\n//? Drop any case-variant of `authorization` from consumer-supplied headers so the\r\n//? framework bearer token always wins. A plain record passed as `fetch` `headers`\r\n//? is filled with `append` (per the Fetch spec), so a lowercase `authorization`\r\n//? key would NOT be overwritten by the later `Authorization` entry — both survive\r\n//? and combine into `Bearer <consumer>, Bearer <framework>`. A server reading the\r\n//? first token would then honour the consumer's value, bypassing the documented\r\n//? \"cannot override Authorization\" guard. Stripping here makes the guard real for\r\n//? every casing; all other consumer headers are preserved untouched.\r\nconst stripAuthorizationHeaders = (\r\n headers: Record<string, string> | undefined,\r\n): Record<string, string> => {\r\n const out: Record<string, string> = {};\r\n if (!headers) return out;\r\n for (const [key, value] of Object.entries(headers)) {\r\n if (key.toLowerCase() === 'authorization') continue;\r\n out[key] = value;\r\n }\r\n return out;\r\n};\r\n\r\n//? One resolve round-trip: POST the pointers, validate the response, return the\r\n//? filtered `{ pointer -> value }` map. No retry/timeout orchestration here — the\r\n//? caller (`fetchResolve`) owns that so a hang can't slip past the abort signal.\r\nconst fetchResolveOnce = async (\r\n config: SecretManagerConfig,\r\n pointers: string[],\r\n): Promise<Record<string, string>> => {\r\n //? Defaults to the Node 20+ global fetch; pass fetchImpl for older hosts.\r\n const fetchFn = config.fetchImpl ?? globalThis.fetch;\r\n const resolvePath = config.resolvePath ?? '/resolve';\r\n const suffix = resolvePath.startsWith('/') ? resolvePath : `/${resolvePath}`;\r\n const endpoint = `${config.url.replace(/\\/+$/, '')}${suffix}`;\r\n\r\n //? Abort a black-hole server (accepts the TCP connection, never responds) so a\r\n //? hang surfaces as a rejection — boot can't freeze, and 'hybrid' reaches its\r\n //? warn-and-keep-local fallback. `timeoutMs: 0` disables the abort entirely.\r\n //? Coerce defensively: a NaN/Infinity timeoutMs (e.g. a bad env parse) would\r\n //? make `timeoutMs > 0` false and silently DISABLE the black-hole abort,\r\n //? reintroducing the boot hang this guard exists to prevent. Mirror the retry\r\n //? path's finite-coercion.\r\n const rawTimeout = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;\r\n const timeoutMs = Number.isFinite(rawTimeout) ? Math.max(0, rawTimeout) : DEFAULT_TIMEOUT_MS;\r\n const signal = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined;\r\n\r\n //? Consumer `headers` are merged first, with any case-variant of `authorization`\r\n //? stripped (`stripAuthorizationHeaders`) so the framework bearer token always\r\n //? wins — see that helper for why a lowercase key would otherwise leak through.\r\n //? `redirect: 'error'` fails closed on any 30x: `validateUrl` pins only the\r\n //? configured host, so following a redirect would carry the bearer token +\r\n //? request body to — and consume the resolved secrets from — an origin that was\r\n //? never validated (scheme/loopback/https checks don't re-apply to the hop). A\r\n //? legitimate `/resolve` endpoint never redirects; a 30x is a misconfig or an\r\n //? attack, and rejecting it keeps the whole exchange pinned to the checked origin.\r\n const response = await fetchFn(endpoint, {\r\n method: 'POST',\r\n headers: {\r\n ...stripAuthorizationHeaders(config.headers),\r\n 'Authorization': `Bearer ${resolveToken(config.token)}`,\r\n 'Content-Type': 'application/json',\r\n 'Accept': 'application/json',\r\n },\r\n body: JSON.stringify({ keys: pointers }),\r\n redirect: 'error',\r\n signal,\r\n });\r\n\r\n if (!response.ok) {\r\n //? Discard the unconsumed error body so a long-lived poll loop can't leave\r\n //? response bodies pending GC; failures carry only status text upward.\r\n void response.body?.cancel();\r\n throw new Error(`[secret-manager] Resolve request failed: ${String(response.status)} ${response.statusText}`);\r\n }\r\n\r\n //? The resolve response is the network input this client trusts least (a\r\n //? compromised/buggy server, or a MITM on an `allowInsecureHttp` channel).\r\n //? Reject an oversized body BEFORE buffering/parsing it so a hostile server\r\n //? can't OOM the booting client. A Content-Length header is advisory (can be\r\n //? absent/lying), so it is a fast pre-check only — the real guard is the\r\n //? streamed byte cap below.\r\n const declaredLength = Number(response.headers.get('content-length'));\r\n if (Number.isFinite(declaredLength) && declaredLength > MAX_RESOLVE_BODY_BYTES) {\r\n void response.body?.cancel();\r\n throw new Error(`[secret-manager] Resolve response too large (${String(declaredLength)} bytes > ${String(MAX_RESOLVE_BODY_BYTES)} cap).`);\r\n }\r\n const raw = await readBodyCapped(response, MAX_RESOLVE_BODY_BYTES);\r\n\r\n //? Parse as `unknown`, then narrow `values` with a runtime guard rather than\r\n //? trusting an up-front cast — the response is attacker-influenced (a\r\n //? compromised/buggy server) so its shape is not assumed.\r\n const [parseError, body] = tryCatchSync((): unknown => JSON.parse(raw));\r\n if (parseError) {\r\n throw new Error('[secret-manager] Resolve response was not valid JSON.');\r\n }\r\n const values = (body as { values?: unknown } | null)?.values;\r\n if (values === null || typeof values !== 'object') {\r\n throw new Error('[secret-manager] Resolve response missing `values` object.');\r\n }\r\n\r\n //? Filter the response down to the pointers we actually requested, and require\r\n //? each value to be a string. A compromised/buggy server could otherwise inject\r\n //? extra keys (cached + surfaced via getCachedResolution) or a non-string that\r\n //? coerces to '123' / '[object Object]' once written into process.env — only\r\n //? requested, string-valued pointers are trusted; a non-string is dropped (and\r\n //? thus treated as an unresolved pointer downstream: fatal in 'remote', warned\r\n //? in 'hybrid').\r\n const requested = new Set(pointers);\r\n const filtered: Record<string, string> = {};\r\n for (const [key, value] of Object.entries(values as Record<string, unknown>)) {\r\n if (!requested.has(key)) continue;\r\n if (typeof value !== 'string') {\r\n console.warn(`[secret-manager] Pointer \"${key}\" resolved to a non-string value (${typeof value}); ignoring.`);\r\n continue;\r\n }\r\n filtered[key] = value;\r\n }\r\n return filtered;\r\n};\r\n\r\nconst fetchResolve = async (\r\n config: SecretManagerConfig,\r\n pointers: string[],\r\n): Promise<Record<string, string>> => {\r\n //? Defensive normalization: a configured `NaN` would make `attempt <= NaN` always\r\n //? false, so the loop body would never run. `lastError` is now pre-initialized\r\n //? (below) so we wouldn't `throw undefined` anymore, but coercing to a finite\r\n //? non-negative value is still the right guard — NaN retries is a config bug, not\r\n //? a valid \"zero retries\" signal.\r\n const rawRetryCount = config.retries?.count ?? 0;\r\n const retryCount = Number.isFinite(rawRetryCount) ? Math.max(0, rawRetryCount) : 0;\r\n const rawDelayMs = config.retries?.delayMs ?? 0;\r\n const delayMs = Number.isFinite(rawDelayMs) ? Math.max(0, rawDelayMs) : 0;\r\n\r\n let lastError: unknown = new Error('[secret-manager] resolve failed: no attempt was made');\r\n for (let attempt = 0; attempt <= retryCount; attempt++) {\r\n try {\r\n return await fetchResolveOnce(config, pointers);\r\n } catch (error) {\r\n lastError = error;\r\n if (attempt < retryCount && delayMs > 0) await sleep(delayMs);\r\n }\r\n }\r\n throw lastError;\r\n};\r\n\r\nconst applyResolved = (\r\n map: Record<string, string>,\r\n values: Record<string, string>,\r\n source: 'remote' | 'hybrid',\r\n): { name: string; pointer: string }[] => {\r\n //? In remote mode a single unresolved pointer is a hard boot failure. Check\r\n //? everything BEFORE mutating process.env so the failure is atomic.\r\n if (source === 'remote') {\r\n const missing = Object.entries(map).filter(([, pointer]) => values[pointer] === undefined);\r\n if (missing.length > 0) {\r\n const detail = missing.map(([name, pointer]) => `${pointer} (referenced by ${name})`).join(', ');\r\n throw new Error(`[secret-manager] Server did not resolve: ${detail}.`);\r\n }\r\n }\r\n\r\n //? Track only the env NAMES whose value actually changed — surfaced to\r\n //? `onApplied` so a client that captured a secret at construction time can\r\n //? re-create its pool/SDK client. Never carries the secret values themselves.\r\n const changes: { name: string; pointer: string }[] = [];\r\n for (const [name, pointer] of Object.entries(map)) {\r\n const value = values[pointer];\r\n if (value === undefined) {\r\n //? hybrid only — leave the pointer in place and warn so the operator sees it.\r\n console.warn(`[secret-manager] Pointer \"${pointer}\" (referenced by ${name}) not resolved; leaving \"${name}\" as-is.`);\r\n continue;\r\n }\r\n if (process.env[name] !== value) changes.push({ name, pointer });\r\n process.env[name] = value;\r\n }\r\n return changes;\r\n};\r\n\r\n//? Public resolve entry: SERIALIZE every resolve behind a single in-flight chain\r\n//? so a slow resolve can never land after a newer one (TOCTOU on `process.env` /\r\n//? `cachedResolution`). We chain off `resolveChain.then(...)` regardless of whether\r\n//? the prior resolve resolved or rejected, then re-publish the tail so the next\r\n//? caller waits on THIS one. The returned promise mirrors the inner outcome so\r\n//? `'remote'` still rejects the caller on a hard failure.\r\nconst doResolve = (\r\n config: SecretManagerConfig,\r\n phase: 'boot' | 'refresh' | 'file-reload',\r\n): Promise<void> => {\r\n const run = resolveChain.then(\r\n () => doResolveInner(config, phase),\r\n () => doResolveInner(config, phase),\r\n );\r\n //? Keep the chain alive even if this resolve rejects (swallow on the tail copy\r\n //? only — the returned `run` keeps the original rejection for the caller).\r\n resolveChain = run.then(noop, noop);\r\n return run;\r\n};\r\n\r\nconst doResolveInner = async (\r\n config: SecretManagerConfig,\r\n phase: 'boot' | 'refresh' | 'file-reload',\r\n): Promise<void> => {\r\n const source = config.source ?? 'remote';\r\n if (source === 'local') return;\r\n\r\n //? Secure default: an unset `envNames` resolves NOTHING off-host — warn loudly\r\n //? on every resolve so the deny-all state is never silent (an operator who\r\n //? expected secrets to resolve sees exactly what to set).\r\n warnIfEnvNamesUnset(config.envNames);\r\n\r\n //? Capture once on first resolve and reuse: the first resolve OVERWRITES the\r\n //? env value with the real secret, after which it no longer looks like a pointer.\r\n //? Re-capture when the map is empty so a pointer that wasn't present at boot\r\n //? (e.g. set into `process.env` after init) is still picked up by a later\r\n //? refresh — a non-empty `{}` would otherwise pin the resolver to zero pointers.\r\n if (pointerMap === null || Object.keys(pointerMap).length === 0) {\r\n pointerMap = capturePointers(\r\n stripStatefulFlags(config.pointerPattern ?? DEFAULT_POINTER_PATTERN),\r\n config.envNames,\r\n );\r\n }\r\n const activePointerMap = pointerMap;\r\n const pointers = [...new Set(Object.values(activePointerMap))];\r\n if (pointers.length === 0) {\r\n cachedResolution = { fetchedAt: Date.now(), values: {} };\r\n return;\r\n }\r\n\r\n //? CC-7 exemption (no-raw-try-catch): the async framework `tryCatch` auto-captures\r\n //? to the error tracker, but this is a deliberate fail-OPEN boot-time guard in an\r\n //? intentionally dependency-light client — a 'hybrid' resolve failure must stay a\r\n //? silent warn with NO error-tracker side-effect (and 'remote' re-throws untouched\r\n //? for a hard boot stop). `tryCatchSync` can't wrap the `await`, so the raw\r\n //? try/catch is kept on purpose; the fail-OPEN contract is the load-bearing detail.\r\n let values: Record<string, string>;\r\n let changes: { name: string; pointer: string }[];\r\n try {\r\n values = await fetchResolve(config, pointers);\r\n changes = applyResolved(activePointerMap, values, source);\r\n cachedResolution = { fetchedAt: Date.now(), values };\r\n } catch (error) {\r\n //? Opt-in observability seam: route the failure to Sentry/metrics. Kept\r\n //? alongside (not replacing) the warn below so the fail-open default is unchanged.\r\n //? Hooks run isolated: a throwing/hanging `onResolveError` must not mask the\r\n //? original resolve error (it is re-thrown below in 'remote').\r\n runHook(() => config.onResolveError?.(error, { phase }), 'onResolveError');\r\n if (source === 'hybrid') {\r\n //? Log the message only — never the raw error object: error objects are the\r\n //? classic accidental channel for a URL/token/PII string reaching a log sink.\r\n //? Full-fidelity routing is the `onResolveError` hook's job.\r\n console.warn('[secret-manager] Resolve failed, leaving local env as-is:', errorMessage(error));\r\n return;\r\n }\r\n throw error;\r\n }\r\n\r\n //? Notify AFTER the cache + process.env are coherent so a consumer that reads\r\n //? process.env inside the callback sees the applied values. Isolated AND\r\n //? time-bounded by `runHookAsync` so a throwing `onApplied` can't abort an\r\n //? otherwise-successful resolve, AND a HANGING `onApplied` can't wedge the\r\n //? serialized resolve chain forever (it is abandoned after `HOOK_TIMEOUT_MS`,\r\n //? with a warn) — process.env + the cache are already written by this point.\r\n if (changes.length > 0) await runHookAsync(() => config.onApplied?.(changes), 'onApplied');\r\n};\r\n\r\n//? Minimal .env parser kept in-package so the resolver stays dependency-free.\r\n//? Handles standard `KEY=VALUE` lines, full-line + inline (` #`) comments, and\r\n//? single/double-quoted values. Not multi-line values or escape sequences.\r\nconst parseEnvFile = (content: string): Record<string, string> => {\r\n const out: Record<string, string> = {};\r\n for (const rawLine of content.split(/\\r?\\n/)) {\r\n const line = rawLine.trim();\r\n if (!line || line.startsWith('#')) continue;\r\n const eq = line.indexOf('=');\r\n if (eq <= 0) continue;\r\n const key = line.slice(0, eq).trim();\r\n //? Restrict to POSIX-shell env-var names. `.`/`-` keys can't be read via the\r\n //? normal `process.env.NAME` lookup, so accepting them is a silent footgun.\r\n if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {\r\n console.warn(`[secret-manager] Ignoring env key \"${key}\": not a valid environment variable name (^[A-Za-z_][A-Za-z0-9_]*$).`);\r\n continue;\r\n }\r\n let value = line.slice(eq + 1).trim();\r\n const quote = value[0];\r\n if (quote === '\"' || quote === \"'\") {\r\n //? Quoted value. The closing quote is the LAST occurrence of the same quote\r\n //? char; anything after it (whitespace + `# ...`) is an inline comment and is\r\n //? dropped (`KEY=\"v\" # note` → `v`). A `#` BEFORE the closing quote is literal\r\n //? and preserved (`KEY=\"a#b\"` → `a#b`).\r\n const closeIdx = value.lastIndexOf(quote);\r\n if (closeIdx > 0) {\r\n value = value.slice(1, closeIdx);\r\n } else {\r\n //? Opening quote with no matching closing quote on this line means a\r\n //? multi-line value (dotenv supports it, this parser does not). Warn so the\r\n //? value isn't silently truncated, then strip any inline comment from the\r\n //? raw remainder so a trailing `# ...` doesn't leak into the value.\r\n console.warn(\r\n `[secret-manager] parseEnvFile: \"${key}\" starts with a quote but has no matching closing quote on the same line. Multi-line values are not supported — the raw text will be used as-is. If this is a multi-line value (e.g. a PEM key), do not rely on this parser.`,\r\n );\r\n const commentAt = value.indexOf(' #');\r\n if (commentAt !== -1) value = value.slice(0, commentAt).trim();\r\n }\r\n } else {\r\n const commentAt = value.indexOf(' #');\r\n if (commentAt !== -1) value = value.slice(0, commentAt).trim();\r\n if (value.startsWith('#')) value = '';\r\n }\r\n out[key] = value;\r\n }\r\n return out;\r\n};\r\n\r\nconst scheduleReload = (): void => {\r\n //? Debounce — fs.watch can fire several events for one save.\r\n if (debounceTimer) clearTimeout(debounceTimer);\r\n debounceTimer = setTimeout(() => {\r\n void reloadSecretManagerFromFiles().catch((error: unknown) => {\r\n console.warn('[secret-manager] dev reload failed:', errorMessage(error));\r\n });\r\n }, 200);\r\n debounceTimer.unref();\r\n};\r\n\r\nconst startDevReload = (config: SecretManagerConfig): void => {\r\n if (devReloadStarted || config.dev === undefined) return;\r\n //? Allowlist the dev environments rather than exact-matching 'production': a\r\n //? 'prod' / 'staging' env must NOT silently start fs watchers + a poll on a\r\n //? host the operator believes is production. Only an explicit 'development' or\r\n //? 'test' enables dev hot reload (an unset env resolves to 'development' via\r\n //? the canonical `resolveEnvKey()`, so it counts as dev).\r\n const nodeEnv = resolveEnvKey();\r\n if (nodeEnv !== 'development' && nodeEnv !== 'test') return;\r\n\r\n const watch = config.dev.watch ?? true;\r\n const pollIntervalMs = config.dev.pollIntervalMs ?? 0;\r\n if (!watch && pollIntervalMs <= 0) return;\r\n devReloadStarted = true;\r\n\r\n if (watch) {\r\n for (const file of config.dev.envFiles ?? DEFAULT_ENV_FILES) {\r\n if (!isSafeEnvFile(file, true)) {\r\n console.warn(`[secret-manager] ignoring unsafe dev envFile path (must be relative + within the project): ${file}`);\r\n continue;\r\n }\r\n try {\r\n const watcher = fsWatch(file, () => {\r\n scheduleReload();\r\n });\r\n watcher.unref();\r\n fileWatchers.push(watcher);\r\n } catch {\r\n //? The file may not exist (e.g. no .env.local) — nothing to watch.\r\n }\r\n }\r\n }\r\n\r\n if (pollIntervalMs > 0) {\r\n pollTimer = setInterval(() => {\r\n void refreshSecretManager().catch((error: unknown) => {\r\n console.warn('[secret-manager] poll refresh failed:', errorMessage(error));\r\n });\r\n }, pollIntervalMs);\r\n pollTimer.unref();\r\n }\r\n};\r\n\r\n/**\r\n * Initialize the secret-manager resolver. Call once as the very first line of\r\n * `server.ts`, BEFORE any other framework code reads `process.env`. In\r\n * `'remote'` / `'hybrid'` mode the resolved secrets are written into\r\n * `process.env` before this resolves, so downstream code sees them via the\r\n * standard `process.env.FOO` lookup. In `'local'` mode it is a no-op.\r\n */\r\nexport const initSecretManager = async (config: SecretManagerConfig): Promise<void> => {\r\n //? Validate the URL BEFORE recording activeConfig, so a failed init can't leave\r\n //? a later refreshSecretManager() resolving against an invalid config.\r\n if ((config.source ?? 'remote') !== 'local') {\r\n //? Only validate the URL when we'll actually hit the network ('remote' /\r\n //? 'hybrid'); 'local' may carry a placeholder url.\r\n validateUrl(config.url, config.allowInsecureHttp ?? false);\r\n }\r\n activeConfig = config;\r\n if ((config.source ?? 'remote') === 'local') return;\r\n await doResolve(config, 'boot');\r\n startRotationPoll(config);\r\n startDevReload(config);\r\n};\r\n\r\n//? Production-capable rotation poll: re-resolve every `config.pollIntervalMs` ms\r\n//? in ALL environments (distinct from the dev-only `dev.pollIntervalMs` file-watch\r\n//? channel, which is gated off in production). Unref'd so it never blocks exit.\r\nconst startRotationPoll = (config: SecretManagerConfig): void => {\r\n const intervalMs = config.pollIntervalMs ?? 0;\r\n if (intervalMs <= 0 || rotationPollTimer) return;\r\n rotationPollTimer = setInterval(() => {\r\n void refreshSecretManager().catch((error: unknown) => {\r\n console.warn('[secret-manager] rotation poll refresh failed:', errorMessage(error));\r\n });\r\n }, intervalMs);\r\n rotationPollTimer.unref();\r\n};\r\n\r\n/**\r\n * Re-resolve against the server, ignoring nothing — used by the dev hot-reload\r\n * watch/poll and callable manually when an admin rotates a secret and you want\r\n * a long-running process to pick it up without a restart.\r\n */\r\nexport const refreshSecretManager = async (): Promise<void> => {\r\n if (!activeConfig || (activeConfig.source ?? 'remote') === 'local') return;\r\n await doResolve(activeConfig, 'refresh');\r\n};\r\n\r\n/**\r\n * Dev hot-reload entry: re-parse the configured env files and apply them — plain\r\n * (non-pointer) values are injected straight into `process.env` (live config\r\n * reload), pointer-shaped values are re-resolved against the server. Wired to the\r\n * `.env` / `.env.local` file watch; also callable manually.\r\n */\r\nexport const reloadSecretManagerFromFiles = async (): Promise<void> => {\r\n const config = activeConfig;\r\n if (!config || (config.source ?? 'remote') === 'local') return;\r\n\r\n const files = config.dev?.envFiles ?? DEFAULT_ENV_FILES;\r\n const pattern = stripStatefulFlags(config.pointerPattern ?? DEFAULT_POINTER_PATTERN);\r\n\r\n //? Re-read every file in load order; later files (e.g. .env.local) override.\r\n //? `warnAbsolute=false`: the absolute-path notice already fired once at boot\r\n //? (startDevReload). Repeating it on every debounced hot-reload would flood the\r\n //? dev log for anyone legitimately using an absolute envFile path.\r\n const merged: Record<string, string> = {};\r\n for (const file of files) {\r\n if (!isSafeEnvFile(file, false)) {\r\n console.warn(`[secret-manager] ignoring unsafe dev envFile path: ${file}`);\r\n continue;\r\n }\r\n let content: string;\r\n try {\r\n content = readFileSync(file, 'utf8');\r\n } catch {\r\n continue; //? file may not exist (e.g. no .env.local)\r\n }\r\n Object.assign(merged, parseEnvFile(content));\r\n }\r\n\r\n //? Split plain (live config reload) from pointer-shaped values through the SAME\r\n //? `envNames` allowlist the boot path uses (`capturePointers`), so a name excluded\r\n //? by `envNames` is dropped on BOTH channels — never POSTed off-host as a pointer,\r\n //? never injected into `process.env` as a plain value. This closes the scoping\r\n //? drift where a file-reload could send/apply names the boot scan rejected.\r\n const { pointers: freshPointerMap, plain: plainValues } = splitPointers(\r\n Object.entries(merged),\r\n pattern,\r\n config.envNames,\r\n );\r\n\r\n //? MERGE the fresh file-sourced pointers over the existing map (don't replace):\r\n //? a pointer captured at boot from the inherited shell/CI env that isn't in a\r\n //? watched file would otherwise be dropped and stop rotating. A file-sourced\r\n //? pointer with the same name still wins.\r\n //? Build the merged map locally and commit it ONLY after a successful resolve so\r\n //? a failed remote reload can't permanently poison the in-memory pointer map and\r\n //? cause every subsequent refreshSecretManager / poll to resolve the bad set.\r\n const previousPointerMap = pointerMap;\r\n pointerMap = { ...pointerMap, ...freshPointerMap };\r\n\r\n //? Resolve the pointers FIRST. In 'remote' mode an unresolved pointer throws —\r\n //? mirror the atomic boot path and inject the plain values only AFTER a\r\n //? successful resolve, so a throw never leaves half-applied state. On failure\r\n //? roll back the pointer map to its pre-reload snapshot.\r\n try {\r\n await doResolve(config, 'file-reload');\r\n } catch (error) {\r\n pointerMap = previousPointerMap;\r\n throw error;\r\n }\r\n for (const [name, value] of Object.entries(plainValues)) {\r\n process.env[name] = value;\r\n }\r\n};\r\n\r\n/**\r\n * Read the last `{ fetchedAt, values }` resolution (pointer -> value), or `null`.\r\n *\r\n * ⚠️ SENSITIVE: `values` maps each pointer to its RAW resolved secret. The result\r\n * is a shallow copy (values are flat strings, so mutating the returned object does\r\n * not corrupt the cache), but the values are still the real secrets — NEVER\r\n * serialize the result into an HTTP response, a `/health` payload, or a log line.\r\n * For a safe diagnostic use {@link getCachedResolutionMeta}, which never exposes\r\n * the values.\r\n *\r\n * If you need to act on changed secrets (e.g. to rebuild a DB pool after rotation),\r\n * use the `onApplied` callback in `SecretManagerConfig` instead — it receives only\r\n * the changed env NAMES, never the secret values, and is called automatically after\r\n * each successful resolve.\r\n */\r\nexport const getCachedResolution = (): CachedResolution | null =>\r\n cachedResolution === null\r\n ? null\r\n : { fetchedAt: cachedResolution.fetchedAt, values: { ...cachedResolution.values } };\r\n\r\n/** Values-free view of {@link getCachedResolution}: the timestamp + resolved pointer NAMES only. */\r\nexport interface CachedResolutionMeta {\r\n /** `Date.now()` of the last successful resolve. */\r\n fetchedAt: number;\r\n /** The resolved pointer strings (never the secret values). */\r\n pointerNames: string[];\r\n /** How many pointers were resolved. */\r\n pointerCount: number;\r\n}\r\n\r\n/**\r\n * Safe diagnostic accessor: the last resolution's timestamp + resolved pointer\r\n * NAMES, with the secret values stripped. Use this on any surface that might be\r\n * logged or served (`/health`, metrics) so the convenient path is also the safe\r\n * one — {@link getCachedResolution} is the values-carrying escape hatch.\r\n */\r\nexport const getCachedResolutionMeta = (): CachedResolutionMeta | null => {\r\n if (cachedResolution === null) return null;\r\n const pointerNames = Object.keys(cachedResolution.values);\r\n return { fetchedAt: cachedResolution.fetchedAt, pointerNames, pointerCount: pointerNames.length };\r\n};\r\n\r\n/**\r\n * Tear down the dev file watchers, the dev poll, the rotation poll, and the\r\n * debounce timer WITHOUT wiping the resolved cache / active config. Use for a\r\n * graceful shutdown of an embedded resolver (worker / CLI) so no timers keep\r\n * firing; the last resolved `process.env` values stay in place.\r\n *\r\n * **Note:** `activeConfig` is intentionally left set after `stopSecretManager`,\r\n * so that `reloadSecretManagerFromFiles` can still use it for a final manual\r\n * reload. As a consequence, calling `refreshSecretManager()` after `stop` will\r\n * issue a live network request (the `!activeConfig` no-op guard is not met).\r\n * If you want to prevent ANY further resolution after `stop`, call\r\n * `resetSecretManagerForTests()` instead (which also clears `activeConfig`).\r\n */\r\nexport const stopSecretManager = (): void => {\r\n devReloadStarted = false;\r\n if (pollTimer) {\r\n clearInterval(pollTimer);\r\n pollTimer = null;\r\n }\r\n if (rotationPollTimer) {\r\n clearInterval(rotationPollTimer);\r\n rotationPollTimer = null;\r\n }\r\n if (debounceTimer) {\r\n clearTimeout(debounceTimer);\r\n debounceTimer = null;\r\n }\r\n for (const watcher of fileWatchers) {\r\n watcher.close();\r\n }\r\n fileWatchers.length = 0;\r\n //? Reset the \"warned once\" guard so a subsequent initSecretManager() call that\r\n //? also omits envNames re-emits the boot warning rather than silently silencing it\r\n //? (the first, valid config could have set envNames correctly while the second,\r\n //? broken one doesn't — the guard must not carry across a full stop-and-reinit).\r\n warnedEnvNamesUnset = false;\r\n};\r\n\r\n/** Test-only — clear module state and tear down any dev watchers / timers. */\r\nexport const resetSecretManagerForTests = (): void => {\r\n stopSecretManager();\r\n cachedResolution = null;\r\n pointerMap = null;\r\n activeConfig = null;\r\n resolveChain = Promise.resolve();\r\n warnedEnvNamesUnset = false;\r\n};\r\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["//? @luckystack/secret-manager — rotation-aware secret resolver client.\r\n//?\r\n//? Idea: secrets live in a central secret-manager server (append-only,\r\n//? versioned, one shared bearer token). Apps commit their `.env` to git, but\r\n//? instead of real secrets it holds POINTERS:\r\n//?\r\n//? OPENAI_KEY=OPENAI_AUTHORIZATION_KEY_V5\r\n//?\r\n//? A pointer has the shape `<BASE>_V<number>`. At boot this client scans\r\n//? `process.env`, collects every pointer-shaped value, asks the server to\r\n//? resolve them in ONE request, and overwrites each `process.env` entry with\r\n//? the real secret — so downstream code reads `process.env.OPENAI_KEY` and gets\r\n//? the resolved value, not the pointer. Rotating a secret means publishing a\r\n//? new version (`..._V6`) on the server, never editing an old one, so old git\r\n//? branches that still point at `..._V5` keep booting.\r\n//?\r\n//? Three modes:\r\n//? 1. `source: 'remote'` (default) — resolve from the server. A missing\r\n//? pointer or an unreachable server throws — production hard-stop.\r\n//? 2. `source: 'local'` — no network. Pointers are left untouched. Use in\r\n//? tests / offline dev when the server isn't reachable.\r\n//? 3. `source: 'hybrid'` — try the server, but on failure warn and leave\r\n//? whatever `process.env` already holds. Use for staging / canary.\r\n//?\r\n//? Optional dev-only hot reload (`config.dev`) re-resolves on `.env` file\r\n//? changes and/or on an interval, so server-side rotations are picked up\r\n//? without restarting a long-running dev process. It is a no-op in production.\r\n\r\nimport { readFileSync, watch as fsWatch, type FSWatcher } from 'node:fs';\r\nimport path from 'node:path';\r\n\r\nimport { resolveEnvKey, sleep, tryCatchSync } from '@luckystack/core';\r\n\r\n/**\r\n * Bearer token: a literal string, or a file whose entire contents are the token.\r\n *\r\n * When using `{ fromFile }`, the path MUST NOT be derived from untrusted input.\r\n * No path-traversal check is applied — the caller is responsible for ensuring\r\n * the path is a fixed, gitignored file (e.g. `.secret-manager-token`) next to\r\n * the project root. Paths like `/etc/passwd` or `../../.ssh/id_rsa` would be\r\n * read without error.\r\n */\r\nexport type SecretManagerToken = string | { fromFile: string };\r\n\r\nexport interface SecretManagerConfig {\r\n /** Base URL of the secret-manager server (trailing slash optional). */\r\n url: string;\r\n /**\r\n * Shared bearer token. Either the literal string or `{ fromFile }` pointing\r\n * at a gitignored file whose entire contents are the token (read at resolve\r\n * time, so rotating the file is picked up by the next poll / refresh).\r\n */\r\n token: SecretManagerToken;\r\n /** Resolution mode. Default `'remote'`. */\r\n source?: 'remote' | 'local' | 'hybrid';\r\n /**\r\n * Override the pointer-shape detector. Any `process.env` value matching this\r\n * is treated as a pointer; anything else is a literal and left untouched.\r\n * Default `/^(.+)_V(\\d+)$/`. A `g`/`y` flag is stripped (a stateful regex would\r\n * misclassify alternating entries).\r\n */\r\n pointerPattern?: RegExp;\r\n /**\r\n * Scope which `process.env` entries are pointer-eligible by NAME. This allowlist\r\n * is REQUIRED to resolve anything off-host: an array of names or a predicate.\r\n *\r\n * SECURE DEFAULT: when unset, NOTHING is resolved off-host (a clear boot warning\r\n * is emitted instead). Scanning the entire inherited environment would POST any\r\n * unrelated, pointer-shaped inherited value (`RELEASE_TAG=build_2024_V2`) to the\r\n * secret-manager server — so an explicit allowlist is mandatory. To opt back into\r\n * scanning every name, pass `() => true` as a deliberate, auditable choice.\r\n */\r\n envNames?: string[] | ((name: string) => boolean);\r\n /**\r\n * Allow a plain-`http:` server `url`. By default only `https:` is accepted for\r\n * non-loopback hosts (the channel carries the bearer token + plaintext secrets);\r\n * loopback (`localhost`/`127.0.0.1`/`[::1]`) is always permitted. Set `true` to\r\n * permit `http:` to any host — a loud warning is still emitted.\r\n */\r\n allowInsecureHttp?: boolean;\r\n /**\r\n * Abort a resolve request that has not responded within this many ms. Prevents a\r\n * black-hole server (accepts the TCP connection, never responds) from hanging boot\r\n * until undici's ~300s default — and, in `'hybrid'`, from never reaching the\r\n * warn-and-keep-local fallback (a hang never rejects). Default `10_000`. Set `0`\r\n * to disable the timeout.\r\n */\r\n timeoutMs?: number;\r\n /**\r\n * Retry a failed resolve before giving up (transport error or non-2xx). Default\r\n * `{ count: 0 }` (no retry). After exhaustion `'remote'` still throws and\r\n * `'hybrid'` still warns-and-keeps-local.\r\n */\r\n retries?: { count: number; delayMs?: number };\r\n /** Override the resolve path appended to `url`. Default `'/resolve'`. */\r\n resolvePath?: string;\r\n /** Extra request headers merged onto every resolve request (cannot override `Authorization`). */\r\n headers?: Record<string, string>;\r\n /**\r\n * Called after a resolve writes new values into `process.env`, with ONLY the env\r\n * NAMES whose value actually changed (never the secret values). A client that\r\n * captured a secret at construction time (Prisma from `DATABASE_URL`, a Redis /\r\n * Stripe / OpenAI SDK) can re-create its pool/client here so a rotation lands.\r\n */\r\n onApplied?: (changes: { name: string; pointer: string }[]) => void | Promise<void>;\r\n /**\r\n * Called when a resolve fails, alongside the existing `console.warn` (current\r\n * behavior is unchanged when unset). Route the failure to Sentry/metrics —\r\n * useful for `'hybrid'`/dev where a failure otherwise silently keeps stale env.\r\n */\r\n onResolveError?: (error: unknown, context: { phase: 'boot' | 'refresh' | 'file-reload' }) => void;\r\n /** Override the global `fetch` (tests / non-Node-20 hosts). */\r\n fetchImpl?: typeof fetch;\r\n /**\r\n * Re-resolve the current pointers every N ms in ALL environments (the production\r\n * rotation poll), distinct from the dev-only `dev.pollIntervalMs` file-watch\r\n * channel. Default `0` (disabled). Unref'd, so it never blocks process exit.\r\n */\r\n pollIntervalMs?: number;\r\n /**\r\n * Opt-in dev-only hot reload. Ignored when `NODE_ENV === 'production'`.\r\n * Provide an (even empty) object to enable it.\r\n */\r\n dev?: {\r\n /**\r\n * Watch the env files and hot-reload on change. Default `true`. On change\r\n * the files are re-parsed and applied: plain (non-pointer) values are\r\n * injected straight into `process.env` (live config reload), and\r\n * pointer-shaped values are re-resolved against the server.\r\n */\r\n watch?: boolean;\r\n /** Re-resolve the current pointers every N ms (server-rotation poll). Default `0` (disabled). */\r\n pollIntervalMs?: number;\r\n /** Env files to watch + reparse, in load order (later overrides earlier). Default `['.env', '.env.local']`. */\r\n envFiles?: string[];\r\n };\r\n}\r\n\r\nexport interface CachedResolution {\r\n /** `Date.now()` of the last successful resolve. */\r\n fetchedAt: number;\r\n /** Map of pointer string -> resolved value (what the server returned). */\r\n values: Record<string, string>;\r\n}\r\n\r\nconst DEFAULT_POINTER_PATTERN = /^(.+)_V(\\d+)$/;\r\nconst DEFAULT_ENV_FILES = ['.env', '.env.local'];\r\n\r\n//? Dev hot-reload reads these files and injects their values into `process.env`.\r\n//? `dev.envFiles` is consumer config (not runtime user input), so an ABSOLUTE\r\n//? path is treated as an explicit, allowed choice (e.g. a shared secrets file on\r\n//? a developer machine). A RELATIVE path, however, must stay within the project\r\n//? root — reject `..` traversal (the plausible \"injected via a relative path\"\r\n//? escape). The caller skips + warns on a rejected entry (fail-open, consistent\r\n//? with the package's swallow-on-missing-file behaviour).\r\n//? Note: absolute paths are accepted without further validation. Consumers who\r\n//? configure `dev.envFiles` with absolute paths take responsibility for ensuring\r\n//? those paths are appropriate (e.g. a shared developer-machine secrets file).\r\n//? A loud warn is emitted so the choice is never silent in logs.\r\nconst isSafeEnvFile = (file: string, warnAbsolute = false): boolean => {\r\n if (path.isAbsolute(file)) {\r\n if (warnAbsolute) {\r\n console.warn(\r\n `[secret-manager] dev.envFiles contains an absolute path: \"${file}\". Absolute paths are permitted as an explicit choice (e.g. a shared secrets file) but are not checked for safety. Ensure this path is intentional.`,\r\n );\r\n }\r\n return true;\r\n }\r\n const rel = path.relative(process.cwd(), path.resolve(process.cwd(), file));\r\n return !rel.startsWith('..');\r\n};\r\n\r\n//? Module state. The resolver is meant to run once per process at boot; these\r\n//? bindings keep it idempotent and let dev hot-reload re-resolve later.\r\nlet cachedResolution: CachedResolution | null = null;\r\n//? envName -> pointer string, captured once on first resolve. Reused on every\r\n//? refresh because the first resolve OVERWRITES the env value with the real\r\n//? secret, after which it no longer looks like a pointer.\r\nlet pointerMap: Record<string, string> | null = null;\r\nlet activeConfig: SecretManagerConfig | null = null;\r\n\r\n//? In-flight resolve, used to SERIALIZE concurrent resolves. Four channels can\r\n//? funnel into `doResolve` (boot, the production rotation poll, the dev poll, the\r\n//? dev file-watch + a manual `refreshSecretManager()`); without a guard a slow\r\n//? in-flight resolve can land AFTER a newer one, leaving `process.env` and\r\n//? `cachedResolution` disagreeing (stale secrets). Each call awaits the previous\r\n//? one's settlement before starting, so resolves apply strictly in order.\r\nlet resolveChain: Promise<void> = Promise.resolve();\r\n\r\n//? Dev hot-reload + rotation-poll handles, torn down by stopSecretManager.\r\nlet devReloadStarted = false;\r\nlet pollTimer: ReturnType<typeof setInterval> | null = null;\r\nlet rotationPollTimer: ReturnType<typeof setInterval> | null = null;\r\nlet debounceTimer: ReturnType<typeof setTimeout> | null = null;\r\nconst fileWatchers: FSWatcher[] = [];\r\n\r\n//? A consumer-supplied `pointerPattern` with a `g`/`y` flag makes `.test()`\r\n//? stateful (advances `lastIndex`), silently misclassifying alternating entries.\r\n//? Strip those flags up front; everything else is preserved.\r\nconst stripStatefulFlags = (pattern: RegExp): RegExp => {\r\n const flags = pattern.flags.replaceAll(/[gy]/g, '');\r\n return flags === pattern.flags ? pattern : new RegExp(pattern.source, flags);\r\n};\r\n\r\n//? No-op used to settle the in-flight resolve chain tail regardless of outcome.\r\nconst noop = (): void => {\r\n /* intentionally empty */\r\n};\r\n\r\n//? Reduce any thrown value to a safe message string for logging — never log the\r\n//? raw error object (its `cause`/own-properties can carry a URL/token/PII string).\r\nconst errorMessage = (error: unknown): string =>\r\n error instanceof Error ? error.message : String(error);\r\n\r\n//? Invoke a consumer callback in isolation: a throwing hook must never abort an\r\n//? otherwise-successful resolve or mask the original error. Failures are warned,\r\n//? not propagated.\r\nconst runHook = (fn: () => void, label: string): void => {\r\n const [error] = tryCatchSync(fn);\r\n if (error) console.warn(`[secret-manager] ${label} callback threw (ignored):`, errorMessage(error));\r\n};\r\n\r\n//? Bound how long an async consumer hook may run before the resolve chain stops\r\n//? waiting on it. A hook that NEVER resolves (a stuck `onApplied` awaiting a dead\r\n//? pool) would otherwise wedge `resolveChain` forever, so every later resolve\r\n//? (boot poll, dev watch, manual refresh) deadlocks behind it.\r\nconst HOOK_TIMEOUT_MS = 30_000;\r\n\r\n//? Async sibling of `runHook` for a callback that may return a Promise (onApplied\r\n//? re-creates pools/SDK clients). Awaited but isolated AND time-bounded — a\r\n//? rejection/throw is warned (never propagated, so the resolve that already\r\n//? applied stays successful), and a hook that HANGS is abandoned after\r\n//? `timeoutMs` via a `Promise.race` (mirrors the fetch `AbortSignal.timeout`\r\n//? black-hole guard) so a stuck hook can't deadlock the serialized resolve chain.\r\n//? The race only stops AWAITING the hook — it cannot cancel the consumer's\r\n//? promise, so a still-running hook keeps going in the background; we just no\r\n//? longer block on it. `timeoutMs <= 0` disables the bound.\r\n//? CC-7 exemption (no-raw-try-catch): `tryCatchSync` can't wrap an `await`, and\r\n//? the async framework `tryCatch` would auto-capture to the error tracker — a\r\n//? deliberate non-goal here (a consumer hook failure must stay a local warn in\r\n//? this dependency-light client, not a tracked event).\r\nconst runHookAsync = async (\r\n fn: () => void | Promise<void>,\r\n label: string,\r\n timeoutMs = HOOK_TIMEOUT_MS,\r\n): Promise<void> => {\r\n try {\r\n if (timeoutMs <= 0) {\r\n await fn();\r\n return;\r\n }\r\n let timer: ReturnType<typeof setTimeout> | undefined;\r\n const timeout = new Promise<void>((resolve) => {\r\n timer = setTimeout(() => {\r\n console.warn(\r\n `[secret-manager] ${label} callback did not settle within ${String(timeoutMs)}ms; continuing without waiting (the hook may still be running in the background).`,\r\n );\r\n resolve();\r\n }, timeoutMs);\r\n timer.unref();\r\n });\r\n try {\r\n await Promise.race([Promise.resolve(fn()), timeout]);\r\n } finally {\r\n if (timer) clearTimeout(timer);\r\n }\r\n } catch (error) {\r\n console.warn(`[secret-manager] ${label} callback threw (ignored):`, errorMessage(error));\r\n }\r\n};\r\n\r\n//? RFC-style loopback hosts: plain http to these never leaves the machine, so it\r\n//? is always permitted regardless of `allowInsecureHttp`.\r\nconst isLoopbackHost = (hostname: string): boolean =>\r\n hostname === 'localhost' ||\r\n hostname === '127.0.0.1' ||\r\n hostname === '::1' ||\r\n hostname === '[::1]';\r\n\r\nconst validateUrl = (url: string, allowInsecureHttp: boolean): void => {\r\n //? Reject relative / non-http(s) URLs (e.g. `file://`) up front so the resolve\r\n //? endpoint can't be pointed at the local filesystem or another protocol.\r\n const [parseError, parsed] = tryCatchSync(() => new URL(url));\r\n if (parseError || !parsed) {\r\n throw new Error(`[secret-manager] Invalid \\`url\\`: \"${url}\" is not an absolute URL.`);\r\n }\r\n if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {\r\n throw new Error(`[secret-manager] Invalid \\`url\\` scheme \"${parsed.protocol}\": only http(s) is supported.`);\r\n }\r\n //? The channel carries the bearer token + plaintext secrets, so plain http is a\r\n //? cleartext leak. Permit it only for loopback, or behind an explicit opt-in\r\n //? (still warned loudly) — otherwise reject before any secret is sent.\r\n if (parsed.protocol === 'http:' && !isLoopbackHost(parsed.hostname)) {\r\n if (!allowInsecureHttp) {\r\n throw new Error(\r\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.`,\r\n );\r\n }\r\n console.warn(\r\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.`,\r\n );\r\n }\r\n};\r\n\r\n//? Build the name-eligibility predicate from `envNames` (allowlist array or\r\n//? predicate). SECURE DEFAULT: when `envNames` is unset, NOTHING is eligible —\r\n//? the resolver must never scan the whole inherited environment and POST every\r\n//? pointer-shaped value off-host. An explicit allowlist (or `() => true`) is\r\n//? required to opt in. The boot-time warning for the unset case is emitted by\r\n//? `warnIfEnvNamesUnset` at the resolve path.\r\nconst toNameFilter = (\r\n envNames: SecretManagerConfig['envNames'],\r\n): ((name: string) => boolean) => {\r\n if (envNames === undefined) return () => false;\r\n if (typeof envNames === 'function') return envNames;\r\n const allow = new Set(envNames);\r\n return (name) => allow.has(name);\r\n};\r\n\r\n//? Warn ONCE per process when `envNames` is unset: the resolver is deny-all in\r\n//? that state, so an operator who expected secrets to resolve gets a clear,\r\n//? actionable boot message instead of a silent no-op. Guarded so the production\r\n//? rotation poll (`refreshSecretManager` on an interval) doesn't re-emit it every\r\n//? cycle and flood the log sink.\r\nlet warnedEnvNamesUnset = false;\r\nconst warnIfEnvNamesUnset = (envNames: SecretManagerConfig['envNames']): void => {\r\n if (envNames !== undefined || warnedEnvNamesUnset) return;\r\n warnedEnvNamesUnset = true;\r\n console.warn(\r\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).',\r\n );\r\n};\r\n\r\n//? Split a set of `{ name -> value }` entries into pointer-shaped vs plain\r\n//? values, applying the `envNames` allowlist FIRST so a name excluded by\r\n//? `envNames` is treated as neither a pointer nor a plain value (it is dropped\r\n//? entirely). Shared by both ingest paths (boot `capturePointers` + the dev\r\n//? file-reload split) so the scoping rule can never drift between them.\r\nconst splitPointers = (\r\n entries: Iterable<[string, string]>,\r\n pattern: RegExp,\r\n envNames: SecretManagerConfig['envNames'],\r\n): { pointers: Record<string, string>; plain: Record<string, string> } => {\r\n const nameAllowed = toNameFilter(envNames);\r\n const pointers: Record<string, string> = {};\r\n const plain: Record<string, string> = {};\r\n for (const [name, value] of entries) {\r\n if (!nameAllowed(name)) continue;\r\n if (pattern.test(value)) pointers[name] = value;\r\n else plain[name] = value;\r\n }\r\n return { pointers, plain };\r\n};\r\n\r\nconst capturePointers = (\r\n pattern: RegExp,\r\n envNames: SecretManagerConfig['envNames'],\r\n): Record<string, string> => {\r\n const entries: [string, string][] = [];\r\n for (const [name, value] of Object.entries(process.env)) {\r\n if (typeof value === 'string') entries.push([name, value]);\r\n }\r\n return splitPointers(entries, pattern, envNames).pointers;\r\n};\r\n\r\nconst validateToken = (token: string): string => {\r\n //? An empty/whitespace token yields an `Authorization: Bearer ` header that\r\n //? silently auth-fails (and in hybrid mode falls back to local env) — reject it.\r\n const trimmed = token.trim();\r\n if (trimmed.length === 0) {\r\n throw new Error('[secret-manager] Bearer token is empty or whitespace-only.');\r\n }\r\n //? The `Bearer ` scheme is added at the call site. A token that already carries\r\n //? it would produce a malformed double-prefix `Bearer Bearer <...>` header —\r\n //? strip the redundant prefix (case-insensitive) and warn so the operator knows\r\n //? their config is wrong. Stripping is the forgiving path: a warn-but-pass-through\r\n //? would silently break every request, making this a very hard-to-debug boot issue.\r\n if (/^bearer\\s/i.test(trimmed)) {\r\n const stripped = trimmed.replace(/^bearer\\s+/i, '');\r\n console.warn(\r\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.',\r\n );\r\n return stripped;\r\n }\r\n return trimmed;\r\n};\r\n\r\nconst resolveToken = (token: SecretManagerToken): string => {\r\n if (typeof token === 'string') return validateToken(token);\r\n //? Distinguish a missing/deleted file from other I/O errors so a dev\r\n //? hot-reload poll over a transiently-absent token file gives a clear log.\r\n //? Note: error messages include `token.fromFile`. In hybrid mode these propagate\r\n //? to console.warn via `errorMessage(error)` (line ~597). If the path contains\r\n //? sensitive directory names (e.g. /home/admin/.keys/prod-token), those names\r\n //? will appear in log aggregators. Treat the token file path as infrastructure\r\n //? metadata and ensure your log sink is appropriately access-controlled.\r\n const [readError, raw] = tryCatchSync(() => readFileSync(token.fromFile, 'utf8'));\r\n if (readError) {\r\n const code = readError instanceof Error && 'code' in readError ? (readError as { code?: string }).code : undefined;\r\n if (code === 'ENOENT') {\r\n throw new Error(`[secret-manager] Token file not found: \"${token.fromFile}\".`);\r\n }\r\n throw new Error(`[secret-manager] Failed to read token file \"${token.fromFile}\": ${errorMessage(readError)}`);\r\n }\r\n if (raw === null) {\r\n throw new Error(`[secret-manager] Token file \"${token.fromFile}\" could not be read.`);\r\n }\r\n return validateToken(raw);\r\n};\r\n\r\nconst DEFAULT_TIMEOUT_MS = 10_000;\r\n\r\n//? Hard cap on the resolve response body. The server returns a flat\r\n//? `{ pointer -> secret }` map; even hundreds of secrets stay well under 1 MB.\r\n//? A response larger than this is a compromised/buggy server (or a MITM on an\r\n//? `allowInsecureHttp` channel) and is rejected BEFORE it can OOM the client.\r\nconst MAX_RESOLVE_BODY_BYTES = 1_048_576;\r\n\r\n//? Read the response body as text, aborting once more than `maxBytes` have been\r\n//? received — so a server that lies about (or omits) Content-Length still can't\r\n//? stream an unbounded body into memory. Falls back to `response.text()` when the\r\n//? body isn't a readable stream (e.g. a mocked Response).\r\nconst readBodyCapped = async (response: Response, maxBytes: number): Promise<string> => {\r\n const body = response.body;\r\n if (!body) return response.text();\r\n const reader = body.getReader();\r\n const decoder = new TextDecoder();\r\n let received = 0;\r\n let out = '';\r\n for (;;) {\r\n const { done, value } = await reader.read();\r\n if (done) break;\r\n received += value.byteLength;\r\n if (received > maxBytes) {\r\n void reader.cancel();\r\n throw new Error(`[secret-manager] Resolve response exceeded the ${String(maxBytes)}-byte cap.`);\r\n }\r\n out += decoder.decode(value, { stream: true });\r\n }\r\n out += decoder.decode();\r\n return out;\r\n};\r\n\r\n//? Drop any case-variant of `authorization` from consumer-supplied headers so the\r\n//? framework bearer token always wins. A plain record passed as `fetch` `headers`\r\n//? is filled with `append` (per the Fetch spec), so a lowercase `authorization`\r\n//? key would NOT be overwritten by the later `Authorization` entry — both survive\r\n//? and combine into `Bearer <consumer>, Bearer <framework>`. A server reading the\r\n//? first token would then honour the consumer's value, bypassing the documented\r\n//? \"cannot override Authorization\" guard. Stripping here makes the guard real for\r\n//? every casing; all other consumer headers are preserved untouched.\r\nconst stripAuthorizationHeaders = (\r\n headers: Record<string, string> | undefined,\r\n): Record<string, string> => {\r\n const out: Record<string, string> = {};\r\n if (!headers) return out;\r\n for (const [key, value] of Object.entries(headers)) {\r\n if (key.toLowerCase() === 'authorization') continue;\r\n out[key] = value;\r\n }\r\n return out;\r\n};\r\n\r\n//? One resolve round-trip: POST the pointers, validate the response, return the\r\n//? filtered `{ pointer -> value }` map. No retry/timeout orchestration here — the\r\n//? caller (`fetchResolve`) owns that so a hang can't slip past the abort signal.\r\nconst fetchResolveOnce = async (\r\n config: SecretManagerConfig,\r\n pointers: string[],\r\n): Promise<Record<string, string>> => {\r\n //? Defaults to the Node 20+ global fetch; pass fetchImpl for older hosts.\r\n const fetchFn = config.fetchImpl ?? globalThis.fetch;\r\n const resolvePath = config.resolvePath ?? '/resolve';\r\n const suffix = resolvePath.startsWith('/') ? resolvePath : `/${resolvePath}`;\r\n const endpoint = `${config.url.replace(/\\/+$/, '')}${suffix}`;\r\n\r\n //? Abort a black-hole server (accepts the TCP connection, never responds) so a\r\n //? hang surfaces as a rejection — boot can't freeze, and 'hybrid' reaches its\r\n //? warn-and-keep-local fallback. `timeoutMs: 0` disables the abort entirely.\r\n //? Coerce defensively: a NaN/Infinity timeoutMs (e.g. a bad env parse) would\r\n //? make `timeoutMs > 0` false and silently DISABLE the black-hole abort,\r\n //? reintroducing the boot hang this guard exists to prevent. Mirror the retry\r\n //? path's finite-coercion.\r\n const rawTimeout = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;\r\n const timeoutMs = Number.isFinite(rawTimeout) ? Math.max(0, rawTimeout) : DEFAULT_TIMEOUT_MS;\r\n const signal = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined;\r\n\r\n //? Consumer `headers` are merged first, with any case-variant of `authorization`\r\n //? stripped (`stripAuthorizationHeaders`) so the framework bearer token always\r\n //? wins — see that helper for why a lowercase key would otherwise leak through.\r\n //? `redirect: 'error'` fails closed on any 30x: `validateUrl` pins only the\r\n //? configured host, so following a redirect would carry the bearer token +\r\n //? request body to — and consume the resolved secrets from — an origin that was\r\n //? never validated (scheme/loopback/https checks don't re-apply to the hop). A\r\n //? legitimate `/resolve` endpoint never redirects; a 30x is a misconfig or an\r\n //? attack, and rejecting it keeps the whole exchange pinned to the checked origin.\r\n const response = await fetchFn(endpoint, {\r\n method: 'POST',\r\n headers: {\r\n ...stripAuthorizationHeaders(config.headers),\r\n 'Authorization': `Bearer ${resolveToken(config.token)}`,\r\n 'Content-Type': 'application/json',\r\n 'Accept': 'application/json',\r\n },\r\n body: JSON.stringify({ keys: pointers }),\r\n redirect: 'error',\r\n signal,\r\n });\r\n\r\n if (!response.ok) {\r\n //? Discard the unconsumed error body so a long-lived poll loop can't leave\r\n //? response bodies pending GC; failures carry only status text upward.\r\n void response.body?.cancel();\r\n throw new Error(`[secret-manager] Resolve request failed: ${String(response.status)} ${response.statusText}`);\r\n }\r\n\r\n //? The resolve response is the network input this client trusts least (a\r\n //? compromised/buggy server, or a MITM on an `allowInsecureHttp` channel).\r\n //? Reject an oversized body BEFORE buffering/parsing it so a hostile server\r\n //? can't OOM the booting client. A Content-Length header is advisory (can be\r\n //? absent/lying), so it is a fast pre-check only — the real guard is the\r\n //? streamed byte cap below.\r\n const declaredLength = Number(response.headers.get('content-length'));\r\n if (Number.isFinite(declaredLength) && declaredLength > MAX_RESOLVE_BODY_BYTES) {\r\n void response.body?.cancel();\r\n throw new Error(`[secret-manager] Resolve response too large (${String(declaredLength)} bytes > ${String(MAX_RESOLVE_BODY_BYTES)} cap).`);\r\n }\r\n const raw = await readBodyCapped(response, MAX_RESOLVE_BODY_BYTES);\r\n\r\n //? Parse as `unknown`, then narrow `values` with a runtime guard rather than\r\n //? trusting an up-front cast — the response is attacker-influenced (a\r\n //? compromised/buggy server) so its shape is not assumed.\r\n const [parseError, body] = tryCatchSync((): unknown => JSON.parse(raw));\r\n if (parseError) {\r\n throw new Error('[secret-manager] Resolve response was not valid JSON.');\r\n }\r\n const values = (body as { values?: unknown } | null)?.values;\r\n if (values === null || typeof values !== 'object') {\r\n throw new Error('[secret-manager] Resolve response missing `values` object.');\r\n }\r\n\r\n //? Filter the response down to the pointers we actually requested, and require\r\n //? each value to be a string. A compromised/buggy server could otherwise inject\r\n //? extra keys (cached + surfaced via getCachedResolution) or a non-string that\r\n //? coerces to '123' / '[object Object]' once written into process.env — only\r\n //? requested, string-valued pointers are trusted; a non-string is dropped (and\r\n //? thus treated as an unresolved pointer downstream: fatal in 'remote', warned\r\n //? in 'hybrid').\r\n const requested = new Set(pointers);\r\n const filtered: Record<string, string> = {};\r\n for (const [key, value] of Object.entries(values as Record<string, unknown>)) {\r\n if (!requested.has(key)) continue;\r\n if (typeof value !== 'string') {\r\n console.warn(`[secret-manager] Pointer \"${key}\" resolved to a non-string value (${typeof value}); ignoring.`);\r\n continue;\r\n }\r\n filtered[key] = value;\r\n }\r\n return filtered;\r\n};\r\n\r\nconst fetchResolve = async (\r\n config: SecretManagerConfig,\r\n pointers: string[],\r\n): Promise<Record<string, string>> => {\r\n //? Defensive normalization: a configured `NaN` would make `attempt <= NaN` always\r\n //? false, so the loop body would never run. `lastError` is now pre-initialized\r\n //? (below) so we wouldn't `throw undefined` anymore, but coercing to a finite\r\n //? non-negative value is still the right guard — NaN retries is a config bug, not\r\n //? a valid \"zero retries\" signal.\r\n const rawRetryCount = config.retries?.count ?? 0;\r\n const retryCount = Number.isFinite(rawRetryCount) ? Math.max(0, rawRetryCount) : 0;\r\n const rawDelayMs = config.retries?.delayMs ?? 0;\r\n const delayMs = Number.isFinite(rawDelayMs) ? Math.max(0, rawDelayMs) : 0;\r\n\r\n let lastError: unknown = new Error('[secret-manager] resolve failed: no attempt was made');\r\n for (let attempt = 0; attempt <= retryCount; attempt++) {\r\n try {\r\n return await fetchResolveOnce(config, pointers);\r\n } catch (error) {\r\n lastError = error;\r\n if (attempt < retryCount && delayMs > 0) await sleep(delayMs);\r\n }\r\n }\r\n throw lastError;\r\n};\r\n\r\n//? Fire the framework's decoupled \"secrets resolved\" channel (if `@luckystack/core`\r\n//? is present in the process) so it can rebuild clients that captured a now-stale\r\n//? secret at construction — most importantly the default Redis client, whose\r\n//? ioredis password is baked in at `new Redis(...)` time and never re-read. Kept\r\n//? DECOUPLED via a well-known global-symbol array so this package keeps NO import\r\n//? of core (its \"zero required deps\" contract). Best-effort + isolated: a missing\r\n//? core, or a throwing listener, must never break the resolve/boot path. This is\r\n//? what makes Redis-auth-via-a-secret-manager-POINTER boot with zero consumer code\r\n//? (the client is rebuilt from the resolved env AT RESOLVE TIME, before any later\r\n//? env revert — ADR 0026).\r\nconst GLOBAL_SECRETS_RESOLVED_LISTENERS = Symbol.for('luckystack.secretsResolved.listeners');\r\n\r\nconst fireFrameworkSecretsResolved = (changedNames: readonly string[]): void => {\r\n const listeners: unknown = Reflect.get(globalThis, GLOBAL_SECRETS_RESOLVED_LISTENERS);\r\n if (!Array.isArray(listeners)) return;\r\n for (const listener of listeners) {\r\n if (typeof listener !== 'function') continue;\r\n try {\r\n (listener as (keys: readonly string[]) => void)(changedNames);\r\n } catch {\r\n //? A misbehaving framework listener must never break the resolve path.\r\n }\r\n }\r\n};\r\n\r\nconst applyResolved = (\r\n map: Record<string, string>,\r\n values: Record<string, string>,\r\n source: 'remote' | 'hybrid',\r\n): { name: string; pointer: string }[] => {\r\n //? In remote mode a single unresolved pointer is a hard boot failure. Check\r\n //? everything BEFORE mutating process.env so the failure is atomic.\r\n if (source === 'remote') {\r\n const missing = Object.entries(map).filter(([, pointer]) => values[pointer] === undefined);\r\n if (missing.length > 0) {\r\n const detail = missing.map(([name, pointer]) => `${pointer} (referenced by ${name})`).join(', ');\r\n throw new Error(`[secret-manager] Server did not resolve: ${detail}.`);\r\n }\r\n }\r\n\r\n //? Track only the env NAMES whose value actually changed — surfaced to\r\n //? `onApplied` so a client that captured a secret at construction time can\r\n //? re-create its pool/SDK client. Never carries the secret values themselves.\r\n const changes: { name: string; pointer: string }[] = [];\r\n for (const [name, pointer] of Object.entries(map)) {\r\n const value = values[pointer];\r\n if (value === undefined) {\r\n //? hybrid only — leave the pointer in place and warn so the operator sees it.\r\n console.warn(`[secret-manager] Pointer \"${pointer}\" (referenced by ${name}) not resolved; leaving \"${name}\" as-is.`);\r\n continue;\r\n }\r\n if (process.env[name] !== value) changes.push({ name, pointer });\r\n process.env[name] = value;\r\n }\r\n return changes;\r\n};\r\n\r\n//? Public resolve entry: SERIALIZE every resolve behind a single in-flight chain\r\n//? so a slow resolve can never land after a newer one (TOCTOU on `process.env` /\r\n//? `cachedResolution`). We chain off `resolveChain.then(...)` regardless of whether\r\n//? the prior resolve resolved or rejected, then re-publish the tail so the next\r\n//? caller waits on THIS one. The returned promise mirrors the inner outcome so\r\n//? `'remote'` still rejects the caller on a hard failure.\r\nconst doResolve = (\r\n config: SecretManagerConfig,\r\n phase: 'boot' | 'refresh' | 'file-reload',\r\n): Promise<void> => {\r\n const run = resolveChain.then(\r\n () => doResolveInner(config, phase),\r\n () => doResolveInner(config, phase),\r\n );\r\n //? Keep the chain alive even if this resolve rejects (swallow on the tail copy\r\n //? only — the returned `run` keeps the original rejection for the caller).\r\n resolveChain = run.then(noop, noop);\r\n return run;\r\n};\r\n\r\nconst doResolveInner = async (\r\n config: SecretManagerConfig,\r\n phase: 'boot' | 'refresh' | 'file-reload',\r\n): Promise<void> => {\r\n const source = config.source ?? 'remote';\r\n if (source === 'local') return;\r\n\r\n //? Secure default: an unset `envNames` resolves NOTHING off-host — warn loudly\r\n //? on every resolve so the deny-all state is never silent (an operator who\r\n //? expected secrets to resolve sees exactly what to set).\r\n warnIfEnvNamesUnset(config.envNames);\r\n\r\n //? Capture once on first resolve and reuse: the first resolve OVERWRITES the\r\n //? env value with the real secret, after which it no longer looks like a pointer.\r\n //? Re-capture when the map is empty so a pointer that wasn't present at boot\r\n //? (e.g. set into `process.env` after init) is still picked up by a later\r\n //? refresh — a non-empty `{}` would otherwise pin the resolver to zero pointers.\r\n if (pointerMap === null || Object.keys(pointerMap).length === 0) {\r\n pointerMap = capturePointers(\r\n stripStatefulFlags(config.pointerPattern ?? DEFAULT_POINTER_PATTERN),\r\n config.envNames,\r\n );\r\n }\r\n const activePointerMap = pointerMap;\r\n const pointers = [...new Set(Object.values(activePointerMap))];\r\n if (pointers.length === 0) {\r\n cachedResolution = { fetchedAt: Date.now(), values: {} };\r\n return;\r\n }\r\n\r\n //? CC-7 exemption (no-raw-try-catch): the async framework `tryCatch` auto-captures\r\n //? to the error tracker, but this is a deliberate fail-OPEN boot-time guard in an\r\n //? intentionally dependency-light client — a 'hybrid' resolve failure must stay a\r\n //? silent warn with NO error-tracker side-effect (and 'remote' re-throws untouched\r\n //? for a hard boot stop). `tryCatchSync` can't wrap the `await`, so the raw\r\n //? try/catch is kept on purpose; the fail-OPEN contract is the load-bearing detail.\r\n let values: Record<string, string>;\r\n let changes: { name: string; pointer: string }[];\r\n try {\r\n values = await fetchResolve(config, pointers);\r\n changes = applyResolved(activePointerMap, values, source);\r\n cachedResolution = { fetchedAt: Date.now(), values };\r\n } catch (error) {\r\n //? Opt-in observability seam: route the failure to Sentry/metrics. Kept\r\n //? alongside (not replacing) the warn below so the fail-open default is unchanged.\r\n //? Hooks run isolated: a throwing/hanging `onResolveError` must not mask the\r\n //? original resolve error (it is re-thrown below in 'remote').\r\n runHook(() => config.onResolveError?.(error, { phase }), 'onResolveError');\r\n if (source === 'hybrid') {\r\n //? Log the message only — never the raw error object: error objects are the\r\n //? classic accidental channel for a URL/token/PII string reaching a log sink.\r\n //? Full-fidelity routing is the `onResolveError` hook's job.\r\n console.warn('[secret-manager] Resolve failed, leaving local env as-is:', errorMessage(error));\r\n return;\r\n }\r\n throw error;\r\n }\r\n\r\n //? Notify AFTER the cache + process.env are coherent so a consumer that reads\r\n //? process.env inside the callback sees the applied values. Isolated AND\r\n //? time-bounded by `runHookAsync` so a throwing `onApplied` can't abort an\r\n //? otherwise-successful resolve, AND a HANGING `onApplied` can't wedge the\r\n //? serialized resolve chain forever (it is abandoned after `HOOK_TIMEOUT_MS`,\r\n //? with a warn) — process.env + the cache are already written by this point.\r\n if (changes.length > 0) {\r\n //? Framework channel FIRST (synchronous, in-process — rebuilds framework\r\n //? clients like Redis from the just-resolved env), then the consumer hook.\r\n fireFrameworkSecretsResolved(changes.map((change) => change.name));\r\n await runHookAsync(() => config.onApplied?.(changes), 'onApplied');\r\n }\r\n};\r\n\r\n//? Minimal .env parser kept in-package so the resolver stays dependency-free.\r\n//? Handles standard `KEY=VALUE` lines, full-line + inline (` #`) comments, and\r\n//? single/double-quoted values. Not multi-line values or escape sequences.\r\nconst parseEnvFile = (content: string): Record<string, string> => {\r\n const out: Record<string, string> = {};\r\n for (const rawLine of content.split(/\\r?\\n/)) {\r\n const line = rawLine.trim();\r\n if (!line || line.startsWith('#')) continue;\r\n const eq = line.indexOf('=');\r\n if (eq <= 0) continue;\r\n const key = line.slice(0, eq).trim();\r\n //? Restrict to POSIX-shell env-var names. `.`/`-` keys can't be read via the\r\n //? normal `process.env.NAME` lookup, so accepting them is a silent footgun.\r\n if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {\r\n console.warn(`[secret-manager] Ignoring env key \"${key}\": not a valid environment variable name (^[A-Za-z_][A-Za-z0-9_]*$).`);\r\n continue;\r\n }\r\n let value = line.slice(eq + 1).trim();\r\n const quote = value[0];\r\n if (quote === '\"' || quote === \"'\") {\r\n //? Quoted value. The closing quote is the LAST occurrence of the same quote\r\n //? char; anything after it (whitespace + `# ...`) is an inline comment and is\r\n //? dropped (`KEY=\"v\" # note` → `v`). A `#` BEFORE the closing quote is literal\r\n //? and preserved (`KEY=\"a#b\"` → `a#b`).\r\n const closeIdx = value.lastIndexOf(quote);\r\n if (closeIdx > 0) {\r\n value = value.slice(1, closeIdx);\r\n } else {\r\n //? Opening quote with no matching closing quote on this line means a\r\n //? multi-line value (dotenv supports it, this parser does not). Warn so the\r\n //? value isn't silently truncated, then strip any inline comment from the\r\n //? raw remainder so a trailing `# ...` doesn't leak into the value.\r\n console.warn(\r\n `[secret-manager] parseEnvFile: \"${key}\" starts with a quote but has no matching closing quote on the same line. Multi-line values are not supported — the raw text will be used as-is. If this is a multi-line value (e.g. a PEM key), do not rely on this parser.`,\r\n );\r\n const commentAt = value.indexOf(' #');\r\n if (commentAt !== -1) value = value.slice(0, commentAt).trim();\r\n }\r\n } else {\r\n const commentAt = value.indexOf(' #');\r\n if (commentAt !== -1) value = value.slice(0, commentAt).trim();\r\n if (value.startsWith('#')) value = '';\r\n }\r\n out[key] = value;\r\n }\r\n return out;\r\n};\r\n\r\nconst scheduleReload = (): void => {\r\n //? Debounce — fs.watch can fire several events for one save.\r\n if (debounceTimer) clearTimeout(debounceTimer);\r\n debounceTimer = setTimeout(() => {\r\n void reloadSecretManagerFromFiles().catch((error: unknown) => {\r\n console.warn('[secret-manager] dev reload failed:', errorMessage(error));\r\n });\r\n }, 200);\r\n debounceTimer.unref();\r\n};\r\n\r\nconst startDevReload = (config: SecretManagerConfig): void => {\r\n if (devReloadStarted || config.dev === undefined) return;\r\n //? Allowlist the dev environments rather than exact-matching 'production': a\r\n //? 'prod' / 'staging' env must NOT silently start fs watchers + a poll on a\r\n //? host the operator believes is production. Only an explicit 'development' or\r\n //? 'test' enables dev hot reload (an unset env resolves to 'development' via\r\n //? the canonical `resolveEnvKey()`, so it counts as dev).\r\n const nodeEnv = resolveEnvKey();\r\n if (nodeEnv !== 'development' && nodeEnv !== 'test') return;\r\n\r\n const watch = config.dev.watch ?? true;\r\n const pollIntervalMs = config.dev.pollIntervalMs ?? 0;\r\n if (!watch && pollIntervalMs <= 0) return;\r\n devReloadStarted = true;\r\n\r\n if (watch) {\r\n for (const file of config.dev.envFiles ?? DEFAULT_ENV_FILES) {\r\n if (!isSafeEnvFile(file, true)) {\r\n console.warn(`[secret-manager] ignoring unsafe dev envFile path (must be relative + within the project): ${file}`);\r\n continue;\r\n }\r\n try {\r\n const watcher = fsWatch(file, () => {\r\n scheduleReload();\r\n });\r\n watcher.unref();\r\n fileWatchers.push(watcher);\r\n } catch {\r\n //? The file may not exist (e.g. no .env.local) — nothing to watch.\r\n }\r\n }\r\n }\r\n\r\n if (pollIntervalMs > 0) {\r\n pollTimer = setInterval(() => {\r\n void refreshSecretManager().catch((error: unknown) => {\r\n console.warn('[secret-manager] poll refresh failed:', errorMessage(error));\r\n });\r\n }, pollIntervalMs);\r\n pollTimer.unref();\r\n }\r\n};\r\n\r\n/**\r\n * Initialize the secret-manager resolver. Call once as the very first line of\r\n * `server.ts`, BEFORE any other framework code reads `process.env`. In\r\n * `'remote'` / `'hybrid'` mode the resolved secrets are written into\r\n * `process.env` before this resolves, so downstream code sees them via the\r\n * standard `process.env.FOO` lookup. In `'local'` mode it is a no-op.\r\n */\r\nexport const initSecretManager = async (config: SecretManagerConfig): Promise<void> => {\r\n //? Validate the URL BEFORE recording activeConfig, so a failed init can't leave\r\n //? a later refreshSecretManager() resolving against an invalid config.\r\n if ((config.source ?? 'remote') !== 'local') {\r\n //? Only validate the URL when we'll actually hit the network ('remote' /\r\n //? 'hybrid'); 'local' may carry a placeholder url.\r\n validateUrl(config.url, config.allowInsecureHttp ?? false);\r\n }\r\n activeConfig = config;\r\n if ((config.source ?? 'remote') === 'local') return;\r\n await doResolve(config, 'boot');\r\n startRotationPoll(config);\r\n startDevReload(config);\r\n};\r\n\r\n//? Production-capable rotation poll: re-resolve every `config.pollIntervalMs` ms\r\n//? in ALL environments (distinct from the dev-only `dev.pollIntervalMs` file-watch\r\n//? channel, which is gated off in production). Unref'd so it never blocks exit.\r\nconst startRotationPoll = (config: SecretManagerConfig): void => {\r\n const intervalMs = config.pollIntervalMs ?? 0;\r\n if (intervalMs <= 0 || rotationPollTimer) return;\r\n rotationPollTimer = setInterval(() => {\r\n void refreshSecretManager().catch((error: unknown) => {\r\n console.warn('[secret-manager] rotation poll refresh failed:', errorMessage(error));\r\n });\r\n }, intervalMs);\r\n rotationPollTimer.unref();\r\n};\r\n\r\n/**\r\n * Re-resolve against the server, ignoring nothing — used by the dev hot-reload\r\n * watch/poll and callable manually when an admin rotates a secret and you want\r\n * a long-running process to pick it up without a restart.\r\n */\r\nexport const refreshSecretManager = async (): Promise<void> => {\r\n if (!activeConfig || (activeConfig.source ?? 'remote') === 'local') return;\r\n await doResolve(activeConfig, 'refresh');\r\n};\r\n\r\n/**\r\n * Dev hot-reload entry: re-parse the configured env files and apply them — plain\r\n * (non-pointer) values are injected straight into `process.env` (live config\r\n * reload), pointer-shaped values are re-resolved against the server. Wired to the\r\n * `.env` / `.env.local` file watch; also callable manually.\r\n */\r\nexport const reloadSecretManagerFromFiles = async (): Promise<void> => {\r\n const config = activeConfig;\r\n if (!config || (config.source ?? 'remote') === 'local') return;\r\n\r\n const files = config.dev?.envFiles ?? DEFAULT_ENV_FILES;\r\n const pattern = stripStatefulFlags(config.pointerPattern ?? DEFAULT_POINTER_PATTERN);\r\n\r\n //? Re-read every file in load order; later files (e.g. .env.local) override.\r\n //? `warnAbsolute=false`: the absolute-path notice already fired once at boot\r\n //? (startDevReload). Repeating it on every debounced hot-reload would flood the\r\n //? dev log for anyone legitimately using an absolute envFile path.\r\n const merged: Record<string, string> = {};\r\n for (const file of files) {\r\n if (!isSafeEnvFile(file, false)) {\r\n console.warn(`[secret-manager] ignoring unsafe dev envFile path: ${file}`);\r\n continue;\r\n }\r\n let content: string;\r\n try {\r\n content = readFileSync(file, 'utf8');\r\n } catch {\r\n continue; //? file may not exist (e.g. no .env.local)\r\n }\r\n Object.assign(merged, parseEnvFile(content));\r\n }\r\n\r\n //? Split plain (live config reload) from pointer-shaped values through the SAME\r\n //? `envNames` allowlist the boot path uses (`capturePointers`), so a name excluded\r\n //? by `envNames` is dropped on BOTH channels — never POSTed off-host as a pointer,\r\n //? never injected into `process.env` as a plain value. This closes the scoping\r\n //? drift where a file-reload could send/apply names the boot scan rejected.\r\n const { pointers: freshPointerMap, plain: plainValues } = splitPointers(\r\n Object.entries(merged),\r\n pattern,\r\n config.envNames,\r\n );\r\n\r\n //? MERGE the fresh file-sourced pointers over the existing map (don't replace):\r\n //? a pointer captured at boot from the inherited shell/CI env that isn't in a\r\n //? watched file would otherwise be dropped and stop rotating. A file-sourced\r\n //? pointer with the same name still wins.\r\n //? Build the merged map locally and commit it ONLY after a successful resolve so\r\n //? a failed remote reload can't permanently poison the in-memory pointer map and\r\n //? cause every subsequent refreshSecretManager / poll to resolve the bad set.\r\n const previousPointerMap = pointerMap;\r\n pointerMap = { ...pointerMap, ...freshPointerMap };\r\n\r\n //? Resolve the pointers FIRST. In 'remote' mode an unresolved pointer throws —\r\n //? mirror the atomic boot path and inject the plain values only AFTER a\r\n //? successful resolve, so a throw never leaves half-applied state. On failure\r\n //? roll back the pointer map to its pre-reload snapshot.\r\n try {\r\n await doResolve(config, 'file-reload');\r\n } catch (error) {\r\n pointerMap = previousPointerMap;\r\n throw error;\r\n }\r\n for (const [name, value] of Object.entries(plainValues)) {\r\n process.env[name] = value;\r\n }\r\n};\r\n\r\n/**\r\n * Read the last `{ fetchedAt, values }` resolution (pointer -> value), or `null`.\r\n *\r\n * ⚠️ SENSITIVE: `values` maps each pointer to its RAW resolved secret. The result\r\n * is a shallow copy (values are flat strings, so mutating the returned object does\r\n * not corrupt the cache), but the values are still the real secrets — NEVER\r\n * serialize the result into an HTTP response, a `/health` payload, or a log line.\r\n * For a safe diagnostic use {@link getCachedResolutionMeta}, which never exposes\r\n * the values.\r\n *\r\n * If you need to act on changed secrets (e.g. to rebuild a DB pool after rotation),\r\n * use the `onApplied` callback in `SecretManagerConfig` instead — it receives only\r\n * the changed env NAMES, never the secret values, and is called automatically after\r\n * each successful resolve.\r\n */\r\nexport const getCachedResolution = (): CachedResolution | null =>\r\n cachedResolution === null\r\n ? null\r\n : { fetchedAt: cachedResolution.fetchedAt, values: { ...cachedResolution.values } };\r\n\r\n/** Values-free view of {@link getCachedResolution}: the timestamp + resolved pointer NAMES only. */\r\nexport interface CachedResolutionMeta {\r\n /** `Date.now()` of the last successful resolve. */\r\n fetchedAt: number;\r\n /** The resolved pointer strings (never the secret values). */\r\n pointerNames: string[];\r\n /** How many pointers were resolved. */\r\n pointerCount: number;\r\n}\r\n\r\n/**\r\n * Safe diagnostic accessor: the last resolution's timestamp + resolved pointer\r\n * NAMES, with the secret values stripped. Use this on any surface that might be\r\n * logged or served (`/health`, metrics) so the convenient path is also the safe\r\n * one — {@link getCachedResolution} is the values-carrying escape hatch.\r\n */\r\nexport const getCachedResolutionMeta = (): CachedResolutionMeta | null => {\r\n if (cachedResolution === null) return null;\r\n const pointerNames = Object.keys(cachedResolution.values);\r\n return { fetchedAt: cachedResolution.fetchedAt, pointerNames, pointerCount: pointerNames.length };\r\n};\r\n\r\n/**\r\n * Tear down the dev file watchers, the dev poll, the rotation poll, and the\r\n * debounce timer WITHOUT wiping the resolved cache / active config. Use for a\r\n * graceful shutdown of an embedded resolver (worker / CLI) so no timers keep\r\n * firing; the last resolved `process.env` values stay in place.\r\n *\r\n * **Note:** `activeConfig` is intentionally left set after `stopSecretManager`,\r\n * so that `reloadSecretManagerFromFiles` can still use it for a final manual\r\n * reload. As a consequence, calling `refreshSecretManager()` after `stop` will\r\n * issue a live network request (the `!activeConfig` no-op guard is not met).\r\n * If you want to prevent ANY further resolution after `stop`, call\r\n * `resetSecretManagerForTests()` instead (which also clears `activeConfig`).\r\n */\r\nexport const stopSecretManager = (): void => {\r\n devReloadStarted = false;\r\n if (pollTimer) {\r\n clearInterval(pollTimer);\r\n pollTimer = null;\r\n }\r\n if (rotationPollTimer) {\r\n clearInterval(rotationPollTimer);\r\n rotationPollTimer = null;\r\n }\r\n if (debounceTimer) {\r\n clearTimeout(debounceTimer);\r\n debounceTimer = null;\r\n }\r\n for (const watcher of fileWatchers) {\r\n watcher.close();\r\n }\r\n fileWatchers.length = 0;\r\n //? Reset the \"warned once\" guard so a subsequent initSecretManager() call that\r\n //? also omits envNames re-emits the boot warning rather than silently silencing it\r\n //? (the first, valid config could have set envNames correctly while the second,\r\n //? broken one doesn't — the guard must not carry across a full stop-and-reinit).\r\n warnedEnvNamesUnset = false;\r\n};\r\n\r\n/** Test-only — clear module state and tear down any dev watchers / timers. */\r\nexport const resetSecretManagerForTests = (): void => {\r\n stopSecretManager();\r\n cachedResolution = null;\r\n pointerMap = null;\r\n activeConfig = null;\r\n resolveChain = Promise.resolve();\r\n warnedEnvNamesUnset = false;\r\n};\r\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luckystack/secret-manager",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -53,6 +53,6 @@
53
53
  "test": "vitest run"
54
54
  },
55
55
  "dependencies": {
56
- "@luckystack/core": "^0.6.4"
56
+ "@luckystack/core": "^0.6.5"
57
57
  }
58
58
  }