first commit
This commit is contained in:
137
docs/api-stack/api/api-secrets-vault/src/adapter/adapter.md
Normal file
137
docs/api-stack/api/api-secrets-vault/src/adapter/adapter.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# `adapter`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Stage-8 concrete `SecretsLike` implementation backed by a
|
||||
`VaultSecretsBackend` (real `@suaveplan/vault.Vault` via `fromVault`,
|
||||
or any backend honouring the structural contract). Suitable for
|
||||
multi-host / cloud deployments that need an encrypted, replicated
|
||||
container with a controlled unlock flow alongside per-plugin scoping.
|
||||
The vault is passed in unlocked by the host startup sequence; the
|
||||
adapter never sees the master key, mirrors the env adapter's allow-
|
||||
list semantics, and emits the canonical
|
||||
`api.secrets.reads_total{backend="vault"}` counter on every
|
||||
successful read.
|
||||
|
||||
The adapter is structurally a `SecretsLike` from
|
||||
`@suaveplan/api-types` and passes the `runSecretsConformance`
|
||||
harness from `@suaveplan/api-secrets/testing` unchanged.
|
||||
|
||||
## Features
|
||||
|
||||
- `createVaultSecretsAdapter({ vault, allowlist }): VaultSecretsAdapter`
|
||||
— factory returning a frozen-shaped `SecretsLike` plus an
|
||||
idempotent `dispose()`.
|
||||
- Per-plugin allow-list enforced BEFORE the vault is touched —
|
||||
rejections throw `SECRETS_VAULT.NOT_ALLOWED`.
|
||||
- Locked-vault detection: every `get` / `require` checks
|
||||
`vault.isLocked` and raises `SECRETS_VAULT.VAULT_LOCKED` with no
|
||||
plaintext fallback.
|
||||
- Backend error wrapping: an unexpected throw from the backend
|
||||
surfaces as `SECRETS_VAULT.VAULT_UNAVAILABLE` with the original
|
||||
error attached on `cause`.
|
||||
- `require` raises `SECRETS_VAULT.NOT_FOUND` for permitted-but-
|
||||
absent names; `get` resolves to `undefined`.
|
||||
- OTel `api.secrets.reads_total{backend="vault"}` counter increments
|
||||
on every successful resolution (both `get` and `require`).
|
||||
- Zod-validated options; `SECRETS_VAULT.INVALID_OPTIONS` on failure.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createVaultSecretsAdapter, fromVault }
|
||||
from "@suaveplan/api-secrets-vault";
|
||||
import { Vault } from "@suaveplan/vault";
|
||||
|
||||
const vault = Vault.open(vaultConfig);
|
||||
await vault.unlock(password, secretKey);
|
||||
|
||||
const secrets = createVaultSecretsAdapter({
|
||||
vault: fromVault(vault),
|
||||
allowlist: ["STRIPE_SECRET_KEY", "JWT_SIGNING_KEY"],
|
||||
});
|
||||
|
||||
const stripeKey = await secrets.get("STRIPE_SECRET_KEY");
|
||||
const jwt = await secrets.require("JWT_SIGNING_KEY");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire a deterministic backend for tests (no vault unlock required):
|
||||
|
||||
```ts
|
||||
import { createVaultSecretsAdapter }
|
||||
from "@suaveplan/api-secrets-vault";
|
||||
import type { VaultSecretsBackend }
|
||||
from "@suaveplan/api-secrets-vault/types";
|
||||
|
||||
function makeFixtureBackend(seed: Record<string, string>): VaultSecretsBackend {
|
||||
const store = new Map(Object.entries(seed));
|
||||
const listeners = new Set<(p: { name: string }) => void>();
|
||||
return {
|
||||
get isLocked() {
|
||||
return false;
|
||||
},
|
||||
async get(name) {
|
||||
return store.get(name);
|
||||
},
|
||||
async set(name, value) {
|
||||
store.set(name, value);
|
||||
for (const l of listeners) l({ name });
|
||||
},
|
||||
async delete(name) {
|
||||
store.delete(name);
|
||||
},
|
||||
async listNames() {
|
||||
return [...store.keys()];
|
||||
},
|
||||
onSecretUpdated(l) {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const adapter = createVaultSecretsAdapter({
|
||||
vault: makeFixtureBackend({ STRIPE: "sk_test_123" }),
|
||||
allowlist: ["STRIPE"],
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface CreateVaultSecretsAdapterOptions`
|
||||
- `vault: VaultSecretsBackend` — pre-unlocked.
|
||||
- `allowlist: readonly string[]`
|
||||
- `interface VaultSecretsAdapter extends SecretsLike`
|
||||
- `readonly allowlist: Allowlist`
|
||||
- `dispose(): void` — idempotent
|
||||
- `createVaultSecretsAdapter(options): VaultSecretsAdapter`
|
||||
- Throws `SECRETS_VAULT.INVALID_OPTIONS` on bad options.
|
||||
- Returned `SecretsLike`:
|
||||
- `get(name): Promise<string | undefined>`
|
||||
- `require(name): Promise<string>`
|
||||
- `list(): readonly string[]`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The allow-list check ordering matters — running it BEFORE the
|
||||
locked-state check means a non-permitted name on a locked vault
|
||||
raises `NOT_ALLOWED`, not `VAULT_LOCKED`. This is deliberate: the
|
||||
allow-list rejection is the canonical "you cannot ask about this
|
||||
name" answer regardless of vault state, and the alternative would
|
||||
leak liveness information about the vault to plugins that aren't
|
||||
allowed to read from it.
|
||||
|
||||
Backend exceptions are wrapped in `VAULT_UNAVAILABLE` only if they
|
||||
are not already a `SecretsVaultError` — a backend that throws our
|
||||
own typed error (e.g. via the `fromVault` write-path) surfaces
|
||||
through unchanged so codes don't double-wrap.
|
||||
|
||||
`dispose()` is idempotent and currently a no-op at the adapter
|
||||
level; the watcher owns the live subscription. Kept on the surface
|
||||
so capability-adapter lifecycles look uniform across the family.
|
||||
@@ -0,0 +1,79 @@
|
||||
# `allowlist`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Compiled per-plugin allow-list of secret names. The vault-backed
|
||||
adapter calls `assertAllowed` BEFORE consulting the backend so a
|
||||
non-permitted name never even discovers whether a corresponding
|
||||
secret exists, and the watcher calls `isAllowed` as a filter on every
|
||||
forwarded `secret.updated` event. Decoupling the allow-list into its
|
||||
own module lets the adapter and watcher share the same compiled set
|
||||
without re-running Zod option validation per call site.
|
||||
|
||||
## Features
|
||||
|
||||
- `createAllowlist({ names })` — Zod-validated factory returning a
|
||||
frozen `Allowlist` (`isAllowed`, `assertAllowed`, `names`).
|
||||
- O(1) lookups via internal `Set<string>` — `assertAllowed` is on
|
||||
the hot read path of every `get` / `require` call.
|
||||
- Deduplicated + sorted public `names` array, frozen so a consumer
|
||||
cannot mutate the surface back upstream.
|
||||
- Empty input is permitted: a plugin manifest that declares no
|
||||
secrets compiles to an allow-list that rejects every read at the
|
||||
gate (strict-mode behaviour by design).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createAllowlist } from "@suaveplan/api-secrets-vault/allowlist";
|
||||
|
||||
const allow = createAllowlist({ names: ["STRIPE", "JWT_SIGNING"] });
|
||||
allow.assertAllowed("STRIPE"); // ok
|
||||
allow.assertAllowed("AWS"); // throws SECRETS_VAULT.NOT_ALLOWED
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Use the predicate form as a watcher filter:
|
||||
|
||||
```ts
|
||||
import { createAllowlist } from "@suaveplan/api-secrets-vault/allowlist";
|
||||
|
||||
const allow = createAllowlist({ names: ["STRIPE"] });
|
||||
|
||||
backend.onSecretUpdated((payload) => {
|
||||
if (!allow.isAllowed(payload.name)) return;
|
||||
bus.emit("secret.changed", { name: payload.name });
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface CreateAllowlistOptions { readonly names: readonly string[] }`
|
||||
- `createAllowlist(options): Allowlist`
|
||||
- `interface Allowlist`
|
||||
- `readonly names: readonly string[]`
|
||||
- `isAllowed(name: string): boolean`
|
||||
- `assertAllowed(name: string): void`
|
||||
— throws `SecretsVaultError(SECRETS_VAULT.NOT_ALLOWED)`
|
||||
- Construction failures raise
|
||||
`SecretsVaultError(SECRETS_VAULT.INVALID_OPTIONS)` carrying the raw
|
||||
Zod issue list on `error.context.issues`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `names` snapshot is built via `new Set(names).sort()` so callers
|
||||
who accidentally pass duplicates get a clean public list, and the
|
||||
sort makes the snapshot's iteration order deterministic — important
|
||||
for tests that snapshot the allow-list shape, and for cache keys
|
||||
keyed on the allow-list ordering.
|
||||
|
||||
The Zod schema rejects empty-string entries because a zero-length
|
||||
allow-list entry would collide with the conformance harness's
|
||||
"missing-name" probe and silently grant access to a "" key in the
|
||||
backend. Real plugin manifests never declare a "" secret, so the
|
||||
check is free strictness.
|
||||
@@ -0,0 +1,89 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Frozen registry of `SECRETS_VAULT.*` error codes thrown by the
|
||||
vault-backed `SecretsLike` adapter and its watcher / allow-list
|
||||
helpers. Kept distinct from upstream `SECRETS.*` (owned by
|
||||
`@suaveplan/api-secrets`) and `SECRETS_ENV.*` (the env adapter) so a
|
||||
single log line is enough for operators to identify whether the
|
||||
failure originated in vault-locked state, allow-list rejection,
|
||||
backend I/O failure, or write-path mutation. Built on
|
||||
`defineCodes` + `SuaveplanError` so the throwable carries the
|
||||
canonical `code` field and structured-logger `name` attribute.
|
||||
|
||||
## Features
|
||||
|
||||
- `SECRETS_VAULT_CODES` — frozen object literal with six entries:
|
||||
`NOT_FOUND`, `NOT_ALLOWED`, `VAULT_LOCKED`, `VAULT_UNAVAILABLE`,
|
||||
`WRITE_FAILED`, `INVALID_OPTIONS`.
|
||||
- `SecretsVaultCode` — string-literal union of the six codes.
|
||||
- `SecretsVaultError` — `SuaveplanError<SecretsVaultCode>` subclass
|
||||
with `error.name === "SecretsVaultError"`.
|
||||
- `SECRETS_VAULT_ERROR_NAME` — exported constant for cross-package
|
||||
`name`-equality checks.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
SECRETS_VAULT_CODES,
|
||||
SecretsVaultError,
|
||||
} from "@suaveplan/api-secrets-vault/error-codes";
|
||||
|
||||
throw new SecretsVaultError(
|
||||
SECRETS_VAULT_CODES.NOT_ALLOWED,
|
||||
"error.secrets_vault.not_allowed",
|
||||
{ context: { name: "AWS" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Cause-chained surfacing from a backend I/O failure:
|
||||
|
||||
```ts
|
||||
import {
|
||||
SECRETS_VAULT_CODES,
|
||||
SecretsVaultError,
|
||||
} from "@suaveplan/api-secrets-vault/error-codes";
|
||||
|
||||
try {
|
||||
await backend.get("STRIPE");
|
||||
} catch (cause) {
|
||||
throw new SecretsVaultError(
|
||||
SECRETS_VAULT_CODES.VAULT_UNAVAILABLE,
|
||||
"error.secrets_vault.vault_unavailable",
|
||||
{ cause, context: { name: "STRIPE" } }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SECRETS_VAULT_CODES: Readonly<{ NOT_FOUND, NOT_ALLOWED, VAULT_LOCKED,
|
||||
VAULT_UNAVAILABLE, WRITE_FAILED, INVALID_OPTIONS }>`
|
||||
- `type SecretsVaultCode = (typeof SECRETS_VAULT_CODES)[…]`
|
||||
- `const SECRETS_VAULT_ERROR_NAME = "SecretsVaultError"`
|
||||
- `class SecretsVaultError extends SuaveplanError<SecretsVaultCode>`
|
||||
- `constructor(code, messageKey, options?: SuaveplanErrorOptions)`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The codes register at module load via the package's root barrel
|
||||
side-effect-importing this file. That guarantees subpath consumers
|
||||
who import `@suaveplan/api-secrets-vault/adapter` directly still get
|
||||
the registry populated for cross-package code lookups. The throwable
|
||||
attaches the original error on `cause` per WHATWG semantics, which
|
||||
the `error.cause`-chain redactor in `@suaveplan/error` walks during
|
||||
log emission.
|
||||
|
||||
`VAULT_LOCKED` is intentionally raised eagerly rather than letting
|
||||
the backend's own locked-state error bubble up — this preserves the
|
||||
package's strict-mode invariant (no plaintext fallback on a locked
|
||||
vault) and lets the adapter throw a consistent code regardless of
|
||||
which `@suaveplan/vault` version is in scope.
|
||||
@@ -0,0 +1,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".
|
||||
88
docs/api-stack/api/api-secrets-vault/src/otel/instruments.md
Normal file
88
docs/api-stack/api/api-secrets-vault/src/otel/instruments.md
Normal 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.
|
||||
106
docs/api-stack/api/api-secrets-vault/src/types/types.md
Normal file
106
docs/api-stack/api/api-secrets-vault/src/types/types.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# `types`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Structural type contracts the vault-backed secrets adapter consumes.
|
||||
Decoupling the adapter from the concrete `@suaveplan/vault.Vault`
|
||||
class via these interfaces lets tests wire a deterministic in-memory
|
||||
backend without going through the 30s-per-unlock Argon2id 2SKD
|
||||
lifecycle, while a real `@suaveplan/vault.Vault` integration test
|
||||
still drives the full lifecycle end-to-end via the `fromVault`
|
||||
bridge. The same surface lets future deployments (HSM-backed,
|
||||
remote-vault-service) adapt to the adapter without touching it.
|
||||
|
||||
## Features
|
||||
|
||||
- `VaultSecretsBackend` — minimum surface the adapter consumes:
|
||||
`isLocked`, `get`, `set`, `delete`, `listNames`, `onSecretUpdated`.
|
||||
- `VaultSecretsBackendEventMap` — typed channel descriptors
|
||||
published by the backend (`secret.updated`).
|
||||
- `VaultSecretListener` / `VaultUnsubscribe` — listener signature
|
||||
and subscription handle returned by `onSecretUpdated`. Returning a
|
||||
promise is permitted but fan-out is fire-and-forget.
|
||||
- `SecretChangedPayload` — host-bus payload the watcher emits
|
||||
(`{ name }` only — value is NEVER broadcast).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { VaultSecretsBackend } from "@suaveplan/api-secrets-vault/types";
|
||||
|
||||
declare const backend: VaultSecretsBackend;
|
||||
const value = await backend.get("STRIPE");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Custom backend (e.g. an HSM-fronted secret store) that fulfils the
|
||||
same contract the in-process vault does:
|
||||
|
||||
```ts
|
||||
import type {
|
||||
VaultSecretListener,
|
||||
VaultSecretsBackend,
|
||||
VaultUnsubscribe,
|
||||
} from "@suaveplan/api-secrets-vault/types";
|
||||
|
||||
function makeHsmBackend(): VaultSecretsBackend {
|
||||
const listeners = new Set<VaultSecretListener>();
|
||||
return {
|
||||
get isLocked() {
|
||||
return false;
|
||||
},
|
||||
async get(name) {
|
||||
return await hsm.fetch(name);
|
||||
},
|
||||
async set(name, value) {
|
||||
await hsm.put(name, value);
|
||||
for (const l of listeners) void l({ name });
|
||||
},
|
||||
async delete(name) {
|
||||
await hsm.remove(name);
|
||||
for (const l of listeners) void l({ name });
|
||||
},
|
||||
async listNames() {
|
||||
return await hsm.enumerate();
|
||||
},
|
||||
onSecretUpdated(listener): VaultUnsubscribe {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface VaultSecretsBackend`
|
||||
- `readonly isLocked: boolean`
|
||||
- `get(name: string): Promise<string | undefined>`
|
||||
- `set(name: string, value: string): Promise<void>`
|
||||
- `delete(name: string): Promise<void>`
|
||||
- `listNames(): Promise<readonly string[]>`
|
||||
- `onSecretUpdated(listener): VaultUnsubscribe`
|
||||
- `interface VaultSecretsBackendEventMap`
|
||||
- `"secret.updated": { readonly name: string }`
|
||||
- `type VaultSecretListener = (p: { name: string }) => void | Promise<void>`
|
||||
- `type VaultUnsubscribe = () => void`
|
||||
- `interface SecretChangedPayload { readonly name: string }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The structural view stops short of mandating a value-broadcast
|
||||
channel. The watcher carries only `{ name }` onto the host bus —
|
||||
listeners refresh by calling the adapter (which re-enters the
|
||||
allow-list gate). This shape resists two failure modes: a worker
|
||||
sniffing the bus to enumerate secret values it shouldn't be allowed
|
||||
to read, and a stale-value race where a listener acts on the
|
||||
broadcast value but a rotation lands before the listener finishes.
|
||||
|
||||
Backends are responsible for honouring locked-state semantics —
|
||||
returning `false` for `isLocked` while the underlying store is
|
||||
locked would break the strict-mode contract the adapter relies on.
|
||||
113
docs/api-stack/api/api-secrets-vault/src/watcher/watcher.md
Normal file
113
docs/api-stack/api/api-secrets-vault/src/watcher/watcher.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# `watcher`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Live subscription that bridges the vault's `secret.updated` events
|
||||
onto the host event bus as `secret.changed`. Plugins that cache
|
||||
secret values register against the host bus and refresh on
|
||||
broadcast; the watcher intersects the vault stream with the per-
|
||||
plugin allow-list so a plugin never even learns that a non-allow-
|
||||
listed secret rotated. Designed as a host-owned lifecycle: one
|
||||
watcher per `(vault, plugin)` pair, disposed during plugin teardown.
|
||||
|
||||
## Features
|
||||
|
||||
- `createSecretsVaultWatcher({ vault, allowlist, bus })` —
|
||||
Zod-validated factory subscribing once to
|
||||
`vault.onSecretUpdated` and returning a `SecretsVaultWatcher`
|
||||
handle.
|
||||
- Per-plugin allow-list filter — `vault.secret.updated` payloads
|
||||
whose `name` is not in the allow-list are silently dropped.
|
||||
- Allow-listed updates forward as `bus.emit("secret.changed",
|
||||
{ name })` carrying ONLY the name; the value is never broadcast.
|
||||
Listeners refresh by calling `adapter.get(name)` which re-enters
|
||||
the allow-list gate.
|
||||
- OTel `api.secrets.watcher_emits_total` counter increments on
|
||||
every forwarded event.
|
||||
- Idempotent `dispose()` — releases the underlying subscription.
|
||||
- Single subscription per watcher: the listener guards against
|
||||
post-dispose deliveries even if the backend's `unsubscribe` lags.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createAllowlist,
|
||||
createSecretsVaultWatcher,
|
||||
} from "@suaveplan/api-secrets-vault";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
|
||||
const bus = new EventBus<{ "secret.changed": { name: string } }>();
|
||||
const allow = createAllowlist({ names: ["STRIPE"] });
|
||||
const watcher = createSecretsVaultWatcher({ vault: backend, allowlist: allow, bus });
|
||||
|
||||
bus.on("secret.changed", (p) => {
|
||||
console.log(`Refreshing cache for ${p.name}`);
|
||||
});
|
||||
|
||||
// ...later, during plugin teardown:
|
||||
watcher.dispose();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Combine with the adapter so plugins see a uniform "read + refresh"
|
||||
loop:
|
||||
|
||||
```ts
|
||||
import {
|
||||
createSecretsVaultWatcher,
|
||||
createVaultSecretsAdapter,
|
||||
} from "@suaveplan/api-secrets-vault";
|
||||
|
||||
const adapter = createVaultSecretsAdapter({
|
||||
vault: backend,
|
||||
allowlist: ["STRIPE"],
|
||||
});
|
||||
const watcher = createSecretsVaultWatcher({
|
||||
vault: backend,
|
||||
allowlist: adapter.allowlist,
|
||||
bus: hostBus,
|
||||
});
|
||||
|
||||
let cached = await adapter.require("STRIPE");
|
||||
hostBus.on("secret.changed", async (p) => {
|
||||
if (p.name === "STRIPE") {
|
||||
cached = await adapter.require("STRIPE");
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface HostEventBusEmitter`
|
||||
- `emit(type: "secret.changed", payload: { name: string }): unknown`
|
||||
- `interface CreateSecretsVaultWatcherOptions`
|
||||
- `vault: VaultSecretsBackend`
|
||||
- `allowlist: Allowlist`
|
||||
- `bus: HostEventBusEmitter`
|
||||
- `createSecretsVaultWatcher(options): SecretsVaultWatcher`
|
||||
- Throws `SECRETS_VAULT.INVALID_OPTIONS` on bad options.
|
||||
- `interface SecretsVaultWatcher`
|
||||
- `dispose(): void` — idempotent
|
||||
- `readonly isDisposed: boolean`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The watcher checks its own `disposed` flag inside the listener AND
|
||||
calls the backend's `unsubscribe` handle on dispose. The double-
|
||||
guard handles backends whose `unsubscribe` is implemented lazily —
|
||||
if a `secret.updated` is in flight when we dispose, the disposed-
|
||||
guard catches the stale delivery so the host bus never receives a
|
||||
post-dispose `secret.changed`.
|
||||
|
||||
Listener errors swallowed at the host bus boundary cannot crash the
|
||||
producer — that's `@suaveplan/events.EventBus` policy. The watcher
|
||||
itself does not `try/catch` around `bus.emit` because the host bus
|
||||
is the canonical place for emit-time error handling, and re-
|
||||
throwing here would block the rest of the listener fan-out at the
|
||||
vault backend.
|
||||
Reference in New Issue
Block a user