@luckystack/secret-manager 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,14 +1,14 @@
1
- # Changelog
2
-
3
- All notable changes to `@luckystack/secret-manager` are documented in this file.
4
-
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
-
8
- ## [Unreleased]
9
-
10
- ## [0.1.0]
11
-
12
- ### Added
13
-
14
- - Initial release. Rotation-aware secret resolver client: scans `process.env` for pointer-shaped values (`<BASE>_V<n>`), resolves them in one `POST /resolve` request against an external secret-manager server, and overwrites `process.env` with the real values. Supports `local` / `remote` / `hybrid` modes and opt-in dev hot reload (`.env` watch + interval poll). The companion append-only secret-manager server lives in its own repository (see `docs/architecture.md`).
1
+ # Changelog
2
+
3
+ All notable changes to `@luckystack/secret-manager` are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0]
11
+
12
+ ### Added
13
+
14
+ - Initial release. Rotation-aware secret resolver client: scans `process.env` for pointer-shaped values (`<BASE>_V<n>`), resolves them in one `POST /resolve` request against an external secret-manager server, and overwrites `process.env` with the real values. Supports `local` / `remote` / `hybrid` modes and opt-in dev hot reload (`.env` watch + interval poll). The companion append-only secret-manager server lives in its own repository (see `docs/architecture.md`).
package/CLAUDE.md CHANGED
@@ -1,81 +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. | -> docs/architecture.md |
28
- | `refreshSecretManager()` | Re-resolve the captured pointers against the server (the dev **poll** channel). Call manually after an admin rotates a secret on a long-running process. | -> docs/architecture.md |
29
- | `reloadSecretManagerFromFiles()` | Re-parse the configured env files (`dev.envFiles`, default `.env` + `.env.local`) and apply them: plain values injected into `process.env` (live config reload), pointer-shaped values re-resolved. The dev **file-watch** channel; callable manually. | -> docs/architecture.md |
30
- | `getCachedResolution()` | Returns the last `{ fetchedAt, values }` (pointer -> value) for diagnostics, or `null`. | -> docs/architecture.md |
31
- | `resetSecretManagerForTests()` | Test-onlyclears module state and tears down dev watchers / timers. | -> docs/architecture.md |
32
- | Type `SecretManagerConfig` | `{ url; token; source?; pointerPattern?; fetchImpl?; dev? }` where `dev` is `{ watch?; pollIntervalMs?; envFiles? }`. | -> docs/architecture.md |
33
- | Type `SecretManagerToken` | `string \| { fromFile: string }`. | -> docs/architecture.md |
34
- | Type `CachedResolution` | `{ fetchedAt: number; values: Record<string, string> }`. | -> docs/architecture.md |
35
-
36
- ### Internal helpers (not exported, listed for AI context)
37
-
38
- | Helper | Role |
39
- | --- | --- |
40
- | `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). |
41
- | `resolveToken(token)` | Return the literal token, or read+trim the `{ fromFile }` file (read at resolve time so file rotation is picked up). |
42
- | `fetchResolve(config, pointers)` | `POST ${url}/resolve` with `{ keys: pointers }` and `Authorization: Bearer <token>`. Throws on non-2xx or a missing `values` object. Honors `fetchImpl`. |
43
- | `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. |
44
- | `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. |
45
- | `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. |
46
-
47
- ## Boot-time contract (authoritative)
48
-
49
- 1. Call `initSecretManager(...)` as the very first line of `server.ts`, before any other framework code reads `process.env`.
50
- 2. `'local'` mode short-circuits no network, no writes, no watchers.
51
- 3. Otherwise it captures the pointer map once, `POST /resolve`s the unique pointers, and overwrites `process.env`.
52
- 4. `'remote'`: a missing pointer or fetch error throws (hard boot stop). `'hybrid'`: warn and leave `process.env` as-is.
53
- 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.
54
- 6. Only after `initSecretManager` resolves should other framework code read `process.env`.
55
-
56
- ## Config keys
57
-
58
- This package reads **no** env vars itself — it consumes `SecretManagerConfig` (typically built in `config.ts` and passed in `server.ts`). The values you commonly source from env / a file:
59
-
60
- | Source | Purpose |
61
- | --- | --- |
62
- | `config.url` | Base URL of the secret-manager server (trailing slash optional). |
63
- | `config.token` | Shared bearer token: literal string or `{ fromFile }` (gitignored single-line file). |
64
- | `config.source` | `'remote'` (default) / `'local'` / `'hybrid'`. |
65
- | `config.dev` | Opt-in dev hot reload: `{ watch?, pollIntervalMs? }`. Ignored in production. |
66
-
67
- `process.env` values matching `pointerPattern` (default `/^(.+)_V(\d+)$/`) are the pointers this client resolves and overwrites.
68
-
69
- ## Peer dependencies
70
-
71
- - **None required.** Speaks plain HTTP via global `fetch`; reads the token file with Node's built-in `fs`.
72
- - **Optional**: any `fetch` polyfill (e.g. `undici`) for non-Node-20 hosts — pass via `SecretManagerConfig.fetchImpl`.
73
-
74
- Node `>= 20` is required because the default code path uses global `fetch`.
75
-
76
- ## Related
77
-
78
- - Concept overview + external-server wire contract: `./docs/architecture.md`.
79
- - Consumer quickstart: `./README.md`.
80
- - Framework-wide packaging map: `/docs/PACKAGE_OVERVIEW.md`.
81
- - 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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Mathijs van Melick
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mathijs van Melick
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,100 +1,100 @@
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
- });
30
-
31
- const server = await bootstrapLuckyStack({ /* ... */ });
32
- await server.listen();
33
- ```
34
-
35
- In your committed `.env`:
36
-
37
- ```
38
- OPENAI_KEY=OPENAI_AUTHORIZATION_KEY_V5
39
- STRIPE_KEY=STRIPE_SECRET_KEY_V2
40
- ```
41
-
42
- After `initSecretManager` resolves, `process.env.OPENAI_KEY` holds the real `sk-...` value.
43
-
44
- ## Modes
45
-
46
- | `source` | Behavior |
47
- | --- | --- |
48
- | `'remote'` (default) | Resolve from the server. A missing pointer or an unreachable server **throws** — production hard-stop. |
49
- | `'local'` | No network. Pointers are left untouched. Use in tests + offline dev. |
50
- | `'hybrid'` | Try the server; on failure warn and leave whatever `process.env` already holds. |
51
-
52
- 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.
53
-
54
- ## Dev hot reload (opt-in)
55
-
56
- Pass a `dev` object to live-reload while a long-running dev process is up. Ignored when `NODE_ENV === 'production'`.
57
-
58
- ```ts
59
- await initSecretManager({
60
- url: process.env.LUCKYSTACK_SECRET_MANAGER_URL!,
61
- token: { fromFile: '.secret-manager-token' },
62
- dev: {
63
- watch: true, // re-read .env / .env.local on change (default true)
64
- pollIntervalMs: 5000, // also re-resolve every 5s (default off)
65
- // envFiles: ['.env', '.env.local'], // override which files are watched
66
- },
67
- });
68
- ```
69
-
70
- - **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.
71
- - **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.
72
-
73
- 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.
74
-
75
- ## Public API
76
-
77
- | Export | Purpose |
78
- | --- | --- |
79
- | `initSecretManager(config)` | Boot-time entry point. Resolves pointer-shaped env values and writes the real values into `process.env`. |
80
- | `refreshSecretManager()` | Re-resolve the captured pointers against the server (the poll channel; call manually after an admin rotates a secret). |
81
- | `reloadSecretManagerFromFiles()` | Re-parse the configured env files and apply them — plain values injected, pointers resolved. The file-watch channel; callable manually. |
82
- | `getCachedResolution()` | Returns the last `{ fetchedAt, values }` resolution (pointer -> value) for diagnostics, or `null`. |
83
- | `resetSecretManagerForTests()` | Test-only — clears module state and tears down dev watchers / timers. |
84
-
85
- ## The token file
86
-
87
- 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.
88
-
89
- ## Peer dependencies
90
-
91
- - **None.** This package speaks plain HTTP via global `fetch` and reads the token file with Node's built-in `fs`.
92
- - **Optional**: any `fetch` polyfill (e.g. `undici`) for non-Node-20 hosts — pass via `SecretManagerConfig.fetchImpl`.
93
-
94
- ## Documentation
95
-
96
- - [`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.
97
-
98
- ## License
99
-
100
- 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
+ });
30
+
31
+ const server = await bootstrapLuckyStack({ /* ... */ });
32
+ await server.listen();
33
+ ```
34
+
35
+ In your committed `.env`:
36
+
37
+ ```
38
+ OPENAI_KEY=OPENAI_AUTHORIZATION_KEY_V5
39
+ STRIPE_KEY=STRIPE_SECRET_KEY_V2
40
+ ```
41
+
42
+ After `initSecretManager` resolves, `process.env.OPENAI_KEY` holds the real `sk-...` value.
43
+
44
+ ## Modes
45
+
46
+ | `source` | Behavior |
47
+ | --- | --- |
48
+ | `'remote'` (default) | Resolve from the server. A missing pointer or an unreachable server **throws** — production hard-stop. |
49
+ | `'local'` | No network. Pointers are left untouched. Use in tests + offline dev. |
50
+ | `'hybrid'` | Try the server; on failure warn and leave whatever `process.env` already holds. |
51
+
52
+ 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.
53
+
54
+ ## Dev hot reload (opt-in)
55
+
56
+ Pass a `dev` object to live-reload while a long-running dev process is up. Ignored when `NODE_ENV === 'production'`.
57
+
58
+ ```ts
59
+ await initSecretManager({
60
+ url: process.env.LUCKYSTACK_SECRET_MANAGER_URL!,
61
+ token: { fromFile: '.secret-manager-token' },
62
+ dev: {
63
+ watch: true, // re-read .env / .env.local on change (default true)
64
+ pollIntervalMs: 5000, // also re-resolve every 5s (default off)
65
+ // envFiles: ['.env', '.env.local'], // override which files are watched
66
+ },
67
+ });
68
+ ```
69
+
70
+ - **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.
71
+ - **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.
72
+
73
+ 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.
74
+
75
+ ## Public API
76
+
77
+ | Export | Purpose |
78
+ | --- | --- |
79
+ | `initSecretManager(config)` | Boot-time entry point. Resolves pointer-shaped env values and writes the real values into `process.env`. |
80
+ | `refreshSecretManager()` | Re-resolve the captured pointers against the server (the poll channel; call manually after an admin rotates a secret). |
81
+ | `reloadSecretManagerFromFiles()` | Re-parse the configured env files and apply them — plain values injected, pointers resolved. The file-watch channel; callable manually. |
82
+ | `getCachedResolution()` | Returns the last `{ fetchedAt, values }` resolution (pointer -> value) for diagnostics, or `null`. |
83
+ | `resetSecretManagerForTests()` | Test-only — clears module state and tears down dev watchers / timers. |
84
+
85
+ ## The token file
86
+
87
+ 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.
88
+
89
+ ## Peer dependencies
90
+
91
+ - **None.** This package speaks plain HTTP via global `fetch` and reads the token file with Node's built-in `fs`.
92
+ - **Optional**: any `fetch` polyfill (e.g. `undici`) for non-Node-20 hosts — pass via `SecretManagerConfig.fetchImpl`.
93
+
94
+ ## Documentation
95
+
96
+ - [`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.
97
+
98
+ ## License
99
+
100
+ MIT — see [LICENSE](./LICENSE).
package/dist/index.d.ts CHANGED
@@ -16,11 +16,75 @@ interface SecretManagerConfig {
16
16
  /**
17
17
  * Override the pointer-shape detector. Any `process.env` value matching this
18
18
  * is treated as a pointer; anything else is a literal and left untouched.
19
- * Default `/^(.+)_V(\d+)$/`.
19
+ * Default `/^(.+)_V(\d+)$/`. A `g`/`y` flag is stripped (a stateful regex would
20
+ * misclassify alternating entries).
20
21
  */
21
22
  pointerPattern?: RegExp;
23
+ /**
24
+ * Scope which `process.env` entries are pointer-eligible by NAME. This allowlist
25
+ * is REQUIRED to resolve anything off-host: an array of names or a predicate.
26
+ *
27
+ * SECURE DEFAULT: when unset, NOTHING is resolved off-host (a clear boot warning
28
+ * is emitted instead). Scanning the entire inherited environment would POST any
29
+ * unrelated, pointer-shaped inherited value (`RELEASE_TAG=build_2024_V2`) to the
30
+ * secret-manager server — so an explicit allowlist is mandatory. To opt back into
31
+ * scanning every name, pass `() => true` as a deliberate, auditable choice.
32
+ */
33
+ envNames?: string[] | ((name: string) => boolean);
34
+ /**
35
+ * Allow a plain-`http:` server `url`. By default only `https:` is accepted for
36
+ * non-loopback hosts (the channel carries the bearer token + plaintext secrets);
37
+ * loopback (`localhost`/`127.0.0.1`/`[::1]`) is always permitted. Set `true` to
38
+ * permit `http:` to any host — a loud warning is still emitted.
39
+ */
40
+ allowInsecureHttp?: boolean;
41
+ /**
42
+ * Abort a resolve request that has not responded within this many ms. Prevents a
43
+ * black-hole server (accepts the TCP connection, never responds) from hanging boot
44
+ * until undici's ~300s default — and, in `'hybrid'`, from never reaching the
45
+ * warn-and-keep-local fallback (a hang never rejects). Default `10_000`. Set `0`
46
+ * to disable the timeout.
47
+ */
48
+ timeoutMs?: number;
49
+ /**
50
+ * Retry a failed resolve before giving up (transport error or non-2xx). Default
51
+ * `{ count: 0 }` (no retry). After exhaustion `'remote'` still throws and
52
+ * `'hybrid'` still warns-and-keeps-local.
53
+ */
54
+ retries?: {
55
+ count: number;
56
+ delayMs?: number;
57
+ };
58
+ /** Override the resolve path appended to `url`. Default `'/resolve'`. */
59
+ resolvePath?: string;
60
+ /** Extra request headers merged onto every resolve request (cannot override `Authorization`). */
61
+ headers?: Record<string, string>;
62
+ /**
63
+ * Called after a resolve writes new values into `process.env`, with ONLY the env
64
+ * NAMES whose value actually changed (never the secret values). A client that
65
+ * captured a secret at construction time (Prisma from `DATABASE_URL`, a Redis /
66
+ * Stripe / OpenAI SDK) can re-create its pool/client here so a rotation lands.
67
+ */
68
+ onApplied?: (changes: {
69
+ name: string;
70
+ pointer: string;
71
+ }[]) => void | Promise<void>;
72
+ /**
73
+ * Called when a resolve fails, alongside the existing `console.warn` (current
74
+ * behavior is unchanged when unset). Route the failure to Sentry/metrics —
75
+ * useful for `'hybrid'`/dev where a failure otherwise silently keeps stale env.
76
+ */
77
+ onResolveError?: (error: unknown, context: {
78
+ phase: 'boot' | 'refresh' | 'file-reload';
79
+ }) => void;
22
80
  /** Override the global `fetch` (tests / non-Node-20 hosts). */
23
81
  fetchImpl?: typeof fetch;
82
+ /**
83
+ * Re-resolve the current pointers every N ms in ALL environments (the production
84
+ * rotation poll), distinct from the dev-only `dev.pollIntervalMs` file-watch
85
+ * channel. Default `0` (disabled). Unref'd, so it never blocks process exit.
86
+ */
87
+ pollIntervalMs?: number;
24
88
  /**
25
89
  * Opt-in dev-only hot reload. Ignored when `NODE_ENV === 'production'`.
26
90
  * Provide an (even empty) object to enable it.
@@ -30,8 +94,7 @@ interface SecretManagerConfig {
30
94
  * Watch the env files and hot-reload on change. Default `true`. On change
31
95
  * the files are re-parsed and applied: plain (non-pointer) values are
32
96
  * injected straight into `process.env` (live config reload), and
33
- * pointer-shaped values are re-resolved against the server. Requires the
34
- * optional `dotenv` peer to parse the files.
97
+ * pointer-shaped values are re-resolved against the server.
35
98
  */
36
99
  watch?: boolean;
37
100
  /** Re-resolve the current pointers every N ms (server-rotation poll). Default `0` (disabled). */
@@ -67,9 +130,41 @@ declare const refreshSecretManager: () => Promise<void>;
67
130
  * `.env` / `.env.local` file watch; also callable manually.
68
131
  */
69
132
  declare const reloadSecretManagerFromFiles: () => Promise<void>;
70
- /** Read the last `{ fetchedAt, values }` resolution (pointer -> value), or `null`. */
133
+ /**
134
+ * Read the last `{ fetchedAt, values }` resolution (pointer -> value), or `null`.
135
+ *
136
+ * ⚠️ SENSITIVE: `values` maps each pointer to its RAW resolved secret. The result
137
+ * is a shallow copy (values are flat strings, so mutating the returned object does
138
+ * not corrupt the cache), but the values are still the real secrets — NEVER
139
+ * serialize the result into an HTTP response, a `/health` payload, or a log line.
140
+ * For a safe diagnostic use {@link getCachedResolutionMeta}, which never exposes
141
+ * the values.
142
+ */
71
143
  declare const getCachedResolution: () => CachedResolution | null;
144
+ /** Values-free view of {@link getCachedResolution}: the timestamp + resolved pointer NAMES only. */
145
+ interface CachedResolutionMeta {
146
+ /** `Date.now()` of the last successful resolve. */
147
+ fetchedAt: number;
148
+ /** The resolved pointer strings (never the secret values). */
149
+ pointerNames: string[];
150
+ /** How many pointers were resolved. */
151
+ pointerCount: number;
152
+ }
153
+ /**
154
+ * Safe diagnostic accessor: the last resolution's timestamp + resolved pointer
155
+ * NAMES, with the secret values stripped. Use this on any surface that might be
156
+ * logged or served (`/health`, metrics) so the convenient path is also the safe
157
+ * one — {@link getCachedResolution} is the values-carrying escape hatch.
158
+ */
159
+ declare const getCachedResolutionMeta: () => CachedResolutionMeta | null;
160
+ /**
161
+ * Tear down the dev file watchers, the dev poll, the rotation poll, and the
162
+ * debounce timer WITHOUT wiping the resolved cache / active config. Use for a
163
+ * graceful shutdown of an embedded resolver (worker / CLI) so no timers keep
164
+ * firing; the last resolved `process.env` values stay in place.
165
+ */
166
+ declare const stopSecretManager: () => void;
72
167
  /** Test-only — clear module state and tear down any dev watchers / timers. */
73
168
  declare const resetSecretManagerForTests: () => void;
74
169
 
75
- export { type CachedResolution, type SecretManagerConfig, type SecretManagerToken, getCachedResolution, initSecretManager, refreshSecretManager, reloadSecretManagerFromFiles, resetSecretManagerForTests };
170
+ export { type CachedResolution, type CachedResolutionMeta, type SecretManagerConfig, type SecretManagerToken, getCachedResolution, getCachedResolutionMeta, initSecretManager, refreshSecretManager, reloadSecretManagerFromFiles, resetSecretManagerForTests, stopSecretManager };