# `watcher` _(no submodules)_ ## 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.