Initial Commit
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,77 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: api-host (foundation-fix-gap-3-secrets-slot)
|
||||
|
||||
**Package**: `@suaveplan/api-host`
|
||||
**Status**: PROPOSED
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: HostAdapters shape
|
||||
|
||||
The package SHALL export `HostAdapters` with a `secrets?: SecretsLike`
|
||||
optional slot. When the operator omits `secrets`, `createHost` SHALL
|
||||
inject `new EmptySecrets()` into the pipeline options and lifecycle
|
||||
driver options so every `ctx.secrets` access on a route handler or
|
||||
plugin lifecycle hook resolves to a defined `SecretsLike` value.
|
||||
|
||||
#### Scenario: secrets omitted → EmptySecrets default
|
||||
|
||||
- **WHEN** `createHost({ adapters: { router, auth, tenant, capabilities, rateLimit, idempotency, csrf, audit, span, db } })` is called without a `secrets` slot
|
||||
- **THEN** every `RequestContext.secrets.require(<name>)` and `PluginContext.secrets.require(<name>)` throws `SECRETS.SECRET_NOT_FOUND`
|
||||
|
||||
#### Scenario: secrets supplied → adapter is threaded
|
||||
|
||||
- **WHEN** `createHost({ adapters: { ..., secrets: createEnvSecrets({ allowedKeys: ["FOO"], source: () => ({ FOO: "bar" }) }) } })` is called
|
||||
- **THEN** a handler's `await ctx.secrets.require("FOO")` resolves to `"bar"`
|
||||
|
||||
### Requirement: EmptySecrets default
|
||||
|
||||
The package SHALL ship `EmptySecrets` implementing `SecretsLike` from
|
||||
`@suaveplan/api-types/secrets`. `get(name)` MUST always resolve to
|
||||
`undefined`. `require(name)` MUST throw `SecretsError` carrying the
|
||||
canonical `SECRETS.SECRET_NOT_FOUND` code (sourced from
|
||||
`@suaveplan/api-secrets`'s `SECRETS_CODES`) and a `context` field of
|
||||
shape `{ name, source: "host.default.empty-secrets" }`. `list()` MUST
|
||||
return a frozen empty `readonly string[]`. The class MUST be reachable
|
||||
via `import { EmptySecrets } from "@suaveplan/api-host/empty-secrets"`
|
||||
and from the root barrel.
|
||||
|
||||
#### Scenario: get returns undefined
|
||||
|
||||
- **WHEN** `await new EmptySecrets().get("anything")` is called
|
||||
- **THEN** the resolved value is `undefined`
|
||||
|
||||
#### Scenario: require throws SECRETS.SECRET_NOT_FOUND
|
||||
|
||||
- **WHEN** `await new EmptySecrets().require("X_API_KEY")` is called
|
||||
- **THEN** a `SecretsError` is thrown with `code === "SECRETS.SECRET_NOT_FOUND"` and `context.name === "X_API_KEY"` and `context.source === "host.default.empty-secrets"`
|
||||
|
||||
#### Scenario: list is the frozen empty array
|
||||
|
||||
- **WHEN** `new EmptySecrets().list()` is called twice
|
||||
- **THEN** both calls return the same frozen `readonly string[]` reference whose length is 0
|
||||
|
||||
### Requirement: RequestPipeline secrets wiring
|
||||
|
||||
`RequestPipelineOptions` SHALL include a REQUIRED `secrets: SecretsLike`
|
||||
field. The pipeline driver SHALL set `RequestContext.secrets` to the
|
||||
configured `secrets` value on every constructed handler context.
|
||||
|
||||
#### Scenario: pipeline threads secrets into ctx
|
||||
|
||||
- **WHEN** a route handler accesses `ctx.secrets` inside the handler body
|
||||
- **THEN** the value is referentially identical to `pipelineOptions.secrets`
|
||||
|
||||
### Requirement: Lifecycle driver secrets wiring
|
||||
|
||||
`LifecycleDriverOptions` SHALL include an optional `secrets?: SecretsLike`
|
||||
field. When omitted the driver SHALL default to `new EmptySecrets()`.
|
||||
The driver's `callUserHook` SHALL construct a minimal `PluginContext`-
|
||||
shaped object including the `plugin: { name, version }` identity and
|
||||
`secrets` slot before invoking the user-supplied hook.
|
||||
|
||||
#### Scenario: lifecycle hook receives secrets
|
||||
|
||||
- **WHEN** a plugin's `start(ctx)` hook is invoked by the lifecycle driver against a host configured with `adapters.secrets`
|
||||
- **THEN** `ctx.secrets` is referentially identical to that adapter (or `EmptySecrets` when no adapter is configured)
|
||||
@@ -0,0 +1,45 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: api-types (foundation-fix-gap-3-secrets-slot)
|
||||
|
||||
**Package**: `@suaveplan/api-types`
|
||||
**Status**: PROPOSED
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: PluginContext shape
|
||||
|
||||
`PluginContext<Caps>` SHALL include a REQUIRED `secrets: SecretsLike`
|
||||
field referencing `@suaveplan/api-types/secrets`'s `SecretsLike`
|
||||
contract. Plugin authors implementing `install`/`start`/`stop`/
|
||||
`uninstall`/`health` hooks can resolve manifested secrets via
|
||||
`ctx.secrets.get(name)` or `ctx.secrets.require(name)` without a
|
||||
null-check; the host SHALL always wire a concrete adapter (or the
|
||||
`EmptySecrets` default).
|
||||
|
||||
#### Scenario: PluginContext.secrets is reachable
|
||||
|
||||
- **WHEN** a plugin hook destructures `{ secrets } = ctx`
|
||||
- **THEN** `secrets` is typed as `SecretsLike` and resolves at runtime to whichever adapter the host configured
|
||||
|
||||
### Requirement: RequestContext inherits secrets
|
||||
|
||||
`RequestContext<Caps>` SHALL extend `Omit<PluginContext<Caps>,
|
||||
"capabilities">` so the `secrets: SecretsLike` field flows through to
|
||||
route handlers unchanged. Same for `JobContext<Caps>` and
|
||||
`ConnectionContext<Caps>` — both inherit `secrets` from the base.
|
||||
|
||||
#### Scenario: route handler reads ctx.secrets
|
||||
|
||||
- **WHEN** a route handler types its argument as `RequestContext<readonly ["db"]>` and reads `await ctx.secrets.require("STRIPE_KEY")`
|
||||
- **THEN** the call typechecks AND returns the resolved value (or throws `SECRETS.SECRET_NOT_FOUND` if no source carries the key)
|
||||
|
||||
#### Scenario: job handler reads ctx.secrets
|
||||
|
||||
- **WHEN** a job invocation typed as `JobContext<Caps>` reads `await ctx.secrets.get("OPTIONAL_KEY")`
|
||||
- **THEN** the call typechecks AND returns `string | undefined`
|
||||
|
||||
#### Scenario: connection handler reads ctx.secrets
|
||||
|
||||
- **WHEN** a WebSocket/SSE handler typed as `ConnectionContext<Caps>` reads `ctx.secrets.list()`
|
||||
- **THEN** the call typechecks AND returns `readonly string[]`
|
||||
@@ -0,0 +1,19 @@
|
||||
# Tasks — foundation-fix-gap-3-secrets-slot
|
||||
|
||||
- [x] Add `secrets: SecretsLike` REQUIRED field to `PluginContext<Caps>` in `@suaveplan/api-types/plugin-context`; verify the `Omit<PluginContext, "capabilities">` extension causes `RequestContext`, `JobContext`, `ConnectionContext` to inherit the field.
|
||||
- [x] Update `plugin-context.md` to document the new field on every flavour.
|
||||
- [x] Add optional `secrets?: SecretsLike` to `HostAdapters` in `@suaveplan/api-host/host/host.types.ts`; update Zod adapter shape in `host.ts` to accept the optional field.
|
||||
- [x] Add `EmptySecrets` class at `packages/api-stack/api/api-host/src/empty-secrets/{empty-secrets.ts,empty-secrets.test.ts,empty-secrets.md,index.ts}`; `get` returns `undefined`, `require` throws `SECRETS.SECRET_NOT_FOUND` via `SecretsError`, `list` returns frozen empty array.
|
||||
- [x] Wire `./empty-secrets` subpath export in `api-host/package.json` and re-export from the root barrel.
|
||||
- [x] Add `@suaveplan/api-secrets` workspace dependency to `api-host/package.json` so `SecretsError` + `SECRETS_CODES` are reachable.
|
||||
- [x] Thread `secrets: SecretsLike` through `RequestPipelineOptions`; update `request-pipeline-helpers.buildRequestContext` to accept + propagate; update `request-pipeline-driver.runPipeline` to pass `config.options.secrets`.
|
||||
- [x] Wire `LifecycleDriverOptions.secrets` and store on the driver; update `callUserHook` to pass `{ plugin, secrets }` to user hooks instead of `{}`.
|
||||
- [x] In `createHost`/`HostImpl`, construct `secrets = adapters.secrets ?? new EmptySecrets()` and inject into both pipeline options and lifecycle driver options.
|
||||
- [x] Update `request-pipeline-outcomes.test.ts` and `request-pipeline.test.ts` (3 pipeline constructor sites) to supply `secrets: new EmptySecrets()`.
|
||||
- [x] Add `request-pipeline-secrets.test.ts` covering: (a) handler reads `ctx.secrets.require("FOO")` via a wired stub, (b) `EmptySecrets` throws `SECRETS.SECRET_NOT_FOUND` and yields 500 envelope, (c) `ctx.secrets` is the same reference passed via options.
|
||||
- [x] Remove the `buildSecrets()` closure + `secrets.require(SECRET_KEY)` binding from `e2e/foundation-stack/src/fixture-plugin.ts`; route handler reads via `ctx.secrets.require("FOUNDATION_SECRET")`.
|
||||
- [x] Wire `createEnvSecrets(...)` into `HostAdapters.secrets` in `e2e/foundation-stack/src/server.ts`; update the inline gap-3 deviation comment to "CLOSED".
|
||||
- [x] Verify gate: `bunx turbo lint typecheck test build --filter=@suaveplan/api-host --filter=@suaveplan/api-types --filter=@suaveplan/e2e-foundation-stack` → green.
|
||||
- [x] Verify coverage: 100% line on touched files (api-host empty-secrets, lifecycle, host, request-pipeline driver/helpers/types).
|
||||
- [x] E2E: re-run `cd e2e/foundation-stack && bunx playwright test` — all 10 tests pass post-shim removal, including the secrets-redaction test.
|
||||
- [x] Fold deltas into canonical `openspec/specs/api-host/spec.md` and `openspec/specs/api-types/spec.md`; move folder to `openspec/archive/2026-05-26-foundation-fix-gap-3-secrets-slot/`.
|
||||
Reference in New Issue
Block a user