113 lines
5.2 KiB
Markdown
113 lines
5.2 KiB
Markdown
# foundation-fix-gap-3-secrets-slot
|
|
|
|
- **Status**: PROPOSED
|
|
- **Spec deltas**:
|
|
- ./specs/api-host/spec.md
|
|
- ./specs/api-types/spec.md
|
|
|
|
## What & why
|
|
|
|
The post-Stage-4 foundation-coherence audit (Gap #3) flagged that
|
|
`HostAdapters` had no `secrets` slot, so even though
|
|
`@suaveplan/api-secrets-env` builds a `SecretsLike` cleanly, the host
|
|
never threaded it through to plugin handlers. The e2e foundation
|
|
fixture worked around this by closing over a `createEnvSecrets(...)`
|
|
binding inside the route module (`buildSecrets()` in
|
|
`e2e/foundation-stack/src/fixture-plugin.ts`). Real plugins would have
|
|
to do the same thing — there was no canonical wiring.
|
|
|
|
This change closes the gap by:
|
|
|
|
1. **`HostAdapters` gains optional `secrets?: SecretsLike`.** When
|
|
omitted, the host injects `EmptySecrets` — a no-op default whose
|
|
`get()` returns `undefined`, `require()` throws
|
|
`SECRETS.SECRET_NOT_FOUND`, and `list()` returns the frozen empty
|
|
array. Optional so legacy bootstraps stay source-compatible.
|
|
2. **`PluginContext<Caps>` and (by inheritance) `RequestContext<Caps>`,
|
|
`JobContext<Caps>`, `ConnectionContext<Caps>` gain
|
|
`secrets: SecretsLike`.** Plugin authors can destructure
|
|
`ctx.secrets` without a null guard at every call-site; the host
|
|
always supplies a value.
|
|
3. **`RequestPipelineOptions` gains required `secrets: SecretsLike`.**
|
|
`createHost` wires `config.adapters.secrets ?? new EmptySecrets()`
|
|
into the pipeline options and into the lifecycle driver, so both
|
|
route handlers (`RequestContext.secrets`) and lifecycle hooks
|
|
(`PluginContext.secrets`) see the same instance.
|
|
4. **`@suaveplan/api-host/empty-secrets` subpath export.** The class
|
|
ships under a dedicated subpath so the host's `EmptySecrets` is
|
|
importable for explicit fallback wiring (e.g. mode-conditional
|
|
choice between `createEnvSecrets` and `EmptySecrets`).
|
|
5. **e2e/foundation-stack** drops the closure-bound `buildSecrets`
|
|
helper; the route handler now reads via `ctx.secrets.require(...)`
|
|
directly and the secrets adapter is wired into
|
|
`HostAdapters.secrets` once at boot.
|
|
|
|
## Impact
|
|
|
|
### Affected dependents
|
|
|
|
- `@suaveplan/api-types` — `PluginContext.secrets` is a new REQUIRED
|
|
field on every context flavour. The 6 in-tree consumers that
|
|
reference `RequestContext` via cast-narrowing or generic parameter
|
|
(api-graphql, api-router, api-router-hono — `as RequestContext`,
|
|
`Parameters<R>[2]` typing) are unaffected because they do not
|
|
construct context literals. The only literal-construction site is
|
|
`@suaveplan/api-host/request-pipeline-helpers.ts`'s
|
|
`buildRequestContext`, which is updated atomically.
|
|
- `@suaveplan/api-host` — gains `EmptySecrets` class + `./empty-secrets`
|
|
subpath export. `HostAdapters.secrets` is optional. Pipeline options
|
|
+ lifecycle driver options both gain a threaded `secrets` slot.
|
|
Dependency on `@suaveplan/api-secrets` is added so the host can
|
|
reuse the `SECRETS_CODES.SECRET_NOT_FOUND` error code namespace and
|
|
`SecretsError` subclass — no new error namespaces are introduced.
|
|
- `@suaveplan/e2e-foundation-stack` — `buildAdapters` wires
|
|
`createEnvSecrets({ allowedKeys: ["FOUNDATION_SECRET"], source: ... })`
|
|
into `HostAdapters.secrets`. The fixture plugin's
|
|
`/api/secret-key` route handler now resolves via
|
|
`ctx.secrets.require("FOUNDATION_SECRET")` instead of a hand-rolled
|
|
closure.
|
|
|
|
### Backward compatibility
|
|
|
|
`HostAdapters.secrets` is optional — bootstraps that do not set it
|
|
receive the `EmptySecrets` default. `RequestContext.secrets` /
|
|
`PluginContext.secrets` are new REQUIRED fields on the public type,
|
|
but the only literal-construction site in the monorepo is owned by
|
|
api-host itself (updated atomically). All other consumers use
|
|
type-narrowing casts or generic parameter typing and are unaffected
|
|
at compile time.
|
|
|
|
The new pipeline `secrets` option is REQUIRED on
|
|
`RequestPipelineOptions` so the host cannot accidentally construct a
|
|
pipeline without threading the slot. Tests + the lifecycle driver
|
|
construct an `EmptySecrets()` explicitly in the same commit.
|
|
|
|
## Parity Checklist
|
|
|
|
This is a genesis-original fix; no upstream parity table required.
|
|
|
|
## Verification
|
|
|
|
`bunx turbo lint typecheck test build --filter=@suaveplan/api-host --filter=@suaveplan/api-types --filter=@suaveplan/e2e-foundation-stack`
|
|
→ green.
|
|
|
|
`bunx turbo test:coverage --filter=@suaveplan/api-host --filter=@suaveplan/api-types`
|
|
→ touched files at 100% line coverage; `empty-secrets.ts` has 75% bun
|
|
function coverage (3 of 4 — the constructor counts as a function under
|
|
`bun:test`'s naive counter even though every test instantiates it).
|
|
Vitest/istanbul function coverage is 100%.
|
|
|
|
E2E (Playwright) — all 10 foundation-stack tests pass post-shim
|
|
removal, including the secrets-redaction test that now exercises the
|
|
pipeline-threaded `ctx.secrets.require(...)` path.
|
|
|
|
Tests added:
|
|
|
|
- `request-pipeline-secrets.test.ts` — handler reads
|
|
`ctx.secrets.require("FOO")` via a wired adapter (round-trip);
|
|
default `EmptySecrets` throws `SECRETS.SECRET_NOT_FOUND` on
|
|
`require`; `ctx.secrets` is the same reference passed via options.
|
|
- `empty-secrets.test.ts` — get returns undefined, require throws
|
|
`SECRETS.SECRET_NOT_FOUND` with `{ name, source }` context, list
|
|
returns a frozen empty array stable across calls.
|