Files
suaveplan/openspec/archive/2026-05-25-api-secrets/specs/api-secrets/spec.md
2026-05-27 23:29:53 +01:00

148 lines
7.1 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-secrets (api-secrets)
**Package**: `@suaveplan/api-secrets`
**Status**: SHIPPED 2026-05-25
> Every requirement carries a `**Verified by:**` line.
## ADDED Requirements
### Requirement: SecretsLike re-export from api-types
The package SHALL re-export the canonical `SecretsLike` interface from `@suaveplan/api-types/secrets`. The canonical shape exposes `get(name): Promise<string | undefined>`, `require(name): Promise<string>` (throws `SECRETS.SECRET_NOT_FOUND` on miss), and `list(): readonly string[]` (enumerates declared names).
#### Scenario: get returns the value when present
- **WHEN** a stub source seeded with `{ A: "alpha" }` calls `get("A")`
- **THEN** the promise resolves to `"alpha"`
#### Scenario: get returns undefined when absent
- **WHEN** the same source calls `get("MISSING")`
- **THEN** the promise resolves to `undefined`
#### Scenario: require throws SECRET_NOT_FOUND on miss
- **WHEN** the source calls `require("MISSING")`
- **THEN** a `SecretsError` is thrown carrying `code === "SECRETS.SECRET_NOT_FOUND"`
**Verified by:** `src/secrets-like/secrets-like.test.ts::"SecretsLike contract — stub implementation > get returns the value when present"` AND `src/secrets-like/secrets-like.test.ts::"SecretsLike contract — stub implementation > get returns undefined when absent"` AND `src/secrets-like/secrets-like.test.ts::"SecretsLike contract — stub implementation > require throws SECRET_NOT_FOUND when absent"`
---
### Requirement: `import z from "zod"` everywhere
Every Zod import MUST use the default form.
#### Scenario: no named import of z
- **WHEN** the test greps `import { z } from "zod"` in `src/**`
- **THEN** zero matches
**Verified by:** `tests/dep-graph.test.ts::"zod-import-form > every src file uses default-import z"`
---
### Requirement: SecretDescriptor schema
The package SHALL export `secretDescriptorSchema` describing `{ name: string, required: boolean, description?: string, format?: "string" | "base64" | "json" }`. Unknown keys MUST be rejected. The list variant `secretDescriptorListSchema` MUST accept the empty array.
#### Scenario: minimal descriptor parses
- **WHEN** `{ name: "STRIPE_KEY", required: true }` is parsed
- **THEN** it succeeds AND `format` is undefined
#### Scenario: unknown format rejected
- **WHEN** `{ name: "X", required: true, format: "weird" }` is parsed
- **THEN** Zod reports an issue at `format`
#### Scenario: unknown keys rejected
- **WHEN** `{ name: "X", required: true, requried: true }` (typo) is parsed
- **THEN** the parse fails
**Verified by:** `src/descriptor/descriptor.test.ts::"secretDescriptorSchema > minimal descriptor parses"` AND `src/descriptor/descriptor.test.ts::"secretDescriptorSchema > unknown format rejected"` AND `src/descriptor/descriptor.test.ts::"secretDescriptorSchema > unknown keys rejected"`
---
### Requirement: redactSecret returns the canonical marker
The package SHALL export `redactSecret(value: string): "<redacted>"` returning the literal string `"<redacted>"` for any input — including the empty string — and `REDACTED_MARKER` exposing the same literal.
#### Scenario: any input maps to canonical marker
- **WHEN** any string is passed to `redactSecret`
- **THEN** the result equals `"<redacted>"`
**Verified by:** `src/redaction/redaction.test.ts::"redactSecret > any input maps to canonical marker"`
---
### Requirement: CapabilityId — secrets and secrets.bun
The package SHALL export `SECRETS_CAPABILITY_ID` (literal `"secrets"`) and `SECRETS_BUN_CAPABILITY_ID` (literal `"secrets.bun"`), both typed as `CapabilityId` via `as const satisfies CapabilityId`. If api-types ever drops either id, this package's typecheck MUST fail.
#### Scenario: both ids assignable to CapabilityId
- **WHEN** the test assigns each constant to a `CapabilityId`-typed variable
- **THEN** the assignment compiles
**Verified by:** `src/capability-id/capability-id.test.ts::"capability-id constants > both ids are valid CapabilityId values"`
---
### Requirement: Required-secret enforcement at start
The package SHALL export `checkRequiredSecrets` (returns a discriminated `{ ok }` result) and `enforceRequiredSecrets` (throws `SecretsError(SECRETS.SECRET_REQUIRED_MISSING_AT_START)` listing missing names on `context.missing`). Hosts use these to gate the `installing → installed` transition.
#### Scenario: missing required secret throws SECRET_REQUIRED_MISSING_AT_START
- **WHEN** a stub source has no entry for `"X"` AND descriptors include `{ name: "X", required: true }`
- **THEN** `enforceRequiredSecrets` throws `SecretsError` with `code === "SECRETS.SECRET_REQUIRED_MISSING_AT_START"` AND `context.missing` lists `["X"]`
#### Scenario: optional descriptors ignored
- **WHEN** descriptors include `{ name: "OPT", required: false }` AND the source has no entry for `"OPT"`
- **THEN** `checkRequiredSecrets` returns `{ ok: true }`
**Verified by:** `src/secrets-like/secrets-like.test.ts::"enforceRequiredSecrets > throws SECRET_REQUIRED_MISSING_AT_START with missing list"` AND `src/secrets-like/secrets-like.test.ts::"checkRequiredSecrets > ignores optional descriptors"`
---
### Requirement: Error codes registry frozen
The package SHALL export `SECRETS_CODES` containing exactly: `SECRET_NOT_FOUND`, `SECRET_REQUIRED_MISSING_AT_START`, `SECRET_INVALID_FORMAT`, `SOURCE_UNAVAILABLE`. The registry MUST be `Object.freeze`d. The package SHALL also export `SecretsError` — a concrete `SuaveplanError<SecretsCode>` subclass with a public constructor.
#### Scenario: every documented code present
- **WHEN** the test enumerates `Object.keys(SECRETS_CODES)`
- **THEN** the sorted keys equal `["SECRET_INVALID_FORMAT", "SECRET_NOT_FOUND", "SECRET_REQUIRED_MISSING_AT_START", "SOURCE_UNAVAILABLE"]`
#### Scenario: registry is frozen
- **WHEN** `Object.isFrozen(SECRETS_CODES)` is evaluated
- **THEN** it equals `true`
**Verified by:** `src/error-codes/error-codes.test.ts::"SECRETS_CODES > contains exactly four keys"` AND `src/error-codes/error-codes.test.ts::"SECRETS_CODES > is frozen"`
---
### Requirement: Conformance harness for sources
The package SHALL export `runSecretsConformance({ label, factory, isNotFound? })`. The harness MUST register five scenarios per call: `get` resolves seeded value, `get` resolves undefined for missing, `require` resolves seeded value, `require` throws (per `isNotFound`) on miss, and `list()` enumerates seeded names. Every concrete source in Stage 4/8 MUST pass it.
#### Scenario: stub source passes default-predicate suite
- **WHEN** a stub `SecretsLike` is run through `runSecretsConformance` with the default predicate
- **THEN** all five scenarios pass
#### Scenario: custom isNotFound predicate honoured
- **WHEN** a source represents missing names as `throw new Error("CUSTOM_MISSING")` AND `isNotFound` checks for that message
- **THEN** the harness still passes
**Verified by:** `src/testing/conformance.test.ts::"stub source — default predicate — SecretsLike conformance > require throws SECRET_NOT_FOUND for missing names"` AND `src/testing/conformance.test.ts::"stub source — custom predicate — SecretsLike conformance > require throws SECRET_NOT_FOUND for missing names"`