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,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);
}