Initial Commit
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
# `api-types` — Foundation gap 5 cleanup: nullable tenant on RequestContext
|
||||
|
||||
**Package**: `@suaveplan/api-types`
|
||||
**Status**: PROPOSED
|
||||
**Type**: type-soundness-fix
|
||||
|
||||
## Why
|
||||
|
||||
The `foundation-fix-gap-5-default-tenant` change widened the runtime
|
||||
behaviour: when the resolver chain misses and no `defaultTenant` is
|
||||
configured, `RequestContext.tenant` / `RequestContext.tenantId` are
|
||||
`null` at runtime. The static types in `@suaveplan/api-types` were NOT
|
||||
updated — they still declared `tenant: TenantContext` and
|
||||
`tenantId: TenantId` as non-nullable.
|
||||
|
||||
Consequences observed in `request-pipeline-helpers.ts`:
|
||||
|
||||
```ts
|
||||
tenantId: (args.tenant?.id ?? null) as never,
|
||||
tenant: (args.tenant ?? null) as never,
|
||||
```
|
||||
|
||||
The `as never` is a documented escape hatch — but it is a type lie that
|
||||
defeats every handler-side guard the type system was supposed to
|
||||
enforce. Out-of-spec debt; resolved before moving on per the user's
|
||||
directive.
|
||||
|
||||
## What changes
|
||||
|
||||
MODIFIED: `RequestContext<Caps>` field types in
|
||||
`@suaveplan/api-types/plugin-context`:
|
||||
|
||||
```ts
|
||||
readonly tenantId: TenantId | null;
|
||||
readonly tenant: TenantContext | null;
|
||||
```
|
||||
|
||||
Existing `tenant`/`tenantId` consumers in the monorepo already use the
|
||||
null-safe `?.` access pattern (verified across `e2e/foundation-stack`,
|
||||
`apps/demo`, `apps/testbed`, and every `api-plugin-*`). No handler
|
||||
needed a code change beyond stripping the now-redundant casts.
|
||||
|
||||
The `JobContext` and `ConnectionContext` flavours are NOT widened.
|
||||
Per-tenant jobs and connection-scoped contexts only exist after a
|
||||
tenant has been resolved — their resolver chain runs separately and
|
||||
never returns `null` to the handler. Widening those would force a
|
||||
spurious null-check on every job/connection handler.
|
||||
|
||||
The `as never` casts in `request-pipeline-helpers.ts` lines 143-150 are
|
||||
removed. The two remaining `as` casts at the same site are
|
||||
generic-erasure points (capabilities/instruments resolve to typed
|
||||
slices at the route handler call-site, not at the untyped pipeline
|
||||
seam); they now use the explicit
|
||||
`as RequestContext["capabilities"]` form with a multi-line code comment
|
||||
explaining the boundary instead of a blanket `as never`.
|
||||
|
||||
## Impact
|
||||
|
||||
- `@suaveplan/api-types` — spec MODIFIED Requirement (this delta).
|
||||
- `@suaveplan/api-host` — `as never` casts stripped from
|
||||
`buildRequestContext`; generic-erasure casts retained with the
|
||||
documented boundary comment.
|
||||
- E2E foundation-stack: 11 Playwright tests still green; no handler
|
||||
changes needed since fixture plugin already used `ctx.tenant?.id`.
|
||||
|
||||
## Verification
|
||||
|
||||
```
|
||||
bunx turbo lint typecheck test build --filter=@suaveplan/api-types --filter=@suaveplan/api-host
|
||||
bunx turbo test:coverage --filter=@suaveplan/api-host --filter=@suaveplan/api-types
|
||||
cd e2e/foundation-stack && bunx playwright test # 11/11
|
||||
```
|
||||
|
||||
All green. No `as never` remains for `tenant`/`tenantId` in
|
||||
`request-pipeline-helpers.ts`. No `biome-ignore` / `@ts-ignore` /
|
||||
`@ts-expect-error` added anywhere.
|
||||
@@ -0,0 +1,38 @@
|
||||
# api-types — Foundation gap 5 cleanup delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Plugin context separation
|
||||
|
||||
The package SHALL export `PluginContext<Caps>` and `RequestContext<Caps>`. `RequestContext` SHALL extend `PluginContext` (modulo the differently-typed `db` and `capabilities` slots) and add `request`, `principal`, `tenantId`, `tenant`, `requestId`, `span`. The `db` slot — exposed BOTH as a top-level context field AND as a capability-slice entry when `db` is declared in `Caps` — MUST be typed as `AdminDatabaseLike | null` on `PluginContext` and `TenantScopedDatabaseLike | null` on `RequestContext`. The two types MUST NOT be mutually assignable so an admin-DB consumer cannot accidentally accept a tenant-scoped one without an explicit cast. `null` SHALL be the runtime value when the host has no configured DB adapter, OR — for `RequestContext` only — when the tenant resolver chain returned no tenant on this request.
|
||||
|
||||
`RequestContext.tenant` SHALL be typed `TenantContext | null` and `RequestContext.tenantId` SHALL be typed `TenantId | null`. The nullable union is required because the `foundation-fix-gap-5-default-tenant` change made `defaultTenant: null` the host default — the resolver chain may legitimately miss on auth-optional routes, and the runtime value is then `null` on both fields. Handlers consuming tenant-bound capabilities MUST null-check OR declare `auth: "required"` on the route so the pipeline rejects the request before the handler runs. `JobContext.tenant` / `JobContext.tenantId` and `ConnectionContext.tenant` / `ConnectionContext.tenantId` SHALL remain non-nullable — per-tenant jobs and connection-scoped contexts only exist after a tenant has been resolved.
|
||||
|
||||
Both context flavours SHALL also expose the orchestration-level `secrets: SecretsLike` slot directly — the slot is always present at the type level (the host injects a default when no adapter is configured) and is independent of the capability map. `JobContext` and `ConnectionContext` SHALL likewise expose a tenant-scoped `db: TenantScopedDatabaseLike | null` top-level field AND inherit `secrets` through the same Omit-based extension.
|
||||
|
||||
#### Scenario: db slot has a different type per context
|
||||
|
||||
- **WHEN** a value of type `PluginContext<{db: true}>` and a value of type `RequestContext<{db: true}>` are compared
|
||||
- **THEN** `expectTypeOf<PluginContext<...>.capabilities.db>().not.toEqualTypeOf<RequestContext<...>.capabilities.db>()` passes
|
||||
|
||||
#### Scenario: capability map drives capability-slice field availability
|
||||
|
||||
- **WHEN** `PluginContext<{db: true, "auth.session": true}>` is constructed
|
||||
- **THEN** `ctx.capabilities.db` and `ctx.capabilities["auth.session"]` are required AND `ctx.capabilities.events` is NOT present in the type (unless `"events"` is also declared)
|
||||
|
||||
#### Scenario: secrets slot is always present on every context
|
||||
|
||||
- **WHEN** a plugin's lifecycle hook destructures `{ secrets } = ctx` where `ctx: PluginContext<readonly []>`
|
||||
- **THEN** `secrets` is typed as `SecretsLike` and resolves at runtime to whichever adapter the host configured (or the `EmptySecrets` default)
|
||||
|
||||
#### Scenario: top-level db is null-permissive
|
||||
|
||||
- **WHEN** a host is booted without an `adapters.db` slot
|
||||
- **THEN** `PluginContext.db === null` AND `RequestContext.db === null` at runtime AND the type system requires every consumer to null-check before use
|
||||
|
||||
#### Scenario: RequestContext.tenant is null-permissive after gap-5
|
||||
|
||||
- **WHEN** a route is reached with no tenant resolved AND no `defaultTenant` configured
|
||||
- **THEN** `RequestContext.tenant === null` AND `RequestContext.tenantId === null` at runtime AND the type system requires every handler to null-check or short-circuit via `auth: "required"` before reading tenant-bound fields
|
||||
|
||||
**Verified by:** `src/_typetests/plugin-context.spec-conformance.test.ts::"db slot type differs between PluginContext and RequestContext"` AND `src/_typetests/plugin-context.spec-conformance.test.ts::"capability map drives ctx field set"` AND host integration in `@suaveplan/api-host`'s `src/host/host.test.ts::"ctx.db === null when adapters.db is undefined"`
|
||||
@@ -0,0 +1,20 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Widen `RequestContext.tenant` to `TenantContext | null` in
|
||||
`packages/api-stack/api/api-types/src/plugin-context/plugin-context.types.ts`.
|
||||
- [x] Widen `RequestContext.tenantId` to `TenantId | null` at the same site.
|
||||
- [x] Add nullability rationale to the `RequestContext` TSDoc block —
|
||||
pointer to gap-5 + handler null-check guidance.
|
||||
- [x] Strip `as never` from `tenant` and `tenantId` assignments in
|
||||
`packages/api-stack/api/api-host/src/request-pipeline/request-pipeline-helpers.ts`.
|
||||
- [x] Replace `capabilities`/`instruments` `as never` casts with
|
||||
explicit `as RequestContext["capabilities"]` / `as RequestContext["instruments"]`
|
||||
plus a multi-line comment explaining the generic-erasure boundary.
|
||||
- [x] Audit handler-side consumers (apps/demo, apps/testbed,
|
||||
e2e/foundation-stack, api-plugin-*); confirm none dereference
|
||||
`ctx.tenant.X` without `?.` and no code change needed.
|
||||
- [x] `bunx turbo lint typecheck test build --filter=@suaveplan/api-types --filter=@suaveplan/api-host` green.
|
||||
- [x] `bunx turbo test:coverage --filter=@suaveplan/api-host --filter=@suaveplan/api-types` green.
|
||||
- [x] `cd e2e/foundation-stack && bunx playwright test` — 11/11 green.
|
||||
- [x] Fold MODIFIED delta into `openspec/specs/api-types/spec.md`.
|
||||
- [x] Archive change folder under `openspec/archive/2026-05-26-foundation-fix-gap-5-cleanup/`.
|
||||
Reference in New Issue
Block a user