first commit
This commit is contained in:
121
docs/api-stack/api/api-secrets-bun/src/adapter/adapter.md
Normal file
121
docs/api-stack/api/api-secrets-bun/src/adapter/adapter.md
Normal 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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
75
docs/api-stack/api/api-secrets-bun/src/otel/instruments.md
Normal file
75
docs/api-stack/api/api-secrets-bun/src/otel/instruments.md
Normal 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.
|
||||
Reference in New Issue
Block a user