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

89 lines
5.9 KiB
Markdown

# @suaveplan/api-tenant-resolver-subdomain — tenant resolver for the Suaveplan API plugin system. Extracts the tenant slug from the request hostname (subdomain) — e.g. `acme.api.example.com` → tenant `acme` — strips a configured `baseDomain`, validates the remaining label, and returns a `TenantContext`. Multi-label support via `tenantLabelIndex` (default 0). `X-Forwarded-Host` is honoured only when `trustForwardedHost: true` AND the upstream IP matches `trustedProxyCidrs`; otherwise strict mode raises `TENANT_SUBDOMAIN_FORWARDED_HOST_UNTRUSTED`. Optional tenant `registry` callback for slug → id mapping. Strict-mode (`strict: true`) raises stable `TENANT_SUBDOMAIN_*` codes on every invalid path; non-strict returns `null` to advance the resolver chain.
Stage-8 tenant resolver for the Suaveplan API plugin system. Reads
the inbound `Host` header (and optionally a CIDR-trusted
`X-Forwarded-Host`), strips a configured `baseDomain`, validates the
remaining label, and returns a `TenantContext` for the host's
per-request pipeline.
```ts
import { createSubdomainTenantResolver } from "@suaveplan/api-tenant-resolver-subdomain";
const resolver = createSubdomainTenantResolver({
baseDomain: "api.example.com",
});
const ctx = await resolver.resolve(request);
// → { id: "acme", slug: "acme" } | null
```
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — Defines the namespaced `TENANT_SUBDOMAIN.*` error-code registry plus the concrete `SubdomainTenantResolverError` class…
- [error-codes](./src/error-codes/error-codes.md) — Defines the namespaced `TENANT_SUBDOMAIN.*` error-code registry plus the concrete `SubdomainTenantResolverError` class…
- [forwarded](./src/forwarded/forwarded.md) — Implements CIDR-based trust enforcement for the optional `X-Forwarded-Host` header. Edge proxies (CDN, load balancer, r…
- [forwarded](./src/forwarded/forwarded.md) — Implements CIDR-based trust enforcement for the optional `X-Forwarded-Host` header. Edge proxies (CDN, load balancer, r…
- [options](./src/options/options.md) — Defines and validates the public option bundle accepted by `createSubdomainTenantResolver`. Every option is bounded by…
- [options](./src/options/options.md) — Defines and validates the public option bundle accepted by `createSubdomainTenantResolver`. Every option is bounded by…
- [otel](./src/otel/instruments.md) — Owns the single OTel counter `api.tenant.resolves_total` that the subdomain resolver increments on every invocation. Th…
- [instruments](./src/otel/instruments.md) — Owns the single OTel counter `api.tenant.resolves_total` that the subdomain resolver increments on every invocation. Th…
- [resolver](./src/resolver/resolver.md) — Builds the Stage-8 `TenantResolver` that derives the tenant from the inbound request's hostname (subdomain). For a host…
- [resolver](./src/resolver/resolver.md) — Builds the Stage-8 `TenantResolver` that derives the tenant from the inbound request's hostname (subdomain). For a host…
- [validate](./src/validate/validate.md) — Provides the default RFC-1035-style hostname-label validator (`defaultLabelValidator`) plus two pure string helpers (`n…
- [validate](./src/validate/validate.md) — Provides the default RFC-1035-style hostname-label validator (`defaultLabelValidator`) plus two pure string helpers (`n…
<!-- end:auto-docs -->
## Why a dedicated package
The tenant-resolver chain (design doc §6, step 4) runs after the
session resolver. Public-facing deployments commonly serve every
tenant at `<slug>.api.example.com` — the resolver job is to take the
hostname, strip the apex, and emit a tenant slug. Collapsing that into
a dedicated adapter — rather than baking it into `api-host` —
keeps the contract orthogonal and lets operators chain
`tenant-resolver-jwt`, `tenant-resolver-header`, and
`tenant-resolver-subdomain` as separate links.
## Chain interaction
The host runs resolvers left-to-right; the first non-`null` result
wins:
```
[ tenant-resolver-jwt, tenant-resolver-header, tenant-resolver-subdomain ]
| | |
reads principal reads X-Tenant-Id reads sub.domain
(typical bearer) (admin / S2S calls) (public site routes)
```
When the inbound host has no leading labels (the request hit the apex
`api.example.com`), `tenant-resolver-subdomain` returns `null` and the
next resolver runs. With `strict: true` the chain stops here and a
400-equivalent is surfaced via a stable `TENANT_SUBDOMAIN.*` code.
## Modules
- `error-codes/` — `TENANT_SUBDOMAIN_CODES` + `SubdomainTenantResolverError`.
- `validate/` — RFC-1035 label validator + `normalizeHost` + `stripBaseDomain`.
- `forwarded/` — CIDR parser + `isTrustedProxy` matcher.
- `options/` — Zod-validated `SubdomainTenantResolverOptions`.
- `otel/` — `api.tenant.resolves_total` counter.
- `resolver/` — `createSubdomainTenantResolver` (the entry point).
## Strict mode summary
| Failure | non-strict | strict (raises code) |
|----------------------------------|------------|--------------------------------------------------|
| host missing / equals baseDomain | `null` | `null` (advance — not an error) |
| host doesn't end with baseDomain | `null` | `TENANT_SUBDOMAIN.BASE_DOMAIN_MISMATCH` |
| label fails validator | `null` | `TENANT_SUBDOMAIN.INVALID` |
| registry returns null | `null` | `TENANT_SUBDOMAIN.NOT_FOUND_IN_REGISTRY` |
| X-Forwarded-Host from untrusted | `null` | `TENANT_SUBDOMAIN.FORWARDED_HOST_UNTRUSTED` |
| invalid options at construction | n/a | `TENANT_SUBDOMAIN.INVALID_OPTIONS` |
## Stability
`0.1.0` — additive only. `api-host` wires this when
`host.tenant.resolvers` includes `"subdomain"`.