@crewhaus/secrets-manager 0.1.1 → 0.1.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/secrets-manager",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "Pluggable secrets backend with rotation + audit-log integration: env-var, file, vault",
6
6
  "main": "src/index.ts",
@@ -12,15 +12,15 @@
12
12
  "test": "bun test src"
13
13
  },
14
14
  "dependencies": {
15
- "@crewhaus/audit-log": "0.1.1",
16
- "@crewhaus/errors": "0.1.1",
17
- "@crewhaus/logging": "0.1.1"
15
+ "@crewhaus/audit-log": "0.1.3",
16
+ "@crewhaus/errors": "0.1.3",
17
+ "@crewhaus/logging": "0.1.3"
18
18
  },
19
19
  "license": "Apache-2.0",
20
20
  "author": {
21
21
  "name": "Max Meier",
22
- "email": "max@studiomax.io",
23
- "url": "https://studiomax.io"
22
+ "email": "max@crewhaus.ai",
23
+ "url": "https://crewhaus.ai"
24
24
  },
25
25
  "repository": {
26
26
  "type": "git",
@@ -32,12 +32,7 @@
32
32
  "url": "https://github.com/crewhaus/factory/issues"
33
33
  },
34
34
  "publishConfig": {
35
- "access": "restricted"
35
+ "access": "public"
36
36
  },
37
- "files": [
38
- "src",
39
- "README.md",
40
- "LICENSE",
41
- "NOTICE"
42
- ]
37
+ "files": ["src", "README.md", "LICENSE", "NOTICE"]
43
38
  }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Section 27 — `env-var` backend coverage.
3
+ *
4
+ * Targets the rotate() warning + missing-value paths and list().
5
+ * No real `process.env` is touched: every test passes an explicit `env`
6
+ * object, and the logger is a plain in-memory spy (no real clock, no
7
+ * stderr writes, no leaked handles).
8
+ */
9
+ import { describe, expect, test } from "bun:test";
10
+ import type { LogFields } from "@crewhaus/logging";
11
+ import { createEnvVarBackend } from "../backends/env-var";
12
+ import { SecretsError } from "../index";
13
+
14
+ type WarnCall = { msg: string; fields?: LogFields };
15
+
16
+ /** Minimal Logger spy — records warn() calls, ignores the rest. */
17
+ function makeLogger() {
18
+ const warns: WarnCall[] = [];
19
+ const logger = {
20
+ debug() {},
21
+ info() {},
22
+ warn(msg: string, fields?: LogFields) {
23
+ warns.push({ msg, fields });
24
+ },
25
+ error() {},
26
+ child() {
27
+ return logger;
28
+ },
29
+ };
30
+ return { logger, warns };
31
+ }
32
+
33
+ describe("env-var backend — id", () => {
34
+ test("exposes the env-var id", () => {
35
+ const backend = createEnvVarBackend({ env: {} as NodeJS.ProcessEnv });
36
+ expect(backend.id).toBe("env-var");
37
+ });
38
+
39
+ test("defaults env to process.env when not supplied", () => {
40
+ // Construct without an `env` option to exercise the `?? process.env`
41
+ // fallback branch. We never read or mutate a real secret here — just
42
+ // assert the backend is constructed against the default env.
43
+ const backend = createEnvVarBackend();
44
+ expect(backend.id).toBe("env-var");
45
+ });
46
+ });
47
+
48
+ describe("env-var backend — rotate() warning + return paths", () => {
49
+ test("rotate(newValue) logs the no-op warning and returns the new value", async () => {
50
+ const { logger, warns } = makeLogger();
51
+ const env: NodeJS.ProcessEnv = { TOKEN: "old" };
52
+ const backend = createEnvVarBackend({ env, logger });
53
+
54
+ const v = await backend.rotate("TOKEN", { newValue: "new" });
55
+
56
+ expect(v).toBe("new");
57
+ expect(env["TOKEN"]).toBe("new");
58
+ expect(warns.length).toBe(1);
59
+ expect(warns[0]?.msg).toBe("secrets.rotate.env-var.no-op");
60
+ expect((warns[0]?.fields as { name: string }).name).toBe("TOKEN");
61
+ });
62
+
63
+ test("rotate without newValue returns the pre-existing env value (logs warning)", async () => {
64
+ const { logger, warns } = makeLogger();
65
+ const env: NodeJS.ProcessEnv = { TOKEN: "already-set" };
66
+ const backend = createEnvVarBackend({ env, logger });
67
+
68
+ const v = await backend.rotate("TOKEN");
69
+
70
+ expect(v).toBe("already-set");
71
+ // value must be untouched when no newValue is provided
72
+ expect(env["TOKEN"]).toBe("already-set");
73
+ expect(warns.length).toBe(1);
74
+ });
75
+
76
+ test("rotate without newValue on an unset name throws SecretsError", async () => {
77
+ const { logger, warns } = makeLogger();
78
+ const env: NodeJS.ProcessEnv = {};
79
+ const backend = createEnvVarBackend({ env, logger });
80
+
81
+ expect(backend.rotate("NOPE")).rejects.toBeInstanceOf(SecretsError);
82
+ // the warning still fires before the missing-value throw
83
+ await backend.rotate("NOPE").catch(() => {});
84
+ expect(warns.length).toBeGreaterThanOrEqual(1);
85
+ });
86
+
87
+ test("rotate without a logger still returns the new value (no-warn branch)", async () => {
88
+ const env: NodeJS.ProcessEnv = { TOKEN: "old" };
89
+ const backend = createEnvVarBackend({ env });
90
+
91
+ const v = await backend.rotate("TOKEN", { newValue: "fresh" });
92
+
93
+ expect(v).toBe("fresh");
94
+ expect(env["TOKEN"]).toBe("fresh");
95
+ });
96
+
97
+ test("rotate without a logger and without newValue throws when unset", async () => {
98
+ const env: NodeJS.ProcessEnv = {};
99
+ const backend = createEnvVarBackend({ env });
100
+ expect(backend.rotate("MISSING")).rejects.toBeInstanceOf(SecretsError);
101
+ });
102
+ });
103
+
104
+ describe("env-var backend — list()", () => {
105
+ test("returns only names with non-empty values", async () => {
106
+ const env: NodeJS.ProcessEnv = {
107
+ A: "1",
108
+ B: "2",
109
+ EMPTY: "",
110
+ UNDEF: undefined,
111
+ };
112
+ const backend = createEnvVarBackend({ env });
113
+
114
+ const names = await backend.list?.();
115
+
116
+ expect(names).toBeDefined();
117
+ expect([...(names ?? [])].sort()).toEqual(["A", "B"]);
118
+ });
119
+
120
+ test("returns an empty array when env has no usable entries", async () => {
121
+ const env: NodeJS.ProcessEnv = { EMPTY: "", UNDEF: undefined };
122
+ const backend = createEnvVarBackend({ env });
123
+ expect(await backend.list?.()).toEqual([]);
124
+ });
125
+ });
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Section 27 — `file` backend coverage.
3
+ *
4
+ * `node:fs` is fully mocked with an in-memory store so no real disk I/O
5
+ * occurs (no tmp dirs, no leaked handles, deterministic). `crypto` is the
6
+ * real WebCrypto for the auto-generate path, but we only assert its shape
7
+ * (64 hex chars), so it stays deterministic in intent. `mock.restore()` does
8
+ * NOT undo `mock.module`, and Bun shares one module registry across all test
9
+ * files (nondeterministic order) — so the `afterAll` below reinstalls the
10
+ * real `node:fs`, keeping the in-memory fake from leaking into sibling files.
11
+ */
12
+ import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
13
+
14
+ // Captured BEFORE the mock below so afterAll can reinstall the real module.
15
+ const realFs = require("node:fs") as typeof import("node:fs");
16
+
17
+ /** In-memory filesystem state shared with the node:fs mock. */
18
+ type FsState = {
19
+ files: Map<string, { content: string; mode?: number }>;
20
+ dirs: Set<string>;
21
+ writes: Array<{ path: string; content: string; mode?: number }>;
22
+ renames: Array<{ from: string; to: string }>;
23
+ };
24
+
25
+ let fsState: FsState;
26
+
27
+ function freshState(): FsState {
28
+ return { files: new Map(), dirs: new Set(), writes: [], renames: [] };
29
+ }
30
+
31
+ // Initialise before the module under test is imported.
32
+ fsState = freshState();
33
+
34
+ mock.module("node:fs", () => ({
35
+ existsSync: (p: string) => fsState.files.has(p) || fsState.dirs.has(p),
36
+ readFileSync: (p: string, _enc?: string) => {
37
+ const f = fsState.files.get(p);
38
+ if (!f) {
39
+ const err = new Error(`ENOENT: no such file ${p}`) as NodeJS.ErrnoException;
40
+ err.code = "ENOENT";
41
+ throw err;
42
+ }
43
+ return f.content;
44
+ },
45
+ writeFileSync: (p: string, data: string, opts?: { mode?: number }) => {
46
+ fsState.files.set(p, { content: data, mode: opts?.mode });
47
+ fsState.writes.push({ path: p, content: data, mode: opts?.mode });
48
+ },
49
+ renameSync: (from: string, to: string) => {
50
+ const f = fsState.files.get(from);
51
+ if (f) {
52
+ fsState.files.set(to, f);
53
+ fsState.files.delete(from);
54
+ }
55
+ fsState.renames.push({ from, to });
56
+ },
57
+ readdirSync: (p: string) => {
58
+ if (!fsState.dirs.has(p)) {
59
+ const err = new Error(`ENOENT: no such dir ${p}`) as NodeJS.ErrnoException;
60
+ err.code = "ENOENT";
61
+ throw err;
62
+ }
63
+ const prefix = `${p}/`;
64
+ const out: string[] = [];
65
+ for (const key of fsState.files.keys()) {
66
+ if (key.startsWith(prefix)) out.push(key.slice(prefix.length));
67
+ }
68
+ return out;
69
+ },
70
+ }));
71
+
72
+ // Import AFTER the mock is registered so the SUT binds to the fake fs.
73
+ const { createFileBackend } = await import("../backends/file");
74
+ const { SecretsError } = await import("../index");
75
+
76
+ const ROOT = "/fake/secrets";
77
+
78
+ beforeEach(() => {
79
+ fsState = freshState();
80
+ fsState.dirs.add(ROOT);
81
+ });
82
+
83
+ afterEach(() => {
84
+ // Reset the in-memory store so each test starts from a clean slate.
85
+ fsState = freshState();
86
+ });
87
+
88
+ afterAll(() => {
89
+ // Reinstall the real module so the in-memory fake cannot outlive this file.
90
+ mock.module("node:fs", () => realFs);
91
+ });
92
+
93
+ describe("file backend — get()", () => {
94
+ test("returns file contents (utf8, whitespace preserved)", async () => {
95
+ fsState.files.set(`${ROOT}/API_KEY`, { content: " secret with spaces \n" });
96
+ const backend = createFileBackend({ rootDir: ROOT });
97
+ expect(await backend.get("API_KEY")).toBe(" secret with spaces \n");
98
+ });
99
+
100
+ test("throws SecretsError when the file is missing, leaking neither name nor path", async () => {
101
+ const backend = createFileBackend({ rootDir: ROOT });
102
+ expect(backend.get("MISSING")).rejects.toBeInstanceOf(SecretsError);
103
+ await backend.get("MISSING").catch((e: unknown) => {
104
+ const msg = (e as Error).message;
105
+ expect(msg).toBe("secret file read failed (not found)");
106
+ // the secret name and the on-disk path must not appear in the message.
107
+ expect(msg).not.toContain("MISSING");
108
+ expect(msg).not.toContain(ROOT);
109
+ });
110
+ });
111
+
112
+ test("rejects path-traversal names before touching fs, without echoing the name", async () => {
113
+ const backend = createFileBackend({ rootDir: ROOT });
114
+ expect(backend.get("../../etc/passwd")).rejects.toBeInstanceOf(SecretsError);
115
+ await backend.get("../../etc/passwd").catch((e: unknown) => {
116
+ const msg = (e as Error).message;
117
+ expect(msg).toContain("invalid secret name");
118
+ expect(msg).not.toContain("../../etc/passwd");
119
+ });
120
+ });
121
+ });
122
+
123
+ describe("file backend — rotate()", () => {
124
+ test("writes to a .tmp file (mode 0o600) then renames atomically", async () => {
125
+ const backend = createFileBackend({ rootDir: ROOT });
126
+
127
+ const v = await backend.rotate("TOKEN", { newValue: "fresh-token" });
128
+
129
+ expect(v).toBe("fresh-token");
130
+ // The committed file holds the new value...
131
+ expect(fsState.files.get(`${ROOT}/TOKEN`)?.content).toBe("fresh-token");
132
+ // ...written first to the tmp path with restrictive mode...
133
+ const tmpWrite = fsState.writes.find((w) => w.path === `${ROOT}/TOKEN.tmp`);
134
+ expect(tmpWrite).toBeDefined();
135
+ expect(tmpWrite?.mode).toBe(0o600);
136
+ // ...and committed via rename(tmp -> final).
137
+ expect(fsState.renames).toContainEqual({
138
+ from: `${ROOT}/TOKEN.tmp`,
139
+ to: `${ROOT}/TOKEN`,
140
+ });
141
+ });
142
+
143
+ test("auto-generates a 64-char hex secret when newValue is omitted", async () => {
144
+ const backend = createFileBackend({ rootDir: ROOT });
145
+ const v = await backend.rotate("AUTO");
146
+ expect(v).toMatch(/^[a-f0-9]{64}$/);
147
+ expect(fsState.files.get(`${ROOT}/AUTO`)?.content).toBe(v);
148
+ });
149
+
150
+ test("rejects malformed names without writing", async () => {
151
+ const backend = createFileBackend({ rootDir: ROOT });
152
+ expect(backend.rotate("path/with/slash")).rejects.toBeInstanceOf(SecretsError);
153
+ expect(fsState.writes.length).toBe(0);
154
+ });
155
+ });
156
+
157
+ describe("file backend — list()", () => {
158
+ test("lists committed secrets, skipping .tmp and dotfiles", async () => {
159
+ fsState.files.set(`${ROOT}/A`, { content: "1" });
160
+ fsState.files.set(`${ROOT}/B`, { content: "2" });
161
+ fsState.files.set(`${ROOT}/C.tmp`, { content: "x" });
162
+ fsState.files.set(`${ROOT}/.hidden`, { content: "y" });
163
+ const backend = createFileBackend({ rootDir: ROOT });
164
+
165
+ const names = await backend.list?.();
166
+
167
+ expect([...(names ?? [])].sort()).toEqual(["A", "B"]);
168
+ });
169
+
170
+ test("returns [] when the root dir does not exist", async () => {
171
+ const backend = createFileBackend({ rootDir: "/fake/does-not-exist" });
172
+ expect(await backend.list?.()).toEqual([]);
173
+ });
174
+
175
+ test("returns [] when the root dir exists but is empty", async () => {
176
+ const backend = createFileBackend({ rootDir: ROOT });
177
+ expect(await backend.list?.()).toEqual([]);
178
+ });
179
+ });
@@ -20,7 +20,7 @@ export function createFileBackend(opts: FileBackendOptions): SecretsBackend {
20
20
 
21
21
  function pathFor(name: string): string {
22
22
  if (!/^[A-Za-z0-9_.-]+$/.test(name)) {
23
- throw new SecretsError(`invalid secret name "${name}" (must match [A-Za-z0-9_.-]+)`);
23
+ throw new SecretsError("invalid secret name (must match [A-Za-z0-9_.-]+)");
24
24
  }
25
25
  return join(rootDir, name);
26
26
  }
@@ -30,7 +30,7 @@ export function createFileBackend(opts: FileBackendOptions): SecretsBackend {
30
30
  async get(name: string): Promise<SecretValue> {
31
31
  const p = pathFor(name);
32
32
  if (!existsSync(p)) {
33
- throw new SecretsError(`secret "${name}" not found at ${p}`);
33
+ throw new SecretsError("secret file read failed (not found)");
34
34
  }
35
35
  return readFileSync(p, "utf8");
36
36
  },
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Section 27 — `vault` backend coverage.
3
+ *
4
+ * No real network: every test injects a deterministic `fetchImpl` that
5
+ * returns canned `Response`s. `VAULT_TOKEN` is saved in beforeEach and
6
+ * restored in afterEach so the surrounding process env is never mutated
7
+ * across tests. No real clock, no leaked handles.
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
10
+ import { createVaultBackend } from "../backends/vault";
11
+ import { SecretsError } from "../index";
12
+
13
+ const ADDR = "http://127.0.0.1:8200";
14
+
15
+ /** Build a fetch stub plus a record of the calls it observed. */
16
+ function stubFetch(responder: (url: string, init?: RequestInit) => Response) {
17
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
18
+ const fetchImpl = (async (url: string, init?: RequestInit) => {
19
+ calls.push({ url, init });
20
+ return responder(url, init);
21
+ }) as unknown as typeof fetch;
22
+ return { fetchImpl, calls };
23
+ }
24
+
25
+ let savedToken: string | undefined;
26
+
27
+ beforeEach(() => {
28
+ savedToken = process.env["VAULT_TOKEN"];
29
+ Reflect.deleteProperty(process.env, "VAULT_TOKEN");
30
+ });
31
+
32
+ afterEach(() => {
33
+ if (savedToken === undefined) {
34
+ Reflect.deleteProperty(process.env, "VAULT_TOKEN");
35
+ } else {
36
+ process.env["VAULT_TOKEN"] = savedToken;
37
+ }
38
+ });
39
+
40
+ describe("vault backend — id + token resolution", () => {
41
+ test("exposes the vault id", () => {
42
+ const backend = createVaultBackend({
43
+ addr: ADDR,
44
+ token: "t",
45
+ fetchImpl: (async () => new Response("{}")) as unknown as typeof fetch,
46
+ });
47
+ expect(backend.id).toBe("vault");
48
+ });
49
+
50
+ test("falls back to VAULT_TOKEN env when no constructor token is given", async () => {
51
+ process.env["VAULT_TOKEN"] = "env-token";
52
+ const { fetchImpl, calls } = stubFetch(
53
+ () => new Response(JSON.stringify({ data: { data: { value: "v" } } }), { status: 200 }),
54
+ );
55
+ const backend = createVaultBackend({ addr: ADDR, fetchImpl });
56
+
57
+ await backend.get("K");
58
+
59
+ const sentToken = (calls[0]?.init?.headers as Record<string, string>)?.["X-Vault-Token"];
60
+ expect(sentToken).toBe("env-token");
61
+ });
62
+
63
+ test("constructor token wins over VAULT_TOKEN env", async () => {
64
+ process.env["VAULT_TOKEN"] = "env-token";
65
+ const { fetchImpl, calls } = stubFetch(
66
+ () => new Response(JSON.stringify({ data: { data: { value: "v" } } }), { status: 200 }),
67
+ );
68
+ const backend = createVaultBackend({ addr: ADDR, token: "ctor-token", fetchImpl });
69
+
70
+ await backend.get("K");
71
+
72
+ const sentToken = (calls[0]?.init?.headers as Record<string, string>)?.["X-Vault-Token"];
73
+ expect(sentToken).toBe("ctor-token");
74
+ });
75
+
76
+ test("throws when neither constructor token nor VAULT_TOKEN is set", async () => {
77
+ const { fetchImpl } = stubFetch(() => new Response("{}"));
78
+ const backend = createVaultBackend({ addr: ADDR, fetchImpl });
79
+ expect(backend.get("K")).rejects.toBeInstanceOf(SecretsError);
80
+ });
81
+ });
82
+
83
+ describe("vault backend — dataUrl name validation", () => {
84
+ test("get rejects names with illegal characters (no fetch issued)", async () => {
85
+ const { fetchImpl, calls } = stubFetch(() => new Response("{}"));
86
+ const backend = createVaultBackend({ addr: ADDR, token: "t", fetchImpl });
87
+ expect(backend.get("bad name!")).rejects.toBeInstanceOf(SecretsError);
88
+ await backend.get("bad name!").catch((e: unknown) => {
89
+ const msg = (e as Error).message;
90
+ expect(msg).toContain("invalid secret name");
91
+ expect(msg).not.toContain("bad name!");
92
+ });
93
+ expect(calls.length).toBe(0);
94
+ });
95
+
96
+ test("rotate rejects names with illegal characters (no fetch issued)", async () => {
97
+ const { fetchImpl, calls } = stubFetch(() => new Response("{}"));
98
+ const backend = createVaultBackend({ addr: ADDR, token: "t", fetchImpl });
99
+ expect(backend.rotate("space here")).rejects.toBeInstanceOf(SecretsError);
100
+ await backend.rotate("space here").catch(() => {});
101
+ expect(calls.length).toBe(0);
102
+ });
103
+
104
+ test("allows KV-v2 nested paths (slashes are legal)", async () => {
105
+ const { fetchImpl, calls } = stubFetch(
106
+ () => new Response(JSON.stringify({ data: { data: { value: "nested" } } }), { status: 200 }),
107
+ );
108
+ const backend = createVaultBackend({ addr: ADDR, token: "t", fetchImpl });
109
+ expect(await backend.get("app/db/password")).toBe("nested");
110
+ expect(calls[0]?.url).toBe(`${ADDR}/v1/secret/data/app/db/password`);
111
+ });
112
+
113
+ test("uses a custom mount point when provided", async () => {
114
+ const { fetchImpl, calls } = stubFetch(
115
+ () => new Response(JSON.stringify({ data: { data: { value: "v" } } }), { status: 200 }),
116
+ );
117
+ const backend = createVaultBackend({ addr: ADDR, mount: "kv2", token: "t", fetchImpl });
118
+ await backend.get("K");
119
+ expect(calls[0]?.url).toBe(`${ADDR}/v1/kv2/data/K`);
120
+ });
121
+ });
122
+
123
+ describe("vault backend — get() response handling", () => {
124
+ test("returns the KV-v2 value on 200", async () => {
125
+ const { fetchImpl } = stubFetch(
126
+ () =>
127
+ new Response(JSON.stringify({ data: { data: { value: "vault-secret" } } }), {
128
+ status: 200,
129
+ }),
130
+ );
131
+ const backend = createVaultBackend({ addr: ADDR, token: "t", fetchImpl });
132
+ expect(await backend.get("K")).toBe("vault-secret");
133
+ });
134
+
135
+ test("throws SecretsError on 404 (missing secret) without leaking name or url", async () => {
136
+ const { fetchImpl } = stubFetch(() => new Response("not found", { status: 404 }));
137
+ const backend = createVaultBackend({ addr: ADDR, token: "t", fetchImpl });
138
+ expect(backend.get("SENSITIVE_NAME")).rejects.toBeInstanceOf(SecretsError);
139
+ await backend.get("SENSITIVE_NAME").catch((e: unknown) => {
140
+ const msg = (e as Error).message;
141
+ expect(msg).toContain("404");
142
+ expect(msg).not.toContain("SENSITIVE_NAME");
143
+ // the url embeds the name + addr; it must not appear either.
144
+ expect(msg).not.toContain(ADDR);
145
+ });
146
+ });
147
+
148
+ test("throws SecretsError on a non-ok, non-404 status (e.g. 500) with status but no name/body", async () => {
149
+ const { fetchImpl } = stubFetch(() => new Response("permission denied", { status: 500 }));
150
+ const backend = createVaultBackend({ addr: ADDR, token: "t", fetchImpl });
151
+ expect(backend.get("SENSITIVE_NAME")).rejects.toBeInstanceOf(SecretsError);
152
+ await backend.get("SENSITIVE_NAME").catch((e: unknown) => {
153
+ const msg = (e as Error).message;
154
+ // status is retained for debugging...
155
+ expect(msg).toContain("500");
156
+ // ...but the raw response body and the secret name are redacted.
157
+ expect(msg).not.toContain("permission denied");
158
+ expect(msg).not.toContain("SENSITIVE_NAME");
159
+ });
160
+ });
161
+
162
+ test("throws when KV-v2 body is missing data.data.value", async () => {
163
+ const { fetchImpl } = stubFetch(
164
+ () => new Response(JSON.stringify({ data: { data: {} } }), { status: 200 }),
165
+ );
166
+ const backend = createVaultBackend({ addr: ADDR, token: "t", fetchImpl });
167
+ expect(backend.get("SENSITIVE_NAME")).rejects.toBeInstanceOf(SecretsError);
168
+ await backend.get("SENSITIVE_NAME").catch((e: unknown) => {
169
+ const msg = (e as Error).message;
170
+ expect(msg).toContain("missing data.data.value");
171
+ expect(msg).not.toContain("SENSITIVE_NAME");
172
+ });
173
+ });
174
+
175
+ test("throws when the value is present but not a string", async () => {
176
+ const { fetchImpl } = stubFetch(
177
+ () => new Response(JSON.stringify({ data: { data: { value: 42 } } }), { status: 200 }),
178
+ );
179
+ const backend = createVaultBackend({ addr: ADDR, token: "t", fetchImpl });
180
+ expect(backend.get("K")).rejects.toBeInstanceOf(SecretsError);
181
+ });
182
+
183
+ test("throws when the response is an empty object (no data key)", async () => {
184
+ const { fetchImpl } = stubFetch(() => new Response(JSON.stringify({}), { status: 200 }));
185
+ const backend = createVaultBackend({ addr: ADDR, token: "t", fetchImpl });
186
+ expect(backend.get("K")).rejects.toBeInstanceOf(SecretsError);
187
+ });
188
+ });
189
+
190
+ describe("vault backend — rotate()", () => {
191
+ test("PUTs the supplied newValue and returns it", async () => {
192
+ let putBody = "";
193
+ const { fetchImpl, calls } = stubFetch((_url, init) => {
194
+ if (init?.method === "PUT") {
195
+ putBody = init.body as string;
196
+ return new Response("{}", { status: 204 });
197
+ }
198
+ return new Response("not found", { status: 404 });
199
+ });
200
+ const backend = createVaultBackend({ addr: ADDR, token: "t", fetchImpl });
201
+
202
+ const v = await backend.rotate("KEY", { newValue: "fresh" });
203
+
204
+ expect(v).toBe("fresh");
205
+ expect(JSON.parse(putBody)).toEqual({ data: { value: "fresh" } });
206
+ const putCall = calls.find((c) => c.init?.method === "PUT");
207
+ const headers = putCall?.init?.headers as Record<string, string>;
208
+ expect(headers?.["X-Vault-Token"]).toBe("t");
209
+ expect(headers?.["Content-Type"]).toBe("application/json");
210
+ });
211
+
212
+ test("auto-generates a 64-char hex secret when newValue is omitted", async () => {
213
+ let putBody = "";
214
+ const { fetchImpl } = stubFetch((_url, init) => {
215
+ putBody = (init?.body as string) ?? "";
216
+ return new Response("{}", { status: 200 });
217
+ });
218
+ const backend = createVaultBackend({ addr: ADDR, token: "t", fetchImpl });
219
+
220
+ const v = await backend.rotate("KEY");
221
+
222
+ expect(v).toMatch(/^[a-f0-9]{64}$/);
223
+ // the generated value is what got PUT to vault
224
+ expect(JSON.parse(putBody)).toEqual({ data: { value: v } });
225
+ });
226
+
227
+ test("throws SecretsError when the PUT is not ok, with status but no name/body", async () => {
228
+ const { fetchImpl } = stubFetch(() => new Response("sealed", { status: 503 }));
229
+ const backend = createVaultBackend({ addr: ADDR, token: "t", fetchImpl });
230
+ expect(backend.rotate("SENSITIVE_NAME", { newValue: "x" })).rejects.toBeInstanceOf(
231
+ SecretsError,
232
+ );
233
+ await backend.rotate("SENSITIVE_NAME", { newValue: "x" }).catch((e: unknown) => {
234
+ const msg = (e as Error).message;
235
+ // status is retained for debugging...
236
+ expect(msg).toContain("503");
237
+ // ...but the raw response body and the secret name are redacted.
238
+ expect(msg).not.toContain("sealed");
239
+ expect(msg).not.toContain("SENSITIVE_NAME");
240
+ });
241
+ });
242
+ });
@@ -34,7 +34,7 @@ export function createVaultBackend(opts: VaultBackendOptions): SecretsBackend {
34
34
 
35
35
  function dataUrl(name: string): string {
36
36
  if (!/^[A-Za-z0-9_/.-]+$/.test(name)) {
37
- throw new SecretsError(`invalid secret name "${name}" for vault backend`);
37
+ throw new SecretsError("invalid secret name for vault backend");
38
38
  }
39
39
  return `${opts.addr}/v1/${encodeURIComponent(mount)}/data/${name}`;
40
40
  }
@@ -47,17 +47,15 @@ export function createVaultBackend(opts: VaultBackendOptions): SecretsBackend {
47
47
  headers: { "X-Vault-Token": getToken() },
48
48
  });
49
49
  if (res.status === 404) {
50
- throw new SecretsError(`secret "${name}" not found in vault at ${url}`);
50
+ throw new SecretsError("secret not found in vault (status 404)");
51
51
  }
52
52
  if (!res.ok) {
53
- throw new SecretsError(`vault GET ${name} returned ${res.status}: ${await res.text()}`);
53
+ throw new SecretsError(`vault request failed (status ${res.status})`);
54
54
  }
55
55
  const body = (await res.json()) as { data?: { data?: { value?: string } } };
56
56
  const v = body?.data?.data?.value;
57
57
  if (typeof v !== "string") {
58
- throw new SecretsError(
59
- `vault response for "${name}" missing data.data.value (KV v2 expected)`,
60
- );
58
+ throw new SecretsError("vault response missing data.data.value (KV v2 expected)");
61
59
  }
62
60
  return v;
63
61
  },
@@ -73,7 +71,7 @@ export function createVaultBackend(opts: VaultBackendOptions): SecretsBackend {
73
71
  body: JSON.stringify({ data: { value: newValue } }),
74
72
  });
75
73
  if (!res.ok) {
76
- throw new SecretsError(`vault PUT ${name} returned ${res.status}: ${await res.text()}`);
74
+ throw new SecretsError(`vault request failed (status ${res.status})`);
77
75
  }
78
76
  return newValue;
79
77
  },
package/src/index.test.ts CHANGED
@@ -215,6 +215,48 @@ describe("createSecrets — rotation handlers (T3)", () => {
215
215
  await secrets.rotate("TOKEN", { newValue: "v2" });
216
216
  expect(calls).toBe(1);
217
217
  });
218
+
219
+ test("async handler that resolves is awaited before rotate() returns", async () => {
220
+ const root = join(tmpRoot, "secrets");
221
+ require("node:fs").mkdirSync(root);
222
+ writeFileSync(join(root, "TOKEN"), "old");
223
+ const secrets = createSecrets({ backend: createFileBackend({ rootDir: root }) });
224
+
225
+ let settled = false;
226
+ secrets.onRotation(async (e) => {
227
+ // microtask + macrotask hop to prove rotate() actually awaits us
228
+ await Promise.resolve();
229
+ expect(e.newValue).toBe("async-value");
230
+ settled = true;
231
+ });
232
+
233
+ await secrets.rotate("TOKEN", { newValue: "async-value" });
234
+ expect(settled).toBe(true);
235
+ });
236
+
237
+ test("async handler that rejects is swallowed and does not block siblings", async () => {
238
+ const root = join(tmpRoot, "secrets");
239
+ require("node:fs").mkdirSync(root);
240
+ writeFileSync(join(root, "TOKEN"), "old");
241
+ const secrets = createSecrets({ backend: createFileBackend({ rootDir: root }) });
242
+
243
+ const order: string[] = [];
244
+ // rejecting async handler -> exercises the promise .catch(() => {}) path
245
+ secrets.onRotation(async () => {
246
+ order.push("rejecting");
247
+ await Promise.resolve();
248
+ throw new Error("async handler boom");
249
+ });
250
+ // resolving async sibling -> still runs
251
+ secrets.onRotation(async () => {
252
+ order.push("resolving");
253
+ });
254
+
255
+ // rotate must resolve (not reject) despite the rejecting handler
256
+ const v = await secrets.rotate("TOKEN", { newValue: "v" });
257
+ expect(v).toBe("v");
258
+ expect(order).toEqual(["rejecting", "resolving"]);
259
+ });
218
260
  });
219
261
 
220
262
  describe("createSecrets — audit-log integration (T8 tenant isolation)", () => {