@crewhaus/secrets-manager 0.1.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/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@crewhaus/secrets-manager",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Pluggable secrets backend with rotation + audit-log integration: env-var, file, vault",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/audit-log": "0.0.0",
16
+ "@crewhaus/errors": "0.0.0",
17
+ "@crewhaus/logging": "0.0.0"
18
+ },
19
+ "license": "Apache-2.0",
20
+ "author": {
21
+ "name": "Max Meier",
22
+ "email": "max@studiomax.io",
23
+ "url": "https://studiomax.io"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/crewhaus/factory.git",
28
+ "directory": "packages/secrets-manager"
29
+ },
30
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/secrets-manager#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/crewhaus/factory/issues"
33
+ },
34
+ "publishConfig": {
35
+ "access": "restricted"
36
+ },
37
+ "files": [
38
+ "src",
39
+ "README.md",
40
+ "LICENSE",
41
+ "NOTICE"
42
+ ]
43
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * env-var backend — the default. `rotate()` is a no-op (the OS owns the
3
+ * env, not us); a logger warning is emitted when one is supplied so the
4
+ * caller knows the rotation didn't really happen.
5
+ */
6
+ import type { Logger } from "@crewhaus/logging";
7
+ import { type SecretValue, type SecretsBackend, SecretsError } from "../index";
8
+
9
+ export type EnvVarBackendOptions = {
10
+ /** Default: `process.env`. Override for tests. */
11
+ readonly env?: NodeJS.ProcessEnv;
12
+ readonly logger?: Logger;
13
+ };
14
+
15
+ export function createEnvVarBackend(opts: EnvVarBackendOptions = {}): SecretsBackend {
16
+ const env = opts.env ?? process.env;
17
+ return {
18
+ id: "env-var",
19
+ async get(name: string): Promise<SecretValue> {
20
+ const value = env[name];
21
+ if (value === undefined || value === "") {
22
+ throw new SecretsError(`secret "${name}" not found in env (env-var backend)`);
23
+ }
24
+ return value;
25
+ },
26
+ async rotate(name: string, rotateOpts): Promise<SecretValue> {
27
+ // env-var is read-only at the secrets layer; rotation requires
28
+ // restarting the process with new env. We accept an explicit
29
+ // newValue so callers can model "I just exported $NAME" — but we
30
+ // can't actually mutate the parent process's env.
31
+ if (rotateOpts?.newValue !== undefined) {
32
+ env[name] = rotateOpts.newValue;
33
+ }
34
+ if (opts.logger) {
35
+ opts.logger.warn("secrets.rotate.env-var.no-op", {
36
+ name,
37
+ msg: "env-var backend does not own rotation; rotate at the orchestrator instead",
38
+ });
39
+ }
40
+ const v = env[name];
41
+ if (v === undefined) {
42
+ throw new SecretsError(
43
+ `secret "${name}" cannot be rotated via env-var backend without a newValue`,
44
+ );
45
+ }
46
+ return v;
47
+ },
48
+ async list(): Promise<ReadonlyArray<string>> {
49
+ return Object.keys(env).filter((k) => env[k] !== undefined && env[k] !== "");
50
+ },
51
+ };
52
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * file backend — reads `<rootDir>/<name>` (mode 0o600 enforced on write).
3
+ * Rotation is an atomic rewrite: write to `.<name>.tmp`, then rename.
4
+ *
5
+ * The file content is the raw secret value (no JSON wrapper, no
6
+ * trailing newline-stripping ambiguity). Whitespace is preserved as-is
7
+ * for tokens that may legitimately contain it.
8
+ */
9
+ import { existsSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { type SecretValue, type SecretsBackend, SecretsError } from "../index";
12
+
13
+ export type FileBackendOptions = {
14
+ /** Default: `.crewhaus/secrets`. */
15
+ readonly rootDir: string;
16
+ };
17
+
18
+ export function createFileBackend(opts: FileBackendOptions): SecretsBackend {
19
+ const rootDir = opts.rootDir;
20
+
21
+ function pathFor(name: string): string {
22
+ if (!/^[A-Za-z0-9_.-]+$/.test(name)) {
23
+ throw new SecretsError(`invalid secret name "${name}" (must match [A-Za-z0-9_.-]+)`);
24
+ }
25
+ return join(rootDir, name);
26
+ }
27
+
28
+ return {
29
+ id: "file",
30
+ async get(name: string): Promise<SecretValue> {
31
+ const p = pathFor(name);
32
+ if (!existsSync(p)) {
33
+ throw new SecretsError(`secret "${name}" not found at ${p}`);
34
+ }
35
+ return readFileSync(p, "utf8");
36
+ },
37
+ async rotate(name: string, rotateOpts): Promise<SecretValue> {
38
+ const p = pathFor(name);
39
+ const newValue = rotateOpts?.newValue ?? generateRandomSecret();
40
+ const tmp = `${p}.tmp`;
41
+ writeFileSync(tmp, newValue, { encoding: "utf8", mode: 0o600 });
42
+ renameSync(tmp, p);
43
+ return newValue;
44
+ },
45
+ async list(): Promise<ReadonlyArray<string>> {
46
+ if (!existsSync(rootDir)) return [];
47
+ return readdirSync(rootDir).filter((f) => !f.endsWith(".tmp") && !f.startsWith("."));
48
+ },
49
+ };
50
+ }
51
+
52
+ function generateRandomSecret(): string {
53
+ // 32 bytes hex → 64 chars; sufficient for tokens.
54
+ const bytes = new Uint8Array(32);
55
+ crypto.getRandomValues(bytes);
56
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
57
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * vault backend — HashiCorp Vault KV v2 over HTTP. Reads
3
+ * `<addr>/v1/<mount>/data/<name>` and writes via PUT to the same path.
4
+ *
5
+ * Auth: token via `VAULT_TOKEN` (or constructor option). The simplest
6
+ * path that doesn't need approles or k8s service accounts.
7
+ */
8
+ import { type SecretValue, type SecretsBackend, SecretsError } from "../index";
9
+
10
+ export type VaultBackendOptions = {
11
+ /** Vault address (e.g. `http://127.0.0.1:8200`). */
12
+ readonly addr: string;
13
+ /** KV v2 mount point (default: `secret`). */
14
+ readonly mount?: string;
15
+ /** Vault token. Falls back to `VAULT_TOKEN` env. */
16
+ readonly token?: string;
17
+ /** Optional fetch override for tests. */
18
+ readonly fetchImpl?: typeof fetch;
19
+ };
20
+
21
+ export function createVaultBackend(opts: VaultBackendOptions): SecretsBackend {
22
+ const mount = opts.mount ?? "secret";
23
+ const fetchImpl = opts.fetchImpl ?? fetch;
24
+
25
+ function getToken(): string {
26
+ const t = opts.token ?? process.env["VAULT_TOKEN"];
27
+ if (!t) {
28
+ throw new SecretsError(
29
+ "vault backend requires a token (constructor opts.token or VAULT_TOKEN env)",
30
+ );
31
+ }
32
+ return t;
33
+ }
34
+
35
+ function dataUrl(name: string): string {
36
+ if (!/^[A-Za-z0-9_/.-]+$/.test(name)) {
37
+ throw new SecretsError(`invalid secret name "${name}" for vault backend`);
38
+ }
39
+ return `${opts.addr}/v1/${encodeURIComponent(mount)}/data/${name}`;
40
+ }
41
+
42
+ return {
43
+ id: "vault",
44
+ async get(name: string): Promise<SecretValue> {
45
+ const url = dataUrl(name);
46
+ const res = await fetchImpl(url, {
47
+ headers: { "X-Vault-Token": getToken() },
48
+ });
49
+ if (res.status === 404) {
50
+ throw new SecretsError(`secret "${name}" not found in vault at ${url}`);
51
+ }
52
+ if (!res.ok) {
53
+ throw new SecretsError(`vault GET ${name} returned ${res.status}: ${await res.text()}`);
54
+ }
55
+ const body = (await res.json()) as { data?: { data?: { value?: string } } };
56
+ const v = body?.data?.data?.value;
57
+ if (typeof v !== "string") {
58
+ throw new SecretsError(
59
+ `vault response for "${name}" missing data.data.value (KV v2 expected)`,
60
+ );
61
+ }
62
+ return v;
63
+ },
64
+ async rotate(name: string, rotateOpts): Promise<SecretValue> {
65
+ const url = dataUrl(name);
66
+ const newValue = rotateOpts?.newValue ?? generateRandomSecret();
67
+ const res = await fetchImpl(url, {
68
+ method: "PUT",
69
+ headers: {
70
+ "X-Vault-Token": getToken(),
71
+ "Content-Type": "application/json",
72
+ },
73
+ body: JSON.stringify({ data: { value: newValue } }),
74
+ });
75
+ if (!res.ok) {
76
+ throw new SecretsError(`vault PUT ${name} returned ${res.status}: ${await res.text()}`);
77
+ }
78
+ return newValue;
79
+ },
80
+ };
81
+ }
82
+
83
+ function generateRandomSecret(): string {
84
+ const bytes = new Uint8Array(32);
85
+ crypto.getRandomValues(bytes);
86
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
87
+ }
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Section 27 — `secrets-manager` tests:
3
+ * - T1 per backend (env-var, file, vault)
4
+ * - T8 cross-tenant secret isolation
5
+ * - T3 rotation callback within 5s of rotate()
6
+ */
7
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { type AuditRecord, openAuditLog } from "@crewhaus/audit-log";
12
+ import {
13
+ SecretsError,
14
+ createEnvVarBackend,
15
+ createFileBackend,
16
+ createSecrets,
17
+ createVaultBackend,
18
+ } from "./index";
19
+
20
+ let tmpRoot = "";
21
+
22
+ beforeEach(() => {
23
+ tmpRoot = mkdtempSync(join(tmpdir(), "secrets-test-"));
24
+ });
25
+
26
+ afterEach(() => {
27
+ rmSync(tmpRoot, { recursive: true, force: true });
28
+ });
29
+
30
+ describe("env-var backend (T1)", () => {
31
+ test("get returns the env value", async () => {
32
+ const backend = createEnvVarBackend({
33
+ env: { MY_SECRET: "abc123" } as NodeJS.ProcessEnv,
34
+ });
35
+ expect(await backend.get("MY_SECRET")).toBe("abc123");
36
+ });
37
+
38
+ test("get throws when missing", async () => {
39
+ const backend = createEnvVarBackend({ env: {} as NodeJS.ProcessEnv });
40
+ expect(backend.get("MISSING")).rejects.toBeInstanceOf(SecretsError);
41
+ });
42
+
43
+ test("get throws on empty-string value", async () => {
44
+ const backend = createEnvVarBackend({
45
+ env: { EMPTY: "" } as NodeJS.ProcessEnv,
46
+ });
47
+ expect(backend.get("EMPTY")).rejects.toBeInstanceOf(SecretsError);
48
+ });
49
+
50
+ test("rotate(newValue) overwrites the env entry and returns it", async () => {
51
+ const env: NodeJS.ProcessEnv = { TOKEN: "old" };
52
+ const backend = createEnvVarBackend({ env });
53
+ const v = await backend.rotate("TOKEN", { newValue: "new" });
54
+ expect(v).toBe("new");
55
+ expect(env["TOKEN"]).toBe("new");
56
+ });
57
+ });
58
+
59
+ describe("file backend (T1)", () => {
60
+ test("get returns the file contents", async () => {
61
+ const root = join(tmpRoot, "secrets");
62
+ require("node:fs").mkdirSync(root);
63
+ writeFileSync(join(root, "API_KEY"), "secret-value");
64
+ const backend = createFileBackend({ rootDir: root });
65
+ expect(await backend.get("API_KEY")).toBe("secret-value");
66
+ });
67
+
68
+ test("get throws when file missing", async () => {
69
+ const root = join(tmpRoot, "secrets");
70
+ require("node:fs").mkdirSync(root);
71
+ const backend = createFileBackend({ rootDir: root });
72
+ expect(backend.get("MISSING")).rejects.toBeInstanceOf(SecretsError);
73
+ });
74
+
75
+ test("rotate writes atomically (mode 0o600) and returns the new value", async () => {
76
+ const root = join(tmpRoot, "secrets");
77
+ require("node:fs").mkdirSync(root);
78
+ writeFileSync(join(root, "TOKEN"), "old", { mode: 0o600 });
79
+ const backend = createFileBackend({ rootDir: root });
80
+ const v = await backend.rotate("TOKEN", { newValue: "fresh-token-xyz" });
81
+ expect(v).toBe("fresh-token-xyz");
82
+ expect(readFileSync(join(root, "TOKEN"), "utf8")).toBe("fresh-token-xyz");
83
+ });
84
+
85
+ test("rotate generates a hex token when newValue is omitted", async () => {
86
+ const root = join(tmpRoot, "secrets");
87
+ require("node:fs").mkdirSync(root);
88
+ const backend = createFileBackend({ rootDir: root });
89
+ const v = await backend.rotate("AUTO");
90
+ expect(v).toMatch(/^[a-f0-9]{64}$/);
91
+ });
92
+
93
+ test("rejects malformed names (T8 path-traversal defense)", async () => {
94
+ const backend = createFileBackend({ rootDir: tmpRoot });
95
+ expect(backend.get("../../../etc/passwd")).rejects.toBeInstanceOf(SecretsError);
96
+ expect(backend.rotate("path/with/slash")).rejects.toBeInstanceOf(SecretsError);
97
+ });
98
+ });
99
+
100
+ describe("vault backend (T1)", () => {
101
+ test("get reads via KV v2 endpoint", async () => {
102
+ let observedUrl = "";
103
+ let observedToken = "";
104
+ const fetchImpl = (async (url: string, init?: RequestInit) => {
105
+ observedUrl = url;
106
+ observedToken = (init?.headers as Record<string, string>)?.["X-Vault-Token"] ?? "";
107
+ return new Response(JSON.stringify({ data: { data: { value: "vault-secret" } } }), {
108
+ status: 200,
109
+ });
110
+ }) as unknown as typeof fetch;
111
+ const backend = createVaultBackend({
112
+ addr: "http://127.0.0.1:8200",
113
+ token: "test-token",
114
+ fetchImpl,
115
+ });
116
+ const v = await backend.get("MY_KEY");
117
+ expect(v).toBe("vault-secret");
118
+ expect(observedUrl).toBe("http://127.0.0.1:8200/v1/secret/data/MY_KEY");
119
+ expect(observedToken).toBe("test-token");
120
+ });
121
+
122
+ test("get throws on 404", async () => {
123
+ const fetchImpl = (async () =>
124
+ new Response("not found", { status: 404 })) as unknown as typeof fetch;
125
+ const backend = createVaultBackend({
126
+ addr: "http://127.0.0.1:8200",
127
+ token: "t",
128
+ fetchImpl,
129
+ });
130
+ expect(backend.get("MISSING")).rejects.toBeInstanceOf(SecretsError);
131
+ });
132
+
133
+ test("rotate PUTs the new value", async () => {
134
+ let observedBody = "";
135
+ const fetchImpl = (async (_url: string, init?: RequestInit) => {
136
+ if (init?.method === "PUT") {
137
+ observedBody = init.body as string;
138
+ return new Response("{}", { status: 204 });
139
+ }
140
+ return new Response("not found", { status: 404 });
141
+ }) as unknown as typeof fetch;
142
+ const backend = createVaultBackend({
143
+ addr: "http://127.0.0.1:8200",
144
+ token: "t",
145
+ fetchImpl,
146
+ });
147
+ const v = await backend.rotate("KEY", { newValue: "fresh" });
148
+ expect(v).toBe("fresh");
149
+ expect(JSON.parse(observedBody)).toEqual({ data: { value: "fresh" } });
150
+ });
151
+
152
+ test("missing token throws", async () => {
153
+ const oldToken = process.env["VAULT_TOKEN"];
154
+ process.env["VAULT_TOKEN"] = undefined;
155
+ const backend = createVaultBackend({ addr: "http://127.0.0.1:8200" });
156
+ expect(backend.get("X")).rejects.toBeInstanceOf(SecretsError);
157
+ if (oldToken !== undefined) process.env["VAULT_TOKEN"] = oldToken;
158
+ });
159
+ });
160
+
161
+ describe("createSecrets — rotation handlers (T3)", () => {
162
+ test("onRotation handlers fire within 5s of rotate()", async () => {
163
+ const root = join(tmpRoot, "secrets");
164
+ require("node:fs").mkdirSync(root);
165
+ writeFileSync(join(root, "TOKEN"), "old");
166
+ const secrets = createSecrets({
167
+ backend: createFileBackend({ rootDir: root }),
168
+ });
169
+
170
+ const events: Array<{ name: string; newValue: string; rotatedAt: number }> = [];
171
+ secrets.onRotation((e) => {
172
+ events.push({ name: e.name, newValue: e.newValue, rotatedAt: e.rotatedAt });
173
+ });
174
+
175
+ const t0 = Date.now();
176
+ await secrets.rotate("TOKEN", { newValue: "new-value" });
177
+ const elapsed = Date.now() - t0;
178
+
179
+ expect(elapsed).toBeLessThan(5_000);
180
+ expect(events.length).toBe(1);
181
+ expect(events[0]?.name).toBe("TOKEN");
182
+ expect(events[0]?.newValue).toBe("new-value");
183
+ });
184
+
185
+ test("multiple handlers fire in order; one throwing does not block others", async () => {
186
+ const root = join(tmpRoot, "secrets");
187
+ require("node:fs").mkdirSync(root);
188
+ writeFileSync(join(root, "TOKEN"), "old");
189
+ const secrets = createSecrets({ backend: createFileBackend({ rootDir: root }) });
190
+
191
+ const calls: string[] = [];
192
+ secrets.onRotation(() => {
193
+ calls.push("first");
194
+ throw new Error("first handler boom");
195
+ });
196
+ secrets.onRotation(() => {
197
+ calls.push("second");
198
+ });
199
+
200
+ await secrets.rotate("TOKEN", { newValue: "new" });
201
+ expect(calls).toEqual(["first", "second"]);
202
+ });
203
+
204
+ test("unsubscribe stops further notifications", async () => {
205
+ const root = join(tmpRoot, "secrets");
206
+ require("node:fs").mkdirSync(root);
207
+ writeFileSync(join(root, "TOKEN"), "old");
208
+ const secrets = createSecrets({ backend: createFileBackend({ rootDir: root }) });
209
+ let calls = 0;
210
+ const off = secrets.onRotation(() => {
211
+ calls++;
212
+ });
213
+ await secrets.rotate("TOKEN", { newValue: "v1" });
214
+ off();
215
+ await secrets.rotate("TOKEN", { newValue: "v2" });
216
+ expect(calls).toBe(1);
217
+ });
218
+ });
219
+
220
+ describe("createSecrets — audit-log integration (T8 tenant isolation)", () => {
221
+ async function readAuditRecords(rootDir: string): Promise<AuditRecord[]> {
222
+ const audit = await openAuditLog({ rootDir });
223
+ const out: AuditRecord[] = [];
224
+ for await (const rec of audit.read()) out.push(rec);
225
+ return out;
226
+ }
227
+
228
+ test("audit-log records secrets_access only when tenant scoped", async () => {
229
+ const root = join(tmpRoot, "secrets");
230
+ const auditRoot = join(tmpRoot, "audit");
231
+ require("node:fs").mkdirSync(root);
232
+ writeFileSync(join(root, "K"), "v");
233
+
234
+ const audit = await openAuditLog({ rootDir: auditRoot });
235
+ const secretsTenantA = createSecrets({
236
+ backend: createFileBackend({ rootDir: root }),
237
+ auditLog: audit,
238
+ tenantId: "tenant-a",
239
+ });
240
+ const secretsNoTenant = createSecrets({
241
+ backend: createFileBackend({ rootDir: root }),
242
+ auditLog: audit,
243
+ });
244
+
245
+ await secretsTenantA.get("K");
246
+ await secretsNoTenant.get("K");
247
+
248
+ const records = await readAuditRecords(auditRoot);
249
+ expect(records.length).toBe(1);
250
+ expect(records[0]?.kind).toBe("secrets_access");
251
+ expect((records[0]?.payload as { tenantId: string }).tenantId).toBe("tenant-a");
252
+ });
253
+
254
+ test("rotate also audit-logs and includes timestamp", async () => {
255
+ const root = join(tmpRoot, "secrets");
256
+ const auditRoot = join(tmpRoot, "audit");
257
+ require("node:fs").mkdirSync(root);
258
+ writeFileSync(join(root, "K"), "v");
259
+
260
+ const audit = await openAuditLog({ rootDir: auditRoot });
261
+ const secrets = createSecrets({
262
+ backend: createFileBackend({ rootDir: root }),
263
+ auditLog: audit,
264
+ tenantId: "tenant-a",
265
+ });
266
+
267
+ await secrets.rotate("K", { newValue: "v2" });
268
+ const records = await readAuditRecords(auditRoot);
269
+ expect(records.length).toBe(1);
270
+ expect(records[0]?.kind).toBe("secrets_rotation");
271
+ const payload = records[0]?.payload as {
272
+ tenantId: string;
273
+ name: string;
274
+ backend: string;
275
+ rotatedAt: number;
276
+ };
277
+ expect(payload.tenantId).toBe("tenant-a");
278
+ expect(payload.name).toBe("K");
279
+ expect(payload.backend).toBe("file");
280
+ expect(typeof payload.rotatedAt).toBe("number");
281
+ });
282
+ });
283
+
284
+ describe("createSecrets — doctor()", () => {
285
+ test("reports available + missing for known names", async () => {
286
+ const root = join(tmpRoot, "secrets");
287
+ require("node:fs").mkdirSync(root);
288
+ writeFileSync(join(root, "EXISTS"), "v");
289
+ const secrets = createSecrets({
290
+ backend: createFileBackend({ rootDir: root }),
291
+ knownSecrets: ["EXISTS", "MISSING"],
292
+ });
293
+ const report = await secrets.doctor();
294
+ expect(report.backend).toBe("file");
295
+ expect(report.available).toEqual(["EXISTS"]);
296
+ expect(report.missing).toEqual(["MISSING"]);
297
+ });
298
+ });
package/src/index.ts ADDED
@@ -0,0 +1,161 @@
1
+ import type { AuditLog } from "@crewhaus/audit-log";
2
+ /**
3
+ * Section 27 — `secrets-manager`. Pluggable secret storage with rotation
4
+ * callbacks and audit-log integration. Three backends:
5
+ * - **env-var** (default; rotation is a no-op + warning)
6
+ * - **file** (reads from `.crewhaus/secrets/<name>`; rotation = atomic rewrite)
7
+ * - **vault** (HashiCorp Vault HTTP API, KV v2 backend)
8
+ *
9
+ * Long-running daemons (CHN gateway, MGD gateway, RES daemon) subscribe to
10
+ * `onRotation(handler)` so they refresh in-flight credentials without
11
+ * restart. Every `get` and `rotate` is audit-logged when a tenant id is
12
+ * configured.
13
+ */
14
+ import { CrewhausError } from "@crewhaus/errors";
15
+ import { createEnvVarBackend } from "./backends/env-var";
16
+ import { createFileBackend } from "./backends/file";
17
+ import { createVaultBackend } from "./backends/vault";
18
+
19
+ export class SecretsError extends CrewhausError {
20
+ override readonly name = "SecretsError";
21
+ constructor(message: string, cause?: unknown) {
22
+ super("config", message, cause);
23
+ }
24
+ }
25
+
26
+ export type SecretValue = string;
27
+
28
+ export type RotationHandler = (event: {
29
+ readonly name: string;
30
+ readonly newValue: SecretValue;
31
+ readonly rotatedAt: number;
32
+ }) => void | Promise<void>;
33
+
34
+ export interface SecretsBackend {
35
+ readonly id: "env-var" | "file" | "vault";
36
+ /** Returns the current value, or throws SecretsError if missing. */
37
+ get(name: string): Promise<SecretValue>;
38
+ /**
39
+ * Rotate the named secret. Implementations may generate a new value or
40
+ * accept an externally-supplied one via `opts.newValue`. Returns the
41
+ * new value so callers can verify the rotation took.
42
+ */
43
+ rotate(name: string, opts?: { readonly newValue?: SecretValue }): Promise<SecretValue>;
44
+ /** Optional health check. Returns the names this backend can resolve. */
45
+ list?(): Promise<ReadonlyArray<string>>;
46
+ }
47
+
48
+ export interface Secrets {
49
+ /** Resolve the named secret. Audit-logs the access when tenantId is set. */
50
+ get(name: string): Promise<SecretValue>;
51
+ /**
52
+ * Rotate the named secret, fire all `onRotation` handlers, and audit-log
53
+ * the rotation when tenantId is set. Returns the new value.
54
+ */
55
+ rotate(name: string, opts?: { readonly newValue?: SecretValue }): Promise<SecretValue>;
56
+ /** Subscribe to rotation events. Returns an unsubscribe function. */
57
+ onRotation(handler: RotationHandler): () => void;
58
+ /** Switch to a fresh backend. Used by tests + the doctor command. */
59
+ doctor(): Promise<DoctorReport>;
60
+ /** Backend identifier for diagnostics. */
61
+ readonly backendId: SecretsBackend["id"];
62
+ }
63
+
64
+ export type DoctorReport = {
65
+ readonly backend: SecretsBackend["id"];
66
+ readonly available: ReadonlyArray<string>;
67
+ readonly missing: ReadonlyArray<string>;
68
+ /** Rotation TTLs known to be due (file/vault track this; env-var returns []). */
69
+ readonly rotationDue: ReadonlyArray<string>;
70
+ };
71
+
72
+ export type CreateSecretsOptions = {
73
+ readonly backend: SecretsBackend;
74
+ readonly auditLog?: AuditLog;
75
+ readonly tenantId?: string;
76
+ /** Names to validate in `doctor()`. */
77
+ readonly knownSecrets?: ReadonlyArray<string>;
78
+ };
79
+
80
+ export function createSecrets(opts: CreateSecretsOptions): Secrets {
81
+ const handlers = new Set<RotationHandler>();
82
+ return {
83
+ backendId: opts.backend.id,
84
+
85
+ async get(name): Promise<SecretValue> {
86
+ const value = await opts.backend.get(name);
87
+ if (opts.auditLog && opts.tenantId !== undefined) {
88
+ await opts.auditLog.append({
89
+ kind: "secrets_access",
90
+ payload: { tenantId: opts.tenantId, name, backend: opts.backend.id },
91
+ });
92
+ }
93
+ return value;
94
+ },
95
+
96
+ async rotate(name, rotateOpts): Promise<SecretValue> {
97
+ const newValue = await opts.backend.rotate(name, rotateOpts);
98
+ const rotatedAt = Date.now();
99
+ if (opts.auditLog && opts.tenantId !== undefined) {
100
+ await opts.auditLog.append({
101
+ kind: "secrets_rotation",
102
+ payload: {
103
+ tenantId: opts.tenantId,
104
+ name,
105
+ backend: opts.backend.id,
106
+ rotatedAt,
107
+ },
108
+ });
109
+ }
110
+ // Fire handlers in order. A handler that throws does not block siblings.
111
+ const event = { name, newValue, rotatedAt };
112
+ const promises: Array<Promise<void>> = [];
113
+ for (const h of handlers) {
114
+ try {
115
+ const result = h(event);
116
+ if (result && typeof (result as Promise<void>).then === "function") {
117
+ promises.push(
118
+ (result as Promise<void>).catch(() => {
119
+ /* swallow per-handler errors */
120
+ }),
121
+ );
122
+ }
123
+ } catch {
124
+ /* swallow per-handler errors */
125
+ }
126
+ }
127
+ await Promise.all(promises);
128
+ return newValue;
129
+ },
130
+
131
+ onRotation(h): () => void {
132
+ handlers.add(h);
133
+ return () => {
134
+ handlers.delete(h);
135
+ };
136
+ },
137
+
138
+ async doctor(): Promise<DoctorReport> {
139
+ const known = opts.knownSecrets ?? [];
140
+ const available: string[] = [];
141
+ const missing: string[] = [];
142
+ for (const name of known) {
143
+ try {
144
+ await opts.backend.get(name);
145
+ available.push(name);
146
+ } catch {
147
+ missing.push(name);
148
+ }
149
+ }
150
+ return {
151
+ backend: opts.backend.id,
152
+ available,
153
+ missing,
154
+ rotationDue: [],
155
+ };
156
+ },
157
+ };
158
+ }
159
+
160
+ // Re-export backends so callers can construct directly.
161
+ export { createEnvVarBackend, createFileBackend, createVaultBackend };