4.8 KiB
reserved-prefix
(no submodules)
Purpose
The reserved-prefix contract publishes the canonical list of URL path prefixes that @suaveplan/api-host (and every first-party engine that ships under the host) considers reserved. Without it, downstream adapters such as @suaveplan/api-auth-better-auth, @suaveplan/api-plugin-rbac, or any user-authored plugin would hard-code a private snapshot of host-internal routes and silently drift the moment a new host module adds an endpoint.
This module is the single source of truth: it exposes the frozen HOST_RESERVED_PREFIXES array, the isReservedPrefix predicate, the normalisePrefix canonicalisation helper, and the assertPrefixAvailable assertion that throws a consumer-supplied error on collision. Adapters consult it before mounting their own routes and refuse to install — surfacing their own code namespace (AUTH_BA_ROUTE_PREFIX_COLLISION, RBAC_ROUTE_PREFIX_COLLISION, …) — when an operator-configured mount path collides with a host-reserved route.
Features
- Frozen
ReadonlyArray<string>of always-reserved prefixes (/_host,/internal). - Case-insensitive, trailing-slash-tolerant, leading-slash-tolerant prefix matching.
- Exact-match AND child-path detection (
/internal/livezis also reserved). assertPrefixAvailable(prefix, factory)throws the factory-supplied error lazily — the factory is only invoked on collision so adapters can defer expensive context construction to the failure path.- Branded
ReservedPrefixtype tagging values that have been canonicalised throughnormalisePrefix, so type-aware pipelines can prove their input is in the canonical form without re-normalising at every hop. - Published as a
./reserved-prefixsubpath export — adapters import only the predicate surface without pulling in the full host bundle.
Usage: Basic
import {
HOST_RESERVED_PREFIXES,
isReservedPrefix,
} from "@suaveplan/api-host/reserved-prefix";
if (isReservedPrefix("/internal/foo")) {
throw new Error("cannot mount under a host-reserved prefix");
}
console.log(HOST_RESERVED_PREFIXES); // ["/_host", "/internal"]
Usage: Advanced
Adapter-side, the typical wiring uses assertPrefixAvailable with the adapter's own error code namespace:
import { assertPrefixAvailable } from "@suaveplan/api-host/reserved-prefix";
import { SuaveplanError } from "@suaveplan/error";
import { AUTH_BA_CODES } from "@suaveplan/api-auth-better-auth/codes";
export function mountBetterAuth(prefix: string): void {
assertPrefixAvailable(prefix, (collision) =>
new SuaveplanError(AUTH_BA_CODES.ROUTE_PREFIX_COLLISION, {
messageKey: "error.auth_ba.route_prefix_collision",
context: { requested: prefix, collision },
})
);
// …safe to register routes under `prefix` now.
}
The normalisePrefix helper is also exported so adapters that store an operator-supplied mount prefix at configuration time can canonicalise it once and rely on the form for subsequent comparisons.
API Reference
HOST_RESERVED_PREFIXES: ReadonlyArray<string>— frozen, deduped, normalised list of always-reserved prefixes.isReservedPrefix(prefix: string): boolean—truewhenprefix(after normalisation) equals or is a child path of any reserved entry.normalisePrefix(prefix: string): string— lowercases, prepends/, collapses trailing slashes; empty input →/.assertPrefixAvailable(prefix, codeFactory)— invokescodeFactory(normalisedCollision)and throws when reserved; no-op otherwise.brandPrefix(prefix: string): ReservedPrefix— normalises and returns the value retyped as theReservedPrefixbrand.type ReservedPrefix— branded string carrying the proof of canonicalisation.type ReservedPrefixErrorFactory—(collision: string) => Error.
Implementation Notes
The list is initialised by feeding ["/_host", "/internal"] through normalisePrefix, then deduping via Set, then Object.freeze so the returned array refuses mutation at runtime (strict mode throws TypeError). Predicate matching is O(n) over the (currently 2-entry) reserved list; for child-path matching the implementation uses startsWith(${reserved}/) rather than startsWith(reserved) to avoid the /internal reserves /internals false positive.
Engine-conditional entries (/admin, /graphql, /ws, /sse) are intentionally NOT in the static list. They will land via a follow-up api-host-reserved-prefix-dynamic capability that inspects the active host registry — keeping the static contract immutable and runtime-deterministic. The branded ReservedPrefix type uses a unique symbol field; the brand never materialises at runtime, so JSON.stringify of a branded value is identical to its underlying string.