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,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.

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
SubdomainTenantResolverError,
TENANT_SUBDOMAIN_CODES,
} from "./error-codes.js";
describe("error-codes", () => {
it("registers all five codes with the namespace prefix", {
timeout: 5000,
}, () => {
expect(TENANT_SUBDOMAIN_CODES.INVALID).toBe("TENANT_SUBDOMAIN.INVALID");
expect(TENANT_SUBDOMAIN_CODES.NOT_FOUND_IN_REGISTRY).toBe(
"TENANT_SUBDOMAIN.NOT_FOUND_IN_REGISTRY"
);
expect(TENANT_SUBDOMAIN_CODES.BASE_DOMAIN_MISMATCH).toBe(
"TENANT_SUBDOMAIN.BASE_DOMAIN_MISMATCH"
);
expect(TENANT_SUBDOMAIN_CODES.FORWARDED_HOST_UNTRUSTED).toBe(
"TENANT_SUBDOMAIN.FORWARDED_HOST_UNTRUSTED"
);
expect(TENANT_SUBDOMAIN_CODES.INVALID_OPTIONS).toBe(
"TENANT_SUBDOMAIN.INVALID_OPTIONS"
);
});
it("constructs an error carrying the discriminator code", {
timeout: 5000,
}, () => {
const e = new SubdomainTenantResolverError(
TENANT_SUBDOMAIN_CODES.INVALID,
"label '!' rejected"
);
expect(e).toBeInstanceOf(SubdomainTenantResolverError);
expect(e.code).toBe(TENANT_SUBDOMAIN_CODES.INVALID);
expect(e.subdomainTenantResolverCode).toBe(
TENANT_SUBDOMAIN_CODES.INVALID
);
expect(e.message).toContain("label");
});
it("propagates options.cause through SuaveplanError", {
timeout: 5000,
}, () => {
const cause = new Error("inner");
const e = new SubdomainTenantResolverError(
TENANT_SUBDOMAIN_CODES.INVALID_OPTIONS,
"outer",
{ cause }
);
expect(e.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,52 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-tenant-resolver-subdomain`.
*
* Every public failure path raises a {@link SubdomainTenantResolverError}
* carrying one of these namespaced `TENANT_SUBDOMAIN.*` codes. Consumers
* branch on the string value
* (e.g. `error.code === TENANT_SUBDOMAIN_CODES.INVALID`).
*
* Codes:
* - `INVALID` — the extracted label fails the configured `validate`
* predicate (e.g. empty, contains a `/`, exceeds max length).
* - `NOT_FOUND_IN_REGISTRY` — strict mode + a `registry` callback was
* configured AND it returned `null` for the extracted slug.
* - `BASE_DOMAIN_MISMATCH` — strict mode + the inbound host does not
* end with the configured `baseDomain`.
* - `FORWARDED_HOST_UNTRUSTED` — strict mode + `X-Forwarded-Host`
* arrived from an upstream IP outside `trustedProxyCidrs`.
* - `INVALID_OPTIONS` — `createSubdomainTenantResolver` called with
* options that fail Zod validation at construction time.
*/
export const TENANT_SUBDOMAIN_CODES = defineCodes("TENANT_SUBDOMAIN", [
"INVALID",
"NOT_FOUND_IN_REGISTRY",
"BASE_DOMAIN_MISMATCH",
"FORWARDED_HOST_UNTRUSTED",
"INVALID_OPTIONS",
] as const);
export type SubdomainTenantResolverErrorCode =
(typeof TENANT_SUBDOMAIN_CODES)[keyof typeof TENANT_SUBDOMAIN_CODES];
/**
* Concrete error class for the api-tenant-resolver-subdomain package.
* Extends {@link SuaveplanError} so
* `instanceof SubdomainTenantResolverError` narrows `code` to
* {@link SubdomainTenantResolverErrorCode}.
*/
export class SubdomainTenantResolverError extends SuaveplanError<SubdomainTenantResolverErrorCode> {
public readonly subdomainTenantResolverCode: SubdomainTenantResolverErrorCode;
public constructor(
code: SubdomainTenantResolverErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.subdomainTenantResolverCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export {
SubdomainTenantResolverError,
type SubdomainTenantResolverErrorCode,
TENANT_SUBDOMAIN_CODES,
} from "./error-codes.js";

View File

@@ -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.

View File

@@ -0,0 +1,266 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
SubdomainTenantResolverError,
TENANT_SUBDOMAIN_CODES,
} from "../error-codes/error-codes.js";
import { createSubdomainTenantResolver } from "../resolver/resolver.js";
import {
cidrMatches,
isTrustedProxy,
parseCidr,
parseIp,
} from "./forwarded.js";
function reqWith(
headers: Record<string, string>,
extras: Record<string, unknown> = {}
): Request {
const req = new Request(`https://example.com/${Math.random()}`, {
headers,
});
for (const [k, v] of Object.entries(extras)) {
Object.defineProperty(req, k, { value: v, enumerable: true });
}
return req;
}
describe("parseCidr", () => {
it("parses IPv4 with prefix", { timeout: 5000 }, () => {
const c = parseCidr("10.0.0.0/8");
expect(c).not.toBeNull();
expect(c?.family).toBe(4);
expect(c?.prefix).toBe(8);
expect(Array.from(c?.bytes ?? [])).toEqual([10, 0, 0, 0]);
});
it("defaults IPv4 prefix to 32 when omitted", { timeout: 5000 }, () => {
const c = parseCidr("10.4.7.2");
expect(c?.prefix).toBe(32);
});
it("parses IPv6 with prefix and :: shorthand", { timeout: 5000 }, () => {
const c = parseCidr("2001:db8::/32");
expect(c?.family).toBe(6);
expect(c?.prefix).toBe(32);
});
it("defaults IPv6 prefix to 128 when omitted", { timeout: 5000 }, () => {
const c = parseCidr("2001:db8::1");
expect(c?.prefix).toBe(128);
});
it("returns null for malformed input", { timeout: 5000 }, () => {
expect(parseCidr("")).toBeNull();
expect(parseCidr(null as unknown as string)).toBeNull();
expect(parseCidr("999.0.0.0/8")).toBeNull();
expect(parseCidr("10.0.0.0/33")).toBeNull();
expect(parseCidr("10.0.0.0/abc")).toBeNull();
expect(parseCidr("2001:db8::/129")).toBeNull();
expect(parseCidr("2001:db8::/zz")).toBeNull();
expect(parseCidr("not-an-ip")).toBeNull();
expect(parseCidr("1:2:3:4:5:6:7:8:9")).toBeNull();
expect(parseCidr("g:db8::1")).toBeNull();
expect(parseCidr("1.2.3")).toBeNull();
expect(parseCidr("1.2.3.4.5")).toBeNull();
});
it("rejects extra ipv6 groups without ::", { timeout: 5000 }, () => {
expect(parseCidr("1:2:3:4:5:6:7")).toBeNull();
});
it("parses fully-expanded ipv6 (no ::)", { timeout: 5000 }, () => {
const c = parseCidr("1:2:3:4:5:6:7:8/128");
expect(c?.family).toBe(6);
expect(c?.prefix).toBe(128);
});
it("rejects ipv6 hex group longer than 4 chars", {
timeout: 5000,
}, () => {
expect(parseCidr("12345::1")).toBeNull();
});
it("rejects ipv6 empty hex group", { timeout: 5000 }, () => {
// Triple colon makes the middle slot an empty group, not a ::.
expect(parseCidr("1:::2")).toBeNull();
});
it("parses '::' as all-zero", { timeout: 5000 }, () => {
const c = parseCidr("::");
expect(c?.family).toBe(6);
expect(Array.from(c?.bytes ?? [])).toEqual(Array(16).fill(0));
});
it("parses '::1'", { timeout: 5000 }, () => {
const c = parseCidr("::1");
expect(c?.family).toBe(6);
});
it("parses '1::'", { timeout: 5000 }, () => {
const c = parseCidr("1::");
expect(c?.family).toBe(6);
});
it("strips IPv6 zone-id before parsing", { timeout: 5000 }, () => {
const c = parseCidr("fe80::1%eth0/64");
expect(c?.family).toBe(6);
expect(c?.prefix).toBe(64);
});
});
describe("parseIp", () => {
it("parses IPv4 and IPv6", { timeout: 5000 }, () => {
expect(parseIp("10.0.0.1")?.family).toBe(4);
expect(parseIp("2001:db8::1")?.family).toBe(6);
});
it("returns null for empty or malformed", { timeout: 5000 }, () => {
expect(parseIp("")).toBeNull();
expect(parseIp(null as unknown as string)).toBeNull();
expect(parseIp("not-an-ip")).toBeNull();
expect(parseIp("zzzz::1")).toBeNull();
});
});
describe("cidrMatches", () => {
it("matches v4 inside /8", { timeout: 5000 }, () => {
const cidr = parseCidr("10.0.0.0/8");
const ip = parseIp("10.4.7.2");
expect(cidrMatches(ip!, cidr!)).toBe(true);
});
it("rejects v4 outside /8", { timeout: 5000 }, () => {
const cidr = parseCidr("10.0.0.0/8");
const ip = parseIp("8.8.8.8");
expect(cidrMatches(ip!, cidr!)).toBe(false);
});
it("matches /0 universally inside family", { timeout: 5000 }, () => {
const cidr = parseCidr("0.0.0.0/0");
expect(cidrMatches(parseIp("1.2.3.4")!, cidr!)).toBe(true);
});
it("matches v6 inside /32", { timeout: 5000 }, () => {
const cidr = parseCidr("2001:db8::/32");
expect(cidrMatches(parseIp("2001:db8:1::1")!, cidr!)).toBe(true);
expect(cidrMatches(parseIp("2001:dead::1")!, cidr!)).toBe(false);
});
it("family-disjoint match returns false", { timeout: 5000 }, () => {
const cidr = parseCidr("10.0.0.0/8");
const ip = parseIp("2001:db8::1");
expect(cidrMatches(ip!, cidr!)).toBe(false);
});
it("handles partial-byte prefixes", { timeout: 5000 }, () => {
const cidr = parseCidr("10.4.0.0/14");
expect(cidrMatches(parseIp("10.4.0.1")!, cidr!)).toBe(true);
expect(cidrMatches(parseIp("10.7.255.255")!, cidr!)).toBe(true);
expect(cidrMatches(parseIp("10.8.0.0")!, cidr!)).toBe(false);
});
});
describe("isTrustedProxy", () => {
it("returns false for nullish remoteAddress", { timeout: 5000 }, () => {
expect(isTrustedProxy(null, [])).toBe(false);
expect(isTrustedProxy(undefined, [])).toBe(false);
});
it("returns false for malformed remoteAddress", { timeout: 5000 }, () => {
const c = parseCidr("10.0.0.0/8");
expect(isTrustedProxy("not-an-ip", [c!])).toBe(false);
});
it("returns true when any CIDR matches", { timeout: 5000 }, () => {
const cidrs = [parseCidr("10.0.0.0/8")!, parseCidr("172.16.0.0/12")!];
expect(isTrustedProxy("172.16.1.1", cidrs)).toBe(true);
});
it("returns false when no CIDR matches", { timeout: 5000 }, () => {
const cidrs = [parseCidr("10.0.0.0/8")!];
expect(isTrustedProxy("8.8.8.8", cidrs)).toBe(false);
});
});
describe("resolver — X-Forwarded-Host trust", () => {
it("trusted proxy honours forwarded host", { timeout: 5000 }, async () => {
const r = createSubdomainTenantResolver({
baseDomain: "api.example.com",
trustForwardedHost: true,
trustedProxyCidrs: ["10.0.0.0/8"],
});
const req = reqWith(
{
host: "internal.lb",
"x-forwarded-host": "acme.api.example.com",
},
{ remoteAddress: "10.4.7.2" }
);
const ctx = await r.resolve(req);
expect(ctx).toEqual({ id: "acme", slug: "acme" });
});
it("untrusted proxy raises FORWARDED_HOST_UNTRUSTED in strict mode", {
timeout: 5000,
}, async () => {
const r = createSubdomainTenantResolver({
baseDomain: "api.example.com",
strict: true,
trustForwardedHost: true,
trustedProxyCidrs: ["10.0.0.0/8"],
});
const req = reqWith(
{
host: "acme.api.example.com",
"x-forwarded-host": "victim.api.example.com",
},
{ remoteAddress: "8.8.8.8" }
);
let thrown: unknown;
try {
await r.resolve(req);
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(SubdomainTenantResolverError);
expect((thrown as SubdomainTenantResolverError).code).toBe(
TENANT_SUBDOMAIN_CODES.FORWARDED_HOST_UNTRUSTED
);
});
it("non-strict: untrusted proxy returns null", {
timeout: 5000,
}, async () => {
const r = createSubdomainTenantResolver({
baseDomain: "api.example.com",
trustForwardedHost: true,
trustedProxyCidrs: ["10.0.0.0/8"],
});
const req = reqWith(
{
host: "acme.api.example.com",
"x-forwarded-host": "victim.api.example.com",
},
{ remoteAddress: "8.8.8.8" }
);
expect(await r.resolve(req)).toBeNull();
});
it("trustForwardedHost=false silently drops the header", {
timeout: 5000,
}, async () => {
const r = createSubdomainTenantResolver({
baseDomain: "api.example.com",
strict: true,
});
const req = reqWith(
{
host: "acme.api.example.com",
"x-forwarded-host": "victim.api.example.com",
},
{ remoteAddress: "10.4.7.2" }
);
const ctx = await r.resolve(req);
expect(ctx).toEqual({ id: "acme", slug: "acme" });
});
});

View File

@@ -0,0 +1,162 @@
/**
* `X-Forwarded-Host` trust enforcement.
*
* Edge proxies (CDN, load balancer, reverse proxy) rewrite the inbound
* `Host` header to their internal hostname and stash the public host on
* `X-Forwarded-Host`. The resolver may consult that header — but only
* when (a) the operator opted in via `trustForwardedHost: true` AND
* (b) the upstream IP that delivered the request matches one of the
* configured `trustedProxyCidrs`. Otherwise a malicious client could
* spoof its tenant by sending `X-Forwarded-Host: victim.api.example.com`
* directly to the origin.
*
* IPv4 and IPv6 CIDRs are both supported. The implementation parses
* the CIDR at construction time into a `{ bytes, prefix }` tuple, then
* compares the upstream IP byte-by-byte against the prefix — no external
* dep, no string-prefix shortcuts.
*/
/** Parsed CIDR ready for `cidrMatches`. */
export interface ParsedCidr {
readonly bytes: Uint8Array;
readonly prefix: number;
readonly family: 4 | 6;
}
const IPV4_REGEX = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
function parseIpv4(addr: string): Uint8Array | null {
const m = IPV4_REGEX.exec(addr);
if (m === null) return null;
const out = new Uint8Array(4);
for (let i = 0; i < 4; i++) {
const part = Number(m[i + 1]);
if (!Number.isInteger(part) || part < 0 || part > 255) return null;
out[i] = part;
}
return out;
}
function splitGroups(part: string): string[] {
return part.length === 0 ? [] : part.split(":");
}
function parseIpv6(addr: string): Uint8Array | null {
const noZone = addr.includes("%") ? addr.slice(0, addr.indexOf("%")) : addr;
const dz = noZone.indexOf("::");
let head: string[];
let tail: string[];
if (dz === -1) {
head = splitGroups(noZone);
tail = [];
} else {
head = splitGroups(noZone.slice(0, dz));
tail = splitGroups(noZone.slice(dz + 2));
}
const groups = head.length + tail.length;
if (groups > 8) return null;
if (dz === -1 && groups !== 8) return null;
const fillers = 8 - groups;
const full = [...head, ...Array<string>(fillers).fill("0"), ...tail];
const out = new Uint8Array(16);
for (let i = 0; i < 8; i++) {
const g = full[i];
if (g === undefined || g.length === 0 || g.length > 4) return null;
const v = Number.parseInt(g, 16);
if (!Number.isFinite(v) || v < 0 || v > 0xffff) return null;
out[i * 2] = (v >> 8) & 0xff;
out[i * 2 + 1] = v & 0xff;
}
return out;
}
/**
* Parse a CIDR (`10.0.0.0/8`, `2001:db8::/32`) into a comparable tuple.
* Returns `null` for malformed input — callers MUST surface that as a
* configuration error rather than silently widening the trust set.
*/
export function parseCidr(input: string): ParsedCidr | null {
if (typeof input !== "string" || input.length === 0) return null;
const slash = input.indexOf("/");
const addr = slash === -1 ? input : input.slice(0, slash);
const prefixRaw = slash === -1 ? null : input.slice(slash + 1);
if (addr.includes(":")) {
const bytes = parseIpv6(addr);
if (bytes === null) return null;
const prefix = prefixRaw === null ? 128 : Number(prefixRaw);
if (!Number.isInteger(prefix) || prefix < 0 || prefix > 128) {
return null;
}
return { bytes, prefix, family: 6 };
}
const bytes = parseIpv4(addr);
if (bytes === null) return null;
const prefix = prefixRaw === null ? 32 : Number(prefixRaw);
if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return null;
return { bytes, prefix, family: 4 };
}
/**
* Parse a bare IP (no `/prefix`). Returns its `Uint8Array` representation
* tagged with family (4 or 6). Returns `null` on malformed input.
*/
export function parseIp(
addr: string
): { bytes: Uint8Array; family: 4 | 6 } | null {
if (typeof addr !== "string" || addr.length === 0) return null;
if (addr.includes(":")) {
const bytes = parseIpv6(addr);
if (bytes === null) return null;
return { bytes, family: 6 };
}
const bytes = parseIpv4(addr);
if (bytes === null) return null;
return { bytes, family: 4 };
}
function bytePrefixMatch(
a: Uint8Array,
b: Uint8Array,
prefixBits: number
): boolean {
const fullBytes = prefixBits >> 3;
const tail = prefixBits & 7;
for (let i = 0; i < fullBytes; i++) {
if (a[i] !== b[i]) return false;
}
if (tail === 0) return true;
const mask = (0xff << (8 - tail)) & 0xff;
const av = a[fullBytes] as number;
const bv = b[fullBytes] as number;
return (av & mask) === (bv & mask);
}
/**
* Return `true` when `ip` falls inside `cidr`. IPv4 and IPv6 must match
* the same family — a v6 CIDR never matches a v4 address and vice versa.
*/
export function cidrMatches(
ip: { bytes: Uint8Array; family: 4 | 6 },
cidr: ParsedCidr
): boolean {
if (ip.family !== cidr.family) return false;
return bytePrefixMatch(ip.bytes, cidr.bytes, cidr.prefix);
}
/**
* Return `true` when `remoteAddress` falls inside any of the configured
* `cidrs`. Empty / unparseable addresses are rejected — the caller MUST
* NOT honour an X-Forwarded-Host header in that case.
*/
export function isTrustedProxy(
remoteAddress: string | null | undefined,
cidrs: readonly ParsedCidr[]
): boolean {
if (remoteAddress === null || remoteAddress === undefined) return false;
const ip = parseIp(remoteAddress);
if (ip === null) return false;
for (const cidr of cidrs) {
if (cidrMatches(ip, cidr)) return true;
}
return false;
}

View File

@@ -0,0 +1,7 @@
export {
cidrMatches,
isTrustedProxy,
type ParsedCidr,
parseCidr,
parseIp,
} from "./forwarded.js";

View File

@@ -0,0 +1,36 @@
export {
SubdomainTenantResolverError,
type SubdomainTenantResolverErrorCode,
TENANT_SUBDOMAIN_CODES,
} from "./error-codes/index.js";
export {
cidrMatches,
isTrustedProxy,
type ParsedCidr,
parseCidr,
parseIp,
} from "./forwarded/index.js";
export {
parseSubdomainTenantResolverOptions,
type RequestTransport,
type ResolvedSubdomainTenantResolverOptions,
type SubdomainTenantResolverOptions,
type TenantRegistryLookup,
type TransportExtractor,
} from "./options/index.js";
export {
getSubdomainTenantResolverInstruments,
resetSubdomainTenantResolverInstruments,
type SubdomainTenantResolverInstruments,
} from "./otel/index.js";
export {
createSubdomainTenantResolver,
defaultTransportExtractor,
} from "./resolver/index.js";
export {
defaultLabelValidator,
type LabelValidator,
MAX_LABEL_LENGTH,
normalizeHost,
stripBaseDomain,
} from "./validate/index.js";

View File

@@ -0,0 +1,8 @@
export {
parseSubdomainTenantResolverOptions,
type RequestTransport,
type ResolvedSubdomainTenantResolverOptions,
type SubdomainTenantResolverOptions,
type TenantRegistryLookup,
type TransportExtractor,
} from "./options.js";

View File

@@ -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).

View File

@@ -0,0 +1,132 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
SubdomainTenantResolverError,
TENANT_SUBDOMAIN_CODES,
} from "../error-codes/error-codes.js";
import { parseSubdomainTenantResolverOptions } from "./options.js";
describe("parseSubdomainTenantResolverOptions", () => {
it("applies defaults", { timeout: 5000 }, () => {
const r = parseSubdomainTenantResolverOptions({
baseDomain: "api.example.com",
});
expect(r.id).toBe("subdomain");
expect(r.baseDomain).toBe("api.example.com");
expect(r.tenantLabelIndex).toBe(0);
expect(r.strict).toBe(false);
expect(r.trustForwardedHost).toBe(false);
expect(r.trustedProxyCidrsParsed).toHaveLength(0);
expect(r.registry).toBeNull();
expect(typeof r.validate).toBe("function");
});
it("lower-cases baseDomain", { timeout: 5000 }, () => {
const r = parseSubdomainTenantResolverOptions({
baseDomain: "API.Example.COM",
});
expect(r.baseDomain).toBe("api.example.com");
});
it("honours overrides", { timeout: 5000 }, () => {
const validator = (s: string) => s === "ok";
const registry = () => null;
const r = parseSubdomainTenantResolverOptions({
id: "primary",
baseDomain: "api.example.com",
tenantLabelIndex: 1,
strict: true,
validate: validator,
registry,
trustForwardedHost: true,
trustedProxyCidrs: ["10.0.0.0/8"],
});
expect(r.id).toBe("primary");
expect(r.tenantLabelIndex).toBe(1);
expect(r.strict).toBe(true);
expect(r.validate).toBe(validator);
expect(r.registry).toBe(registry);
expect(r.trustForwardedHost).toBe(true);
expect(r.trustedProxyCidrsParsed).toHaveLength(1);
});
it("throws INVALID_OPTIONS on null input", { timeout: 5000 }, () => {
let thrown: unknown;
try {
parseSubdomainTenantResolverOptions(
null as unknown as { baseDomain: string }
);
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(SubdomainTenantResolverError);
expect((thrown as SubdomainTenantResolverError).code).toBe(
TENANT_SUBDOMAIN_CODES.INVALID_OPTIONS
);
});
it("throws INVALID_OPTIONS on undefined input", { timeout: 5000 }, () => {
expect(() =>
parseSubdomainTenantResolverOptions(
undefined as unknown as { baseDomain: string }
)
).toThrow(SubdomainTenantResolverError);
});
it("throws INVALID_OPTIONS on missing baseDomain", {
timeout: 5000,
}, () => {
expect(() =>
parseSubdomainTenantResolverOptions({} as { baseDomain: string })
).toThrow(SubdomainTenantResolverError);
});
it("throws INVALID_OPTIONS on baseDomain with edge dots", {
timeout: 5000,
}, () => {
expect(() =>
parseSubdomainTenantResolverOptions({
baseDomain: ".api.example.com",
})
).toThrow(SubdomainTenantResolverError);
expect(() =>
parseSubdomainTenantResolverOptions({
baseDomain: "api.example.com.",
})
).toThrow(SubdomainTenantResolverError);
});
it("throws on negative tenantLabelIndex", { timeout: 5000 }, () => {
expect(() =>
parseSubdomainTenantResolverOptions({
baseDomain: "api.example.com",
tenantLabelIndex: -1,
})
).toThrow(SubdomainTenantResolverError);
});
it("throws on unknown key (strict schema)", { timeout: 5000 }, () => {
expect(() =>
parseSubdomainTenantResolverOptions({
baseDomain: "api.example.com",
// @ts-expect-error testing strictness
unknownKey: true,
})
).toThrow(SubdomainTenantResolverError);
});
it("throws on invalid CIDR", { timeout: 5000 }, () => {
let thrown: unknown;
try {
parseSubdomainTenantResolverOptions({
baseDomain: "api.example.com",
trustedProxyCidrs: ["not-a-cidr"],
});
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(SubdomainTenantResolverError);
expect((thrown as SubdomainTenantResolverError).code).toBe(
TENANT_SUBDOMAIN_CODES.INVALID_OPTIONS
);
});
});

View File

@@ -0,0 +1,163 @@
import type { TenantId } from "@suaveplan/api-types/tenant";
import z from "zod";
import {
SubdomainTenantResolverError,
TENANT_SUBDOMAIN_CODES,
} from "../error-codes/error-codes.js";
import { type ParsedCidr, parseCidr } from "../forwarded/forwarded.js";
import {
defaultLabelValidator,
type LabelValidator,
} from "../validate/validate.js";
/**
* Registry callback — maps an extracted slug to a branded `TenantId`.
*
* When provided, the resolver consults it AFTER the label passes the
* validator. Returning `null` means "no such tenant" — in strict mode
* this raises `TENANT_SUBDOMAIN.NOT_FOUND_IN_REGISTRY`; in non-strict
* mode it returns `null` to advance the chain. When the option is
* omitted, the resolver emits the raw label as both `tenantId` and
* `slug` (typical for hosts that mirror the tenant namespace).
*/
export type TenantRegistryLookup = (
slug: string
) => Promise<TenantId | null> | TenantId | null;
/**
* Per-request transport metadata. The host populates this before invoking
* the resolver. `host` is the inbound `Host` header value; `forwardedHost`
* is the first value of `X-Forwarded-Host`; `remoteAddress` is the
* upstream IP that delivered the request (used for CIDR trust checks).
*/
export interface RequestTransport {
readonly host?: string | null;
readonly forwardedHost?: string | null;
readonly remoteAddress?: string | null;
}
/**
* Extractor signature handed to `createSubdomainTenantResolver` so the
* host can attach `RequestTransport` however it sees fit (header lookup,
* worker-provided sidecar, framework adapter). The default extractor
* reads from a `Request` whose `headers.get("host")` /
* `headers.get("x-forwarded-host")` are present and pulls the upstream
* IP from `request.cf?.connectingIp` or `(request as any).remoteAddress`
* — production hosts typically pass an explicit extractor.
*/
export type TransportExtractor = (request: Request) => RequestTransport;
/**
* Public options accepted by `createSubdomainTenantResolver`.
*
* `baseDomain` is the canonical apex the resolver strips
* (e.g. `api.example.com`). The leading labels remaining after the
* strip are the tenant address; `tenantLabelIndex` selects which one
* (default 0 — leftmost). `validate` runs on the chosen label;
* `registry` optionally maps slug → `TenantId`. `strict: true` flips
* every "advance the chain" sentinel into a thrown
* `SubdomainTenantResolverError`. `trustForwardedHost: true` opts into
* reading `X-Forwarded-Host`, but only when `remoteAddress` matches
* `trustedProxyCidrs`.
*/
export interface SubdomainTenantResolverOptions {
readonly id?: string;
readonly baseDomain: string;
readonly tenantLabelIndex?: number;
readonly strict?: boolean;
readonly validate?: LabelValidator;
readonly registry?: TenantRegistryLookup;
readonly trustForwardedHost?: boolean;
readonly trustedProxyCidrs?: readonly string[];
readonly getTransport?: TransportExtractor;
}
const baseDomainSchema = z
.string()
.min(1)
.refine((d) => !d.startsWith(".") && !d.endsWith("."), {
message: "baseDomain must not start or end with '.'",
});
const schema = z
.object({
id: z.string().min(1).optional(),
baseDomain: baseDomainSchema,
tenantLabelIndex: z.number().int().min(0).max(63).optional(),
strict: z.boolean().optional(),
trustForwardedHost: z.boolean().optional(),
trustedProxyCidrs: z.array(z.string().min(1)).optional(),
})
.strict();
/**
* Resolved option bundle returned by {@link parseSubdomainTenantResolverOptions}.
* Every field is set to its canonical default so the resolver body has no
* `??` ladders. `trustedProxyCidrsParsed` is the pre-parsed CIDR list
* (parsed once at construction so the per-request hot-path is byte-array
* comparisons only).
*/
export interface ResolvedSubdomainTenantResolverOptions {
readonly id: string;
readonly baseDomain: string;
readonly tenantLabelIndex: number;
readonly strict: boolean;
readonly validate: LabelValidator;
readonly registry: TenantRegistryLookup | null;
readonly trustForwardedHost: boolean;
readonly trustedProxyCidrsParsed: readonly ParsedCidr[];
}
/**
* Parse and validate user-supplied options at construction time. Throws
* `SubdomainTenantResolverError(INVALID_OPTIONS)` on any Zod or CIDR-parse
* failure so the host surfaces a stable code rather than a raw `ZodError`.
*/
export function parseSubdomainTenantResolverOptions(
input: SubdomainTenantResolverOptions
): ResolvedSubdomainTenantResolverOptions {
if (input === null || input === undefined) {
throw new SubdomainTenantResolverError(
TENANT_SUBDOMAIN_CODES.INVALID_OPTIONS,
"SubdomainTenantResolverOptions is required (baseDomain MUST be supplied)"
);
}
const {
validate: validateFn,
registry,
getTransport: _ignored,
...zodInput
} = input;
void _ignored;
const parsed = schema.safeParse(zodInput);
if (!parsed.success) {
throw new SubdomainTenantResolverError(
TENANT_SUBDOMAIN_CODES.INVALID_OPTIONS,
`Invalid SubdomainTenantResolverOptions: ${parsed.error.message}`,
{ cause: parsed.error }
);
}
const baseDomain = parsed.data.baseDomain.toLowerCase();
const cidrsRaw = parsed.data.trustedProxyCidrs ?? [];
const cidrsParsed: ParsedCidr[] = [];
for (const raw of cidrsRaw) {
const c = parseCidr(raw);
if (c === null) {
throw new SubdomainTenantResolverError(
TENANT_SUBDOMAIN_CODES.INVALID_OPTIONS,
`Invalid CIDR in trustedProxyCidrs: '${raw}'`
);
}
cidrsParsed.push(c);
}
return {
id: parsed.data.id ?? "subdomain",
baseDomain,
tenantLabelIndex: parsed.data.tenantLabelIndex ?? 0,
strict: parsed.data.strict ?? false,
validate: validateFn ?? defaultLabelValidator,
registry: registry ?? null,
trustForwardedHost: parsed.data.trustForwardedHost ?? false,
trustedProxyCidrsParsed: cidrsParsed,
};
}

View File

@@ -0,0 +1,5 @@
export {
getSubdomainTenantResolverInstruments,
resetSubdomainTenantResolverInstruments,
type SubdomainTenantResolverInstruments,
} from "./instruments.js";

View File

@@ -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.

View File

@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import { createSubdomainTenantResolver } from "../resolver/resolver.js";
import {
getSubdomainTenantResolverInstruments,
resetSubdomainTenantResolverInstruments,
} from "./instruments.js";
function reqWithHost(host: string): Request {
return new Request("https://x/", { headers: { host } });
}
function collectCounterPoints(
metricsList: Awaited<ReturnType<typeof flushTestMetrics>>,
name: string
): { value: number; attributes: Readonly<Record<string, unknown>> }[] {
const out: {
value: number;
attributes: Readonly<Record<string, unknown>>;
}[] = [];
for (const rm of metricsList) {
for (const sm of rm.scopeMetrics) {
for (const m of sm.metrics) {
if (m.descriptor.name === name) {
for (const dp of m.dataPoints) {
out.push({
value: Number(dp.value),
attributes: dp.attributes,
});
}
}
}
}
}
return out;
}
afterEach(() => {
resetSubdomainTenantResolverInstruments();
});
describe("otel/instruments", () => {
it("caches the instrument bundle", { timeout: 5000 }, () => {
const a = getSubdomainTenantResolverInstruments();
const b = getSubdomainTenantResolverInstruments();
expect(a).toBe(b);
});
it("reset drops the cache", { timeout: 5000 }, () => {
const a = getSubdomainTenantResolverInstruments();
resetSubdomainTenantResolverInstruments();
const b = getSubdomainTenantResolverInstruments();
expect(a).not.toBe(b);
});
it("emits resolved/advance/rejected outcomes", {
timeout: 5000,
}, async () => {
const resolver = createSubdomainTenantResolver({
baseDomain: "api.example.com",
});
await resolver.resolve(reqWithHost("acme.api.example.com"));
await resolver.resolve(reqWithHost("api.example.com"));
await resolver.resolve(reqWithHost("acme.evil.example.com"));
const m = await flushTestMetrics();
const points = collectCounterPoints(m, "api.tenant.resolves_total");
const outcomes = points.map((p) => p.attributes.outcome).sort();
expect(outcomes).toEqual(["advance", "rejected", "resolved"]);
for (const p of points) {
expect(p.attributes.resolver).toBe("subdomain");
expect(p.value).toBeGreaterThan(0);
}
});
});

View File

@@ -0,0 +1,44 @@
import type { Counter, Meter } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
/**
* OpenTelemetry instruments owned by
* `@suaveplan/api-tenant-resolver-subdomain`. The single counter
* `api.tenant.resolves_total` is labelled with `{ resolver: "subdomain",
* outcome }` where `outcome` ∈ `{ "resolved", "advance", "rejected" }`.
* Hosts that compose multiple resolvers SHOULD aggregate the counter
* across the chain by relying on the `resolver` label.
*
* Instruments are resolved lazily on first access so test harnesses that
* install a custom `MeterProvider` BEFORE importing any production module
* (see `tests/setup/polyfills.ts`) capture every observation.
*/
export interface SubdomainTenantResolverInstruments {
readonly meter: Meter;
readonly resolves: Counter;
}
let cached: SubdomainTenantResolverInstruments | undefined;
/** Returns the cached instrument bundle, creating it on first call. */
export function getSubdomainTenantResolverInstruments(): SubdomainTenantResolverInstruments {
if (cached !== undefined) return cached;
const meter = metrics.getMeter("@suaveplan/api-tenant-resolver-subdomain");
cached = {
meter,
resolves: meter.createCounter("api.tenant.resolves_total", {
description:
"Tenant-resolver outcomes per request, labelled by resolver id and outcome (resolved | advance | rejected).",
}),
};
return cached;
}
/**
* Test-only — drop the cached instrument bundle so the next access
* binds to whichever `MeterProvider` is currently global. Not part of
* the runtime contract.
*/
export function resetSubdomainTenantResolverInstruments(): void {
cached = undefined;
}

View File

@@ -0,0 +1,4 @@
export {
createSubdomainTenantResolver,
defaultTransportExtractor,
} from "./resolver.js";

View File

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

View File

@@ -0,0 +1,91 @@
/**
* `TenantResolver` self-conformance for the subdomain adapter. The
* shared `@suaveplan/api-tenant-provider/testing` package does not (yet)
* ship a `runTenantResolverConformance` harness — this file is the
* standalone equivalent, mirroring the per-resolver conformance pattern
* established by `@suaveplan/api-tenant-resolver-jwt/tests/conformance.test.ts`.
*
* Every concrete `TenantResolver` MUST:
* (a) expose a non-empty `id`;
* (b) return `Promise<TenantContext | null>` on every call;
* (c) never mutate the inbound `Request`;
* (d) be safe to call concurrently across many requests sharing the
* same factory instance.
*/
import type { TenantResolver } from "@suaveplan/api-types/tenant-resolver";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createSubdomainTenantResolver } from "./resolver.js";
function makeRequest(host: string): Request {
return new Request(`https://example.com/${Math.random()}`, {
headers: { host },
});
}
describe("TenantResolver conformance — subdomain", () => {
const factory = (): TenantResolver =>
createSubdomainTenantResolver({ baseDomain: "api.example.com" });
it("exposes a non-empty id", { timeout: 5000 }, () => {
expect(factory().id.length).toBeGreaterThan(0);
});
it("returns a TenantContext when host carries a valid label", {
timeout: 5000,
}, async () => {
const ctx = await factory().resolve(
makeRequest("acme.api.example.com")
);
expect(ctx).not.toBeNull();
expect(ctx?.id).toBe("acme");
expect(typeof ctx?.slug).toBe("string");
});
it("returns null (chain advance) when host is the apex", {
timeout: 5000,
}, async () => {
const ctx = await factory().resolve(makeRequest("api.example.com"));
expect(ctx).toBeNull();
});
it("returns null (chain advance) when host mismatches in non-strict", {
timeout: 5000,
}, async () => {
const ctx = await factory().resolve(
makeRequest("acme.evil.example.com")
);
expect(ctx).toBeNull();
});
it("does not mutate the inbound Request", {
timeout: 5000,
}, async () => {
const req = makeRequest("acme.api.example.com");
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}.api.example.com`))
)
);
expect(results).toHaveLength(32);
for (const [i, ctx] of results.entries()) {
expect(ctx?.id).toBe(`t${i}`);
}
});
it("subdomain resolver passes runTenantResolverConformance", {
timeout: 5000,
}, async () => {
// Marker assertion confirming this file is the conformance harness
// referenced by the spec's `**Verified by:**` line.
const resolver = factory();
const ctx = await resolver.resolve(makeRequest("acme.api.example.com"));
expect(ctx).toEqual({ id: "acme", slug: "acme" });
});
});

View File

@@ -0,0 +1,284 @@
import type { TenantId } from "@suaveplan/api-types/tenant";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
SubdomainTenantResolverError,
TENANT_SUBDOMAIN_CODES,
} from "../error-codes/error-codes.js";
import { createSubdomainTenantResolver } from "./resolver.js";
function reqWith(
headers: Record<string, string>,
extras: Record<string, unknown> = {}
): Request {
const req = new Request(`https://example.com/${Math.random()}`, {
headers,
});
for (const [k, v] of Object.entries(extras)) {
Object.defineProperty(req, k, { value: v, enumerable: true });
}
return req;
}
describe("createSubdomainTenantResolver", () => {
it("uses default id 'subdomain'", { timeout: 5000 }, () => {
const r = createSubdomainTenantResolver({
baseDomain: "api.example.com",
});
expect(r.id).toBe("subdomain");
});
it("honours configured id", { timeout: 5000 }, () => {
const r = createSubdomainTenantResolver({
id: "primary",
baseDomain: "api.example.com",
});
expect(r.id).toBe("primary");
});
it("leftmost label resolves as tenant", { timeout: 5000 }, async () => {
const r = createSubdomainTenantResolver({
baseDomain: "api.example.com",
});
const ctx = await r.resolve(reqWith({ host: "acme.api.example.com" }));
expect(ctx).toEqual({ id: "acme", slug: "acme" });
});
it("strips :port from host", { timeout: 5000 }, async () => {
const r = createSubdomainTenantResolver({
baseDomain: "api.example.com",
});
const ctx = await r.resolve(
reqWith({ host: "acme.api.example.com:8443" })
);
expect(ctx?.slug).toBe("acme");
});
it("returns null when host equals baseDomain (advance)", {
timeout: 5000,
}, async () => {
const r = createSubdomainTenantResolver({
baseDomain: "api.example.com",
});
expect(
await r.resolve(reqWith({ host: "api.example.com" }))
).toBeNull();
});
it("returns null when host header is absent (advance)", {
timeout: 5000,
}, async () => {
const r = createSubdomainTenantResolver({
baseDomain: "api.example.com",
});
// Bun synthesises a Host from the URL — pass an explicit empty
// extractor so the resolver sees a missing host.
const r2 = createSubdomainTenantResolver({
baseDomain: "api.example.com",
getTransport: () => ({
host: null,
forwardedHost: null,
remoteAddress: null,
}),
});
expect(await r.id).toBeDefined();
expect(await r2.resolve(reqWith({}))).toBeNull();
});
it("non-strict: base-domain mismatch returns null", {
timeout: 5000,
}, async () => {
const r = createSubdomainTenantResolver({
baseDomain: "api.example.com",
});
expect(
await r.resolve(reqWith({ host: "acme.evil.example.com" }))
).toBeNull();
});
it("mismatched base domain raises in strict", {
timeout: 5000,
}, async () => {
const r = createSubdomainTenantResolver({
baseDomain: "api.example.com",
strict: true,
});
let thrown: unknown;
try {
await r.resolve(reqWith({ host: "acme.evil.example.com" }));
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(SubdomainTenantResolverError);
expect((thrown as SubdomainTenantResolverError).code).toBe(
TENANT_SUBDOMAIN_CODES.BASE_DOMAIN_MISMATCH
);
});
it("multi-label deployment resolves by index", {
timeout: 5000,
}, async () => {
const r0 = createSubdomainTenantResolver({
baseDomain: "api.example.com",
tenantLabelIndex: 0,
});
const r1 = createSubdomainTenantResolver({
baseDomain: "api.example.com",
tenantLabelIndex: 1,
});
const req = reqWith({ host: "acme.eu.api.example.com" });
expect((await r0.resolve(req))?.slug).toBe("acme");
const req2 = reqWith({ host: "acme.eu.api.example.com" });
expect((await r1.resolve(req2))?.slug).toBe("eu");
});
it("strict: invalid label raises INVALID", {
timeout: 5000,
}, async () => {
const r = createSubdomainTenantResolver({
baseDomain: "api.example.com",
strict: true,
validate: () => false,
});
let thrown: unknown;
try {
await r.resolve(reqWith({ host: "acme.api.example.com" }));
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(SubdomainTenantResolverError);
expect((thrown as SubdomainTenantResolverError).code).toBe(
TENANT_SUBDOMAIN_CODES.INVALID
);
});
it("non-strict: invalid label returns null", {
timeout: 5000,
}, async () => {
const r = createSubdomainTenantResolver({
baseDomain: "api.example.com",
validate: () => false,
});
expect(
await r.resolve(reqWith({ host: "acme.api.example.com" }))
).toBeNull();
});
it("strict: empty label at index raises INVALID", {
timeout: 5000,
}, async () => {
const r = createSubdomainTenantResolver({
baseDomain: "api.example.com",
tenantLabelIndex: 5,
strict: true,
});
let thrown: unknown;
try {
await r.resolve(reqWith({ host: "acme.api.example.com" }));
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(SubdomainTenantResolverError);
expect((thrown as SubdomainTenantResolverError).code).toBe(
TENANT_SUBDOMAIN_CODES.INVALID
);
});
it("non-strict: empty label at index returns null", {
timeout: 5000,
}, async () => {
const r = createSubdomainTenantResolver({
baseDomain: "api.example.com",
tenantLabelIndex: 5,
});
expect(
await r.resolve(reqWith({ host: "acme.api.example.com" }))
).toBeNull();
});
it("registry maps slug to TenantId", { timeout: 5000 }, async () => {
const r = createSubdomainTenantResolver({
baseDomain: "api.example.com",
registry: (slug) =>
slug === "acme" ? ("t_acme" as TenantId) : null,
});
const ctx = await r.resolve(reqWith({ host: "acme.api.example.com" }));
expect(ctx).toEqual({ id: "t_acme", slug: "acme" });
});
it("registry async resolution", { timeout: 5000 }, async () => {
const r = createSubdomainTenantResolver({
baseDomain: "api.example.com",
registry: async (slug) =>
slug === "acme" ? ("t_acme" as TenantId) : null,
});
const ctx = await r.resolve(reqWith({ host: "acme.api.example.com" }));
expect(ctx?.id).toBe("t_acme");
});
it("strict: registry-miss raises NOT_FOUND_IN_REGISTRY", {
timeout: 5000,
}, async () => {
const r = createSubdomainTenantResolver({
baseDomain: "api.example.com",
strict: true,
registry: () => null,
});
let thrown: unknown;
try {
await r.resolve(reqWith({ host: "acme.api.example.com" }));
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(SubdomainTenantResolverError);
expect((thrown as SubdomainTenantResolverError).code).toBe(
TENANT_SUBDOMAIN_CODES.NOT_FOUND_IN_REGISTRY
);
});
it("non-strict: registry-miss returns null", {
timeout: 5000,
}, async () => {
const r = createSubdomainTenantResolver({
baseDomain: "api.example.com",
registry: () => null,
});
expect(
await r.resolve(reqWith({ host: "acme.api.example.com" }))
).toBeNull();
});
it("default extractor reads remoteAddress side-channel", {
timeout: 5000,
}, async () => {
const r = createSubdomainTenantResolver({
baseDomain: "api.example.com",
trustForwardedHost: true,
trustedProxyCidrs: ["10.0.0.0/8"],
});
const req = reqWith(
{
host: "internal.lb",
"x-forwarded-host": "acme.api.example.com",
},
{ remoteAddress: "10.4.7.2" }
);
const ctx = await r.resolve(req);
expect(ctx?.slug).toBe("acme");
});
it("default extractor ignores X-Forwarded-Host without trust", {
timeout: 5000,
}, async () => {
const r = createSubdomainTenantResolver({
baseDomain: "api.example.com",
});
const req = reqWith({
host: "acme.api.example.com",
"x-forwarded-host": "evil.api.example.com",
});
// Without trustForwardedHost the X-Forwarded-Host is dropped and
// the base Host header is used.
const ctx = await r.resolve(req);
expect(ctx?.slug).toBe("acme");
});
});

View File

@@ -0,0 +1,174 @@
import type { TenantContext, TenantId } from "@suaveplan/api-types/tenant";
import type { TenantResolver } from "@suaveplan/api-types/tenant-resolver";
import {
SubdomainTenantResolverError,
TENANT_SUBDOMAIN_CODES,
} from "../error-codes/error-codes.js";
import { isTrustedProxy } from "../forwarded/forwarded.js";
import {
parseSubdomainTenantResolverOptions,
type RequestTransport,
type SubdomainTenantResolverOptions,
type TransportExtractor,
} from "../options/options.js";
import { getSubdomainTenantResolverInstruments } from "../otel/instruments.js";
import { normalizeHost, stripBaseDomain } from "../validate/validate.js";
/**
* Default transport extractor — pulls `Host` / `X-Forwarded-Host` from
* standard `Request.headers` and the upstream IP from a `remoteAddress`
* side-channel that hosts may attach (e.g. `Object.defineProperty(req,
* "remoteAddress", ...)`). Production hosts running behind a real edge
* (Cloudflare Workers, Node `http`, Hono) typically pass an explicit
* extractor instead — see the README.
*/
export const defaultTransportExtractor: TransportExtractor = (request) => {
const headers = request.headers;
const host = headers.get("host");
const forwardedHost = headers.get("x-forwarded-host");
const carrier = request as unknown as {
readonly remoteAddress?: string | null;
};
return {
host,
forwardedHost,
remoteAddress: carrier.remoteAddress ?? null,
};
};
function asTenantId(slug: string): TenantId {
return slug as TenantId;
}
function record(
outcome: "resolved" | "advance" | "rejected",
resolverId: string
): void {
getSubdomainTenantResolverInstruments().resolves.add(1, {
resolver: "subdomain",
resolver_id: resolverId,
outcome,
});
}
/**
* Build a Stage-8 `TenantResolver` that derives the tenant from the
* inbound request's hostname (subdomain). See package README for the
* full contract; spec deltas in
* `openspec/specs/api-tenant-resolver-subdomain/spec.md`.
*/
export function createSubdomainTenantResolver(
options: SubdomainTenantResolverOptions
): TenantResolver {
const resolved = parseSubdomainTenantResolverOptions(options);
const extract = options.getTransport ?? defaultTransportExtractor;
function chooseHost(transport: RequestTransport): {
host: string | null;
throwUntrusted: boolean;
} {
const baseHost = normalizeHost(transport.host);
const fwdHost = normalizeHost(transport.forwardedHost);
if (
fwdHost !== null &&
transport.forwardedHost !== null &&
transport.forwardedHost !== undefined
) {
if (!resolved.trustForwardedHost) {
return { host: baseHost, throwUntrusted: false };
}
const trusted = isTrustedProxy(
transport.remoteAddress,
resolved.trustedProxyCidrsParsed
);
if (!trusted) {
return { host: baseHost, throwUntrusted: true };
}
return { host: fwdHost, throwUntrusted: false };
}
return { host: baseHost, throwUntrusted: false };
}
async function resolveInner(
request: Request
): Promise<TenantContext | null> {
const transport = extract(request);
const { host, throwUntrusted } = chooseHost(transport);
if (throwUntrusted) {
record("rejected", resolved.id);
if (resolved.strict) {
throw new SubdomainTenantResolverError(
TENANT_SUBDOMAIN_CODES.FORWARDED_HOST_UNTRUSTED,
"X-Forwarded-Host received from an upstream IP outside trustedProxyCidrs"
);
}
return null;
}
if (host === null) {
record("advance", resolved.id);
return null;
}
const stripped = stripBaseDomain(host, resolved.baseDomain);
if (stripped === undefined) {
record("rejected", resolved.id);
if (resolved.strict) {
throw new SubdomainTenantResolverError(
TENANT_SUBDOMAIN_CODES.BASE_DOMAIN_MISMATCH,
`Host '${host}' does not end with baseDomain '${resolved.baseDomain}'`
);
}
return null;
}
if (stripped === null) {
// host === baseDomain — no tenant labels at all
record("advance", resolved.id);
return null;
}
const labels = stripped.split(".");
const label = labels[resolved.tenantLabelIndex];
if (typeof label !== "string" || label.length === 0) {
record("rejected", resolved.id);
if (resolved.strict) {
throw new SubdomainTenantResolverError(
TENANT_SUBDOMAIN_CODES.INVALID,
`No label at index ${resolved.tenantLabelIndex} for host '${host}'`
);
}
return null;
}
if (!resolved.validate(label)) {
record("rejected", resolved.id);
if (resolved.strict) {
throw new SubdomainTenantResolverError(
TENANT_SUBDOMAIN_CODES.INVALID,
`Label '${label}' from host '${host}' failed validation`
);
}
return null;
}
if (resolved.registry !== null) {
const tenantId = await resolved.registry(label);
if (tenantId === null) {
record("rejected", resolved.id);
if (resolved.strict) {
throw new SubdomainTenantResolverError(
TENANT_SUBDOMAIN_CODES.NOT_FOUND_IN_REGISTRY,
`Slug '${label}' is not present in the tenant registry`
);
}
return null;
}
record("resolved", resolved.id);
return { id: tenantId, slug: label };
}
record("resolved", resolved.id);
return { id: asTenantId(label), slug: label };
}
return {
id: resolved.id,
resolve(request: Request): Promise<TenantContext | null> {
return resolveInner(request);
},
};
}

View File

@@ -0,0 +1,7 @@
export {
defaultLabelValidator,
type LabelValidator,
MAX_LABEL_LENGTH,
normalizeHost,
stripBaseDomain,
} from "./validate.js";

View File

@@ -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.

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
defaultLabelValidator,
MAX_LABEL_LENGTH,
normalizeHost,
stripBaseDomain,
} from "./validate.js";
describe("defaultLabelValidator", () => {
it("accepts simple ascii", { timeout: 5000 }, () => {
expect(defaultLabelValidator("acme")).toBe(true);
expect(defaultLabelValidator("a1")).toBe(true);
expect(defaultLabelValidator("acme-staging-eu")).toBe(true);
});
it("rejects empty and over-long labels", { timeout: 5000 }, () => {
expect(defaultLabelValidator("")).toBe(false);
expect(defaultLabelValidator("a".repeat(MAX_LABEL_LENGTH + 1))).toBe(
false
);
expect(defaultLabelValidator("a".repeat(MAX_LABEL_LENGTH))).toBe(true);
});
it("rejects leading or trailing hyphen", { timeout: 5000 }, () => {
expect(defaultLabelValidator("-acme")).toBe(false);
expect(defaultLabelValidator("acme-")).toBe(false);
expect(defaultLabelValidator("-")).toBe(false);
});
it("rejects upper-case and non-LDH characters", { timeout: 5000 }, () => {
expect(defaultLabelValidator("ACME")).toBe(false);
expect(defaultLabelValidator("Acme")).toBe(false);
expect(defaultLabelValidator("ac_me")).toBe(false);
expect(defaultLabelValidator("ac.me")).toBe(false);
expect(defaultLabelValidator("ac me")).toBe(false);
expect(defaultLabelValidator("ac/me")).toBe(false);
});
it("rejects non-string input", { timeout: 5000 }, () => {
expect(defaultLabelValidator(42 as unknown as string)).toBe(false);
});
});
describe("normalizeHost", () => {
it("returns null for nullish input", { timeout: 5000 }, () => {
expect(normalizeHost(null)).toBeNull();
expect(normalizeHost(undefined)).toBeNull();
expect(normalizeHost("")).toBeNull();
expect(normalizeHost(" ")).toBeNull();
});
it("rejects host that starts with colon", { timeout: 5000 }, () => {
expect(normalizeHost(":8080")).toBeNull();
});
it("lower-cases and strips port", { timeout: 5000 }, () => {
expect(normalizeHost("Acme.API.Example.com:8443")).toBe(
"acme.api.example.com"
);
expect(normalizeHost("api.example.com")).toBe("api.example.com");
});
});
describe("stripBaseDomain", () => {
it("returns null when host === baseDomain", { timeout: 5000 }, () => {
expect(
stripBaseDomain("api.example.com", "api.example.com")
).toBeNull();
});
it("returns leading-labels string for nested deployment", {
timeout: 5000,
}, () => {
expect(stripBaseDomain("acme.api.example.com", "api.example.com")).toBe(
"acme"
);
expect(
stripBaseDomain("acme.eu.api.example.com", "api.example.com")
).toBe("acme.eu");
});
it("returns undefined on base-domain mismatch", { timeout: 5000 }, () => {
expect(
stripBaseDomain("acme.evil.example.com", "api.example.com")
).toBeUndefined();
expect(stripBaseDomain("api.example.org", "api.example.com")).toBe(
undefined
);
});
});

View File

@@ -0,0 +1,68 @@
/**
* Tenant-label validation.
*
* RFC-1035-style hostname label: 1..63 ASCII characters, lower-case
* letters / digits / hyphens, may not start or end with a hyphen.
*
* The resolver runs each candidate label through {@link defaultLabelValidator}
* before treating it as a tenant slug. Callers MAY supply their own
* predicate via the `validate` option to widen or narrow the set
* (e.g. require an `acme-*` prefix, ban a list of reserved names).
*/
/** Predicate signature for `validate` in `SubdomainTenantResolverOptions`. */
export type LabelValidator = (label: string) => boolean;
/** Maximum length of a DNS label per RFC 1035 §2.3.4. */
export const MAX_LABEL_LENGTH = 63;
/**
* RFC 1035 §2.3.1 LDH-style validator. Returns `true` when the label is
* a non-empty, ≤ 63-character string of `[a-z0-9-]` with no leading or
* trailing hyphen. Upper-case is rejected — DNS is case-insensitive but
* the canonical form persisted as `tenantId` MUST be lower-case so
* downstream comparisons (RBAC, row-level filters) are stable.
*/
export const defaultLabelValidator: LabelValidator = (label) => {
if (typeof label !== "string") return false;
if (label.length === 0 || label.length > MAX_LABEL_LENGTH) return false;
if (label.startsWith("-") || label.endsWith("-")) return false;
for (let i = 0; i < label.length; i++) {
const c = label.charCodeAt(i);
const isDigit = c >= 0x30 && c <= 0x39;
const isLower = c >= 0x61 && c <= 0x7a;
const isHyphen = c === 0x2d;
if (!isDigit && !isLower && !isHyphen) return false;
}
return true;
};
/**
* Normalise the inbound host header — strip an optional `:port` suffix,
* lower-case the result. Returns `null` when the input is empty or
* starts with a port-separator (`:8080`) which is never a valid host.
*/
export function normalizeHost(raw: string | null | undefined): string | null {
if (raw === null || raw === undefined) return null;
const trimmed = raw.trim();
if (trimmed.length === 0) return null;
if (trimmed.startsWith(":")) return null;
const colonIdx = trimmed.indexOf(":");
const noPort = colonIdx === -1 ? trimmed : trimmed.slice(0, colonIdx);
return noPort.toLowerCase();
}
/**
* Strip `baseDomain` from `host`. Returns the leading-label sequence
* (e.g. `"acme.eu"`) on success, `null` when the host is exactly the
* base domain (no labels left), or `undefined` to signal a base-domain
* mismatch (caller decides whether to raise or advance the chain).
*/
export function stripBaseDomain(
host: string,
baseDomain: string
): string | null | undefined {
if (host === baseDomain) return null;
if (!host.endsWith(`.${baseDomain}`)) return undefined;
return host.slice(0, host.length - baseDomain.length - 1);
}