Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 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,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,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,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,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,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,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,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.