first commit
This commit is contained in:
71
docs/api-stack/api/api-secrets/README.md
Normal file
71
docs/api-stack/api/api-secrets/README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# @suaveplan/api-secrets - Abstract Secrets contract for the Suaveplan API plugin system — per-plugin secret access typed against manifest secrets[], canonical redaction marker, conformance harness. Concrete adapters (env/Bun.secrets/vault) live in sibling packages.
|
||||
|
||||
Abstract Secrets contract for the Suaveplan API plugin system — per-plugin secret access typed against `manifest.secrets[]`, canonical redaction marker, frozen `SECRETS.*` error-code registry, capability-id pins, and the conformance harness every concrete source must pass. api-plugin-foundation umbrella; concrete sources land (`api-secrets-env`, `api-secrets-vault`) and Stage 8 (`api-secrets-bun`).
|
||||
|
||||
The canonical `SecretsLike` type is owned by `@suaveplan/api-types/secrets`; this package re-exports it and supplies the runtime surface every consumer needs.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [capability-id](./src/capability-id/capability-id.md) — This module pins the two literal capability ids the api-secrets family contributes to the plugin-host `CapabilityId` un…
|
||||
- [capability-id](./src/capability-id/capability-id.md) — This module pins the two literal capability ids the api-secrets family contributes to the plugin-host `CapabilityId` un…
|
||||
- [descriptor](./src/descriptor/descriptor.md) — `SecretDescriptor` is the manifest-side declaration of a secret a plugin needs. The host iterates the manifest's `secre…
|
||||
- [descriptor](./src/descriptor/descriptor.md) — `SecretDescriptor` is the manifest-side declaration of a secret a plugin needs. The host iterates the manifest's `secre…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `SECRETS_CODES` is the frozen registry of fully-qualified error codes the API plugin system throws when a plugin tr…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `SECRETS_CODES` is the frozen registry of fully-qualified error codes the API plugin system throws when a plugin tr…
|
||||
- [redaction](./src/redaction/redaction.md) — This module pins the single literal string every logger, audit sink, and OTel exporter in the API plugin system use…
|
||||
- [redaction](./src/redaction/redaction.md) — This module pins the single literal string every logger, audit sink, and OTel exporter in the API plugin system use…
|
||||
- [secrets-like](./src/secrets-like/secrets-like.md) — This module is the runtime entry point for the abstract Secrets contract. The canonical `SecretsLike` interface — `get(…
|
||||
- [secrets-like](./src/secrets-like/secrets-like.md) — This module is the runtime entry point for the abstract Secrets contract. The canonical `SecretsLike` interface — `get(…
|
||||
- [testing](./src/testing/conformance.md) — `runSecretsConformance` is the reusable Vitest suite every concrete `SecretsLike` implementation in the api-secrets fam…
|
||||
- [conformance](./src/testing/conformance.md) — `runSecretsConformance` is the reusable Vitest suite every concrete `SecretsLike` implementation in the api-secrets fam…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Subpath modules
|
||||
|
||||
| Subpath | Purpose | Docs |
|
||||
| --- | --- | --- |
|
||||
| `./capability-id` | `SECRETS_CAPABILITY_ID` (`"secrets"`) + `SECRETS_BUN_CAPABILITY_ID` (`"secrets.bun"`) reservation. | [capability-id.md](./src/capability-id/capability-id.md) |
|
||||
| `./descriptor` | `SecretDescriptor` interface + strict Zod schemas (`secretDescriptorSchema`, `secretDescriptorListSchema`). | [descriptor.md](./src/descriptor/descriptor.md) |
|
||||
| `./error-codes` | `SECRETS_CODES` frozen registry + `SecretsError` concrete subclass. | [error-codes.md](./src/error-codes/error-codes.md) |
|
||||
| `./redaction` | `REDACTED_MARKER` literal + `redactSecret` helper. | [redaction.md](./src/redaction/redaction.md) |
|
||||
| `./secrets-like` | Re-export of `SecretsLike` + `checkRequiredSecrets` / `enforceRequiredSecrets` host helpers. | [secrets-like.md](./src/secrets-like/secrets-like.md) |
|
||||
| `./testing` | `runSecretsConformance` reusable suite for every concrete source. | [conformance.md](./src/testing/conformance.md) |
|
||||
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
bun add @suaveplan/api-secrets
|
||||
```
|
||||
|
||||
## Quick example
|
||||
|
||||
```ts
|
||||
import {
|
||||
SECRETS_CAPABILITY_ID,
|
||||
enforceRequiredSecrets,
|
||||
secretDescriptorListSchema,
|
||||
} from "@suaveplan/api-secrets";
|
||||
|
||||
const descriptors = secretDescriptorListSchema.parse(manifest.secrets);
|
||||
await enforceRequiredSecrets(ctx.secrets, descriptors);
|
||||
|
||||
if (manifest.requiredCapabilities.includes(SECRETS_CAPABILITY_ID)) {
|
||||
// ctx.secrets is guaranteed populated at this point
|
||||
}
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
Runtime: `@suaveplan/api-types`, `@suaveplan/error`, `tslib`, `zod`.
|
||||
|
||||
## Verification
|
||||
|
||||
- `bunx turbo lint typecheck test --filter=@suaveplan/api-secrets` — green.
|
||||
- `bunx turbo test:coverage --filter=@suaveplan/api-secrets` — 100/100/100/100.
|
||||
- `bunx turbo build --filter=@suaveplan/api-secrets` — green.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# `capability-id` — `secrets` and `secrets.bun` constants
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module pins the two literal capability ids the api-secrets family contributes to the plugin-host `CapabilityId` union: `"secrets"` (the abstract Secrets capability, always provided by Stage-4 adapters) and `"secrets.bun"` (reserved here so plugin manifests can declare `requiredCapabilities: ["secrets.bun"]` at type-check time before The `api-secrets-bun` adapter ships). The constants are typed via `satisfies CapabilityId`, so if the union in `@suaveplan/api-types` ever drops either id this package's typecheck fails — surfacing a contract regression at the boundary instead of letting it slip into production.
|
||||
|
||||
## Features
|
||||
|
||||
- `SECRETS_CAPABILITY_ID` — typed as the literal `"secrets"`.
|
||||
- `SECRETS_BUN_CAPABILITY_ID` — typed as the literal `"secrets.bun"`.
|
||||
- Compile-time invariant: both literals must remain assignable to `CapabilityId`.
|
||||
- Re-export of `CapabilityId` for downstream packages importing solely from `api-secrets`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SECRETS_CAPABILITY_ID } from "@suaveplan/api-secrets/capability-id";
|
||||
|
||||
const requirements: readonly CapabilityId[] = [SECRETS_CAPABILITY_ID];
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Encode a plugin manifest's required capabilities as a literal-tuple so consumers downstream get autocompletion:
|
||||
|
||||
```ts
|
||||
import {
|
||||
SECRETS_BUN_CAPABILITY_ID,
|
||||
SECRETS_CAPABILITY_ID,
|
||||
type CapabilityId,
|
||||
} from "@suaveplan/api-secrets/capability-id";
|
||||
|
||||
const manifest = {
|
||||
name: "@example/billing",
|
||||
requiredCapabilities: [SECRETS_CAPABILITY_ID] as const,
|
||||
optionalCapabilities: [SECRETS_BUN_CAPABILITY_ID] as const,
|
||||
} satisfies {
|
||||
name: string;
|
||||
requiredCapabilities: readonly CapabilityId[];
|
||||
optionalCapabilities: readonly CapabilityId[];
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type CapabilityId` — re-exported from `@suaveplan/api-types/capabilities`.
|
||||
- `const SECRETS_CAPABILITY_ID: "secrets"`.
|
||||
- `const SECRETS_BUN_CAPABILITY_ID: "secrets.bun"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Both constants use `as const satisfies CapabilityId` so the value type narrows to the literal AND the assignment-to-union invariant is checked. If a future api-types revision removes either id, this module fails to compile — the spec-conformance test in this folder then surfaces a clear error rather than a vague "literal not assignable". The `secrets.bun` id is reserved before its adapter exists specifically so plugin authors writing for the Bun deployment can ship manifests today; the host's capability resolver simply refuses to inject `ctx.secrets.bun` until The adapter is registered.
|
||||
71
docs/api-stack/api/api-secrets/src/descriptor/descriptor.md
Normal file
71
docs/api-stack/api/api-secrets/src/descriptor/descriptor.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# `descriptor` — `SecretDescriptor` and Zod schema
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SecretDescriptor` is the manifest-side declaration of a secret a plugin needs. The host iterates the manifest's `secrets[]` to populate the typed `SecretsLike` it hands the plugin, to enforce required-secret presence before transitioning from `installing → installed`, and to surface human-readable descriptions in the admin UI. `secretDescriptorSchema` is the canonical Zod schema for those entries; it rejects unknown keys so a typo like `requried: true` fails at manifest-load time rather than as a silently-ignored field.
|
||||
|
||||
## Features
|
||||
|
||||
- `SecretDescriptor` interface with `name`, `required`, optional `description`, optional `format` (`"string" | "base64" | "json"`).
|
||||
- Strict Zod schema rejecting unknown keys.
|
||||
- Convenience `secretDescriptorListSchema` for whole `secrets[]` arrays.
|
||||
- `format` hint adapters can validate against and throw `SECRETS.SECRET_INVALID_FORMAT` on mismatch.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { secretDescriptorSchema } from "@suaveplan/api-secrets/descriptor";
|
||||
|
||||
const desc = secretDescriptorSchema.parse({
|
||||
name: "STRIPE_API_KEY",
|
||||
required: true,
|
||||
description: "Stripe live secret key",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Parse an entire manifest section, catching typos before install:
|
||||
|
||||
```ts
|
||||
import { secretDescriptorListSchema } from "@suaveplan/api-secrets/descriptor";
|
||||
|
||||
function parseManifestSecrets(raw: unknown) {
|
||||
const result = secretDescriptorListSchema.safeParse(raw);
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues
|
||||
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
||||
.join("; ");
|
||||
throw new Error(`Invalid secrets manifest: ${issues}`);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
// Adapter validates format hint when reading:
|
||||
async function readWithFormatCheck(
|
||||
source: { get(n: string): Promise<string | undefined> },
|
||||
desc: SecretDescriptor
|
||||
) {
|
||||
const raw = await source.get(desc.name);
|
||||
if (raw === undefined) return undefined;
|
||||
if (desc.format === "json") JSON.parse(raw); // throws on malformed
|
||||
if (desc.format === "base64" && !/^[A-Za-z0-9+/=]+$/.test(raw)) {
|
||||
throw new Error("SECRETS.SECRET_INVALID_FORMAT");
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface SecretDescriptor` — readonly `name`, `required`, optional `description`, `format`.
|
||||
- `const secretDescriptorSchema: z.ZodType<SecretDescriptor>` — strict object schema.
|
||||
- `const secretDescriptorListSchema` — `z.array(secretDescriptorSchema)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Zod's `strict()` mode is the load-bearing piece — without it `{ name: "X", required: true, requried: true }` would parse cleanly and silently drop the typo, leaving the plugin unable to look up its actual secret without warning. The `format` enum is open by design but capped at three values today (`"string" | "base64" | "json"`); add new entries through an OpenSpec change because every adapter must understand them. The interface is `readonly` field-by-field rather than wrapped in `Readonly<>` because mixed-mutability fields on a `Manifest` are common (descriptions may be filled in by an admin UI after upload), and a future evolution might relax `description` without breaking the readonly contract on `name`/`required`.
|
||||
@@ -0,0 +1,62 @@
|
||||
# `error-codes` — `SECRETS_CODES` and `SecretsError`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SECRETS_CODES` is the frozen registry of fully-qualified error codes the API plugin system throws when a plugin tries to read a secret the source cannot supply, or when the host detects a missing required secret during install. Pinning the registry in one place keeps the wire format stable for clients, lets the `@suaveplan/error` i18n resolver translate codes to human messages, and gives every concrete source (env, Bun.secrets, vault) a single canonical set of identifiers to throw.
|
||||
|
||||
`SecretsError` is the concrete `SuaveplanError` subclass for the secrets domain. It widens the abstract base's `protected` constructor to public so callers can throw without defining a per-call subclass, while still benefiting from `instanceof SecretsError` narrowing the code union to `SecretsCode`.
|
||||
|
||||
## Features
|
||||
|
||||
- Four canonical codes: `SECRET_NOT_FOUND`, `SECRET_REQUIRED_MISSING_AT_START`, `SECRET_INVALID_FORMAT`, `SOURCE_UNAVAILABLE`.
|
||||
- All codes fully-qualified at the `SECRETS.` namespace.
|
||||
- `Object.freeze`d registry — mutation attempts throw in strict mode.
|
||||
- `SecretsError` class with type-level code narrowing.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SECRETS_CODES, SecretsError } from "@suaveplan/api-secrets/error-codes";
|
||||
|
||||
throw new SecretsError(
|
||||
SECRETS_CODES.SECRET_NOT_FOUND,
|
||||
"error.secrets.not_found",
|
||||
{ context: { name: "STRIPE_KEY" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Pattern-matching on the code union in a host-side error mapper:
|
||||
|
||||
```ts
|
||||
import { SECRETS_CODES, type SecretsCode, SecretsError } from "@suaveplan/api-secrets/error-codes";
|
||||
|
||||
function toHttpStatus(err: SecretsError): number {
|
||||
const code: SecretsCode = err.code;
|
||||
switch (code) {
|
||||
case SECRETS_CODES.SECRET_NOT_FOUND:
|
||||
return 404;
|
||||
case SECRETS_CODES.SECRET_REQUIRED_MISSING_AT_START:
|
||||
return 503; // install incomplete
|
||||
case SECRETS_CODES.SECRET_INVALID_FORMAT:
|
||||
return 422;
|
||||
case SECRETS_CODES.SOURCE_UNAVAILABLE:
|
||||
return 503;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `const SECRETS_CODES` — `Readonly<{ [K in CodeKey]: \`SECRETS.${K}\` }>`; frozen.
|
||||
- `type SecretsCode` — the literal-string union derived from `SECRETS_CODES`.
|
||||
- `class SecretsError extends SuaveplanError<SecretsCode>` — public constructor `(code, messageKey, options?)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is built via `defineCodes("SECRETS", [...] as const)` from `@suaveplan/error`. `defineCodes` returns a frozen record so the registry is immutable at runtime; the `as const` on the keys array gives TypeScript the literal-string view it needs to derive `SecretsCode`. The `SecretsError` constructor delegates straight to the `SuaveplanError` base, including the trace-id, context, and clock injection points — every error thrown through `SecretsError` participates in `walkCauses`, `toSpanEvent`, and the redacted wire format. Note that `SuaveplanError` is abstract specifically because every domain should have its own `instanceof`-distinct subclass; do not import `SuaveplanError` directly to throw from this package.
|
||||
59
docs/api-stack/api/api-secrets/src/redaction/redaction.md
Normal file
59
docs/api-stack/api/api-secrets/src/redaction/redaction.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# `redaction` — `redactSecret` and the canonical marker
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module pins the single literal string every logger, audit sink, and OTel exporter in the API plugin system uses when redacting secret material. Having one canonical marker means downstream tooling — grep-based forensics, dashboard mask filters, regulator-facing export pipelines — can rely on one token to recognise and strip redacted content rather than guessing per-plugin variants.
|
||||
|
||||
The `redactSecret` function deliberately ignores its input contents, so logs cannot distinguish "was set to empty" from "was set to a real secret" by observing redaction output. That property matters: an attacker reading mask output across a long log timeline must not be able to infer which entries had non-trivial secret values just because the marker length varies.
|
||||
|
||||
## Features
|
||||
|
||||
- `REDACTED_MARKER` constant typed as the literal `"<redacted>"`.
|
||||
- `redactSecret(value)` returning the marker for every input — empty, whitespace, multi-line, multi-megabyte.
|
||||
- Return type narrowed to the literal so callers concatenating into structured logs do not widen the field shape.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { redactSecret } from "@suaveplan/api-secrets/redaction";
|
||||
|
||||
const log = { user: "u_42", secret: redactSecret(stripeKey) };
|
||||
console.log(JSON.stringify(log)); // { "user":"u_42","secret":"<redacted>" }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Build a logger middleware that redacts every field whose name matches a configured pattern:
|
||||
|
||||
```ts
|
||||
import { REDACTED_MARKER, redactSecret } from "@suaveplan/api-secrets/redaction";
|
||||
|
||||
function redactFields<T extends Record<string, unknown>>(
|
||||
obj: T,
|
||||
isSensitive: (key: keyof T) => boolean
|
||||
): T {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
out[k] = isSensitive(k as keyof T) && typeof v === "string"
|
||||
? redactSecret(v)
|
||||
: v;
|
||||
}
|
||||
return out as T;
|
||||
}
|
||||
|
||||
// Marker is always the same token — easy to grep:
|
||||
const findRedacted = (line: string) => line.includes(REDACTED_MARKER);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `const REDACTED_MARKER: "<redacted>"` — the literal canonical marker.
|
||||
- `function redactSecret(value: string): "<redacted>"` — returns the marker regardless of `value`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function ignores its parameter via the conventional `_value` name; the parameter exists solely to make the contract explicit at call sites (you are passing a value to redact, not asking for a generic placeholder). The return type is the literal `"<redacted>"`, not the wider `string`, so concatenation into typed log records preserves narrow types. Performance is `O(1)` and allocation-free — the marker is module-scoped and reused for every call. Note this helper is not a wider "redact substrings of a free-form string" tool; callers that need that should detect the substring boundaries themselves and call `redactSecret` for each match.
|
||||
@@ -0,0 +1,58 @@
|
||||
# `secrets-like` — `SecretsLike` re-export and required-secret enforcement
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module is the runtime entry point for the abstract Secrets contract. The canonical `SecretsLike` interface — `get(name)`, `require(name)`, `list()` — lives in `@suaveplan/api-types/secrets` so type-only consumers (the manifest validator, the plugin host signature) can depend on it without pulling api-secrets's runtime. We re-export it here so plugin authors importing from `@suaveplan/api-secrets` get one canonical entry. The runtime in this file is the host-side helper pair `checkRequiredSecrets` and `enforceRequiredSecrets` — both walk a manifest's `secrets[]` to assert every `required: true` descriptor resolves before the plugin transitions from `installing → installed`.
|
||||
|
||||
The helpers are split into a returning form and a throwing form so callers can integrate the check into whatever lifecycle plumbing they prefer — discriminated-result for hosts that aggregate failures, exception for hosts that bail fast.
|
||||
|
||||
## Features
|
||||
|
||||
- Re-export of the canonical `SecretsLike` from `@suaveplan/api-types`.
|
||||
- `SecretsListLike` — minimal structural shape (`list(): readonly string[]`) accepted by the helpers.
|
||||
- `checkRequiredSecrets` — pure, returns `{ ok: true } | { ok: false; missing }`.
|
||||
- `enforceRequiredSecrets` — throws `SecretsError(SECRETS.SECRET_REQUIRED_MISSING_AT_START)` listing every missing name on `context.missing`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { enforceRequiredSecrets } from "@suaveplan/api-secrets/secrets-like";
|
||||
|
||||
await enforceRequiredSecrets(ctx.secrets, manifest.secrets);
|
||||
// throws SECRETS.SECRET_REQUIRED_MISSING_AT_START if any required name absent
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Aggregate missing secrets across multiple plugins during a batch install:
|
||||
|
||||
```ts
|
||||
import { checkRequiredSecrets } from "@suaveplan/api-secrets/secrets-like";
|
||||
|
||||
interface InstallPlan { plugin: string; missing: readonly string[] }
|
||||
|
||||
const plans: InstallPlan[] = [];
|
||||
for (const { name, secretsView, descriptors } of installCandidates) {
|
||||
const result = checkRequiredSecrets(secretsView, descriptors);
|
||||
if (!result.ok) plans.push({ plugin: name, missing: result.missing });
|
||||
}
|
||||
if (plans.length > 0) {
|
||||
// present an operator-facing report rather than failing the first plugin
|
||||
reportToAdminConsole(plans);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type SecretsLike` — re-exported from `@suaveplan/api-types/secrets`.
|
||||
- `interface SecretsListLike` — `{ list(): readonly string[] }`.
|
||||
- `function checkRequiredSecrets(secrets, descriptors): RequiredSecretsCheck`.
|
||||
- `function enforceRequiredSecrets(secrets, descriptors): void` — throws on failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The helpers accept `SecretsListLike` rather than the full `SecretsLike` so tests can pass a `{ list: () => [...] }` literal without implementing every method. Required-secret enforcement deliberately uses `list()` rather than calling `require()` for every entry — `list()` is documented as cheap (often O(1) over an in-memory map), while `require` may hit a network or a secret-manager daemon. The throwing form attaches the full missing-names array to `error.context.missing`, which the `@suaveplan/error` wire-format adapter renders into the JSON envelope so admin UIs can show every missing name in one shot rather than playing whack-a-mole.
|
||||
68
docs/api-stack/api/api-secrets/src/testing/conformance.md
Normal file
68
docs/api-stack/api/api-secrets/src/testing/conformance.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# `testing/conformance` — `runSecretsConformance` harness
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`runSecretsConformance` is the reusable Vitest suite every concrete `SecretsLike` implementation in the api-secrets family MUST pass. Stage-4 adapters (`api-secrets-env`, `api-secrets-vault`) and The `api-secrets-bun` adapter all import it, hand it a factory that returns a fresh fixture per scenario, and inherit the cross-package shape guarantees — `get` returns the value, `get` returns `undefined` on miss, `require` returns the value, `require` throws `SECRETS.SECRET_NOT_FOUND` on miss, and `list()` enumerates seeded names. Centralising the suite means an adapter cannot accidentally drift its semantics from peer adapters without a test failure showing up in its own CI.
|
||||
|
||||
The harness is intentionally factory-based so sources that own real resources (testcontainers-backed vault, OS keystore) can seed and tear down per scenario rather than reusing a single instance across the suite.
|
||||
|
||||
## Features
|
||||
|
||||
- Five canonical scenarios covering the four documented behaviours plus `list()` enumeration.
|
||||
- Per-scenario fixture factory with optional teardown hook.
|
||||
- Customisable `isNotFound` predicate so sources representing missing names differently (network adapter wrapping a transport error) can still pass.
|
||||
- All `it(...)` calls carry `{ timeout: 5000 }`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runSecretsConformance } from "@suaveplan/api-secrets/testing";
|
||||
import { createEnvSecrets } from "./env-secrets.js";
|
||||
|
||||
runSecretsConformance({
|
||||
label: "env-secrets",
|
||||
factory: (seed) => {
|
||||
const restore = applyEnv(seed);
|
||||
const secrets = createEnvSecrets({ allowList: Object.keys(seed) });
|
||||
return { secrets, teardown: () => restore() };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Adapt the harness to a source whose missing-name representation differs from the canonical code:
|
||||
|
||||
```ts
|
||||
import { runSecretsConformance } from "@suaveplan/api-secrets/testing";
|
||||
import { VaultTransportError } from "./vault-transport.js";
|
||||
|
||||
runSecretsConformance({
|
||||
label: "vault-secrets",
|
||||
factory: async (seed) => {
|
||||
const container = await startVaultContainer({ seed });
|
||||
return {
|
||||
secrets: container.secrets,
|
||||
teardown: () => container.stop(),
|
||||
};
|
||||
},
|
||||
// vault wraps "not found" in a transport-level 404 — recognise it as
|
||||
// the canonical SECRET_NOT_FOUND for the purposes of the suite.
|
||||
isNotFound: (err) =>
|
||||
err instanceof VaultTransportError && err.status === 404,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `function runSecretsConformance(options: RunSecretsConformanceOptions): void` — registers a Vitest `describe` block; call once at module top level.
|
||||
- `interface RunSecretsConformanceOptions` — `{ label, factory, isNotFound? }`.
|
||||
- `interface SecretsConformanceFixture` — `{ secrets, teardown? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The harness deliberately uses `describe`/`it` from `@suaveplan/testing/runner` rather than importing directly from `vitest` — that runner wrapper routes to `bun:test` under Bun's runner condition, so a package whose runtime is browser-tier can still drive its conformance check from a `bun test` invocation. The default `isNotFound` matches on the canonical `SECRETS.SECRET_NOT_FOUND` code string; adapters not extending `SecretsError` directly must supply their own predicate. Note the harness does not check `dispose()` — that's intentional, because the canonical api-types `SecretsLike` shape does not require `dispose`; adapters that DO own resources should pair the conformance run with their own teardown suite.
|
||||
Reference in New Issue
Block a user