first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,136 @@
# @suaveplan/api-secrets-bun — SecretsLike adapter backed by Bun.secrets (macOS Keychain / Linux libsecret / Windows Credential Manager). Per-plugin allowlist enforcement, locked-keystore detection, idempotent dispose, OTel api.secrets.reads_total{backend="bun"} counter.
Stage-8 `SecretsLike` adapter backed by `Bun.secrets` (macOS Keychain,
Linux libsecret, Windows Credential Manager) with per-plugin allow-list
enforcement, OS-keystore failure-mode classification, and an idempotent
lifecycle.
The right default for local-dev and single-host operator setups: secrets
never touch disk in plaintext and rotate via the OS UI.
Per-plugin allow-list keeps each plugin scoped to its manifest-declared
secrets — a leaked plugin cannot enumerate or read any keystore entry
beyond what its `manifest.secrets[]` declared. Values never appear in
logs or errors — only names.
<!-- begin:auto-docs -->
## Documentation tree
- [adapter](./src/adapter/adapter.md) — The adapter module builds a concrete `SecretsLike` over the `Bun.secrets` API — the cross-platform wrapper Bun ships ar…
- [adapter](./src/adapter/adapter.md) — The adapter module builds a concrete `SecretsLike` over the `Bun.secrets` API — the cross-platform wrapper Bun ships ar…
- [allowlist](./src/allowlist/allowlist.md) — The allow-list module encapsulates the per-plugin scoping rule that defines the package's security boundary: a plugin c…
- [allowlist](./src/allowlist/allowlist.md) — The allow-list module encapsulates the per-plugin scoping rule that defines the package's security boundary: a plugin c…
- [descriptor-builder](./src/descriptor-builder/descriptor-builder.md) — This module produces the `SecretDescriptor[]` shape the plugin manifest emits in its `secrets[]` block from a compact a…
- [descriptor-builder](./src/descriptor-builder/descriptor-builder.md) — This module produces the `SecretDescriptor[]` shape the plugin manifest emits in its `secrets[]` block from a compact a…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable `SECRETS_BUN.*` code registry and the `SecretsBunError` subclass thrown by every failure pa…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable `SECRETS_BUN.*` code registry and the `SecretsBunError` subclass thrown by every failure pa…
- [otel](./src/otel/instruments.md) — This module owns the OpenTelemetry instrument bundle the `Bun.secrets`-backed adapter emits and a lazy cache that re-bi…
- [instruments](./src/otel/instruments.md) — This module owns the OpenTelemetry instrument bundle the `Bun.secrets`-backed adapter emits and a lazy cache that re-bi…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/api-secrets-bun
```
## Usage
```ts
import { createBunSecretsAdapter } from "@suaveplan/api-secrets-bun";
const adapter = createBunSecretsAdapter({
service: "billing-plugin",
allowlist: ["STRIPE_SECRET_KEY", "JWT_SIGNING_KEY"],
});
const key = await adapter.secrets.require("STRIPE_SECRET_KEY");
```
### Manifest-driven allow-list
The companion `buildBunSecretDescriptors` builder lets a plugin derive
its `manifest.secrets[]` and the adapter's `allowlist` from a single
declaration so they cannot drift:
```ts
import {
buildBunSecretDescriptors,
descriptorAllowlist,
createBunSecretsAdapter,
} from "@suaveplan/api-secrets-bun";
const descriptors = buildBunSecretDescriptors([
"STRIPE_SECRET_KEY",
{ name: "OPTIONAL_WEBHOOK_KEY", required: false },
]);
const adapter = createBunSecretsAdapter({
service: "billing-plugin",
allowlist: descriptorAllowlist(descriptors),
});
// `descriptors` goes on the plugin manifest's `secrets[]`.
```
## Capability
This package declares `secrets.bun`. Plugins that specifically require
the Bun keystore (e.g. desktop runtimes that bundle Bun) can pin the
backend in `requiredCapabilities`; otherwise the host's `host.secrets.backend`
config selects this adapter alongside the abstract `secrets` capability.
## Failure modes
Distinguishes:
- `SECRETS_BUN.NOT_FOUND` — permitted name; OS keystore returned `null`.
`get()` resolves to `undefined`; `require()` throws.
- `SECRETS_BUN.NOT_ALLOWED` — name not in the plugin's allow-list.
Raised BEFORE any keystore round-trip.
- `SECRETS_BUN.KEYSTORE_LOCKED` — OS keystore is locked. The adapter
does NOT fall back silently — operator must unlock.
- `SECRETS_BUN.KEYSTORE_UNAVAILABLE` — `Bun.secrets` is missing
(running under Node without the shim), the OS keystore daemon is not
running, or the adapter has been `dispose()`d.
- `SECRETS_BUN.WRITE_FAILED` — `set`/`delete` failed at the OS layer.
Always wraps the underlying error as `cause`.
- `SECRETS_BUN.INVALID_OPTIONS` — adapter construction failed Zod
validation.
## OpenTelemetry
Emits:
- `api.secrets.reads_total{backend="bun"}` — successful reads.
- `api.secrets.not_found_total{backend="bun"}` — permitted-but-absent.
- `api.secrets.denied_total{backend="bun"}` — allow-list rejections.
Metric names are backend-agnostic so dashboards roll up across every
`api.secrets.*` source via the `backend` label.
## Lifecycle
`adapter.dispose()` is idempotent — repeat calls are no-ops. Post-dispose
reads / writes throw `KEYSTORE_UNAVAILABLE` rather than leaking the
keystore reference.
## Conformance
Passes `runSecretsConformance` from `@suaveplan/api-secrets/testing`
unchanged — see `src/adapter/adapter.spec-conformance.test.ts`.
## Modules
| Module | Subpath | Purpose |
| --- | --- | --- |
| `adapter` | `./adapter` | `createBunSecretsAdapter` factory + types. |
| `allowlist` | `./allowlist` | Per-plugin allow-list with `assertAllowed`. |
| `descriptor-builder` | `./descriptor-builder` | Compact → `SecretDescriptor[]`. |
| `error-codes` | `./error-codes` | `SECRETS_BUN.*` registry + `SecretsBunError`. |
| `otel` | `./otel` | OTel instruments + reset hook. |
## License
MIT

View File

@@ -0,0 +1,121 @@
# `adapter`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The adapter module builds a concrete `SecretsLike` over the
`Bun.secrets` API — the cross-platform wrapper Bun ships around the OS
keystore (macOS Keychain, Linux libsecret, Windows Credential Manager).
The host installs the returned `secrets` handle as `ctx.secrets` so
plugins read manifest-declared secrets through the same surface every
other backend exposes; the only difference is values live in the OS
keystore rather than `process.env`, a remote vault, or a worker proxy.
The adapter also exposes adapter-specific `set` / `deleteSecret`
helpers that the abstract `SecretsLike` does not need but are essential
for the operator UX of rotating a secret without dropping out to the
OS UI. Both helpers go through the same allow-list gate as `get` so a
plugin cannot write outside its manifest.
## Features
- Per-plugin allow-list gates every `get` / `require` / `set` /
`deleteSecret` BEFORE the keystore round-trip.
- Distinguishes `NOT_FOUND` (`null` from `Bun.secrets.get`) from
`KEYSTORE_LOCKED` and `KEYSTORE_UNAVAILABLE` via a classifier that
inspects `error.code` first then falls back to message substrings.
- Idempotent `dispose()` for lifecycle dedup; post-dispose reads /
writes throw `KEYSTORE_UNAVAILABLE`.
- Emits `api.secrets.reads_total{backend="bun"}` on every successful
resolve, `api.secrets.not_found_total{backend="bun"}` on every
permitted-but-absent read, and `api.secrets.denied_total{backend="bun"}`
on every allow-list rejection.
- Injectable `keystore` and `classifyError` so tests run against an
in-memory keystore on Node without touching the real OS daemon.
- Zod-validated options — empty `service` / empty `allowlist` / empty
names all raise `SECRETS_BUN.INVALID_OPTIONS` at construction.
## Usage: Basic
```ts
import { createBunSecretsAdapter } from "@suaveplan/api-secrets-bun/adapter";
const adapter = createBunSecretsAdapter({
service: "my-plugin",
allowlist: ["STRIPE_SECRET_KEY", "JWT_SIGNING_KEY"],
});
const key = await adapter.secrets.require("STRIPE_SECRET_KEY");
```
## Usage: Advanced
Inject a custom keystore (test harness) and a bespoke classifier (e.g.
when wrapping a third-party keystore CLI whose error codes do not match
Bun's convention):
```ts
import {
createBunSecretsAdapter,
type BunSecretsKeystore,
} from "@suaveplan/api-secrets-bun/adapter";
const memory: BunSecretsKeystore = {
/* ... in-memory get / set / delete ... */
};
const adapter = createBunSecretsAdapter({
service: "my-plugin",
allowlist: ["A"],
keystore: memory,
classifyError: (err) =>
typeof err === "object" && err !== null && "stderr" in err &&
String((err as { stderr: unknown }).stderr).includes("locked")
? "KEYSTORE_LOCKED"
: undefined,
});
await adapter.set("A", "v");
const v = await adapter.secrets.require("A");
// Lifecycle: dispose once at plugin teardown.
adapter.dispose();
```
## API Reference
- `createBunSecretsAdapter(options): BunSecretsAdapter` — entry point.
- `BunSecretsAdapter.secrets: SecretsLike` — read-only handle the host
installs as `ctx.secrets`.
- `BunSecretsAdapter.set(name, value): Promise<void>` — adapter-specific
write through the allow-list and keystore.
- `BunSecretsAdapter.deleteSecret(name): Promise<void>` — adapter-specific
delete.
- `BunSecretsAdapter.dispose(): void` — idempotent lifecycle teardown.
- `BunSecretsKeystore` — structural view of `Bun.secrets`.
- `defaultClassifyError(err): "KEYSTORE_LOCKED" | "KEYSTORE_UNAVAILABLE" | undefined` —
default failure-mode classifier.
## Implementation Notes
The default keystore probe reads `globalThis.Bun.secrets` rather than
importing the `Bun` global directly, so the package typechecks under
plain Node when `@types/bun` is absent. When the probe returns
`undefined` (Node, or a Bun runtime missing the `secrets` namespace),
the constructor throws `KEYSTORE_UNAVAILABLE` so a misconfigured host
fails loudly at start rather than at first read.
`api.secrets.reads_total` is incremented from inside `get` / `require`
on the success path only, AFTER the keystore returns a non-`null` value.
This pins the metric to actual reads rather than attempts so dashboards
graphing read pressure are not contaminated by allow-list rejections
(those live on `denied_total`) or missing entries (those live on
`not_found_total`).
The `dispose` flag is checked first inside every public call so a
disposed adapter cannot leak a stale keystore reference; it is also
idempotent so the host can call it from both a `SIGTERM` handler and a
plugin-unload hook without crashing.

View File

@@ -0,0 +1,73 @@
# `allowlist`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The allow-list module encapsulates the per-plugin scoping rule that
defines the package's security boundary: a plugin can only read secrets
that its manifest's `secrets[]` block declared, no matter what
`Bun.secrets` happens to hold under the host's service namespace. Every
`get` / `require` call routes through `assertAllowed` BEFORE any
keystore round-trip, so a leaked plugin cannot probe key existence by
name even when the OS daemon is reachable.
The interface is intentionally minimal — a frozen ordered `names` array
plus an O(1) `has` probe and the throwing `assertAllowed` guard — so the
adapter can compose it without re-implementing the membership semantics.
## Features
- Frozen `names` array preserving manifest declaration order; safe to
return directly from the adapter's `list()`.
- `has(name)` boolean probe for branchy callers (e.g. selective writes).
- `assertAllowed(name)` guard that throws `SECRETS_BUN.NOT_ALLOWED`
with `context.name` populated for the audit log.
- Silent de-duplication so a manifest that imports the same secret
twice does not crash at construction.
## Usage: Basic
```ts
import { createAllowlist } from "@suaveplan/api-secrets-bun/allowlist";
const al = createAllowlist(["STRIPE_SECRET_KEY", "JWT_SIGNING_KEY"]);
al.assertAllowed("STRIPE_SECRET_KEY"); // no-op
al.assertAllowed("AWS_SECRET_KEY"); // throws SECRETS_BUN.NOT_ALLOWED
```
## Usage: Advanced
Compose the allow-list with the adapter to gate `set` as well as `get`:
```ts
import { createAllowlist } from "@suaveplan/api-secrets-bun/allowlist";
const al = createAllowlist(manifest.secrets.map((s) => s.name));
function writeIfAllowed(name: string, value: string): void {
al.assertAllowed(name);
return Bun.secrets.set({ service: "my-plugin", name, value });
}
```
## API Reference
- `createAllowlist(names: readonly string[]): Allowlist` — build a
frozen allow-list from a manifest-derived name list.
- `Allowlist.names: readonly string[]` — frozen ordered view.
- `Allowlist.has(name: string): boolean` — membership probe.
- `Allowlist.assertAllowed(name: string): void` — throws
`SecretsBunError` with code `SECRETS_BUN.NOT_ALLOWED` on miss.
## Implementation Notes
The allow-list owns its `Set<string>` once at construction so per-call
lookup is O(1). The `names` array is frozen at the top level only; the
strings inside are primitives and already immutable. `assertAllowed`
populates `context.name` so the audit trail can route a denied lookup
back to the offending plugin without surfacing the value (there is no
value yet — the call is rejected before keystore consultation).

View File

@@ -0,0 +1,93 @@
# `descriptor-builder`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module produces the `SecretDescriptor[]` shape the plugin manifest
emits in its `secrets[]` block from a compact author-facing list. Pairing
the manifest output with `descriptorAllowlist(descriptors)` gives the
adapter's `allowlist` option, so a single declaration drives both the
manifest the installer reads and the runtime gate the adapter enforces —
the two cannot drift.
The builder accepts either bare strings (shorthand for
`{ name, required: true }`) or full row objects, mirroring the sibling
env-adapter's `buildEnvSecretDescriptors`. Both shapes return frozen
descriptors so consumers can ship the result directly without defensive
copies.
## Features
- Compact bare-string shorthand (`"NAME"`) for required secrets.
- Full row objects for `required: false`, `description`, `format`.
- Frozen array + frozen descriptors for safe sharing.
- `descriptorAllowlist` extracts the `name` field for the adapter's
`allowlist` option.
## Usage: Basic
```ts
import {
buildBunSecretDescriptors,
descriptorAllowlist,
} from "@suaveplan/api-secrets-bun/descriptor-builder";
const descriptors = buildBunSecretDescriptors([
"STRIPE_SECRET_KEY",
"JWT_SIGNING_KEY",
]);
// Ship `descriptors` on the plugin manifest's `secrets[]`.
// Pass `descriptorAllowlist(descriptors)` to `createBunSecretsAdapter`.
```
## Usage: Advanced
Mix bare strings with optional secrets and richer metadata:
```ts
import {
buildBunSecretDescriptors,
descriptorAllowlist,
createBunSecretsAdapter,
} from "@suaveplan/api-secrets-bun";
const descriptors = buildBunSecretDescriptors([
"STRIPE_SECRET_KEY",
{
name: "OPTIONAL_WEBHOOK_KEY",
required: false,
description: "Override for inbound webhook signing",
format: "base64",
},
]);
const adapter = createBunSecretsAdapter({
service: "billing",
allowlist: descriptorAllowlist(descriptors),
});
```
## API Reference
- `buildBunSecretDescriptors(rows): readonly SecretDescriptor[]` —
build a frozen descriptor array from compact rows.
- `BunSecretDescriptorInput` — full input row type.
- `descriptorAllowlist(descs): readonly string[]` — extract names for
the adapter's `allowlist`.
## Implementation Notes
`required` defaults to `true` so a manifest author who forgets the field
fails loud at install (the installer treats every required secret as
must-be-present in the OS keystore). Setting it to `false` is the
deliberate opt-in for optional secrets — feature-flagged paths that
degrade gracefully when the value is absent.
The descriptor objects are frozen with `Object.freeze` rather than
shipped as `readonly` types alone because the manifest sometimes
traverses through `JSON.parse` round-trips and downstream code historically
mutated descriptors in place; freezing kills that class of bug.

View File

@@ -0,0 +1,86 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the stable `SECRETS_BUN.*` code registry and the
`SecretsBunError` subclass thrown by every failure path inside the
`Bun.secrets`-backed adapter. Splitting these codes from the upstream
`SECRETS.*` namespace gives operators a one-glance distinction between
the abstract secrets contract (`@suaveplan/api-secrets`), the
environment-variable adapter (`SECRETS_ENV.*`), and the OS-keystore
adapter (`SECRETS_BUN.*`).
The registry is frozen at module-load time so no consumer can mutate the
codes that downstream telemetry already aggregates on. `SecretsBunError`
pins `error.name` to a single canonical string so structured loggers can
group rolled-up failure rates across every concrete code.
## Features
- Six codes — `NOT_FOUND`, `NOT_ALLOWED`, `KEYSTORE_UNAVAILABLE`,
`KEYSTORE_LOCKED`, `WRITE_FAILED`, `INVALID_OPTIONS`.
- Frozen registry via `defineCodes` so the surface cannot drift between
runs in the same process.
- Single `SecretsBunError` subclass for every throwable.
- Carries `cause` chains so the underlying `Bun.secrets` error survives
the wrap.
## Usage: Basic
```ts
import {
SECRETS_BUN_CODES,
SecretsBunError,
} from "@suaveplan/api-secrets-bun/error-codes";
throw new SecretsBunError(
SECRETS_BUN_CODES.NOT_ALLOWED,
"error.secrets_bun.not_allowed",
{ context: { name: "AWS_SECRET_KEY" } }
);
```
## Usage: Advanced
Catch and route on `code`:
```ts
import {
SECRETS_BUN_CODES,
SecretsBunError,
} from "@suaveplan/api-secrets-bun/error-codes";
try {
await secrets.require("STRIPE");
} catch (err) {
if (err instanceof SecretsBunError) {
if (err.code === SECRETS_BUN_CODES.KEYSTORE_LOCKED) {
// Prompt the operator to unlock the OS keystore.
} else if (err.code === SECRETS_BUN_CODES.NOT_FOUND) {
// Surface a manifest-mismatch installer hint.
}
}
throw err;
}
```
## API Reference
- `SECRETS_BUN_CODES` — frozen `{ [name]: "SECRETS_BUN.<name>" }`.
- `SecretsBunCode` — TypeScript union of every fully-qualified code.
- `SECRETS_BUN_ERROR_NAME` — `"SecretsBunError"`.
- `SecretsBunError extends SuaveplanError<SecretsBunCode>` — concrete
subclass with `code` typed to `SecretsBunCode`.
## Implementation Notes
Codes follow the project convention `<NAMESPACE>.<CASE>` so the dotted
prefix is a stable telemetry key for grouping at the metric backend.
The `SecretsBunError` constructor sets `this.name` AFTER `super` because
`SuaveplanError`'s base assigns its own default name; overriding it
unconditionally is the only way to guarantee the canonical name survives
inheritance.

View File

@@ -0,0 +1,75 @@
# `otel/instruments`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the OpenTelemetry instrument bundle the
`Bun.secrets`-backed adapter emits and a lazy cache that re-binds it to
the current global `MeterProvider`. Dashboards aggregate the
`api.secrets.*` counters across every concrete adapter via the
`backend` label, so the metric names are deliberately backend-agnostic
and the discriminator lives in label space.
The cache is process-wide because OTel SDKs treat instruments as
identity values: two `createCounter` calls with the same name on the
same meter return the same instrument, but re-binding the meter against
a freshly-installed provider in tests requires explicit reset.
## Features
- `api.secrets.reads_total{backend="bun"}` — counter of successful
reads from the adapter.
- `api.secrets.not_found_total{backend="bun"}` — counter of permitted
names that the keystore reported as absent.
- `api.secrets.denied_total{backend="bun"}` — counter of allow-list
rejections.
- Cached bundle; `resetSecretsBunInstruments()` for tests.
## Usage: Basic
```ts
import { getSecretsBunInstruments } from "@suaveplan/api-secrets-bun/otel";
const inst = getSecretsBunInstruments();
inst.readsTotal.add(1, { backend: "bun" });
```
## Usage: Advanced
In a test, install a fresh `MeterProvider` and reset the cache so the
next call re-binds to it:
```ts
import { metrics } from "@opentelemetry/api";
import {
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { resetSecretsBunInstruments } from "@suaveplan/api-secrets-bun/otel";
const exporter = new InMemoryMetricExporter(/* ... */);
const reader = new PeriodicExportingMetricReader({ exporter });
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
resetSecretsBunInstruments();
```
## API Reference
- `getSecretsBunInstruments(): SecretsBunInstruments` — lazy, cached.
- `resetSecretsBunInstruments(): void` — drop the cache.
- `SecretsBunInstruments` — bundle of three `Counter` instruments.
## Implementation Notes
The meter name is the package name (`@suaveplan/api-secrets-bun`) so the
metric source is unambiguous in OTel-Collector pipelines. Labels live on
the `add` call rather than the descriptor because OTel's data model
expects cardinality discriminators as attributes; the `backend` label
takes a single value here but the shape matches the abstract
`api-secrets` family where future backends will share the metric name
and split on the label.