@luckystack/secret-manager 0.1.2 → 0.1.4

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,81 @@
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-only — clears 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. | -> 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-only — clears 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`.
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.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["//? @luckystack/secret-manager — rotation-aware secret resolver client.\n//?\n//? Idea: secrets live in a central secret-manager server (append-only,\n//? versioned, one shared bearer token). Apps commit their `.env` to git, but\n//? instead of real secrets it holds POINTERS:\n//?\n//? OPENAI_KEY=OPENAI_AUTHORIZATION_KEY_V5\n//?\n//? A pointer has the shape `<BASE>_V<number>`. At boot this client scans\n//? `process.env`, collects every pointer-shaped value, asks the server to\n//? resolve them in ONE request, and overwrites each `process.env` entry with\n//? the real secret — so downstream code reads `process.env.OPENAI_KEY` and gets\n//? the resolved value, not the pointer. Rotating a secret means publishing a\n//? new version (`..._V6`) on the server, never editing an old one, so old git\n//? branches that still point at `..._V5` keep booting.\n//?\n//? Three modes:\n//? 1. `source: 'remote'` (default) — resolve from the server. A missing\n//? pointer or an unreachable server throws — production hard-stop.\n//? 2. `source: 'local'` — no network. Pointers are left untouched. Use in\n//? tests / offline dev when the server isn't reachable.\n//? 3. `source: 'hybrid'` — try the server, but on failure warn and leave\n//? whatever `process.env` already holds. Use for staging / canary.\n//?\n//? Optional dev-only hot reload (`config.dev`) re-resolves on `.env` file\n//? changes and/or on an interval, so server-side rotations are picked up\n//? without restarting a long-running dev process. It is a no-op in production.\n\nimport { readFileSync, watch as fsWatch, type FSWatcher } from 'node:fs';\n\n/** Bearer token: a literal string, or a file whose entire contents are the token. */\nexport type SecretManagerToken = string | { fromFile: string };\n\nexport interface SecretManagerConfig {\n /** Base URL of the secret-manager server (trailing slash optional). */\n url: string;\n /**\n * Shared bearer token. Either the literal string or `{ fromFile }` pointing\n * at a gitignored file whose entire contents are the token (read at resolve\n * time, so rotating the file is picked up by the next poll / refresh).\n */\n token: SecretManagerToken;\n /** Resolution mode. Default `'remote'`. */\n source?: 'remote' | 'local' | 'hybrid';\n /**\n * Override the pointer-shape detector. Any `process.env` value matching this\n * is treated as a pointer; anything else is a literal and left untouched.\n * Default `/^(.+)_V(\\d+)$/`.\n */\n pointerPattern?: RegExp;\n /** Override the global `fetch` (tests / non-Node-20 hosts). */\n fetchImpl?: typeof fetch;\n /**\n * Opt-in dev-only hot reload. Ignored when `NODE_ENV === 'production'`.\n * Provide an (even empty) object to enable it.\n */\n dev?: {\n /**\n * Watch the env files and hot-reload on change. Default `true`. On change\n * the files are re-parsed and applied: plain (non-pointer) values are\n * injected straight into `process.env` (live config reload), and\n * pointer-shaped values are re-resolved against the server. Requires the\n * optional `dotenv` peer to parse the files.\n */\n watch?: boolean;\n /** Re-resolve the current pointers every N ms (server-rotation poll). Default `0` (disabled). */\n pollIntervalMs?: number;\n /** Env files to watch + reparse, in load order (later overrides earlier). Default `['.env', '.env.local']`. */\n envFiles?: string[];\n };\n}\n\nexport interface CachedResolution {\n /** `Date.now()` of the last successful resolve. */\n fetchedAt: number;\n /** Map of pointer string -> resolved value (what the server returned). */\n values: Record<string, string>;\n}\n\nconst DEFAULT_POINTER_PATTERN = /^(.+)_V(\\d+)$/;\nconst DEFAULT_ENV_FILES = ['.env', '.env.local'];\n\n//? Module state. The resolver is meant to run once per process at boot; these\n//? bindings keep it idempotent and let dev hot-reload re-resolve later.\nlet cachedResolution: CachedResolution | null = null;\n//? envName -> pointer string, captured once on first resolve. Reused on every\n//? refresh because the first resolve OVERWRITES the env value with the real\n//? secret, after which it no longer looks like a pointer.\nlet pointerMap: Record<string, string> | null = null;\nlet activeConfig: SecretManagerConfig | null = null;\n\n//? Dev hot-reload handles, torn down by resetSecretManagerForTests.\nlet devReloadStarted = false;\nlet pollTimer: ReturnType<typeof setInterval> | null = null;\nlet debounceTimer: ReturnType<typeof setTimeout> | null = null;\nconst fileWatchers: FSWatcher[] = [];\n\nconst capturePointers = (pattern: RegExp): Record<string, string> => {\n const map: Record<string, string> = {};\n for (const [name, value] of Object.entries(process.env)) {\n if (typeof value === 'string' && pattern.test(value)) {\n map[name] = value;\n }\n }\n return map;\n};\n\nconst resolveToken = (token: SecretManagerToken): string =>\n typeof token === 'string' ? token : readFileSync(token.fromFile, 'utf8').trim();\n\nconst fetchResolve = async (\n config: SecretManagerConfig,\n pointers: string[],\n): Promise<Record<string, string>> => {\n //? Defaults to the Node 20+ global fetch; pass fetchImpl for older hosts.\n const fetchFn = config.fetchImpl ?? globalThis.fetch;\n const endpoint = `${config.url.replace(/\\/+$/, '')}/resolve`;\n const response = await fetchFn(endpoint, {\n method: 'POST',\n headers: {\n 'Authorization': `Bearer ${resolveToken(config.token)}`,\n 'Content-Type': 'application/json',\n 'Accept': 'application/json',\n },\n body: JSON.stringify({ keys: pointers }),\n });\n\n if (!response.ok) {\n throw new Error(`[secret-manager] Resolve request failed: ${String(response.status)} ${response.statusText}`);\n }\n\n const body = await response.json() as { values?: Record<string, string> };\n if (!body.values || typeof body.values !== 'object') {\n throw new Error('[secret-manager] Resolve response missing `values` object.');\n }\n\n return body.values;\n};\n\nconst applyResolved = (\n map: Record<string, string>,\n values: Record<string, string>,\n source: 'remote' | 'hybrid',\n): void => {\n //? In remote mode a single unresolved pointer is a hard boot failure. Check\n //? everything BEFORE mutating process.env so the failure is atomic.\n if (source === 'remote') {\n const missing = Object.entries(map).filter(([, pointer]) => values[pointer] === undefined);\n if (missing.length > 0) {\n const detail = missing.map(([name, pointer]) => `${pointer} (referenced by ${name})`).join(', ');\n throw new Error(`[secret-manager] Server did not resolve: ${detail}.`);\n }\n }\n\n for (const [name, pointer] of Object.entries(map)) {\n const value = values[pointer];\n if (value === undefined) {\n //? hybrid only — leave the pointer in place and warn so the operator sees it.\n console.warn(`[secret-manager] Pointer \"${pointer}\" (referenced by ${name}) not resolved; leaving \"${name}\" as-is.`);\n continue;\n }\n process.env[name] = value;\n }\n};\n\nconst doResolve = async (config: SecretManagerConfig): Promise<void> => {\n const source = config.source ?? 'remote';\n if (source === 'local') return;\n\n pointerMap ??= capturePointers(config.pointerPattern ?? DEFAULT_POINTER_PATTERN);\n const pointers = [...new Set(Object.values(pointerMap))];\n if (pointers.length === 0) {\n cachedResolution = { fetchedAt: Date.now(), values: {} };\n return;\n }\n\n try {\n const values = await fetchResolve(config, pointers);\n applyResolved(pointerMap, values, source);\n cachedResolution = { fetchedAt: Date.now(), values };\n } catch (error) {\n if (source === 'hybrid') {\n console.warn('[secret-manager] Resolve failed, leaving local env as-is:', error);\n return;\n }\n throw error;\n }\n};\n\n//? Minimal .env parser kept in-package so the resolver stays dependency-free.\n//? Handles standard `KEY=VALUE` lines, full-line + inline (` #`) comments, and\n//? single/double-quoted values. Not multi-line values or escape sequences.\nconst parseEnvFile = (content: string): Record<string, string> => {\n const out: Record<string, string> = {};\n for (const rawLine of content.split(/\\r?\\n/)) {\n const line = rawLine.trim();\n if (!line || line.startsWith('#')) continue;\n const eq = line.indexOf('=');\n if (eq <= 0) continue;\n const key = line.slice(0, eq).trim();\n if (!/^[\\w.-]+$/.test(key)) continue;\n let value = line.slice(eq + 1).trim();\n const quote = value[0];\n if ((quote === '\"' || quote === \"'\") && value.length >= 2 && value.endsWith(quote)) {\n value = value.slice(1, -1);\n } else {\n const commentAt = value.indexOf(' #');\n if (commentAt !== -1) value = value.slice(0, commentAt).trim();\n if (value.startsWith('#')) value = '';\n }\n out[key] = value;\n }\n return out;\n};\n\nconst scheduleReload = (): void => {\n //? Debounce — fs.watch can fire several events for one save.\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n void reloadSecretManagerFromFiles().catch((error: unknown) => {\n console.warn('[secret-manager] dev reload failed:', error);\n });\n }, 200);\n debounceTimer.unref();\n};\n\nconst startDevReload = (config: SecretManagerConfig): void => {\n if (devReloadStarted || config.dev === undefined) return;\n if (process.env.NODE_ENV === 'production') return;\n\n const watch = config.dev.watch ?? true;\n const pollIntervalMs = config.dev.pollIntervalMs ?? 0;\n if (!watch && pollIntervalMs <= 0) return;\n devReloadStarted = true;\n\n if (watch) {\n for (const file of config.dev.envFiles ?? DEFAULT_ENV_FILES) {\n try {\n const watcher = fsWatch(file, () => {\n scheduleReload();\n });\n watcher.unref();\n fileWatchers.push(watcher);\n } catch {\n //? The file may not exist (e.g. no .env.local) — nothing to watch.\n }\n }\n }\n\n if (pollIntervalMs > 0) {\n pollTimer = setInterval(() => {\n void refreshSecretManager().catch((error: unknown) => {\n console.warn('[secret-manager] poll refresh failed:', error);\n });\n }, pollIntervalMs);\n pollTimer.unref();\n }\n};\n\n/**\n * Initialize the secret-manager resolver. Call once as the very first line of\n * `server.ts`, BEFORE any other framework code reads `process.env`. In\n * `'remote'` / `'hybrid'` mode the resolved secrets are written into\n * `process.env` before this resolves, so downstream code sees them via the\n * standard `process.env.FOO` lookup. In `'local'` mode it is a no-op.\n */\nexport const initSecretManager = async (config: SecretManagerConfig): Promise<void> => {\n activeConfig = config;\n if ((config.source ?? 'remote') === 'local') return;\n await doResolve(config);\n startDevReload(config);\n};\n\n/**\n * Re-resolve against the server, ignoring nothing — used by the dev hot-reload\n * watch/poll and callable manually when an admin rotates a secret and you want\n * a long-running process to pick it up without a restart.\n */\nexport const refreshSecretManager = async (): Promise<void> => {\n if (!activeConfig || (activeConfig.source ?? 'remote') === 'local') return;\n await doResolve(activeConfig);\n};\n\n/**\n * Dev hot-reload entry: re-parse the configured env files and apply them — plain\n * (non-pointer) values are injected straight into `process.env` (live config\n * reload), pointer-shaped values are re-resolved against the server. Wired to the\n * `.env` / `.env.local` file watch; also callable manually.\n */\nexport const reloadSecretManagerFromFiles = async (): Promise<void> => {\n const config = activeConfig;\n if (!config || (config.source ?? 'remote') === 'local') return;\n\n const files = config.dev?.envFiles ?? DEFAULT_ENV_FILES;\n const pattern = config.pointerPattern ?? DEFAULT_POINTER_PATTERN;\n\n //? Re-read every file in load order; later files (e.g. .env.local) override.\n const merged: Record<string, string> = {};\n for (const file of files) {\n let content: string;\n try {\n content = readFileSync(file, 'utf8');\n } catch {\n continue; //? file may not exist (e.g. no .env.local)\n }\n Object.assign(merged, parseEnvFile(content));\n }\n\n //? Plain values inject directly (live config reload); pointer-shaped values go\n //? to the server. Swap in the fresh pointer set so this resolve + later polls\n //? use it (this is also how a pointer added after boot gets picked up).\n const freshPointerMap: Record<string, string> = {};\n for (const [name, value] of Object.entries(merged)) {\n if (pattern.test(value)) {\n freshPointerMap[name] = value;\n } else {\n process.env[name] = value;\n }\n }\n pointerMap = freshPointerMap;\n await doResolve(config);\n};\n\n/** Read the last `{ fetchedAt, values }` resolution (pointer -> value), or `null`. */\nexport const getCachedResolution = (): CachedResolution | null => cachedResolution;\n\n/** Test-only — clear module state and tear down any dev watchers / timers. */\nexport const resetSecretManagerForTests = (): void => {\n cachedResolution = null;\n pointerMap = null;\n activeConfig = null;\n devReloadStarted = false;\n if (pollTimer) {\n clearInterval(pollTimer);\n pollTimer = null;\n }\n if (debounceTimer) {\n clearTimeout(debounceTimer);\n debounceTimer = null;\n }\n for (const watcher of fileWatchers) {\n watcher.close();\n }\n fileWatchers.length = 0;\n};\n"],"mappings":";AA4BA,SAAS,cAAc,SAAS,eAA+B;AAmD/D,IAAM,0BAA0B;AAChC,IAAM,oBAAoB,CAAC,QAAQ,YAAY;AAI/C,IAAI,mBAA4C;AAIhD,IAAI,aAA4C;AAChD,IAAI,eAA2C;AAG/C,IAAI,mBAAmB;AACvB,IAAI,YAAmD;AACvD,IAAI,gBAAsD;AAC1D,IAAM,eAA4B,CAAC;AAEnC,IAAM,kBAAkB,CAAC,YAA4C;AACnE,QAAM,MAA8B,CAAC;AACrC,aAAW,CAAC,MAAM,KAAK,KAAK,OAAO,QAAQ,QAAQ,GAAG,GAAG;AACvD,QAAI,OAAO,UAAU,YAAY,QAAQ,KAAK,KAAK,GAAG;AACpD,UAAI,IAAI,IAAI;AAAA,IACd;AAAA,EACF;AACA,SAAO;AACT;AAEA,IAAM,eAAe,CAAC,UACpB,OAAO,UAAU,WAAW,QAAQ,aAAa,MAAM,UAAU,MAAM,EAAE,KAAK;AAEhF,IAAM,eAAe,OACnB,QACA,aACoC;AAEpC,QAAM,UAAU,OAAO,aAAa,WAAW;AAC/C,QAAM,WAAW,GAAG,OAAO,IAAI,QAAQ,QAAQ,EAAE,CAAC;AAClD,QAAM,WAAW,MAAM,QAAQ,UAAU;AAAA,IACvC,QAAQ;AAAA,IACR,SAAS;AAAA,MACP,iBAAiB,UAAU,aAAa,OAAO,KAAK,CAAC;AAAA,MACrD,gBAAgB;AAAA,MAChB,UAAU;AAAA,IACZ;AAAA,IACA,MAAM,KAAK,UAAU,EAAE,MAAM,SAAS,CAAC;AAAA,EACzC,CAAC;AAED,MAAI,CAAC,SAAS,IAAI;AAChB,UAAM,IAAI,MAAM,4CAA4C,OAAO,SAAS,MAAM,CAAC,IAAI,SAAS,UAAU,EAAE;AAAA,EAC9G;AAEA,QAAM,OAAO,MAAM,SAAS,KAAK;AACjC,MAAI,CAAC,KAAK,UAAU,OAAO,KAAK,WAAW,UAAU;AACnD,UAAM,IAAI,MAAM,4DAA4D;AAAA,EAC9E;AAEA,SAAO,KAAK;AACd;AAEA,IAAM,gBAAgB,CACpB,KACA,QACA,WACS;AAGT,MAAI,WAAW,UAAU;AACvB,UAAM,UAAU,OAAO,QAAQ,GAAG,EAAE,OAAO,CAAC,CAAC,EAAE,OAAO,MAAM,OAAO,OAAO,MAAM,MAAS;AACzF,QAAI,QAAQ,SAAS,GAAG;AACtB,YAAM,SAAS,QAAQ,IAAI,CAAC,CAAC,MAAM,OAAO,MAAM,GAAG,OAAO,mBAAmB,IAAI,GAAG,EAAE,KAAK,IAAI;AAC/F,YAAM,IAAI,MAAM,4CAA4C,MAAM,GAAG;AAAA,IACvE;AAAA,EACF;AAEA,aAAW,CAAC,MAAM,OAAO,KAAK,OAAO,QAAQ,GAAG,GAAG;AACjD,UAAM,QAAQ,OAAO,OAAO;AAC5B,QAAI,UAAU,QAAW;AAEvB,cAAQ,KAAK,6BAA6B,OAAO,oBAAoB,IAAI,4BAA4B,IAAI,UAAU;AACnH;AAAA,IACF;AACA,YAAQ,IAAI,IAAI,IAAI;AAAA,EACtB;AACF;AAEA,IAAM,YAAY,OAAO,WAA+C;AACtE,QAAM,SAAS,OAAO,UAAU;AAChC,MAAI,WAAW,QAAS;AAExB,iBAAe,gBAAgB,OAAO,kBAAkB,uBAAuB;AAC/E,QAAM,WAAW,CAAC,GAAG,IAAI,IAAI,OAAO,OAAO,UAAU,CAAC,CAAC;AACvD,MAAI,SAAS,WAAW,GAAG;AACzB,uBAAmB,EAAE,WAAW,KAAK,IAAI,GAAG,QAAQ,CAAC,EAAE;AACvD;AAAA,EACF;AAEA,MAAI;AACF,UAAM,SAAS,MAAM,aAAa,QAAQ,QAAQ;AAClD,kBAAc,YAAY,QAAQ,MAAM;AACxC,uBAAmB,EAAE,WAAW,KAAK,IAAI,GAAG,OAAO;AAAA,EACrD,SAAS,OAAO;AACd,QAAI,WAAW,UAAU;AACvB,cAAQ,KAAK,6DAA6D,KAAK;AAC/E;AAAA,IACF;AACA,UAAM;AAAA,EACR;AACF;AAKA,IAAM,eAAe,CAAC,YAA4C;AAChE,QAAM,MAA8B,CAAC;AACrC,aAAW,WAAW,QAAQ,MAAM,OAAO,GAAG;AAC5C,UAAM,OAAO,QAAQ,KAAK;AAC1B,QAAI,CAAC,QAAQ,KAAK,WAAW,GAAG,EAAG;AACnC,UAAM,KAAK,KAAK,QAAQ,GAAG;AAC3B,QAAI,MAAM,EAAG;AACb,UAAM,MAAM,KAAK,MAAM,GAAG,EAAE,EAAE,KAAK;AACnC,QAAI,CAAC,YAAY,KAAK,GAAG,EAAG;AAC5B,QAAI,QAAQ,KAAK,MAAM,KAAK,CAAC,EAAE,KAAK;AACpC,UAAM,QAAQ,MAAM,CAAC;AACrB,SAAK,UAAU,OAAO,UAAU,QAAQ,MAAM,UAAU,KAAK,MAAM,SAAS,KAAK,GAAG;AAClF,cAAQ,MAAM,MAAM,GAAG,EAAE;AAAA,IAC3B,OAAO;AACL,YAAM,YAAY,MAAM,QAAQ,IAAI;AACpC,UAAI,cAAc,GAAI,SAAQ,MAAM,MAAM,GAAG,SAAS,EAAE,KAAK;AAC7D,UAAI,MAAM,WAAW,GAAG,EAAG,SAAQ;AAAA,IACrC;AACA,QAAI,GAAG,IAAI;AAAA,EACb;AACA,SAAO;AACT;AAEA,IAAM,iBAAiB,MAAY;AAEjC,MAAI,cAAe,cAAa,aAAa;AAC7C,kBAAgB,WAAW,MAAM;AAC/B,SAAK,6BAA6B,EAAE,MAAM,CAAC,UAAmB;AAC5D,cAAQ,KAAK,uCAAuC,KAAK;AAAA,IAC3D,CAAC;AAAA,EACH,GAAG,GAAG;AACN,gBAAc,MAAM;AACtB;AAEA,IAAM,iBAAiB,CAAC,WAAsC;AAC5D,MAAI,oBAAoB,OAAO,QAAQ,OAAW;AAClD,MAAI,QAAQ,IAAI,aAAa,aAAc;AAE3C,QAAM,QAAQ,OAAO,IAAI,SAAS;AAClC,QAAM,iBAAiB,OAAO,IAAI,kBAAkB;AACpD,MAAI,CAAC,SAAS,kBAAkB,EAAG;AACnC,qBAAmB;AAEnB,MAAI,OAAO;AACT,eAAW,QAAQ,OAAO,IAAI,YAAY,mBAAmB;AAC3D,UAAI;AACF,cAAM,UAAU,QAAQ,MAAM,MAAM;AAClC,yBAAe;AAAA,QACjB,CAAC;AACD,gBAAQ,MAAM;AACd,qBAAa,KAAK,OAAO;AAAA,MAC3B,QAAQ;AAAA,MAER;AAAA,IACF;AAAA,EACF;AAEA,MAAI,iBAAiB,GAAG;AACtB,gBAAY,YAAY,MAAM;AAC5B,WAAK,qBAAqB,EAAE,MAAM,CAAC,UAAmB;AACpD,gBAAQ,KAAK,yCAAyC,KAAK;AAAA,MAC7D,CAAC;AAAA,IACH,GAAG,cAAc;AACjB,cAAU,MAAM;AAAA,EAClB;AACF;AASO,IAAM,oBAAoB,OAAO,WAA+C;AACrF,iBAAe;AACf,OAAK,OAAO,UAAU,cAAc,QAAS;AAC7C,QAAM,UAAU,MAAM;AACtB,iBAAe,MAAM;AACvB;AAOO,IAAM,uBAAuB,YAA2B;AAC7D,MAAI,CAAC,iBAAiB,aAAa,UAAU,cAAc,QAAS;AACpE,QAAM,UAAU,YAAY;AAC9B;AAQO,IAAM,+BAA+B,YAA2B;AACrE,QAAM,SAAS;AACf,MAAI,CAAC,WAAW,OAAO,UAAU,cAAc,QAAS;AAExD,QAAM,QAAQ,OAAO,KAAK,YAAY;AACtC,QAAM,UAAU,OAAO,kBAAkB;AAGzC,QAAM,SAAiC,CAAC;AACxC,aAAW,QAAQ,OAAO;AACxB,QAAI;AACJ,QAAI;AACF,gBAAU,aAAa,MAAM,MAAM;AAAA,IACrC,QAAQ;AACN;AAAA,IACF;AACA,WAAO,OAAO,QAAQ,aAAa,OAAO,CAAC;AAAA,EAC7C;AAKA,QAAM,kBAA0C,CAAC;AACjD,aAAW,CAAC,MAAM,KAAK,KAAK,OAAO,QAAQ,MAAM,GAAG;AAClD,QAAI,QAAQ,KAAK,KAAK,GAAG;AACvB,sBAAgB,IAAI,IAAI;AAAA,IAC1B,OAAO;AACL,cAAQ,IAAI,IAAI,IAAI;AAAA,IACtB;AAAA,EACF;AACA,eAAa;AACb,QAAM,UAAU,MAAM;AACxB;AAGO,IAAM,sBAAsB,MAA+B;AAG3D,IAAM,6BAA6B,MAAY;AACpD,qBAAmB;AACnB,eAAa;AACb,iBAAe;AACf,qBAAmB;AACnB,MAAI,WAAW;AACb,kBAAc,SAAS;AACvB,gBAAY;AAAA,EACd;AACA,MAAI,eAAe;AACjB,iBAAa,aAAa;AAC1B,oBAAgB;AAAA,EAClB;AACA,aAAW,WAAW,cAAc;AAClC,YAAQ,MAAM;AAAA,EAChB;AACA,eAAa,SAAS;AACxB;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["//? @luckystack/secret-manager — rotation-aware secret resolver client.\r\n//?\r\n//? Idea: secrets live in a central secret-manager server (append-only,\r\n//? versioned, one shared bearer token). Apps commit their `.env` to git, but\r\n//? instead of real secrets it holds POINTERS:\r\n//?\r\n//? OPENAI_KEY=OPENAI_AUTHORIZATION_KEY_V5\r\n//?\r\n//? A pointer has the shape `<BASE>_V<number>`. At boot this client scans\r\n//? `process.env`, collects every pointer-shaped value, asks the server to\r\n//? resolve them in ONE request, and overwrites each `process.env` entry with\r\n//? the real secret — so downstream code reads `process.env.OPENAI_KEY` and gets\r\n//? the resolved value, not the pointer. Rotating a secret means publishing a\r\n//? new version (`..._V6`) on the server, never editing an old one, so old git\r\n//? branches that still point at `..._V5` keep booting.\r\n//?\r\n//? Three modes:\r\n//? 1. `source: 'remote'` (default) — resolve from the server. A missing\r\n//? pointer or an unreachable server throws — production hard-stop.\r\n//? 2. `source: 'local'` — no network. Pointers are left untouched. Use in\r\n//? tests / offline dev when the server isn't reachable.\r\n//? 3. `source: 'hybrid'` — try the server, but on failure warn and leave\r\n//? whatever `process.env` already holds. Use for staging / canary.\r\n//?\r\n//? Optional dev-only hot reload (`config.dev`) re-resolves on `.env` file\r\n//? changes and/or on an interval, so server-side rotations are picked up\r\n//? without restarting a long-running dev process. It is a no-op in production.\r\n\r\nimport { readFileSync, watch as fsWatch, type FSWatcher } from 'node:fs';\r\n\r\n/** Bearer token: a literal string, or a file whose entire contents are the token. */\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+)$/`.\r\n */\r\n pointerPattern?: RegExp;\r\n /** Override the global `fetch` (tests / non-Node-20 hosts). */\r\n fetchImpl?: typeof fetch;\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. Requires the\r\n * optional `dotenv` peer to parse the files.\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//? 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//? Dev hot-reload handles, torn down by resetSecretManagerForTests.\r\nlet devReloadStarted = false;\r\nlet pollTimer: ReturnType<typeof setInterval> | null = null;\r\nlet debounceTimer: ReturnType<typeof setTimeout> | null = null;\r\nconst fileWatchers: FSWatcher[] = [];\r\n\r\nconst capturePointers = (pattern: RegExp): Record<string, string> => {\r\n const map: Record<string, string> = {};\r\n for (const [name, value] of Object.entries(process.env)) {\r\n if (typeof value === 'string' && pattern.test(value)) {\r\n map[name] = value;\r\n }\r\n }\r\n return map;\r\n};\r\n\r\nconst resolveToken = (token: SecretManagerToken): string =>\r\n typeof token === 'string' ? token : readFileSync(token.fromFile, 'utf8').trim();\r\n\r\nconst fetchResolve = 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 endpoint = `${config.url.replace(/\\/+$/, '')}/resolve`;\r\n const response = await fetchFn(endpoint, {\r\n method: 'POST',\r\n 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 });\r\n\r\n if (!response.ok) {\r\n throw new Error(`[secret-manager] Resolve request failed: ${String(response.status)} ${response.statusText}`);\r\n }\r\n\r\n const body = await response.json() as { values?: Record<string, string> };\r\n if (!body.values || typeof body.values !== 'object') {\r\n throw new Error('[secret-manager] Resolve response missing `values` object.');\r\n }\r\n\r\n return body.values;\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): void => {\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 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 process.env[name] = value;\r\n }\r\n};\r\n\r\nconst doResolve = async (config: SecretManagerConfig): Promise<void> => {\r\n const source = config.source ?? 'remote';\r\n if (source === 'local') return;\r\n\r\n pointerMap ??= capturePointers(config.pointerPattern ?? DEFAULT_POINTER_PATTERN);\r\n const pointers = [...new Set(Object.values(pointerMap))];\r\n if (pointers.length === 0) {\r\n cachedResolution = { fetchedAt: Date.now(), values: {} };\r\n return;\r\n }\r\n\r\n try {\r\n const values = await fetchResolve(config, pointers);\r\n applyResolved(pointerMap, values, source);\r\n cachedResolution = { fetchedAt: Date.now(), values };\r\n } catch (error) {\r\n if (source === 'hybrid') {\r\n console.warn('[secret-manager] Resolve failed, leaving local env as-is:', error);\r\n return;\r\n }\r\n throw error;\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 if (!/^[\\w.-]+$/.test(key)) continue;\r\n let value = line.slice(eq + 1).trim();\r\n const quote = value[0];\r\n if ((quote === '\"' || quote === \"'\") && value.length >= 2 && value.endsWith(quote)) {\r\n value = value.slice(1, -1);\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:', 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 if (process.env.NODE_ENV === 'production') 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 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:', 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 activeConfig = config;\r\n if ((config.source ?? 'remote') === 'local') return;\r\n await doResolve(config);\r\n startDevReload(config);\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);\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 = 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 const merged: Record<string, string> = {};\r\n for (const file of files) {\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 //? Plain values inject directly (live config reload); pointer-shaped values go\r\n //? to the server. Swap in the fresh pointer set so this resolve + later polls\r\n //? use it (this is also how a pointer added after boot gets picked up).\r\n const freshPointerMap: Record<string, string> = {};\r\n for (const [name, value] of Object.entries(merged)) {\r\n if (pattern.test(value)) {\r\n freshPointerMap[name] = value;\r\n } else {\r\n process.env[name] = value;\r\n }\r\n }\r\n pointerMap = freshPointerMap;\r\n await doResolve(config);\r\n};\r\n\r\n/** Read the last `{ fetchedAt, values }` resolution (pointer -> value), or `null`. */\r\nexport const getCachedResolution = (): CachedResolution | null => cachedResolution;\r\n\r\n/** Test-only — clear module state and tear down any dev watchers / timers. */\r\nexport const resetSecretManagerForTests = (): void => {\r\n cachedResolution = null;\r\n pointerMap = null;\r\n activeConfig = null;\r\n devReloadStarted = false;\r\n if (pollTimer) {\r\n clearInterval(pollTimer);\r\n pollTimer = 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};\r\n"],"mappings":"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","names":[]}
@@ -1,114 +1,114 @@
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
-
27
- ## Boot flow
28
-
29
- ```
30
- process start
31
- -> dotenv loads .env / .env.local (consumer's server.ts)
32
- -> initSecretManager(...) <-- THIS CLIENT, first line of server.ts
33
- 1. capture { envName -> pointer } from process.env (once)
34
- 2. POST /resolve { keys: [unique pointers] }
35
- 3. overwrite process.env[envName] = values[pointer]
36
- -> framework boot (reads the resolved process.env)
37
- -> server.listen()
38
- ```
39
-
40
- `initSecretManager` runs **before** anything else that reads `process.env` at module-init time.
41
-
42
- ## Modes
43
-
44
- | `source` | Behavior | Writes to `process.env`? |
45
- | --- | --- | --- |
46
- | `'remote'` (default) | Resolve from the server. A missing pointer or fetch error throws — production hard-stop. | After a successful resolve. |
47
- | `'local'` | No network. Pointers untouched. Tests / offline dev. | Never. |
48
- | `'hybrid'` | Try the server; on failure warn and keep local env. | Only on a successful resolve; missing pointers are warned and left as-is. |
49
-
50
- ## Dev hot reload (opt-in, dev-only)
51
-
52
- Set `config.dev` to live-reload while a long-running dev process is up (no-op when `NODE_ENV === 'production'`):
53
-
54
- - `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.
55
- - `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.
56
- - `dev.envFiles` — override which files are watched + re-parsed (default `['.env', '.env.local']`).
57
-
58
- 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.
59
-
60
- ## Auth model
61
-
62
- A single shared bearer token accompanies every request:
63
-
64
- ```
65
- Authorization: Bearer <token>
66
- ```
67
-
68
- 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.
69
-
70
- ## What this package does NOT do
71
-
72
- - **No secret storage.** No on-disk persistence beyond reading the token file. The in-memory cache is plain JS, discarded on exit.
73
- - **No versioning logic.** It sends the full pointer string and writes back whatever the server returns. Version naming + resolution is the server's job.
74
- - **No admin / write operations.** Read-only `POST /resolve`. Publishing new versions happens through the server's own UI.
75
- - **No app-key validation.** Whether `DATABASE_URL` is a valid URL is the app's concern (validate after `initSecretManager` returns).
76
-
77
- ## External server — wire contract (separate repo)
78
-
79
- The server lives in its own repo (`luckystack-secret-manager`). The client only depends on this one endpoint:
80
-
81
- ### `POST /resolve`
82
-
83
- Request (the app sends only the pointers it references):
84
-
85
- ```json
86
- { "keys": ["OPENAI_AUTHORIZATION_KEY_V5", "STRIPE_SECRET_KEY_V2"] }
87
- ```
88
-
89
- Response:
90
-
91
- ```json
92
- { "values": { "OPENAI_AUTHORIZATION_KEY_V5": "sk-...", "STRIPE_SECRET_KEY_V2": "rk-..." } }
93
- ```
94
-
95
- - Auth: `Authorization: Bearer <token>` (the shared token). 401 on mismatch.
96
- - 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'`.
97
-
98
- 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.
99
-
100
- ## Implementation status
101
-
102
- | Piece | Status | Where |
103
- | --- | --- | --- |
104
- | Resolver client (this package) | Implemented | `packages/secret-manager/src/index.ts` |
105
- | local / remote / hybrid modes | Implemented | this doc |
106
- | Opt-in dev hot reload (watch + poll) | Implemented | this doc |
107
- | External secret-manager server (storage, versioning, admin UI, auth) | **Separate repo** | `luckystack-secret-manager` |
108
-
109
- ## Related
110
-
111
- - Function index: `../CLAUDE.md`.
112
- - Consumer quickstart: `../README.md`.
113
- - Framework-wide packaging map: `/docs/PACKAGE_OVERVIEW.md`.
114
- - 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
+
27
+ ## Boot flow
28
+
29
+ ```
30
+ process start
31
+ -> dotenv loads .env / .env.local (consumer's server.ts)
32
+ -> initSecretManager(...) <-- THIS CLIENT, first line of server.ts
33
+ 1. capture { envName -> pointer } from process.env (once)
34
+ 2. POST /resolve { keys: [unique pointers] }
35
+ 3. overwrite process.env[envName] = values[pointer]
36
+ -> framework boot (reads the resolved process.env)
37
+ -> server.listen()
38
+ ```
39
+
40
+ `initSecretManager` runs **before** anything else that reads `process.env` at module-init time.
41
+
42
+ ## Modes
43
+
44
+ | `source` | Behavior | Writes to `process.env`? |
45
+ | --- | --- | --- |
46
+ | `'remote'` (default) | Resolve from the server. A missing pointer or fetch error throws — production hard-stop. | After a successful resolve. |
47
+ | `'local'` | No network. Pointers untouched. Tests / offline dev. | Never. |
48
+ | `'hybrid'` | Try the server; on failure warn and keep local env. | Only on a successful resolve; missing pointers are warned and left as-is. |
49
+
50
+ ## Dev hot reload (opt-in, dev-only)
51
+
52
+ Set `config.dev` to live-reload while a long-running dev process is up (no-op when `NODE_ENV === 'production'`):
53
+
54
+ - `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.
55
+ - `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.
56
+ - `dev.envFiles` — override which files are watched + re-parsed (default `['.env', '.env.local']`).
57
+
58
+ 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.
59
+
60
+ ## Auth model
61
+
62
+ A single shared bearer token accompanies every request:
63
+
64
+ ```
65
+ Authorization: Bearer <token>
66
+ ```
67
+
68
+ 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.
69
+
70
+ ## What this package does NOT do
71
+
72
+ - **No secret storage.** No on-disk persistence beyond reading the token file. The in-memory cache is plain JS, discarded on exit.
73
+ - **No versioning logic.** It sends the full pointer string and writes back whatever the server returns. Version naming + resolution is the server's job.
74
+ - **No admin / write operations.** Read-only `POST /resolve`. Publishing new versions happens through the server's own UI.
75
+ - **No app-key validation.** Whether `DATABASE_URL` is a valid URL is the app's concern (validate after `initSecretManager` returns).
76
+
77
+ ## External server — wire contract (separate repo)
78
+
79
+ The server lives in its own repo (`luckystack-secret-manager`). The client only depends on this one endpoint:
80
+
81
+ ### `POST /resolve`
82
+
83
+ Request (the app sends only the pointers it references):
84
+
85
+ ```json
86
+ { "keys": ["OPENAI_AUTHORIZATION_KEY_V5", "STRIPE_SECRET_KEY_V2"] }
87
+ ```
88
+
89
+ Response:
90
+
91
+ ```json
92
+ { "values": { "OPENAI_AUTHORIZATION_KEY_V5": "sk-...", "STRIPE_SECRET_KEY_V2": "rk-..." } }
93
+ ```
94
+
95
+ - Auth: `Authorization: Bearer <token>` (the shared token). 401 on mismatch.
96
+ - 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'`.
97
+
98
+ 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.
99
+
100
+ ## Implementation status
101
+
102
+ | Piece | Status | Where |
103
+ | --- | --- | --- |
104
+ | Resolver client (this package) | Implemented | `packages/secret-manager/src/index.ts` |
105
+ | local / remote / hybrid modes | Implemented | this doc |
106
+ | Opt-in dev hot reload (watch + poll) | Implemented | this doc |
107
+ | External secret-manager server (storage, versioning, admin UI, auth) | **Separate repo** | `luckystack-secret-manager` |
108
+
109
+ ## Related
110
+
111
+ - Function index: `../CLAUDE.md`.
112
+ - Consumer quickstart: `../README.md`.
113
+ - Framework-wide packaging map: `/docs/PACKAGE_OVERVIEW.md`.
114
+ - 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.1.2",
3
+ "version": "0.1.4",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"