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