@luckystack/secret-manager
v0.8.3
Published
Rotation-aware secret resolver client for LuckyStack. Commit `.env` pointers (e.g. OPENAI_KEY=OPENAI_AUTHORIZATION_KEY_V5) instead of real secrets; at boot this client resolves them against an external append-only secret-manager server and writes the real
Maintainers
Readme
@luckystack/secret-manager
Rotation-aware secret resolver client. Commit
.envpointers instead of real secrets; resolve them against an external secret-manager server at boot. Part of LuckyStack.
@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.
For the full design (pointer model, append-only versioning, the external server's wire contract), read docs/architecture.md.
Install
npm install @luckystack/secret-managerRequires Node >= 20 (uses global fetch). For older Node, inject a polyfill via fetchImpl.
Quickstart
Call initSecretManager as the very first line of your server.ts, before any other framework code reads process.env.
import { initSecretManager } from '@luckystack/secret-manager';
import { bootstrapLuckyStack } from '@luckystack/server';
await initSecretManager({
url: process.env.LUCKYSTACK_SECRET_MANAGER_URL!,
token: { fromFile: '.secret-manager-token' }, // gitignored file, one line = the token
source: 'hybrid', // try the server, keep local env on failure
envNames: ['OPENAI_KEY', 'STRIPE_KEY'], // REQUIRED — allowlist of names to resolve
});
const server = await bootstrapLuckyStack({ /* ... */ });
await server.listen();
envNamesis required to resolve anything. It is the allowlist ofprocess.envNAMES that are eligible for resolution. Secure default: whenenvNamesis unset, NOTHING is resolved off-host — the client logs a one-time boot warning and leaves every value as-is (a deny-all no-op). This prevents the client from POSTing an unrelated, pointer-shaped inherited value (e.g. a CIRELEASE_TAG=build_2024_V2) to the server. See Scoping resolution withenvNames.
In your committed .env:
OPENAI_KEY=OPENAI_AUTHORIZATION_KEY_V5
STRIPE_KEY=STRIPE_SECRET_KEY_V2After initSecretManager resolves, process.env.OPENAI_KEY holds the real sk-... value.
Modes
| source | Behavior |
| --- | --- |
| 'remote' (default) | Resolve from the server. A missing pointer or an unreachable server throws — production hard-stop. |
| 'local' | No network. Pointers are left untouched. Use in tests + offline dev. |
| 'hybrid' | Try the server; on failure warn and leave whatever process.env already holds. |
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.
Scoping resolution with envNames
envNames is the allowlist of process.env NAMES the client is allowed to resolve. It is required to resolve anything — pass an array of names or a predicate:
envNames: ['OPENAI_KEY', 'STRIPE_KEY'] // explicit allowlist (recommended)
envNames: (name) => name.endsWith('_KEY') // or a predicate
envNames: () => true // deliberately scan EVERY env nameSecure default — deny-all. When envNames is unset, nothing is resolved off-host: the client emits a one-time boot warning and leaves every value as-is. This is intentional. Without a name allowlist the resolver would scan the entire inherited environment and POST any unrelated, pointer-shaped value (a CI RELEASE_TAG=build_2024_V2, a shell TERM-like ..._V2) to the secret-manager server. So you must opt in explicitly — either list the names you control, or pass () => true as a deliberate, auditable choice to scan everything.
If you call
initSecretManagerand see[secret-manager] \envNames` is not set: NO environment values will be resolved off-host, that is this guard: addenvNames`.
Dev hot reload (opt-in)
Pass a dev object to live-reload while a long-running dev process is up. Ignored when NODE_ENV === 'production'.
await initSecretManager({
url: process.env.LUCKYSTACK_SECRET_MANAGER_URL!,
token: { fromFile: '.secret-manager-token' },
dev: {
watch: true, // re-read .env / .env.local on change (default true)
pollIntervalMs: 5000, // also re-resolve every 5s (default off)
// envFiles: ['.env', '.env.local'], // override which files are watched
},
});- On file change (
watch): the env files are re-parsed and applied — plain values (e.g.ENVIRONMENT=production,PORT=123) are injected straight intoprocess.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. - On the poll interval (
pollIntervalMs): the current pointers are re-resolved, catching server-side rotations. The interval lives in yourconfig.ts, so it's changeable in one place.
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.
Public API
| Export | Purpose |
| --- | --- |
| initSecretManager(config) | Boot-time entry point. Resolves pointer-shaped env values and writes the real values into process.env. |
| refreshSecretManager() | Re-resolve the captured pointers against the server (the poll channel; call manually after an admin rotates a secret). |
| reloadSecretManagerFromFiles() | Re-parse the configured env files and apply them — plain values injected, pointers resolved. The file-watch channel; callable manually. |
| getCachedResolution() | Returns a shallow copy of the last { fetchedAt, values } resolution (pointer -> value), or null. ⚠️ SENSITIVE — values are the RAW resolved secrets; never serialize into an HTTP response, a /health payload, or a log line. For a safe diagnostic use getCachedResolutionMeta(). |
| getCachedResolutionMeta() | Values-free diagnostic view: { fetchedAt, pointerNames, pointerCount } — resolved pointer NAMES only, never the secret values. Safe for logs and /health endpoints. |
| resetSecretManagerForTests() | Test-only — clears module state and tears down dev watchers / timers. |
The token file
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.
Peer dependencies
- None. This package speaks plain HTTP via global
fetchand reads the token file with Node's built-infs. - Optional: any
fetchpolyfill (e.g.undici) for non-Node-20 hosts — pass viaSecretManagerConfig.fetchImpl.
Documentation
docs/architecture.md— pointer model, append-only versioning, the external server'sPOST /resolvewire contract, and what this package does NOT do.
License
MIT — see LICENSE.
