Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
detectAuthPathCollisions,
|
||||
isInsidePrefix,
|
||||
normalisePrefix,
|
||||
} from "./collision-detector.js";
|
||||
export type {
|
||||
CollisionReport,
|
||||
CollisionReportEntry,
|
||||
DetectCollisionsInput,
|
||||
PluginDescriptor,
|
||||
PluginRouteDescriptor,
|
||||
} from "./collision-detector.types.js";
|
||||
Reference in New Issue
Block a user