@crewhaus/secrets-manager 0.1.4 → 0.1.5

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.
@@ -0,0 +1,13 @@
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 SecretsBackend } from "../index";
8
+ export type EnvVarBackendOptions = {
9
+ /** Default: `process.env`. Override for tests. */
10
+ readonly env?: NodeJS.ProcessEnv;
11
+ readonly logger?: Logger;
12
+ };
13
+ export declare function createEnvVarBackend(opts?: EnvVarBackendOptions): SecretsBackend;
@@ -0,0 +1,37 @@
1
+ import { SecretsError } from "../index";
2
+ export function createEnvVarBackend(opts = {}) {
3
+ const env = opts.env ?? process.env;
4
+ return {
5
+ id: "env-var",
6
+ async get(name) {
7
+ const value = env[name];
8
+ if (value === undefined || value === "") {
9
+ throw new SecretsError(`secret "${name}" not found in env (env-var backend)`);
10
+ }
11
+ return value;
12
+ },
13
+ async rotate(name, rotateOpts) {
14
+ // env-var is read-only at the secrets layer; rotation requires
15
+ // restarting the process with new env. We accept an explicit
16
+ // newValue so callers can model "I just exported $NAME" — but we
17
+ // can't actually mutate the parent process's env.
18
+ if (rotateOpts?.newValue !== undefined) {
19
+ env[name] = rotateOpts.newValue;
20
+ }
21
+ if (opts.logger) {
22
+ opts.logger.warn("secrets.rotate.env-var.no-op", {
23
+ name,
24
+ msg: "env-var backend does not own rotation; rotate at the orchestrator instead",
25
+ });
26
+ }
27
+ const v = env[name];
28
+ if (v === undefined) {
29
+ throw new SecretsError(`secret "${name}" cannot be rotated via env-var backend without a newValue`);
30
+ }
31
+ return v;
32
+ },
33
+ async list() {
34
+ return Object.keys(env).filter((k) => env[k] !== undefined && env[k] !== "");
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,6 @@
1
+ import { type SecretsBackend } from "../index";
2
+ export type FileBackendOptions = {
3
+ /** Default: `.crewhaus/secrets`. */
4
+ readonly rootDir: string;
5
+ };
6
+ export declare function createFileBackend(opts: FileBackendOptions): SecretsBackend;
@@ -0,0 +1,54 @@
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, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync, } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { SecretsError } from "../index";
12
+ export function createFileBackend(opts) {
13
+ const rootDir = opts.rootDir;
14
+ function pathFor(name) {
15
+ if (!/^[A-Za-z0-9_.-]+$/.test(name)) {
16
+ throw new SecretsError("invalid secret name (must match [A-Za-z0-9_.-]+)");
17
+ }
18
+ return join(rootDir, name);
19
+ }
20
+ return {
21
+ id: "file",
22
+ async get(name) {
23
+ const p = pathFor(name);
24
+ if (!existsSync(p)) {
25
+ throw new SecretsError("secret file read failed (not found)");
26
+ }
27
+ return readFileSync(p, "utf8");
28
+ },
29
+ async rotate(name, rotateOpts) {
30
+ const p = pathFor(name);
31
+ const newValue = rotateOpts?.newValue ?? generateRandomSecret();
32
+ // Create the secrets root on first use so a fresh checkout can rotate a
33
+ // secret without a raw ENOENT — recursive mkdir is a no-op when it
34
+ // already exists. 0o700 keeps the directory owner-only, consistent with
35
+ // the 0o600 secret files written into it.
36
+ mkdirSync(rootDir, { recursive: true, mode: 0o700 });
37
+ const tmp = `${p}.tmp`;
38
+ writeFileSync(tmp, newValue, { encoding: "utf8", mode: 0o600 });
39
+ renameSync(tmp, p);
40
+ return newValue;
41
+ },
42
+ async list() {
43
+ if (!existsSync(rootDir))
44
+ return [];
45
+ return readdirSync(rootDir).filter((f) => !f.endsWith(".tmp") && !f.startsWith("."));
46
+ },
47
+ };
48
+ }
49
+ function generateRandomSecret() {
50
+ // 32 bytes hex → 64 chars; sufficient for tokens.
51
+ const bytes = new Uint8Array(32);
52
+ crypto.getRandomValues(bytes);
53
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
54
+ }
@@ -0,0 +1,19 @@
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 SecretsBackend } from "../index";
9
+ export type VaultBackendOptions = {
10
+ /** Vault address (e.g. `http://127.0.0.1:8200`). */
11
+ readonly addr: string;
12
+ /** KV v2 mount point (default: `secret`). */
13
+ readonly mount?: string;
14
+ /** Vault token. Falls back to `VAULT_TOKEN` env. */
15
+ readonly token?: string;
16
+ /** Optional fetch override for tests. */
17
+ readonly fetchImpl?: typeof fetch;
18
+ };
19
+ export declare function createVaultBackend(opts: VaultBackendOptions): SecretsBackend;
@@ -0,0 +1,67 @@
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 { SecretsError } from "../index";
9
+ export function createVaultBackend(opts) {
10
+ const mount = opts.mount ?? "secret";
11
+ const fetchImpl = opts.fetchImpl ?? fetch;
12
+ function getToken() {
13
+ const t = opts.token ?? process.env["VAULT_TOKEN"];
14
+ if (!t) {
15
+ throw new SecretsError("vault backend requires a token (constructor opts.token or VAULT_TOKEN env)");
16
+ }
17
+ return t;
18
+ }
19
+ function dataUrl(name) {
20
+ if (!/^[A-Za-z0-9_/.-]+$/.test(name)) {
21
+ throw new SecretsError("invalid secret name for vault backend");
22
+ }
23
+ return `${opts.addr}/v1/${encodeURIComponent(mount)}/data/${name}`;
24
+ }
25
+ return {
26
+ id: "vault",
27
+ async get(name) {
28
+ const url = dataUrl(name);
29
+ const res = await fetchImpl(url, {
30
+ headers: { "X-Vault-Token": getToken() },
31
+ });
32
+ if (res.status === 404) {
33
+ throw new SecretsError("secret not found in vault (status 404)");
34
+ }
35
+ if (!res.ok) {
36
+ throw new SecretsError(`vault request failed (status ${res.status})`);
37
+ }
38
+ const body = (await res.json());
39
+ const v = body?.data?.data?.value;
40
+ if (typeof v !== "string") {
41
+ throw new SecretsError("vault response missing data.data.value (KV v2 expected)");
42
+ }
43
+ return v;
44
+ },
45
+ async rotate(name, rotateOpts) {
46
+ const url = dataUrl(name);
47
+ const newValue = rotateOpts?.newValue ?? generateRandomSecret();
48
+ const res = await fetchImpl(url, {
49
+ method: "PUT",
50
+ headers: {
51
+ "X-Vault-Token": getToken(),
52
+ "Content-Type": "application/json",
53
+ },
54
+ body: JSON.stringify({ data: { value: newValue } }),
55
+ });
56
+ if (!res.ok) {
57
+ throw new SecretsError(`vault request failed (status ${res.status})`);
58
+ }
59
+ return newValue;
60
+ },
61
+ };
62
+ }
63
+ function generateRandomSecret() {
64
+ const bytes = new Uint8Array(32);
65
+ crypto.getRandomValues(bytes);
66
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
67
+ }
@@ -0,0 +1,75 @@
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
+ export declare class SecretsError extends CrewhausError {
19
+ readonly name = "SecretsError";
20
+ constructor(message: string, cause?: unknown);
21
+ }
22
+ export type SecretValue = string;
23
+ export type RotationHandler = (event: {
24
+ readonly name: string;
25
+ readonly newValue: SecretValue;
26
+ readonly rotatedAt: number;
27
+ }) => void | Promise<void>;
28
+ export interface SecretsBackend {
29
+ readonly id: "env-var" | "file" | "vault";
30
+ /** Returns the current value, or throws SecretsError if missing. */
31
+ get(name: string): Promise<SecretValue>;
32
+ /**
33
+ * Rotate the named secret. Implementations may generate a new value or
34
+ * accept an externally-supplied one via `opts.newValue`. Returns the
35
+ * new value so callers can verify the rotation took.
36
+ */
37
+ rotate(name: string, opts?: {
38
+ readonly newValue?: SecretValue;
39
+ }): Promise<SecretValue>;
40
+ /** Optional health check. Returns the names this backend can resolve. */
41
+ list?(): Promise<ReadonlyArray<string>>;
42
+ }
43
+ export interface Secrets {
44
+ /** Resolve the named secret. Audit-logs the access when tenantId is set. */
45
+ get(name: string): Promise<SecretValue>;
46
+ /**
47
+ * Rotate the named secret, fire all `onRotation` handlers, and audit-log
48
+ * the rotation when tenantId is set. Returns the new value.
49
+ */
50
+ rotate(name: string, opts?: {
51
+ readonly newValue?: SecretValue;
52
+ }): Promise<SecretValue>;
53
+ /** Subscribe to rotation events. Returns an unsubscribe function. */
54
+ onRotation(handler: RotationHandler): () => void;
55
+ /** Switch to a fresh backend. Used by tests + the doctor command. */
56
+ doctor(): Promise<DoctorReport>;
57
+ /** Backend identifier for diagnostics. */
58
+ readonly backendId: SecretsBackend["id"];
59
+ }
60
+ export type DoctorReport = {
61
+ readonly backend: SecretsBackend["id"];
62
+ readonly available: ReadonlyArray<string>;
63
+ readonly missing: ReadonlyArray<string>;
64
+ /** Rotation TTLs known to be due (file/vault track this; env-var returns []). */
65
+ readonly rotationDue: ReadonlyArray<string>;
66
+ };
67
+ export type CreateSecretsOptions = {
68
+ readonly backend: SecretsBackend;
69
+ readonly auditLog?: AuditLog;
70
+ readonly tenantId?: string;
71
+ /** Names to validate in `doctor()`. */
72
+ readonly knownSecrets?: ReadonlyArray<string>;
73
+ };
74
+ export declare function createSecrets(opts: CreateSecretsOptions): Secrets;
75
+ export { createEnvVarBackend, createFileBackend, createVaultBackend };
package/dist/index.js ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Section 27 — `secrets-manager`. Pluggable secret storage with rotation
3
+ * callbacks and audit-log integration. Three backends:
4
+ * - **env-var** (default; rotation is a no-op + warning)
5
+ * - **file** (reads from `.crewhaus/secrets/<name>`; rotation = atomic rewrite)
6
+ * - **vault** (HashiCorp Vault HTTP API, KV v2 backend)
7
+ *
8
+ * Long-running daemons (CHN gateway, MGD gateway, RES daemon) subscribe to
9
+ * `onRotation(handler)` so they refresh in-flight credentials without
10
+ * restart. Every `get` and `rotate` is audit-logged when a tenant id is
11
+ * configured.
12
+ */
13
+ import { CrewhausError } from "@crewhaus/errors";
14
+ import { createEnvVarBackend } from "./backends/env-var";
15
+ import { createFileBackend } from "./backends/file";
16
+ import { createVaultBackend } from "./backends/vault";
17
+ export class SecretsError extends CrewhausError {
18
+ name = "SecretsError";
19
+ constructor(message, cause) {
20
+ super("config", message, cause);
21
+ }
22
+ }
23
+ export function createSecrets(opts) {
24
+ const handlers = new Set();
25
+ return {
26
+ backendId: opts.backend.id,
27
+ async get(name) {
28
+ const value = await opts.backend.get(name);
29
+ if (opts.auditLog && opts.tenantId !== undefined) {
30
+ await opts.auditLog.append({
31
+ kind: "secrets_access",
32
+ payload: { tenantId: opts.tenantId, name, backend: opts.backend.id },
33
+ });
34
+ }
35
+ return value;
36
+ },
37
+ async rotate(name, rotateOpts) {
38
+ const newValue = await opts.backend.rotate(name, rotateOpts);
39
+ const rotatedAt = Date.now();
40
+ if (opts.auditLog && opts.tenantId !== undefined) {
41
+ await opts.auditLog.append({
42
+ kind: "secrets_rotation",
43
+ payload: {
44
+ tenantId: opts.tenantId,
45
+ name,
46
+ backend: opts.backend.id,
47
+ rotatedAt,
48
+ },
49
+ });
50
+ }
51
+ // Fire handlers in order. A handler that throws does not block siblings.
52
+ const event = { name, newValue, rotatedAt };
53
+ const promises = [];
54
+ for (const h of handlers) {
55
+ try {
56
+ const result = h(event);
57
+ if (result && typeof result.then === "function") {
58
+ promises.push(result.catch(() => {
59
+ /* swallow per-handler errors */
60
+ }));
61
+ }
62
+ }
63
+ catch {
64
+ /* swallow per-handler errors */
65
+ }
66
+ }
67
+ await Promise.all(promises);
68
+ return newValue;
69
+ },
70
+ onRotation(h) {
71
+ handlers.add(h);
72
+ return () => {
73
+ handlers.delete(h);
74
+ };
75
+ },
76
+ async doctor() {
77
+ const known = opts.knownSecrets ?? [];
78
+ const available = [];
79
+ const missing = [];
80
+ for (const name of known) {
81
+ try {
82
+ await opts.backend.get(name);
83
+ available.push(name);
84
+ }
85
+ catch {
86
+ missing.push(name);
87
+ }
88
+ }
89
+ return {
90
+ backend: opts.backend.id,
91
+ available,
92
+ missing,
93
+ rotationDue: [],
94
+ };
95
+ },
96
+ };
97
+ }
98
+ // Re-export backends so callers can construct directly.
99
+ export { createEnvVarBackend, createFileBackend, createVaultBackend };
package/package.json CHANGED
@@ -1,20 +1,23 @@
1
1
  {
2
2
  "name": "@crewhaus/secrets-manager",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Pluggable secrets backend with rotation + audit-log integration: env-var, file, vault",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/audit-log": "0.1.4",
16
- "@crewhaus/errors": "0.1.4",
17
- "@crewhaus/logging": "0.1.4"
18
+ "@crewhaus/audit-log": "0.1.5",
19
+ "@crewhaus/errors": "0.1.5",
20
+ "@crewhaus/logging": "0.1.5"
18
21
  },
19
22
  "license": "Apache-2.0",
20
23
  "author": {
@@ -34,5 +37,5 @@
34
37
  "publishConfig": {
35
38
  "access": "public"
36
39
  },
37
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
40
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
38
41
  }
@@ -1,125 +0,0 @@
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
- });
@@ -1,52 +0,0 @@
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
- }