Initial Commit
This commit is contained in:
137
packages/api-stack/api/api-secrets-vault/src/adapter/adapter.md
Normal file
137
packages/api-stack/api/api-secrets-vault/src/adapter/adapter.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# `adapter`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Stage-8 concrete `SecretsLike` implementation backed by a
|
||||
`VaultSecretsBackend` (real `@suaveplan/vault.Vault` via `fromVault`,
|
||||
or any backend honouring the structural contract). Suitable for
|
||||
multi-host / cloud deployments that need an encrypted, replicated
|
||||
container with a controlled unlock flow alongside per-plugin scoping.
|
||||
The vault is passed in unlocked by the host startup sequence; the
|
||||
adapter never sees the master key, mirrors the env adapter's allow-
|
||||
list semantics, and emits the canonical
|
||||
`api.secrets.reads_total{backend="vault"}` counter on every
|
||||
successful read.
|
||||
|
||||
The adapter is structurally a `SecretsLike` from
|
||||
`@suaveplan/api-types` and passes the `runSecretsConformance`
|
||||
harness from `@suaveplan/api-secrets/testing` unchanged.
|
||||
|
||||
## Features
|
||||
|
||||
- `createVaultSecretsAdapter({ vault, allowlist }): VaultSecretsAdapter`
|
||||
— factory returning a frozen-shaped `SecretsLike` plus an
|
||||
idempotent `dispose()`.
|
||||
- Per-plugin allow-list enforced BEFORE the vault is touched —
|
||||
rejections throw `SECRETS_VAULT.NOT_ALLOWED`.
|
||||
- Locked-vault detection: every `get` / `require` checks
|
||||
`vault.isLocked` and raises `SECRETS_VAULT.VAULT_LOCKED` with no
|
||||
plaintext fallback.
|
||||
- Backend error wrapping: an unexpected throw from the backend
|
||||
surfaces as `SECRETS_VAULT.VAULT_UNAVAILABLE` with the original
|
||||
error attached on `cause`.
|
||||
- `require` raises `SECRETS_VAULT.NOT_FOUND` for permitted-but-
|
||||
absent names; `get` resolves to `undefined`.
|
||||
- OTel `api.secrets.reads_total{backend="vault"}` counter increments
|
||||
on every successful resolution (both `get` and `require`).
|
||||
- Zod-validated options; `SECRETS_VAULT.INVALID_OPTIONS` on failure.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createVaultSecretsAdapter, fromVault }
|
||||
from "@suaveplan/api-secrets-vault";
|
||||
import { Vault } from "@suaveplan/vault";
|
||||
|
||||
const vault = Vault.open(vaultConfig);
|
||||
await vault.unlock(password, secretKey);
|
||||
|
||||
const secrets = createVaultSecretsAdapter({
|
||||
vault: fromVault(vault),
|
||||
allowlist: ["STRIPE_SECRET_KEY", "JWT_SIGNING_KEY"],
|
||||
});
|
||||
|
||||
const stripeKey = await secrets.get("STRIPE_SECRET_KEY");
|
||||
const jwt = await secrets.require("JWT_SIGNING_KEY");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire a deterministic backend for tests (no vault unlock required):
|
||||
|
||||
```ts
|
||||
import { createVaultSecretsAdapter }
|
||||
from "@suaveplan/api-secrets-vault";
|
||||
import type { VaultSecretsBackend }
|
||||
from "@suaveplan/api-secrets-vault/types";
|
||||
|
||||
function makeFixtureBackend(seed: Record<string, string>): VaultSecretsBackend {
|
||||
const store = new Map(Object.entries(seed));
|
||||
const listeners = new Set<(p: { name: string }) => void>();
|
||||
return {
|
||||
get isLocked() {
|
||||
return false;
|
||||
},
|
||||
async get(name) {
|
||||
return store.get(name);
|
||||
},
|
||||
async set(name, value) {
|
||||
store.set(name, value);
|
||||
for (const l of listeners) l({ name });
|
||||
},
|
||||
async delete(name) {
|
||||
store.delete(name);
|
||||
},
|
||||
async listNames() {
|
||||
return [...store.keys()];
|
||||
},
|
||||
onSecretUpdated(l) {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const adapter = createVaultSecretsAdapter({
|
||||
vault: makeFixtureBackend({ STRIPE: "sk_test_123" }),
|
||||
allowlist: ["STRIPE"],
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface CreateVaultSecretsAdapterOptions`
|
||||
- `vault: VaultSecretsBackend` — pre-unlocked.
|
||||
- `allowlist: readonly string[]`
|
||||
- `interface VaultSecretsAdapter extends SecretsLike`
|
||||
- `readonly allowlist: Allowlist`
|
||||
- `dispose(): void` — idempotent
|
||||
- `createVaultSecretsAdapter(options): VaultSecretsAdapter`
|
||||
- Throws `SECRETS_VAULT.INVALID_OPTIONS` on bad options.
|
||||
- Returned `SecretsLike`:
|
||||
- `get(name): Promise<string | undefined>`
|
||||
- `require(name): Promise<string>`
|
||||
- `list(): readonly string[]`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The allow-list check ordering matters — running it BEFORE the
|
||||
locked-state check means a non-permitted name on a locked vault
|
||||
raises `NOT_ALLOWED`, not `VAULT_LOCKED`. This is deliberate: the
|
||||
allow-list rejection is the canonical "you cannot ask about this
|
||||
name" answer regardless of vault state, and the alternative would
|
||||
leak liveness information about the vault to plugins that aren't
|
||||
allowed to read from it.
|
||||
|
||||
Backend exceptions are wrapped in `VAULT_UNAVAILABLE` only if they
|
||||
are not already a `SecretsVaultError` — a backend that throws our
|
||||
own typed error (e.g. via the `fromVault` write-path) surfaces
|
||||
through unchanged so codes don't double-wrap.
|
||||
|
||||
`dispose()` is idempotent and currently a no-op at the adapter
|
||||
level; the watcher owns the live subscription. Kept on the surface
|
||||
so capability-adapter lifecycles look uniform across the family.
|
||||
@@ -0,0 +1,76 @@
|
||||
// NOTE: import from source not dist — api-secrets's dist bundles vitest
|
||||
// internals (pre-existing build-config bug; tracked upstream). Source
|
||||
// import goes through workspace TS resolution and uses our local vitest.
|
||||
import { runSecretsConformance } from "../../../api-secrets/src/testing/conformance.js";
|
||||
import { SECRETS_VAULT_CODES } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
VaultSecretListener,
|
||||
VaultSecretsBackend,
|
||||
VaultUnsubscribe,
|
||||
} from "../types/types.js";
|
||||
import { createVaultSecretsAdapter } from "./adapter.js";
|
||||
|
||||
function makeBackend(seed: Record<string, string>): VaultSecretsBackend {
|
||||
const store = new Map<string, string>(Object.entries(seed));
|
||||
const listeners = new Set<VaultSecretListener>();
|
||||
return {
|
||||
get isLocked(): boolean {
|
||||
return false;
|
||||
},
|
||||
async get(name: string): Promise<string | undefined> {
|
||||
return store.get(name);
|
||||
},
|
||||
async set(name: string, value: string): Promise<void> {
|
||||
store.set(name, value);
|
||||
for (const l of listeners) void l({ name });
|
||||
},
|
||||
async delete(name: string): Promise<void> {
|
||||
store.delete(name);
|
||||
},
|
||||
async listNames(): Promise<readonly string[]> {
|
||||
return [...store.keys()];
|
||||
},
|
||||
onSecretUpdated(listener): VaultUnsubscribe {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
runSecretsConformance({
|
||||
label: "vault",
|
||||
factory: (seed) => {
|
||||
const seededNames = Object.keys(seed);
|
||||
// Allow every seeded name plus the harness's "MISSING" probe so
|
||||
// the not-found scenarios exercise our `NOT_FOUND` path rather
|
||||
// than the allow-list rejection.
|
||||
const allowlist =
|
||||
seededNames.length === 0
|
||||
? ["MISSING"]
|
||||
: [...seededNames, "MISSING"];
|
||||
const backend = makeBackend(seed);
|
||||
const adapter = createVaultSecretsAdapter({
|
||||
vault: backend,
|
||||
allowlist,
|
||||
});
|
||||
return {
|
||||
secrets: {
|
||||
get: (name) => adapter.get(name),
|
||||
require: (name) => adapter.require(name),
|
||||
// The harness verifies `list()` returns exactly the seeded
|
||||
// names. Filter out the "MISSING" probe we added solely to
|
||||
// route the not-found scenarios through NOT_FOUND.
|
||||
list: () =>
|
||||
adapter.list().filter((n) => n !== "MISSING" || n in seed),
|
||||
},
|
||||
teardown: () => {
|
||||
adapter.dispose();
|
||||
},
|
||||
};
|
||||
},
|
||||
isNotFound: (err) =>
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"code" in err &&
|
||||
(err as { code: unknown }).code === SECRETS_VAULT_CODES.NOT_FOUND,
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
|
||||
import {
|
||||
SECRETS_VAULT_CODES,
|
||||
SecretsVaultError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { resetSecretsVaultInstruments } from "../otel/instruments.js";
|
||||
import type {
|
||||
VaultSecretListener,
|
||||
VaultSecretsBackend,
|
||||
VaultUnsubscribe,
|
||||
} from "../types/types.js";
|
||||
import { createVaultSecretsAdapter } from "./adapter.js";
|
||||
|
||||
interface FixtureBackend extends VaultSecretsBackend {
|
||||
locked: boolean;
|
||||
rawSet(name: string, value: string): void;
|
||||
rawDelete(name: string): void;
|
||||
failOnGet(message?: string): void;
|
||||
listeners: Set<VaultSecretListener>;
|
||||
}
|
||||
|
||||
function makeFixtureBackend(seed: Record<string, string> = {}): FixtureBackend {
|
||||
const store = new Map<string, string>(Object.entries(seed));
|
||||
const listeners = new Set<VaultSecretListener>();
|
||||
let locked = false;
|
||||
let throwOnGet: string | null = null;
|
||||
const backend: FixtureBackend = {
|
||||
get isLocked(): boolean {
|
||||
return locked;
|
||||
},
|
||||
set locked(v: boolean) {
|
||||
locked = v;
|
||||
},
|
||||
get locked(): boolean {
|
||||
return locked;
|
||||
},
|
||||
listeners,
|
||||
async get(name: string): Promise<string | undefined> {
|
||||
if (throwOnGet !== null) {
|
||||
throw new Error(throwOnGet);
|
||||
}
|
||||
return store.get(name);
|
||||
},
|
||||
async set(name: string, value: string): Promise<void> {
|
||||
store.set(name, value);
|
||||
for (const l of listeners) void l({ name });
|
||||
},
|
||||
async delete(name: string): Promise<void> {
|
||||
const had = store.delete(name);
|
||||
if (had) {
|
||||
for (const l of listeners) void l({ name });
|
||||
}
|
||||
},
|
||||
async listNames(): Promise<readonly string[]> {
|
||||
return [...store.keys()];
|
||||
},
|
||||
onSecretUpdated(listener): VaultUnsubscribe {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
rawSet(name, value) {
|
||||
store.set(name, value);
|
||||
},
|
||||
rawDelete(name) {
|
||||
store.delete(name);
|
||||
},
|
||||
failOnGet(message = "io down") {
|
||||
throwOnGet = message;
|
||||
},
|
||||
};
|
||||
return backend;
|
||||
}
|
||||
|
||||
describe("createVaultSecretsAdapter — option validation", () => {
|
||||
it("rejects a non-object vault", { timeout: 5000 }, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
createVaultSecretsAdapter({
|
||||
vault: null as unknown as VaultSecretsBackend,
|
||||
allowlist: ["A"],
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SecretsVaultError);
|
||||
expect((caught as SecretsVaultError).code).toBe(
|
||||
SECRETS_VAULT_CODES.INVALID_OPTIONS
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an empty-string allowlist entry", { timeout: 5000 }, () => {
|
||||
const backend = makeFixtureBackend();
|
||||
let caught: unknown;
|
||||
try {
|
||||
createVaultSecretsAdapter({ vault: backend, allowlist: [""] });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SecretsVaultError);
|
||||
expect((caught as SecretsVaultError).code).toBe(
|
||||
SECRETS_VAULT_CODES.INVALID_OPTIONS
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createVaultSecretsAdapter — allow-list gate", () => {
|
||||
it("get rejects a non-allowlisted name with NOT_ALLOWED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = makeFixtureBackend({ B: "2" });
|
||||
const adapter = createVaultSecretsAdapter({
|
||||
vault: backend,
|
||||
allowlist: ["A"],
|
||||
});
|
||||
await expect(adapter.get("B")).rejects.toMatchObject({
|
||||
code: SECRETS_VAULT_CODES.NOT_ALLOWED,
|
||||
});
|
||||
});
|
||||
|
||||
it("require rejects a non-allowlisted name with NOT_ALLOWED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = makeFixtureBackend();
|
||||
const adapter = createVaultSecretsAdapter({
|
||||
vault: backend,
|
||||
allowlist: ["A"],
|
||||
});
|
||||
await expect(adapter.require("B")).rejects.toMatchObject({
|
||||
code: SECRETS_VAULT_CODES.NOT_ALLOWED,
|
||||
});
|
||||
});
|
||||
|
||||
it("allow-list precedes the locked-state check", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = makeFixtureBackend();
|
||||
backend.locked = true;
|
||||
const adapter = createVaultSecretsAdapter({
|
||||
vault: backend,
|
||||
allowlist: ["A"],
|
||||
});
|
||||
await expect(adapter.get("B")).rejects.toMatchObject({
|
||||
code: SECRETS_VAULT_CODES.NOT_ALLOWED,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createVaultSecretsAdapter — locked vault", () => {
|
||||
it("locked vault raises SECRETS_VAULT_LOCKED on get", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = makeFixtureBackend({ A: "1" });
|
||||
backend.locked = true;
|
||||
const adapter = createVaultSecretsAdapter({
|
||||
vault: backend,
|
||||
allowlist: ["A"],
|
||||
});
|
||||
await expect(adapter.get("A")).rejects.toMatchObject({
|
||||
code: SECRETS_VAULT_CODES.VAULT_LOCKED,
|
||||
});
|
||||
});
|
||||
|
||||
it("locked vault raises SECRETS_VAULT_LOCKED on require", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = makeFixtureBackend({ A: "1" });
|
||||
backend.locked = true;
|
||||
const adapter = createVaultSecretsAdapter({
|
||||
vault: backend,
|
||||
allowlist: ["A"],
|
||||
});
|
||||
await expect(adapter.require("A")).rejects.toMatchObject({
|
||||
code: SECRETS_VAULT_CODES.VAULT_LOCKED,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createVaultSecretsAdapter — read happy path", () => {
|
||||
it("get returns stored value AND increments reads_total", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
resetSecretsVaultInstruments();
|
||||
const backend = makeFixtureBackend({ STRIPE: "sk_test_123" });
|
||||
const adapter = createVaultSecretsAdapter({
|
||||
vault: backend,
|
||||
allowlist: ["STRIPE"],
|
||||
});
|
||||
const value = await adapter.get("STRIPE");
|
||||
expect(value).toBe("sk_test_123");
|
||||
const snapshots = await flushTestMetrics();
|
||||
const all = snapshots.flatMap((s) =>
|
||||
s.scopeMetrics.flatMap((sm) => sm.metrics)
|
||||
);
|
||||
const hit = all.find(
|
||||
(m) => m.descriptor.name === "api.secrets.reads_total"
|
||||
);
|
||||
expect(hit).toBeDefined();
|
||||
});
|
||||
|
||||
it("require resolves to the seeded value", { timeout: 5000 }, async () => {
|
||||
const backend = makeFixtureBackend({ JWT: "key" });
|
||||
const adapter = createVaultSecretsAdapter({
|
||||
vault: backend,
|
||||
allowlist: ["JWT"],
|
||||
});
|
||||
await expect(adapter.require("JWT")).resolves.toBe("key");
|
||||
});
|
||||
|
||||
it("get resolves to undefined for allowlisted-but-absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = makeFixtureBackend();
|
||||
const adapter = createVaultSecretsAdapter({
|
||||
vault: backend,
|
||||
allowlist: ["JWT"],
|
||||
});
|
||||
await expect(adapter.get("JWT")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("require raises NOT_FOUND for allowlisted-but-absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = makeFixtureBackend();
|
||||
const adapter = createVaultSecretsAdapter({
|
||||
vault: backend,
|
||||
allowlist: ["JWT"],
|
||||
});
|
||||
await expect(adapter.require("JWT")).rejects.toMatchObject({
|
||||
code: SECRETS_VAULT_CODES.NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
it("list returns the frozen allow-list snapshot", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const backend = makeFixtureBackend({ A: "1", B: "2", LEAKED: "x" });
|
||||
const adapter = createVaultSecretsAdapter({
|
||||
vault: backend,
|
||||
allowlist: ["A", "B"],
|
||||
});
|
||||
const names = [...adapter.list()].sort();
|
||||
expect(names).toEqual(["A", "B"]);
|
||||
expect(Object.isFrozen(adapter.list())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createVaultSecretsAdapter — backend errors", () => {
|
||||
it("wraps unexpected backend throws as VAULT_UNAVAILABLE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = makeFixtureBackend();
|
||||
backend.failOnGet("network partition");
|
||||
const adapter = createVaultSecretsAdapter({
|
||||
vault: backend,
|
||||
allowlist: ["A"],
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await adapter.get("A");
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SecretsVaultError);
|
||||
expect((caught as SecretsVaultError).code).toBe(
|
||||
SECRETS_VAULT_CODES.VAULT_UNAVAILABLE
|
||||
);
|
||||
expect((caught as SecretsVaultError).cause).toBeDefined();
|
||||
});
|
||||
|
||||
it("VAULT_UNAVAILABLE surfaces from require too", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = makeFixtureBackend();
|
||||
backend.failOnGet();
|
||||
const adapter = createVaultSecretsAdapter({
|
||||
vault: backend,
|
||||
allowlist: ["A"],
|
||||
});
|
||||
await expect(adapter.require("A")).rejects.toMatchObject({
|
||||
code: SECRETS_VAULT_CODES.VAULT_UNAVAILABLE,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not double-wrap a SecretsVaultError thrown by the backend", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const passthrough: VaultSecretsBackend = {
|
||||
get isLocked(): boolean {
|
||||
return false;
|
||||
},
|
||||
async get(): Promise<string | undefined> {
|
||||
throw new SecretsVaultError(
|
||||
SECRETS_VAULT_CODES.WRITE_FAILED,
|
||||
"error.test.passthrough"
|
||||
);
|
||||
},
|
||||
async set(): Promise<void> {},
|
||||
async delete(): Promise<void> {},
|
||||
async listNames(): Promise<readonly string[]> {
|
||||
return [];
|
||||
},
|
||||
onSecretUpdated(): VaultUnsubscribe {
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
const adapter = createVaultSecretsAdapter({
|
||||
vault: passthrough,
|
||||
allowlist: ["A"],
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await adapter.get("A");
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect((caught as SecretsVaultError).code).toBe(
|
||||
SECRETS_VAULT_CODES.WRITE_FAILED
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createVaultSecretsAdapter — dispose", () => {
|
||||
it("dispose is idempotent", { timeout: 5000 }, () => {
|
||||
const backend = makeFixtureBackend();
|
||||
const adapter = createVaultSecretsAdapter({
|
||||
vault: backend,
|
||||
allowlist: ["A"],
|
||||
});
|
||||
adapter.dispose();
|
||||
adapter.dispose();
|
||||
adapter.dispose();
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes the compiled allowlist for watcher reuse", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const backend = makeFixtureBackend();
|
||||
const adapter = createVaultSecretsAdapter({
|
||||
vault: backend,
|
||||
allowlist: ["A", "B"],
|
||||
});
|
||||
expect(adapter.allowlist.isAllowed("A")).toBe(true);
|
||||
expect(adapter.allowlist.isAllowed("C")).toBe(false);
|
||||
});
|
||||
});
|
||||
150
packages/api-stack/api/api-secrets-vault/src/adapter/adapter.ts
Normal file
150
packages/api-stack/api/api-secrets-vault/src/adapter/adapter.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { SecretsLike } from "@suaveplan/api-types/secrets";
|
||||
import z from "zod";
|
||||
import { type Allowlist, createAllowlist } from "../allowlist/allowlist.js";
|
||||
import {
|
||||
SECRETS_VAULT_CODES,
|
||||
SecretsVaultError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { getSecretsVaultInstruments } from "../otel/instruments.js";
|
||||
import type { VaultSecretsBackend } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Construction options for {@link createVaultSecretsAdapter}.
|
||||
*
|
||||
* - `vault` — an unlocked `VaultSecretsBackend`. The host's startup
|
||||
* sequence unlocks the underlying `@suaveplan/vault.Vault` before
|
||||
* constructing the adapter; the adapter never sees the master key.
|
||||
* - `allowlist` — the per-plugin whitelist drawn from the plugin
|
||||
* manifest's `secrets[]`. Reads outside this set raise
|
||||
* `SECRETS_VAULT.NOT_ALLOWED` BEFORE the vault is touched.
|
||||
*/
|
||||
export interface CreateVaultSecretsAdapterOptions {
|
||||
readonly vault: VaultSecretsBackend;
|
||||
readonly allowlist: readonly string[];
|
||||
}
|
||||
|
||||
const optionsSchema = z.object({
|
||||
vault: z.object({
|
||||
isLocked: z.boolean(),
|
||||
get: z.function(),
|
||||
set: z.function(),
|
||||
delete: z.function(),
|
||||
listNames: z.function(),
|
||||
onSecretUpdated: z.function(),
|
||||
}),
|
||||
allowlist: z.array(z.string().min(1)),
|
||||
});
|
||||
|
||||
/**
|
||||
* Vault-backed `SecretsLike` instance plus a disposal hook. The
|
||||
* adapter is returned alongside a `dispose()` that the host calls
|
||||
* during plugin teardown — currently a no-op at the adapter level
|
||||
* (the watcher owns the live subscription), kept on the surface so
|
||||
* dispose semantics are uniform across capability adapters.
|
||||
*/
|
||||
export interface VaultSecretsAdapter extends SecretsLike {
|
||||
/** Compiled allow-list. Exposed so the watcher can intersect
|
||||
* with the same set without re-compiling Zod options. */
|
||||
readonly allowlist: Allowlist;
|
||||
/** Idempotent disposal. Safe to call multiple times. */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a {@link SecretsLike} backed by an unlocked
|
||||
* `VaultSecretsBackend` with per-plugin allow-list enforcement and
|
||||
* OTel telemetry. The returned adapter:
|
||||
*
|
||||
* 1. validates options with Zod (raises `SECRETS_VAULT.INVALID_OPTIONS`
|
||||
* on failure);
|
||||
* 2. on every `get` / `require`:
|
||||
* - rejects names outside `allowlist` with
|
||||
* `SECRETS_VAULT.NOT_ALLOWED` BEFORE touching the vault;
|
||||
* - raises `SECRETS_VAULT.VAULT_LOCKED` when `vault.isLocked`
|
||||
* is `true` — no silent plaintext fallback;
|
||||
* - wraps unexpected backend errors in
|
||||
* `SECRETS_VAULT.VAULT_UNAVAILABLE` so transport failures
|
||||
* surface distinctly from "key not present" semantics;
|
||||
* - resolves `get` to `undefined` when the vault returns no
|
||||
* entry; `require` raises `SECRETS_VAULT.NOT_FOUND`;
|
||||
* - increments `api.secrets.reads_total{backend="vault"}` on
|
||||
* every successful resolution;
|
||||
* 3. `list()` returns the frozen allow-list copy so the worker
|
||||
* surface cannot enumerate secret names beyond the plugin's
|
||||
* manifest.
|
||||
*/
|
||||
export function createVaultSecretsAdapter(
|
||||
options: CreateVaultSecretsAdapterOptions
|
||||
): VaultSecretsAdapter {
|
||||
const parsed = optionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new SecretsVaultError(
|
||||
SECRETS_VAULT_CODES.INVALID_OPTIONS,
|
||||
"error.secrets_vault.invalid_options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const { vault } = options;
|
||||
const allow = createAllowlist({ names: options.allowlist });
|
||||
let disposed = false;
|
||||
|
||||
const ensureUnlocked = (): void => {
|
||||
if (vault.isLocked) {
|
||||
throw new SecretsVaultError(
|
||||
SECRETS_VAULT_CODES.VAULT_LOCKED,
|
||||
"error.secrets_vault.vault_locked"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const safeGet = async (name: string): Promise<string | undefined> => {
|
||||
try {
|
||||
return await vault.get(name);
|
||||
} catch (cause) {
|
||||
if (cause instanceof SecretsVaultError) throw cause;
|
||||
throw new SecretsVaultError(
|
||||
SECRETS_VAULT_CODES.VAULT_UNAVAILABLE,
|
||||
"error.secrets_vault.vault_unavailable",
|
||||
{ cause, context: { name } }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
allowlist: allow,
|
||||
async get(name: string): Promise<string | undefined> {
|
||||
allow.assertAllowed(name);
|
||||
ensureUnlocked();
|
||||
const value = await safeGet(name);
|
||||
if (value !== undefined) {
|
||||
getSecretsVaultInstruments().readsTotal.add(1, {
|
||||
backend: "vault",
|
||||
});
|
||||
}
|
||||
return value;
|
||||
},
|
||||
async require(name: string): Promise<string> {
|
||||
allow.assertAllowed(name);
|
||||
ensureUnlocked();
|
||||
const value = await safeGet(name);
|
||||
if (value === undefined) {
|
||||
throw new SecretsVaultError(
|
||||
SECRETS_VAULT_CODES.NOT_FOUND,
|
||||
"error.secrets_vault.not_found",
|
||||
{ context: { name } }
|
||||
);
|
||||
}
|
||||
getSecretsVaultInstruments().readsTotal.add(1, {
|
||||
backend: "vault",
|
||||
});
|
||||
return value;
|
||||
},
|
||||
list(): readonly string[] {
|
||||
return allow.names;
|
||||
},
|
||||
dispose(): void {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type CreateVaultSecretsAdapterOptions,
|
||||
createVaultSecretsAdapter,
|
||||
type VaultSecretsAdapter,
|
||||
} from "./adapter.js";
|
||||
@@ -0,0 +1,79 @@
|
||||
# `allowlist`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Compiled per-plugin allow-list of secret names. The vault-backed
|
||||
adapter calls `assertAllowed` BEFORE consulting the backend so a
|
||||
non-permitted name never even discovers whether a corresponding
|
||||
secret exists, and the watcher calls `isAllowed` as a filter on every
|
||||
forwarded `secret.updated` event. Decoupling the allow-list into its
|
||||
own module lets the adapter and watcher share the same compiled set
|
||||
without re-running Zod option validation per call site.
|
||||
|
||||
## Features
|
||||
|
||||
- `createAllowlist({ names })` — Zod-validated factory returning a
|
||||
frozen `Allowlist` (`isAllowed`, `assertAllowed`, `names`).
|
||||
- O(1) lookups via internal `Set<string>` — `assertAllowed` is on
|
||||
the hot read path of every `get` / `require` call.
|
||||
- Deduplicated + sorted public `names` array, frozen so a consumer
|
||||
cannot mutate the surface back upstream.
|
||||
- Empty input is permitted: a plugin manifest that declares no
|
||||
secrets compiles to an allow-list that rejects every read at the
|
||||
gate (strict-mode behaviour by design).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createAllowlist } from "@suaveplan/api-secrets-vault/allowlist";
|
||||
|
||||
const allow = createAllowlist({ names: ["STRIPE", "JWT_SIGNING"] });
|
||||
allow.assertAllowed("STRIPE"); // ok
|
||||
allow.assertAllowed("AWS"); // throws SECRETS_VAULT.NOT_ALLOWED
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Use the predicate form as a watcher filter:
|
||||
|
||||
```ts
|
||||
import { createAllowlist } from "@suaveplan/api-secrets-vault/allowlist";
|
||||
|
||||
const allow = createAllowlist({ names: ["STRIPE"] });
|
||||
|
||||
backend.onSecretUpdated((payload) => {
|
||||
if (!allow.isAllowed(payload.name)) return;
|
||||
bus.emit("secret.changed", { name: payload.name });
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface CreateAllowlistOptions { readonly names: readonly string[] }`
|
||||
- `createAllowlist(options): Allowlist`
|
||||
- `interface Allowlist`
|
||||
- `readonly names: readonly string[]`
|
||||
- `isAllowed(name: string): boolean`
|
||||
- `assertAllowed(name: string): void`
|
||||
— throws `SecretsVaultError(SECRETS_VAULT.NOT_ALLOWED)`
|
||||
- Construction failures raise
|
||||
`SecretsVaultError(SECRETS_VAULT.INVALID_OPTIONS)` carrying the raw
|
||||
Zod issue list on `error.context.issues`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `names` snapshot is built via `new Set(names).sort()` so callers
|
||||
who accidentally pass duplicates get a clean public list, and the
|
||||
sort makes the snapshot's iteration order deterministic — important
|
||||
for tests that snapshot the allow-list shape, and for cache keys
|
||||
keyed on the allow-list ordering.
|
||||
|
||||
The Zod schema rejects empty-string entries because a zero-length
|
||||
allow-list entry would collide with the conformance harness's
|
||||
"missing-name" probe and silently grant access to a "" key in the
|
||||
backend. Real plugin manifests never declare a "" secret, so the
|
||||
check is free strictness.
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SECRETS_VAULT_CODES,
|
||||
SecretsVaultError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { createAllowlist } from "./allowlist.js";
|
||||
|
||||
describe("createAllowlist", () => {
|
||||
it("rejects non-string names with INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
const bad = 123 as unknown;
|
||||
createAllowlist({ names: [bad as string] });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SecretsVaultError);
|
||||
expect((caught as SecretsVaultError).code).toBe(
|
||||
SECRETS_VAULT_CODES.INVALID_OPTIONS
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects empty-string entries", { timeout: 5000 }, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
createAllowlist({ names: ["A", ""] });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SecretsVaultError);
|
||||
});
|
||||
|
||||
it("accepts an empty list", { timeout: 5000 }, () => {
|
||||
const allow = createAllowlist({ names: [] });
|
||||
expect(allow.names).toEqual([]);
|
||||
expect(allow.isAllowed("anything")).toBe(false);
|
||||
});
|
||||
|
||||
it("deduplicates and sorts names", { timeout: 5000 }, () => {
|
||||
const allow = createAllowlist({ names: ["B", "A", "B", "A"] });
|
||||
expect(allow.names).toEqual(["A", "B"]);
|
||||
});
|
||||
|
||||
it("isAllowed is true for in-list names", { timeout: 5000 }, () => {
|
||||
const allow = createAllowlist({ names: ["STRIPE"] });
|
||||
expect(allow.isAllowed("STRIPE")).toBe(true);
|
||||
expect(allow.isAllowed("AWS")).toBe(false);
|
||||
});
|
||||
|
||||
it("assertAllowed raises NOT_ALLOWED with the name in context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const allow = createAllowlist({ names: ["STRIPE"] });
|
||||
let caught: unknown;
|
||||
try {
|
||||
allow.assertAllowed("AWS");
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SecretsVaultError);
|
||||
expect((caught as SecretsVaultError).code).toBe(
|
||||
SECRETS_VAULT_CODES.NOT_ALLOWED
|
||||
);
|
||||
expect((caught as SecretsVaultError).context).toEqual({
|
||||
name: "AWS",
|
||||
});
|
||||
});
|
||||
|
||||
it("un-manifested read raises SECRETS_VAULT_NOT_ALLOWED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const allow = createAllowlist({ names: ["STRIPE"] });
|
||||
expect(() => allow.assertAllowed("AWS")).toThrow(SecretsVaultError);
|
||||
});
|
||||
|
||||
it("names array is frozen", { timeout: 5000 }, () => {
|
||||
const allow = createAllowlist({ names: ["A"] });
|
||||
expect(Object.isFrozen(allow.names)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import z from "zod";
|
||||
import {
|
||||
SECRETS_VAULT_CODES,
|
||||
SecretsVaultError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Construction options for {@link createAllowlist}. `names` is the
|
||||
* per-plugin whitelist drawn from the plugin manifest's `secrets[]`
|
||||
* declarations. The set MAY be empty — a manifest that declares no
|
||||
* secrets ends up with an allow-list that rejects every read at the
|
||||
* gate, which is the correct strict-mode behaviour.
|
||||
*/
|
||||
export interface CreateAllowlistOptions {
|
||||
readonly names: readonly string[];
|
||||
}
|
||||
|
||||
const optionsSchema = z.object({
|
||||
names: z.array(z.string().min(1)),
|
||||
});
|
||||
|
||||
/**
|
||||
* Compiled per-plugin allow-list. `assertAllowed` throws
|
||||
* `SECRETS_VAULT.NOT_ALLOWED` for any name not in the set;
|
||||
* `isAllowed` is the predicate form for filter pipelines
|
||||
* (e.g. the watcher's allow-list-filtered fan-out).
|
||||
*/
|
||||
export interface Allowlist {
|
||||
/** Frozen sorted snapshot of allowed names. */
|
||||
readonly names: readonly string[];
|
||||
/** Predicate form — returns `true` when the name is allowed. */
|
||||
isAllowed(name: string): boolean;
|
||||
/** Throwing form — raises `SECRETS_VAULT.NOT_ALLOWED` when not
|
||||
* allowed. Used at the adapter's `get` / `require` gate before
|
||||
* the vault is consulted. */
|
||||
assertAllowed(name: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a per-plugin {@link Allowlist} from a list of permitted
|
||||
* names. The constructor:
|
||||
*
|
||||
* 1. validates `options` with Zod (rejects non-string / empty-name
|
||||
* entries) and raises `SECRETS_VAULT.INVALID_OPTIONS` on
|
||||
* failure;
|
||||
* 2. deduplicates and freezes the input list so a later mutation
|
||||
* of the caller's array cannot expand the allowed set;
|
||||
* 3. compiles the names into a `Set<string>` for O(1) lookups —
|
||||
* `assertAllowed` is on the hot read path so the set form
|
||||
* matters under realistic plugin sizes.
|
||||
*/
|
||||
export function createAllowlist(options: CreateAllowlistOptions): Allowlist {
|
||||
const parsed = optionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new SecretsVaultError(
|
||||
SECRETS_VAULT_CODES.INVALID_OPTIONS,
|
||||
"error.secrets_vault.invalid_options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const names = Object.freeze([...new Set(parsed.data.names)].sort());
|
||||
const allowSet = new Set<string>(names);
|
||||
return {
|
||||
names,
|
||||
isAllowed(name: string): boolean {
|
||||
return allowSet.has(name);
|
||||
},
|
||||
assertAllowed(name: string): void {
|
||||
if (!allowSet.has(name)) {
|
||||
throw new SecretsVaultError(
|
||||
SECRETS_VAULT_CODES.NOT_ALLOWED,
|
||||
"error.secrets_vault.not_allowed",
|
||||
{ context: { name } }
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type Allowlist,
|
||||
type CreateAllowlistOptions,
|
||||
createAllowlist,
|
||||
} from "./allowlist.js";
|
||||
@@ -0,0 +1,89 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Frozen registry of `SECRETS_VAULT.*` error codes thrown by the
|
||||
vault-backed `SecretsLike` adapter and its watcher / allow-list
|
||||
helpers. Kept distinct from upstream `SECRETS.*` (owned by
|
||||
`@suaveplan/api-secrets`) and `SECRETS_ENV.*` (the env adapter) so a
|
||||
single log line is enough for operators to identify whether the
|
||||
failure originated in vault-locked state, allow-list rejection,
|
||||
backend I/O failure, or write-path mutation. Built on
|
||||
`defineCodes` + `SuaveplanError` so the throwable carries the
|
||||
canonical `code` field and structured-logger `name` attribute.
|
||||
|
||||
## Features
|
||||
|
||||
- `SECRETS_VAULT_CODES` — frozen object literal with six entries:
|
||||
`NOT_FOUND`, `NOT_ALLOWED`, `VAULT_LOCKED`, `VAULT_UNAVAILABLE`,
|
||||
`WRITE_FAILED`, `INVALID_OPTIONS`.
|
||||
- `SecretsVaultCode` — string-literal union of the six codes.
|
||||
- `SecretsVaultError` — `SuaveplanError<SecretsVaultCode>` subclass
|
||||
with `error.name === "SecretsVaultError"`.
|
||||
- `SECRETS_VAULT_ERROR_NAME` — exported constant for cross-package
|
||||
`name`-equality checks.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
SECRETS_VAULT_CODES,
|
||||
SecretsVaultError,
|
||||
} from "@suaveplan/api-secrets-vault/error-codes";
|
||||
|
||||
throw new SecretsVaultError(
|
||||
SECRETS_VAULT_CODES.NOT_ALLOWED,
|
||||
"error.secrets_vault.not_allowed",
|
||||
{ context: { name: "AWS" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Cause-chained surfacing from a backend I/O failure:
|
||||
|
||||
```ts
|
||||
import {
|
||||
SECRETS_VAULT_CODES,
|
||||
SecretsVaultError,
|
||||
} from "@suaveplan/api-secrets-vault/error-codes";
|
||||
|
||||
try {
|
||||
await backend.get("STRIPE");
|
||||
} catch (cause) {
|
||||
throw new SecretsVaultError(
|
||||
SECRETS_VAULT_CODES.VAULT_UNAVAILABLE,
|
||||
"error.secrets_vault.vault_unavailable",
|
||||
{ cause, context: { name: "STRIPE" } }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SECRETS_VAULT_CODES: Readonly<{ NOT_FOUND, NOT_ALLOWED, VAULT_LOCKED,
|
||||
VAULT_UNAVAILABLE, WRITE_FAILED, INVALID_OPTIONS }>`
|
||||
- `type SecretsVaultCode = (typeof SECRETS_VAULT_CODES)[…]`
|
||||
- `const SECRETS_VAULT_ERROR_NAME = "SecretsVaultError"`
|
||||
- `class SecretsVaultError extends SuaveplanError<SecretsVaultCode>`
|
||||
- `constructor(code, messageKey, options?: SuaveplanErrorOptions)`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The codes register at module load via the package's root barrel
|
||||
side-effect-importing this file. That guarantees subpath consumers
|
||||
who import `@suaveplan/api-secrets-vault/adapter` directly still get
|
||||
the registry populated for cross-package code lookups. The throwable
|
||||
attaches the original error on `cause` per WHATWG semantics, which
|
||||
the `error.cause`-chain redactor in `@suaveplan/error` walks during
|
||||
log emission.
|
||||
|
||||
`VAULT_LOCKED` is intentionally raised eagerly rather than letting
|
||||
the backend's own locked-state error bubble up — this preserves the
|
||||
package's strict-mode invariant (no plaintext fallback on a locked
|
||||
vault) and lets the adapter throw a consistent code regardless of
|
||||
which `@suaveplan/vault` version is in scope.
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SECRETS_VAULT_CODES,
|
||||
SECRETS_VAULT_ERROR_NAME,
|
||||
SecretsVaultError,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("SECRETS_VAULT_CODES registry", () => {
|
||||
it("registers every documented code", { timeout: 5000 }, () => {
|
||||
expect(Object.keys(SECRETS_VAULT_CODES).sort()).toEqual([
|
||||
"INVALID_OPTIONS",
|
||||
"NOT_ALLOWED",
|
||||
"NOT_FOUND",
|
||||
"VAULT_LOCKED",
|
||||
"VAULT_UNAVAILABLE",
|
||||
"WRITE_FAILED",
|
||||
]);
|
||||
});
|
||||
|
||||
it("every code is a fully-qualified SECRETS_VAULT.* string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const code of Object.values(SECRETS_VAULT_CODES)) {
|
||||
expect(code.startsWith("SECRETS_VAULT.")).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("SecretsVaultError", () => {
|
||||
it("attaches the canonical error.name", { timeout: 5000 }, () => {
|
||||
const err = new SecretsVaultError(
|
||||
SECRETS_VAULT_CODES.NOT_FOUND,
|
||||
"error.secrets_vault.not_found"
|
||||
);
|
||||
expect(err.name).toBe(SECRETS_VAULT_ERROR_NAME);
|
||||
expect(err.code).toBe(SECRETS_VAULT_CODES.NOT_FOUND);
|
||||
});
|
||||
|
||||
it("preserves cause + context", { timeout: 5000 }, () => {
|
||||
const cause = new Error("io down");
|
||||
const err = new SecretsVaultError(
|
||||
SECRETS_VAULT_CODES.VAULT_UNAVAILABLE,
|
||||
"error.secrets_vault.vault_unavailable",
|
||||
{ cause, context: { name: "STRIPE" } }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ name: "STRIPE" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Frozen registry of error codes thrown by the vault-backed
|
||||
* `SecretsLike` adapter. Distinct from upstream `SECRETS.*` (owned by
|
||||
* `@suaveplan/api-secrets`) and `SECRETS_ENV.*` (env adapter) so
|
||||
* operators can disambiguate the failure surface from a single log
|
||||
* line.
|
||||
*
|
||||
* - `NOT_FOUND` — the requested name IS in the per-plugin allow-list
|
||||
* but the vault holds no entry for it. Surfaced from `require()`;
|
||||
* `get()` resolves to `undefined` for parity with the `SecretsLike`
|
||||
* contract.
|
||||
* - `NOT_ALLOWED` — the requested name is not in the plugin
|
||||
* manifest's `secrets[]` allow-list. Enforced BEFORE the vault is
|
||||
* consulted so a non-permitted name never resolves a value or even
|
||||
* advertises whether one exists.
|
||||
* - `VAULT_LOCKED` — the underlying vault is locked at the moment of
|
||||
* the read. Distinct from `NOT_FOUND`: a missing-but-vault-locked
|
||||
* condition raises this code instead of silently returning
|
||||
* `undefined`, preserving strict-mode operational semantics.
|
||||
* - `VAULT_UNAVAILABLE` — the vault threw an unexpected error during
|
||||
* a read (drive I/O failure, decrypt error, etc.). The original
|
||||
* error is attached as `cause`.
|
||||
* - `WRITE_FAILED` — `set()` / `delete()` on the backend raised. Not
|
||||
* used by the read-only `SecretsLike` surface; reserved for direct
|
||||
* use of `createVaultSecretsAdapter` mutating helpers in admin
|
||||
* surfaces.
|
||||
* - `INVALID_OPTIONS` — adapter / watcher construction options failed
|
||||
* Zod validation.
|
||||
*/
|
||||
export const SECRETS_VAULT_CODES = defineCodes("SECRETS_VAULT", [
|
||||
"NOT_FOUND",
|
||||
"NOT_ALLOWED",
|
||||
"VAULT_LOCKED",
|
||||
"VAULT_UNAVAILABLE",
|
||||
"WRITE_FAILED",
|
||||
"INVALID_OPTIONS",
|
||||
] as const);
|
||||
|
||||
/**
|
||||
* Union of fully-qualified `SECRETS_VAULT.*` codes. Use as the `code`
|
||||
* field type on a `SecretsVaultError`-typed throwable.
|
||||
*/
|
||||
export type SecretsVaultCode =
|
||||
(typeof SECRETS_VAULT_CODES)[keyof typeof SECRETS_VAULT_CODES];
|
||||
|
||||
/**
|
||||
* Stable `error.name` value for every throwable this package produces.
|
||||
* Structured loggers group on `name` for cross-code aggregation.
|
||||
*/
|
||||
export const SECRETS_VAULT_ERROR_NAME = "SecretsVaultError" as const;
|
||||
|
||||
/**
|
||||
* Concrete `SuaveplanError` subclass for vault-adapter failures.
|
||||
* Mirrors the shape of `SecretsError` / `SecretsEnvError` so consumers
|
||||
* can `instanceof`-check or code-match without parsing message strings.
|
||||
*/
|
||||
export class SecretsVaultError extends SuaveplanError<SecretsVaultCode> {
|
||||
public constructor(
|
||||
code: SecretsVaultCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = SECRETS_VAULT_ERROR_NAME;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
SECRETS_VAULT_CODES,
|
||||
SECRETS_VAULT_ERROR_NAME,
|
||||
type SecretsVaultCode,
|
||||
SecretsVaultError,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1,117 @@
|
||||
# `from-vault`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Bridge a live `@suaveplan/vault.Vault` instance to the structural
|
||||
`VaultSecretsBackend` the adapter consumes. The bridge encodes
|
||||
secrets as vault items with `category === "secret"` and a single
|
||||
concealed field `"value"` so the underlying field-encryption layer
|
||||
covers them at rest; mutations through the bridge emit
|
||||
`secret.updated` to subscribers so the watcher's host-bus fan-out
|
||||
fires deterministically on rotation through this surface.
|
||||
|
||||
## Features
|
||||
|
||||
- `fromVault(vault): VaultSecretsBackend` — delegates `get` /
|
||||
`set` / `delete` / `listNames` onto `vault.items` with the
|
||||
`category === "secret"` convention.
|
||||
- In-bridge `secret.updated` emission on every mutation that
|
||||
flows through this bridge — the real `@suaveplan/vault.Vault`
|
||||
does not broadcast item events natively, so the bridge owns the
|
||||
fan-out.
|
||||
- Surfaces `vault.isLocked` directly: adapter reads honour the
|
||||
vault lifecycle FSM without an extra caching layer.
|
||||
- Write-path errors wrap as `SECRETS_VAULT.WRITE_FAILED` with the
|
||||
original cause preserved on `error.cause`.
|
||||
- `VAULT_SECRETS_CATEGORY` constant exported for vault inspector
|
||||
/ key-rotation tooling that wants to filter on the same label.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createVaultSecretsAdapter,
|
||||
fromVault,
|
||||
} from "@suaveplan/api-secrets-vault";
|
||||
import { Vault } from "@suaveplan/vault";
|
||||
|
||||
const vault = Vault.open(vaultConfig);
|
||||
await vault.unlock(password, secretKey);
|
||||
|
||||
const backend = fromVault(vault);
|
||||
await backend.set("STRIPE", "sk_live_123");
|
||||
|
||||
const adapter = createVaultSecretsAdapter({
|
||||
vault: backend,
|
||||
allowlist: ["STRIPE"],
|
||||
});
|
||||
const stripeKey = await adapter.require("STRIPE");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire the bridge alongside the watcher so rotations propagate to
|
||||
plugin caches:
|
||||
|
||||
```ts
|
||||
import {
|
||||
createSecretsVaultWatcher,
|
||||
createVaultSecretsAdapter,
|
||||
fromVault,
|
||||
} from "@suaveplan/api-secrets-vault";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
|
||||
const backend = fromVault(vault);
|
||||
const adapter = createVaultSecretsAdapter({
|
||||
vault: backend,
|
||||
allowlist: ["STRIPE"],
|
||||
});
|
||||
const hostBus = new EventBus<{ "secret.changed": { name: string } }>();
|
||||
const watcher = createSecretsVaultWatcher({
|
||||
vault: backend,
|
||||
allowlist: adapter.allowlist,
|
||||
bus: hostBus,
|
||||
});
|
||||
|
||||
// Operator rotates STRIPE via an admin path that goes through the
|
||||
// bridge — `secret.updated` fires inside the backend, the watcher
|
||||
// forwards as `secret.changed` on `hostBus`, the plugin refreshes
|
||||
// its cache by calling `adapter.require("STRIPE")`.
|
||||
await backend.set("STRIPE", "sk_live_rotated");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `fromVault(vault: Vault): VaultSecretsBackend`
|
||||
- Returns a bridge over the supplied `@suaveplan/vault.Vault`.
|
||||
- The vault MUST be passed in already unlocked. The bridge does
|
||||
not call `vault.unlock()` — that lifecycle is the host's.
|
||||
- Write errors raise `SecretsVaultError(SECRETS_VAULT.WRITE_FAILED)`.
|
||||
- `const VAULT_SECRETS_CATEGORY = "secret"`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bridge enumerates items with `category === "secret"` to look up
|
||||
a row by title — a linear scan over the secrets category, not the
|
||||
whole vault. The real-world cardinality of plugin secrets per
|
||||
vault is small (manifests rarely declare more than a few dozen
|
||||
names), so the scan is cheap. A future revision could maintain a
|
||||
title-indexed snapshot inside the bridge if profile data suggests
|
||||
the scan is hot.
|
||||
|
||||
The single concealed field `"value"` keeps the bridge's storage
|
||||
schema minimal — if a future requirement extends the per-secret
|
||||
metadata (e.g. expiry timestamp, last-rotation operator), it can
|
||||
land as extra `fields: {...}` entries on the same item without
|
||||
breaking the read path.
|
||||
|
||||
In-bridge `secret.updated` fan-out cannot observe external
|
||||
mutations performed directly against `vault.items.create` /
|
||||
`update` / `delete`. If a host bypasses the bridge it MUST publish
|
||||
its own update notification through a sibling channel; the
|
||||
watcher's contract is "everything that goes through this bridge is
|
||||
broadcast", not "every change anywhere in the vault is broadcast".
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Unit-level coverage for the `fromVault` bridge's error-wrapping
|
||||
* arms. The integration suite (`tests/integration.test.ts`) drives
|
||||
* the bridge against a real, unlocked `@suaveplan/vault.Vault` end-
|
||||
* to-end; this file uses a thin structural shim around the
|
||||
* `vault.items` surface to exercise the bridge's catch arms (update
|
||||
* write failure, delete write failure) which only fire on
|
||||
* race-condition / corrupt-state paths a real vault rarely
|
||||
* reproduces deterministically. The shim is NOT a behavioural mock —
|
||||
* it stands in only for the `items` repository's `list` / `update` /
|
||||
* `delete` method calls, with the real bridge in front of it.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Vault } from "@suaveplan/vault";
|
||||
import {
|
||||
SECRETS_VAULT_CODES,
|
||||
SecretsVaultError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { fromVault, VAULT_SECRETS_CATEGORY } from "./from-vault.js";
|
||||
|
||||
type ItemsShim = Vault["items"];
|
||||
|
||||
interface ShimOptions {
|
||||
readonly listResult?: ReadonlyArray<{ id: string; title: string }>;
|
||||
readonly onUpdate?: () => unknown;
|
||||
readonly onDelete?: () => unknown;
|
||||
readonly onCreate?: () => unknown;
|
||||
readonly locked?: boolean;
|
||||
}
|
||||
|
||||
function shimVault(options: ShimOptions = {}): Vault {
|
||||
const items = {
|
||||
list: (_opts?: { category?: string }): ReadonlyArray<unknown> => {
|
||||
return options.listResult ?? [];
|
||||
},
|
||||
create: (_input: unknown) => {
|
||||
if (options.onCreate) options.onCreate();
|
||||
return { id: "created", title: "X" };
|
||||
},
|
||||
update: (_id: string, _patch: unknown) => {
|
||||
if (options.onUpdate) return options.onUpdate();
|
||||
return { id: "u", title: "X" };
|
||||
},
|
||||
delete: (_id: string): void => {
|
||||
if (options.onDelete) {
|
||||
options.onDelete();
|
||||
return;
|
||||
}
|
||||
},
|
||||
} as unknown as ItemsShim;
|
||||
return {
|
||||
isLocked: options.locked ?? false,
|
||||
items,
|
||||
} as unknown as Vault;
|
||||
}
|
||||
|
||||
describe("fromVault — error wrapping", () => {
|
||||
it("set update path wraps thrown errors as WRITE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const vault = shimVault({
|
||||
listResult: [{ id: "existing", title: "ROT" }],
|
||||
onUpdate: () => {
|
||||
throw new Error("forced update failure");
|
||||
},
|
||||
});
|
||||
const backend = fromVault(vault);
|
||||
let caught: unknown;
|
||||
try {
|
||||
await backend.set("ROT", "newvalue");
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SecretsVaultError);
|
||||
expect((caught as SecretsVaultError).code).toBe(
|
||||
SECRETS_VAULT_CODES.WRITE_FAILED
|
||||
);
|
||||
expect((caught as SecretsVaultError).context).toMatchObject({
|
||||
name: "ROT",
|
||||
op: "set",
|
||||
});
|
||||
});
|
||||
|
||||
it("set create path wraps thrown errors as WRITE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const vault = shimVault({
|
||||
listResult: [],
|
||||
onCreate: () => {
|
||||
throw new Error("forced create failure");
|
||||
},
|
||||
});
|
||||
const backend = fromVault(vault);
|
||||
await expect(backend.set("NEW", "v")).rejects.toMatchObject({
|
||||
code: SECRETS_VAULT_CODES.WRITE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("delete write path wraps thrown errors as WRITE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const vault = shimVault({
|
||||
listResult: [{ id: "existing", title: "KEEP" }],
|
||||
onDelete: () => {
|
||||
throw new Error("forced delete failure");
|
||||
},
|
||||
});
|
||||
const backend = fromVault(vault);
|
||||
let caught: unknown;
|
||||
try {
|
||||
await backend.delete("KEEP");
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SecretsVaultError);
|
||||
expect((caught as SecretsVaultError).code).toBe(
|
||||
SECRETS_VAULT_CODES.WRITE_FAILED
|
||||
);
|
||||
expect((caught as SecretsVaultError).context).toMatchObject({
|
||||
name: "KEEP",
|
||||
op: "delete",
|
||||
});
|
||||
});
|
||||
|
||||
it("VAULT_SECRETS_CATEGORY constant equals 'secret'", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(VAULT_SECRETS_CATEGORY).toBe("secret");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { Vault, VaultItem } from "@suaveplan/vault";
|
||||
import {
|
||||
SECRETS_VAULT_CODES,
|
||||
SecretsVaultError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
VaultSecretListener,
|
||||
VaultSecretsBackend,
|
||||
VaultUnsubscribe,
|
||||
} from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Storage convention used by {@link fromVault}: secrets live as items
|
||||
* with `category === "secret"`, `title === <name>`, and a single
|
||||
* concealed field `value === <secret-string>`. The category constant
|
||||
* is exported so adminitrative tools (vault inspectors, key-rotation
|
||||
* scripts) can filter by the same label.
|
||||
*/
|
||||
export const VAULT_SECRETS_CATEGORY = "secret" as const;
|
||||
const SECRET_FIELD = "value" as const;
|
||||
|
||||
/**
|
||||
* Bridge a live `@suaveplan/vault.Vault` instance to the structural
|
||||
* {@link VaultSecretsBackend} the adapter consumes. The function:
|
||||
*
|
||||
* 1. delegates `get` / `set` / `delete` / `listNames` onto
|
||||
* `vault.items` using the `category === "secret"` convention;
|
||||
* 2. encodes the secret value in the item's single concealed field
|
||||
* `"value"` so the underlying field-encryption layer covers it
|
||||
* at rest;
|
||||
* 3. implements an in-bridge fan-out for `secret.updated`. Since
|
||||
* `@suaveplan/vault.Vault` does not broadcast item-level events,
|
||||
* the bridge emits whenever a mutation goes through the same
|
||||
* bridge instance. Operators running multiple bridges over the
|
||||
* same vault would observe per-bridge fan-out only.
|
||||
* 4. surfaces `vault.isLocked` directly so adapter reads honour the
|
||||
* lifecycle FSM without an extra layer of caching.
|
||||
*
|
||||
* Per the package contract, the vault MUST be passed in unlocked. The
|
||||
* bridge does NOT call `vault.unlock()` — that lifecycle belongs to
|
||||
* the host.
|
||||
*/
|
||||
export function fromVault(vault: Vault): VaultSecretsBackend {
|
||||
type Listener = VaultSecretListener;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
const findRow = (name: string): VaultItem | null => {
|
||||
if (vault.isLocked) return null;
|
||||
const rows = vault.items.list({
|
||||
category: VAULT_SECRETS_CATEGORY,
|
||||
});
|
||||
for (const row of rows) {
|
||||
if (row.title === name) return row;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const emit = (name: string): void => {
|
||||
for (const listener of [...listeners]) {
|
||||
try {
|
||||
const r = listener({ name });
|
||||
// Fire-and-forget — we tolerate promise returns so
|
||||
// the listener signature can be async.
|
||||
if (r && typeof (r as Promise<void>).then === "function") {
|
||||
(r as Promise<void>).catch(() => {
|
||||
/* listener errors do not propagate to producers */
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* listener errors do not propagate to producers */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
get isLocked(): boolean {
|
||||
return vault.isLocked;
|
||||
},
|
||||
async get(name: string): Promise<string | undefined> {
|
||||
const row = findRow(name);
|
||||
if (row === null) return undefined;
|
||||
return row.concealedFields[SECRET_FIELD];
|
||||
},
|
||||
async set(name: string, value: string): Promise<void> {
|
||||
try {
|
||||
const existing = findRow(name);
|
||||
if (existing === null) {
|
||||
vault.items.create({
|
||||
title: name,
|
||||
category: VAULT_SECRETS_CATEGORY,
|
||||
fields: {},
|
||||
concealedFields: { [SECRET_FIELD]: value },
|
||||
tags: [],
|
||||
favorite: false,
|
||||
});
|
||||
} else {
|
||||
vault.items.update(existing.id, {
|
||||
concealedFields: { [SECRET_FIELD]: value },
|
||||
});
|
||||
}
|
||||
emit(name);
|
||||
} catch (cause) {
|
||||
throw new SecretsVaultError(
|
||||
SECRETS_VAULT_CODES.WRITE_FAILED,
|
||||
"error.secrets_vault.write_failed",
|
||||
{ cause, context: { name, op: "set" } }
|
||||
);
|
||||
}
|
||||
},
|
||||
async delete(name: string): Promise<void> {
|
||||
try {
|
||||
const existing = findRow(name);
|
||||
if (existing === null) return;
|
||||
vault.items.delete(existing.id);
|
||||
emit(name);
|
||||
} catch (cause) {
|
||||
throw new SecretsVaultError(
|
||||
SECRETS_VAULT_CODES.WRITE_FAILED,
|
||||
"error.secrets_vault.write_failed",
|
||||
{ cause, context: { name, op: "delete" } }
|
||||
);
|
||||
}
|
||||
},
|
||||
async listNames(): Promise<readonly string[]> {
|
||||
if (vault.isLocked) return [];
|
||||
const rows = vault.items.list({
|
||||
category: VAULT_SECRETS_CATEGORY,
|
||||
});
|
||||
return rows.map((r) => r.title);
|
||||
},
|
||||
onSecretUpdated(listener: Listener): VaultUnsubscribe {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { fromVault, VAULT_SECRETS_CATEGORY } from "./from-vault.js";
|
||||
48
packages/api-stack/api/api-secrets-vault/src/index.ts
Normal file
48
packages/api-stack/api/api-secrets-vault/src/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* `@suaveplan/api-secrets-vault` — root barrel.
|
||||
*
|
||||
* Stage-8 `SecretsLike` adapter backed by `@suaveplan/vault`. The
|
||||
* vault is passed in already unlocked; the adapter never sees the
|
||||
* master key. Per-plugin allow-list enforcement, vault
|
||||
* `secret.updated` → host bus `secret.changed` forwarding, OTel
|
||||
* `api.secrets.reads_total{backend="vault"}` counter, idempotent
|
||||
* dispose.
|
||||
*/
|
||||
|
||||
import "./error-codes/error-codes.js";
|
||||
|
||||
export {
|
||||
type CreateVaultSecretsAdapterOptions,
|
||||
createVaultSecretsAdapter,
|
||||
type VaultSecretsAdapter,
|
||||
} from "./adapter/index.js";
|
||||
export {
|
||||
type Allowlist,
|
||||
type CreateAllowlistOptions,
|
||||
createAllowlist,
|
||||
} from "./allowlist/index.js";
|
||||
export {
|
||||
SECRETS_VAULT_CODES,
|
||||
SECRETS_VAULT_ERROR_NAME,
|
||||
type SecretsVaultCode,
|
||||
SecretsVaultError,
|
||||
} from "./error-codes/index.js";
|
||||
export { fromVault, VAULT_SECRETS_CATEGORY } from "./from-vault/index.js";
|
||||
export {
|
||||
getSecretsVaultInstruments,
|
||||
resetSecretsVaultInstruments,
|
||||
type SecretsVaultInstruments,
|
||||
} from "./otel/index.js";
|
||||
export type {
|
||||
SecretChangedPayload,
|
||||
VaultSecretListener,
|
||||
VaultSecretsBackend,
|
||||
VaultSecretsBackendEventMap,
|
||||
VaultUnsubscribe,
|
||||
} from "./types/index.js";
|
||||
export {
|
||||
type CreateSecretsVaultWatcherOptions,
|
||||
createSecretsVaultWatcher,
|
||||
type HostEventBusEmitter,
|
||||
type SecretsVaultWatcher,
|
||||
} from "./watcher/index.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
getSecretsVaultInstruments,
|
||||
resetSecretsVaultInstruments,
|
||||
type SecretsVaultInstruments,
|
||||
} from "./instruments.js";
|
||||
@@ -0,0 +1,88 @@
|
||||
# `otel/instruments`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
OpenTelemetry instrument bundle for the vault-backed secrets
|
||||
adapter. Owns the three counters the package emits and a lazily-
|
||||
bound cache keyed on the global `MeterProvider`. The canonical
|
||||
`api.secrets.reads_total{backend="vault"}` counter is the metric
|
||||
operators correlate across every secrets adapter in the host scope —
|
||||
its label namespace lets a single dashboard ingest reads from env,
|
||||
Bun-native, vault, or future adapters without per-backend counter
|
||||
names.
|
||||
|
||||
## Features
|
||||
|
||||
- `getSecretsVaultInstruments()` — returns the cached bundle,
|
||||
binding to the global `MeterProvider` on first call.
|
||||
- `resetSecretsVaultInstruments()` — drops the cache so the next
|
||||
`get` rebinds against a freshly-installed `MeterProvider`. Used
|
||||
in test setup so each spec starts with a clean read.
|
||||
- `readsTotal` — counter for successful resolutions; the adapter
|
||||
always tags `backend="vault"`.
|
||||
- `writesTotal` — counter for `set` / `delete` mutations on the
|
||||
`fromVault` bridge, partitioned by `op ∈ "set" | "delete"`. (The
|
||||
bridge is the only place writes flow; the adapter is read-only.)
|
||||
- `watcherEmitsTotal` — counter for `secret.changed` events
|
||||
forwarded by the watcher.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getSecretsVaultInstruments }
|
||||
from "@suaveplan/api-secrets-vault/otel";
|
||||
|
||||
const ins = getSecretsVaultInstruments();
|
||||
ins.readsTotal.add(1, { backend: "vault" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Reset + rebind in a test that installs its own `MeterProvider`:
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
getSecretsVaultInstruments,
|
||||
resetSecretsVaultInstruments,
|
||||
} from "@suaveplan/api-secrets-vault/otel";
|
||||
|
||||
const provider = new MeterProvider({ readers: [/* exporter */] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetSecretsVaultInstruments();
|
||||
|
||||
const ins = getSecretsVaultInstruments();
|
||||
ins.readsTotal.add(1, { backend: "vault" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface SecretsVaultInstruments`
|
||||
- `readonly readsTotal: Counter`
|
||||
- `readonly writesTotal: Counter`
|
||||
- `readonly watcherEmitsTotal: Counter`
|
||||
- `getSecretsVaultInstruments(): SecretsVaultInstruments`
|
||||
- `resetSecretsVaultInstruments(): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The cache holds the bundle across calls so production paths pay the
|
||||
`metrics.getMeter` cost once per process. Each counter description
|
||||
is human-readable; `api.secrets.reads_total` is intentionally a
|
||||
generic name with a `backend` attribute rather than
|
||||
`api.secrets.vault.reads_total` so cross-adapter aggregations are
|
||||
single-counter operations on the metrics backend.
|
||||
|
||||
Calling `resetSecretsVaultInstruments` between tests is mandatory
|
||||
when a new `MeterProvider` is installed — otherwise the cached
|
||||
counters point at a stale provider and the new exporter sees zero
|
||||
emissions even though `add(1)` was called. The vitest setup at
|
||||
`tests/setup/polyfills.ts` installs its provider before any source
|
||||
import, so the first `get` already binds against it; tests then
|
||||
call `reset` only when they need to verify counter emission against
|
||||
a fresh exporter state.
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
|
||||
import {
|
||||
getSecretsVaultInstruments,
|
||||
resetSecretsVaultInstruments,
|
||||
} from "./instruments.js";
|
||||
|
||||
describe("secrets-vault OTel instruments", () => {
|
||||
it("reads_total emits with backend label", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
resetSecretsVaultInstruments();
|
||||
const ins = getSecretsVaultInstruments();
|
||||
ins.readsTotal.add(1, { backend: "vault" });
|
||||
const snapshots = await flushTestMetrics();
|
||||
const all = snapshots.flatMap((s) =>
|
||||
s.scopeMetrics.flatMap((sm) => sm.metrics)
|
||||
);
|
||||
const hit = all.find(
|
||||
(m) => m.descriptor.name === "api.secrets.reads_total"
|
||||
);
|
||||
expect(hit).toBeDefined();
|
||||
});
|
||||
|
||||
it("writes_total emits partitioned by op", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
resetSecretsVaultInstruments();
|
||||
const ins = getSecretsVaultInstruments();
|
||||
ins.writesTotal.add(1, { op: "set" });
|
||||
ins.writesTotal.add(1, { op: "delete" });
|
||||
const snapshots = await flushTestMetrics();
|
||||
const all = snapshots.flatMap((s) =>
|
||||
s.scopeMetrics.flatMap((sm) => sm.metrics)
|
||||
);
|
||||
const hit = all.find(
|
||||
(m) => m.descriptor.name === "api.secrets.writes_total"
|
||||
);
|
||||
expect(hit).toBeDefined();
|
||||
});
|
||||
|
||||
it("watcher_emits_total emits", { timeout: 30_000 }, async () => {
|
||||
resetSecretsVaultInstruments();
|
||||
const ins = getSecretsVaultInstruments();
|
||||
ins.watcherEmitsTotal.add(1);
|
||||
const snapshots = await flushTestMetrics();
|
||||
const all = snapshots.flatMap((s) =>
|
||||
s.scopeMetrics.flatMap((sm) => sm.metrics)
|
||||
);
|
||||
const hit = all.find(
|
||||
(m) => m.descriptor.name === "api.secrets.watcher_emits_total"
|
||||
);
|
||||
expect(hit).toBeDefined();
|
||||
});
|
||||
|
||||
it("caches the bundle across calls", { timeout: 5000 }, () => {
|
||||
resetSecretsVaultInstruments();
|
||||
const a = getSecretsVaultInstruments();
|
||||
const b = getSecretsVaultInstruments();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { type Counter, metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Bundle of OpenTelemetry instruments emitted by the vault-backed
|
||||
* secrets adapter.
|
||||
*
|
||||
* - `readsTotal` — incremented on every successful `get()` /
|
||||
* `require()` resolution against the backend. Carries the
|
||||
* `backend="vault"` attribute so operators can group cross-adapter
|
||||
* read traffic on a single counter at the host scope.
|
||||
* - `writesTotal` — incremented on every successful `set()` /
|
||||
* `delete()` mutation, partitioned by `op` ∈ "set" | "delete".
|
||||
* - `watcherEmitsTotal` — incremented every time the watcher
|
||||
* forwards a vault `secret.updated` event onto the host bus as
|
||||
* `secret.changed`. Operators use this to confirm cache-refresh
|
||||
* propagation in production.
|
||||
*/
|
||||
export interface SecretsVaultInstruments {
|
||||
readonly readsTotal: Counter;
|
||||
readonly writesTotal: Counter;
|
||||
readonly watcherEmitsTotal: Counter;
|
||||
}
|
||||
|
||||
let cached: SecretsVaultInstruments | undefined;
|
||||
|
||||
/**
|
||||
* Lazily build (and cache) the secrets-vault instruments bundle
|
||||
* against the current global `MeterProvider`. Call
|
||||
* {@link resetSecretsVaultInstruments} in test setup to force a
|
||||
* re-bind against a freshly-installed provider.
|
||||
*/
|
||||
export function getSecretsVaultInstruments(): SecretsVaultInstruments {
|
||||
if (cached !== undefined) return cached;
|
||||
const meter = metrics.getMeter("@suaveplan/api-secrets-vault");
|
||||
cached = {
|
||||
readsTotal: meter.createCounter("api.secrets.reads_total", {
|
||||
description:
|
||||
'Total successful secrets reads, partitioned by `backend` label (vault adapter emits backend="vault")',
|
||||
}),
|
||||
writesTotal: meter.createCounter("api.secrets.writes_total", {
|
||||
description:
|
||||
'Total successful secrets mutations from the vault adapter, partitioned by `op` ∈ "set" | "delete"',
|
||||
}),
|
||||
watcherEmitsTotal: meter.createCounter(
|
||||
"api.secrets.watcher_emits_total",
|
||||
{
|
||||
description:
|
||||
"Total `secret.changed` events forwarded by the vault watcher onto the host bus",
|
||||
}
|
||||
),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached instrument bundle so the next
|
||||
* {@link getSecretsVaultInstruments} call rebinds against the global
|
||||
* `MeterProvider` in effect at that point. Tests call this after
|
||||
* installing a fresh `MeterProvider`.
|
||||
*/
|
||||
export function resetSecretsVaultInstruments(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type {
|
||||
SecretChangedPayload,
|
||||
VaultSecretListener,
|
||||
VaultSecretsBackend,
|
||||
VaultSecretsBackendEventMap,
|
||||
VaultUnsubscribe,
|
||||
} from "./types.js";
|
||||
106
packages/api-stack/api/api-secrets-vault/src/types/types.md
Normal file
106
packages/api-stack/api/api-secrets-vault/src/types/types.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# `types`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Structural type contracts the vault-backed secrets adapter consumes.
|
||||
Decoupling the adapter from the concrete `@suaveplan/vault.Vault`
|
||||
class via these interfaces lets tests wire a deterministic in-memory
|
||||
backend without going through the 30s-per-unlock Argon2id 2SKD
|
||||
lifecycle, while a real `@suaveplan/vault.Vault` integration test
|
||||
still drives the full lifecycle end-to-end via the `fromVault`
|
||||
bridge. The same surface lets future deployments (HSM-backed,
|
||||
remote-vault-service) adapt to the adapter without touching it.
|
||||
|
||||
## Features
|
||||
|
||||
- `VaultSecretsBackend` — minimum surface the adapter consumes:
|
||||
`isLocked`, `get`, `set`, `delete`, `listNames`, `onSecretUpdated`.
|
||||
- `VaultSecretsBackendEventMap` — typed channel descriptors
|
||||
published by the backend (`secret.updated`).
|
||||
- `VaultSecretListener` / `VaultUnsubscribe` — listener signature
|
||||
and subscription handle returned by `onSecretUpdated`. Returning a
|
||||
promise is permitted but fan-out is fire-and-forget.
|
||||
- `SecretChangedPayload` — host-bus payload the watcher emits
|
||||
(`{ name }` only — value is NEVER broadcast).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { VaultSecretsBackend } from "@suaveplan/api-secrets-vault/types";
|
||||
|
||||
declare const backend: VaultSecretsBackend;
|
||||
const value = await backend.get("STRIPE");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Custom backend (e.g. an HSM-fronted secret store) that fulfils the
|
||||
same contract the in-process vault does:
|
||||
|
||||
```ts
|
||||
import type {
|
||||
VaultSecretListener,
|
||||
VaultSecretsBackend,
|
||||
VaultUnsubscribe,
|
||||
} from "@suaveplan/api-secrets-vault/types";
|
||||
|
||||
function makeHsmBackend(): VaultSecretsBackend {
|
||||
const listeners = new Set<VaultSecretListener>();
|
||||
return {
|
||||
get isLocked() {
|
||||
return false;
|
||||
},
|
||||
async get(name) {
|
||||
return await hsm.fetch(name);
|
||||
},
|
||||
async set(name, value) {
|
||||
await hsm.put(name, value);
|
||||
for (const l of listeners) void l({ name });
|
||||
},
|
||||
async delete(name) {
|
||||
await hsm.remove(name);
|
||||
for (const l of listeners) void l({ name });
|
||||
},
|
||||
async listNames() {
|
||||
return await hsm.enumerate();
|
||||
},
|
||||
onSecretUpdated(listener): VaultUnsubscribe {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface VaultSecretsBackend`
|
||||
- `readonly isLocked: boolean`
|
||||
- `get(name: string): Promise<string | undefined>`
|
||||
- `set(name: string, value: string): Promise<void>`
|
||||
- `delete(name: string): Promise<void>`
|
||||
- `listNames(): Promise<readonly string[]>`
|
||||
- `onSecretUpdated(listener): VaultUnsubscribe`
|
||||
- `interface VaultSecretsBackendEventMap`
|
||||
- `"secret.updated": { readonly name: string }`
|
||||
- `type VaultSecretListener = (p: { name: string }) => void | Promise<void>`
|
||||
- `type VaultUnsubscribe = () => void`
|
||||
- `interface SecretChangedPayload { readonly name: string }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The structural view stops short of mandating a value-broadcast
|
||||
channel. The watcher carries only `{ name }` onto the host bus —
|
||||
listeners refresh by calling the adapter (which re-enters the
|
||||
allow-list gate). This shape resists two failure modes: a worker
|
||||
sniffing the bus to enumerate secret values it shouldn't be allowed
|
||||
to read, and a stale-value race where a listener acts on the
|
||||
broadcast value but a rotation lands before the listener finishes.
|
||||
|
||||
Backends are responsible for honouring locked-state semantics —
|
||||
returning `false` for `isLocked` while the underlying store is
|
||||
locked would break the strict-mode contract the adapter relies on.
|
||||
75
packages/api-stack/api/api-secrets-vault/src/types/types.ts
Normal file
75
packages/api-stack/api/api-secrets-vault/src/types/types.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Structural contracts the vault-backed secrets adapter consumes.
|
||||
*
|
||||
* Decoupling the adapter from the concrete `@suaveplan/vault.Vault`
|
||||
* class via these interfaces achieves two things:
|
||||
*
|
||||
* 1. Tests can wire a deterministic in-memory backend without going
|
||||
* through the 30s-per-unlock Argon2id 2SKD lifecycle. The
|
||||
* `tests/integration.test.ts` suite still drives a real
|
||||
* `@suaveplan/vault.Vault` end-to-end so the wiring is exercised.
|
||||
* 2. Future vault deployments (HSM-backed, remote-vault-service) can
|
||||
* adapt to the same structural surface without touching the
|
||||
* adapter or the watcher.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Watcher events the backend publishes. The adapter subscribes to
|
||||
* `secret.updated` and forwards an allow-list-filtered `secret.changed`
|
||||
* event onto the host event bus.
|
||||
*/
|
||||
export interface VaultSecretsBackendEventMap {
|
||||
/** Emitted when a secret's value mutates. Payload carries only the
|
||||
* name; the new value is never broadcast — listeners refresh by
|
||||
* calling `get(name)` on the adapter (which re-enters the allow-list
|
||||
* check). */
|
||||
readonly "secret.updated": { readonly name: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription handle returned by `onSecretUpdated`. Calling it
|
||||
* unsubscribes the listener; idempotent.
|
||||
*/
|
||||
export type VaultUnsubscribe = () => void;
|
||||
|
||||
/**
|
||||
* Listener signature for `secret.updated`. Returning a promise is
|
||||
* permitted but the backend MUST NOT await the result — fan-out is
|
||||
* fire-and-forget.
|
||||
*/
|
||||
export type VaultSecretListener = (payload: {
|
||||
readonly name: string;
|
||||
}) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Minimum surface the adapter needs from a vault. Implemented either
|
||||
* by `fromVault(vault)` over a real `@suaveplan/vault.Vault` or by a
|
||||
* direct in-memory backend.
|
||||
*/
|
||||
export interface VaultSecretsBackend {
|
||||
/** Whether the vault is currently locked. Reads on a locked vault
|
||||
* raise `SECRETS_VAULT.VAULT_LOCKED`. */
|
||||
readonly isLocked: boolean;
|
||||
/** Resolve a secret value or `undefined` when no entry exists. */
|
||||
get(name: string): Promise<string | undefined>;
|
||||
/** Set / overwrite a secret value. Emits `secret.updated`. */
|
||||
set(name: string, value: string): Promise<void>;
|
||||
/** Remove a secret. No-op when the name is absent. Emits
|
||||
* `secret.updated` when an entry was removed. */
|
||||
delete(name: string): Promise<void>;
|
||||
/** Enumerate every stored secret name. The adapter intersects this
|
||||
* with the per-plugin allow-list before exposing `list()`. */
|
||||
listNames(): Promise<readonly string[]>;
|
||||
/** Subscribe to vault events. Implementations return an
|
||||
* unsubscribe handle. */
|
||||
onSecretUpdated(listener: VaultSecretListener): VaultUnsubscribe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host event-bus payload the adapter emits on its `secret.changed`
|
||||
* channel. Value is intentionally omitted — listeners refresh by
|
||||
* re-reading through the adapter.
|
||||
*/
|
||||
export interface SecretChangedPayload {
|
||||
readonly name: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type CreateSecretsVaultWatcherOptions,
|
||||
createSecretsVaultWatcher,
|
||||
type HostEventBusEmitter,
|
||||
type SecretsVaultWatcher,
|
||||
} from "./watcher.js";
|
||||
113
packages/api-stack/api/api-secrets-vault/src/watcher/watcher.md
Normal file
113
packages/api-stack/api/api-secrets-vault/src/watcher/watcher.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# `watcher`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Live subscription that bridges the vault's `secret.updated` events
|
||||
onto the host event bus as `secret.changed`. Plugins that cache
|
||||
secret values register against the host bus and refresh on
|
||||
broadcast; the watcher intersects the vault stream with the per-
|
||||
plugin allow-list so a plugin never even learns that a non-allow-
|
||||
listed secret rotated. Designed as a host-owned lifecycle: one
|
||||
watcher per `(vault, plugin)` pair, disposed during plugin teardown.
|
||||
|
||||
## Features
|
||||
|
||||
- `createSecretsVaultWatcher({ vault, allowlist, bus })` —
|
||||
Zod-validated factory subscribing once to
|
||||
`vault.onSecretUpdated` and returning a `SecretsVaultWatcher`
|
||||
handle.
|
||||
- Per-plugin allow-list filter — `vault.secret.updated` payloads
|
||||
whose `name` is not in the allow-list are silently dropped.
|
||||
- Allow-listed updates forward as `bus.emit("secret.changed",
|
||||
{ name })` carrying ONLY the name; the value is never broadcast.
|
||||
Listeners refresh by calling `adapter.get(name)` which re-enters
|
||||
the allow-list gate.
|
||||
- OTel `api.secrets.watcher_emits_total` counter increments on
|
||||
every forwarded event.
|
||||
- Idempotent `dispose()` — releases the underlying subscription.
|
||||
- Single subscription per watcher: the listener guards against
|
||||
post-dispose deliveries even if the backend's `unsubscribe` lags.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createAllowlist,
|
||||
createSecretsVaultWatcher,
|
||||
} from "@suaveplan/api-secrets-vault";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
|
||||
const bus = new EventBus<{ "secret.changed": { name: string } }>();
|
||||
const allow = createAllowlist({ names: ["STRIPE"] });
|
||||
const watcher = createSecretsVaultWatcher({ vault: backend, allowlist: allow, bus });
|
||||
|
||||
bus.on("secret.changed", (p) => {
|
||||
console.log(`Refreshing cache for ${p.name}`);
|
||||
});
|
||||
|
||||
// ...later, during plugin teardown:
|
||||
watcher.dispose();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Combine with the adapter so plugins see a uniform "read + refresh"
|
||||
loop:
|
||||
|
||||
```ts
|
||||
import {
|
||||
createSecretsVaultWatcher,
|
||||
createVaultSecretsAdapter,
|
||||
} from "@suaveplan/api-secrets-vault";
|
||||
|
||||
const adapter = createVaultSecretsAdapter({
|
||||
vault: backend,
|
||||
allowlist: ["STRIPE"],
|
||||
});
|
||||
const watcher = createSecretsVaultWatcher({
|
||||
vault: backend,
|
||||
allowlist: adapter.allowlist,
|
||||
bus: hostBus,
|
||||
});
|
||||
|
||||
let cached = await adapter.require("STRIPE");
|
||||
hostBus.on("secret.changed", async (p) => {
|
||||
if (p.name === "STRIPE") {
|
||||
cached = await adapter.require("STRIPE");
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface HostEventBusEmitter`
|
||||
- `emit(type: "secret.changed", payload: { name: string }): unknown`
|
||||
- `interface CreateSecretsVaultWatcherOptions`
|
||||
- `vault: VaultSecretsBackend`
|
||||
- `allowlist: Allowlist`
|
||||
- `bus: HostEventBusEmitter`
|
||||
- `createSecretsVaultWatcher(options): SecretsVaultWatcher`
|
||||
- Throws `SECRETS_VAULT.INVALID_OPTIONS` on bad options.
|
||||
- `interface SecretsVaultWatcher`
|
||||
- `dispose(): void` — idempotent
|
||||
- `readonly isDisposed: boolean`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The watcher checks its own `disposed` flag inside the listener AND
|
||||
calls the backend's `unsubscribe` handle on dispose. The double-
|
||||
guard handles backends whose `unsubscribe` is implemented lazily —
|
||||
if a `secret.updated` is in flight when we dispose, the disposed-
|
||||
guard catches the stale delivery so the host bus never receives a
|
||||
post-dispose `secret.changed`.
|
||||
|
||||
Listener errors swallowed at the host bus boundary cannot crash the
|
||||
producer — that's `@suaveplan/events.EventBus` policy. The watcher
|
||||
itself does not `try/catch` around `bus.emit` because the host bus
|
||||
is the canonical place for emit-time error handling, and re-
|
||||
throwing here would block the rest of the listener fan-out at the
|
||||
vault backend.
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
|
||||
import { createAllowlist } from "../allowlist/allowlist.js";
|
||||
import {
|
||||
SECRETS_VAULT_CODES,
|
||||
SecretsVaultError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { resetSecretsVaultInstruments } from "../otel/instruments.js";
|
||||
import type {
|
||||
VaultSecretListener,
|
||||
VaultSecretsBackend,
|
||||
VaultUnsubscribe,
|
||||
} from "../types/types.js";
|
||||
import {
|
||||
createSecretsVaultWatcher,
|
||||
type HostEventBusEmitter,
|
||||
} from "./watcher.js";
|
||||
|
||||
interface FakeBus extends HostEventBusEmitter {
|
||||
readonly events: Array<{
|
||||
readonly type: string;
|
||||
readonly payload: { readonly name: string };
|
||||
}>;
|
||||
}
|
||||
|
||||
function makeBus(): FakeBus {
|
||||
const events: Array<{
|
||||
type: string;
|
||||
payload: { readonly name: string };
|
||||
}> = [];
|
||||
return {
|
||||
events,
|
||||
emit(type, payload) {
|
||||
events.push({ type, payload });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface FixtureBackend extends VaultSecretsBackend {
|
||||
fire(name: string): void;
|
||||
readonly listenerCount: number;
|
||||
}
|
||||
|
||||
function makeFixtureBackend(): FixtureBackend {
|
||||
const listeners = new Set<VaultSecretListener>();
|
||||
return {
|
||||
get isLocked(): boolean {
|
||||
return false;
|
||||
},
|
||||
async get(): Promise<string | undefined> {
|
||||
return undefined;
|
||||
},
|
||||
async set(): Promise<void> {},
|
||||
async delete(): Promise<void> {},
|
||||
async listNames(): Promise<readonly string[]> {
|
||||
return [];
|
||||
},
|
||||
onSecretUpdated(listener): VaultUnsubscribe {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
fire(name: string): void {
|
||||
for (const l of [...listeners]) void l({ name });
|
||||
},
|
||||
get listenerCount(): number {
|
||||
return listeners.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("createSecretsVaultWatcher — option validation", () => {
|
||||
it("rejects a missing vault", { timeout: 5000 }, () => {
|
||||
const bus = makeBus();
|
||||
const allow = createAllowlist({ names: ["A"] });
|
||||
let caught: unknown;
|
||||
try {
|
||||
createSecretsVaultWatcher({
|
||||
vault: null as unknown as VaultSecretsBackend,
|
||||
allowlist: allow,
|
||||
bus,
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SecretsVaultError);
|
||||
expect((caught as SecretsVaultError).code).toBe(
|
||||
SECRETS_VAULT_CODES.INVALID_OPTIONS
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSecretsVaultWatcher — fan-out", () => {
|
||||
it("allowlisted update emits secret.changed", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
resetSecretsVaultInstruments();
|
||||
const backend = makeFixtureBackend();
|
||||
const bus = makeBus();
|
||||
const allow = createAllowlist({ names: ["STRIPE"] });
|
||||
createSecretsVaultWatcher({ vault: backend, allowlist: allow, bus });
|
||||
|
||||
backend.fire("STRIPE");
|
||||
|
||||
expect(bus.events).toEqual([
|
||||
{ type: "secret.changed", payload: { name: "STRIPE" } },
|
||||
]);
|
||||
const snapshots = await flushTestMetrics();
|
||||
const all = snapshots.flatMap((s) =>
|
||||
s.scopeMetrics.flatMap((sm) => sm.metrics)
|
||||
);
|
||||
const hit = all.find(
|
||||
(m) => m.descriptor.name === "api.secrets.watcher_emits_total"
|
||||
);
|
||||
expect(hit).toBeDefined();
|
||||
});
|
||||
|
||||
it("non-allowlisted update does not emit", { timeout: 5000 }, () => {
|
||||
const backend = makeFixtureBackend();
|
||||
const bus = makeBus();
|
||||
const allow = createAllowlist({ names: ["STRIPE"] });
|
||||
createSecretsVaultWatcher({ vault: backend, allowlist: allow, bus });
|
||||
|
||||
backend.fire("AWS");
|
||||
|
||||
expect(bus.events).toEqual([]);
|
||||
});
|
||||
|
||||
it("payload contains only { name } — value is never broadcast", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const backend = makeFixtureBackend();
|
||||
const bus = makeBus();
|
||||
const allow = createAllowlist({ names: ["STRIPE"] });
|
||||
createSecretsVaultWatcher({ vault: backend, allowlist: allow, bus });
|
||||
|
||||
backend.fire("STRIPE");
|
||||
|
||||
expect(bus.events).toHaveLength(1);
|
||||
expect(Object.keys(bus.events[0]?.payload ?? {})).toEqual(["name"]);
|
||||
});
|
||||
|
||||
it("subscription is single — one listener per watcher", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const backend = makeFixtureBackend();
|
||||
const bus = makeBus();
|
||||
const allow = createAllowlist({ names: ["STRIPE"] });
|
||||
createSecretsVaultWatcher({ vault: backend, allowlist: allow, bus });
|
||||
expect(backend.listenerCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSecretsVaultWatcher — dispose", () => {
|
||||
it("dispose stops further fan-out", { timeout: 5000 }, () => {
|
||||
const backend = makeFixtureBackend();
|
||||
const bus = makeBus();
|
||||
const allow = createAllowlist({ names: ["STRIPE"] });
|
||||
const watcher = createSecretsVaultWatcher({
|
||||
vault: backend,
|
||||
allowlist: allow,
|
||||
bus,
|
||||
});
|
||||
|
||||
watcher.dispose();
|
||||
expect(watcher.isDisposed).toBe(true);
|
||||
expect(backend.listenerCount).toBe(0);
|
||||
|
||||
backend.fire("STRIPE");
|
||||
expect(bus.events).toEqual([]);
|
||||
});
|
||||
|
||||
it("dispose is idempotent", { timeout: 5000 }, () => {
|
||||
const backend = makeFixtureBackend();
|
||||
const bus = makeBus();
|
||||
const allow = createAllowlist({ names: ["STRIPE"] });
|
||||
const watcher = createSecretsVaultWatcher({
|
||||
vault: backend,
|
||||
allowlist: allow,
|
||||
bus,
|
||||
});
|
||||
watcher.dispose();
|
||||
watcher.dispose();
|
||||
watcher.dispose();
|
||||
expect(watcher.isDisposed).toBe(true);
|
||||
});
|
||||
|
||||
it("late delivery after dispose is dropped by the in-listener guard", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Simulate a backend whose `unsubscribe` is lazy: keep the
|
||||
// listener registered after dispose, then fire. The watcher's
|
||||
// own `disposed` flag inside the listener must suppress the
|
||||
// emit even though the listener is still wired.
|
||||
const listeners = new Set<VaultSecretListener>();
|
||||
const lazyBackend: VaultSecretsBackend = {
|
||||
get isLocked() {
|
||||
return false;
|
||||
},
|
||||
async get() {
|
||||
return undefined;
|
||||
},
|
||||
async set() {},
|
||||
async delete() {},
|
||||
async listNames() {
|
||||
return [];
|
||||
},
|
||||
onSecretUpdated(l) {
|
||||
listeners.add(l);
|
||||
// Lazy unsubscribe — returns a no-op so the listener
|
||||
// stays registered. This stress-tests the watcher's
|
||||
// disposed-flag guard inside the listener body.
|
||||
return () => {
|
||||
/* deliberately leaks */
|
||||
};
|
||||
},
|
||||
};
|
||||
const bus = makeBus();
|
||||
const allow = createAllowlist({ names: ["STRIPE"] });
|
||||
const watcher = createSecretsVaultWatcher({
|
||||
vault: lazyBackend,
|
||||
allowlist: allow,
|
||||
bus,
|
||||
});
|
||||
watcher.dispose();
|
||||
for (const l of listeners) void l({ name: "STRIPE" });
|
||||
expect(bus.events).toEqual([]);
|
||||
});
|
||||
});
|
||||
120
packages/api-stack/api/api-secrets-vault/src/watcher/watcher.ts
Normal file
120
packages/api-stack/api/api-secrets-vault/src/watcher/watcher.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import z from "zod";
|
||||
import type { Allowlist } from "../allowlist/allowlist.js";
|
||||
import {
|
||||
SECRETS_VAULT_CODES,
|
||||
SecretsVaultError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { getSecretsVaultInstruments } from "../otel/instruments.js";
|
||||
import type {
|
||||
SecretChangedPayload,
|
||||
VaultSecretsBackend,
|
||||
} from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Structural view of the host event bus the watcher emits onto. The
|
||||
* watcher only ever calls `emit("secret.changed", { name })`; this
|
||||
* narrow shape lets the package work with `@suaveplan/events.EventBus`
|
||||
* or any future host-bus substitute without a hard import-time
|
||||
* dependency on a particular bus instance.
|
||||
*/
|
||||
export interface HostEventBusEmitter {
|
||||
emit(type: "secret.changed", payload: SecretChangedPayload): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construction options for {@link createSecretsVaultWatcher}.
|
||||
*
|
||||
* - `vault` — the same backend the adapter consumes. The watcher
|
||||
* subscribes to `secret.updated` once and forwards filtered events
|
||||
* onto the host bus. Sharing the backend instance is the only way
|
||||
* to keep adapter reads and watcher events coherent.
|
||||
* - `allowlist` — per-plugin allow-list (the compiled
|
||||
* {@link Allowlist} the adapter publishes). Only updates for
|
||||
* allow-listed names are forwarded; non-allow-listed updates are
|
||||
* silently dropped so a worker cannot infer the existence of a
|
||||
* secret outside its manifest.
|
||||
* - `bus` — the host event bus the watcher emits onto. Typed against
|
||||
* the {@link HostEventBusEmitter} structural view.
|
||||
*/
|
||||
export interface CreateSecretsVaultWatcherOptions {
|
||||
readonly vault: VaultSecretsBackend;
|
||||
readonly allowlist: Allowlist;
|
||||
readonly bus: HostEventBusEmitter;
|
||||
}
|
||||
|
||||
const optionsSchema = z.object({
|
||||
vault: z.object({
|
||||
onSecretUpdated: z.function(),
|
||||
}),
|
||||
allowlist: z.object({
|
||||
isAllowed: z.function(),
|
||||
}),
|
||||
bus: z.object({
|
||||
emit: z.function(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Live watcher subscribed to the vault's `secret.updated` events. The
|
||||
* host owns the lifetime — call {@link SecretsVaultWatcher.dispose}
|
||||
* during plugin teardown to release the subscription.
|
||||
*/
|
||||
export interface SecretsVaultWatcher {
|
||||
/** Idempotent disposal. Subsequent calls are no-ops. */
|
||||
dispose(): void;
|
||||
/** Whether the watcher has been disposed. Mostly a test
|
||||
* affordance. */
|
||||
readonly isDisposed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a {@link SecretsVaultWatcher} that:
|
||||
*
|
||||
* 1. validates options with Zod (raises
|
||||
* `SECRETS_VAULT.INVALID_OPTIONS` on failure);
|
||||
* 2. subscribes exactly once to the vault's `secret.updated`
|
||||
* channel (the source-of-truth for value rotation);
|
||||
* 3. on every received update, intersects against the per-plugin
|
||||
* allow-list — non-allow-listed names are silently dropped;
|
||||
* 4. forwards allow-listed updates onto the host bus as
|
||||
* `secret.changed` carrying `{ name }` only (value is NEVER
|
||||
* broadcast — listeners refresh by calling `adapter.get(name)`
|
||||
* which re-enters the allow-list gate);
|
||||
* 5. increments `api.secrets.watcher_emits_total` on every
|
||||
* forwarded event.
|
||||
*
|
||||
* The subscription is single — calling `createSecretsVaultWatcher`
|
||||
* twice with the same options creates two subscriptions, each owning
|
||||
* its own dispose handle. This matches plugin-lifecycle semantics:
|
||||
* the host runs one watcher per `(vault, plugin)` pair.
|
||||
*/
|
||||
export function createSecretsVaultWatcher(
|
||||
options: CreateSecretsVaultWatcherOptions
|
||||
): SecretsVaultWatcher {
|
||||
const parsed = optionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new SecretsVaultError(
|
||||
SECRETS_VAULT_CODES.INVALID_OPTIONS,
|
||||
"error.secrets_vault.invalid_options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const { vault, allowlist, bus } = options;
|
||||
let disposed = false;
|
||||
const unsubscribe = vault.onSecretUpdated((payload) => {
|
||||
if (disposed) return;
|
||||
if (!allowlist.isAllowed(payload.name)) return;
|
||||
bus.emit("secret.changed", { name: payload.name });
|
||||
getSecretsVaultInstruments().watcherEmitsTotal.add(1);
|
||||
});
|
||||
return {
|
||||
get isDisposed(): boolean {
|
||||
return disposed;
|
||||
},
|
||||
dispose(): void {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
unsubscribe();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user