Files
2026-05-27 23:29:53 +01:00

69 lines
2.9 KiB
Markdown

# `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.