@luckystack/secret-manager 0.6.7 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-07-16
11
+
10
12
  ### Added
11
13
 
12
14
  - **Fires the framework "secrets resolved" channel automatically** (ADR 0026). After
package/CLAUDE.md CHANGED
@@ -1,104 +1,104 @@
1
- # @luckystack/secret-manager
2
-
3
- > AI summary + function INDEX. For deep specs see `docs/` next to this file.
4
-
5
- ## What this package does
6
-
7
- `@luckystack/secret-manager` is the **client** half of a rotation-aware secret system. The app commits a `.env` containing **pointers** (`OPENAI_KEY=OPENAI_AUTHORIZATION_KEY_V5`), not real secrets. At boot this client scans `process.env`, collects every pointer-shaped value (`<BASE>_V<n>`), resolves them in ONE `POST /resolve` request against an external append-only secret-manager server, and overwrites each `process.env` entry with the real value — so downstream code reads `process.env.OPENAI_KEY` and gets the resolved secret. The companion server (storage, versioning, admin UI, one shared bearer token) lives in its own repository.
8
-
9
- Three modes: `'remote'` (default — missing pointer / unreachable server throws), `'local'` (no network; pointers untouched; tests + offline dev), `'hybrid'` (try server, warn + keep local env on failure). Optional opt-in dev hot reload re-resolves on `.env` change and/or on an interval (no-op in production).
10
-
11
- ## When to USE this package
12
-
13
- - You want to keep `.env` committed (safe, shareable) while real secrets live on a central server.
14
- - You need rotation that doesn't break old branches: bump `..._V5` to `..._V6`, old branches keep resolving their pinned version.
15
- - You are wiring a LuckyStack app at boot and want resolved secrets in `process.env` before any framework code runs.
16
-
17
- ## When to NOT suggest this (yet)
18
-
19
- - Plain local dev with real values in `.env.local`: just use `process.env.FOO` directly — non-pointer values are left untouched, so you don't even need to call this.
20
- - Per-request secret rotation with zero staleness: this resolves at boot (+ optional dev poll). Use a dedicated SDK at the call site for sub-second rotation guarantees.
21
- - Validating env-key shapes / required-key enforcement: out of scope. Validate in your config layer (e.g. `@luckystack/core` `projectConfig`) after `initSecretManager` has populated `process.env`.
22
-
23
- ## Function Index
24
-
25
- | Function / Export | One-liner | Deep doc |
26
- | --- | --- | --- |
27
- | `initSecretManager(config)` | Boot-time entry. Scans `process.env` for pointer-shaped values, `POST /resolve`s them, overwrites `process.env` with real values. No-op in `'local'`. Starts dev hot reload when `config.dev` is set. Starts the production rotation poll when `config.pollIntervalMs` is set. | -> docs/architecture.md |
28
- | `refreshSecretManager()` | Re-resolve the captured pointers against the server (the production rotation poll channel). Call manually after an admin rotates a secret on a long-running process. No-op in `'local'` mode. | -> docs/architecture.md |
29
- | `reloadSecretManagerFromFiles()` | Re-parse the configured env files (`dev.envFiles`, default `.env` + `.env.local`) and apply them: plain values injected into `process.env` (live config reload), pointer-shaped values re-resolved. The dev **file-watch** channel; callable manually. No-op before init or in `'local'` mode. | -> docs/architecture.md |
30
- | `stopSecretManager()` | Tear down all dev watchers, debounce timers, and rotation-poll intervals started by `initSecretManager`. Call on process shutdown if you need deterministic cleanup; otherwise timers are `unref`'d and won't block exit. | -> docs/architecture.md |
31
- | `getCachedResolution()` | Returns a shallow copy of the last `{ fetchedAt, values }` (pointer -> resolved secret) for diagnostics, or `null`. **Sensitive** — never serialize into HTTP responses, health payloads, or logs. | -> docs/architecture.md |
32
- | `getCachedResolutionMeta()` | Values-free diagnostic view: `{ fetchedAt, pointerNames, pointerCount }` — the resolved pointer names only, never the secret values. Safe for logs and health endpoints. | -> docs/architecture.md |
33
- | `resetSecretManagerForTests()` | Test-only — clears all module state and tears down dev watchers / timers. | -> docs/architecture.md |
34
- | Type `SecretManagerConfig` | `{ url; token; source?; pointerPattern?; envNames?; allowInsecureHttp?; timeoutMs?; retries?; resolvePath?; headers?; onApplied?; onResolveError?; fetchImpl?; pollIntervalMs?; dev? }` where `dev` is `{ watch?; pollIntervalMs?; envFiles? }`. | -> docs/architecture.md |
35
- | Type `SecretManagerToken` | `string \| { fromFile: string }`. | -> docs/architecture.md |
36
- | Type `CachedResolution` | `{ fetchedAt: number; values: Record<string, string> }`. | -> docs/architecture.md |
37
- | Type `CachedResolutionMeta` | `{ fetchedAt: number; pointerNames: string[]; pointerCount: number }`. | -> docs/architecture.md |
38
-
39
- ### Internal helpers (not exported, listed for AI context)
40
-
41
- | Helper | Role |
42
- | --- | --- |
43
- | `capturePointers(pattern)` | Scan `process.env` once, return `{ envName -> pointer }` for every value matching the pointer pattern. Captured once because the first resolve overwrites the value with the real secret (no longer pointer-shaped). |
44
- | `resolveToken(token)` | Return the literal token, or read+trim the `{ fromFile }` file (read at resolve time so file rotation is picked up). |
45
- | `fetchResolve(config, pointers)` | `POST ${url}/resolve` with `{ keys: pointers }` and `Authorization: Bearer <token>`. Throws on non-2xx or a missing `values` object. Honors `fetchImpl`. |
46
- | `applyResolved(map, values, source)` | Overwrite `process.env[envName]` with the resolved value. In `'remote'` mode, fail fast (throw) if any pointer is unresolved BEFORE mutating; in `'hybrid'` warn per missing pointer and leave it as-is. |
47
- | `parseEnvFile(content)` | In-package minimal `.env` parser (KEY=VALUE, full-line + inline comments, quoted values). Keeps the package dependency-free; used by the file-reload path. |
48
- | `startDevReload(config)` | Opt-in (`config.dev` set) + non-production. Starts a debounced `fs.watch` on `dev.envFiles` (-> `reloadSecretManagerFromFiles`) and/or an interval poll (-> `refreshSecretManager`). Both channels swallow + warn on error so a transient failure never crashes dev. |
49
-
50
- ## Boot-time contract (authoritative)
51
-
52
- 1. Call `initSecretManager(...)` as the very first line of `server.ts`, before any other framework code reads `process.env`.
53
- 2. `'local'` mode short-circuits — no network, no writes, no watchers.
54
- 3. Otherwise it captures the pointer map once, `POST /resolve`s the unique pointers, and overwrites `process.env`.
55
- 4. `'remote'`: a missing pointer or fetch error throws (hard boot stop). `'hybrid'`: warn and leave `process.env` as-is.
56
- 5. When `config.dev` is set and not production, dev hot reload starts: a debounced watch on `dev.envFiles` re-parses the files (plain values injected, pointers re-resolved) and an optional interval poll re-resolves the current pointers.
57
- 6. Only after `initSecretManager` resolves should other framework code read `process.env`.
58
-
59
- ## Config keys
60
-
61
- This package reads **no** env vars itself — it consumes `SecretManagerConfig` (typically built in `config.ts` and passed in `server.ts`). The values you commonly source from env / a file:
62
-
63
- | Key | Purpose | Notes |
64
- | --- | --- | --- |
65
- | `url` | Base URL of the secret-manager server (trailing slash optional). | Required (except `source:'local'`). |
66
- | `token` | Shared bearer token: literal string or `{ fromFile }` (gitignored single-line file). | Read at resolve time — file rotation picked up on next poll. A `Bearer ` prefix is stripped + warned. |
67
- | `source` | `'remote'` (default) / `'local'` / `'hybrid'`. | `'remote'` = hard boot stop on failure; `'hybrid'` = warn + keep local env. |
68
- | `envNames` | Allowlist of env-var NAMES eligible for resolution: `string[]` or `(name) => boolean`. **Secure default: unset resolves NOTHING off-host** (a boot warning is emitted so the deny-all is never silent). Pass `() => true` to scan every name deliberately. | Required to actually resolve anything. |
69
- | `dev` | Opt-in dev hot reload: `{ watch?, pollIntervalMs?, envFiles? }`. Ignored in production (`NODE_ENV !== 'development'|'test'`). | — |
70
-
71
- ### Advanced keys (direct-call-only — not needed for typical boot wiring)
72
-
73
- | Key | Purpose |
74
- | --- | --- |
75
- | `pointerPattern` | Override the pointer-shape detector (default `/^(.+)_V(\d+)$/`). Stateful `g`/`y` flags are stripped automatically. |
76
- | `allowInsecureHttp` | Permit `http:` to a non-loopback host. A loud warning is still emitted. Loopback is always permitted. |
77
- | `timeoutMs` | Abort a black-hole server after N ms (default `10_000`). Set `0` to disable. |
78
- | `retries` | `{ count, delayMs? }` — retry on transport error / non-2xx before giving up. Default `{ count: 0 }`. |
79
- | `resolvePath` | Override the resolve endpoint path (default `'/resolve'`). |
80
- | `headers` | Extra request headers merged onto every resolve request (cannot override `Authorization`). |
81
- | `onApplied` | Called after secrets are written to `process.env` — receives changed env NAMES only (never the values). Use to re-create pools/SDK clients on rotation. |
82
- | `onResolveError` | Called on resolve failure (alongside the existing `console.warn`). Route to Sentry/metrics; useful for `'hybrid'` where a silent warn is the default. |
83
- | `fetchImpl` | Override the global `fetch`. For non-Node-20 hosts or test injection. |
84
- | `pollIntervalMs` | Production rotation poll interval in ms (re-resolves in ALL environments). Default `0` (disabled). Timers are `unref`'d. |
85
-
86
- `process.env` values matching `pointerPattern` (default `/^(.+)_V(\d+)$/`) are the pointers this client resolves and overwrites.
87
-
88
- ## Design note — single resolver per process
89
-
90
- `@luckystack/secret-manager` uses module-level state (`activeConfig`, `pointerMap`, `cachedResolution`, `resolveChain`). This is intentional: there is one canonical view of `process.env`, so a second parallel resolver would race against the first. If you need to resolve different configs separately (e.g. a multi-stage bootstrap), call `stopSecretManager()` + `resetSecretManagerForTests()` between them (the latter is exported but named for test clarity — it is safe to call in non-test bootstrap code too). Running two resolvers concurrently against the same `process.env` is unsupported.
91
-
92
- ## Peer dependencies
93
-
94
- - **None required.** Speaks plain HTTP via global `fetch`; reads the token file with Node's built-in `fs`.
95
- - **Optional**: any `fetch` polyfill (e.g. `undici`) for non-Node-20 hosts — pass via `SecretManagerConfig.fetchImpl`.
96
-
97
- Node `>= 20` is required because the default code path uses global `fetch`.
98
-
99
- ## Related
100
-
101
- - Concept overview + external-server wire contract: `./docs/architecture.md`.
102
- - Consumer quickstart: `./README.md`.
103
- - Framework-wide packaging map: `/docs/PACKAGE_OVERVIEW.md`.
104
- - Architecture deep-dive: `/docs/ARCHITECTURE_SECRET_MANAGER.md`.
1
+ # @luckystack/secret-manager
2
+
3
+ > AI summary + function INDEX. For deep specs see `docs/` next to this file.
4
+
5
+ ## What this package does
6
+
7
+ `@luckystack/secret-manager` is the **client** half of a rotation-aware secret system. The app commits a `.env` containing **pointers** (`OPENAI_KEY=OPENAI_AUTHORIZATION_KEY_V5`), not real secrets. At boot this client scans `process.env`, collects every pointer-shaped value (`<BASE>_V<n>`), resolves them in ONE `POST /resolve` request against an external append-only secret-manager server, and overwrites each `process.env` entry with the real value — so downstream code reads `process.env.OPENAI_KEY` and gets the resolved secret. The companion server (storage, versioning, admin UI, one shared bearer token) lives in its own repository.
8
+
9
+ Three modes: `'remote'` (default — missing pointer / unreachable server throws), `'local'` (no network; pointers untouched; tests + offline dev), `'hybrid'` (try server, warn + keep local env on failure). Optional opt-in dev hot reload re-resolves on `.env` change and/or on an interval (no-op in production).
10
+
11
+ ## When to USE this package
12
+
13
+ - You want to keep `.env` committed (safe, shareable) while real secrets live on a central server.
14
+ - You need rotation that doesn't break old branches: bump `..._V5` to `..._V6`, old branches keep resolving their pinned version.
15
+ - You are wiring a LuckyStack app at boot and want resolved secrets in `process.env` before any framework code runs.
16
+
17
+ ## When to NOT suggest this (yet)
18
+
19
+ - Plain local dev with real values in `.env.local`: just use `process.env.FOO` directly — non-pointer values are left untouched, so you don't even need to call this.
20
+ - Per-request secret rotation with zero staleness: this resolves at boot (+ optional dev poll). Use a dedicated SDK at the call site for sub-second rotation guarantees.
21
+ - Validating env-key shapes / required-key enforcement: out of scope. Validate in your config layer (e.g. `@luckystack/core` `projectConfig`) after `initSecretManager` has populated `process.env`.
22
+
23
+ ## Function Index
24
+
25
+ | Function / Export | One-liner | Deep doc |
26
+ | --- | --- | --- |
27
+ | `initSecretManager(config)` | Boot-time entry. Scans `process.env` for pointer-shaped values, `POST /resolve`s them, overwrites `process.env` with real values. No-op in `'local'`. Starts dev hot reload when `config.dev` is set. Starts the production rotation poll when `config.pollIntervalMs` is set. | -> docs/architecture.md |
28
+ | `refreshSecretManager()` | Re-resolve the captured pointers against the server (the production rotation poll channel). Call manually after an admin rotates a secret on a long-running process. No-op in `'local'` mode. | -> docs/architecture.md |
29
+ | `reloadSecretManagerFromFiles()` | Re-parse the configured env files (`dev.envFiles`, default `.env` + `.env.local`) and apply them: plain values injected into `process.env` (live config reload), pointer-shaped values re-resolved. The dev **file-watch** channel; callable manually. No-op before init or in `'local'` mode. | -> docs/architecture.md |
30
+ | `stopSecretManager()` | Tear down all dev watchers, debounce timers, and rotation-poll intervals started by `initSecretManager`. Call on process shutdown if you need deterministic cleanup; otherwise timers are `unref`'d and won't block exit. | -> docs/architecture.md |
31
+ | `getCachedResolution()` | Returns a shallow copy of the last `{ fetchedAt, values }` (pointer -> resolved secret) for diagnostics, or `null`. **Sensitive** — never serialize into HTTP responses, health payloads, or logs. | -> docs/architecture.md |
32
+ | `getCachedResolutionMeta()` | Values-free diagnostic view: `{ fetchedAt, pointerNames, pointerCount }` — the resolved pointer names only, never the secret values. Safe for logs and health endpoints. | -> docs/architecture.md |
33
+ | `resetSecretManagerForTests()` | Test-only — clears all module state and tears down dev watchers / timers. | -> docs/architecture.md |
34
+ | Type `SecretManagerConfig` | `{ url; token; source?; pointerPattern?; envNames?; allowInsecureHttp?; timeoutMs?; retries?; resolvePath?; headers?; onApplied?; onResolveError?; fetchImpl?; pollIntervalMs?; dev? }` where `dev` is `{ watch?; pollIntervalMs?; envFiles? }`. | -> docs/architecture.md |
35
+ | Type `SecretManagerToken` | `string \| { fromFile: string }`. | -> docs/architecture.md |
36
+ | Type `CachedResolution` | `{ fetchedAt: number; values: Record<string, string> }`. | -> docs/architecture.md |
37
+ | Type `CachedResolutionMeta` | `{ fetchedAt: number; pointerNames: string[]; pointerCount: number }`. | -> docs/architecture.md |
38
+
39
+ ### Internal helpers (not exported, listed for AI context)
40
+
41
+ | Helper | Role |
42
+ | --- | --- |
43
+ | `capturePointers(pattern)` | Scan `process.env` once, return `{ envName -> pointer }` for every value matching the pointer pattern. Captured once because the first resolve overwrites the value with the real secret (no longer pointer-shaped). |
44
+ | `resolveToken(token)` | Return the literal token, or read+trim the `{ fromFile }` file (read at resolve time so file rotation is picked up). |
45
+ | `fetchResolve(config, pointers)` | `POST ${url}/resolve` with `{ keys: pointers }` and `Authorization: Bearer <token>`. Throws on non-2xx or a missing `values` object. Honors `fetchImpl`. |
46
+ | `applyResolved(map, values, source)` | Overwrite `process.env[envName]` with the resolved value. In `'remote'` mode, fail fast (throw) if any pointer is unresolved BEFORE mutating; in `'hybrid'` warn per missing pointer and leave it as-is. |
47
+ | `parseEnvFile(content)` | In-package minimal `.env` parser (KEY=VALUE, full-line + inline comments, quoted values). Keeps the package dependency-free; used by the file-reload path. |
48
+ | `startDevReload(config)` | Opt-in (`config.dev` set) + non-production. Starts a debounced `fs.watch` on `dev.envFiles` (-> `reloadSecretManagerFromFiles`) and/or an interval poll (-> `refreshSecretManager`). Both channels swallow + warn on error so a transient failure never crashes dev. |
49
+
50
+ ## Boot-time contract (authoritative)
51
+
52
+ 1. Call `initSecretManager(...)` as the very first line of `server.ts`, before any other framework code reads `process.env`.
53
+ 2. `'local'` mode short-circuits — no network, no writes, no watchers.
54
+ 3. Otherwise it captures the pointer map once, `POST /resolve`s the unique pointers, and overwrites `process.env`.
55
+ 4. `'remote'`: a missing pointer or fetch error throws (hard boot stop). `'hybrid'`: warn and leave `process.env` as-is.
56
+ 5. When `config.dev` is set and not production, dev hot reload starts: a debounced watch on `dev.envFiles` re-parses the files (plain values injected, pointers re-resolved) and an optional interval poll re-resolves the current pointers.
57
+ 6. Only after `initSecretManager` resolves should other framework code read `process.env`.
58
+
59
+ ## Config keys
60
+
61
+ This package reads **no** env vars itself — it consumes `SecretManagerConfig` (typically built in `config.ts` and passed in `server.ts`). The values you commonly source from env / a file:
62
+
63
+ | Key | Purpose | Notes |
64
+ | --- | --- | --- |
65
+ | `url` | Base URL of the secret-manager server (trailing slash optional). | Required (except `source:'local'`). |
66
+ | `token` | Shared bearer token: literal string or `{ fromFile }` (gitignored single-line file). | Read at resolve time — file rotation picked up on next poll. A `Bearer ` prefix is stripped + warned. |
67
+ | `source` | `'remote'` (default) / `'local'` / `'hybrid'`. | `'remote'` = hard boot stop on failure; `'hybrid'` = warn + keep local env. |
68
+ | `envNames` | Allowlist of env-var NAMES eligible for resolution: `string[]` or `(name) => boolean`. **Secure default: unset resolves NOTHING off-host** (a boot warning is emitted so the deny-all is never silent). Pass `() => true` to scan every name deliberately. | Required to actually resolve anything. |
69
+ | `dev` | Opt-in dev hot reload: `{ watch?, pollIntervalMs?, envFiles? }`. Ignored in production (`NODE_ENV !== 'development'|'test'`). | — |
70
+
71
+ ### Advanced keys (direct-call-only — not needed for typical boot wiring)
72
+
73
+ | Key | Purpose |
74
+ | --- | --- |
75
+ | `pointerPattern` | Override the pointer-shape detector (default `/^(.+)_V(\d+)$/`). Stateful `g`/`y` flags are stripped automatically. |
76
+ | `allowInsecureHttp` | Permit `http:` to a non-loopback host. A loud warning is still emitted. Loopback is always permitted. |
77
+ | `timeoutMs` | Abort a black-hole server after N ms (default `10_000`). Set `0` to disable. |
78
+ | `retries` | `{ count, delayMs? }` — retry on transport error / non-2xx before giving up. Default `{ count: 0 }`. |
79
+ | `resolvePath` | Override the resolve endpoint path (default `'/resolve'`). |
80
+ | `headers` | Extra request headers merged onto every resolve request (cannot override `Authorization`). |
81
+ | `onApplied` | Called after secrets are written to `process.env` — receives changed env NAMES only (never the values). Use to re-create pools/SDK clients on rotation. |
82
+ | `onResolveError` | Called on resolve failure (alongside the existing `console.warn`). Route to Sentry/metrics; useful for `'hybrid'` where a silent warn is the default. |
83
+ | `fetchImpl` | Override the global `fetch`. For non-Node-20 hosts or test injection. |
84
+ | `pollIntervalMs` | Production rotation poll interval in ms (re-resolves in ALL environments). Default `0` (disabled). Timers are `unref`'d. |
85
+
86
+ `process.env` values matching `pointerPattern` (default `/^(.+)_V(\d+)$/`) are the pointers this client resolves and overwrites.
87
+
88
+ ## Design note — single resolver per process
89
+
90
+ `@luckystack/secret-manager` uses module-level state (`activeConfig`, `pointerMap`, `cachedResolution`, `resolveChain`). This is intentional: there is one canonical view of `process.env`, so a second parallel resolver would race against the first. If you need to resolve different configs separately (e.g. a multi-stage bootstrap), call `stopSecretManager()` + `resetSecretManagerForTests()` between them (the latter is exported but named for test clarity — it is safe to call in non-test bootstrap code too). Running two resolvers concurrently against the same `process.env` is unsupported.
91
+
92
+ ## Peer dependencies
93
+
94
+ - **None required.** Speaks plain HTTP via global `fetch`; reads the token file with Node's built-in `fs`.
95
+ - **Optional**: any `fetch` polyfill (e.g. `undici`) for non-Node-20 hosts — pass via `SecretManagerConfig.fetchImpl`.
96
+
97
+ Node `>= 20` is required because the default code path uses global `fetch`.
98
+
99
+ ## Related
100
+
101
+ - Concept overview + external-server wire contract: `./docs/architecture.md`.
102
+ - Consumer quickstart: `./README.md`.
103
+ - Framework-wide packaging map: `/docs/PACKAGE_OVERVIEW.md`.
104
+ - Architecture deep-dive: `/docs/ARCHITECTURE_SECRET_MANAGER.md`.
package/README.md CHANGED
@@ -1,123 +1,123 @@
1
- # @luckystack/secret-manager
2
-
3
- > Rotation-aware secret resolver client. Commit `.env` **pointers** instead of real secrets; resolve them against an external secret-manager server at boot. Part of [LuckyStack](https://github.com/ItsLucky23/LuckyStack-v2).
4
-
5
- `@luckystack/secret-manager` is the thin client that lives inside a LuckyStack app. Your committed `.env` holds pointers like `OPENAI_KEY=OPENAI_AUTHORIZATION_KEY_V5` — never the real secret. At boot the client collects every pointer-shaped value, asks the server to resolve them in one request, and overwrites `process.env` with the real values. Rotating a secret means publishing a new version (`..._V6`) on the server; old git branches that still point at `..._V5` keep booting.
6
-
7
- For the full design (pointer model, append-only versioning, the external server's wire contract), read [`docs/architecture.md`](./docs/architecture.md).
8
-
9
- ## Install
10
-
11
- ```bash
12
- npm install @luckystack/secret-manager
13
- ```
14
-
15
- Requires Node `>= 20` (uses global `fetch`). For older Node, inject a polyfill via `fetchImpl`.
16
-
17
- ## Quickstart
18
-
19
- Call `initSecretManager` as the very first line of your `server.ts`, before any other framework code reads `process.env`.
20
-
21
- ```ts
22
- import { initSecretManager } from '@luckystack/secret-manager';
23
- import { bootstrapLuckyStack } from '@luckystack/server';
24
-
25
- await initSecretManager({
26
- url: process.env.LUCKYSTACK_SECRET_MANAGER_URL!,
27
- token: { fromFile: '.secret-manager-token' }, // gitignored file, one line = the token
28
- source: 'hybrid', // try the server, keep local env on failure
29
- envNames: ['OPENAI_KEY', 'STRIPE_KEY'], // REQUIRED — allowlist of names to resolve
30
- });
31
-
32
- const server = await bootstrapLuckyStack({ /* ... */ });
33
- await server.listen();
34
- ```
35
-
36
- > **`envNames` is required to resolve anything.** It is the allowlist of `process.env`
37
- > NAMES that are eligible for resolution. **Secure default: when `envNames` is unset,
38
- > NOTHING is resolved off-host** — the client logs a one-time boot warning and leaves
39
- > every value as-is (a deny-all no-op). This prevents the client from POSTing an
40
- > unrelated, pointer-shaped inherited value (e.g. a CI `RELEASE_TAG=build_2024_V2`) to
41
- > the server. See [Scoping resolution with `envNames`](#scoping-resolution-with-envnames).
42
-
43
- In your committed `.env`:
44
-
45
- ```
46
- OPENAI_KEY=OPENAI_AUTHORIZATION_KEY_V5
47
- STRIPE_KEY=STRIPE_SECRET_KEY_V2
48
- ```
49
-
50
- After `initSecretManager` resolves, `process.env.OPENAI_KEY` holds the real `sk-...` value.
51
-
52
- ## Modes
53
-
54
- | `source` | Behavior |
55
- | --- | --- |
56
- | `'remote'` (default) | Resolve from the server. A missing pointer or an unreachable server **throws** — production hard-stop. |
57
- | `'local'` | No network. Pointers are left untouched. Use in tests + offline dev. |
58
- | `'hybrid'` | Try the server; on failure warn and leave whatever `process.env` already holds. |
59
-
60
- A value that is **not** pointer-shaped (e.g. `NODE_ENV=production`, or a real secret you pasted locally) is treated as a literal and never sent to the server — so local overrides win automatically.
61
-
62
- ## Scoping resolution with `envNames`
63
-
64
- `envNames` is the allowlist of `process.env` NAMES the client is allowed to resolve. **It is required to resolve anything** — pass an array of names or a predicate:
65
-
66
- ```ts
67
- envNames: ['OPENAI_KEY', 'STRIPE_KEY'] // explicit allowlist (recommended)
68
- envNames: (name) => name.endsWith('_KEY') // or a predicate
69
- envNames: () => true // deliberately scan EVERY env name
70
- ```
71
-
72
- **Secure default — deny-all.** When `envNames` is unset, **nothing is resolved off-host**: the client emits a one-time boot warning and leaves every value as-is. This is intentional. Without a name allowlist the resolver would scan the entire inherited environment and POST any unrelated, pointer-shaped value (a CI `RELEASE_TAG=build_2024_V2`, a shell `TERM`-like `..._V2`) to the secret-manager server. So you must opt in explicitly — either list the names you control, or pass `() => true` as a deliberate, auditable choice to scan everything.
73
-
74
- > If you call `initSecretManager` and see `[secret-manager] \`envNames\` is not set: NO environment values will be resolved off-host`, that is this guard: add `envNames`.
75
-
76
- ## Dev hot reload (opt-in)
77
-
78
- Pass a `dev` object to live-reload while a long-running dev process is up. Ignored when `NODE_ENV === 'production'`.
79
-
80
- ```ts
81
- await initSecretManager({
82
- url: process.env.LUCKYSTACK_SECRET_MANAGER_URL!,
83
- token: { fromFile: '.secret-manager-token' },
84
- dev: {
85
- watch: true, // re-read .env / .env.local on change (default true)
86
- pollIntervalMs: 5000, // also re-resolve every 5s (default off)
87
- // envFiles: ['.env', '.env.local'], // override which files are watched
88
- },
89
- });
90
- ```
91
-
92
- - **On file change** (`watch`): the env files are re-parsed and applied — plain values (e.g. `ENVIRONMENT=production`, `PORT=123`) are injected straight into `process.env` (live config reload), and pointer-shaped values are re-resolved against the server. A pointer added or bumped after boot is picked up here — no restart.
93
- - **On the poll interval** (`pollIntervalMs`): the current pointers are re-resolved, catching server-side rotations. The interval lives in your `config.ts`, so it's changeable in one place.
94
-
95
- So `.env` (plain config) and `.env.local` (pointers/secrets) both live-reload — `.env` values are injected as-is, `.env.local` pointers are resolved from the server.
96
-
97
- ## Public API
98
-
99
- | Export | Purpose |
100
- | --- | --- |
101
- | `initSecretManager(config)` | Boot-time entry point. Resolves pointer-shaped env values and writes the real values into `process.env`. |
102
- | `refreshSecretManager()` | Re-resolve the captured pointers against the server (the poll channel; call manually after an admin rotates a secret). |
103
- | `reloadSecretManagerFromFiles()` | Re-parse the configured env files and apply them — plain values injected, pointers resolved. The file-watch channel; callable manually. |
104
- | `getCachedResolution()` | Returns a shallow copy of the last `{ fetchedAt, values }` resolution (pointer -> value), or `null`. **⚠️ SENSITIVE** — `values` are the RAW resolved secrets; never serialize into an HTTP response, a `/health` payload, or a log line. For a safe diagnostic use `getCachedResolutionMeta()`. |
105
- | `getCachedResolutionMeta()` | Values-free diagnostic view: `{ fetchedAt, pointerNames, pointerCount }` — resolved pointer NAMES only, never the secret values. Safe for logs and `/health` endpoints. |
106
- | `resetSecretManagerForTests()` | Test-only — clears module state and tears down dev watchers / timers. |
107
-
108
- ## The token file
109
-
110
- The shared bearer token is the only real secret on the developer machine. Keep it in a single-line file (e.g. `.secret-manager-token`) that is **gitignored**, and reference it with `token: { fromFile: '.secret-manager-token' }`. CI runners can inject the file from their secret store. You may also pass the token as a literal string if you read it from your own secret source.
111
-
112
- ## Peer dependencies
113
-
114
- - **None.** This package speaks plain HTTP via global `fetch` and reads the token file with Node's built-in `fs`.
115
- - **Optional**: any `fetch` polyfill (e.g. `undici`) for non-Node-20 hosts — pass via `SecretManagerConfig.fetchImpl`.
116
-
117
- ## Documentation
118
-
119
- - [`docs/architecture.md`](./docs/architecture.md) — pointer model, append-only versioning, the external server's `POST /resolve` wire contract, and what this package does NOT do.
120
-
121
- ## License
122
-
123
- MIT — see [LICENSE](./LICENSE).
1
+ # @luckystack/secret-manager
2
+
3
+ > Rotation-aware secret resolver client. Commit `.env` **pointers** instead of real secrets; resolve them against an external secret-manager server at boot. Part of [LuckyStack](https://github.com/ItsLucky23/LuckyStack-v2).
4
+
5
+ `@luckystack/secret-manager` is the thin client that lives inside a LuckyStack app. Your committed `.env` holds pointers like `OPENAI_KEY=OPENAI_AUTHORIZATION_KEY_V5` — never the real secret. At boot the client collects every pointer-shaped value, asks the server to resolve them in one request, and overwrites `process.env` with the real values. Rotating a secret means publishing a new version (`..._V6`) on the server; old git branches that still point at `..._V5` keep booting.
6
+
7
+ For the full design (pointer model, append-only versioning, the external server's wire contract), read [`docs/architecture.md`](./docs/architecture.md).
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install @luckystack/secret-manager
13
+ ```
14
+
15
+ Requires Node `>= 20` (uses global `fetch`). For older Node, inject a polyfill via `fetchImpl`.
16
+
17
+ ## Quickstart
18
+
19
+ Call `initSecretManager` as the very first line of your `server.ts`, before any other framework code reads `process.env`.
20
+
21
+ ```ts
22
+ import { initSecretManager } from '@luckystack/secret-manager';
23
+ import { bootstrapLuckyStack } from '@luckystack/server';
24
+
25
+ await initSecretManager({
26
+ url: process.env.LUCKYSTACK_SECRET_MANAGER_URL!,
27
+ token: { fromFile: '.secret-manager-token' }, // gitignored file, one line = the token
28
+ source: 'hybrid', // try the server, keep local env on failure
29
+ envNames: ['OPENAI_KEY', 'STRIPE_KEY'], // REQUIRED — allowlist of names to resolve
30
+ });
31
+
32
+ const server = await bootstrapLuckyStack({ /* ... */ });
33
+ await server.listen();
34
+ ```
35
+
36
+ > **`envNames` is required to resolve anything.** It is the allowlist of `process.env`
37
+ > NAMES that are eligible for resolution. **Secure default: when `envNames` is unset,
38
+ > NOTHING is resolved off-host** — the client logs a one-time boot warning and leaves
39
+ > every value as-is (a deny-all no-op). This prevents the client from POSTing an
40
+ > unrelated, pointer-shaped inherited value (e.g. a CI `RELEASE_TAG=build_2024_V2`) to
41
+ > the server. See [Scoping resolution with `envNames`](#scoping-resolution-with-envnames).
42
+
43
+ In your committed `.env`:
44
+
45
+ ```
46
+ OPENAI_KEY=OPENAI_AUTHORIZATION_KEY_V5
47
+ STRIPE_KEY=STRIPE_SECRET_KEY_V2
48
+ ```
49
+
50
+ After `initSecretManager` resolves, `process.env.OPENAI_KEY` holds the real `sk-...` value.
51
+
52
+ ## Modes
53
+
54
+ | `source` | Behavior |
55
+ | --- | --- |
56
+ | `'remote'` (default) | Resolve from the server. A missing pointer or an unreachable server **throws** — production hard-stop. |
57
+ | `'local'` | No network. Pointers are left untouched. Use in tests + offline dev. |
58
+ | `'hybrid'` | Try the server; on failure warn and leave whatever `process.env` already holds. |
59
+
60
+ A value that is **not** pointer-shaped (e.g. `NODE_ENV=production`, or a real secret you pasted locally) is treated as a literal and never sent to the server — so local overrides win automatically.
61
+
62
+ ## Scoping resolution with `envNames`
63
+
64
+ `envNames` is the allowlist of `process.env` NAMES the client is allowed to resolve. **It is required to resolve anything** — pass an array of names or a predicate:
65
+
66
+ ```ts
67
+ envNames: ['OPENAI_KEY', 'STRIPE_KEY'] // explicit allowlist (recommended)
68
+ envNames: (name) => name.endsWith('_KEY') // or a predicate
69
+ envNames: () => true // deliberately scan EVERY env name
70
+ ```
71
+
72
+ **Secure default — deny-all.** When `envNames` is unset, **nothing is resolved off-host**: the client emits a one-time boot warning and leaves every value as-is. This is intentional. Without a name allowlist the resolver would scan the entire inherited environment and POST any unrelated, pointer-shaped value (a CI `RELEASE_TAG=build_2024_V2`, a shell `TERM`-like `..._V2`) to the secret-manager server. So you must opt in explicitly — either list the names you control, or pass `() => true` as a deliberate, auditable choice to scan everything.
73
+
74
+ > If you call `initSecretManager` and see `[secret-manager] \`envNames\` is not set: NO environment values will be resolved off-host`, that is this guard: add `envNames`.
75
+
76
+ ## Dev hot reload (opt-in)
77
+
78
+ Pass a `dev` object to live-reload while a long-running dev process is up. Ignored when `NODE_ENV === 'production'`.
79
+
80
+ ```ts
81
+ await initSecretManager({
82
+ url: process.env.LUCKYSTACK_SECRET_MANAGER_URL!,
83
+ token: { fromFile: '.secret-manager-token' },
84
+ dev: {
85
+ watch: true, // re-read .env / .env.local on change (default true)
86
+ pollIntervalMs: 5000, // also re-resolve every 5s (default off)
87
+ // envFiles: ['.env', '.env.local'], // override which files are watched
88
+ },
89
+ });
90
+ ```
91
+
92
+ - **On file change** (`watch`): the env files are re-parsed and applied — plain values (e.g. `ENVIRONMENT=production`, `PORT=123`) are injected straight into `process.env` (live config reload), and pointer-shaped values are re-resolved against the server. A pointer added or bumped after boot is picked up here — no restart.
93
+ - **On the poll interval** (`pollIntervalMs`): the current pointers are re-resolved, catching server-side rotations. The interval lives in your `config.ts`, so it's changeable in one place.
94
+
95
+ So `.env` (plain config) and `.env.local` (pointers/secrets) both live-reload — `.env` values are injected as-is, `.env.local` pointers are resolved from the server.
96
+
97
+ ## Public API
98
+
99
+ | Export | Purpose |
100
+ | --- | --- |
101
+ | `initSecretManager(config)` | Boot-time entry point. Resolves pointer-shaped env values and writes the real values into `process.env`. |
102
+ | `refreshSecretManager()` | Re-resolve the captured pointers against the server (the poll channel; call manually after an admin rotates a secret). |
103
+ | `reloadSecretManagerFromFiles()` | Re-parse the configured env files and apply them — plain values injected, pointers resolved. The file-watch channel; callable manually. |
104
+ | `getCachedResolution()` | Returns a shallow copy of the last `{ fetchedAt, values }` resolution (pointer -> value), or `null`. **⚠️ SENSITIVE** — `values` are the RAW resolved secrets; never serialize into an HTTP response, a `/health` payload, or a log line. For a safe diagnostic use `getCachedResolutionMeta()`. |
105
+ | `getCachedResolutionMeta()` | Values-free diagnostic view: `{ fetchedAt, pointerNames, pointerCount }` — resolved pointer NAMES only, never the secret values. Safe for logs and `/health` endpoints. |
106
+ | `resetSecretManagerForTests()` | Test-only — clears module state and tears down dev watchers / timers. |
107
+
108
+ ## The token file
109
+
110
+ The shared bearer token is the only real secret on the developer machine. Keep it in a single-line file (e.g. `.secret-manager-token`) that is **gitignored**, and reference it with `token: { fromFile: '.secret-manager-token' }`. CI runners can inject the file from their secret store. You may also pass the token as a literal string if you read it from your own secret source.
111
+
112
+ ## Peer dependencies
113
+
114
+ - **None.** This package speaks plain HTTP via global `fetch` and reads the token file with Node's built-in `fs`.
115
+ - **Optional**: any `fetch` polyfill (e.g. `undici`) for non-Node-20 hosts — pass via `SecretManagerConfig.fetchImpl`.
116
+
117
+ ## Documentation
118
+
119
+ - [`docs/architecture.md`](./docs/architecture.md) — pointer model, append-only versioning, the external server's `POST /resolve` wire contract, and what this package does NOT do.
120
+
121
+ ## License
122
+
123
+ MIT — see [LICENSE](./LICENSE).
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\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":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["//? @luckystack/secret-manager — rotation-aware secret resolver client.\n//?\n//? Idea: secrets live in a central secret-manager server (append-only,\n//? versioned, one shared bearer token). Apps commit their `.env` to git, but\n//? instead of real secrets it holds POINTERS:\n//?\n//? OPENAI_KEY=OPENAI_AUTHORIZATION_KEY_V5\n//?\n//? A pointer has the shape `<BASE>_V<number>`. At boot this client scans\n//? `process.env`, collects every pointer-shaped value, asks the server to\n//? resolve them in ONE request, and overwrites each `process.env` entry with\n//? the real secret — so downstream code reads `process.env.OPENAI_KEY` and gets\n//? the resolved value, not the pointer. Rotating a secret means publishing a\n//? new version (`..._V6`) on the server, never editing an old one, so old git\n//? branches that still point at `..._V5` keep booting.\n//?\n//? Three modes:\n//? 1. `source: 'remote'` (default) — resolve from the server. A missing\n//? pointer or an unreachable server throws — production hard-stop.\n//? 2. `source: 'local'` — no network. Pointers are left untouched. Use in\n//? tests / offline dev when the server isn't reachable.\n//? 3. `source: 'hybrid'` — try the server, but on failure warn and leave\n//? whatever `process.env` already holds. Use for staging / canary.\n//?\n//? Optional dev-only hot reload (`config.dev`) re-resolves on `.env` file\n//? changes and/or on an interval, so server-side rotations are picked up\n//? without restarting a long-running dev process. It is a no-op in production.\n\nimport { readFileSync, watch as fsWatch, type FSWatcher } from 'node:fs';\nimport path from 'node:path';\n\nimport { resolveEnvKey, sleep, tryCatchSync } from '@luckystack/core';\n\n/**\n * Bearer token: a literal string, or a file whose entire contents are the token.\n *\n * When using `{ fromFile }`, the path MUST NOT be derived from untrusted input.\n * No path-traversal check is applied — the caller is responsible for ensuring\n * the path is a fixed, gitignored file (e.g. `.secret-manager-token`) next to\n * the project root. Paths like `/etc/passwd` or `../../.ssh/id_rsa` would be\n * read without error.\n */\nexport type SecretManagerToken = string | { fromFile: string };\n\nexport interface SecretManagerConfig {\n /** Base URL of the secret-manager server (trailing slash optional). */\n url: string;\n /**\n * Shared bearer token. Either the literal string or `{ fromFile }` pointing\n * at a gitignored file whose entire contents are the token (read at resolve\n * time, so rotating the file is picked up by the next poll / refresh).\n */\n token: SecretManagerToken;\n /** Resolution mode. Default `'remote'`. */\n source?: 'remote' | 'local' | 'hybrid';\n /**\n * Override the pointer-shape detector. Any `process.env` value matching this\n * is treated as a pointer; anything else is a literal and left untouched.\n * Default `/^(.+)_V(\\d+)$/`. A `g`/`y` flag is stripped (a stateful regex would\n * misclassify alternating entries).\n */\n pointerPattern?: RegExp;\n /**\n * Scope which `process.env` entries are pointer-eligible by NAME. This allowlist\n * is REQUIRED to resolve anything off-host: an array of names or a predicate.\n *\n * SECURE DEFAULT: when unset, NOTHING is resolved off-host (a clear boot warning\n * is emitted instead). Scanning the entire inherited environment would POST any\n * unrelated, pointer-shaped inherited value (`RELEASE_TAG=build_2024_V2`) to the\n * secret-manager server — so an explicit allowlist is mandatory. To opt back into\n * scanning every name, pass `() => true` as a deliberate, auditable choice.\n */\n envNames?: string[] | ((name: string) => boolean);\n /**\n * Allow a plain-`http:` server `url`. By default only `https:` is accepted for\n * non-loopback hosts (the channel carries the bearer token + plaintext secrets);\n * loopback (`localhost`/`127.0.0.1`/`[::1]`) is always permitted. Set `true` to\n * permit `http:` to any host — a loud warning is still emitted.\n */\n allowInsecureHttp?: boolean;\n /**\n * Abort a resolve request that has not responded within this many ms. Prevents a\n * black-hole server (accepts the TCP connection, never responds) from hanging boot\n * until undici's ~300s default — and, in `'hybrid'`, from never reaching the\n * warn-and-keep-local fallback (a hang never rejects). Default `10_000`. Set `0`\n * to disable the timeout.\n */\n timeoutMs?: number;\n /**\n * Retry a failed resolve before giving up (transport error or non-2xx). Default\n * `{ count: 0 }` (no retry). After exhaustion `'remote'` still throws and\n * `'hybrid'` still warns-and-keeps-local.\n */\n retries?: { count: number; delayMs?: number };\n /** Override the resolve path appended to `url`. Default `'/resolve'`. */\n resolvePath?: string;\n /** Extra request headers merged onto every resolve request (cannot override `Authorization`). */\n headers?: Record<string, string>;\n /**\n * Called after a resolve writes new values into `process.env`, with ONLY the env\n * NAMES whose value actually changed (never the secret values). A client that\n * captured a secret at construction time (Prisma from `DATABASE_URL`, a Redis /\n * Stripe / OpenAI SDK) can re-create its pool/client here so a rotation lands.\n */\n onApplied?: (changes: { name: string; pointer: string }[]) => void | Promise<void>;\n /**\n * Called when a resolve fails, alongside the existing `console.warn` (current\n * behavior is unchanged when unset). Route the failure to Sentry/metrics —\n * useful for `'hybrid'`/dev where a failure otherwise silently keeps stale env.\n */\n onResolveError?: (error: unknown, context: { phase: 'boot' | 'refresh' | 'file-reload' }) => void;\n /** Override the global `fetch` (tests / non-Node-20 hosts). */\n fetchImpl?: typeof fetch;\n /**\n * Re-resolve the current pointers every N ms in ALL environments (the production\n * rotation poll), distinct from the dev-only `dev.pollIntervalMs` file-watch\n * channel. Default `0` (disabled). Unref'd, so it never blocks process exit.\n */\n pollIntervalMs?: number;\n /**\n * Opt-in dev-only hot reload. Ignored when `NODE_ENV === 'production'`.\n * Provide an (even empty) object to enable it.\n */\n dev?: {\n /**\n * Watch the env files and hot-reload on change. Default `true`. On change\n * the files are re-parsed and applied: plain (non-pointer) values are\n * injected straight into `process.env` (live config reload), and\n * pointer-shaped values are re-resolved against the server.\n */\n watch?: boolean;\n /** Re-resolve the current pointers every N ms (server-rotation poll). Default `0` (disabled). */\n pollIntervalMs?: number;\n /** Env files to watch + reparse, in load order (later overrides earlier). Default `['.env', '.env.local']`. */\n envFiles?: string[];\n };\n}\n\nexport interface CachedResolution {\n /** `Date.now()` of the last successful resolve. */\n fetchedAt: number;\n /** Map of pointer string -> resolved value (what the server returned). */\n values: Record<string, string>;\n}\n\nconst DEFAULT_POINTER_PATTERN = /^(.+)_V(\\d+)$/;\nconst DEFAULT_ENV_FILES = ['.env', '.env.local'];\n\n//? Dev hot-reload reads these files and injects their values into `process.env`.\n//? `dev.envFiles` is consumer config (not runtime user input), so an ABSOLUTE\n//? path is treated as an explicit, allowed choice (e.g. a shared secrets file on\n//? a developer machine). A RELATIVE path, however, must stay within the project\n//? root — reject `..` traversal (the plausible \"injected via a relative path\"\n//? escape). The caller skips + warns on a rejected entry (fail-open, consistent\n//? with the package's swallow-on-missing-file behaviour).\n//? Note: absolute paths are accepted without further validation. Consumers who\n//? configure `dev.envFiles` with absolute paths take responsibility for ensuring\n//? those paths are appropriate (e.g. a shared developer-machine secrets file).\n//? A loud warn is emitted so the choice is never silent in logs.\nconst isSafeEnvFile = (file: string, warnAbsolute = false): boolean => {\n if (path.isAbsolute(file)) {\n if (warnAbsolute) {\n console.warn(\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.`,\n );\n }\n return true;\n }\n const rel = path.relative(process.cwd(), path.resolve(process.cwd(), file));\n return !rel.startsWith('..');\n};\n\n//? Module state. The resolver is meant to run once per process at boot; these\n//? bindings keep it idempotent and let dev hot-reload re-resolve later.\nlet cachedResolution: CachedResolution | null = null;\n//? envName -> pointer string, captured once on first resolve. Reused on every\n//? refresh because the first resolve OVERWRITES the env value with the real\n//? secret, after which it no longer looks like a pointer.\nlet pointerMap: Record<string, string> | null = null;\nlet activeConfig: SecretManagerConfig | null = null;\n\n//? In-flight resolve, used to SERIALIZE concurrent resolves. Four channels can\n//? funnel into `doResolve` (boot, the production rotation poll, the dev poll, the\n//? dev file-watch + a manual `refreshSecretManager()`); without a guard a slow\n//? in-flight resolve can land AFTER a newer one, leaving `process.env` and\n//? `cachedResolution` disagreeing (stale secrets). Each call awaits the previous\n//? one's settlement before starting, so resolves apply strictly in order.\nlet resolveChain: Promise<void> = Promise.resolve();\n\n//? Dev hot-reload + rotation-poll handles, torn down by stopSecretManager.\nlet devReloadStarted = false;\nlet pollTimer: ReturnType<typeof setInterval> | null = null;\nlet rotationPollTimer: ReturnType<typeof setInterval> | null = null;\nlet debounceTimer: ReturnType<typeof setTimeout> | null = null;\nconst fileWatchers: FSWatcher[] = [];\n\n//? A consumer-supplied `pointerPattern` with a `g`/`y` flag makes `.test()`\n//? stateful (advances `lastIndex`), silently misclassifying alternating entries.\n//? Strip those flags up front; everything else is preserved.\nconst stripStatefulFlags = (pattern: RegExp): RegExp => {\n const flags = pattern.flags.replaceAll(/[gy]/g, '');\n return flags === pattern.flags ? pattern : new RegExp(pattern.source, flags);\n};\n\n//? No-op used to settle the in-flight resolve chain tail regardless of outcome.\nconst noop = (): void => {\n /* intentionally empty */\n};\n\n//? Reduce any thrown value to a safe message string for logging — never log the\n//? raw error object (its `cause`/own-properties can carry a URL/token/PII string).\nconst errorMessage = (error: unknown): string =>\n error instanceof Error ? error.message : String(error);\n\n//? Invoke a consumer callback in isolation: a throwing hook must never abort an\n//? otherwise-successful resolve or mask the original error. Failures are warned,\n//? not propagated.\nconst runHook = (fn: () => void, label: string): void => {\n const [error] = tryCatchSync(fn);\n if (error) console.warn(`[secret-manager] ${label} callback threw (ignored):`, errorMessage(error));\n};\n\n//? Bound how long an async consumer hook may run before the resolve chain stops\n//? waiting on it. A hook that NEVER resolves (a stuck `onApplied` awaiting a dead\n//? pool) would otherwise wedge `resolveChain` forever, so every later resolve\n//? (boot poll, dev watch, manual refresh) deadlocks behind it.\nconst HOOK_TIMEOUT_MS = 30_000;\n\n//? Async sibling of `runHook` for a callback that may return a Promise (onApplied\n//? re-creates pools/SDK clients). Awaited but isolated AND time-bounded — a\n//? rejection/throw is warned (never propagated, so the resolve that already\n//? applied stays successful), and a hook that HANGS is abandoned after\n//? `timeoutMs` via a `Promise.race` (mirrors the fetch `AbortSignal.timeout`\n//? black-hole guard) so a stuck hook can't deadlock the serialized resolve chain.\n//? The race only stops AWAITING the hook — it cannot cancel the consumer's\n//? promise, so a still-running hook keeps going in the background; we just no\n//? longer block on it. `timeoutMs <= 0` disables the bound.\n//? CC-7 exemption (no-raw-try-catch): `tryCatchSync` can't wrap an `await`, and\n//? the async framework `tryCatch` would auto-capture to the error tracker — a\n//? deliberate non-goal here (a consumer hook failure must stay a local warn in\n//? this dependency-light client, not a tracked event).\nconst runHookAsync = async (\n fn: () => void | Promise<void>,\n label: string,\n timeoutMs = HOOK_TIMEOUT_MS,\n): Promise<void> => {\n try {\n if (timeoutMs <= 0) {\n await fn();\n return;\n }\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timeout = new Promise<void>((resolve) => {\n timer = setTimeout(() => {\n console.warn(\n `[secret-manager] ${label} callback did not settle within ${String(timeoutMs)}ms; continuing without waiting (the hook may still be running in the background).`,\n );\n resolve();\n }, timeoutMs);\n timer.unref();\n });\n try {\n await Promise.race([Promise.resolve(fn()), timeout]);\n } finally {\n if (timer) clearTimeout(timer);\n }\n } catch (error) {\n console.warn(`[secret-manager] ${label} callback threw (ignored):`, errorMessage(error));\n }\n};\n\n//? RFC-style loopback hosts: plain http to these never leaves the machine, so it\n//? is always permitted regardless of `allowInsecureHttp`.\nconst isLoopbackHost = (hostname: string): boolean =>\n hostname === 'localhost' ||\n hostname === '127.0.0.1' ||\n hostname === '::1' ||\n hostname === '[::1]';\n\nconst validateUrl = (url: string, allowInsecureHttp: boolean): void => {\n //? Reject relative / non-http(s) URLs (e.g. `file://`) up front so the resolve\n //? endpoint can't be pointed at the local filesystem or another protocol.\n const [parseError, parsed] = tryCatchSync(() => new URL(url));\n if (parseError || !parsed) {\n throw new Error(`[secret-manager] Invalid \\`url\\`: \"${url}\" is not an absolute URL.`);\n }\n if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {\n throw new Error(`[secret-manager] Invalid \\`url\\` scheme \"${parsed.protocol}\": only http(s) is supported.`);\n }\n //? The channel carries the bearer token + plaintext secrets, so plain http is a\n //? cleartext leak. Permit it only for loopback, or behind an explicit opt-in\n //? (still warned loudly) — otherwise reject before any secret is sent.\n if (parsed.protocol === 'http:' && !isLoopbackHost(parsed.hostname)) {\n if (!allowInsecureHttp) {\n throw new Error(\n `[secret-manager] Refusing plain-http \\`url\\` \"${url}\": the bearer token and resolved secrets would travel in cleartext. Use https, point at a loopback host, or set \\`allowInsecureHttp: true\\` to override.`,\n );\n }\n console.warn(\n `[secret-manager] Using plain-http transport to a non-loopback host (\"${parsed.hostname}\"): the bearer token and resolved secrets travel in CLEARTEXT. Prefer https.`,\n );\n }\n};\n\n//? Build the name-eligibility predicate from `envNames` (allowlist array or\n//? predicate). SECURE DEFAULT: when `envNames` is unset, NOTHING is eligible —\n//? the resolver must never scan the whole inherited environment and POST every\n//? pointer-shaped value off-host. An explicit allowlist (or `() => true`) is\n//? required to opt in. The boot-time warning for the unset case is emitted by\n//? `warnIfEnvNamesUnset` at the resolve path.\nconst toNameFilter = (\n envNames: SecretManagerConfig['envNames'],\n): ((name: string) => boolean) => {\n if (envNames === undefined) return () => false;\n if (typeof envNames === 'function') return envNames;\n const allow = new Set(envNames);\n return (name) => allow.has(name);\n};\n\n//? Warn ONCE per process when `envNames` is unset: the resolver is deny-all in\n//? that state, so an operator who expected secrets to resolve gets a clear,\n//? actionable boot message instead of a silent no-op. Guarded so the production\n//? rotation poll (`refreshSecretManager` on an interval) doesn't re-emit it every\n//? cycle and flood the log sink.\nlet warnedEnvNamesUnset = false;\nconst warnIfEnvNamesUnset = (envNames: SecretManagerConfig['envNames']): void => {\n if (envNames !== undefined || warnedEnvNamesUnset) return;\n warnedEnvNamesUnset = true;\n console.warn(\n '[secret-manager] `envNames` is not set: NO environment values will be resolved off-host. Set `envNames` to an allowlist of the env names to resolve (or `() => true` to deliberately scan every name).',\n );\n};\n\n//? Split a set of `{ name -> value }` entries into pointer-shaped vs plain\n//? values, applying the `envNames` allowlist FIRST so a name excluded by\n//? `envNames` is treated as neither a pointer nor a plain value (it is dropped\n//? entirely). Shared by both ingest paths (boot `capturePointers` + the dev\n//? file-reload split) so the scoping rule can never drift between them.\nconst splitPointers = (\n entries: Iterable<[string, string]>,\n pattern: RegExp,\n envNames: SecretManagerConfig['envNames'],\n): { pointers: Record<string, string>; plain: Record<string, string> } => {\n const nameAllowed = toNameFilter(envNames);\n const pointers: Record<string, string> = {};\n const plain: Record<string, string> = {};\n for (const [name, value] of entries) {\n if (!nameAllowed(name)) continue;\n if (pattern.test(value)) pointers[name] = value;\n else plain[name] = value;\n }\n return { pointers, plain };\n};\n\nconst capturePointers = (\n pattern: RegExp,\n envNames: SecretManagerConfig['envNames'],\n): Record<string, string> => {\n const entries: [string, string][] = [];\n for (const [name, value] of Object.entries(process.env)) {\n if (typeof value === 'string') entries.push([name, value]);\n }\n return splitPointers(entries, pattern, envNames).pointers;\n};\n\nconst validateToken = (token: string): string => {\n //? An empty/whitespace token yields an `Authorization: Bearer ` header that\n //? silently auth-fails (and in hybrid mode falls back to local env) — reject it.\n const trimmed = token.trim();\n if (trimmed.length === 0) {\n throw new Error('[secret-manager] Bearer token is empty or whitespace-only.');\n }\n //? The `Bearer ` scheme is added at the call site. A token that already carries\n //? it would produce a malformed double-prefix `Bearer Bearer <...>` header —\n //? strip the redundant prefix (case-insensitive) and warn so the operator knows\n //? their config is wrong. Stripping is the forgiving path: a warn-but-pass-through\n //? would silently break every request, making this a very hard-to-debug boot issue.\n if (/^bearer\\s/i.test(trimmed)) {\n const stripped = trimmed.replace(/^bearer\\s+/i, '');\n console.warn(\n '[secret-manager] Token starts with a \"Bearer \" prefix; the scheme is added automatically — the prefix was stripped. Drop it from your configured token to silence this warning.',\n );\n return stripped;\n }\n return trimmed;\n};\n\nconst resolveToken = (token: SecretManagerToken): string => {\n if (typeof token === 'string') return validateToken(token);\n //? Distinguish a missing/deleted file from other I/O errors so a dev\n //? hot-reload poll over a transiently-absent token file gives a clear log.\n //? Note: error messages include `token.fromFile`. In hybrid mode these propagate\n //? to console.warn via `errorMessage(error)` (line ~597). If the path contains\n //? sensitive directory names (e.g. /home/admin/.keys/prod-token), those names\n //? will appear in log aggregators. Treat the token file path as infrastructure\n //? metadata and ensure your log sink is appropriately access-controlled.\n const [readError, raw] = tryCatchSync(() => readFileSync(token.fromFile, 'utf8'));\n if (readError) {\n const code = readError instanceof Error && 'code' in readError ? (readError as { code?: string }).code : undefined;\n if (code === 'ENOENT') {\n throw new Error(`[secret-manager] Token file not found: \"${token.fromFile}\".`);\n }\n throw new Error(`[secret-manager] Failed to read token file \"${token.fromFile}\": ${errorMessage(readError)}`);\n }\n if (raw === null) {\n throw new Error(`[secret-manager] Token file \"${token.fromFile}\" could not be read.`);\n }\n return validateToken(raw);\n};\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\n\n//? Hard cap on the resolve response body. The server returns a flat\n//? `{ pointer -> secret }` map; even hundreds of secrets stay well under 1 MB.\n//? A response larger than this is a compromised/buggy server (or a MITM on an\n//? `allowInsecureHttp` channel) and is rejected BEFORE it can OOM the client.\nconst MAX_RESOLVE_BODY_BYTES = 1_048_576;\n\n//? Read the response body as text, aborting once more than `maxBytes` have been\n//? received — so a server that lies about (or omits) Content-Length still can't\n//? stream an unbounded body into memory. Falls back to `response.text()` when the\n//? body isn't a readable stream (e.g. a mocked Response).\nconst readBodyCapped = async (response: Response, maxBytes: number): Promise<string> => {\n const body = response.body;\n if (!body) return response.text();\n const reader = body.getReader();\n const decoder = new TextDecoder();\n let received = 0;\n let out = '';\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n received += value.byteLength;\n if (received > maxBytes) {\n void reader.cancel();\n throw new Error(`[secret-manager] Resolve response exceeded the ${String(maxBytes)}-byte cap.`);\n }\n out += decoder.decode(value, { stream: true });\n }\n out += decoder.decode();\n return out;\n};\n\n//? Drop any case-variant of `authorization` from consumer-supplied headers so the\n//? framework bearer token always wins. A plain record passed as `fetch` `headers`\n//? is filled with `append` (per the Fetch spec), so a lowercase `authorization`\n//? key would NOT be overwritten by the later `Authorization` entry — both survive\n//? and combine into `Bearer <consumer>, Bearer <framework>`. A server reading the\n//? first token would then honour the consumer's value, bypassing the documented\n//? \"cannot override Authorization\" guard. Stripping here makes the guard real for\n//? every casing; all other consumer headers are preserved untouched.\nconst stripAuthorizationHeaders = (\n headers: Record<string, string> | undefined,\n): Record<string, string> => {\n const out: Record<string, string> = {};\n if (!headers) return out;\n for (const [key, value] of Object.entries(headers)) {\n if (key.toLowerCase() === 'authorization') continue;\n out[key] = value;\n }\n return out;\n};\n\n//? One resolve round-trip: POST the pointers, validate the response, return the\n//? filtered `{ pointer -> value }` map. No retry/timeout orchestration here — the\n//? caller (`fetchResolve`) owns that so a hang can't slip past the abort signal.\nconst fetchResolveOnce = async (\n config: SecretManagerConfig,\n pointers: string[],\n): Promise<Record<string, string>> => {\n //? Defaults to the Node 20+ global fetch; pass fetchImpl for older hosts.\n const fetchFn = config.fetchImpl ?? globalThis.fetch;\n const resolvePath = config.resolvePath ?? '/resolve';\n const suffix = resolvePath.startsWith('/') ? resolvePath : `/${resolvePath}`;\n const endpoint = `${config.url.replace(/\\/+$/, '')}${suffix}`;\n\n //? Abort a black-hole server (accepts the TCP connection, never responds) so a\n //? hang surfaces as a rejection — boot can't freeze, and 'hybrid' reaches its\n //? warn-and-keep-local fallback. `timeoutMs: 0` disables the abort entirely.\n //? Coerce defensively: a NaN/Infinity timeoutMs (e.g. a bad env parse) would\n //? make `timeoutMs > 0` false and silently DISABLE the black-hole abort,\n //? reintroducing the boot hang this guard exists to prevent. Mirror the retry\n //? path's finite-coercion.\n const rawTimeout = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const timeoutMs = Number.isFinite(rawTimeout) ? Math.max(0, rawTimeout) : DEFAULT_TIMEOUT_MS;\n const signal = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined;\n\n //? Consumer `headers` are merged first, with any case-variant of `authorization`\n //? stripped (`stripAuthorizationHeaders`) so the framework bearer token always\n //? wins — see that helper for why a lowercase key would otherwise leak through.\n //? `redirect: 'error'` fails closed on any 30x: `validateUrl` pins only the\n //? configured host, so following a redirect would carry the bearer token +\n //? request body to — and consume the resolved secrets from — an origin that was\n //? never validated (scheme/loopback/https checks don't re-apply to the hop). A\n //? legitimate `/resolve` endpoint never redirects; a 30x is a misconfig or an\n //? attack, and rejecting it keeps the whole exchange pinned to the checked origin.\n const response = await fetchFn(endpoint, {\n method: 'POST',\n headers: {\n ...stripAuthorizationHeaders(config.headers),\n 'Authorization': `Bearer ${resolveToken(config.token)}`,\n 'Content-Type': 'application/json',\n 'Accept': 'application/json',\n },\n body: JSON.stringify({ keys: pointers }),\n redirect: 'error',\n signal,\n });\n\n if (!response.ok) {\n //? Discard the unconsumed error body so a long-lived poll loop can't leave\n //? response bodies pending GC; failures carry only status text upward.\n void response.body?.cancel();\n throw new Error(`[secret-manager] Resolve request failed: ${String(response.status)} ${response.statusText}`);\n }\n\n //? The resolve response is the network input this client trusts least (a\n //? compromised/buggy server, or a MITM on an `allowInsecureHttp` channel).\n //? Reject an oversized body BEFORE buffering/parsing it so a hostile server\n //? can't OOM the booting client. A Content-Length header is advisory (can be\n //? absent/lying), so it is a fast pre-check only — the real guard is the\n //? streamed byte cap below.\n const declaredLength = Number(response.headers.get('content-length'));\n if (Number.isFinite(declaredLength) && declaredLength > MAX_RESOLVE_BODY_BYTES) {\n void response.body?.cancel();\n throw new Error(`[secret-manager] Resolve response too large (${String(declaredLength)} bytes > ${String(MAX_RESOLVE_BODY_BYTES)} cap).`);\n }\n const raw = await readBodyCapped(response, MAX_RESOLVE_BODY_BYTES);\n\n //? Parse as `unknown`, then narrow `values` with a runtime guard rather than\n //? trusting an up-front cast — the response is attacker-influenced (a\n //? compromised/buggy server) so its shape is not assumed.\n const [parseError, body] = tryCatchSync((): unknown => JSON.parse(raw));\n if (parseError) {\n throw new Error('[secret-manager] Resolve response was not valid JSON.');\n }\n const values = (body as { values?: unknown } | null)?.values;\n if (values === null || typeof values !== 'object') {\n throw new Error('[secret-manager] Resolve response missing `values` object.');\n }\n\n //? Filter the response down to the pointers we actually requested, and require\n //? each value to be a string. A compromised/buggy server could otherwise inject\n //? extra keys (cached + surfaced via getCachedResolution) or a non-string that\n //? coerces to '123' / '[object Object]' once written into process.env — only\n //? requested, string-valued pointers are trusted; a non-string is dropped (and\n //? thus treated as an unresolved pointer downstream: fatal in 'remote', warned\n //? in 'hybrid').\n const requested = new Set(pointers);\n const filtered: Record<string, string> = {};\n for (const [key, value] of Object.entries(values as Record<string, unknown>)) {\n if (!requested.has(key)) continue;\n if (typeof value !== 'string') {\n console.warn(`[secret-manager] Pointer \"${key}\" resolved to a non-string value (${typeof value}); ignoring.`);\n continue;\n }\n filtered[key] = value;\n }\n return filtered;\n};\n\nconst fetchResolve = async (\n config: SecretManagerConfig,\n pointers: string[],\n): Promise<Record<string, string>> => {\n //? Defensive normalization: a configured `NaN` would make `attempt <= NaN` always\n //? false, so the loop body would never run. `lastError` is now pre-initialized\n //? (below) so we wouldn't `throw undefined` anymore, but coercing to a finite\n //? non-negative value is still the right guard — NaN retries is a config bug, not\n //? a valid \"zero retries\" signal.\n const rawRetryCount = config.retries?.count ?? 0;\n const retryCount = Number.isFinite(rawRetryCount) ? Math.max(0, rawRetryCount) : 0;\n const rawDelayMs = config.retries?.delayMs ?? 0;\n const delayMs = Number.isFinite(rawDelayMs) ? Math.max(0, rawDelayMs) : 0;\n\n let lastError: unknown = new Error('[secret-manager] resolve failed: no attempt was made');\n for (let attempt = 0; attempt <= retryCount; attempt++) {\n try {\n return await fetchResolveOnce(config, pointers);\n } catch (error) {\n lastError = error;\n if (attempt < retryCount && delayMs > 0) await sleep(delayMs);\n }\n }\n throw lastError;\n};\n\n//? Fire the framework's decoupled \"secrets resolved\" channel (if `@luckystack/core`\n//? is present in the process) so it can rebuild clients that captured a now-stale\n//? secret at construction — most importantly the default Redis client, whose\n//? ioredis password is baked in at `new Redis(...)` time and never re-read. Kept\n//? DECOUPLED via a well-known global-symbol array so this package keeps NO import\n//? of core (its \"zero required deps\" contract). Best-effort + isolated: a missing\n//? core, or a throwing listener, must never break the resolve/boot path. This is\n//? what makes Redis-auth-via-a-secret-manager-POINTER boot with zero consumer code\n//? (the client is rebuilt from the resolved env AT RESOLVE TIME, before any later\n//? env revert — ADR 0026).\nconst GLOBAL_SECRETS_RESOLVED_LISTENERS = Symbol.for('luckystack.secretsResolved.listeners');\n\nconst fireFrameworkSecretsResolved = (changedNames: readonly string[]): void => {\n const listeners: unknown = Reflect.get(globalThis, GLOBAL_SECRETS_RESOLVED_LISTENERS);\n if (!Array.isArray(listeners)) return;\n for (const listener of listeners) {\n if (typeof listener !== 'function') continue;\n try {\n (listener as (keys: readonly string[]) => void)(changedNames);\n } catch {\n //? A misbehaving framework listener must never break the resolve path.\n }\n }\n};\n\nconst applyResolved = (\n map: Record<string, string>,\n values: Record<string, string>,\n source: 'remote' | 'hybrid',\n): { name: string; pointer: string }[] => {\n //? In remote mode a single unresolved pointer is a hard boot failure. Check\n //? everything BEFORE mutating process.env so the failure is atomic.\n if (source === 'remote') {\n const missing = Object.entries(map).filter(([, pointer]) => values[pointer] === undefined);\n if (missing.length > 0) {\n const detail = missing.map(([name, pointer]) => `${pointer} (referenced by ${name})`).join(', ');\n throw new Error(`[secret-manager] Server did not resolve: ${detail}.`);\n }\n }\n\n //? Track only the env NAMES whose value actually changed — surfaced to\n //? `onApplied` so a client that captured a secret at construction time can\n //? re-create its pool/SDK client. Never carries the secret values themselves.\n const changes: { name: string; pointer: string }[] = [];\n for (const [name, pointer] of Object.entries(map)) {\n const value = values[pointer];\n if (value === undefined) {\n //? hybrid only — leave the pointer in place and warn so the operator sees it.\n console.warn(`[secret-manager] Pointer \"${pointer}\" (referenced by ${name}) not resolved; leaving \"${name}\" as-is.`);\n continue;\n }\n if (process.env[name] !== value) changes.push({ name, pointer });\n process.env[name] = value;\n }\n return changes;\n};\n\n//? Public resolve entry: SERIALIZE every resolve behind a single in-flight chain\n//? so a slow resolve can never land after a newer one (TOCTOU on `process.env` /\n//? `cachedResolution`). We chain off `resolveChain.then(...)` regardless of whether\n//? the prior resolve resolved or rejected, then re-publish the tail so the next\n//? caller waits on THIS one. The returned promise mirrors the inner outcome so\n//? `'remote'` still rejects the caller on a hard failure.\nconst doResolve = (\n config: SecretManagerConfig,\n phase: 'boot' | 'refresh' | 'file-reload',\n): Promise<void> => {\n const run = resolveChain.then(\n () => doResolveInner(config, phase),\n () => doResolveInner(config, phase),\n );\n //? Keep the chain alive even if this resolve rejects (swallow on the tail copy\n //? only — the returned `run` keeps the original rejection for the caller).\n resolveChain = run.then(noop, noop);\n return run;\n};\n\nconst doResolveInner = async (\n config: SecretManagerConfig,\n phase: 'boot' | 'refresh' | 'file-reload',\n): Promise<void> => {\n const source = config.source ?? 'remote';\n if (source === 'local') return;\n\n //? Secure default: an unset `envNames` resolves NOTHING off-host — warn loudly\n //? on every resolve so the deny-all state is never silent (an operator who\n //? expected secrets to resolve sees exactly what to set).\n warnIfEnvNamesUnset(config.envNames);\n\n //? Capture once on first resolve and reuse: the first resolve OVERWRITES the\n //? env value with the real secret, after which it no longer looks like a pointer.\n //? Re-capture when the map is empty so a pointer that wasn't present at boot\n //? (e.g. set into `process.env` after init) is still picked up by a later\n //? refresh — a non-empty `{}` would otherwise pin the resolver to zero pointers.\n if (pointerMap === null || Object.keys(pointerMap).length === 0) {\n pointerMap = capturePointers(\n stripStatefulFlags(config.pointerPattern ?? DEFAULT_POINTER_PATTERN),\n config.envNames,\n );\n }\n const activePointerMap = pointerMap;\n const pointers = [...new Set(Object.values(activePointerMap))];\n if (pointers.length === 0) {\n cachedResolution = { fetchedAt: Date.now(), values: {} };\n return;\n }\n\n //? CC-7 exemption (no-raw-try-catch): the async framework `tryCatch` auto-captures\n //? to the error tracker, but this is a deliberate fail-OPEN boot-time guard in an\n //? intentionally dependency-light client — a 'hybrid' resolve failure must stay a\n //? silent warn with NO error-tracker side-effect (and 'remote' re-throws untouched\n //? for a hard boot stop). `tryCatchSync` can't wrap the `await`, so the raw\n //? try/catch is kept on purpose; the fail-OPEN contract is the load-bearing detail.\n let values: Record<string, string>;\n let changes: { name: string; pointer: string }[];\n try {\n values = await fetchResolve(config, pointers);\n changes = applyResolved(activePointerMap, values, source);\n cachedResolution = { fetchedAt: Date.now(), values };\n } catch (error) {\n //? Opt-in observability seam: route the failure to Sentry/metrics. Kept\n //? alongside (not replacing) the warn below so the fail-open default is unchanged.\n //? Hooks run isolated: a throwing/hanging `onResolveError` must not mask the\n //? original resolve error (it is re-thrown below in 'remote').\n runHook(() => config.onResolveError?.(error, { phase }), 'onResolveError');\n if (source === 'hybrid') {\n //? Log the message only — never the raw error object: error objects are the\n //? classic accidental channel for a URL/token/PII string reaching a log sink.\n //? Full-fidelity routing is the `onResolveError` hook's job.\n console.warn('[secret-manager] Resolve failed, leaving local env as-is:', errorMessage(error));\n return;\n }\n throw error;\n }\n\n //? Notify AFTER the cache + process.env are coherent so a consumer that reads\n //? process.env inside the callback sees the applied values. Isolated AND\n //? time-bounded by `runHookAsync` so a throwing `onApplied` can't abort an\n //? otherwise-successful resolve, AND a HANGING `onApplied` can't wedge the\n //? serialized resolve chain forever (it is abandoned after `HOOK_TIMEOUT_MS`,\n //? with a warn) — process.env + the cache are already written by this point.\n if (changes.length > 0) {\n //? Framework channel FIRST (synchronous, in-process — rebuilds framework\n //? clients like Redis from the just-resolved env), then the consumer hook.\n fireFrameworkSecretsResolved(changes.map((change) => change.name));\n await runHookAsync(() => config.onApplied?.(changes), 'onApplied');\n }\n};\n\n//? Minimal .env parser kept in-package so the resolver stays dependency-free.\n//? Handles standard `KEY=VALUE` lines, full-line + inline (` #`) comments, and\n//? single/double-quoted values. Not multi-line values or escape sequences.\nconst parseEnvFile = (content: string): Record<string, string> => {\n const out: Record<string, string> = {};\n for (const rawLine of content.split(/\\r?\\n/)) {\n const line = rawLine.trim();\n if (!line || line.startsWith('#')) continue;\n const eq = line.indexOf('=');\n if (eq <= 0) continue;\n const key = line.slice(0, eq).trim();\n //? Restrict to POSIX-shell env-var names. `.`/`-` keys can't be read via the\n //? normal `process.env.NAME` lookup, so accepting them is a silent footgun.\n if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {\n console.warn(`[secret-manager] Ignoring env key \"${key}\": not a valid environment variable name (^[A-Za-z_][A-Za-z0-9_]*$).`);\n continue;\n }\n let value = line.slice(eq + 1).trim();\n const quote = value[0];\n if (quote === '\"' || quote === \"'\") {\n //? Quoted value. The closing quote is the LAST occurrence of the same quote\n //? char; anything after it (whitespace + `# ...`) is an inline comment and is\n //? dropped (`KEY=\"v\" # note` → `v`). A `#` BEFORE the closing quote is literal\n //? and preserved (`KEY=\"a#b\"` → `a#b`).\n const closeIdx = value.lastIndexOf(quote);\n if (closeIdx > 0) {\n value = value.slice(1, closeIdx);\n } else {\n //? Opening quote with no matching closing quote on this line means a\n //? multi-line value (dotenv supports it, this parser does not). Warn so the\n //? value isn't silently truncated, then strip any inline comment from the\n //? raw remainder so a trailing `# ...` doesn't leak into the value.\n console.warn(\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.`,\n );\n const commentAt = value.indexOf(' #');\n if (commentAt !== -1) value = value.slice(0, commentAt).trim();\n }\n } else {\n const commentAt = value.indexOf(' #');\n if (commentAt !== -1) value = value.slice(0, commentAt).trim();\n if (value.startsWith('#')) value = '';\n }\n out[key] = value;\n }\n return out;\n};\n\nconst scheduleReload = (): void => {\n //? Debounce — fs.watch can fire several events for one save.\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n void reloadSecretManagerFromFiles().catch((error: unknown) => {\n console.warn('[secret-manager] dev reload failed:', errorMessage(error));\n });\n }, 200);\n debounceTimer.unref();\n};\n\nconst startDevReload = (config: SecretManagerConfig): void => {\n if (devReloadStarted || config.dev === undefined) return;\n //? Allowlist the dev environments rather than exact-matching 'production': a\n //? 'prod' / 'staging' env must NOT silently start fs watchers + a poll on a\n //? host the operator believes is production. Only an explicit 'development' or\n //? 'test' enables dev hot reload (an unset env resolves to 'development' via\n //? the canonical `resolveEnvKey()`, so it counts as dev).\n const nodeEnv = resolveEnvKey();\n if (nodeEnv !== 'development' && nodeEnv !== 'test') return;\n\n const watch = config.dev.watch ?? true;\n const pollIntervalMs = config.dev.pollIntervalMs ?? 0;\n if (!watch && pollIntervalMs <= 0) return;\n devReloadStarted = true;\n\n if (watch) {\n for (const file of config.dev.envFiles ?? DEFAULT_ENV_FILES) {\n if (!isSafeEnvFile(file, true)) {\n console.warn(`[secret-manager] ignoring unsafe dev envFile path (must be relative + within the project): ${file}`);\n continue;\n }\n try {\n const watcher = fsWatch(file, () => {\n scheduleReload();\n });\n watcher.unref();\n fileWatchers.push(watcher);\n } catch {\n //? The file may not exist (e.g. no .env.local) — nothing to watch.\n }\n }\n }\n\n if (pollIntervalMs > 0) {\n pollTimer = setInterval(() => {\n void refreshSecretManager().catch((error: unknown) => {\n console.warn('[secret-manager] poll refresh failed:', errorMessage(error));\n });\n }, pollIntervalMs);\n pollTimer.unref();\n }\n};\n\n/**\n * Initialize the secret-manager resolver. Call once as the very first line of\n * `server.ts`, BEFORE any other framework code reads `process.env`. In\n * `'remote'` / `'hybrid'` mode the resolved secrets are written into\n * `process.env` before this resolves, so downstream code sees them via the\n * standard `process.env.FOO` lookup. In `'local'` mode it is a no-op.\n */\nexport const initSecretManager = async (config: SecretManagerConfig): Promise<void> => {\n //? Validate the URL BEFORE recording activeConfig, so a failed init can't leave\n //? a later refreshSecretManager() resolving against an invalid config.\n if ((config.source ?? 'remote') !== 'local') {\n //? Only validate the URL when we'll actually hit the network ('remote' /\n //? 'hybrid'); 'local' may carry a placeholder url.\n validateUrl(config.url, config.allowInsecureHttp ?? false);\n }\n activeConfig = config;\n if ((config.source ?? 'remote') === 'local') return;\n await doResolve(config, 'boot');\n startRotationPoll(config);\n startDevReload(config);\n};\n\n//? Production-capable rotation poll: re-resolve every `config.pollIntervalMs` ms\n//? in ALL environments (distinct from the dev-only `dev.pollIntervalMs` file-watch\n//? channel, which is gated off in production). Unref'd so it never blocks exit.\nconst startRotationPoll = (config: SecretManagerConfig): void => {\n const intervalMs = config.pollIntervalMs ?? 0;\n if (intervalMs <= 0 || rotationPollTimer) return;\n rotationPollTimer = setInterval(() => {\n void refreshSecretManager().catch((error: unknown) => {\n console.warn('[secret-manager] rotation poll refresh failed:', errorMessage(error));\n });\n }, intervalMs);\n rotationPollTimer.unref();\n};\n\n/**\n * Re-resolve against the server, ignoring nothing — used by the dev hot-reload\n * watch/poll and callable manually when an admin rotates a secret and you want\n * a long-running process to pick it up without a restart.\n */\nexport const refreshSecretManager = async (): Promise<void> => {\n if (!activeConfig || (activeConfig.source ?? 'remote') === 'local') return;\n await doResolve(activeConfig, 'refresh');\n};\n\n/**\n * Dev hot-reload entry: re-parse the configured env files and apply them — plain\n * (non-pointer) values are injected straight into `process.env` (live config\n * reload), pointer-shaped values are re-resolved against the server. Wired to the\n * `.env` / `.env.local` file watch; also callable manually.\n */\nexport const reloadSecretManagerFromFiles = async (): Promise<void> => {\n const config = activeConfig;\n if (!config || (config.source ?? 'remote') === 'local') return;\n\n const files = config.dev?.envFiles ?? DEFAULT_ENV_FILES;\n const pattern = stripStatefulFlags(config.pointerPattern ?? DEFAULT_POINTER_PATTERN);\n\n //? Re-read every file in load order; later files (e.g. .env.local) override.\n //? `warnAbsolute=false`: the absolute-path notice already fired once at boot\n //? (startDevReload). Repeating it on every debounced hot-reload would flood the\n //? dev log for anyone legitimately using an absolute envFile path.\n const merged: Record<string, string> = {};\n for (const file of files) {\n if (!isSafeEnvFile(file, false)) {\n console.warn(`[secret-manager] ignoring unsafe dev envFile path: ${file}`);\n continue;\n }\n let content: string;\n try {\n content = readFileSync(file, 'utf8');\n } catch {\n continue; //? file may not exist (e.g. no .env.local)\n }\n Object.assign(merged, parseEnvFile(content));\n }\n\n //? Split plain (live config reload) from pointer-shaped values through the SAME\n //? `envNames` allowlist the boot path uses (`capturePointers`), so a name excluded\n //? by `envNames` is dropped on BOTH channels — never POSTed off-host as a pointer,\n //? never injected into `process.env` as a plain value. This closes the scoping\n //? drift where a file-reload could send/apply names the boot scan rejected.\n const { pointers: freshPointerMap, plain: plainValues } = splitPointers(\n Object.entries(merged),\n pattern,\n config.envNames,\n );\n\n //? MERGE the fresh file-sourced pointers over the existing map (don't replace):\n //? a pointer captured at boot from the inherited shell/CI env that isn't in a\n //? watched file would otherwise be dropped and stop rotating. A file-sourced\n //? pointer with the same name still wins.\n //? Build the merged map locally and commit it ONLY after a successful resolve so\n //? a failed remote reload can't permanently poison the in-memory pointer map and\n //? cause every subsequent refreshSecretManager / poll to resolve the bad set.\n const previousPointerMap = pointerMap;\n pointerMap = { ...pointerMap, ...freshPointerMap };\n\n //? Resolve the pointers FIRST. In 'remote' mode an unresolved pointer throws —\n //? mirror the atomic boot path and inject the plain values only AFTER a\n //? successful resolve, so a throw never leaves half-applied state. On failure\n //? roll back the pointer map to its pre-reload snapshot.\n try {\n await doResolve(config, 'file-reload');\n } catch (error) {\n pointerMap = previousPointerMap;\n throw error;\n }\n for (const [name, value] of Object.entries(plainValues)) {\n process.env[name] = value;\n }\n};\n\n/**\n * Read the last `{ fetchedAt, values }` resolution (pointer -> value), or `null`.\n *\n * ⚠️ SENSITIVE: `values` maps each pointer to its RAW resolved secret. The result\n * is a shallow copy (values are flat strings, so mutating the returned object does\n * not corrupt the cache), but the values are still the real secrets — NEVER\n * serialize the result into an HTTP response, a `/health` payload, or a log line.\n * For a safe diagnostic use {@link getCachedResolutionMeta}, which never exposes\n * the values.\n *\n * If you need to act on changed secrets (e.g. to rebuild a DB pool after rotation),\n * use the `onApplied` callback in `SecretManagerConfig` instead — it receives only\n * the changed env NAMES, never the secret values, and is called automatically after\n * each successful resolve.\n */\nexport const getCachedResolution = (): CachedResolution | null =>\n cachedResolution === null\n ? null\n : { fetchedAt: cachedResolution.fetchedAt, values: { ...cachedResolution.values } };\n\n/** Values-free view of {@link getCachedResolution}: the timestamp + resolved pointer NAMES only. */\nexport interface CachedResolutionMeta {\n /** `Date.now()` of the last successful resolve. */\n fetchedAt: number;\n /** The resolved pointer strings (never the secret values). */\n pointerNames: string[];\n /** How many pointers were resolved. */\n pointerCount: number;\n}\n\n/**\n * Safe diagnostic accessor: the last resolution's timestamp + resolved pointer\n * NAMES, with the secret values stripped. Use this on any surface that might be\n * logged or served (`/health`, metrics) so the convenient path is also the safe\n * one — {@link getCachedResolution} is the values-carrying escape hatch.\n */\nexport const getCachedResolutionMeta = (): CachedResolutionMeta | null => {\n if (cachedResolution === null) return null;\n const pointerNames = Object.keys(cachedResolution.values);\n return { fetchedAt: cachedResolution.fetchedAt, pointerNames, pointerCount: pointerNames.length };\n};\n\n/**\n * Tear down the dev file watchers, the dev poll, the rotation poll, and the\n * debounce timer WITHOUT wiping the resolved cache / active config. Use for a\n * graceful shutdown of an embedded resolver (worker / CLI) so no timers keep\n * firing; the last resolved `process.env` values stay in place.\n *\n * **Note:** `activeConfig` is intentionally left set after `stopSecretManager`,\n * so that `reloadSecretManagerFromFiles` can still use it for a final manual\n * reload. As a consequence, calling `refreshSecretManager()` after `stop` will\n * issue a live network request (the `!activeConfig` no-op guard is not met).\n * If you want to prevent ANY further resolution after `stop`, call\n * `resetSecretManagerForTests()` instead (which also clears `activeConfig`).\n */\nexport const stopSecretManager = (): void => {\n devReloadStarted = false;\n if (pollTimer) {\n clearInterval(pollTimer);\n pollTimer = null;\n }\n if (rotationPollTimer) {\n clearInterval(rotationPollTimer);\n rotationPollTimer = null;\n }\n if (debounceTimer) {\n clearTimeout(debounceTimer);\n debounceTimer = null;\n }\n for (const watcher of fileWatchers) {\n watcher.close();\n }\n fileWatchers.length = 0;\n //? Reset the \"warned once\" guard so a subsequent initSecretManager() call that\n //? also omits envNames re-emits the boot warning rather than silently silencing it\n //? (the first, valid config could have set envNames correctly while the second,\n //? broken one doesn't — the guard must not carry across a full stop-and-reinit).\n warnedEnvNamesUnset = false;\n};\n\n/** Test-only — clear module state and tear down any dev watchers / timers. */\nexport const resetSecretManagerForTests = (): void => {\n stopSecretManager();\n cachedResolution = null;\n pointerMap = null;\n activeConfig = null;\n resolveChain = Promise.resolve();\n warnedEnvNamesUnset = false;\n};\n"],"mappings":"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","names":[]}
@@ -1,126 +1,126 @@
1
- # Architecture — secret-manager client + external server
2
-
3
- > The client (`@luckystack/secret-manager`) is implemented and described here. The external **secret-manager server** it talks to lives in a separate repository (`luckystack-secret-manager`); only its wire contract is captured below so the client can be developed against a stable API.
4
-
5
- `@luckystack/secret-manager` is a **wiring client**, not a vault. It resolves committed `.env` pointers to real secrets at boot and writes them into `process.env`. Storage, versioning, auth, and the admin UI all live in the external server.
6
-
7
- ## TL;DR
8
-
9
- - **This package does:** scan `process.env` for pointer-shaped values (`<BASE>_V<n>`), `POST /resolve` them in one request, overwrite each `process.env` entry with the real secret, and (optionally, in dev) re-resolve on `.env` change / on an interval.
10
- - **This package does NOT:** store secrets, version values, render an admin UI, or know what a "secret" is. All of that lives in the external server.
11
- - **The workflow it enables:** developers commit `.env` containing only pointers (`OPENAI_KEY=OPENAI_AUTHORIZATION_KEY_V5`), not real secrets. The server resolves `OPENAI_AUTHORIZATION_KEY_V5` to its actual value at boot.
12
- - **The strict invariant (enforced by the server):** values are **append-only by version**. `..._V5` is immutable; rotating means publishing `..._V6`. Old branches keep working because their committed `.env` still points at `..._V5`.
13
-
14
- ## Why this design
15
-
16
- 1. **`.env` is dangerous to commit, painful to share.** Pointers are safe to commit (they are opaque names, not values), so the team shares one source of truth and nobody leaks a secret into git history.
17
- 2. **Rotation breaks old branches.** With versioned values, a six-month-old branch still references `..._V5` (resolvable until you retire it), while a fresh PR adopts `..._V6`. No "works on main, broken on old branch" surprises.
18
- 3. **One flat keystore, one token.** A single HTTP boundary with one shared bearer token — no per-app vault SDK wiring.
19
-
20
- ## The pointer model
21
-
22
- - A pointer is any `.env` value matching `^(.+)_V(\d+)$` (configurable via `pointerPattern`).
23
- - The env **name** is decoupled from the secret **base name**: `OPENAI_KEY` (name) -> `OPENAI_AUTHORIZATION_KEY_V5` (value) -> base `OPENAI_AUTHORIZATION_KEY`, version `5`.
24
- - The client sends the **full pointer string** to the server and overwrites the env name with the resolved value. The base/version split is the server's job.
25
- - A non-pointer value (`NODE_ENV=production`, or a real secret pasted locally) is a literal: never sent, never overwritten. Local overrides win for free.
26
- - Scanning is gated by `envNames` (a name allowlist — `string[]` or `(name) => boolean`). **This is required to resolve anything**, and the secure default is deny-all: see [Name scoping](#name-scoping-envnames) below.
27
-
28
- ## Name scoping (`envNames`)
29
-
30
- Pointer detection runs **only** over the env NAMES allowed by `config.envNames`:
31
-
32
- - `string[]` — resolve exactly these names.
33
- - `(name) => boolean` — a predicate (e.g. `(n) => n.endsWith('_KEY')`).
34
- - `() => true` — deliberately scan every name.
35
-
36
- **Secure default: when `envNames` is unset, NOTHING is resolved off-host.** The client emits a one-time boot warning and leaves `process.env` untouched (a deny-all no-op), in every mode. This prevents the resolver from POSTing an unrelated, pointer-shaped inherited value (a CI `RELEASE_TAG=build_2024_V2`) to the server — an explicit allowlist (or `() => true`) is mandatory to opt in. The same allowlist gates the dev file-reload channel, so a name excluded by `envNames` is never POSTed as a pointer nor injected as a plain value.
37
-
38
- ## Boot flow
39
-
40
- ```
41
- process start
42
- -> dotenv loads .env / .env.local (consumer's server.ts)
43
- -> initSecretManager(...) <-- THIS CLIENT, first line of server.ts
44
- 0. require an `envNames` allowlist (unset = deny-all + boot warning)
45
- 1. capture { envName -> pointer } from the allowed names in process.env (once)
46
- 2. POST /resolve { keys: [unique pointers] }
47
- 3. overwrite process.env[envName] = values[pointer]
48
- -> framework boot (reads the resolved process.env)
49
- -> server.listen()
50
- ```
51
-
52
- `initSecretManager` runs **before** anything else that reads `process.env` at module-init time.
53
-
54
- ## Modes
55
-
56
- | `source` | Behavior | Writes to `process.env`? |
57
- | --- | --- | --- |
58
- | `'remote'` (default) | Resolve from the server. A missing pointer or fetch error throws — production hard-stop. | After a successful resolve. |
59
- | `'local'` | No network. Pointers untouched. Tests / offline dev. | Never. |
60
- | `'hybrid'` | Try the server; on failure warn and keep local env. | Only on a successful resolve; missing pointers are warned and left as-is. |
61
-
62
- ## Dev hot reload (opt-in, dev-only)
63
-
64
- Set `config.dev` to live-reload while a long-running dev process is up (no-op when `NODE_ENV === 'production'`):
65
-
66
- - `dev.watch` (default `true`) — a debounced `fs.watch` on `dev.envFiles` (default `.env` + `.env.local`). On change the files are **re-parsed and applied**: plain values (`ENVIRONMENT=production`, `PORT=123`) are injected straight into `process.env` (live config reload), and pointer-shaped values are re-resolved against the server. A pointer added or bumped after boot is picked up here — no restart.
67
- - `dev.pollIntervalMs` (default `0`/off) — re-resolve the current pointers every N ms, catching server-side rotations. The interval lives in `config.ts`, changeable in one place.
68
- - `dev.envFiles` — override which files are watched + re-parsed (default `['.env', '.env.local']`).
69
-
70
- The file parse is done by a tiny in-package `.env` parser (standard `KEY=VALUE`, comments, quotes), so the package stays dependency-free. Both channels swallow + warn on a transient error rather than crashing the dev process.
71
-
72
- ## Auth model
73
-
74
- A single shared bearer token accompanies every request:
75
-
76
- ```
77
- Authorization: Bearer <token>
78
- ```
79
-
80
- The token is the only real secret on the developer machine. Keep it in a gitignored single-line file referenced via `token: { fromFile: '.secret-manager-token' }` (read at resolve time, so file rotation is picked up by the next poll). CI runners inject the file from their secret store.
81
-
82
- ## What this package does NOT do
83
-
84
- - **No secret storage.** No on-disk persistence beyond reading the token file. The in-memory cache is plain JS, discarded on exit.
85
- - **No versioning logic.** It sends the full pointer string and writes back whatever the server returns. Version naming + resolution is the server's job.
86
- - **No admin / write operations.** Read-only `POST /resolve`. Publishing new versions happens through the server's own UI.
87
- - **No app-key validation.** Whether `DATABASE_URL` is a valid URL is the app's concern (validate after `initSecretManager` returns).
88
-
89
- ## External server — wire contract (separate repo)
90
-
91
- The server lives in its own repo (`luckystack-secret-manager`). The client only depends on this one endpoint:
92
-
93
- ### `POST /resolve`
94
-
95
- Request (the app sends only the pointers it references):
96
-
97
- ```json
98
- { "keys": ["OPENAI_AUTHORIZATION_KEY_V5", "STRIPE_SECRET_KEY_V2"] }
99
- ```
100
-
101
- Response:
102
-
103
- ```json
104
- { "values": { "OPENAI_AUTHORIZATION_KEY_V5": "sk-...", "STRIPE_SECRET_KEY_V2": "rk-..." } }
105
- ```
106
-
107
- - Auth: `Authorization: Bearer <token>` (the shared token). 401 on mismatch.
108
- - Pointers the server can't resolve are omitted from `values`. The client treats a missing pointer as fatal in `'remote'` mode, and a warning in `'hybrid'`.
109
-
110
- The server additionally exposes admin endpoints (`GET /keys` listing masked values, `POST /keys` appending a new version) used only by its own admin webpage — the client never calls them. The full server + admin-UI implementation lives in the separate, running `luckystack-secret-manager` repo.
111
-
112
- ## Implementation status
113
-
114
- | Piece | Status | Where |
115
- | --- | --- | --- |
116
- | Resolver client (this package) | Implemented | `packages/secret-manager/src/index.ts` |
117
- | local / remote / hybrid modes | Implemented | this doc |
118
- | Opt-in dev hot reload (watch + poll) | Implemented | this doc |
119
- | External secret-manager server (storage, versioning, admin UI, auth) | **Separate repo** | `luckystack-secret-manager` |
120
-
121
- ## Related
122
-
123
- - Function index: `../CLAUDE.md`.
124
- - Consumer quickstart: `../README.md`.
125
- - Framework-wide packaging map: `/docs/PACKAGE_OVERVIEW.md`.
126
- - Architecture deep-dive: `/docs/ARCHITECTURE_SECRET_MANAGER.md`.
1
+ # Architecture — secret-manager client + external server
2
+
3
+ > The client (`@luckystack/secret-manager`) is implemented and described here. The external **secret-manager server** it talks to lives in a separate repository (`luckystack-secret-manager`); only its wire contract is captured below so the client can be developed against a stable API.
4
+
5
+ `@luckystack/secret-manager` is a **wiring client**, not a vault. It resolves committed `.env` pointers to real secrets at boot and writes them into `process.env`. Storage, versioning, auth, and the admin UI all live in the external server.
6
+
7
+ ## TL;DR
8
+
9
+ - **This package does:** scan `process.env` for pointer-shaped values (`<BASE>_V<n>`), `POST /resolve` them in one request, overwrite each `process.env` entry with the real secret, and (optionally, in dev) re-resolve on `.env` change / on an interval.
10
+ - **This package does NOT:** store secrets, version values, render an admin UI, or know what a "secret" is. All of that lives in the external server.
11
+ - **The workflow it enables:** developers commit `.env` containing only pointers (`OPENAI_KEY=OPENAI_AUTHORIZATION_KEY_V5`), not real secrets. The server resolves `OPENAI_AUTHORIZATION_KEY_V5` to its actual value at boot.
12
+ - **The strict invariant (enforced by the server):** values are **append-only by version**. `..._V5` is immutable; rotating means publishing `..._V6`. Old branches keep working because their committed `.env` still points at `..._V5`.
13
+
14
+ ## Why this design
15
+
16
+ 1. **`.env` is dangerous to commit, painful to share.** Pointers are safe to commit (they are opaque names, not values), so the team shares one source of truth and nobody leaks a secret into git history.
17
+ 2. **Rotation breaks old branches.** With versioned values, a six-month-old branch still references `..._V5` (resolvable until you retire it), while a fresh PR adopts `..._V6`. No "works on main, broken on old branch" surprises.
18
+ 3. **One flat keystore, one token.** A single HTTP boundary with one shared bearer token — no per-app vault SDK wiring.
19
+
20
+ ## The pointer model
21
+
22
+ - A pointer is any `.env` value matching `^(.+)_V(\d+)$` (configurable via `pointerPattern`).
23
+ - The env **name** is decoupled from the secret **base name**: `OPENAI_KEY` (name) -> `OPENAI_AUTHORIZATION_KEY_V5` (value) -> base `OPENAI_AUTHORIZATION_KEY`, version `5`.
24
+ - The client sends the **full pointer string** to the server and overwrites the env name with the resolved value. The base/version split is the server's job.
25
+ - A non-pointer value (`NODE_ENV=production`, or a real secret pasted locally) is a literal: never sent, never overwritten. Local overrides win for free.
26
+ - Scanning is gated by `envNames` (a name allowlist — `string[]` or `(name) => boolean`). **This is required to resolve anything**, and the secure default is deny-all: see [Name scoping](#name-scoping-envnames) below.
27
+
28
+ ## Name scoping (`envNames`)
29
+
30
+ Pointer detection runs **only** over the env NAMES allowed by `config.envNames`:
31
+
32
+ - `string[]` — resolve exactly these names.
33
+ - `(name) => boolean` — a predicate (e.g. `(n) => n.endsWith('_KEY')`).
34
+ - `() => true` — deliberately scan every name.
35
+
36
+ **Secure default: when `envNames` is unset, NOTHING is resolved off-host.** The client emits a one-time boot warning and leaves `process.env` untouched (a deny-all no-op), in every mode. This prevents the resolver from POSTing an unrelated, pointer-shaped inherited value (a CI `RELEASE_TAG=build_2024_V2`) to the server — an explicit allowlist (or `() => true`) is mandatory to opt in. The same allowlist gates the dev file-reload channel, so a name excluded by `envNames` is never POSTed as a pointer nor injected as a plain value.
37
+
38
+ ## Boot flow
39
+
40
+ ```
41
+ process start
42
+ -> dotenv loads .env / .env.local (consumer's server.ts)
43
+ -> initSecretManager(...) <-- THIS CLIENT, first line of server.ts
44
+ 0. require an `envNames` allowlist (unset = deny-all + boot warning)
45
+ 1. capture { envName -> pointer } from the allowed names in process.env (once)
46
+ 2. POST /resolve { keys: [unique pointers] }
47
+ 3. overwrite process.env[envName] = values[pointer]
48
+ -> framework boot (reads the resolved process.env)
49
+ -> server.listen()
50
+ ```
51
+
52
+ `initSecretManager` runs **before** anything else that reads `process.env` at module-init time.
53
+
54
+ ## Modes
55
+
56
+ | `source` | Behavior | Writes to `process.env`? |
57
+ | --- | --- | --- |
58
+ | `'remote'` (default) | Resolve from the server. A missing pointer or fetch error throws — production hard-stop. | After a successful resolve. |
59
+ | `'local'` | No network. Pointers untouched. Tests / offline dev. | Never. |
60
+ | `'hybrid'` | Try the server; on failure warn and keep local env. | Only on a successful resolve; missing pointers are warned and left as-is. |
61
+
62
+ ## Dev hot reload (opt-in, dev-only)
63
+
64
+ Set `config.dev` to live-reload while a long-running dev process is up (no-op when `NODE_ENV === 'production'`):
65
+
66
+ - `dev.watch` (default `true`) — a debounced `fs.watch` on `dev.envFiles` (default `.env` + `.env.local`). On change the files are **re-parsed and applied**: plain values (`ENVIRONMENT=production`, `PORT=123`) are injected straight into `process.env` (live config reload), and pointer-shaped values are re-resolved against the server. A pointer added or bumped after boot is picked up here — no restart.
67
+ - `dev.pollIntervalMs` (default `0`/off) — re-resolve the current pointers every N ms, catching server-side rotations. The interval lives in `config.ts`, changeable in one place.
68
+ - `dev.envFiles` — override which files are watched + re-parsed (default `['.env', '.env.local']`).
69
+
70
+ The file parse is done by a tiny in-package `.env` parser (standard `KEY=VALUE`, comments, quotes), so the package stays dependency-free. Both channels swallow + warn on a transient error rather than crashing the dev process.
71
+
72
+ ## Auth model
73
+
74
+ A single shared bearer token accompanies every request:
75
+
76
+ ```
77
+ Authorization: Bearer <token>
78
+ ```
79
+
80
+ The token is the only real secret on the developer machine. Keep it in a gitignored single-line file referenced via `token: { fromFile: '.secret-manager-token' }` (read at resolve time, so file rotation is picked up by the next poll). CI runners inject the file from their secret store.
81
+
82
+ ## What this package does NOT do
83
+
84
+ - **No secret storage.** No on-disk persistence beyond reading the token file. The in-memory cache is plain JS, discarded on exit.
85
+ - **No versioning logic.** It sends the full pointer string and writes back whatever the server returns. Version naming + resolution is the server's job.
86
+ - **No admin / write operations.** Read-only `POST /resolve`. Publishing new versions happens through the server's own UI.
87
+ - **No app-key validation.** Whether `DATABASE_URL` is a valid URL is the app's concern (validate after `initSecretManager` returns).
88
+
89
+ ## External server — wire contract (separate repo)
90
+
91
+ The server lives in its own repo (`luckystack-secret-manager`). The client only depends on this one endpoint:
92
+
93
+ ### `POST /resolve`
94
+
95
+ Request (the app sends only the pointers it references):
96
+
97
+ ```json
98
+ { "keys": ["OPENAI_AUTHORIZATION_KEY_V5", "STRIPE_SECRET_KEY_V2"] }
99
+ ```
100
+
101
+ Response:
102
+
103
+ ```json
104
+ { "values": { "OPENAI_AUTHORIZATION_KEY_V5": "sk-...", "STRIPE_SECRET_KEY_V2": "rk-..." } }
105
+ ```
106
+
107
+ - Auth: `Authorization: Bearer <token>` (the shared token). 401 on mismatch.
108
+ - Pointers the server can't resolve are omitted from `values`. The client treats a missing pointer as fatal in `'remote'` mode, and a warning in `'hybrid'`.
109
+
110
+ The server additionally exposes admin endpoints (`GET /keys` listing masked values, `POST /keys` appending a new version) used only by its own admin webpage — the client never calls them. The full server + admin-UI implementation lives in the separate, running `luckystack-secret-manager` repo.
111
+
112
+ ## Implementation status
113
+
114
+ | Piece | Status | Where |
115
+ | --- | --- | --- |
116
+ | Resolver client (this package) | Implemented | `packages/secret-manager/src/index.ts` |
117
+ | local / remote / hybrid modes | Implemented | this doc |
118
+ | Opt-in dev hot reload (watch + poll) | Implemented | this doc |
119
+ | External secret-manager server (storage, versioning, admin UI, auth) | **Separate repo** | `luckystack-secret-manager` |
120
+
121
+ ## Related
122
+
123
+ - Function index: `../CLAUDE.md`.
124
+ - Consumer quickstart: `../README.md`.
125
+ - Framework-wide packaging map: `/docs/PACKAGE_OVERVIEW.md`.
126
+ - Architecture deep-dive: `/docs/ARCHITECTURE_SECRET_MANAGER.md`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luckystack/secret-manager",
3
- "version": "0.6.7",
3
+ "version": "0.7.1",
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.7"
56
+ "@luckystack/core": "^0.7.1"
57
57
  }
58
58
  }