Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View 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

View File

@@ -0,0 +1,121 @@
{
"name": "@suaveplan/api-secrets",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Abstract Secrets contract for the Suaveplan API plugin foundation — per-plugin secret access typed against manifest secrets[], canonical redaction marker, conformance harness. Concrete adapters (env/Bun.secrets/vault) live in sibling packages.",
"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"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./secrets-like": {
"import": {
"types": "./dist/secrets-like/index.d.ts",
"default": "./dist/secrets-like/index.js"
},
"require": {
"types": "./dist/secrets-like/index.d.ts",
"default": "./dist/secrets-like/index.js"
}
},
"./descriptor": {
"import": {
"types": "./dist/descriptor/index.d.ts",
"default": "./dist/descriptor/index.js"
},
"require": {
"types": "./dist/descriptor/index.d.ts",
"default": "./dist/descriptor/index.js"
}
},
"./redaction": {
"import": {
"types": "./dist/redaction/index.d.ts",
"default": "./dist/redaction/index.js"
},
"require": {
"types": "./dist/redaction/index.d.ts",
"default": "./dist/redaction/index.js"
}
},
"./capability-id": {
"import": {
"types": "./dist/capability-id/index.d.ts",
"default": "./dist/capability-id/index.js"
},
"require": {
"types": "./dist/capability-id/index.d.ts",
"default": "./dist/capability-id/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
},
"require": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./testing": {
"import": {
"types": "./dist/testing/index.d.ts",
"default": "./dist/testing/index.js"
},
"require": {
"types": "./dist/testing/index.d.ts",
"default": "./dist/testing/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"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-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"
]
}

View File

@@ -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.

View File

@@ -0,0 +1,30 @@
import type { CapabilityId } from "@suaveplan/api-types/capabilities";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
SECRETS_BUN_CAPABILITY_ID,
SECRETS_CAPABILITY_ID,
} from "./capability-id.js";
describe("capability-id constants", () => {
it("SECRETS_CAPABILITY_ID is the literal 'secrets'", {
timeout: 5000,
}, () => {
expect(SECRETS_CAPABILITY_ID).toBe("secrets");
});
it("SECRETS_BUN_CAPABILITY_ID is the literal 'secrets.bun'", {
timeout: 5000,
}, () => {
expect(SECRETS_BUN_CAPABILITY_ID).toBe("secrets.bun");
});
it("both ids are valid CapabilityId values", { timeout: 5000 }, () => {
// Compile-time invariant: both literal types are assignable to the
// canonical `CapabilityId` union. Reaching this line at typecheck
// proves api-types still lists them.
const a: CapabilityId = SECRETS_CAPABILITY_ID;
const b: CapabilityId = SECRETS_BUN_CAPABILITY_ID;
expect(a).toBe("secrets");
expect(b).toBe("secrets.bun");
});
});

View File

@@ -0,0 +1,23 @@
import type { CapabilityId } from "@suaveplan/api-types/capabilities";
/**
* Re-export of the canonical `CapabilityId` union. `@suaveplan/api-types`
* already lists `"secrets"` and `"secrets.bun"` in the union; this package
* pins them here so spec-conformance tests in this package's own suite
* verify both ids remain present in every future revision.
*/
export type { CapabilityId };
/**
* Literal id for the abstract Secrets capability. Plugins declare this in
* `manifest.requiredCapabilities` to receive a typed `ctx.secrets`.
*/
export const SECRETS_CAPABILITY_ID = "secrets" as const satisfies CapabilityId;
/**
* Reserved literal id for the Bun.secrets concrete adapter (Stage 8). Pinned
* here so plugin manifests can declare it as a `requiredCapabilities` entry
* at type-check time before the adapter ships.
*/
export const SECRETS_BUN_CAPABILITY_ID =
"secrets.bun" as const satisfies CapabilityId;

View File

@@ -0,0 +1,5 @@
export {
type CapabilityId,
SECRETS_BUN_CAPABILITY_ID,
SECRETS_CAPABILITY_ID,
} from "./capability-id.js";

View 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`.

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
secretDescriptorListSchema,
secretDescriptorSchema,
} from "./descriptor.js";
describe("secretDescriptorSchema", () => {
it("minimal descriptor parses", { timeout: 5000 }, () => {
const parsed = secretDescriptorSchema.parse({
name: "STRIPE_KEY",
required: true,
});
expect(parsed.name).toBe("STRIPE_KEY");
expect(parsed.required).toBe(true);
expect(parsed.format).toBeUndefined();
expect(parsed.description).toBeUndefined();
});
it("full descriptor parses", { timeout: 5000 }, () => {
const parsed = secretDescriptorSchema.parse({
name: "JWT_SIGNING_KEY",
required: true,
description: "HS256 signing key for session JWTs",
format: "base64",
});
expect(parsed.format).toBe("base64");
expect(parsed.description).toBe("HS256 signing key for session JWTs");
});
it("unknown format rejected", { timeout: 5000 }, () => {
const result = secretDescriptorSchema.safeParse({
name: "X",
required: true,
format: "weird",
});
expect(result.success).toBe(false);
if (!result.success) {
const issuePaths = result.error.issues.map((i) => i.path.join("."));
expect(issuePaths).toContain("format");
}
});
it("unknown keys rejected", { timeout: 5000 }, () => {
const result = secretDescriptorSchema.safeParse({
name: "X",
required: true,
requried: true, // typo — must reject
});
expect(result.success).toBe(false);
});
it("empty name rejected", { timeout: 5000 }, () => {
const result = secretDescriptorSchema.safeParse({
name: "",
required: true,
});
expect(result.success).toBe(false);
});
it("missing required field rejected", { timeout: 5000 }, () => {
const result = secretDescriptorSchema.safeParse({ name: "X" });
expect(result.success).toBe(false);
});
});
describe("secretDescriptorListSchema", () => {
it("empty array is valid", { timeout: 5000 }, () => {
expect(secretDescriptorListSchema.parse([])).toEqual([]);
});
it("rejects when any entry invalid", { timeout: 5000 }, () => {
const result = secretDescriptorListSchema.safeParse([
{ name: "OK", required: true },
{ name: "", required: true },
]);
expect(result.success).toBe(false);
});
it("parses a multi-entry list", { timeout: 5000 }, () => {
const parsed = secretDescriptorListSchema.parse([
{ name: "A", required: true },
{ name: "B", required: false, format: "json" },
]);
expect(parsed).toHaveLength(2);
expect(parsed[1]?.format).toBe("json");
});
});

View File

@@ -0,0 +1,55 @@
import z from "zod";
/**
* Manifest-side declaration of a secret a plugin needs. The host iterates the
* manifest's `secrets[]` to populate the typed `SecretsLike` handed to the
* plugin, and to enforce required-secret presence at install time.
*/
export interface SecretDescriptor {
/** The lookup name (e.g. `"STRIPE_API_KEY"`). */
readonly name: string;
/**
* When `true`, the host MUST fail install with
* `SECRETS.SECRET_REQUIRED_MISSING_AT_START` if the source cannot resolve
* this name.
*/
readonly required: boolean;
/** Free-form human-readable description for operators. */
readonly description?: string;
/**
* Declared on-the-wire format. Adapters MAY validate the resolved value
* against this hint and throw `SECRETS.SECRET_INVALID_FORMAT` on a
* mismatch.
*/
readonly format?: "string" | "base64" | "json";
}
/**
* Zod schema for a manifest `secrets[]` entry. Unknown keys are rejected so a
* typo (`requried: true`) surfaces at manifest-load time rather than as a
* silently-ignored field.
*/
export const secretDescriptorSchema = z
.object({
name: z.string().min(1),
required: z.boolean(),
description: z.string().optional(),
format: z.enum(["string", "base64", "json"]).optional(),
})
.strict();
// Compile-time assertion that the schema's output is assignable to the
// hand-written SecretDescriptor interface. Zod's inferred output uses
// `string | undefined` for `.optional()` fields, while our interface uses
// `description?: string`; the two are structurally compatible.
const _schemaAssignsToDescriptor: SecretDescriptor = {
name: "X",
required: true,
} satisfies z.infer<typeof secretDescriptorSchema>;
void _schemaAssignsToDescriptor;
/**
* Convenience schema for an entire `secrets[]` array on a manifest. Empty is
* valid (a plugin may declare no secrets).
*/
export const secretDescriptorListSchema = z.array(secretDescriptorSchema);

View File

@@ -0,0 +1,5 @@
export {
type SecretDescriptor,
secretDescriptorListSchema,
secretDescriptorSchema,
} from "./descriptor.js";

View File

@@ -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.

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SECRETS_CODES } from "./error-codes.js";
describe("SECRETS_CODES", () => {
it("exposes the documented entries fully-qualified", {
timeout: 5000,
}, () => {
expect(SECRETS_CODES.SECRET_NOT_FOUND).toBe("SECRETS.SECRET_NOT_FOUND");
expect(SECRETS_CODES.SECRET_REQUIRED_MISSING_AT_START).toBe(
"SECRETS.SECRET_REQUIRED_MISSING_AT_START"
);
expect(SECRETS_CODES.SECRET_INVALID_FORMAT).toBe(
"SECRETS.SECRET_INVALID_FORMAT"
);
expect(SECRETS_CODES.SOURCE_UNAVAILABLE).toBe(
"SECRETS.SOURCE_UNAVAILABLE"
);
});
it("contains exactly four keys", { timeout: 5000 }, () => {
const keys = Object.keys(SECRETS_CODES).sort();
expect(keys).toEqual([
"SECRET_INVALID_FORMAT",
"SECRET_NOT_FOUND",
"SECRET_REQUIRED_MISSING_AT_START",
"SOURCE_UNAVAILABLE",
]);
});
it("is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(SECRETS_CODES)).toBe(true);
});
});

View File

@@ -0,0 +1,45 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Frozen registry of error codes thrown by the `SecretsLike` contract and its
* concrete sources. The host implementing required-secret enforcement at start
* MUST throw `SECRETS.SECRET_REQUIRED_MISSING_AT_START`; sources unable to
* resolve a requested name throw `SECRETS.SECRET_NOT_FOUND`.
*/
export const SECRETS_CODES = defineCodes("SECRETS", [
"SECRET_NOT_FOUND",
"SECRET_REQUIRED_MISSING_AT_START",
"SECRET_INVALID_FORMAT",
"SOURCE_UNAVAILABLE",
] as const);
/**
* Union of fully-qualified `SECRETS.*` codes — useful as the `code` field
* type on a custom `SecretsError extends SuaveplanError` subclass.
*/
export type SecretsCode = (typeof SECRETS_CODES)[keyof typeof SECRETS_CODES];
/**
* Concrete `SuaveplanError` subclass for the secrets domain. Widens the
* abstract base's `protected` constructor to public so callers can throw
* `new SecretsError(SECRETS_CODES.X, "message.key", { context })` without
* defining a per-call subclass. The constructor also pins the canonical
* `SECRETS_ERROR_NAME` on `this.name` so structured loggers can group every
* secrets-domain failure under one bucket without inspecting the code.
*/
export const SECRETS_ERROR_NAME = "SecretsError" as const;
export class SecretsError extends SuaveplanError<SecretsCode> {
public constructor(
code: SecretsCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = SECRETS_ERROR_NAME;
}
}

View File

@@ -0,0 +1,5 @@
export {
SECRETS_CODES,
type SecretsCode,
SecretsError,
} from "./error-codes.js";

View File

@@ -0,0 +1,38 @@
/**
* `@suaveplan/api-secrets` — root barrel.
*
* Abstract Secrets contract for the API plugin foundation. The canonical
* `SecretsLike` shape is owned by `@suaveplan/api-types/secrets` and
* re-exported here; this package contributes the descriptor Zod schema,
* redaction helper, error-code registry, capability-id constants, and the
* conformance harness Stage-4/Stage-8 sources MUST pass.
*/
export {
type CapabilityId,
SECRETS_BUN_CAPABILITY_ID,
SECRETS_CAPABILITY_ID,
} from "./capability-id/index.js";
export {
type SecretDescriptor,
secretDescriptorListSchema,
secretDescriptorSchema,
} from "./descriptor/index.js";
export {
SECRETS_CODES,
type SecretsCode,
SecretsError,
} from "./error-codes/index.js";
export { REDACTED_MARKER, redactSecret } from "./redaction/index.js";
export {
checkRequiredSecrets,
enforceRequiredSecrets,
type RequiredSecretsCheck,
type SecretsLike,
type SecretsListLike,
} from "./secrets-like/index.js";
export {
type RunSecretsConformanceOptions,
runSecretsConformance,
type SecretsConformanceFixture,
} from "./testing/index.js";

View File

@@ -0,0 +1 @@
export { REDACTED_MARKER, redactSecret } from "./redaction.js";

View 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.

View File

@@ -0,0 +1,20 @@
/**
* The canonical redaction marker pinned by this package. Every logger /
* audit-sink / OTel exporter in the API plugin foundation MUST use this
* literal string when redacting secret material so downstream tooling has a
* single token to grep / mask / strip.
*/
export const REDACTED_MARKER = "<redacted>" as const;
/**
* Replace a secret value with the canonical redaction marker.
*
* The function intentionally ignores the input contents — including empty
* strings — so logs cannot distinguish "was set to empty" from "was set to a
* real secret" by observing redaction output. Callers wanting to redact a
* value INSIDE a larger string MUST call this on the substring they wish to
* replace (or use a logger adapter built on top of this helper).
*/
export function redactSecret(_value: string): "<redacted>" {
return REDACTED_MARKER;
}

View File

@@ -0,0 +1,7 @@
export {
checkRequiredSecrets,
enforceRequiredSecrets,
type RequiredSecretsCheck,
type SecretsLike,
type SecretsListLike,
} from "./secrets-like.js";

View File

@@ -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.

View File

@@ -0,0 +1,139 @@
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { SecretDescriptor } from "../descriptor/descriptor.js";
import { SECRETS_CODES, SecretsError } from "../error-codes/error-codes.js";
import {
checkRequiredSecrets,
enforceRequiredSecrets,
type SecretsLike,
type SecretsListLike,
} from "./secrets-like.js";
function makeSecrets(store: Record<string, string>): SecretsLike {
return {
async get(name) {
return store[name];
},
async require(name) {
const v = store[name];
if (v === undefined) {
throw new SecretsError(
SECRETS_CODES.SECRET_NOT_FOUND,
"error.secrets.not_found",
{ context: { name } }
);
}
return v;
},
list() {
return Object.keys(store);
},
};
}
describe("SecretsLike contract — stub implementation", () => {
it("get returns the value when present", { timeout: 5000 }, async () => {
const s = makeSecrets({ A: "alpha" });
await expect(s.get("A")).resolves.toBe("alpha");
});
it("get returns undefined when absent", { timeout: 5000 }, async () => {
const s = makeSecrets({});
await expect(s.get("MISSING")).resolves.toBeUndefined();
});
it("require throws SECRET_NOT_FOUND when absent", {
timeout: 5000,
}, async () => {
const s = makeSecrets({});
await expect(s.require("MISSING")).rejects.toBeInstanceOf(
SuaveplanError
);
try {
await s.require("MISSING");
} catch (err) {
expect((err as SecretsError).code).toBe("SECRETS.SECRET_NOT_FOUND");
}
});
it("require returns value when present", { timeout: 5000 }, async () => {
const s = makeSecrets({ A: "alpha" });
await expect(s.require("A")).resolves.toBe("alpha");
});
it("list enumerates declared names", { timeout: 5000 }, () => {
const s = makeSecrets({ A: "1", B: "2" });
expect([...s.list()].sort()).toEqual(["A", "B"]);
});
});
describe("checkRequiredSecrets", () => {
const descriptors: SecretDescriptor[] = [
{ name: "REQ_A", required: true },
{ name: "REQ_B", required: true },
{ name: "OPT_C", required: false },
];
it("ok when every required name present", { timeout: 5000 }, () => {
const s = makeSecrets({ REQ_A: "1", REQ_B: "2" });
const result = checkRequiredSecrets(s, descriptors);
expect(result.ok).toBe(true);
});
it("not-ok with missing list when names absent", { timeout: 5000 }, () => {
const s = makeSecrets({ REQ_A: "1" });
const result = checkRequiredSecrets(s, descriptors);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.missing).toEqual(["REQ_B"]);
}
});
it("ignores optional descriptors", { timeout: 5000 }, () => {
const s = makeSecrets({ REQ_A: "1", REQ_B: "2" });
const result = checkRequiredSecrets(s, descriptors);
expect(result.ok).toBe(true);
});
it("empty descriptors yields ok", { timeout: 5000 }, () => {
const s: SecretsListLike = { list: () => [] };
const result = checkRequiredSecrets(s, []);
expect(result.ok).toBe(true);
});
it("accepts a structural SecretsListLike", { timeout: 5000 }, () => {
const s: SecretsListLike = { list: () => ["X"] };
const result = checkRequiredSecrets(s, [{ name: "X", required: true }]);
expect(result.ok).toBe(true);
});
});
describe("enforceRequiredSecrets", () => {
it("resolves silently when ok", { timeout: 5000 }, () => {
const s = makeSecrets({ X: "1" });
expect(() =>
enforceRequiredSecrets(s, [{ name: "X", required: true }])
).not.toThrow();
});
it("throws SECRET_REQUIRED_MISSING_AT_START with missing list", {
timeout: 5000,
}, () => {
const s = makeSecrets({});
try {
enforceRequiredSecrets(s, [
{ name: "X", required: true },
{ name: "Y", required: true },
]);
throw new Error("expected to throw");
} catch (err) {
expect(err).toBeInstanceOf(SecretsError);
expect((err as SecretsError).code).toBe(
"SECRETS.SECRET_REQUIRED_MISSING_AT_START"
);
expect((err as SecretsError).context).toEqual({
missing: ["X", "Y"],
});
}
});
});

View File

@@ -0,0 +1,83 @@
import type { SecretDescriptor as ApiTypesSecretDescriptor } from "@suaveplan/api-types/secrets";
import type { SecretDescriptor } from "../descriptor/descriptor.js";
import { SECRETS_CODES, SecretsError } from "../error-codes/error-codes.js";
// Re-export the canonical type from api-types so consumers can `import
// { SecretsLike } from "@suaveplan/api-secrets"`.
export type { SecretsLike } from "@suaveplan/api-types/secrets";
/**
* Static assertion that our local `SecretDescriptor` is assignable to
* `@suaveplan/api-types`'s narrower view. Catches future drift between the
* two definitions at compile time.
*/
const _descriptorAssignable: ApiTypesSecretDescriptor = {
name: "X",
required: true,
} satisfies SecretDescriptor;
void _descriptorAssignable;
/**
* Minimal structural view we need to enforce required-secret presence at
* start. Decoupling here lets `enforceRequiredSecrets` accept any object
* implementing `list()` — whether the concrete api-types `SecretsLike` or a
* test stub.
*/
export interface SecretsListLike {
list(): readonly string[];
}
/**
* Result returned by `checkRequiredSecrets` — `ok: true` when every
* required secret resolved; `ok: false` plus the list of missing names
* otherwise. The host translates `ok: false` into a thrown
* `SECRETS.SECRET_REQUIRED_MISSING_AT_START`.
*/
export type RequiredSecretsCheck =
| { readonly ok: true }
| { readonly ok: false; readonly missing: readonly string[] };
/**
* Walk a manifest's `secrets[]` and assert every `required: true` descriptor
* is enumerated by the source's `list()`. Returns a result discriminator
* rather than throwing so callers can integrate the check into their own
* lifecycle plumbing.
*/
export function checkRequiredSecrets(
secrets: SecretsListLike,
descriptors: readonly SecretDescriptor[]
): RequiredSecretsCheck {
const declared = new Set(secrets.list());
const required = descriptors.filter((d) => d.required);
const missing: string[] = [];
for (const desc of required) {
if (!declared.has(desc.name)) {
missing.push(desc.name);
}
}
if (missing.length === 0) {
return { ok: true };
}
return { ok: false, missing };
}
/**
* Throwing wrapper around `checkRequiredSecrets` for hosts that want
* exception-style control flow. Throws a `SecretsError` carrying the
* canonical `SECRETS.SECRET_REQUIRED_MISSING_AT_START` code and the list of
* missing names on the `context` field.
*/
export function enforceRequiredSecrets(
secrets: SecretsListLike,
descriptors: readonly SecretDescriptor[]
): void {
const result = checkRequiredSecrets(secrets, descriptors);
if (result.ok) {
return;
}
throw new SecretsError(
SECRETS_CODES.SECRET_REQUIRED_MISSING_AT_START,
"error.secrets.required_missing_at_start",
{ context: { missing: result.missing } }
);
}

View 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.

View File

@@ -0,0 +1,80 @@
import type { SecretsLike } from "@suaveplan/api-types/secrets";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SECRETS_CODES, SecretsError } from "../error-codes/error-codes.js";
import { runSecretsConformance } from "./conformance.js";
function makeStubSource(seed: Readonly<Record<string, string>>): SecretsLike {
return {
async get(name) {
return seed[name];
},
async require(name) {
const v = seed[name];
if (v === undefined) {
throw new SecretsError(
SECRETS_CODES.SECRET_NOT_FOUND,
"error.secrets.not_found"
);
}
return v;
},
list() {
return Object.keys(seed);
},
};
}
// 1. Default-predicate suite — exercises the default isNotFound branch.
runSecretsConformance({
label: "stub source — default predicate",
factory: (seed) => ({ secrets: makeStubSource(seed) }),
});
// 2. Custom-predicate suite — exercises the user-supplied isNotFound branch
// AND the optional teardown hook on the fixture.
const teardowns: string[] = [];
function makeCustomMissingSource(
seed: Readonly<Record<string, string>>
): SecretsLike {
return {
async get(name) {
return seed[name];
},
async require(name) {
const v = seed[name];
if (v === undefined) {
throw new Error("CUSTOM_MISSING");
}
return v;
},
list() {
return Object.keys(seed);
},
};
}
runSecretsConformance({
label: "stub source — custom predicate",
factory: (seed) => ({
secrets: makeCustomMissingSource(seed),
teardown: () => {
teardowns.push("done");
},
}),
isNotFound: (err) =>
err instanceof Error && err.message === "CUSTOM_MISSING",
});
describe("runSecretsConformance — option contract", () => {
it("teardowns array exists for closure capture", {
timeout: 5000,
}, () => {
// The custom-predicate suite above registered scenarios that push
// onto `teardowns`. We don't assert size here because vitest's
// cross-describe scheduling makes the order non-deterministic; the
// important coverage is that the closure is wired (the suite
// registered successfully without throwing) and the custom
// predicate runs (verified by the suite passing).
expect(Array.isArray(teardowns)).toBe(true);
});
});

View File

@@ -0,0 +1,130 @@
import type { SecretsLike } from "@suaveplan/api-types/secrets";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SECRETS_CODES, type SecretsCode } from "../error-codes/error-codes.js";
/**
* Factory-supplied test fixture for the secrets-source conformance harness.
* Implementations populate `seed` (initial name → value map), then return
* the `SecretsLike` instance to exercise.
*/
export interface SecretsConformanceFixture {
/** The `SecretsLike` under test. */
readonly secrets: SecretsLike;
/** Optional teardown — called after every scenario. */
readonly teardown?: () => Promise<void> | void;
}
/**
* Construction options for the conformance harness. Sources declare
* `caseSensitive` (most are; environment-variable sources may not be on
* Windows) and `supportsListReadingAhead` (`list()` returns every seeded
* name without resolving them first).
*/
export interface RunSecretsConformanceOptions {
/** Human-readable label embedded in `describe` titles. */
readonly label: string;
/**
* Asynchronously build a fresh fixture for each scenario. The factory
* receives the seed map for the upcoming case and MUST honour it.
*/
readonly factory: (
seed: Readonly<Record<string, string>>
) => Promise<SecretsConformanceFixture> | SecretsConformanceFixture;
/**
* Predicate that recognises the source's representation of a
* `SECRETS.SECRET_NOT_FOUND` failure thrown from `require`. Defaults to
* matching `(err as { code?: string }).code === "SECRETS.SECRET_NOT_FOUND"`.
*/
readonly isNotFound?: (err: unknown) => boolean;
}
function defaultIsNotFound(err: unknown): boolean {
const code: SecretsCode = SECRETS_CODES.SECRET_NOT_FOUND;
return (
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code: unknown }).code === code
);
}
/**
* Reusable spec-conformance suite for every concrete `SecretsLike`
* implementation. Covers the four documented behaviours: `get` returns a
* present value, `get` returns undefined for missing names, `require`
* resolves to the value, and `require` throws `SECRETS.SECRET_NOT_FOUND`
* for missing names. Also exercises `list()` enumeration.
*
* Every Stage-4/Stage-8 source MUST call this harness from its own test
* suite. Failing the harness means the source breaks the cross-package
* contract.
*/
export function runSecretsConformance(
options: RunSecretsConformanceOptions
): void {
const { label, factory } = options;
const isNotFound = options.isNotFound ?? defaultIsNotFound;
describe(`${label} — SecretsLike conformance`, () => {
it("get resolves to the seeded value", { timeout: 5000 }, async () => {
const fx = await factory({ ALPHA: "one" });
try {
await expect(fx.secrets.get("ALPHA")).resolves.toBe("one");
} finally {
await fx.teardown?.();
}
});
it("get resolves to undefined for missing names", {
timeout: 5000,
}, async () => {
const fx = await factory({});
try {
await expect(
fx.secrets.get("MISSING")
).resolves.toBeUndefined();
} finally {
await fx.teardown?.();
}
});
it("require resolves to the seeded value", {
timeout: 5000,
}, async () => {
const fx = await factory({ BETA: "two" });
try {
await expect(fx.secrets.require("BETA")).resolves.toBe("two");
} finally {
await fx.teardown?.();
}
});
it("require throws SECRET_NOT_FOUND for missing names", {
timeout: 5000,
}, async () => {
const fx = await factory({});
try {
let caught: unknown;
try {
await fx.secrets.require("MISSING");
} catch (err) {
caught = err;
}
expect(caught).toBeDefined();
expect(isNotFound(caught)).toBe(true);
} finally {
await fx.teardown?.();
}
});
it("list enumerates seeded names", { timeout: 5000 }, async () => {
const fx = await factory({ A: "1", B: "2" });
try {
const names = [...fx.secrets.list()].sort();
expect(names).toEqual(["A", "B"]);
} finally {
await fx.teardown?.();
}
});
});
}

View File

@@ -0,0 +1,5 @@
export {
type RunSecretsConformanceOptions,
runSecretsConformance,
type SecretsConformanceFixture,
} from "./conformance.js";

View File

@@ -0,0 +1,53 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("dep-graph", () => {
it("runtime deps are exactly the expected set (sorted)", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { dependencies?: Record<string, string> };
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
expect(runtime).toEqual([
"@suaveplan/api-types",
"@suaveplan/error",
"tslib",
"zod",
]);
});
it("zero peerDependencies", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { peerDependencies?: Record<string, string> };
expect(Object.keys(pkg.peerDependencies ?? {})).toEqual([]);
});
});
describe("zod-import-form", () => {
it("every src file uses default-import z", { timeout: 5000 }, () => {
const { readdirSync, statSync, readFileSync } =
require("node:fs") as typeof import("node:fs");
function* walk(dir: string): Generator<string> {
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
if (statSync(p).isDirectory()) {
yield* walk(p);
} else if (p.endsWith(".ts")) {
yield p;
}
}
}
const srcDir = join(__dirname, "..", "src");
const bad: string[] = [];
for (const file of walk(srcDir)) {
const src = readFileSync(file, "utf8");
if (/import\s*\{\s*z\s*\}\s*from\s*["']zod["']/.test(src)) {
bad.push(file);
}
}
expect(bad).toEqual([]);
});
});

View File

@@ -0,0 +1 @@
export {};

View 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", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,36 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-secrets",
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: [
"tslib",
"zod",
"@suaveplan/api-types",
"@suaveplan/error",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.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",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});