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,88 @@
# `collision-detector` — install-time auth-path collision check
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`collision-detector` is the install-time guard that prevents a plugin from
accidentally squatting on the auth-provider's owned path prefix. It walks
every plugin route, segment-aligns its path against the provider's
`pathPrefix`, and emits `AUTH_PROVIDER.PATH_COLLISION` entries for every
overlap that is not on the plugin's `publicRoutes` allowlist or marked
`isPublic` on the per-route descriptor. Returning a report instead of
throwing keeps callers in control of how to surface aggregated failures —
typically the host turns a non-empty report into a single error listing
every offender.
## Features
- Segment-aware containment — `/auth` matches `/auth` and `/auth/login` but
NOT `/authoritative`.
- Configurable prefix — a deployment that mounts the provider under
`/myauth` shifts the collision domain accordingly.
- Per-route `isPublic` opt-out for plugin routes that augment the auth
surface (e.g. a custom `/auth/healthz`).
- Plugin-level `publicRoutes` allowlist — identical effect, declared once
per plugin manifest.
- Aggregating reporter — every offender is returned in one frozen report.
- Trailing-slash normalisation — `/auth` and `/auth/` are treated as equal.
## Usage: Basic
```ts
import { detectAuthPathCollisions } from "@suaveplan/api-auth-provider/collision-detector";
const report = detectAuthPathCollisions({ provider, plugins });
if (report.hasCollisions) {
throw new Error(`Auth-path collisions: ${report.entries.length}`);
}
```
## Usage: Advanced
Aggregating a problem-detail response across every offender:
```ts
import {
detectAuthPathCollisions,
type CollisionReportEntry,
} from "@suaveplan/api-auth-provider/collision-detector";
const report = detectAuthPathCollisions({ provider, plugins });
if (report.hasCollisions) {
const offenders = report.entries.map(
(e: CollisionReportEntry) => ({ plugin: e.details.plugin, path: e.details.path })
);
return new Response(
JSON.stringify({ type: "auth-collision", offenders }),
{ status: 409 }
);
}
```
## API Reference
- `function detectAuthPathCollisions(input: DetectCollisionsInput): CollisionReport`
- `function isInsidePrefix(routePath: string, prefix: string): boolean`
- `function normalisePrefix(prefix: string): string`
- `interface DetectCollisionsInput { provider; plugins }`
- `interface PluginDescriptor { name; routes; publicRoutes? }`
- `interface PluginRouteDescriptor { path; method?; isPublic? }`
- `interface CollisionReport { entries; hasCollisions }`
- `interface CollisionReportEntry { code; message; details }`
## Implementation Notes
The detector returns a non-throwing report so the host can decide whether a
collision is fatal or merely loggable — empirically a deployment running an
older plugin against a newer provider needs the choice. Segment-aware
matching is essential: a naïve `startsWith` would falsely flag
`/authoritative` (or worse, `/auth-cache`) when the provider owns `/auth`.
The root prefix `"/"` is short-circuited to "owns nothing" so a deployment
that mistakenly configures the auth provider at the root does not blow up
the whole route table; the path-prefix validator catches that mis-configuration
at construction time. Two opt-out paths (per-route `isPublic` and plugin
`publicRoutes`) coexist because manifests historically use one or the other
and supporting both keeps migration tractable.

View File

@@ -0,0 +1,173 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUTH_PROVIDER_CODES } from "../error-codes/error-codes.js";
import type { AuthProviderAdapter } from "../provider/provider.types.js";
import {
detectAuthPathCollisions,
isInsidePrefix,
normalisePrefix,
} from "./collision-detector.js";
const makeProvider = (pathPrefix = "/auth"): AuthProviderAdapter => ({
id: "better-auth",
pathPrefix,
routes: () => {},
lifecycle: {
install: async () => {},
start: async () => {},
stop: async () => {},
uninstall: async () => {},
},
});
describe("normalisePrefix", () => {
it("strips a trailing slash", { timeout: 5000 }, () => {
expect(normalisePrefix("/auth/")).toBe("/auth");
});
it("preserves the bare root", { timeout: 5000 }, () => {
expect(normalisePrefix("/")).toBe("/");
});
it("returns short prefixes unchanged", { timeout: 5000 }, () => {
expect(normalisePrefix("/auth")).toBe("/auth");
});
});
describe("isInsidePrefix", () => {
it("matches exact prefix", { timeout: 5000 }, () => {
expect(isInsidePrefix("/auth", "/auth")).toBe(true);
});
it("matches descendant segments", { timeout: 5000 }, () => {
expect(isInsidePrefix("/auth/login", "/auth")).toBe(true);
expect(isInsidePrefix("/auth/oauth/google/start", "/auth")).toBe(true);
});
it("rejects neighbouring prefix-string matches without segment boundary", {
timeout: 5000,
}, () => {
expect(isInsidePrefix("/authoritative", "/auth")).toBe(false);
});
it("returns false for the root prefix '/'", { timeout: 5000 }, () => {
expect(isInsidePrefix("/billing/x", "/")).toBe(false);
});
});
describe("detectAuthPathCollisions", () => {
it("reports a plugin route nested in the auth prefix", {
timeout: 5000,
}, () => {
const report = detectAuthPathCollisions({
provider: makeProvider(),
plugins: [
{
name: "plugin-x",
routes: [{ path: "/auth/custom" }],
},
],
});
expect(report.hasCollisions).toBe(true);
expect(report.entries.length).toBe(1);
const entry = report.entries[0];
expect(entry?.code).toBe(AUTH_PROVIDER_CODES.PATH_COLLISION);
expect(entry?.details.plugin).toBe("plugin-x");
expect(entry?.details.path).toBe("/auth/custom");
expect(entry?.details.prefix).toBe("/auth");
});
it("does not report routes outside the auth prefix", {
timeout: 5000,
}, () => {
const report = detectAuthPathCollisions({
provider: makeProvider(),
plugins: [
{
name: "plugin-x",
routes: [{ path: "/billing/invoices" }],
},
],
});
expect(report.hasCollisions).toBe(false);
expect(report.entries.length).toBe(0);
});
it("configurable prefix shifts collision domain", { timeout: 5000 }, () => {
const myProvider = makeProvider("/myauth");
const r1 = detectAuthPathCollisions({
provider: myProvider,
plugins: [{ name: "p", routes: [{ path: "/auth/x" }] }],
});
expect(r1.hasCollisions).toBe(false);
const r2 = detectAuthPathCollisions({
provider: myProvider,
plugins: [{ name: "p", routes: [{ path: "/myauth/x" }] }],
});
expect(r2.hasCollisions).toBe(true);
});
it("respects per-route isPublic opt-out", { timeout: 5000 }, () => {
const report = detectAuthPathCollisions({
provider: makeProvider(),
plugins: [
{
name: "p",
routes: [{ path: "/auth/healthz", isPublic: true }],
},
],
});
expect(report.hasCollisions).toBe(false);
});
it("publicRoutes allowlist does not silence unrelated paths", {
timeout: 5000,
}, () => {
const report = detectAuthPathCollisions({
provider: makeProvider(),
plugins: [
{
name: "p",
routes: [{ path: "/auth/other" }],
publicRoutes: ["/auth/something-else"],
},
],
});
expect(report.hasCollisions).toBe(true);
});
it("respects plugin-level publicRoutes allowlist", {
timeout: 5000,
}, () => {
const report = detectAuthPathCollisions({
provider: makeProvider(),
plugins: [
{
name: "p",
routes: [{ path: "/auth/legacy" }],
publicRoutes: ["/auth/legacy"],
},
],
});
expect(report.hasCollisions).toBe(false);
});
it("aggregates collisions across plugins", { timeout: 5000 }, () => {
const report = detectAuthPathCollisions({
provider: makeProvider(),
plugins: [
{ name: "a", routes: [{ path: "/auth/a" }] },
{ name: "b", routes: [{ path: "/auth/b" }, { path: "/safe" }] },
],
});
expect(report.entries.length).toBe(2);
expect(
report.entries.map((e) => `${e.details.plugin}:${e.details.path}`)
).toEqual(["a:/auth/a", "b:/auth/b"]);
});
it("freezes the returned report", { timeout: 5000 }, () => {
const report = detectAuthPathCollisions({
provider: makeProvider(),
plugins: [],
});
expect(Object.isFrozen(report)).toBe(true);
expect(Object.isFrozen(report.entries)).toBe(true);
});
it("trailing-slash provider prefix still detects collisions", {
timeout: 5000,
}, () => {
const report = detectAuthPathCollisions({
provider: makeProvider("/auth/"),
plugins: [{ name: "p", routes: [{ path: "/auth/x" }] }],
});
expect(report.hasCollisions).toBe(true);
});
});

View File

@@ -0,0 +1,97 @@
/**
* Install-time auth-path collision detector.
*
* For each plugin route, if its path is "inside" the provider's `pathPrefix`
* AND it is not on the plugin's `publicRoutes` allowlist AND the route entry
* itself is not explicitly marked `isPublic`, the detector emits an
* `AUTH_PROVIDER.PATH_COLLISION` entry. Plugin routes outside the prefix are
* never reported.
*
* "Inside" follows segment-aware semantics — `/auth` covers `/auth`,
* `/auth/login`, and `/auth/oauth/x/start`, but NOT `/authoritative` (segment
* boundary required). The root prefix `/` is treated as "owns nothing" so a
* `/` provider does not collide with arbitrary plugin routes.
*/
import { AUTH_PROVIDER_CODES } from "../error-codes/error-codes.js";
import type {
CollisionReport,
CollisionReportEntry,
DetectCollisionsInput,
PluginDescriptor,
} from "./collision-detector.types.js";
/** Normalise a prefix — strip trailing slash so `/auth` ≡ `/auth/`. */
export function normalisePrefix(prefix: string): string {
if (prefix.length > 1 && prefix.endsWith("/")) {
return prefix.slice(0, -1);
}
return prefix;
}
/**
* Segment-aware containment check. `prefix === "/auth"` matches `/auth` and
* `/auth/anything` but not `/authoritative`. The root prefix `/` matches
* nothing (it would otherwise own the entire surface).
*/
export function isInsidePrefix(routePath: string, prefix: string): boolean {
const norm = normalisePrefix(prefix);
if (norm === "/") {
return false;
}
if (routePath === norm) {
return true;
}
return routePath.startsWith(`${norm}/`);
}
/** True when the supplied path is in the plugin's `publicRoutes` allowlist. */
function isOnAllowlist(plugin: PluginDescriptor, routePath: string): boolean {
if (plugin.publicRoutes === undefined) {
return false;
}
for (const allowed of plugin.publicRoutes) {
if (allowed === routePath) {
return true;
}
}
return false;
}
/**
* Detect collisions between an installed provider and a candidate plugin set.
* Returns a report — never throws. Callers decide whether to throw based on
* `report.hasCollisions`.
*/
export function detectAuthPathCollisions(
input: DetectCollisionsInput
): CollisionReport {
const prefix = normalisePrefix(input.provider.pathPrefix);
const entries: CollisionReportEntry[] = [];
for (const plugin of input.plugins) {
for (const route of plugin.routes) {
if (!isInsidePrefix(route.path, prefix)) {
continue;
}
if (route.isPublic === true) {
continue;
}
if (isOnAllowlist(plugin, route.path)) {
continue;
}
entries.push({
code: AUTH_PROVIDER_CODES.PATH_COLLISION,
message: `Plugin "${plugin.name}" declares route "${route.path}" inside the auth prefix "${prefix}" owned by provider "${input.provider.id}".`,
details: {
plugin: plugin.name,
path: route.path,
prefix,
},
});
}
}
return Object.freeze({
entries: Object.freeze(entries),
hasCollisions: entries.length > 0,
});
}

View File

@@ -0,0 +1,64 @@
/**
* Type shapes for the install-time collision detector. Input descriptors are
* deliberately structural so the host can pass either Zod-parsed manifest
* fragments or hand-rolled fixtures.
*/
import type { HttpMethod } from "@suaveplan/api-types/router";
import type { AuthProviderErrorCode } from "../error-codes/error-codes.js";
import type { AuthProviderAdapter } from "../provider/provider.types.js";
/** Minimal shape of a plugin's declared route — what the detector needs. */
export interface PluginRouteDescriptor {
/** Mounted path (e.g. `/billing/invoices` or `/auth/custom`). Must begin with `/`. */
readonly path: string;
/** HTTP method — informational; the detector reports per-route regardless. */
readonly method?: HttpMethod;
/**
* Optional opt-in: a route declared on `publicRoutes` is allowed inside
* the auth prefix (e.g. a plugin that augments `/auth/healthz`). Empty
* by default — strict mode.
*/
readonly isPublic?: boolean;
}
/** Minimal shape of a plugin participating in the collision check. */
export interface PluginDescriptor {
/** Stable plugin identifier (e.g. `"api-plugin-billing"`). */
readonly name: string;
/** Routes the plugin declares in its manifest. */
readonly routes: ReadonlyArray<PluginRouteDescriptor>;
/**
* Paths under the auth prefix the plugin is explicitly allowed to keep —
* mirrors `publicRoutes` in the per-route descriptor for plugins that
* declare an allowlist at the plugin level.
*/
readonly publicRoutes?: ReadonlyArray<string>;
}
/** One detected collision. */
export interface CollisionReportEntry {
/** `AUTH_PROVIDER.PATH_COLLISION`. */
readonly code: AuthProviderErrorCode;
/** Free-form human-readable message. */
readonly message: string;
/** Structured fields: `plugin`, `path`, `prefix`. */
readonly details: {
readonly plugin: string;
readonly path: string;
readonly prefix: string;
};
}
/** Full collision report — empty `entries` means no collisions. */
export interface CollisionReport {
readonly entries: ReadonlyArray<CollisionReportEntry>;
/** Convenience boolean — `entries.length > 0`. */
readonly hasCollisions: boolean;
}
/** Input to {@link detectAuthPathCollisions}. */
export interface DetectCollisionsInput {
readonly provider: AuthProviderAdapter;
readonly plugins: ReadonlyArray<PluginDescriptor>;
}

View File

@@ -0,0 +1,12 @@
export {
detectAuthPathCollisions,
isInsidePrefix,
normalisePrefix,
} from "./collision-detector.js";
export type {
CollisionReport,
CollisionReportEntry,
DetectCollisionsInput,
PluginDescriptor,
PluginRouteDescriptor,
} from "./collision-detector.types.js";