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,102 @@
# @suaveplan/api-tenant-resolver-header — header-based tenant resolver for the Suaveplan API plugin system. Implements the TenantResolver contract from @suaveplan/api-types by reading the tenant identifier from a configurable HTTP header (default X-Tenant-Id), validating against a regex (default ^[a-z0-9-]{1,64}$) and an optional registry (a `lookup`-shaped TenantLookup compatible with @suaveplan/api-tenant-provider), and enforcing trusted-source metadata so end-user clients cannot spoof tenant headers. Strict mode raises namespaced TENANT_HEADER_INVALID / NOT_FOUND_IN_REGISTRY / UNTRUSTED_SOURCE errors; non-strict mode returns null so the resolver chain advances. Emits api.tenant.resolves_total{resolver="header"} counters via the OTel global meter.
Stage-8 tenant resolver. Implements the `TenantResolver` contract from
`@suaveplan/api-types/tenant-resolver` by reading the tenant identifier from
a configurable HTTP header (default `X-Tenant-Id`), validating its shape
against a regex (default `^[a-z0-9-]{1,64}$`), optionally probing a
registry, and enforcing trusted-source metadata so end-user clients cannot
spoof the tenant.
```ts
import { createHeaderTenantResolver } from "@suaveplan/api-tenant-resolver-header";
const resolver = createHeaderTenantResolver();
// service-mesh middleware stamps request.metadata.source = "service-mesh"
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 stable error-code registry for `@suaveplan/api-tenant-resolver-header`. Every public failure path raises a…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry for `@suaveplan/api-tenant-resolver-header`. Every public failure path raises a…
- [instruments](./src/instruments/instruments.md) — Builds the OTel instrument bundle the header tenant resolver emits on every `resolve()` invocation. Centralising instru…
- [instruments](./src/instruments/instruments.md) — Builds the OTel instrument bundle the header tenant resolver emits on every `resolve()` invocation. Centralising instru…
- [options](./src/options/options.md) — Defines the Zod-validated options schema accepted by `createHeaderTenantResolver`. Construction-time validation guarant…
- [options](./src/options/options.md) — Defines the Zod-validated options schema accepted by `createHeaderTenantResolver`. Construction-time validation guarant…
- [resolver](./src/resolver/resolver.md) — Implements the Stage-8 tenant resolver factory `createHeaderTenantResolver`. The resolver reads the tenant identifier f…
- [resolver](./src/resolver/resolver.md) — Implements the Stage-8 tenant resolver factory `createHeaderTenantResolver`. The resolver reads the tenant identifier f…
- [trust](./src/trust/trust.md) — Encapsulates the trusted-source enforcement consulted by the header tenant resolver before it acts on a header value. T…
- [trust](./src/trust/trust.md) — Encapsulates the trusted-source enforcement consulted by the header tenant resolver before it acts on a header value. T…
- [validate](./src/validate/validate.md) — Owns the default tenant-id validator regex and the validator / registry function signatures consumed by `createHeaderTe…
- [validate](./src/validate/validate.md) — Owns the default tenant-id validator regex and the validator / registry function signatures consumed by `createHeaderTe…
<!-- end:auto-docs -->
## Why a dedicated package
The tenant-resolver chain (design doc §6 step 4) runs after the session
resolver. Service-to-service traffic frequently carries the tenant in an
HTTP header (`X-Tenant-Id`, `X-Acme-Tenant`, …) because no end-user session
exists to resolve it from. This adapter handles that case while preventing
header spoofing from public traffic via the trusted-source gate.
## Chain interaction
The host runs resolvers left-to-right; the first non-`null` result wins:
```text
[ api-tenant-resolver-jwt, api-tenant-resolver-header, api-tenant-resolver-subdomain ]
| | |
reads principal reads X-Tenant-Id reads sub.domain
(typical bearer) (admin / S2S calls) (public site routes)
```
`api-tenant-resolver-header` returns `null` when the header is absent, when
the request source is untrusted (non-strict), when the value fails
validation (non-strict), or when the value isn't in the registry
(non-strict). In strict mode each of those paths raises the corresponding
`TENANT_HEADER_*` code.
## Modules
- `./resolver` — `createHeaderTenantResolver`,
`CreateHeaderTenantResolverOptions`.
- `./options` — `HeaderTenantResolverOptions`,
`parseHeaderTenantResolverOptions`, `ResolvedHeaderTenantResolverOptions`.
- `./validate` — `defaultTenantIdValidator`, `DEFAULT_TENANT_ID_PATTERN`,
`TenantIdValidator`, `TenantRegistryProbe`.
- `./trust` — `defaultMetadataExtractor`, `isTrustedSource`,
`RequestMetadataExtractor`, `RequestTrustMetadata`.
- `./instruments` — `createHeaderResolverInstruments`,
`HeaderResolverInstruments`, `HeaderResolveOutcome`.
- `./error-codes` — `HeaderTenantResolverError`,
`HeaderTenantResolverErrorCode`, `TENANT_HEADER_CODES`.
## Options
| Option | Default | Effect |
| ---------------- | -------------------- | ---------------------------------------------------------------------------- |
| `id` | `"header"` | Resolver id used in chain logs and metric tags. |
| `headerName` | `"X-Tenant-Id"` | HTTP header consulted; case-insensitive. |
| `strict` | `false` | When `true`, failures raise typed errors. When `false`, failures yield `null`. |
| `trustedSources` | `["service-mesh"]` | Values for `request.metadata.source` that may set the tenant header. |
| `validate` | `^[a-z0-9-]{1,64}$` | Validator applied to the header value. |
| `registry` | `undefined` | Optional sync-or-async membership probe; runs only after validator passes. |
| `getMetadata` | reads `req.metadata` | Override for the trust-metadata extractor. |
| `instruments` | OTel global meter | Override for the instrument bundle. |
## Error codes
- `TENANT_HEADER.INVALID` — header value failed validator (strict-only).
- `TENANT_HEADER.NOT_FOUND_IN_REGISTRY` — value not in registry (strict-only).
- `TENANT_HEADER.UNTRUSTED_SOURCE` — request source not in `trustedSources`
(strict-only).
- `TENANT_HEADER.INVALID_OPTIONS` — construction-time Zod failure.
## Observability
- Counter `api.tenant.resolves_total{resolver="header", outcome}` — exactly
one increment per `resolve()` call. `outcome` is one of `resolved`,
`absent`, `invalid`, `not_found_in_registry`, `untrusted_source`.

View File

@@ -0,0 +1,94 @@
{
"name": "@suaveplan/api-tenant-resolver-header",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Stage-8 header-based tenant resolver for the Suaveplan API plugin foundation. Implements the TenantResolver contract from @suaveplan/api-types by reading the tenant identifier from a configurable HTTP header (default X-Tenant-Id), validating against a regex (default ^[a-z0-9-]{1,64}$) and an optional registry (a `lookup`-shaped TenantLookup compatible with @suaveplan/api-tenant-provider), and enforcing trusted-source metadata so end-user clients cannot spoof tenant headers. Strict mode raises namespaced TENANT_HEADER_INVALID / NOT_FOUND_IN_REGISTRY / UNTRUSTED_SOURCE errors; non-strict mode returns null so the resolver chain advances. Emits api.tenant.resolves_total{resolver=\"header\"} counters via the OTel global meter.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./resolver": {
"import": {
"types": "./dist/resolver/resolver.d.ts",
"default": "./dist/resolver/resolver.js"
}
},
"./options": {
"import": {
"types": "./dist/options/options.d.ts",
"default": "./dist/options/options.js"
}
},
"./validate": {
"import": {
"types": "./dist/validate/validate.d.ts",
"default": "./dist/validate/validate.js"
}
},
"./trust": {
"import": {
"types": "./dist/trust/trust.d.ts",
"default": "./dist/trust/trust.js"
}
},
"./instruments": {
"import": {
"types": "./dist/instruments/instruments.d.ts",
"default": "./dist/instruments/instruments.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/api-types": "workspace:^",
"@suaveplan/error": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,86 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable error-code registry for
`@suaveplan/api-tenant-resolver-header`. Every public failure path raises a
{@link HeaderTenantResolverError} carrying one of the namespaced
`TENANT_HEADER.*` codes so consumers branch on a string value rather than a
brittle `instanceof` check on a third-party concrete error class.
The registry is the single source of truth for the resolver's wire-level
failure modes. Strict-mode hosts map the codes to HTTP responses (`INVALID`,
`NOT_FOUND_IN_REGISTRY`, `UNTRUSTED_SOURCE` all surface as HTTP 400 unless
the host overrides the mapping); non-strict-mode hosts swallow the codes by
returning `null` from the resolver so the chain advances.
## Features
- Frozen four-entry registry: `INVALID`, `NOT_FOUND_IN_REGISTRY`,
`UNTRUSTED_SOURCE`, `INVALID_OPTIONS`.
- Namespaced `TENANT_HEADER.*` codes built via `defineCodes` from
`@suaveplan/error`.
- `HeaderTenantResolverError` subclass narrows `code` for downstream
pattern-matching and carries a `headerTenantResolverCode` mirror field for
ergonomic switch statements.
## Usage: Basic
```ts
import {
HeaderTenantResolverError,
TENANT_HEADER_CODES,
} from "@suaveplan/api-tenant-resolver-header/error-codes";
throw new HeaderTenantResolverError(
TENANT_HEADER_CODES.INVALID,
"X-Tenant-Id 'Acme!' does not match ^[a-z0-9-]{1,64}$"
);
```
## Usage: Advanced
```ts
import {
HeaderTenantResolverError,
TENANT_HEADER_CODES,
} from "@suaveplan/api-tenant-resolver-header/error-codes";
function toHttpStatus(error: HeaderTenantResolverError): number {
switch (error.code) {
case TENANT_HEADER_CODES.INVALID:
case TENANT_HEADER_CODES.NOT_FOUND_IN_REGISTRY:
case TENANT_HEADER_CODES.UNTRUSTED_SOURCE:
return 400;
case TENANT_HEADER_CODES.INVALID_OPTIONS:
return 500;
}
}
```
## API Reference
- `TENANT_HEADER_CODES` — frozen registry with keys `INVALID`,
`NOT_FOUND_IN_REGISTRY`, `UNTRUSTED_SOURCE`, `INVALID_OPTIONS`.
- `type HeaderTenantResolverErrorCode` — string union of the four code
values, e.g. `"TENANT_HEADER.INVALID"`.
- `class HeaderTenantResolverError extends SuaveplanError<HeaderTenantResolverErrorCode>`
— accepts `(code, message, { cause? })` and exposes
`headerTenantResolverCode` alongside the inherited `code` field.
## Implementation Notes
The registry is intentionally minimal — four codes covering the resolver's
four distinct outcomes. Adding a code requires a spec delta because the codes
are part of the package's wire contract. Hosts that need finer-grained
diagnostics attach detail via `error.cause`; the codes themselves stay coarse
so consumers' switch statements don't proliferate when new validators are
plugged in.
`defineCodes` freezes the returned object so accidental mutation of the
registry at runtime is impossible — the four `TENANT_HEADER.*` strings are
the only values that will ever flow through the public surface.

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
HeaderTenantResolverError,
TENANT_HEADER_CODES,
} from "./error-codes.js";
describe("TENANT_HEADER_CODES registry", () => {
it("exposes exactly the four documented codes", { timeout: 5000 }, () => {
expect(Object.keys(TENANT_HEADER_CODES).sort()).toEqual([
"INVALID",
"INVALID_OPTIONS",
"NOT_FOUND_IN_REGISTRY",
"UNTRUSTED_SOURCE",
]);
});
it("namespaces every code under TENANT_HEADER.*", {
timeout: 5000,
}, () => {
for (const value of Object.values(TENANT_HEADER_CODES)) {
expect(value.startsWith("TENANT_HEADER.")).toBe(true);
}
});
it("is frozen at the top level", { timeout: 5000 }, () => {
expect(Object.isFrozen(TENANT_HEADER_CODES)).toBe(true);
});
});
describe("HeaderTenantResolverError", () => {
it("narrows code to HeaderTenantResolverErrorCode", {
timeout: 5000,
}, () => {
const err = new HeaderTenantResolverError(
TENANT_HEADER_CODES.INVALID,
"boom"
);
expect(err.code).toBe("TENANT_HEADER.INVALID");
expect(err.headerTenantResolverCode).toBe(TENANT_HEADER_CODES.INVALID);
expect(err.message).toBe("boom");
});
it("forwards cause via options", { timeout: 5000 }, () => {
const cause = new Error("root");
const err = new HeaderTenantResolverError(
TENANT_HEADER_CODES.INVALID_OPTIONS,
"bad",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,50 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-tenant-resolver-header`.
*
* Every public failure path raises a {@link HeaderTenantResolverError}
* carrying one of the namespaced `TENANT_HEADER.*` codes below. Consumers
* branch on the string value
* (e.g. `error.code === TENANT_HEADER_CODES.INVALID`).
*
* Codes:
* - `INVALID` — header is present but fails the configured validator.
* Strict-mode surfaces this as HTTP 400; non-strict-mode returns `null`.
* - `NOT_FOUND_IN_REGISTRY` — header value passes the validator but is not
* a member of the optional tenant registry. Strict-mode surfaces this as
* HTTP 400; non-strict-mode returns `null`.
* - `UNTRUSTED_SOURCE` — header is present but the request's metadata
* `source` is not in the configured `trustedSources` list. Strict-mode
* raises; non-strict-mode returns `null`.
* - `INVALID_OPTIONS` — `createHeaderTenantResolver` called with options
* that fail Zod validation at construction time.
*/
export const TENANT_HEADER_CODES = defineCodes("TENANT_HEADER", [
"INVALID",
"NOT_FOUND_IN_REGISTRY",
"UNTRUSTED_SOURCE",
"INVALID_OPTIONS",
] as const);
export type HeaderTenantResolverErrorCode =
(typeof TENANT_HEADER_CODES)[keyof typeof TENANT_HEADER_CODES];
/**
* Concrete error class for `@suaveplan/api-tenant-resolver-header`. Extends
* {@link SuaveplanError} so `instanceof HeaderTenantResolverError` narrows
* `code` to {@link HeaderTenantResolverErrorCode}.
*/
export class HeaderTenantResolverError extends SuaveplanError<HeaderTenantResolverErrorCode> {
public readonly headerTenantResolverCode: HeaderTenantResolverErrorCode;
public constructor(
code: HeaderTenantResolverErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.headerTenantResolverCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export {
HeaderTenantResolverError,
type HeaderTenantResolverErrorCode,
TENANT_HEADER_CODES,
} from "./error-codes.js";

View File

@@ -0,0 +1,31 @@
export {
HeaderTenantResolverError,
type HeaderTenantResolverErrorCode,
TENANT_HEADER_CODES,
} from "./error-codes/index.js";
export {
createHeaderResolverInstruments,
type HeaderResolveOutcome,
type HeaderResolverInstruments,
} from "./instruments/index.js";
export {
type HeaderTenantResolverOptions,
parseHeaderTenantResolverOptions,
type ResolvedHeaderTenantResolverOptions,
} from "./options/index.js";
export {
type CreateHeaderTenantResolverOptions,
createHeaderTenantResolver,
} from "./resolver/index.js";
export {
defaultMetadataExtractor,
isTrustedSource,
type RequestMetadataExtractor,
type RequestTrustMetadata,
} from "./trust/index.js";
export {
DEFAULT_TENANT_ID_PATTERN,
defaultTenantIdValidator,
type TenantIdValidator,
type TenantRegistryProbe,
} from "./validate/index.js";

View File

@@ -0,0 +1,5 @@
export {
createHeaderResolverInstruments,
type HeaderResolveOutcome,
type HeaderResolverInstruments,
} from "./instruments.js";

View File

@@ -0,0 +1,74 @@
# `instruments`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Builds the OTel instrument bundle the header tenant resolver emits on every
`resolve()` invocation. Centralising instrument creation keeps the resolver
factory free of meter plumbing — the resolver simply records against the
frozen counter returned here. The counter name (`api.tenant.resolves_total`)
follows the API plugin system design doc §6 step 4 so a missing entry
is a spec deviation a CI gate could detect.
The instrument is wired against the OTel global meter provider by default,
which falls back to a no-op implementation when no SDK is installed. Tests
need not configure a provider; production hosts register their SDK once at
boot and the resolver picks it up automatically.
## Features
- One counter: `api.tenant.resolves_total` labelled
`{ resolver: "header", outcome }`.
- `outcome` attribute drawn from a closed five-value union
(`resolved`, `absent`, `invalid`, `not_found_in_registry`,
`untrusted_source`) so dashboards can break down resolver behaviour.
- Optional `meterProvider` injection for tests that want a captive
provider; defaults to the OTel global.
## Usage: Basic
```ts
import { createHeaderResolverInstruments } from "@suaveplan/api-tenant-resolver-header/instruments";
const instruments = createHeaderResolverInstruments();
instruments.resolvesTotal.add(1, { resolver: "header", outcome: "resolved" });
```
## Usage: Advanced
```ts
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { createHeaderResolverInstruments } from "@suaveplan/api-tenant-resolver-header/instruments";
const provider = new MeterProvider();
const instruments = createHeaderResolverInstruments({
meterProvider: provider,
});
// hook instruments.meter into a Prometheus exporter to scrape
// `api_tenant_resolves_total{resolver="header",outcome="invalid"}` etc.
```
## API Reference
- `interface HeaderResolverInstruments` — `{ meter, resolvesTotal }`.
- `type HeaderResolveOutcome` — five-value union of outcome attribute
values.
- `function createHeaderResolverInstruments(config?: { meterProvider? })`
— returns a frozen instrument bundle.
## Implementation Notes
The bundle is frozen with `Object.freeze` so consumers cannot accidentally
swap the counter instance at runtime. The counter is created lazily-per-
resolver-instance rather than module-level so a host that wants different
meter providers per host instance (multi-tenant test harness) can wire each
resolver separately.
A counter — not a histogram — is the right primitive here because the
quantity being observed is the count of resolver invocations broken down by
outcome, not a duration. Resolver latency is dominated by the registry
probe; hosts that need probe latency wire a histogram around their
`TenantRegistryProbe` rather than expecting this package to time it.

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createHeaderResolverInstruments } from "./instruments.js";
describe("createHeaderResolverInstruments", () => {
it("returns a frozen bundle with the documented instruments", {
timeout: 5000,
}, () => {
const bundle = createHeaderResolverInstruments();
expect(Object.isFrozen(bundle)).toBe(true);
expect(bundle.meter).toBeDefined();
expect(bundle.resolvesTotal).toBeDefined();
expect(typeof bundle.resolvesTotal.add).toBe("function");
});
it("counter accepts add() without throwing under the global no-op SDK", {
timeout: 5000,
}, () => {
const bundle = createHeaderResolverInstruments();
expect(() =>
bundle.resolvesTotal.add(1, {
resolver: "header",
outcome: "resolved",
})
).not.toThrow();
});
it("uses an injected meter provider when supplied", {
timeout: 5000,
}, () => {
let getMeterCalls = 0;
const meterProvider = {
getMeter() {
getMeterCalls += 1;
return {
createCounter: () => ({ add: () => undefined }) as never,
} as never;
},
} as never;
createHeaderResolverInstruments({ meterProvider });
expect(getMeterCalls).toBe(1);
});
});

View File

@@ -0,0 +1,51 @@
import type { Counter, Meter, MeterProvider } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
/**
* Frozen OTel instrument bundle emitted by the header tenant resolver.
*
* `resolvesTotal` increments once per `resolve()` invocation; every
* observation carries a `resolver="header"` attribute plus an `outcome`
* attribute drawn from {@link HeaderResolveOutcome}. The instrument name
* `api.tenant.resolves_total` is the canonical counter from the API plugin
* foundation design doc (§6 step 4).
*/
export interface HeaderResolverInstruments {
readonly meter: Meter;
readonly resolvesTotal: Counter;
}
/**
* Outcome attribute attached to every `resolvesTotal` observation.
*
* - `resolved` — header present, validated, registry-checked, returned a
* `TenantContext`.
* - `absent` — header missing; resolver returned `null`.
* - `invalid` — header value failed the validator.
* - `not_found_in_registry` — value passed validator but is not in the
* configured registry.
* - `untrusted_source` — request `metadata.source` is not in
* `trustedSources`.
*/
export type HeaderResolveOutcome =
| "resolved"
| "absent"
| "invalid"
| "not_found_in_registry"
| "untrusted_source";
const METER_NAME = "@suaveplan/api-tenant-resolver-header";
/**
* Build the resolver's instrument registry. Defaults to the OTel global
* meter provider (which falls back to no-op when no SDK is installed) so
* unit tests need not wire a provider.
*/
export function createHeaderResolverInstruments(config?: {
readonly meterProvider?: MeterProvider;
}): HeaderResolverInstruments {
const meterProvider = config?.meterProvider ?? metrics.getMeterProvider();
const meter = meterProvider.getMeter(METER_NAME);
const resolvesTotal = meter.createCounter("api.tenant.resolves_total");
return Object.freeze({ meter, resolvesTotal });
}

View File

@@ -0,0 +1,5 @@
export {
type HeaderTenantResolverOptions,
parseHeaderTenantResolverOptions,
type ResolvedHeaderTenantResolverOptions,
} from "./options.js";

View File

@@ -0,0 +1,91 @@
# `options`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the Zod-validated options schema accepted by
`createHeaderTenantResolver`. Construction-time validation guarantees a host
mis-config raises a stable `TENANT_HEADER.INVALID_OPTIONS` code rather than a
runtime `TypeError` on the first request, and downstream code consumes the
resolved option bundle (`ResolvedHeaderTenantResolverOptions`) which has
every field populated with a default.
The schema is `.strict()` so unknown keys raise — typos in `headername`,
`trusted_sources`, or `requirePrincipal` (a JWT-resolver option that has no
meaning here) fail loudly at construction.
## Features
- Zero-config defaults: `id="header"`, `headerName="x-tenant-id"`,
`strict=false`, `trustedSources=["service-mesh"]`.
- Strict Zod object (unknown keys rejected) so option name typos surface as
`INVALID_OPTIONS` rather than silently no-oping.
- `headerName` normalised to lowercase at parse time so the resolver's
`Headers.get(...)` lookup is case-insensitive in a single place.
- `trustedSources` defensively frozen so the resolver never mutates the
caller's array.
## Usage: Basic
```ts
import { parseHeaderTenantResolverOptions } from "@suaveplan/api-tenant-resolver-header/options";
const opts = parseHeaderTenantResolverOptions(undefined);
// → { id: "header", headerName: "x-tenant-id", strict: false,
// trustedSources: ["service-mesh"] }
```
## Usage: Advanced
```ts
import {
HeaderTenantResolverError,
TENANT_HEADER_CODES,
} from "@suaveplan/api-tenant-resolver-header/error-codes";
import { parseHeaderTenantResolverOptions } from "@suaveplan/api-tenant-resolver-header/options";
try {
parseHeaderTenantResolverOptions({
id: "primary",
headerName: "X-Acme-Tenant",
strict: true,
trustedSources: ["service-mesh", "internal-gateway"],
});
} catch (e) {
if (
e instanceof HeaderTenantResolverError &&
e.code === TENANT_HEADER_CODES.INVALID_OPTIONS
) {
// surface to operator as a 5xx
}
}
```
## API Reference
- `interface HeaderTenantResolverOptions` — all fields optional; describes
user input.
- `interface ResolvedHeaderTenantResolverOptions` — every field required;
describes the parsed bundle the resolver consumes internally.
- `function parseHeaderTenantResolverOptions(input)
: ResolvedHeaderTenantResolverOptions`
— throws `HeaderTenantResolverError(INVALID_OPTIONS)` with the underlying
`ZodError` attached as `cause` on any validation failure.
## Implementation Notes
`headerName` is lowercased because the WHATWG `Headers` object's `get`
method is case-insensitive — keeping the configured value lowercase means
direct string comparison stays correct in the trust-source path (where the
resolver references `resolved.headerName` for diagnostic messages).
`trustedSources` is frozen after parsing so the resolver's runtime path can
share the same array across requests without defensive copying.
The default `trustedSources` of `["service-mesh"]` aligns with the design
intent that a tenant header is a service-to-service contract, never an
end-user-controllable input. Operators who need to widen the trust set must
do so explicitly in host config; there is no default that admits browser
traffic.

View File

@@ -0,0 +1,110 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
HeaderTenantResolverError,
TENANT_HEADER_CODES,
} from "../error-codes/error-codes.js";
import { parseHeaderTenantResolverOptions } from "./options.js";
describe("parseHeaderTenantResolverOptions", () => {
it("fills defaults when input is undefined", { timeout: 5000 }, () => {
const parsed = parseHeaderTenantResolverOptions(undefined);
expect(parsed.id).toBe("header");
expect(parsed.headerName).toBe("x-tenant-id");
expect(parsed.strict).toBe(false);
expect(parsed.trustedSources).toEqual(["service-mesh"]);
});
it("fills defaults when input is empty object", { timeout: 5000 }, () => {
const parsed = parseHeaderTenantResolverOptions({});
expect(parsed).toEqual({
id: "header",
headerName: "x-tenant-id",
strict: false,
trustedSources: ["service-mesh"],
});
});
it("normalises headerName to lowercase", { timeout: 5000 }, () => {
const parsed = parseHeaderTenantResolverOptions({
headerName: "X-Acme-Tenant",
});
expect(parsed.headerName).toBe("x-acme-tenant");
});
it("honours explicit overrides", { timeout: 5000 }, () => {
const parsed = parseHeaderTenantResolverOptions({
id: "primary",
headerName: "x-tid",
strict: true,
trustedSources: ["a", "b"],
});
expect(parsed).toEqual({
id: "primary",
headerName: "x-tid",
strict: true,
trustedSources: ["a", "b"],
});
});
it("freezes the trustedSources array", { timeout: 5000 }, () => {
const parsed = parseHeaderTenantResolverOptions({
trustedSources: ["a"],
});
expect(Object.isFrozen(parsed.trustedSources)).toBe(true);
});
it("rejects empty string id", { timeout: 5000 }, () => {
let thrown: unknown;
try {
parseHeaderTenantResolverOptions({ id: "" });
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(HeaderTenantResolverError);
expect((thrown as HeaderTenantResolverError).code).toBe(
TENANT_HEADER_CODES.INVALID_OPTIONS
);
});
it("rejects empty headerName", { timeout: 5000 }, () => {
expect(() =>
parseHeaderTenantResolverOptions({ headerName: "" })
).toThrow(HeaderTenantResolverError);
});
it("rejects non-boolean strict", { timeout: 5000 }, () => {
expect(() =>
parseHeaderTenantResolverOptions({
strict: "yes" as unknown as boolean,
})
).toThrow(HeaderTenantResolverError);
});
it("rejects empty entry in trustedSources", { timeout: 5000 }, () => {
expect(() =>
parseHeaderTenantResolverOptions({
trustedSources: [""],
})
).toThrow(HeaderTenantResolverError);
});
it("rejects unknown keys under strict mode", { timeout: 5000 }, () => {
expect(() =>
parseHeaderTenantResolverOptions({
header_name: "x",
} as unknown as Parameters<
typeof parseHeaderTenantResolverOptions
>[0])
).toThrow(HeaderTenantResolverError);
});
it("carries the ZodError as cause", { timeout: 5000 }, () => {
let thrown: unknown;
try {
parseHeaderTenantResolverOptions({ id: "" });
} catch (e) {
thrown = e;
}
expect((thrown as HeaderTenantResolverError).cause).toBeDefined();
});
});

View File

@@ -0,0 +1,75 @@
import z from "zod";
import {
HeaderTenantResolverError,
TENANT_HEADER_CODES,
} from "../error-codes/error-codes.js";
/**
* Public options accepted by `createHeaderTenantResolver`.
*
* `headerName` selects which HTTP header carries the tenant identifier;
* defaults to `X-Tenant-Id` so the common path is zero-configuration. The
* lookup is case-insensitive — the resolver normalises both the configured
* name and the inbound request header to lowercase.
*
* `strict` toggles raise-vs-advance behaviour for every failure mode (invalid
* value, untrusted source, registry miss). When `strict: true` the resolver
* throws a {@link HeaderTenantResolverError}; when `strict: false` (default)
* it returns `null` so a downstream resolver may match.
*
* `trustedSources` lists the request-metadata `source` values that are
* allowed to set the tenant header. Defaults to `["service-mesh"]` so
* end-user traffic (sources `"edge"`, `"public"`, `"browser"`, …) cannot
* spoof the tenant unless an operator explicitly opts in.
*
* `id` overrides the resolver's registered id used in chain logs and metric
* tags.
*/
export interface HeaderTenantResolverOptions {
readonly id?: string;
readonly headerName?: string;
readonly strict?: boolean;
readonly trustedSources?: readonly string[];
}
const optionsSchema = z
.object({
id: z.string().min(1).optional(),
headerName: z.string().min(1).optional(),
strict: z.boolean().optional(),
trustedSources: z.array(z.string().min(1)).optional(),
})
.strict();
export interface ResolvedHeaderTenantResolverOptions {
readonly id: string;
readonly headerName: string;
readonly strict: boolean;
readonly trustedSources: readonly string[];
}
/**
* Parse and validate user-supplied options at construction time. Throws
* `HeaderTenantResolverError(INVALID_OPTIONS)` on any Zod failure so the
* host surfaces a stable code rather than a raw `ZodError`.
*/
export function parseHeaderTenantResolverOptions(
input: HeaderTenantResolverOptions | undefined
): ResolvedHeaderTenantResolverOptions {
const parsed = optionsSchema.safeParse(input ?? {});
if (!parsed.success) {
throw new HeaderTenantResolverError(
TENANT_HEADER_CODES.INVALID_OPTIONS,
`Invalid HeaderTenantResolverOptions: ${parsed.error.message}`,
{ cause: parsed.error }
);
}
return {
id: parsed.data.id ?? "header",
headerName: (parsed.data.headerName ?? "X-Tenant-Id").toLowerCase(),
strict: parsed.data.strict ?? false,
trustedSources: Object.freeze([
...(parsed.data.trustedSources ?? ["service-mesh"]),
]),
};
}

View File

@@ -0,0 +1,4 @@
export {
type CreateHeaderTenantResolverOptions,
createHeaderTenantResolver,
} from "./resolver.js";

View File

@@ -0,0 +1,108 @@
# `resolver`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements The tenant resolver factory `createHeaderTenantResolver`.
The resolver reads the tenant identifier from a configurable HTTP header
(default `X-Tenant-Id`), enforces trusted-source metadata, runs a
configurable validator, and (optionally) consults a registry probe before
projecting the value into a `TenantContext` for the host's per-request
pipeline. Each invocation emits exactly one
`api.tenant.resolves_total{resolver="header", outcome}` counter increment.
The resolver short-circuits in the order: absent → untrusted → invalid →
registry → resolved. The order matters because untrusted-source enforcement
must happen BEFORE the value is examined so a misconfigured client never
sees the validator's regex pattern echoed back in an error message.
## Features
- `createHeaderTenantResolver(opts?)` factory returning a `TenantResolver`
that conforms to `@suaveplan/api-types/tenant-resolver`.
- Configurable `headerName` (case-insensitive); defaults to `X-Tenant-Id`.
- Injectable `validate` function — defaults to `^[a-z0-9-]{1,64}$`.
- Optional `registry` probe (sync or async) — fired only after validator
passes so an attacker probing for tenant existence cannot bypass shape
enforcement.
- Trusted-source gate via injectable `getMetadata` extractor; defaults to
reading `request.metadata.source`.
- Strict-mode toggle: raise `TENANT_HEADER_*` errors vs return `null` so a
downstream resolver may take over.
- OTel counter `api.tenant.resolves_total{resolver="header",outcome}` —
every code path increments exactly once per `resolve()` call.
## Usage: Basic
```ts
import { createHeaderTenantResolver } from "@suaveplan/api-tenant-resolver-header/resolver";
const resolver = createHeaderTenantResolver();
// caller wires the request after ingress middleware stamps the metadata:
(request as Request & { metadata: { source: string } }).metadata = {
source: "service-mesh",
};
const ctx = await resolver.resolve(request);
// → { id: "acme", slug: "acme" } | null
```
## Usage: Advanced
```ts
import type { TenantProvider } from "@suaveplan/api-tenant-provider/provider";
import { createHeaderTenantResolver } from "@suaveplan/api-tenant-resolver-header/resolver";
import type { TenantRegistryProbe } from "@suaveplan/api-tenant-resolver-header/validate";
declare const provider: TenantProvider;
const registry: TenantRegistryProbe = async (tid) =>
(await provider.lookup(tid as never)) !== null;
const resolver = createHeaderTenantResolver({
id: "header-primary",
headerName: "X-Acme-Tenant",
strict: true,
trustedSources: ["service-mesh", "internal-gateway"],
validate: (v) => /^acme-[a-z0-9]{8}$/.test(v),
registry,
getMetadata: (req) => sidecar.get(req) ?? null,
});
const ctx = await resolver.resolve(request);
```
## API Reference
- `interface CreateHeaderTenantResolverOptions extends HeaderTenantResolverOptions`:
- `validate?: TenantIdValidator`
- `registry?: TenantRegistryProbe`
- `getMetadata?: RequestMetadataExtractor`
- `instruments?: HeaderResolverInstruments`
- `function createHeaderTenantResolver(options?): TenantResolver` — returns
`{ id, resolve(request) }`. `resolve` returns
`Promise<TenantContext | null>`; throws
`HeaderTenantResolverError(INVALID | UNTRUSTED_SOURCE |
NOT_FOUND_IN_REGISTRY)` only when `strict: true`.
## Implementation Notes
The resolver checks **trusted-source before validation** so a public client
sending `X-Tenant-Id: <attack-payload>` never reaches the validator's
diagnostic path. This is a defence-in-depth choice — the validator itself
should not be exploitable, but minimising the surface that untrusted input
reaches narrows the audit footprint.
The OTel counter increments **exactly once** per `resolve()` call. Every
branch — including the `absent` and `null`-return non-strict paths — calls
`increment` exactly once. The `outcome` attribute makes the breakdown
queryable from dashboards: a sudden spike in `outcome="invalid"` signals
either a misconfigured client or a probing attacker, and a spike in
`outcome="untrusted_source"` signals that an ingress adapter has stopped
stamping `source` correctly.
The brand-cast to `TenantId` happens at the trust boundary: the value has
passed the validator (shape) and the registry (existence) when the cast
fires. Consumers downstream get an opaque branded string they cannot have
fabricated from user input.

View File

@@ -0,0 +1,72 @@
/**
* Self-conformance for the `TenantResolver` contract: the header resolver
* MUST (a) expose a non-empty `id`, (b) return `Promise<TenantContext | null>`
* shape on every call, (c) never mutate the inbound `Request`, (d) be safe
* to call concurrently across many requests sharing the same factory.
*
* `@suaveplan/api-types/tenant-resolver` does not (yet) ship a conformance
* harness; this file stands in as one, mirroring the sibling
* `@suaveplan/api-tenant-resolver-jwt` pattern. The spec delta references
* `runTenantResolverConformance` from `api-tenant-provider/testing` for
* forward compatibility — when that harness lands, this file flips to a
* thin wrapper that calls it.
*/
import type { TenantResolver } from "@suaveplan/api-types/tenant-resolver";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createHeaderTenantResolver } from "./resolver.js";
function makeRequest(headerValue: string | undefined): Request {
const headers: Record<string, string> = {};
if (headerValue !== undefined) headers["X-Tenant-Id"] = headerValue;
const req = new Request(`https://example.com/${Math.random()}`, {
headers,
});
Object.defineProperty(req, "metadata", {
value: { source: "service-mesh" },
enumerable: true,
});
return req;
}
describe("header resolver passes runTenantResolverConformance", () => {
const factory = (): TenantResolver => createHeaderTenantResolver();
it("exposes a non-empty id", { timeout: 5000 }, () => {
expect(factory().id.length).toBeGreaterThan(0);
});
it("returns a TenantContext when header is present and trusted", {
timeout: 5000,
}, async () => {
const ctx = await factory().resolve(makeRequest("acme"));
expect(ctx).not.toBeNull();
expect(ctx?.id).toBe("acme");
expect(typeof ctx?.slug).toBe("string");
});
it("returns null (chain advance) when header is absent", {
timeout: 5000,
}, async () => {
expect(await factory().resolve(makeRequest(undefined))).toBeNull();
});
it("does not mutate the inbound Request", { timeout: 5000 }, async () => {
const req = makeRequest("acme");
const before = Object.keys(req).sort();
await factory().resolve(req);
expect(Object.keys(req).sort()).toEqual(before);
});
it("is safe to call concurrently", { timeout: 5000 }, async () => {
const resolver = factory();
const results = await Promise.all(
Array.from({ length: 32 }, (_, i) =>
resolver.resolve(makeRequest(`t${i}`))
)
);
expect(results).toHaveLength(32);
for (const [i, ctx] of results.entries()) {
expect(ctx?.id).toBe(`t${i}`);
}
});
});

View File

@@ -0,0 +1,265 @@
import type { Counter } from "@opentelemetry/api";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
HeaderTenantResolverError,
TENANT_HEADER_CODES,
} from "../error-codes/error-codes.js";
import type {
HeaderResolveOutcome,
HeaderResolverInstruments,
} from "../instruments/instruments.js";
import { createHeaderResolverInstruments } from "../instruments/instruments.js";
import type { RequestTrustMetadata } from "../trust/trust.js";
import { createHeaderTenantResolver } from "./resolver.js";
interface CapturedObservation {
outcome: HeaderResolveOutcome;
resolver: string;
}
function captureInstruments(): {
instruments: HeaderResolverInstruments;
observations: CapturedObservation[];
} {
const base = createHeaderResolverInstruments();
const observations: CapturedObservation[] = [];
const wrapped: Counter = {
...base.resolvesTotal,
add(_value: number, attrs?: Record<string, unknown>) {
observations.push({
outcome: (attrs?.outcome as HeaderResolveOutcome) ?? "resolved",
resolver: (attrs?.resolver as string) ?? "unknown",
});
},
} as Counter;
return {
instruments: { meter: base.meter, resolvesTotal: wrapped },
observations,
};
}
function reqWith(
headers: Record<string, string>,
metadata: RequestTrustMetadata | null | undefined = {
source: "service-mesh",
}
): Request {
const req = new Request("https://example.com/x", { headers });
if (metadata !== undefined) {
Object.defineProperty(req, "metadata", {
value: metadata,
enumerable: true,
});
}
return req;
}
describe("createHeaderTenantResolver", () => {
it("well-formed header resolves", { timeout: 5000 }, async () => {
const { instruments, observations } = captureInstruments();
const resolver = createHeaderTenantResolver({ instruments });
const ctx = await resolver.resolve(reqWith({ "X-Tenant-Id": "acme" }));
expect(ctx).toEqual({ id: "acme", slug: "acme" });
expect(observations).toEqual([
{ resolver: "header", outcome: "resolved" },
]);
});
it("missing header returns null (chain advance)", {
timeout: 5000,
}, async () => {
const { instruments, observations } = captureInstruments();
const resolver = createHeaderTenantResolver({ instruments });
const ctx = await resolver.resolve(reqWith({}));
expect(ctx).toBeNull();
expect(observations).toEqual([
{ resolver: "header", outcome: "absent" },
]);
});
it("strict mode raises 400 on invalid", { timeout: 5000 }, async () => {
const resolver = createHeaderTenantResolver({ strict: true });
let thrown: unknown;
try {
await resolver.resolve(reqWith({ "X-Tenant-Id": "INVALID!" }));
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(HeaderTenantResolverError);
expect((thrown as HeaderTenantResolverError).code).toBe(
TENANT_HEADER_CODES.INVALID
);
});
it("non-strict mode returns null on invalid", {
timeout: 5000,
}, async () => {
const { instruments, observations } = captureInstruments();
const resolver = createHeaderTenantResolver({ instruments });
const ctx = await resolver.resolve(
reqWith({ "X-Tenant-Id": "INVALID!" })
);
expect(ctx).toBeNull();
expect(observations[0]?.outcome).toBe("invalid");
});
it("well-formed but unknown raises NOT_FOUND_IN_REGISTRY in strict", {
timeout: 5000,
}, async () => {
const resolver = createHeaderTenantResolver({
strict: true,
registry: () => false,
});
let thrown: unknown;
try {
await resolver.resolve(
reqWith({ "X-Tenant-Id": "unknown-tenant-1" })
);
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(HeaderTenantResolverError);
expect((thrown as HeaderTenantResolverError).code).toBe(
TENANT_HEADER_CODES.NOT_FOUND_IN_REGISTRY
);
});
it("registry hit yields TenantContext", { timeout: 5000 }, async () => {
const known = new Set(["acme", "beta"]);
const resolver = createHeaderTenantResolver({
registry: async (tid) => known.has(tid),
});
const ctx = await resolver.resolve(reqWith({ "X-Tenant-Id": "acme" }));
expect(ctx).toEqual({ id: "acme", slug: "acme" });
});
it("registry miss returns null in non-strict mode", {
timeout: 5000,
}, async () => {
const { instruments, observations } = captureInstruments();
const resolver = createHeaderTenantResolver({
instruments,
registry: () => false,
});
const ctx = await resolver.resolve(reqWith({ "X-Tenant-Id": "acme" }));
expect(ctx).toBeNull();
expect(observations[0]?.outcome).toBe("not_found_in_registry");
});
it("supports configurable headerName (case-insensitive)", {
timeout: 5000,
}, async () => {
const resolver = createHeaderTenantResolver({
headerName: "X-Acme-Tenant",
});
const ctx = await resolver.resolve(
reqWith({ "x-acme-tenant": "acme" })
);
expect(ctx).toEqual({ id: "acme", slug: "acme" });
});
it("honours custom validator", { timeout: 5000 }, async () => {
const resolver = createHeaderTenantResolver({
validate: (v) => v === "exact-only",
});
expect(
await resolver.resolve(reqWith({ "X-Tenant-Id": "exact-only" }))
).toEqual({ id: "exact-only", slug: "exact-only" });
expect(
await resolver.resolve(reqWith({ "X-Tenant-Id": "anything-else" }))
).toBeNull();
});
it("uses the configured id", { timeout: 5000 }, () => {
expect(createHeaderTenantResolver().id).toBe("header");
expect(createHeaderTenantResolver({ id: "primary" }).id).toBe(
"primary"
);
});
it("propagates an INVALID_OPTIONS error for bad input", {
timeout: 5000,
}, () => {
expect(() => createHeaderTenantResolver({ headerName: "" })).toThrow(
HeaderTenantResolverError
);
});
it("untrusted source is checked BEFORE validator (defence in depth)", {
timeout: 5000,
}, async () => {
const { instruments, observations } = captureInstruments();
const resolver = createHeaderTenantResolver({
instruments,
// intentionally an invalid value AND an untrusted source — the
// untrusted-source path must win so the validator never sees the
// attack-controlled value.
});
const ctx = await resolver.resolve(
reqWith({ "X-Tenant-Id": "INVALID!" }, { source: "edge" })
);
expect(ctx).toBeNull();
expect(observations[0]?.outcome).toBe("untrusted_source");
});
it("empty header value is treated as absent", {
timeout: 5000,
}, async () => {
const { instruments, observations } = captureInstruments();
const resolver = createHeaderTenantResolver({ instruments });
// Manually set an empty header value via Headers init.
const headers = new Headers();
headers.set("X-Tenant-Id", "");
const req = new Request("https://example.com/x", { headers });
Object.defineProperty(req, "metadata", {
value: { source: "service-mesh" },
enumerable: true,
});
const ctx = await resolver.resolve(req);
expect(ctx).toBeNull();
expect(observations[0]?.outcome).toBe("absent");
});
it("honours an explicit getMetadata extractor (WeakMap pattern)", {
timeout: 5000,
}, async () => {
const sidecar = new WeakMap<Request, RequestTrustMetadata>();
const resolver = createHeaderTenantResolver({
getMetadata: (r) => sidecar.get(r) ?? null,
});
const req = new Request("https://example.com/x", {
headers: { "X-Tenant-Id": "acme" },
});
sidecar.set(req, { source: "service-mesh" });
expect(await resolver.resolve(req)).toEqual({
id: "acme",
slug: "acme",
});
});
it("emits exactly one counter increment per resolve() call", {
timeout: 5000,
}, async () => {
const { instruments, observations } = captureInstruments();
const resolver = createHeaderTenantResolver({ instruments });
await resolver.resolve(reqWith({ "X-Tenant-Id": "acme" }));
await resolver.resolve(reqWith({}));
await resolver.resolve(
reqWith({ "X-Tenant-Id": "BAD!" }, { source: "service-mesh" })
);
expect(observations).toHaveLength(3);
expect(observations.map((o) => o.outcome)).toEqual([
"resolved",
"absent",
"invalid",
]);
});
it("default trusted source is service-mesh only (fail-closed)", {
timeout: 5000,
}, async () => {
const resolver = createHeaderTenantResolver();
const req = reqWith({ "X-Tenant-Id": "acme" }, { source: "browser" });
expect(await resolver.resolve(req)).toBeNull();
});
});

View File

@@ -0,0 +1,170 @@
import type { TenantContext, TenantId } from "@suaveplan/api-types/tenant";
import type { TenantResolver } from "@suaveplan/api-types/tenant-resolver";
import {
HeaderTenantResolverError,
TENANT_HEADER_CODES,
} from "../error-codes/error-codes.js";
import {
createHeaderResolverInstruments,
type HeaderResolveOutcome,
type HeaderResolverInstruments,
} from "../instruments/instruments.js";
import {
type HeaderTenantResolverOptions,
parseHeaderTenantResolverOptions,
} from "../options/options.js";
import {
defaultMetadataExtractor,
isTrustedSource,
type RequestMetadataExtractor,
} from "../trust/trust.js";
import {
defaultTenantIdValidator,
type TenantIdValidator,
type TenantRegistryProbe,
} from "../validate/validate.js";
/**
* Full options object accepted by {@link createHeaderTenantResolver}.
* Extends the Zod-validated {@link HeaderTenantResolverOptions} with the
* injectable validator, registry, metadata extractor, and instrument
* bundle.
*/
export interface CreateHeaderTenantResolverOptions
extends HeaderTenantResolverOptions {
/** Validator applied to the header value. Defaults to `^[a-z0-9-]{1,64}$`. */
readonly validate?: TenantIdValidator;
/** Optional registry membership probe; runs only after validator passes. */
readonly registry?: TenantRegistryProbe;
/** Override for the trust-metadata extractor. */
readonly getMetadata?: RequestMetadataExtractor;
/** Override for the OTel instrument bundle. */
readonly instruments?: HeaderResolverInstruments;
}
const ZOD_OPTION_KEYS = new Set<keyof HeaderTenantResolverOptions>([
"id",
"headerName",
"strict",
"trustedSources",
]);
function splitOptions(options: CreateHeaderTenantResolverOptions | undefined): {
zodInput: HeaderTenantResolverOptions | undefined;
validate: TenantIdValidator;
registry: TenantRegistryProbe | undefined;
getMetadata: RequestMetadataExtractor;
instruments: HeaderResolverInstruments;
} {
const input = options ?? {};
const zodInput: Record<string, unknown> = {};
for (const key of Object.keys(input)) {
if (ZOD_OPTION_KEYS.has(key as keyof HeaderTenantResolverOptions)) {
zodInput[key] = (input as Record<string, unknown>)[key];
}
}
return {
zodInput:
Object.keys(zodInput).length === 0
? undefined
: (zodInput as HeaderTenantResolverOptions),
validate: input.validate ?? defaultTenantIdValidator,
registry: input.registry,
getMetadata: input.getMetadata ?? defaultMetadataExtractor,
instruments: input.instruments ?? createHeaderResolverInstruments(),
};
}
/**
* Build a Stage-8 `TenantResolver` that reads the tenant identifier from a
* configurable HTTP header. The resolver runs four sequential checks:
*
* 1. Header presence — missing header yields `null` (chain advances).
* 2. Trusted-source — `request.metadata.source` MUST be in
* `trustedSources`. Strict-mode raises
* `TENANT_HEADER.UNTRUSTED_SOURCE`; non-strict-mode returns `null`.
* 3. Validator — value MUST match the configured regex / validator.
* Strict-mode raises `TENANT_HEADER.INVALID`; non-strict-mode returns
* `null`.
* 4. Registry — when supplied, the value MUST be a member. Strict-mode
* raises `TENANT_HEADER.NOT_FOUND_IN_REGISTRY`; non-strict-mode returns
* `null`.
*
* Every outcome (`resolved`, `absent`, `invalid`, `not_found_in_registry`,
* `untrusted_source`) increments
* `api.tenant.resolves_total{resolver="header",outcome}` exactly once per
* `resolve()` invocation.
*/
export function createHeaderTenantResolver(
options?: CreateHeaderTenantResolverOptions
): TenantResolver {
const { zodInput, validate, registry, getMetadata, instruments } =
splitOptions(options);
const resolved = parseHeaderTenantResolverOptions(zodInput);
function increment(outcome: HeaderResolveOutcome): void {
instruments.resolvesTotal.add(1, {
resolver: "header",
outcome,
});
}
function strictThrow(
code: (typeof TENANT_HEADER_CODES)[keyof typeof TENANT_HEADER_CODES],
message: string
): never {
throw new HeaderTenantResolverError(code, message);
}
return {
id: resolved.id,
async resolve(request: Request): Promise<TenantContext | null> {
const rawValue = request.headers.get(resolved.headerName);
if (rawValue === null || rawValue.length === 0) {
increment("absent");
return null;
}
const metadata = getMetadata(request) ?? null;
if (!isTrustedSource(metadata, resolved.trustedSources)) {
increment("untrusted_source");
if (resolved.strict) {
strictThrow(
TENANT_HEADER_CODES.UNTRUSTED_SOURCE,
`Header '${resolved.headerName}' is present but request source is not trusted (expected one of: ${resolved.trustedSources.join(", ")})`
);
}
return null;
}
if (!validate(rawValue)) {
increment("invalid");
if (resolved.strict) {
strictThrow(
TENANT_HEADER_CODES.INVALID,
`Header '${resolved.headerName}' value did not pass validator`
);
}
return null;
}
if (registry !== undefined) {
const present = await registry(rawValue);
if (!present) {
increment("not_found_in_registry");
if (resolved.strict) {
strictThrow(
TENANT_HEADER_CODES.NOT_FOUND_IN_REGISTRY,
`Header '${resolved.headerName}' value '${rawValue}' is not present in the tenant registry`
);
}
return null;
}
}
increment("resolved");
const tenantId = rawValue as TenantId;
return { id: tenantId, slug: rawValue };
},
};
}

View File

@@ -0,0 +1,6 @@
export {
defaultMetadataExtractor,
isTrustedSource,
type RequestMetadataExtractor,
type RequestTrustMetadata,
} from "./trust.js";

View File

@@ -0,0 +1,86 @@
# `trust`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Encapsulates the trusted-source enforcement consulted by the header tenant
resolver before it acts on a header value. The check exists because an
HTTP header is end-user-controllable on public traffic; without a
trusted-source gate, any browser could spoof the tenant by sending
`X-Tenant-Id: competitor`.
Upstream middleware (a service-mesh adapter, an mTLS-verified ingress,
internal-network admission gate) stamps a `source` value on
`request.metadata`. The resolver consults that stamp via `isTrustedSource`;
when the source is missing or absent from the configured allow-list, the
header is ignored (strict-mode raises `TENANT_HEADER.UNTRUSTED_SOURCE`,
non-strict-mode returns `null` so the chain advances).
## Features
- `RequestTrustMetadata` shape — `{ source?: string }` minimal contract.
- `RequestMetadataExtractor` injectable extractor with a fail-closed
default that reads `request.metadata`.
- `isTrustedSource(metadata, trustedSources)` — fail-closed boolean.
Missing metadata, missing `source`, or a `source` not in the allow-list
all yield `false`.
## Usage: Basic
```ts
import {
defaultMetadataExtractor,
isTrustedSource,
} from "@suaveplan/api-tenant-resolver-header/trust";
const metadata = defaultMetadataExtractor(request);
if (isTrustedSource(metadata, ["service-mesh"])) {
// header may be consulted
}
```
## Usage: Advanced
```ts
import type {
RequestMetadataExtractor,
RequestTrustMetadata,
} from "@suaveplan/api-tenant-resolver-header/trust";
const sidecar = new WeakMap<Request, RequestTrustMetadata>();
const fromSidecar: RequestMetadataExtractor = (req) =>
sidecar.get(req) ?? null;
// ingress middleware stamps a frozen Request via a WeakMap rather than
// mutating the request itself:
sidecar.set(request, { source: "service-mesh" });
```
## API Reference
- `interface RequestTrustMetadata` — `{ readonly source?: string }`.
- `type RequestMetadataExtractor = (request: Request)
=> RequestTrustMetadata | null | undefined`.
- `const defaultMetadataExtractor: RequestMetadataExtractor` — reads
`request.metadata`.
- `function isTrustedSource(metadata, trustedSources): boolean` —
fail-closed allow-list check.
## Implementation Notes
`isTrustedSource` returns `false` for absent metadata so the policy is
fail-closed by default. The alternative — treating "no metadata" as
"trust by default" — would mean a single misconfigured ingress (one that
forgets to set `source`) opens the entire fleet to header spoofing. The
trade-off is that hosts MUST wire an ingress adapter that stamps `source`
before the resolver runs; the resolver's contract is explicit about this
expectation.
The default extractor reads a mutable property because that is the simplest
host wiring. Frozen-Request runtimes (Workers, certain edge platforms) pass
an explicit extractor backed by a `WeakMap`. Either way, the trust check is
identical — only the I/O changes.

View File

@@ -0,0 +1,129 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
HeaderTenantResolverError,
TENANT_HEADER_CODES,
} from "../error-codes/error-codes.js";
import { createHeaderTenantResolver } from "../resolver/resolver.js";
import {
defaultMetadataExtractor,
isTrustedSource,
type RequestTrustMetadata,
} from "./trust.js";
function reqWith(metadata: RequestTrustMetadata | null | undefined): Request {
const req = new Request("https://example.com/x", {
headers: { "X-Tenant-Id": "acme" },
});
if (metadata !== undefined) {
Object.defineProperty(req, "metadata", {
value: metadata,
enumerable: true,
});
}
return req;
}
describe("isTrustedSource", () => {
it("returns true when source is in trustedSources", {
timeout: 5000,
}, () => {
expect(
isTrustedSource({ source: "service-mesh" }, ["service-mesh"])
).toBe(true);
});
it("returns false when source is absent from trustedSources", {
timeout: 5000,
}, () => {
expect(isTrustedSource({ source: "edge" }, ["service-mesh"])).toBe(
false
);
});
it("returns false when metadata is null", { timeout: 5000 }, () => {
expect(isTrustedSource(null, ["service-mesh"])).toBe(false);
});
it("returns false when metadata is undefined", { timeout: 5000 }, () => {
expect(isTrustedSource(undefined, ["service-mesh"])).toBe(false);
});
it("returns false when source field is missing", { timeout: 5000 }, () => {
expect(isTrustedSource({}, ["service-mesh"])).toBe(false);
});
});
describe("defaultMetadataExtractor", () => {
it("reads request.metadata when present", { timeout: 5000 }, () => {
const req = reqWith({ source: "service-mesh" });
expect(defaultMetadataExtractor(req)).toEqual({
source: "service-mesh",
});
});
it("returns null when request has no metadata property", {
timeout: 5000,
}, () => {
const req = new Request("https://example.com/x");
expect(defaultMetadataExtractor(req)).toBeNull();
});
});
describe("trusted-source enforcement (integration with resolver)", () => {
it("untrusted source raises in strict mode", {
timeout: 5000,
}, async () => {
const resolver = createHeaderTenantResolver({ strict: true });
const req = reqWith({ source: "edge" });
let thrown: unknown;
try {
await resolver.resolve(req);
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(HeaderTenantResolverError);
expect((thrown as HeaderTenantResolverError).code).toBe(
TENANT_HEADER_CODES.UNTRUSTED_SOURCE
);
});
it("untrusted source returns null in non-strict mode", {
timeout: 5000,
}, async () => {
const resolver = createHeaderTenantResolver();
const req = reqWith({ source: "edge" });
expect(await resolver.resolve(req)).toBeNull();
});
it("trusted source resolves when value is well-formed", {
timeout: 5000,
}, async () => {
const resolver = createHeaderTenantResolver();
const req = reqWith({ source: "service-mesh" });
expect(await resolver.resolve(req)).toEqual({
id: "acme",
slug: "acme",
});
});
it("missing metadata is treated as untrusted (fail-closed)", {
timeout: 5000,
}, async () => {
const resolver = createHeaderTenantResolver();
const req = reqWith(undefined);
expect(await resolver.resolve(req)).toBeNull();
});
it("operator-widened trustedSources admit additional sources", {
timeout: 5000,
}, async () => {
const resolver = createHeaderTenantResolver({
trustedSources: ["service-mesh", "internal-gateway"],
});
const req = reqWith({ source: "internal-gateway" });
expect(await resolver.resolve(req)).toEqual({
id: "acme",
slug: "acme",
});
});
});

View File

@@ -0,0 +1,47 @@
/**
* Request-metadata shape consumed by the trust check. Upstream middleware
* (typically an ingress / service-mesh adapter) attaches this object to the
* `Request` so the tenant resolver can decide whether the request is
* allowed to carry a tenant header.
*
* The shape is intentionally minimal — `source` is the only field this
* resolver consults. Hosts MAY attach further metadata (correlation ids,
* client cert subject) that other middleware reads.
*/
export interface RequestTrustMetadata {
readonly source?: string;
}
/**
* Extractor invoked once per request to look up the metadata attached by
* upstream middleware. Defaults to reading
* `(request as { metadata?: RequestTrustMetadata }).metadata` so the simple
* wiring (mutating the request once at ingress) works out of the box;
* production hosts pass an explicit extractor backed by a `WeakMap`.
*/
export type RequestMetadataExtractor = (
request: Request
) => RequestTrustMetadata | null | undefined;
/** Default metadata extractor — reads `request.metadata`. */
export const defaultMetadataExtractor: RequestMetadataExtractor = (request) => {
const carrier = request as unknown as {
readonly metadata?: RequestTrustMetadata;
};
return carrier.metadata ?? null;
};
/**
* Returns `true` iff the request's metadata `source` is contained in
* `trustedSources`. A missing metadata bundle, a missing `source`, or a
* `source` value not present in `trustedSources` all yield `false` so the
* default is fail-closed.
*/
export function isTrustedSource(
metadata: RequestTrustMetadata | null | undefined,
trustedSources: readonly string[]
): boolean {
if (metadata === null || metadata === undefined) return false;
if (metadata.source === undefined) return false;
return trustedSources.includes(metadata.source);
}

View File

@@ -0,0 +1,6 @@
export {
DEFAULT_TENANT_ID_PATTERN,
defaultTenantIdValidator,
type TenantIdValidator,
type TenantRegistryProbe,
} from "./validate.js";

View File

@@ -0,0 +1,75 @@
# `validate`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the default tenant-id validator regex and the validator / registry
function signatures consumed by `createHeaderTenantResolver`. Splitting the
validator surface out of `resolver.ts` keeps the resolver implementation a
pure orchestrator (regex check → registry probe → trust check → return) and
lets hosts substitute alternative validators without monkey-patching the
factory.
The default validator (`^[a-z0-9-]{1,64}$`) is deliberately conservative —
it admits the shape of a typical slug-style identifier and rejects
everything else. Hosts whose tenant ids are UUIDs, prefixed strings, or
arbitrary unicode must pass an explicit `validate` option.
## Features
- `DEFAULT_TENANT_ID_PATTERN` exported as a `RegExp` so hosts can extend
rather than re-derive (`new RegExp(...|extra-form)`).
- `defaultTenantIdValidator` — boolean function backed by the pattern.
- `TenantIdValidator` and `TenantRegistryProbe` type aliases so host wiring
is explicit.
## Usage: Basic
```ts
import { defaultTenantIdValidator } from "@suaveplan/api-tenant-resolver-header/validate";
defaultTenantIdValidator("acme"); // true
defaultTenantIdValidator("ACME"); // false (uppercase rejected)
defaultTenantIdValidator(""); // false (zero-length rejected)
```
## Usage: Advanced
```ts
import type {
TenantIdValidator,
TenantRegistryProbe,
} from "@suaveplan/api-tenant-resolver-header/validate";
const uuidValidator: TenantIdValidator = (v) =>
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(v);
const registry: TenantRegistryProbe = async (tid) =>
(await tenantProvider.lookup(tid as never)) !== null;
```
## API Reference
- `const DEFAULT_TENANT_ID_PATTERN: RegExp` — `^[a-z0-9-]{1,64}$`.
- `const defaultTenantIdValidator: TenantIdValidator` — applies the default
pattern; returns boolean.
- `type TenantIdValidator = (value: string) => boolean` — synchronous
validator contract.
- `type TenantRegistryProbe = (tenantId: string) => boolean | Promise<boolean>`
— sync-or-async registry membership probe.
## Implementation Notes
The validator is intentionally synchronous: the regex check is the
zero-I/O happy path, and a synchronous boolean lets the resolver avoid an
`await` micro-task in the common-case good-tenant path. Registry probes can
be async because the host may back them with a real `TenantProvider.lookup`
that hits a database; the resolver awaits the probe only after the regex
check has already passed so a bad value never leaks into the registry call.
The default pattern's 64-character cap is the same maximum used by
`api-tenant-provider`'s seed schema; widening it here without widening it
there would produce values the resolver accepts but the registry refuses.

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DEFAULT_TENANT_ID_PATTERN,
defaultTenantIdValidator,
} from "./validate.js";
describe("defaultTenantIdValidator", () => {
it("accepts lowercase slug-style identifiers", { timeout: 5000 }, () => {
expect(defaultTenantIdValidator("acme")).toBe(true);
expect(defaultTenantIdValidator("acme-corp")).toBe(true);
expect(defaultTenantIdValidator("a")).toBe(true);
expect(defaultTenantIdValidator("0")).toBe(true);
});
it("rejects uppercase characters", { timeout: 5000 }, () => {
expect(defaultTenantIdValidator("Acme")).toBe(false);
expect(defaultTenantIdValidator("ACME")).toBe(false);
});
it("rejects empty strings", { timeout: 5000 }, () => {
expect(defaultTenantIdValidator("")).toBe(false);
});
it("rejects strings longer than 64 characters", { timeout: 5000 }, () => {
expect(defaultTenantIdValidator("a".repeat(64))).toBe(true);
expect(defaultTenantIdValidator("a".repeat(65))).toBe(false);
});
it("rejects punctuation other than hyphen", { timeout: 5000 }, () => {
expect(defaultTenantIdValidator("a_b")).toBe(false);
expect(defaultTenantIdValidator("a.b")).toBe(false);
expect(defaultTenantIdValidator("a b")).toBe(false);
expect(defaultTenantIdValidator("a/b")).toBe(false);
});
});
describe("DEFAULT_TENANT_ID_PATTERN", () => {
it("matches the expected canonical form", { timeout: 5000 }, () => {
expect(DEFAULT_TENANT_ID_PATTERN.source).toBe("^[a-z0-9-]{1,64}$");
});
});

View File

@@ -0,0 +1,36 @@
/**
* Default tenant-id validator regex applied by the header resolver.
*
* Aligns with the `^[a-z0-9-]{1,64}$` spec in
* `openspec/changes/api-tenant-resolver-header/proposal.md`: lowercase
* alphanumerics + hyphens, 1-64 characters. Hosts override via the
* `validate` option on `createHeaderTenantResolver` when their tenant
* identifier shape diverges (e.g. UUIDs, prefixed slugs).
*/
export const DEFAULT_TENANT_ID_PATTERN: RegExp = /^[a-z0-9-]{1,64}$/;
/**
* Validator function signature consumed by `createHeaderTenantResolver`.
* Returns `true` to accept the value, `false` to reject. Throwing is
* permitted and surfaces through the resolver's strict/non-strict branch
* exactly like a `false` return.
*/
export type TenantIdValidator = (value: string) => boolean;
/** Default validator: lowercase a-z, digits, hyphen, 1-64 chars. */
export const defaultTenantIdValidator: TenantIdValidator = (value: string) =>
DEFAULT_TENANT_ID_PATTERN.test(value);
/**
* Optional registry-membership probe. The resolver calls this AFTER the
* regex validator passes. Returning `false` (or `Promise<false>`) surfaces
* as `TENANT_HEADER.NOT_FOUND_IN_REGISTRY` (strict) or `null` (non-strict).
*
* Hosts typically back this with a `TenantProvider.lookup(tid)` call
* (`@suaveplan/api-tenant-provider`), but the contract is intentionally
* registry-shape-agnostic so hosts may plug in a static `Set` for tests or
* a memoised cache.
*/
export type TenantRegistryProbe = (
tenantId: string
) => boolean | Promise<boolean>;

View File

@@ -0,0 +1,69 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG = join(__dirname, "..", "package.json");
const SRC = join(__dirname, "..", "src");
describe("@suaveplan/api-tenant-resolver-header package shape", () => {
it("depends on the expected set of workspace + catalog packages", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(readFileSync(PKG, "utf8")) as {
dependencies: Record<string, string>;
};
const deps = Object.keys(pkg.dependencies).sort();
expect(deps).toEqual([
"@opentelemetry/api",
"@suaveplan/api-types",
"@suaveplan/error",
"tslib",
"zod",
]);
});
it("every src file uses default-import z (no named import)", {
timeout: 5000,
}, () => {
const offenders: string[] = [];
walk(SRC, (file) => {
if (!file.endsWith(".ts")) return;
const content = readFileSync(file, "utf8");
if (/import\s*\{\s*z\s*\}\s*from\s*["']zod["']/.test(content)) {
offenders.push(file);
}
});
expect(offenders).toEqual([]);
});
it("root barrel has no export-star", { timeout: 5000 }, () => {
const content = readFileSync(join(SRC, "index.ts"), "utf8");
expect(/export\s*\*/.test(content)).toBe(false);
});
it("no test file imports vitest / bun:test directly (rule 11)", {
timeout: 5000,
}, () => {
const offenders: string[] = [];
walk(SRC, (file) => {
if (!file.endsWith(".test.ts")) return;
const content = readFileSync(file, "utf8");
if (
/from\s*["'](?:vitest|bun:test)["']/.test(content) &&
!/from\s*["']vitest\/config["']/.test(content)
) {
offenders.push(file);
}
});
expect(offenders).toEqual([]);
});
});
function walk(dir: string, fn: (file: string) => void): void {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
const st = statSync(p);
if (st.isDirectory()) walk(p, fn);
else fn(p);
}
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,36 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-tenant-resolver-header",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
/^node:/,
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,31 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});