Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
# `api-host` — Foundation gap 5: configurable default tenant
**Package**: `@suaveplan/api-host`
**Status**: PROPOSED
**Type**: behavior-change
## Why
`packages/api-stack/api/api-host/src/request-pipeline/request-pipeline.ts`
hard-coded a `{id: "public" as TenantId, slug: "public"}` fallback
whenever the configured `TenantResolverChain` returned `null` on an
auth-optional route. The `"public"` literal became load-bearing — every
handler that introspected `ctx.tenantId` had to treat the string
`"public"` as a sentinel rather than receiving an honest absence.
This blocked deployments that:
- Genuinely run multi-tenant traffic where there is no public tenant
row in the database, and want resolver miss → `400` not silent fallback.
- Run an actual tenant with `id="public"` and need to distinguish "the
literal public tenant" from "no tenant resolved".
- Want to defer the policy choice (`null` vs. stub vs. error) to the
operator's bootstrap module rather than the framework.
## What changes
ADDED: `HostConfig.defaultTenant?: TenantContext | null` and
`RequestPipelineOptions.defaultTenant?: TenantContext | null`.
MODIFIED: §6 step 1 ("Tenant resolve") of the request pipeline.
- When the resolver chain returns `null` AND the route's `auth` policy
is `undefined`/`null` (auth-optional):
- `defaultTenant: undefined` (the new default) → `ctx.tenant === null`,
`ctx.tenantId === null`.
- `defaultTenant: null` (explicit) → identical to `undefined`.
- `defaultTenant: TenantContext` → inject verbatim. This preserves
pre-foundation-gap-5 behaviour for migrators by setting
`{ id: "public", slug: "public" }`.
- When the route's `auth` is required AND the resolver chain misses,
the pipeline still raises `HOST.TENANT_NOT_RESOLVED` (400) regardless
of `defaultTenant` — the fallback is a convenience for public routes,
not a silent bypass of tenant-required policy.
Downstream pipeline steps now tolerate `tenantCtx === null`:
- Rate-limit key falls back to `"anon"` when no principal AND no tenant
is bound.
- Per-request logger meta carries `tenantId: null`.
- `succeed()` and `fail()` propagate `tenantId: null` into
`PipelineResult.tenantId`.
- `PipelineCapabilityResolver.forRequest` accepts
`tenant: TenantContext | null`.
`RequestContext.tenant` / `RequestContext.tenantId` remain declared as
non-nullable in `@suaveplan/api-types`; handlers consuming tenant-bound
capabilities MUST gate on `tenant === null` first when their host is
configured with `defaultTenant: null`. The contract is documented in
`host.md` and `request-pipeline.types.ts`.
## Migration
Operators relying on the legacy `"public"` stub MUST set
`defaultTenant: { id: "public", slug: "public" }` in `createHost(...)`.
The E2E foundation-stack server has been migrated this way to keep its
existing `tenantId === "public"` assertion green; new deployments SHOULD
leave the field unset.

View File

@@ -0,0 +1,46 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-host (foundation-fix-gap-5-default-tenant)
**Package**: `@suaveplan/api-host`
**Status**: PROPOSED
## MODIFIED Requirements
### Requirement: Request pipeline runs §6 steps in order and is the only entry point for plugin traffic
The package SHALL provide a `RequestPipeline.handle(request: Request)` driver that executes design doc §6 steps in the exact documented order:
1. Route match (host) — returns `HOST.ROUTE_NOT_FOUND` (404) when no route matches.
2. Tenant resolve — non-public routes that fail to resolve a tenant return `HOST.TENANT_NOT_RESOLVED` (400). Public (auth-optional) routes that fail to resolve a tenant fall back according to `HostConfig.defaultTenant` (and the equivalent `RequestPipelineOptions.defaultTenant`): when `undefined` or `null` (the default) `ctx.tenant` and `ctx.tenantId` are propagated as `null`; when set to a `TenantContext` value the value is injected verbatim, preserving the pre-foundation-gap-5 `{id: "public", slug: "public"}` semantics for migrators who opt in explicitly.
3. Session resolve via the configured `SessionResolverChain` — resolver throws emit `api.auth.failures_total`.
4. CSRF — required when `route.options.csrf === true`, the request method is not `GET`/`HEAD`, the route is not `streaming`, AND a principal was resolved. Invalid token → `HOST.CSRF_TOKEN_INVALID` (403).
5. RBAC — `null` principal on a non-public route → `HOST.SESSION_REQUIRED` (401); missing scope → `HOST.RBAC_DENIED` (403) + `api.rbac.denials_total`.
6. Rate limit — keyed by principal id (when present) else tenant id; when neither is bound the limiter keys off the literal `"anon"`. Bucket-empty → `HOST.RATE_LIMIT_EXCEEDED` (429) + `api.rate_limit.exceeded_total`.
7. Idempotency — `route.options.idempotent === true` + `Idempotency-Key` header present: replay → `HOST.IDEMPOTENCY_REPLAY` (409) + `api.idempotency.replays_total`.
8. Request validation — Zod body / params / query when `route.options.runtimeValidation !== false` and route is not streaming; failures emit `HOST.REQUEST_VALIDATION_FAILED` (400) + `api.validation.failures_total{where=request}`.
9. Plugin handler — invoked through `ErrorBoundary.runHandler` with a `RequestContext` carrying tenant, principal, capabilities, parsed body/params/query. When `defaultTenant` is `null` AND the resolver missed, `ctx.tenant` and `ctx.tenantId` are runtime-`null`; handlers consuming tenant-bound capabilities MUST gate on `tenant === null` before dereferencing.
10. Response validation — `enforce` (production) → `HOST.RESPONSE_VALIDATION_FAILED` (500) on mismatch; `warn` (development) → success + `api.validation.failures_total{where=response.warn}`.
11. Audit envelope (when `auditable: true`) + OTel telemetry close — every request emits `api.requests_total{route, plugin, status, outcome}` and `api.request_duration_ms` histogram.
The pipeline SHALL always set the `X-Request-Id` response header. Streaming routes (`streaming: true`) SHALL bypass CSRF, request validation buffering, and response validation.
#### Scenario: 11 steps execute in order on happy path
- **WHEN** a `POST /billing/invoices` with valid principal + scope is dispatched
- **THEN** the response is 200 AND `X-Request-Id` is set AND an audit envelope is emitted for `auditable: true` routes.
#### Scenario: auth-optional route + resolver miss + defaultTenant unset
- **WHEN** the route has no `auth` policy AND every resolver in the chain returns `null` AND `HostConfig.defaultTenant` is unset
- **THEN** the pipeline propagates `ctx.tenant === null`, `ctx.tenantId === null`, and `PipelineResult.tenantId === null`.
#### Scenario: auth-optional route + resolver miss + explicit defaultTenant
- **WHEN** the route has no `auth` policy AND every resolver in the chain returns `null` AND `HostConfig.defaultTenant = { id: "public", slug: "public" }`
- **THEN** the pipeline injects the supplied `TenantContext` verbatim into `ctx.tenant` / `ctx.tenantId` / `PipelineResult.tenantId`.
#### Scenario: auth-required route + resolver miss + defaultTenant set
- **WHEN** the route declares `auth: <scope>` AND every resolver returns `null` AND `HostConfig.defaultTenant` is set to a non-null `TenantContext`
- **THEN** the pipeline raises `HOST.TENANT_NOT_RESOLVED` (400) — the fallback is NEVER used to silently bind an auth-required request to a public tenant.

View File

@@ -0,0 +1,53 @@
# Tasks — foundation gap 5: configurable default tenant
## 1. Implementation
- [x] 1.1 Add `defaultTenant?: TenantContext | null` to
`RequestPipelineOptions` (`request-pipeline.types.ts`).
- [x] 1.2 Add `defaultTenant?: TenantContext | null` to `HostConfig`
(`host/host.types.ts`).
- [x] 1.3 Wire `config.defaultTenant ?? null` through
`host.ts` → `RequestPipeline` constructor.
- [x] 1.4 Replace hard-coded `{id: "public", slug: "public"}` injection
in `request-pipeline.ts` with config-aware logic.
- [x] 1.5 Widen `PipelineCapabilityResolver.forRequest`'s `tenant`
parameter to `TenantContext | null`.
- [x] 1.6 Update downstream pipeline call sites (`rateKey`, logger meta,
`buildRequestContext`, `succeed()`/`fail()` signatures) to tolerate
`tenantCtx === null`.
## 2. Tests
- [x] 2.1 Replace the legacy "public stub" test with a "null tenant by
default" assertion.
- [x] 2.2 Add a test asserting an explicit `defaultTenant` is propagated
verbatim to `ctx.tenant` / `ctx.tenantId` / `PipelineResult.tenantId`.
- [x] 2.3 Add a test asserting auth-required routes still 400 on a
resolver miss regardless of `defaultTenant`.
## 3. Migration
- [x] 3.1 Migrate the foundation-stack E2E (`e2e/foundation-stack/src/server.ts`)
to opt into the legacy fallback (`defaultTenant: { id: "public", ... }`)
so the existing `tenantId === "public"` assertion remains green.
## 4. Docs
- [x] 4.1 Document the new behaviour in
`packages/api-stack/api/api-host/src/host/host.md`.
- [x] 4.2 Document the per-step interaction in
`request-pipeline.types.ts` (`RequestPipelineOptions.defaultTenant`).
## 5. Spec
- [x] 5.1 Author MODIFIED Requirements delta under
`specs/api-host/spec.md`.
- [x] 5.2 Fold delta into `openspec/specs/api-host/spec.md`.
- [x] 5.3 Move change folder to `openspec/archive/2026-05-26-foundation-fix-gap-5-default-tenant/`.
## 6. Gate
- [x] 6.1 `bunx turbo lint typecheck test build --filter=@suaveplan/api-host` green.
- [x] 6.2 `bunx turbo test:coverage --filter=@suaveplan/api-host` —
`request-pipeline.ts` reports 100% line + branch coverage; touched
lines in `host.ts` are exercised by existing `createHost(...)` tests.