Initial Commit
This commit is contained in:
@@ -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