Initial Commit
This commit is contained in:
134
packages/api-stack/api/api-secrets-env/README.md
Normal file
134
packages/api-stack/api/api-secrets-env/README.md
Normal 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
|
||||
|
||||
76
packages/api-stack/api/api-secrets-env/package.json
Normal file
76
packages/api-stack/api/api-secrets-env/package.json
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "@suaveplan/api-secrets-env",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Stage-4 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.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./adapter": {
|
||||
"import": {
|
||||
"types": "./dist/adapter/adapter.d.ts",
|
||||
"default": "./dist/adapter/adapter.js"
|
||||
}
|
||||
},
|
||||
"./descriptor-builder": {
|
||||
"import": {
|
||||
"types": "./dist/descriptor-builder/descriptor-builder.d.ts",
|
||||
"default": "./dist/descriptor-builder/descriptor-builder.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/api-secrets": "workspace:^",
|
||||
"@suaveplan/api-types": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
113
packages/api-stack/api/api-secrets-env/src/adapter/adapter.md
Normal file
113
packages/api-stack/api/api-secrets-env/src/adapter/adapter.md
Normal 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.
|
||||
@@ -0,0 +1,41 @@
|
||||
// NOTE: import from source not dist — api-secrets's dist bundles vitest
|
||||
// internals (pre-existing build-config bug; tracked upstream). Source
|
||||
// import goes through workspace TS resolution and uses our local vitest.
|
||||
import { runSecretsConformance } from "../../../api-secrets/src/testing/conformance.js";
|
||||
import { SECRETS_ENV_CODES } from "../error-codes/error-codes.js";
|
||||
import { createEnvSecrets } from "./adapter.js";
|
||||
|
||||
runSecretsConformance({
|
||||
label: "env-direct",
|
||||
factory: (seed) => {
|
||||
// Allow every seeded name plus the harness's "MISSING" probe so
|
||||
// the not-found scenarios exercise our `KEY_NOT_FOUND` path rather
|
||||
// than the allow-list rejection.
|
||||
const seededNames = Object.keys(seed);
|
||||
const allowedKeys =
|
||||
seededNames.length === 0
|
||||
? ["MISSING"]
|
||||
: [...seededNames, "MISSING"];
|
||||
const snapshot: Record<string, string | undefined> = { ...seed };
|
||||
const secrets = createEnvSecrets({
|
||||
allowedKeys,
|
||||
source: () => snapshot,
|
||||
});
|
||||
return {
|
||||
secrets: {
|
||||
get: (name) => secrets.get(name),
|
||||
require: (name) => secrets.require(name),
|
||||
// The harness verifies `list()` returns exactly the seeded
|
||||
// names. Filter out the "MISSING" probe we added solely to
|
||||
// route the not-found scenarios through KEY_NOT_FOUND.
|
||||
list: () =>
|
||||
secrets.list().filter((n) => n !== "MISSING" || n in seed),
|
||||
},
|
||||
};
|
||||
},
|
||||
isNotFound: (err) =>
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"code" in err &&
|
||||
(err as { code: unknown }).code === SECRETS_ENV_CODES.KEY_NOT_FOUND,
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SECRETS_ENV_CODES,
|
||||
SecretsEnvError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { __setBunGlobalProbeForTest, createEnvSecrets } from "./adapter.js";
|
||||
|
||||
const fixed =
|
||||
(
|
||||
record: Record<string, string | undefined>
|
||||
): (() => Readonly<Record<string, string | undefined>>) =>
|
||||
() =>
|
||||
record;
|
||||
|
||||
describe("createEnvSecrets — option validation", () => {
|
||||
it("rejects empty allowedKeys", { timeout: 5000 }, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
createEnvSecrets({ allowedKeys: [], source: fixed({}) });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SecretsEnvError);
|
||||
expect((caught as SecretsEnvError).code).toBe(
|
||||
SECRETS_ENV_CODES.INVALID_OPTIONS
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an empty-string key", { timeout: 5000 }, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
createEnvSecrets({ allowedKeys: [""], source: fixed({}) });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SecretsEnvError);
|
||||
expect((caught as SecretsEnvError).code).toBe(
|
||||
SECRETS_ENV_CODES.INVALID_OPTIONS
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createEnvSecrets — allow-list", () => {
|
||||
it("get rejects a non-permitted name BEFORE source lookup", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let sourceCalls = 0;
|
||||
const secrets = createEnvSecrets({
|
||||
allowedKeys: ["A"],
|
||||
source: () => {
|
||||
sourceCalls += 1;
|
||||
return { A: "1", B: "2" };
|
||||
},
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await secrets.get("B");
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SecretsEnvError);
|
||||
expect((caught as SecretsEnvError).code).toBe(
|
||||
SECRETS_ENV_CODES.KEY_NOT_PERMITTED
|
||||
);
|
||||
expect(sourceCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("require rejects a non-permitted name", { timeout: 5000 }, async () => {
|
||||
const secrets = createEnvSecrets({
|
||||
allowedKeys: ["A"],
|
||||
source: fixed({ B: "2" }),
|
||||
});
|
||||
await expect(secrets.require("B")).rejects.toMatchObject({
|
||||
code: SECRETS_ENV_CODES.KEY_NOT_PERMITTED,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createEnvSecrets — lookup", () => {
|
||||
it("get resolves a present permitted name", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const secrets = createEnvSecrets({
|
||||
allowedKeys: ["FOO"],
|
||||
source: fixed({ FOO: "bar" }),
|
||||
});
|
||||
await expect(secrets.get("FOO")).resolves.toBe("bar");
|
||||
});
|
||||
|
||||
it("get resolves to undefined for a permitted-but-absent name", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const secrets = createEnvSecrets({
|
||||
allowedKeys: ["FOO"],
|
||||
source: fixed({}),
|
||||
});
|
||||
await expect(secrets.get("FOO")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("require throws KEY_NOT_FOUND for permitted-but-absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const secrets = createEnvSecrets({
|
||||
allowedKeys: ["FOO"],
|
||||
source: fixed({}),
|
||||
});
|
||||
await expect(secrets.require("FOO")).rejects.toMatchObject({
|
||||
code: SECRETS_ENV_CODES.KEY_NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies prefix on every lookup", { timeout: 5000 }, async () => {
|
||||
const secrets = createEnvSecrets({
|
||||
allowedKeys: ["API_KEY"],
|
||||
prefix: "BILLING_",
|
||||
source: fixed({ BILLING_API_KEY: "sk_test_123" }),
|
||||
});
|
||||
await expect(secrets.require("API_KEY")).resolves.toBe("sk_test_123");
|
||||
});
|
||||
|
||||
it("list returns the frozen allowedKeys (not source contents)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const secrets = createEnvSecrets({
|
||||
allowedKeys: ["A", "B"],
|
||||
source: fixed({ A: "1", B: "2", LEAKED: "x" }),
|
||||
});
|
||||
const names = [...secrets.list()].sort();
|
||||
expect(names).toEqual(["A", "B"]);
|
||||
expect(Object.isFrozen(secrets.list())).toBe(true);
|
||||
});
|
||||
|
||||
it("picks up rotated env values across calls (no cache)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let value = "before";
|
||||
const secrets = createEnvSecrets({
|
||||
allowedKeys: ["K"],
|
||||
source: () => ({ K: value }),
|
||||
});
|
||||
await expect(secrets.get("K")).resolves.toBe("before");
|
||||
value = "after";
|
||||
await expect(secrets.get("K")).resolves.toBe("after");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createEnvSecrets — default source", () => {
|
||||
it("reads from process.env when no source supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const key = "SECRETS_ENV_TEST_VAR_2026";
|
||||
process.env[key] = "real-value";
|
||||
try {
|
||||
const secrets = createEnvSecrets({ allowedKeys: [key] });
|
||||
await expect(secrets.get(key)).resolves.toBe("real-value");
|
||||
} finally {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
it("overlays Bun.env on process.env when both present", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const key = "SECRETS_ENV_BUN_OVERLAY_2026";
|
||||
process.env[key] = "from-process";
|
||||
// Bun publishes globalThis.Bun as configurable: false — direct
|
||||
// mutation / Object.defineProperty throw under a real Bun
|
||||
// runtime. Inject through the dedicated probe hook instead.
|
||||
try {
|
||||
__setBunGlobalProbeForTest(() => ({
|
||||
env: { [key]: "from-bun" },
|
||||
}));
|
||||
const secrets = createEnvSecrets({ allowedKeys: [key] });
|
||||
await expect(secrets.get(key)).resolves.toBe("from-bun");
|
||||
} finally {
|
||||
__setBunGlobalProbeForTest(undefined);
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to process.env when Bun.env is absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const key = "SECRETS_ENV_NO_BUN_2026";
|
||||
process.env[key] = "process-only";
|
||||
try {
|
||||
// Probe returns a Bun-like object whose `env` is absent —
|
||||
// forces the no-Bun.env branch even under a real Bun runtime.
|
||||
__setBunGlobalProbeForTest(() => ({}));
|
||||
const secrets = createEnvSecrets({ allowedKeys: [key] });
|
||||
await expect(secrets.get(key)).resolves.toBe("process-only");
|
||||
} finally {
|
||||
__setBunGlobalProbeForTest(undefined);
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to process.env when globalThis.Bun is undefined", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const key = "SECRETS_ENV_BUN_UNDEFINED_2026";
|
||||
process.env[key] = "process-undefined";
|
||||
try {
|
||||
// Probe returns undefined — pins the false branch of the
|
||||
// `typeof bunGlobal === "object" && bunGlobal !== null`
|
||||
// guard inside defaultSource (Bun-less Node environment).
|
||||
__setBunGlobalProbeForTest(() => undefined);
|
||||
const secrets = createEnvSecrets({ allowedKeys: [key] });
|
||||
await expect(secrets.get(key)).resolves.toBe("process-undefined");
|
||||
} finally {
|
||||
__setBunGlobalProbeForTest(undefined);
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to process.env when globalThis.Bun is null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const key = "SECRETS_ENV_BUN_NULL_2026";
|
||||
process.env[key] = "process-null";
|
||||
try {
|
||||
// Probe returns null — typeof null is "object" so the first
|
||||
// conjunct passes; the `!== null` guard forces the fallback.
|
||||
__setBunGlobalProbeForTest(() => null);
|
||||
const secrets = createEnvSecrets({ allowedKeys: [key] });
|
||||
await expect(secrets.get(key)).resolves.toBe("process-null");
|
||||
} finally {
|
||||
__setBunGlobalProbeForTest(undefined);
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
});
|
||||
143
packages/api-stack/api/api-secrets-env/src/adapter/adapter.ts
Normal file
143
packages/api-stack/api/api-secrets-env/src/adapter/adapter.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { SecretsLike } from "@suaveplan/api-types/secrets";
|
||||
import z from "zod";
|
||||
import {
|
||||
SECRETS_ENV_CODES,
|
||||
SecretsEnvError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Construction options for {@link createEnvSecrets}.
|
||||
*
|
||||
* - `allowedKeys` — per-plugin whitelist of permitted secret names.
|
||||
* Required and non-empty; `get`/`require` reject any name not in this
|
||||
* set with `SECRETS_ENV.KEY_NOT_PERMITTED` BEFORE consulting the env
|
||||
* source. The list is also what `list()` enumerates so the worker
|
||||
* surface cannot discover names beyond the plugin's manifest.
|
||||
* - `source` — injectable env-snapshot function. Default reads from
|
||||
* `process.env` overlaid with `Bun.env`. Tests supply a deterministic
|
||||
* record so they never mutate the real environment.
|
||||
* - `prefix` — optional string prepended to every looked-up name. Use to
|
||||
* namespace a plugin's env vars on a multi-tenant host (e.g.
|
||||
* `prefix: "STRIPE_"` makes `get("API_KEY")` read `STRIPE_API_KEY`).
|
||||
* Empty string disables prefixing. `list()` always returns the
|
||||
* un-prefixed allowed-keys — the prefix is an internal lookup detail.
|
||||
*/
|
||||
export interface CreateEnvSecretsOptions {
|
||||
readonly allowedKeys: readonly string[];
|
||||
readonly source?: () => Readonly<Record<string, string | undefined>>;
|
||||
readonly prefix?: string;
|
||||
}
|
||||
|
||||
const optionsSchema = z.object({
|
||||
allowedKeys: z.array(z.string().min(1)).min(1),
|
||||
source: z.function().optional(),
|
||||
prefix: z.string().optional(),
|
||||
});
|
||||
|
||||
const defaultBunGlobalProbe = (): unknown =>
|
||||
(globalThis as { Bun?: unknown }).Bun;
|
||||
let bunGlobalProbe: () => unknown = defaultBunGlobalProbe;
|
||||
|
||||
/**
|
||||
* Test-only hook: replace the `globalThis.Bun` probe used by
|
||||
* {@link defaultSource}. Bun publishes `globalThis.Bun` as
|
||||
* `configurable: false, writable: false`, so direct mutation /
|
||||
* `Object.defineProperty` throws under a real Bun runtime. Tests inject
|
||||
* a probe that returns whatever shape is needed (e.g. `{ env: {...} }`,
|
||||
* `{}` to force the no-Bun.env branch). Pass `undefined` to restore the
|
||||
* default `globalThis.Bun` probe. Not part of the public surface.
|
||||
*/
|
||||
export function __setBunGlobalProbeForTest(
|
||||
probe: (() => unknown) | undefined
|
||||
): void {
|
||||
bunGlobalProbe = probe ?? defaultBunGlobalProbe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default env-snapshot function. Reads `process.env` overlaid with
|
||||
* `Bun.env` (when running under Bun) so values present in both win from
|
||||
* Bun. Each call returns a fresh snapshot so rotated env vars between
|
||||
* `get` calls are picked up without a constructor cache.
|
||||
*/
|
||||
function defaultSource(): Readonly<Record<string, string | undefined>> {
|
||||
const bunGlobal = bunGlobalProbe() as
|
||||
| { env?: Record<string, string | undefined> }
|
||||
| undefined
|
||||
| null;
|
||||
const bunEnv =
|
||||
typeof bunGlobal === "object" && bunGlobal !== null
|
||||
? bunGlobal.env
|
||||
: undefined;
|
||||
if (bunEnv === undefined) {
|
||||
return { ...process.env };
|
||||
}
|
||||
return { ...process.env, ...bunEnv };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a {@link SecretsLike} backed by an environment-variable source
|
||||
* with per-plugin allowed-keys enforcement. The returned instance:
|
||||
*
|
||||
* 1. validates options with Zod and throws
|
||||
* `SECRETS_ENV.INVALID_OPTIONS` on failure;
|
||||
* 2. rejects any `get`/`require` whose name is not in `allowedKeys` with
|
||||
* `SECRETS_ENV.KEY_NOT_PERMITTED` BEFORE touching the env source;
|
||||
* 3. resolves `get` to `undefined` when a permitted name is absent;
|
||||
* 4. throws `SECRETS_ENV.KEY_NOT_FOUND` from `require` when a permitted
|
||||
* name is absent;
|
||||
* 5. returns a frozen copy of `allowedKeys` from `list()` so the surface
|
||||
* never enumerates names outside the plugin's manifest.
|
||||
*/
|
||||
export function createEnvSecrets(
|
||||
options: CreateEnvSecretsOptions
|
||||
): SecretsLike {
|
||||
const parsed = optionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new SecretsEnvError(
|
||||
SECRETS_ENV_CODES.INVALID_OPTIONS,
|
||||
"error.secrets_env.invalid_options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const allowedKeys = Object.freeze([...options.allowedKeys]);
|
||||
const allowSet = new Set<string>(allowedKeys);
|
||||
const source = options.source ?? defaultSource;
|
||||
const prefix = options.prefix ?? "";
|
||||
|
||||
const assertPermitted = (name: string): void => {
|
||||
if (!allowSet.has(name)) {
|
||||
throw new SecretsEnvError(
|
||||
SECRETS_ENV_CODES.KEY_NOT_PERMITTED,
|
||||
"error.secrets_env.key_not_permitted",
|
||||
{ context: { name } }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const lookup = (name: string): string | undefined => {
|
||||
const snapshot = source();
|
||||
return snapshot[`${prefix}${name}`];
|
||||
};
|
||||
|
||||
return {
|
||||
get(name: string): Promise<string | undefined> {
|
||||
assertPermitted(name);
|
||||
return Promise.resolve(lookup(name));
|
||||
},
|
||||
async require(name: string): Promise<string> {
|
||||
assertPermitted(name);
|
||||
const value = lookup(name);
|
||||
if (value === undefined) {
|
||||
throw new SecretsEnvError(
|
||||
SECRETS_ENV_CODES.KEY_NOT_FOUND,
|
||||
"error.secrets_env.key_not_found",
|
||||
{ context: { name } }
|
||||
);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
list(): readonly string[] {
|
||||
return allowedKeys;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
__setBunGlobalProbeForTest,
|
||||
type CreateEnvSecretsOptions,
|
||||
createEnvSecrets,
|
||||
} from "./adapter.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { secretDescriptorListSchema } from "../../../api-secrets/src/descriptor/descriptor.js";
|
||||
import {
|
||||
buildEnvSecretDescriptors,
|
||||
descriptorAllowedKeys,
|
||||
} from "./descriptor-builder.js";
|
||||
|
||||
describe("buildEnvSecretDescriptors", () => {
|
||||
it("expands bare-string rows to required descriptors", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = buildEnvSecretDescriptors(["A", "B"]);
|
||||
expect(out).toEqual([
|
||||
{ name: "A", required: true },
|
||||
{ name: "B", required: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards required override, description, and format", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = buildEnvSecretDescriptors([
|
||||
{
|
||||
name: "FOO",
|
||||
required: false,
|
||||
description: "optional",
|
||||
format: "base64",
|
||||
},
|
||||
]);
|
||||
expect(out[0]).toEqual({
|
||||
name: "FOO",
|
||||
required: false,
|
||||
description: "optional",
|
||||
format: "base64",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults required to true on input rows", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = buildEnvSecretDescriptors([{ name: "FOO" }]);
|
||||
expect(out[0]).toEqual({ name: "FOO", required: true });
|
||||
});
|
||||
|
||||
it("omits absent optional fields from the descriptor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = buildEnvSecretDescriptors([{ name: "FOO" }]);
|
||||
expect(Object.keys(out[0]).sort()).toEqual(["name", "required"]);
|
||||
});
|
||||
|
||||
it("freezes the output array and rows", { timeout: 5000 }, () => {
|
||||
const out = buildEnvSecretDescriptors(["A"]);
|
||||
expect(Object.isFrozen(out)).toBe(true);
|
||||
expect(Object.isFrozen(out[0])).toBe(true);
|
||||
});
|
||||
|
||||
it("produces output that validates under secretDescriptorListSchema", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = buildEnvSecretDescriptors([
|
||||
"BARE",
|
||||
{
|
||||
name: "RICH",
|
||||
required: false,
|
||||
description: "optional",
|
||||
format: "json",
|
||||
},
|
||||
]);
|
||||
// Schema is `.strict()` and rejects unknown keys, so this asserts
|
||||
// the builder produces no stray properties.
|
||||
expect(() => secretDescriptorListSchema.parse(out)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("descriptorAllowedKeys", () => {
|
||||
it("extracts the name field in order", { timeout: 5000 }, () => {
|
||||
const out = buildEnvSecretDescriptors(["A", "B", "C"]);
|
||||
expect(descriptorAllowedKeys(out)).toEqual(["A", "B", "C"]);
|
||||
});
|
||||
|
||||
it("returns a frozen array", { timeout: 5000 }, () => {
|
||||
const out = buildEnvSecretDescriptors(["A"]);
|
||||
const keys = descriptorAllowedKeys(out);
|
||||
expect(Object.isFrozen(keys)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { SecretDescriptor } from "@suaveplan/api-secrets/descriptor";
|
||||
|
||||
/**
|
||||
* Input row for {@link buildEnvSecretDescriptors}. The `name` becomes the
|
||||
* descriptor's lookup name; `required` defaults to `true` because env-var
|
||||
* deployments treat omitted-but-needed secrets as a fail-fast install
|
||||
* error; `description` and `format` are forwarded verbatim.
|
||||
*/
|
||||
export interface EnvSecretDescriptorInput {
|
||||
readonly name: string;
|
||||
readonly required?: boolean;
|
||||
readonly description?: string;
|
||||
readonly format?: "string" | "base64" | "json";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `SecretDescriptor[]` for an env-backed plugin from a compact
|
||||
* allow-list. Accepts either bare strings (shorthand for
|
||||
* `{ name, required: true }`) or full input rows. Useful when the plugin's
|
||||
* `allowedKeys` and manifest `secrets[]` are derived from the same source
|
||||
* of truth.
|
||||
*
|
||||
* The returned array is frozen at the top level so callers can ship it to
|
||||
* the manifest without worrying about downstream mutation; the descriptor
|
||||
* objects inside are also frozen.
|
||||
*/
|
||||
export function buildEnvSecretDescriptors(
|
||||
rows: ReadonlyArray<string | EnvSecretDescriptorInput>
|
||||
): readonly SecretDescriptor[] {
|
||||
const out: SecretDescriptor[] = [];
|
||||
for (const row of rows) {
|
||||
if (typeof row === "string") {
|
||||
out.push(Object.freeze({ name: row, required: true }));
|
||||
continue;
|
||||
}
|
||||
const required = row.required ?? true;
|
||||
const desc: SecretDescriptor = {
|
||||
name: row.name,
|
||||
required,
|
||||
...(row.description === undefined
|
||||
? {}
|
||||
: { description: row.description }),
|
||||
...(row.format === undefined ? {} : { format: row.format }),
|
||||
};
|
||||
out.push(Object.freeze(desc));
|
||||
}
|
||||
return Object.freeze(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract just the `name` field from a descriptor list — the shape
|
||||
* {@link createEnvSecrets}'s `allowedKeys` expects. Pairs with
|
||||
* {@link buildEnvSecretDescriptors} so a single declaration drives both
|
||||
* the manifest `secrets[]` and the adapter's allow-list.
|
||||
*/
|
||||
export function descriptorAllowedKeys(
|
||||
descriptors: readonly SecretDescriptor[]
|
||||
): readonly string[] {
|
||||
return Object.freeze(descriptors.map((d) => d.name));
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
buildEnvSecretDescriptors,
|
||||
descriptorAllowedKeys,
|
||||
type EnvSecretDescriptorInput,
|
||||
} from "./descriptor-builder.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SECRETS_ENV_CODES,
|
||||
SECRETS_ENV_ERROR_NAME,
|
||||
SecretsEnvError,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("SECRETS_ENV_CODES registry", () => {
|
||||
it("exposes the three documented codes", { timeout: 5000 }, () => {
|
||||
expect(SECRETS_ENV_CODES).toEqual({
|
||||
KEY_NOT_PERMITTED: "SECRETS_ENV.KEY_NOT_PERMITTED",
|
||||
KEY_NOT_FOUND: "SECRETS_ENV.KEY_NOT_FOUND",
|
||||
INVALID_OPTIONS: "SECRETS_ENV.INVALID_OPTIONS",
|
||||
});
|
||||
});
|
||||
|
||||
it("is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(SECRETS_ENV_CODES)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SecretsEnvError", () => {
|
||||
it("pins the canonical name", { timeout: 5000 }, () => {
|
||||
const err = new SecretsEnvError(
|
||||
SECRETS_ENV_CODES.KEY_NOT_PERMITTED,
|
||||
"error.x"
|
||||
);
|
||||
expect(err.name).toBe(SECRETS_ENV_ERROR_NAME);
|
||||
});
|
||||
|
||||
it("carries the code", { timeout: 5000 }, () => {
|
||||
const err = new SecretsEnvError(
|
||||
SECRETS_ENV_CODES.KEY_NOT_FOUND,
|
||||
"error.x",
|
||||
{ context: { name: "FOO" } }
|
||||
);
|
||||
expect(err.code).toBe("SECRETS_ENV.KEY_NOT_FOUND");
|
||||
expect(err.context).toEqual({ name: "FOO" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Frozen registry of error codes thrown by the env-backed `SecretsLike`
|
||||
* adapter. Distinct from upstream `SECRETS.*` (owned by
|
||||
* `@suaveplan/api-secrets`) so operators can tell at a glance whether the
|
||||
* failure originated in the env adapter's allow-list enforcement versus
|
||||
* the underlying source contract.
|
||||
*
|
||||
* - `KEY_NOT_PERMITTED` — the requested name is not in the per-plugin
|
||||
* allowed-keys whitelist. The whitelist is enforced BEFORE the lookup
|
||||
* reaches the env source so a non-permitted name never resolves a value,
|
||||
* regardless of what is present in the process environment.
|
||||
* - `KEY_NOT_FOUND` — the key IS permitted but the environment has no
|
||||
* value for it. Surfaced from `require()`; `get()` returns `undefined`
|
||||
* for parity with the `SecretsLike` contract.
|
||||
* - `INVALID_OPTIONS` — the construction options failed Zod validation
|
||||
* (empty `allowedKeys`, malformed `prefix`, etc).
|
||||
*/
|
||||
export const SECRETS_ENV_CODES = defineCodes("SECRETS_ENV", [
|
||||
"KEY_NOT_PERMITTED",
|
||||
"KEY_NOT_FOUND",
|
||||
"INVALID_OPTIONS",
|
||||
] as const);
|
||||
|
||||
/**
|
||||
* Union of fully-qualified `SECRETS_ENV.*` codes. Use as the `code` field
|
||||
* type on a `SecretsEnvError`-typed throwable.
|
||||
*/
|
||||
export type SecretsEnvCode =
|
||||
(typeof SECRETS_ENV_CODES)[keyof typeof SECRETS_ENV_CODES];
|
||||
|
||||
/**
|
||||
* Stable `error.name` value for every throwable this package produces.
|
||||
* Structured loggers group on `name` for cross-code aggregation.
|
||||
*/
|
||||
export const SECRETS_ENV_ERROR_NAME = "SecretsEnvError" as const;
|
||||
|
||||
/**
|
||||
* Concrete `SuaveplanError` subclass for env-adapter failures. Mirrors the
|
||||
* shape of `SecretsError` / `SecretsProxyError` so consumers can
|
||||
* `instanceof`-check or code-match without parsing message strings.
|
||||
*/
|
||||
export class SecretsEnvError extends SuaveplanError<SecretsEnvCode> {
|
||||
public constructor(
|
||||
code: SecretsEnvCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = SECRETS_ENV_ERROR_NAME;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
SECRETS_ENV_CODES,
|
||||
SECRETS_ENV_ERROR_NAME,
|
||||
type SecretsEnvCode,
|
||||
SecretsEnvError,
|
||||
} from "./error-codes.js";
|
||||
23
packages/api-stack/api/api-secrets-env/src/index.ts
Normal file
23
packages/api-stack/api/api-secrets-env/src/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* `@suaveplan/api-secrets-env` — root barrel.
|
||||
*
|
||||
* Stage-4 `SecretsLike` adapter backed by `process.env` / `Bun.env`.
|
||||
* Per-plugin allowed-keys enforcement keeps each plugin scoped to its
|
||||
* own manifest-declared secrets.
|
||||
*/
|
||||
|
||||
export {
|
||||
type CreateEnvSecretsOptions,
|
||||
createEnvSecrets,
|
||||
} from "./adapter/index.js";
|
||||
export {
|
||||
buildEnvSecretDescriptors,
|
||||
descriptorAllowedKeys,
|
||||
type EnvSecretDescriptorInput,
|
||||
} from "./descriptor-builder/index.js";
|
||||
export {
|
||||
SECRETS_ENV_CODES,
|
||||
SECRETS_ENV_ERROR_NAME,
|
||||
type SecretsEnvCode,
|
||||
SecretsEnvError,
|
||||
} from "./error-codes/index.js";
|
||||
12
packages/api-stack/api/api-secrets-env/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-secrets-env/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
30
packages/api-stack/api/api-secrets-env/vite.config.ts
Normal file
30
packages/api-stack/api/api-secrets-env/vite.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../../node_modules/.vite/packages/api-secrets-env",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [/^@suaveplan\//, /^node:/, "tslib", "zod"],
|
||||
},
|
||||
},
|
||||
});
|
||||
29
packages/api-stack/api/api-secrets-env/vitest.config.ts
Normal file
29
packages/api-stack/api/api-secrets-env/vitest.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
include: ["src/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/index.ts",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user