Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
# @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"`.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# `error-codes` — stable error-code registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the namespaced `TENANT_SUBDOMAIN.*` error-code registry plus the
|
||||
concrete `SubdomainTenantResolverError` class for the
|
||||
`@suaveplan/api-tenant-resolver-subdomain` package. Every public failure
|
||||
path in the resolver constructs an error with one of these codes so
|
||||
consumers can branch on a stable string value (e.g. `error.code ===
|
||||
TENANT_SUBDOMAIN_CODES.BASE_DOMAIN_MISMATCH`) and `instanceof
|
||||
SubdomainTenantResolverError` narrows `code` to the union type.
|
||||
|
||||
These codes back The host-side mapping (4xx vs 5xx response
|
||||
shape) and the OTel `outcome` label so an operator dashboarding tenant
|
||||
resolution sees the exact failure mode without parsing free-text
|
||||
messages.
|
||||
|
||||
## Features
|
||||
|
||||
- Five stable codes: `INVALID`, `NOT_FOUND_IN_REGISTRY`,
|
||||
`BASE_DOMAIN_MISMATCH`, `FORWARDED_HOST_UNTRUSTED`, `INVALID_OPTIONS`.
|
||||
- `defineCodes` from `@suaveplan/error` — values are frozen and the
|
||||
namespace prefix (`TENANT_SUBDOMAIN.`) is applied once.
|
||||
- Subclass with public-constructor lint compliance and a redundant
|
||||
`subdomainTenantResolverCode` field for downstream type narrowing.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
SubdomainTenantResolverError,
|
||||
TENANT_SUBDOMAIN_CODES,
|
||||
} from "@suaveplan/api-tenant-resolver-subdomain/error-codes";
|
||||
|
||||
throw new SubdomainTenantResolverError(
|
||||
TENANT_SUBDOMAIN_CODES.INVALID,
|
||||
"Label 'ACME!' contains forbidden characters"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
SubdomainTenantResolverError,
|
||||
TENANT_SUBDOMAIN_CODES,
|
||||
} from "@suaveplan/api-tenant-resolver-subdomain/error-codes";
|
||||
|
||||
try {
|
||||
await resolver.resolve(request);
|
||||
} catch (e) {
|
||||
if (e instanceof SubdomainTenantResolverError) {
|
||||
switch (e.code) {
|
||||
case TENANT_SUBDOMAIN_CODES.BASE_DOMAIN_MISMATCH:
|
||||
return new Response("Wrong domain", { status: 400 });
|
||||
case TENANT_SUBDOMAIN_CODES.FORWARDED_HOST_UNTRUSTED:
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
default:
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `TENANT_SUBDOMAIN_CODES` — frozen record of code constants.
|
||||
- `SubdomainTenantResolverErrorCode` — union of the registered codes.
|
||||
- `SubdomainTenantResolverError` — concrete subclass of
|
||||
`SuaveplanError<SubdomainTenantResolverErrorCode>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is built with `defineCodes("TENANT_SUBDOMAIN", [...] as const)`
|
||||
so the namespace prefix is enforced once and every value is statically
|
||||
typed as `TENANT_SUBDOMAIN.<NAME>`. The error subclass exposes a public
|
||||
constructor (per the api-stack public-constructor lint rule) and carries
|
||||
a `subdomainTenantResolverCode` field so callers that down-cast on the
|
||||
narrow class don't have to re-narrow the inherited `code` field.
|
||||
@@ -0,0 +1,83 @@
|
||||
# `forwarded` — `X-Forwarded-Host` trust enforcement
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements CIDR-based trust enforcement for the optional
|
||||
`X-Forwarded-Host` header. Edge proxies (CDN, load balancer, reverse
|
||||
proxy) rewrite the inbound `Host` to their internal hostname and stash
|
||||
the public host on `X-Forwarded-Host`. The resolver may honour that
|
||||
header, but ONLY when (a) the operator opted in via
|
||||
`trustForwardedHost: true` and (b) the upstream IP belongs to a
|
||||
trusted-proxy CIDR — otherwise a malicious client could spoof its
|
||||
tenant by sending `X-Forwarded-Host: victim.api.example.com` directly
|
||||
to the origin.
|
||||
|
||||
This module owns the parser (`parseCidr`, `parseIp`) and the matcher
|
||||
(`cidrMatches`, `isTrustedProxy`) used by the resolver hot-path.
|
||||
|
||||
## Features
|
||||
|
||||
- IPv4 and IPv6 support (including `::` shorthand and zone-id stripping).
|
||||
- Byte-array comparison with bit-aware prefix masking — no string
|
||||
manipulation in the hot-path.
|
||||
- Family-disjoint match — an IPv6 CIDR never matches an IPv4 address.
|
||||
- `parseCidr` returns `null` on malformed input so the caller (options
|
||||
parser) surfaces a stable `INVALID_OPTIONS` error rather than
|
||||
silently widening the trust set.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
isTrustedProxy,
|
||||
parseCidr,
|
||||
} from "@suaveplan/api-tenant-resolver-subdomain/forwarded";
|
||||
|
||||
const cidrs = ["10.0.0.0/8", "2001:db8::/32"]
|
||||
.map((c) => parseCidr(c)!)
|
||||
.filter((c) => c !== null);
|
||||
isTrustedProxy("10.4.7.2", cidrs); // → true
|
||||
isTrustedProxy("8.8.8.8", cidrs); // → false
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
cidrMatches,
|
||||
parseCidr,
|
||||
parseIp,
|
||||
} from "@suaveplan/api-tenant-resolver-subdomain/forwarded";
|
||||
|
||||
const cidr = parseCidr("172.16.0.0/12");
|
||||
const ip = parseIp("172.16.5.99");
|
||||
if (cidr !== null && ip !== null && cidrMatches(ip, cidr)) {
|
||||
// ...trusted...
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ParsedCidr` — `{ bytes: Uint8Array; prefix: number; family: 4 | 6 }`.
|
||||
- `parseCidr(input: string): ParsedCidr | null`.
|
||||
- `parseIp(addr: string): { bytes: Uint8Array; family: 4 | 6 } | null`.
|
||||
- `cidrMatches(ip, cidr): boolean`.
|
||||
- `isTrustedProxy(remoteAddress, cidrs): boolean`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
IPv6 parsing handles the canonical `::` double-colon expansion, zone-id
|
||||
suffixes (`%eth0` is stripped — never meaningful for trust checks),
|
||||
and rejects > 8 groups or non-hex content. Prefix matching extracts
|
||||
the full-byte run with a byte-equality loop, then applies a
|
||||
high-bit mask to the trailing partial byte; `prefix === 0` is the
|
||||
"match everything in this family" case and is handled implicitly
|
||||
(loop runs zero times, tail short-circuit returns `true`).
|
||||
|
||||
The module is dependency-free — no `node:net`, no `ipaddr.js`. This
|
||||
keeps the package universal and the CIDR parsing observable in
|
||||
tests without runtime injection.
|
||||
@@ -0,0 +1,84 @@
|
||||
# `options` — Zod-validated resolver options
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines and validates the public option bundle accepted by
|
||||
`createSubdomainTenantResolver`. Every option is bounded by a Zod
|
||||
schema at construction time so an operator that misspells a key,
|
||||
supplies an invalid CIDR, or passes a negative `tenantLabelIndex`
|
||||
discovers the mistake immediately rather than at the first inbound
|
||||
request. The resolved bundle replaces every `??` ladder so the
|
||||
resolver body stays small and the hot-path is allocation-free.
|
||||
|
||||
The shape mirrors `@suaveplan/api-tenant-resolver-header` /
|
||||
`-jwt` (id + strict + validator + registry) plus the subdomain-specific
|
||||
fields (`baseDomain`, `tenantLabelIndex`, `trustForwardedHost`,
|
||||
`trustedProxyCidrs`) and a `getTransport` extractor so the host can
|
||||
inject framework-specific request metadata.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict Zod schema — extra keys fail validation.
|
||||
- CIDR pre-parsing at construction time so the per-request hot-path is
|
||||
byte-array prefix comparison only.
|
||||
- Stable `INVALID_OPTIONS` code on every failure (Zod, CIDR parse,
|
||||
missing option object).
|
||||
- `LabelValidator` and `TenantRegistryLookup` opt-in extension points.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseSubdomainTenantResolverOptions } from "@suaveplan/api-tenant-resolver-subdomain/options";
|
||||
|
||||
const resolved = parseSubdomainTenantResolverOptions({
|
||||
baseDomain: "api.example.com",
|
||||
});
|
||||
// → { id: "subdomain", baseDomain: "api.example.com",
|
||||
// tenantLabelIndex: 0, strict: false, ... }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
import { parseSubdomainTenantResolverOptions } from "@suaveplan/api-tenant-resolver-subdomain/options";
|
||||
|
||||
const registry = new Map<string, TenantId>([
|
||||
["acme", "t_acme" as TenantId],
|
||||
]);
|
||||
|
||||
const resolved = parseSubdomainTenantResolverOptions({
|
||||
id: "primary",
|
||||
baseDomain: "api.example.com",
|
||||
tenantLabelIndex: 0,
|
||||
strict: true,
|
||||
trustForwardedHost: true,
|
||||
trustedProxyCidrs: ["10.0.0.0/8", "2001:db8::/32"],
|
||||
registry: (slug) => registry.get(slug) ?? null,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SubdomainTenantResolverOptions` — public input bundle.
|
||||
- `ResolvedSubdomainTenantResolverOptions` — internal, every default
|
||||
applied, CIDRs pre-parsed.
|
||||
- `parseSubdomainTenantResolverOptions(input): Resolved...`.
|
||||
- `TenantRegistryLookup` — slug → `TenantId | null` (sync or async).
|
||||
- `RequestTransport` — `{ host, forwardedHost, remoteAddress }`.
|
||||
- `TransportExtractor` — `(request: Request) => RequestTransport`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema is `.strict()` so a typo (`requirePrincipal` instead of
|
||||
`requireTenant`) is a hard error at construction. `validate` and
|
||||
`registry` are stripped before the Zod parse — `z.function()` is
|
||||
deprecated and unnecessary here because runtime function calls already
|
||||
fail loudly if the wrong shape is passed. `trustedProxyCidrs` is
|
||||
re-parsed through `parseCidr` so every CIDR is normalised to a
|
||||
`ParsedCidr` once; this is the only allocation per resolver
|
||||
construction (per-request matching uses the cached array).
|
||||
@@ -0,0 +1,76 @@
|
||||
# `otel/instruments` — OpenTelemetry instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the single OTel counter `api.tenant.resolves_total` that the
|
||||
subdomain resolver increments on every invocation. The counter is
|
||||
labelled with `{ resolver: "subdomain", resolver_id, outcome }` where
|
||||
`outcome ∈ { "resolved", "advance", "rejected" }`. The label-set is
|
||||
designed so a host composing multiple resolvers (`jwt` → `header` →
|
||||
`subdomain`) can aggregate across the chain by `resolver` and still
|
||||
slice per-adapter by `resolver_id`.
|
||||
|
||||
Instruments are resolved lazily on first access so a test harness can
|
||||
install a custom `MeterProvider` BEFORE importing any production module
|
||||
and capture every observation. `reset...Instruments()` is the
|
||||
companion test-only seam.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy meter resolution against the global `MeterProvider`.
|
||||
- Single counter, three outcome labels — no over-instrumented surface.
|
||||
- Test reset hook for deterministic per-test observation.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getSubdomainTenantResolverInstruments } from "@suaveplan/api-tenant-resolver-subdomain/otel";
|
||||
|
||||
const { resolves } = getSubdomainTenantResolverInstruments();
|
||||
resolves.add(1, { resolver: "subdomain", outcome: "resolved" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
getSubdomainTenantResolverInstruments,
|
||||
resetSubdomainTenantResolverInstruments,
|
||||
} from "@suaveplan/api-tenant-resolver-subdomain/otel";
|
||||
|
||||
// Test harness — install MeterProvider then reset the cache so the
|
||||
// next access binds to the test provider.
|
||||
resetSubdomainTenantResolverInstruments();
|
||||
const inst = getSubdomainTenantResolverInstruments();
|
||||
// ...run resolver, flush exporter, assert datapoints...
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SubdomainTenantResolverInstruments` — `{ meter, resolves }`.
|
||||
- `getSubdomainTenantResolverInstruments(): Instruments`.
|
||||
- `resetSubdomainTenantResolverInstruments(): void` (test-only).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The label `resolver` is the canonical chain identifier (always
|
||||
`"subdomain"` in this package). `resolver_id` carries the per-instance
|
||||
id from `SubdomainTenantResolverOptions.id` (default `"subdomain"`) so
|
||||
operators that chain multiple subdomain resolvers — e.g. `tenant` and
|
||||
`region` mounted at different `tenantLabelIndex` values — still see
|
||||
disjoint metric streams. `outcome` is the failure-mode discriminator
|
||||
matching the `TENANT_SUBDOMAIN.*` code namespace (`rejected` ↔ any of
|
||||
`INVALID`, `BASE_DOMAIN_MISMATCH`, `FORWARDED_HOST_UNTRUSTED`,
|
||||
`NOT_FOUND_IN_REGISTRY`).
|
||||
|
||||
The cached instrument bundle pattern is shared with every other
|
||||
api-stack OTel-bearing package — the meter and counter handles bind
|
||||
once and the `add` calls reuse the same descriptors so the OTel SDK
|
||||
amortises the attribute-set allocation per data-point. Tests stamp
|
||||
the global `MeterProvider` from the `tests/setup/polyfills.ts`
|
||||
side-effect import, then call `resetSubdomainTenantResolverInstruments`
|
||||
in `afterEach` so each test sees its own counter slice.
|
||||
@@ -0,0 +1,100 @@
|
||||
# `resolver` — `createSubdomainTenantResolver`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Builds The `TenantResolver` that derives the tenant from the
|
||||
inbound request's hostname (subdomain). For a host
|
||||
`acme.api.example.com` configured with `baseDomain: "api.example.com"`,
|
||||
the resolver returns `{ id: "acme" as TenantId, slug: "acme" }`. The
|
||||
chain-advance sentinel (`null`) is returned when the host is missing,
|
||||
equals the base domain, or fails validation in non-strict mode. Strict
|
||||
mode throws a `SubdomainTenantResolverError` carrying one of four
|
||||
discriminator codes so the host can emit a 4xx-shaped response.
|
||||
|
||||
The transport extractor is dependency-injected so the resolver works
|
||||
identically under Node / Bun / Cloudflare Workers / Hono — every host
|
||||
attaches `host`, `forwardedHost`, and `remoteAddress` via whichever
|
||||
framework primitive it prefers.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure function — no per-request allocations beyond the returned
|
||||
`TenantContext`.
|
||||
- Multi-label support via `tenantLabelIndex` (default 0 — leftmost).
|
||||
- Optional `registry` callback for slug → `TenantId` projection.
|
||||
- `X-Forwarded-Host` consulted only when `trustForwardedHost: true` AND
|
||||
the upstream IP matches `trustedProxyCidrs`.
|
||||
- OTel counter on every outcome (`resolved | advance | rejected`).
|
||||
- Strict-mode parity with the JWT / header siblings.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createSubdomainTenantResolver } from "@suaveplan/api-tenant-resolver-subdomain";
|
||||
|
||||
const resolver = createSubdomainTenantResolver({
|
||||
baseDomain: "api.example.com",
|
||||
});
|
||||
|
||||
const req = new Request("https://acme.api.example.com/v1/health", {
|
||||
headers: { host: "acme.api.example.com" },
|
||||
});
|
||||
const ctx = await resolver.resolve(req);
|
||||
// → { id: "acme", slug: "acme" }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
import { createSubdomainTenantResolver } from "@suaveplan/api-tenant-resolver-subdomain";
|
||||
|
||||
const registry = new Map<string, TenantId>([
|
||||
["acme", "t_acme" as TenantId],
|
||||
]);
|
||||
|
||||
const resolver = createSubdomainTenantResolver({
|
||||
id: "primary",
|
||||
baseDomain: "api.example.com",
|
||||
tenantLabelIndex: 0,
|
||||
strict: true,
|
||||
trustForwardedHost: true,
|
||||
trustedProxyCidrs: ["10.0.0.0/8"],
|
||||
registry: (slug) => registry.get(slug) ?? null,
|
||||
getTransport: (req) => ({
|
||||
host: req.headers.get("host"),
|
||||
forwardedHost: req.headers.get("x-forwarded-host"),
|
||||
remoteAddress: (req as unknown as { ip?: string }).ip ?? null,
|
||||
}),
|
||||
});
|
||||
|
||||
const req = new Request("https://internal.lb/v1/x", {
|
||||
headers: {
|
||||
host: "internal.lb",
|
||||
"x-forwarded-host": "acme.eu.api.example.com",
|
||||
},
|
||||
});
|
||||
Object.defineProperty(req, "ip", { value: "10.4.7.2" });
|
||||
const ctx = await resolver.resolve(req);
|
||||
// → { id: "t_acme", slug: "acme" }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createSubdomainTenantResolver(options): TenantResolver`.
|
||||
- `defaultTransportExtractor: TransportExtractor`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The host-selection step is split into a small `chooseHost` helper so
|
||||
the strict-mode `FORWARDED_HOST_UNTRUSTED` branch is reachable without
|
||||
duplicating the base / forwarded fallback for the non-trust path.
|
||||
Outcomes are recorded as soon as the branch is committed (before the
|
||||
strict-mode `throw`) so the counter is monotonic across strict and
|
||||
non-strict deployments — operators get the same dashboard regardless
|
||||
of mode. The registry callback may be synchronous (`Map.get`) or async
|
||||
(DB lookup); the awaited result is always coerced to `TenantId | null`.
|
||||
@@ -0,0 +1,78 @@
|
||||
# `validate` — tenant-label validation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the default RFC-1035-style hostname-label validator
|
||||
(`defaultLabelValidator`) plus two pure string helpers (`normalizeHost`,
|
||||
`stripBaseDomain`) that the resolver uses to take an inbound `Host`
|
||||
header all the way down to a candidate tenant slug. Splitting these
|
||||
helpers out of the resolver body keeps the resolver readable and the
|
||||
host-parsing edge cases unit-testable in isolation.
|
||||
|
||||
Tenant labels are persisted as `tenantId` and compared against by
|
||||
downstream RBAC / row-level filters, so the canonical form is enforced
|
||||
here: lower-case ASCII, 1..63 characters, `[a-z0-9-]`, no leading or
|
||||
trailing hyphen.
|
||||
|
||||
## Features
|
||||
|
||||
- `defaultLabelValidator` — RFC 1035 §2.3.1 LDH-style validator.
|
||||
- `MAX_LABEL_LENGTH` constant exported for downstream schemas.
|
||||
- `normalizeHost(raw)` — strip `:port` suffix, lower-case, refuse the
|
||||
empty / starts-with-`:` cases.
|
||||
- `stripBaseDomain(host, baseDomain)` — tri-state return:
|
||||
`string` (labels), `null` (exact match — no tenant), `undefined`
|
||||
(mismatch — caller decides whether to raise).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
defaultLabelValidator,
|
||||
normalizeHost,
|
||||
stripBaseDomain,
|
||||
} from "@suaveplan/api-tenant-resolver-subdomain/validate";
|
||||
|
||||
const host = normalizeHost("Acme.API.Example.com:443");
|
||||
// → "acme.api.example.com"
|
||||
const labels = stripBaseDomain(host!, "api.example.com");
|
||||
// → "acme"
|
||||
defaultLabelValidator(labels!); // → true
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { defaultLabelValidator } from "@suaveplan/api-tenant-resolver-subdomain/validate";
|
||||
|
||||
const RESERVED = new Set(["www", "admin", "api"]);
|
||||
const customValidator = (label: string): boolean => {
|
||||
if (!defaultLabelValidator(label)) return false;
|
||||
return !RESERVED.has(label);
|
||||
};
|
||||
// → pass `customValidator` as `validate` in SubdomainTenantResolverOptions
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `LabelValidator` — `(label: string) => boolean`.
|
||||
- `defaultLabelValidator: LabelValidator`.
|
||||
- `MAX_LABEL_LENGTH: 63`.
|
||||
- `normalizeHost(raw: string | null | undefined): string | null`.
|
||||
- `stripBaseDomain(host, baseDomain): string | null | undefined`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The label validator is hand-coded against the character-code range
|
||||
(`0x30..0x39`, `0x61..0x7a`, `0x2d`) rather than a regex so the
|
||||
hot-path has no allocator overhead. The tri-state `stripBaseDomain`
|
||||
return is the standard "missing vs absent vs malformed" pattern from
|
||||
the rest of the codebase: callers MUST distinguish "host is exactly the
|
||||
apex" (advance the chain — common for marketing pages) from "host has
|
||||
the wrong apex" (strict-mode error). `normalizeHost` does not honour
|
||||
IPv6 bracketed literals (`[::1]`) — tenant resolution by IPv6 host is
|
||||
not a supported deployment shape.
|
||||
Reference in New Issue
Block a user