Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
# @suaveplan/api-secrets-vault — SecretsLike adapter backed by @suaveplan/vault. The vault is passed in 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"}`, idempotent dispose. Real vault instance, no mocks.
Stage-8 `SecretsLike` adapter backed by `@suaveplan/vault`. The vault is
passed in already unlocked by the host's startup sequence; the adapter
never sees the master key. Per-plugin allow-list enforcement, vault
`secret.updated` → host event-bus `secret.changed` forwarding, OTel
`api.secrets.reads_total{backend="vault"}` counter, idempotent dispose.
Real vault instance, no mocks.
<!-- begin:auto-docs -->
## Documentation tree
- [adapter](./src/adapter/adapter.md) — Stage-8 concrete `SecretsLike` implementation backed by a `VaultSecretsBackend` (real `@suaveplan/vault.Vault` via `fro…
- [adapter](./src/adapter/adapter.md) — Stage-8 concrete `SecretsLike` implementation backed by a `VaultSecretsBackend` (real `@suaveplan/vault.Vault` via `fro…
- [allowlist](./src/allowlist/allowlist.md) — Compiled per-plugin allow-list of secret names. The vault-backed adapter calls `assertAllowed` BEFORE consulting the ba…
- [allowlist](./src/allowlist/allowlist.md) — Compiled per-plugin allow-list of secret names. The vault-backed adapter calls `assertAllowed` BEFORE consulting the ba…
- [error-codes](./src/error-codes/error-codes.md) — Frozen registry of `SECRETS_VAULT.*` error codes thrown by the vault-backed `SecretsLike` adapter and its watcher / all…
- [error-codes](./src/error-codes/error-codes.md) — Frozen registry of `SECRETS_VAULT.*` error codes thrown by the vault-backed `SecretsLike` adapter and its watcher / all…
- [from-vault](./src/from-vault/from-vault.md) — Bridge a live `@suaveplan/vault.Vault` instance to the structural `VaultSecretsBackend` the adapter consumes. The bridg…
- [from-vault](./src/from-vault/from-vault.md) — Bridge a live `@suaveplan/vault.Vault` instance to the structural `VaultSecretsBackend` the adapter consumes. The bridg…
- [otel](./src/otel/instruments.md) — OpenTelemetry instrument bundle for the vault-backed secrets adapter. Owns the three counters the package emits and a l…
- [instruments](./src/otel/instruments.md) — OpenTelemetry instrument bundle for the vault-backed secrets adapter. Owns the three counters the package emits and a l…
- [types](./src/types/types.md) — Structural type contracts the vault-backed secrets adapter consumes. Decoupling the adapter from the concrete `@suavepl…
- [types](./src/types/types.md) — Structural type contracts the vault-backed secrets adapter consumes. Decoupling the adapter from the concrete `@suavepl…
- [watcher](./src/watcher/watcher.md) — Live subscription that bridges the vault's `secret.updated` events onto the host event bus as `secret.changed`. Plugins…
- [watcher](./src/watcher/watcher.md) — Live subscription that bridges the vault's `secret.updated` events onto the host event bus as `secret.changed`. Plugins…
<!-- end:auto-docs -->
## Modules
- `./adapter` — `createVaultSecretsAdapter({ vault, allowlist })`
factory. Returns a `VaultSecretsAdapter` (extends `SecretsLike`)
enforcing the per-plugin allow-list BEFORE every read, raising
`SECRETS_VAULT.VAULT_LOCKED` on a locked vault without plaintext
fallback, wrapping unexpected backend throws as
`SECRETS_VAULT.VAULT_UNAVAILABLE`, emitting
`api.secrets.reads_total{backend="vault"}` on success.
- `./allowlist` — `createAllowlist({ names })`. Zod-validated factory
returning a frozen `Allowlist` (`isAllowed`, `assertAllowed`,
`names`). Compiled into an O(1) `Set` for the hot read path.
Shared between the adapter and the watcher.
- `./watcher` — `createSecretsVaultWatcher({ vault, allowlist, bus })`.
Subscribes once to the vault's `secret.updated` channel, forwards
allow-listed updates onto the host bus as `secret.changed` carrying
ONLY `{ name }` (value is never broadcast). Idempotent `dispose()`
with an in-listener disposed-flag guard that survives lazy backend
unsubscribes.
- `./error-codes` — `SECRETS_VAULT_CODES` registry: `NOT_FOUND`,
`NOT_ALLOWED`, `VAULT_LOCKED`, `VAULT_UNAVAILABLE`, `WRITE_FAILED`,
`INVALID_OPTIONS`. `SecretsVaultError` subclass with stable
`error.name === "SecretsVaultError"`.
- `./otel` — `getSecretsVaultInstruments()` / `resetSecretsVaultInstruments()`.
Counters: `api.secrets.reads_total{backend}`,
`api.secrets.writes_total{op}`,
`api.secrets.watcher_emits_total`.
- `./types` — `VaultSecretsBackend`, `VaultSecretListener`,
`VaultUnsubscribe`, `SecretChangedPayload`. Structural surface the
adapter consumes — decoupled from `@suaveplan/vault.Vault` so tests
can wire deterministic backends.
- `./from-vault` — `fromVault(vault)` bridges a live
`@suaveplan/vault.Vault` instance to the structural backend.
Secrets are stored as items with `category === "secret"` and a
single concealed field `"value"` so the underlying field-encryption
layer covers them at rest. In-bridge `secret.updated` fan-out fires
on every mutation that goes through the bridge.
## Quickstart
```ts
import {
createSecretsVaultWatcher,
createVaultSecretsAdapter,
fromVault,
} from "@suaveplan/api-secrets-vault";
import { EventBus } from "@suaveplan/events";
import { Vault } from "@suaveplan/vault";
const vault = Vault.open(vaultConfig);
await vault.unlock(password, secretKey);
const backend = fromVault(vault);
const adapter = createVaultSecretsAdapter({
vault: backend,
allowlist: ["STRIPE_SECRET_KEY", "JWT_SIGNING_KEY"],
});
const stripeKey = await adapter.require("STRIPE_SECRET_KEY");
const hostBus = new EventBus<{ "secret.changed": { name: string } }>();
const watcher = createSecretsVaultWatcher({
vault: backend,
allowlist: adapter.allowlist,
bus: hostBus,
});
hostBus.on("secret.changed", (p) => {
console.log(`refresh ${p.name}`);
});
```
## Conformance
Passes `runSecretsConformance` from `@suaveplan/api-secrets/testing` —
see `src/adapter/adapter.spec-conformance.test.ts`. The
`tests/integration.test.ts` suite drives a real
`@suaveplan/vault.Vault` end-to-end with Argon2id parameters set to
the minimum viable values so the lifecycle fits inside vitest's 60s
timeout. No mocks.

View File

@@ -0,0 +1,105 @@
{
"name": "@suaveplan/api-secrets-vault",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Stage-8 SecretsLike adapter backed by @suaveplan/vault. The vault is passed in 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\"}`, idempotent dispose. Real vault instance, no mocks.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./adapter": {
"import": {
"types": "./dist/adapter/adapter.d.ts",
"default": "./dist/adapter/adapter.js"
}
},
"./allowlist": {
"import": {
"types": "./dist/allowlist/allowlist.d.ts",
"default": "./dist/allowlist/allowlist.js"
}
},
"./watcher": {
"import": {
"types": "./dist/watcher/watcher.d.ts",
"default": "./dist/watcher/watcher.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./otel": {
"import": {
"types": "./dist/otel/instruments.d.ts",
"default": "./dist/otel/instruments.js"
}
},
"./types": {
"import": {
"types": "./dist/types/types.d.ts",
"default": "./dist/types/types.js"
}
},
"./from-vault": {
"import": {
"types": "./dist/from-vault/from-vault.d.ts",
"default": "./dist/from-vault/from-vault.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/api-secrets": "workspace:^",
"@suaveplan/api-types": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/logger": "workspace:^",
"@suaveplan/vault": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "catalog:observability",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View 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.

View File

@@ -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,
});

View File

@@ -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);
});
});

View 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;
},
};
}

View File

@@ -0,0 +1,5 @@
export {
type CreateVaultSecretsAdapterOptions,
createVaultSecretsAdapter,
type VaultSecretsAdapter,
} from "./adapter.js";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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 } }
);
}
},
};
}

View File

@@ -0,0 +1,5 @@
export {
type Allowlist,
type CreateAllowlistOptions,
createAllowlist,
} from "./allowlist.js";

View File

@@ -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.

View File

@@ -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" });
});
});

View File

@@ -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;
}
}

View File

@@ -0,0 +1,6 @@
export {
SECRETS_VAULT_CODES,
SECRETS_VAULT_ERROR_NAME,
type SecretsVaultCode,
SecretsVaultError,
} from "./error-codes.js";

View File

@@ -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".

View File

@@ -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");
});
});

View File

@@ -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);
};
},
};
}

View File

@@ -0,0 +1 @@
export { fromVault, VAULT_SECRETS_CATEGORY } from "./from-vault.js";

View 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";

View File

@@ -0,0 +1,5 @@
export {
getSecretsVaultInstruments,
resetSecretsVaultInstruments,
type SecretsVaultInstruments,
} from "./instruments.js";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -0,0 +1,7 @@
export type {
SecretChangedPayload,
VaultSecretListener,
VaultSecretsBackend,
VaultSecretsBackendEventMap,
VaultUnsubscribe,
} from "./types.js";

View 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.

View 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;
}

View File

@@ -0,0 +1,6 @@
export {
type CreateSecretsVaultWatcherOptions,
createSecretsVaultWatcher,
type HostEventBusEmitter,
type SecretsVaultWatcher,
} from "./watcher.js";

View 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.

View File

@@ -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([]);
});
});

View 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();
},
};
}

View File

@@ -0,0 +1,93 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const pkgRoot = resolve(__dirname, "..");
const srcRoot = join(pkgRoot, "src");
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) out.push(...walk(full));
else out.push(full);
}
return out;
}
describe("@suaveplan/api-secrets-vault dep graph + layout", () => {
it("package.json carries the required @suaveplan/* runtime deps", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(pkgRoot, "package.json"), "utf8")
) as { dependencies: Record<string, string> };
const suaveplan = Object.keys(pkg.dependencies)
.filter((k) => k.startsWith("@suaveplan/"))
.sort();
expect(suaveplan).toEqual([
"@suaveplan/api-secrets",
"@suaveplan/api-types",
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/logger",
"@suaveplan/vault",
]);
});
it("zero peerDependencies", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(pkgRoot, "package.json"), "utf8")
) as { peerDependencies?: Record<string, string> };
expect(Object.keys(pkg.peerDependencies ?? {})).toEqual([]);
});
it("every src file uses default-import z (no named { z })", {
timeout: 5000,
}, () => {
const bad: string[] = [];
for (const file of walk(srcRoot)) {
if (!file.endsWith(".ts")) continue;
const src = readFileSync(file, "utf8");
if (/import\s*\{\s*z\s*\}\s*from\s*["']zod["']/.test(src)) {
bad.push(file);
}
}
expect(bad).toEqual([]);
});
it("no static-analysis suppression comments in src/", {
timeout: 5000,
}, () => {
const bad: string[] = [];
for (const file of walk(srcRoot)) {
if (!file.endsWith(".ts")) continue;
const src = readFileSync(file, "utf8");
if (/biome-ignore|eslint-disable|@ts-(ignore|nocheck)/.test(src)) {
bad.push(file);
}
}
expect(bad).toEqual([]);
});
it("package.json exposes the documented subpath exports", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(pkgRoot, "package.json"), "utf8")
) as { exports: Record<string, unknown> };
const subpaths = Object.keys(pkg.exports)
.filter((k) => k !== "./package.json" && k !== ".")
.sort();
expect(subpaths).toEqual([
"./adapter",
"./allowlist",
"./error-codes",
"./from-vault",
"./otel",
"./types",
"./watcher",
]);
});
});

View File

@@ -0,0 +1,295 @@
/**
* Real-vault end-to-end test. Drives a live `@suaveplan/vault.Vault`
* through `fromVault` → `createVaultSecretsAdapter` → `SecretsLike`
* reads, exercising the full unlock + drive + encryption pipeline
* without a single mock.
*
* Argon2id is enforced at the OWASP minimum (memoryKB=65_536,
* iterations=3, parallelism=1) by `@suaveplan/crypto-vault`. Each
* unlock costs ~5–30s on this hardware; the tests therefore share a
* single unlocked vault across the describe block via `beforeAll`,
* resetting per-test state by clearing the bridge's items so each
* scenario starts from a known empty state.
*/
import { EventBus } from "@suaveplan/events";
import {
afterAll,
beforeAll,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import type {
DriveLike,
DriveSharingLike,
SyncDelegationResult,
SyncSnapshot,
VaultConfig,
VaultSyncLike,
} from "@suaveplan/vault";
import { Vault } from "@suaveplan/vault";
import {
createSecretsVaultWatcher,
createVaultSecretsAdapter,
fromVault,
SECRETS_VAULT_CODES,
SecretsVaultError,
type SecretsVaultError as SecretsVaultErrorType,
VAULT_SECRETS_CATEGORY,
type VaultSecretsBackend,
} from "../src/index.js";
function memoryDrive(): DriveLike & { blobs: Map<string, Uint8Array> } {
const blobs = new Map<string, Uint8Array>();
return {
blobs,
async read(path) {
const v = blobs.get(path);
if (v === undefined) throw new Error(`missing ${path}`);
return v;
},
async write(path, data) {
blobs.set(path, new Uint8Array(data));
},
async delete(path) {
return blobs.delete(path);
},
async exists(path) {
return blobs.has(path);
},
};
}
function noopSharing(): DriveSharingLike {
return {
async createLink() {
return { id: "x", url: "https://x.example/x" };
},
async revokeLink() {},
async isActive() {
return true;
},
};
}
class NoopSync implements VaultSyncLike {
public lastSnapshot: SyncSnapshot = { items: [], snapshotAt: 0 };
public hydrate(s: SyncSnapshot): void {
this.lastSnapshot = s;
}
public async pull(): Promise<SyncDelegationResult> {
return { merged: [], conflicts: [], deleted: [], durationMs: 0 };
}
public async push(): Promise<SyncDelegationResult> {
return { merged: [], conflicts: [], deleted: [], durationMs: 0 };
}
public async sync(): Promise<SyncDelegationResult> {
return { merged: [], conflicts: [], deleted: [], durationMs: 0 };
}
public currentSnapshot(): SyncSnapshot {
return this.lastSnapshot;
}
}
function makeConfig(drive = memoryDrive()): VaultConfig {
return {
vaultId: "secrets",
email: "agent@suaveplan.example",
containerPath: "/secrets.vault",
attachmentsPrefix: "/secrets/att",
// OWASP minimums enforced by @suaveplan/crypto-vault.
argon2id: {
memoryKB: 65_536,
iterations: 3,
parallelism: 1,
outputLength: 32,
},
argon2idSalt: new Uint8Array(16),
drive,
driveSharing: noopSharing(),
sync: new NoopSync(),
};
}
async function clearSecrets(vault: Vault): Promise<void> {
const rows = vault.items.list({ category: VAULT_SECRETS_CATEGORY });
for (const row of rows) {
vault.items.delete(row.id);
}
}
describe("real @suaveplan/vault — end-to-end", () => {
// Share one unlocked vault across the block — `beforeAll` runs
// the ~5–30s Argon2id pipeline once instead of per-test.
let vault: Vault;
let backend: VaultSecretsBackend;
beforeAll(async () => {
vault = Vault.open(makeConfig());
await vault.unlock("pw", "sk");
backend = fromVault(vault);
}, 120000);
afterAll(async () => {
await vault.lock();
}, 30000);
it("unlock → set via bridge → adapter.require reads back", {
timeout: 30000,
}, async () => {
await clearSecrets(vault);
await backend.set("STRIPE_SECRET_KEY", "sk_live_real");
const adapter = createVaultSecretsAdapter({
vault: backend,
allowlist: ["STRIPE_SECRET_KEY"],
});
const value = await adapter.require("STRIPE_SECRET_KEY");
expect(value).toBe("sk_live_real");
adapter.dispose();
});
it("set then delete: listNames reflects the deletion", {
timeout: 30000,
}, async () => {
await clearSecrets(vault);
await backend.set("A", "1");
await backend.set("B", "2");
expect((await backend.listNames()).sort()).toEqual(["A", "B"]);
await backend.delete("A");
expect(await backend.listNames()).toEqual(["B"]);
});
it("watcher fans set() through the bridge as secret.changed", {
timeout: 30000,
}, async () => {
await clearSecrets(vault);
const adapter = createVaultSecretsAdapter({
vault: backend,
allowlist: ["STRIPE"],
});
const bus = new EventBus<{ "secret.changed": { name: string } }>();
const heard: string[] = [];
bus.on("secret.changed", (p) => {
heard.push(p.name);
});
const watcher = createSecretsVaultWatcher({
vault: backend,
allowlist: adapter.allowlist,
bus,
});
await backend.set("STRIPE", "sk_live_rotated");
await backend.set("AWS", "ignored — not in allowlist");
expect(heard).toEqual(["STRIPE"]);
watcher.dispose();
adapter.dispose();
});
it("write-path errors surface as SECRETS_VAULT.WRITE_FAILED", {
timeout: 30000,
}, async () => {
await clearSecrets(vault);
// Empty title — vault.items.create's ItemFieldsSchema rejects
// empty titles, surfacing as a VaultError that the bridge
// wraps as WRITE_FAILED.
let caught: unknown;
try {
await backend.set("", "value");
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(SecretsVaultError);
expect((caught as SecretsVaultErrorType).code).toBe(
SECRETS_VAULT_CODES.WRITE_FAILED
);
});
it("delete is a no-op for an absent name (no error, no fan-out)", {
timeout: 30000,
}, async () => {
await clearSecrets(vault);
const heard: string[] = [];
const unsub = backend.onSecretUpdated((p) => {
heard.push(p.name);
});
await backend.delete("never-existed");
expect(heard).toEqual([]);
unsub();
});
it("set overwrites an existing entry via the update path", {
timeout: 30000,
}, async () => {
await clearSecrets(vault);
await backend.set("ROT", "v1");
await backend.set("ROT", "v2");
await backend.set("ROT", "v3");
const adapter = createVaultSecretsAdapter({
vault: backend,
allowlist: ["ROT"],
});
await expect(adapter.require("ROT")).resolves.toBe("v3");
adapter.dispose();
});
it("listener errors do not propagate to the producer", {
timeout: 30000,
}, async () => {
await clearSecrets(vault);
const sync = backend.onSecretUpdated(() => {
throw new Error("listener boom");
});
const async = backend.onSecretUpdated(async () => {
throw new Error("async boom");
});
await expect(backend.set("X", "y")).resolves.toBeUndefined();
sync();
async();
});
});
// Locked-vault assertions need a SEPARATE, dedicated vault instance:
// `vault.lock()` calls the global `zeroizeAll()` which would invalidate
// the shared unlocked vault's key buffer. Run this AFTER the shared-
// vault describe block so its `afterAll` `lock()` doesn't trip on a
// zeroized buffer.
describe("real @suaveplan/vault — locked-vault semantics", () => {
it("locked vault → adapter.get raises SECRETS_VAULT.VAULT_LOCKED; bridge get returns undefined", {
timeout: 120000,
}, async () => {
const v = Vault.open(makeConfig());
await v.unlock("pw", "sk");
const b = fromVault(v);
await b.set("X", "y");
await v.flush();
await v.lock();
expect(b.isLocked).toBe(true);
await expect(b.get("X")).resolves.toBeUndefined();
expect(await b.listNames()).toEqual([]);
const adapter = createVaultSecretsAdapter({
vault: b,
allowlist: ["X"],
});
let caught: unknown;
try {
await adapter.get("X");
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(SecretsVaultError);
expect((caught as SecretsVaultErrorType).code).toBe(
SECRETS_VAULT_CODES.VAULT_LOCKED
);
adapter.dispose();
});
});

View File

@@ -0,0 +1,35 @@
/**
* Test setup for @suaveplan/api-secrets-vault.
*
* Installs an in-memory OpenTelemetry MeterProvider BEFORE any source
* module imports so the meter resolved on first call binds to it. The
* helper `flushTestMetrics` collects every snapshot emitted since the
* previous call, which the OTel assertion tests use to verify counter
* emission without relying on real exporters.
*/
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,36 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/api-secrets-vault",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
/^node:/,
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,31 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/index.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 60000,
hookTimeout: 120000,
},
});