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,134 @@
# @suaveplan/api-secrets-env — SecretsLike adapter backed by process.env / Bun.env with per-plugin allowed-keys enforcement. Reads from an injectable source function so tests never mutate the real environment; an optional prefix scopes lookups for multi-tenant single-host deployments.
Stage-4 `SecretsLike` adapter backed by `process.env` / `Bun.env` with
per-plugin allowed-keys enforcement.
The simplest secrets source: read from the host's environment. Suitable
for local development, single-host deployments, container orchestrators
that inject secrets as env vars, and anywhere the operator already has
tooling around env-var injection.
Per-plugin allow-list keeps each plugin scoped to its manifest-declared
secrets — a worker or untrusted plugin cannot enumerate or read any env
var 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) — Stage-4 concrete `SecretsLike` implementation that resolves secret names against the host process's environment variabl…
- [adapter](./src/adapter/adapter.md) — Stage-4 concrete `SecretsLike` implementation that resolves secret names against the host process's environment variabl…
- [descriptor-builder](./src/descriptor-builder/descriptor-builder.md) — Convenience builder that turns a compact allow-list declaration into a fully-typed `SecretDescriptor[]` suitable for em…
- [descriptor-builder](./src/descriptor-builder/descriptor-builder.md) — Convenience builder that turns a compact allow-list declaration into a fully-typed `SecretDescriptor[]` suitable for em…
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry and dedicated `SuaveplanError` subclass for the env-backed secrets adapter. Every failure mo…
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry and dedicated `SuaveplanError` subclass for the env-backed secrets adapter. Every failure mo…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/api-secrets-env
```
## Usage
```ts
import { createEnvSecrets } from "@suaveplan/api-secrets-env";
const secrets = createEnvSecrets({
allowedKeys: ["STRIPE_SECRET_KEY", "JWT_SIGNING_KEY"],
});
const key = await secrets.require("STRIPE_SECRET_KEY");
```
### Prefix for multi-tenant single-host deployments
```ts
const secrets = createEnvSecrets({
allowedKeys: ["API_KEY", "WEBHOOK_SECRET"],
prefix: "BILLING_",
// Reads BILLING_API_KEY, BILLING_WEBHOOK_SECRET from env.
});
```
### Manifest-driven allow-list
The companion `buildEnvSecretDescriptors` builder lets a plugin derive
its `manifest.secrets[]` and the adapter's `allowedKeys` from a single
declaration so they cannot drift:
```ts
import {
buildEnvSecretDescriptors,
descriptorAllowedKeys,
createEnvSecrets,
} from "@suaveplan/api-secrets-env";
const descriptors = buildEnvSecretDescriptors([
"STRIPE_SECRET_KEY",
{ name: "OPTIONAL_WEBHOOK_KEY", required: false },
]);
const secrets = createEnvSecrets({
allowedKeys: descriptorAllowedKeys(descriptors),
});
// `descriptors` goes on the plugin manifest's `secrets[]`.
```
## Fail-fast contract
The plugin manifest declares each required secret with
`{ name, required: true }`. The host's installer reads the manifest's
`secrets[]` and, at install or start time, calls
`enforceRequiredSecrets` (from `@suaveplan/api-secrets`) against the
adapter's `list()`. Any `required: true` secret missing from the host's
env triggers `SECRETS.SECRET_REQUIRED_MISSING_AT_START`, and the
installer refuses to install / refuses to start.
This adapter contributes to that contract by:
1. enumerating the plugin's permitted names via `list()` — the same
names the manifest declares;
2. returning `undefined` from `get()` for a permitted-but-absent name
so `enforceRequiredSecrets` can detect it;
3. throwing `SECRETS_ENV.KEY_NOT_FOUND` from `require()` so a plugin
that chooses to defer the check past install also fails loudly.
## Allowed-keys enforcement
Every `get` / `require` checks the allow-list BEFORE consulting the env
source. A non-permitted name throws
`SECRETS_ENV.KEY_NOT_PERMITTED` and never reads from the snapshot —
the env source can be expensive or have side effects without affecting
this ordering. `list()` returns the frozen `allowedKeys` array, so the
adapter never surfaces any name outside the plugin's manifest.
## Secret-value redaction
Secret VALUES never appear in error messages, log lines, or telemetry
emitted by this package — only NAMES. Errors carry the name on
`error.context.name` for diagnostic surfaces; the value (when one was
resolved) never leaves the call site.
## API
| Export | Subpath | Purpose |
| --- | --- | --- |
| `createEnvSecrets` | `./adapter` | Build a `SecretsLike` over env. |
| `CreateEnvSecretsOptions` | `./adapter` | Option-bag type. |
| `buildEnvSecretDescriptors` | `./descriptor-builder` | Compact → `SecretDescriptor[]`. |
| `descriptorAllowedKeys` | `./descriptor-builder` | Extract names for `allowedKeys`. |
| `SECRETS_ENV_CODES` | `./error-codes` | Frozen `SECRETS_ENV.*` code registry. |
| `SecretsEnvError` | `./error-codes` | Thrown by every failure mode. |
## Conformance
Passes `runSecretsConformance` from `@suaveplan/api-secrets/testing`
unchanged — see `src/adapter/adapter.spec-conformance.test.ts`.
## License
MIT

View File

@@ -0,0 +1,113 @@
# `adapter`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Stage-4 concrete `SecretsLike` implementation that resolves secret names
against the host process's environment variables. Suitable for local
development, container orchestrators that inject secrets as env vars,
single-host deployments, and anywhere the operator already has tooling
around env injection. Per-plugin allowed-keys enforcement keeps a worker
or untrusted plugin from enumerating or reading any env var beyond what
its manifest declared.
The adapter is structurally a `SecretsLike` from `@suaveplan/api-types`
and passes the `runSecretsConformance` harness from
`@suaveplan/api-secrets/testing` unchanged. The env source is an
injectable function so tests never mutate the real process environment;
in production the default reads from `process.env` overlaid with
`Bun.env` when running under Bun.
## Features
- `createEnvSecrets({ allowedKeys, source?, prefix? }): SecretsLike` —
factory returning a frozen-shaped instance.
- Per-plugin allow-list enforced BEFORE the env source is consulted, so
a non-permitted name never resolves a value regardless of what is in
the environment. Rejections throw `SECRETS_ENV.KEY_NOT_PERMITTED`.
- Optional `prefix` for multi-tenant single-host deployments. `list()`
returns the un-prefixed allowed-keys; the prefix is a lookup detail.
- Injectable `source` function: tests pass a deterministic record;
production defaults to a fresh `process.env`/`Bun.env` snapshot per
call so a rotated value is picked up immediately with no caching.
- Zod-validated options; `SECRETS_ENV.INVALID_OPTIONS` on failure.
- `require()` throws `SECRETS_ENV.KEY_NOT_FOUND` for permitted-but-absent
names; `get()` resolves to `undefined` for parity with the contract.
## Usage: Basic
```ts
import { createEnvSecrets } from "@suaveplan/api-secrets-env/adapter";
const secrets = createEnvSecrets({
allowedKeys: ["STRIPE_SECRET_KEY", "JWT_SIGNING_KEY"],
});
const stripeKey = await secrets.get("STRIPE_SECRET_KEY");
const jwt = await secrets.require("JWT_SIGNING_KEY");
```
## Usage: Advanced
Inject a deterministic source and apply a per-plugin prefix:
```ts
import { createEnvSecrets } from "@suaveplan/api-secrets-env/adapter";
const snapshot = {
BILLING_STRIPE_SECRET_KEY: "sk_test_123",
BILLING_WEBHOOK_SECRET: "whsec_456",
} as const;
const secrets = createEnvSecrets({
allowedKeys: ["STRIPE_SECRET_KEY", "WEBHOOK_SECRET"],
prefix: "BILLING_",
source: () => snapshot,
});
await secrets.require("STRIPE_SECRET_KEY"); // → "sk_test_123"
try {
await secrets.get("OTHER_PLUGIN_KEY");
} catch (err) {
// SECRETS_ENV.KEY_NOT_PERMITTED — not in allow-list.
}
```
## API Reference
- `interface CreateEnvSecretsOptions`
- `allowedKeys: readonly string[]` — required, non-empty.
- `source?: () => Readonly<Record<string, string | undefined>>` —
default: `process.env` overlaid with `Bun.env`.
- `prefix?: string` — default `""`.
- `createEnvSecrets(options): SecretsLike` — throws
`SecretsEnvError(SECRETS_ENV.INVALID_OPTIONS)` on bad options.
- Returned `SecretsLike`:
- `get(name): Promise<string | undefined>` — `KEY_NOT_PERMITTED` if
`name ∉ allowedKeys`; `undefined` if permitted-but-absent.
- `require(name): Promise<string>` — `KEY_NOT_PERMITTED` or
`KEY_NOT_FOUND`.
- `list(): readonly string[]` — frozen copy of `allowedKeys`.
## Implementation Notes
The default source returns a fresh shallow snapshot per call
(`{ ...process.env, ...Bun.env }`) so a rotation between two `get` calls
is visible without any constructor caching. The Bun overlay only runs
when the `Bun` global exists; the env-snapshot type
`Record<string, string | undefined>` matches Node's `process.env` shape
so `value === undefined` after lookup is the only sentinel needed.
`allowedKeys` is frozen and copied at construction; the returned `list()`
hands out the same frozen array on every call so consumers cannot mutate
the surface. The allow-list check runs BEFORE the source lookup so a
non-permitted name never even reads the snapshot — that ordering matters
when the source function is expensive or has side effects.
Zod option-validation runs once at construction; the resulting
`SECRETS_ENV.INVALID_OPTIONS` carries the raw issue list on
`error.context.issues` for upstream diagnostic surfaces.

View File

@@ -0,0 +1,103 @@
# `descriptor-builder`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Convenience builder that turns a compact allow-list declaration into a
fully-typed `SecretDescriptor[]` suitable for embedding in a plugin
manifest's `secrets[]` array. Pairs with `createEnvSecrets` so a plugin's
allowed-keys and manifest descriptors come from the same source of truth
instead of drifting through copy-paste.
The builder accepts either bare name strings (shorthand for
`{ name, required: true }`) or full input rows with `required`,
`description`, and `format` overrides. A second helper extracts just the
names for direct hand-off to `createEnvSecrets`'s `allowedKeys` option.
## Features
- `buildEnvSecretDescriptors(rows)` — accept `string | EnvSecretDescriptorInput`;
defaults `required` to `true` (env deployments fail-fast on missing
declared secrets).
- `descriptorAllowedKeys(descriptors)` — extracts the `name` field list,
returning a frozen array ready for `createEnvSecrets({ allowedKeys })`.
- Output arrays and descriptor objects are frozen so the manifest is
immutable past construction.
## Usage: Basic
```ts
import {
buildEnvSecretDescriptors,
descriptorAllowedKeys,
} from "@suaveplan/api-secrets-env/descriptor-builder";
const descriptors = buildEnvSecretDescriptors([
"STRIPE_SECRET_KEY",
"JWT_SIGNING_KEY",
]);
const allowedKeys = descriptorAllowedKeys(descriptors);
```
## Usage: Advanced
Mix shorthand names with full rows carrying format hints:
```ts
import {
buildEnvSecretDescriptors,
descriptorAllowedKeys,
} from "@suaveplan/api-secrets-env/descriptor-builder";
import { createEnvSecrets } from "@suaveplan/api-secrets-env/adapter";
const descriptors = buildEnvSecretDescriptors([
{
name: "STRIPE_SECRET_KEY",
required: true,
description: "Stripe API server key",
format: "string",
},
{
name: "OPTIONAL_WEBHOOK_KEY",
required: false,
description: "Optional override; falls back to JWT key",
},
"JWT_SIGNING_KEY",
]);
const secrets = createEnvSecrets({
allowedKeys: descriptorAllowedKeys(descriptors),
});
```
## API Reference
- `interface EnvSecretDescriptorInput`
- `name: string`
- `required?: boolean` (default `true`)
- `description?: string`
- `format?: "string" | "base64" | "json"`
- `buildEnvSecretDescriptors(rows: ReadonlyArray<string | EnvSecretDescriptorInput>): readonly SecretDescriptor[]`
— frozen.
- `descriptorAllowedKeys(descriptors: readonly SecretDescriptor[]): readonly string[]`
— frozen.
## Implementation Notes
`required` defaults to `true` because env-var-style deployments treat a
declared-but-missing secret as a hard install error — the alternative
(silent `false`) led to plugins shipping with empty Stripe keys in
historical incidents. Override per row when an env var is genuinely
optional.
The `description` and `format` fields are only included on the output
descriptor when present on the input row. The conditional spread keeps
the output object shape predictable for downstream Zod validation
(`secretDescriptorSchema.strict()` rejects unknown keys but accepts
omitted optional keys). Output arrays and descriptor objects are
`Object.freeze`'d so a downstream caller cannot mutate a manifest after
the fact — the manifest is the authoritative declaration and should be
immutable past build time.

View File

@@ -0,0 +1,91 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Stable error-code registry and dedicated `SuaveplanError` subclass for the
env-backed secrets adapter. Every failure mode the adapter can produce maps
to one of these codes; structured loggers and middleware can dispatch on
either `error.code` or the stable `error.name` (`SecretsEnvError`) without
parsing message strings or relying on `instanceof` chains across module
boundaries.
The codes intentionally namespace under `SECRETS_ENV.*` rather than reusing
`SECRETS.*` (owned by `@suaveplan/api-secrets`). The split lets operators
instantly distinguish env-adapter rejections (allow-list violation,
malformed options, missing key for a permitted name) from upstream
`SecretsLike` contract failures or proxy-boundary failures.
## Features
- `SECRETS_ENV_CODES` — frozen registry built via `defineCodes` from
`@suaveplan/error`. Codes: `KEY_NOT_PERMITTED`, `KEY_NOT_FOUND`,
`INVALID_OPTIONS`.
- `SecretsEnvError` — concrete `SuaveplanError<SecretsEnvCode>` subclass.
Public constructor lets callers throw without subclassing per call site.
- `SECRETS_ENV_ERROR_NAME` — stable `"SecretsEnvError"` literal pinned on
`Error.name` for log aggregation.
- `SecretsEnvCode` — exhaustive union of fully-qualified `SECRETS_ENV.*`
codes for use in switch statements and as the `code` field type.
## Usage: Basic
```ts
import {
SECRETS_ENV_CODES,
SecretsEnvError,
} from "@suaveplan/api-secrets-env/error-codes";
throw new SecretsEnvError(
SECRETS_ENV_CODES.KEY_NOT_PERMITTED,
"error.secrets_env.key_not_permitted",
{ context: { name: "STRIPE_SECRET_KEY" } }
);
```
## Usage: Advanced
Pattern-match on the typed code union for exhaustive handling:
```ts
import {
SECRETS_ENV_CODES,
type SecretsEnvCode,
SecretsEnvError,
} from "@suaveplan/api-secrets-env/error-codes";
function describe(err: SecretsEnvError): string {
const code: SecretsEnvCode = err.code;
switch (code) {
case SECRETS_ENV_CODES.KEY_NOT_PERMITTED:
return "not in plugin manifest";
case SECRETS_ENV_CODES.KEY_NOT_FOUND:
return "permitted but absent from environment";
case SECRETS_ENV_CODES.INVALID_OPTIONS:
return "adapter mis-configured";
}
}
```
## API Reference
- `SECRETS_ENV_CODES: { readonly KEY_NOT_PERMITTED: "SECRETS_ENV.KEY_NOT_PERMITTED"; readonly KEY_NOT_FOUND: "SECRETS_ENV.KEY_NOT_FOUND"; readonly INVALID_OPTIONS: "SECRETS_ENV.INVALID_OPTIONS"; }` — frozen.
- `type SecretsEnvCode = (typeof SECRETS_ENV_CODES)[keyof typeof SECRETS_ENV_CODES]`.
- `SECRETS_ENV_ERROR_NAME: "SecretsEnvError"`.
- `class SecretsEnvError extends SuaveplanError<SecretsEnvCode>` —
public `constructor(code, messageKey, options?)`.
## Implementation Notes
The registry is built with `defineCodes("SECRETS_ENV", [...] as const)` so
the runtime values are strings of the form `"SECRETS_ENV.X"` and the type
is the literal union — letting consumers narrow without runtime checks.
`SecretsEnvError` widens the abstract `SuaveplanError` constructor from
`protected` to `public`; the rationale is identical to `SecretsError` in
`@suaveplan/api-secrets` — throwing at call sites without one-off
subclasses keeps the failure-site code short. The `error.name` pin
happens in the constructor so even hand-crafted throwables surface the
canonical name to structured loggers.