5.2 KiB
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:
HostAdaptersgains optionalsecrets?: SecretsLike. When omitted, the host injectsEmptySecrets— a no-op default whoseget()returnsundefined,require()throwsSECRETS.SECRET_NOT_FOUND, andlist()returns the frozen empty array. Optional so legacy bootstraps stay source-compatible.PluginContext<Caps>and (by inheritance)RequestContext<Caps>,JobContext<Caps>,ConnectionContext<Caps>gainsecrets: SecretsLike. Plugin authors can destructurectx.secretswithout a null guard at every call-site; the host always supplies a value.RequestPipelineOptionsgains requiredsecrets: SecretsLike.createHostwiresconfig.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.@suaveplan/api-host/empty-secretssubpath export. The class ships under a dedicated subpath so the host'sEmptySecretsis importable for explicit fallback wiring (e.g. mode-conditional choice betweencreateEnvSecretsandEmptySecrets).- e2e/foundation-stack drops the closure-bound
buildSecretshelper; the route handler now reads viactx.secrets.require(...)directly and the secrets adapter is wired intoHostAdapters.secretsonce at boot.
Impact
Affected dependents
@suaveplan/api-types—PluginContext.secretsis a new REQUIRED field on every context flavour. The 6 in-tree consumers that referenceRequestContextvia 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'sbuildRequestContext, which is updated atomically.@suaveplan/api-host— gainsEmptySecretsclass +./empty-secretssubpath export.HostAdapters.secretsis optional. Pipeline options- lifecycle driver options both gain a threaded
secretsslot. Dependency on@suaveplan/api-secretsis added so the host can reuse theSECRETS_CODES.SECRET_NOT_FOUNDerror code namespace andSecretsErrorsubclass — no new error namespaces are introduced.
- lifecycle driver options both gain a threaded
@suaveplan/e2e-foundation-stack—buildAdapterswirescreateEnvSecrets({ allowedKeys: ["FOUNDATION_SECRET"], source: ... })intoHostAdapters.secrets. The fixture plugin's/api/secret-keyroute handler now resolves viactx.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 readsctx.secrets.require("FOO")via a wired adapter (round-trip); defaultEmptySecretsthrowsSECRETS.SECRET_NOT_FOUNDonrequire;ctx.secretsis the same reference passed via options.empty-secrets.test.ts— get returns undefined, require throwsSECRETS.SECRET_NOT_FOUNDwith{ name, source }context, list returns a frozen empty array stable across calls.