2.9 KiB
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 →
400not 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 (
nullvs. 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
nullAND the route'sauthpolicy isundefined/null(auth-optional):defaultTenant: undefined(the new default) →ctx.tenant === null,ctx.tenantId === null.defaultTenant: null(explicit) → identical toundefined.defaultTenant: TenantContext→ inject verbatim. This preserves pre-foundation-gap-5 behaviour for migrators by setting{ id: "public", slug: "public" }.
-
When the route's
authis required AND the resolver chain misses, the pipeline still raisesHOST.TENANT_NOT_RESOLVED(400) regardless ofdefaultTenant— 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()andfail()propagatetenantId: nullintoPipelineResult.tenantId.PipelineCapabilityResolver.forRequestacceptstenant: 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.