Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .changeset/auth-route-ledger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
"@objectstack/plugin-auth": patch
"@objectstack/client": patch
---

test(plugin-auth): enumerate better-auth's route table — the `/auth/**` wildcard becomes 55 exact rows (#3656)

The widest hole the #3642 capstone measured. That guard reports how many SDK
calls match only a `**` prefix family rather than a resolvable route, and the
answer was 60 of ~196 — with 54 on `* /auth/**`, the largest and most
security-relevant namespace in the client. `auth.me` builds
`/api/v1/auth/get-session`; a prefix claim cannot tell you better-auth still
calls it that, and better-auth is a third-party dependency on its own release
cadence (this repo already chased its 1.7 column drift in #3624 / #3647).

`plugin-auth` mounts it with a single catch-all, so there are no per-route
registration calls to capture the way tranche 3 captured
`registerStorageRoutes`. The seam is `auth.api`: every better-auth endpoint
carries `.path` and `.options.method`, so a live instance is the route table.

`auth-route-ledger.ts` reads it, in two halves checked differently on purpose:

- **55 reviewed rows** — every route the SDK calls, each naming its client
method, checked strictly against the live table. This is the rename detector.
- **129-path mounted-surface inventory** — checked for exact equality both
ways, so a version bump that adds publicly-mounted auth endpoints becomes a
reviewable CI diff. Machine-maintained rather than reviewed prose: demanding
a rationale for all 129 would make every better-auth upgrade a hundred-row
review and the ledger would rot into rubber-stamping.

Enumeration is config-dependent, so the inventory is pinned at the
configuration enabling every plugin the SDK targets — the maximal surface —
with the participating `OS_*` env vars cleared so a developer's shell cannot
produce a spurious diff. Mutation-checked: renaming a ledgered route fails the
suite naming it.

The capstone guard now includes this ledger in its union and prefers exact rows
over wildcard families when matching — without that ordering fix every
`/auth/*` URL would still have been absorbed by `* /auth/**` and the new ledger
would have changed nothing. Wildcard-only matches fall **60 → 3**; the ratchet
moves with them. What remains is `* /ai/**`, whose routes `service-ai` builds
at plugin start.

No runtime change: a ledger, a guard, and the header/audit-doc notes.
41 changes: 38 additions & 3 deletions docs/audits/2026-07-dispatcher-client-route-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,10 +232,44 @@ rots silently:
from both ends: a non-`projects` method reaching `/cloud/` fails. Tracked as
#3655.
- **Dynamic families.** A `**` row claims a prefix, not a resolvable route.
**60 of ~196 matched calls (~31%) rest on nothing stronger** — 54 of them on
**60 of ~196 matched calls (~31%) rested on nothing stronger** — 54 of them on
`* /auth/**`, where the routes come from a third-party dependency on its own
release cadence. The guard counts and ratchets this, so it can only shrink.
Tracked as #3656.
Closed to **3** by #3656, below.

## 11. The auth surface, enumerated (#3656)

The widest hole §10 measured. `plugin-auth` mounts better-auth with one
catch-all — `rawApp.all(`${basePath}/*`)` — so there are no per-route
registration calls to capture the way tranche 3 captured
`registerStorageRoutes`. The seam is **`auth.api`**: every better-auth endpoint
object carries `.path` and `.options.method`, so a live instance *is* the route
table. `packages/plugins/plugin-auth/src/auth-route-ledger.ts` reads it.

Two halves, checked differently on purpose:

| Half | Check | Catches |
|---|---|---|
| `AUTH_ROUTE_LEDGER` — 55 reviewed rows, every route the SDK calls, each naming its client method | **strict**: each must exist in the live table | an upstream **rename**. `auth.me` targets `/get-session`; if better-auth renames it, 26 `auth.*` methods 404 and nothing noticed before |
| `BETTER_AUTH_MOUNTED_SURFACE` — 129 wire paths, the whole inventory | **exact equality**, both directions | a version bump silently **adding** publicly-mounted auth endpoints |

The asymmetry is deliberate: demanding a hand-written rationale for all 129
would turn every better-auth upgrade into a hundred-row review, and the ledger
would rot into rubber-stamping. But the catch-all publishes whatever upstream
adds, so growth still has to be a reviewable CI diff — hence a
machine-maintained inventory rather than reviewed prose.

Enumeration is config-dependent (better-auth plugins are opt-in), so the
inventory is pinned at the configuration enabling every plugin the SDK targets
— the maximal surface — with the participating `OS_*` env vars cleared so a
developer's shell cannot produce a spurious diff. Mutation-checked: renaming a
ledgered route fails the suite naming it.

Effect on §10's guard: the wildcard-only count **fell 60 → 3** (only `* /ai/**`
remains, whose routes `service-ai buildAIRoutes()` builds at plugin start). The
capstone also had to prefer exact rows over wildcard families when matching —
otherwise every `/auth/*` URL would still have been absorbed by `* /auth/**`
and the new ledger would have changed nothing.

## Follow-up slicing (proposed)

Expand All @@ -250,7 +284,8 @@ rots silently:
9. **Autonomous service mounts** (§9) — done in #3636.
10. **Cross-surface URL conformance** (§10, the reverse direction) — done in #3642.
11. **Control-plane surface** (§10) — #3655, needs a ledger in the `cloud` repo.
12. **Enumerate `/auth/**`** (§10) — #3656, lowers the wildcard ratchet.
12. **Enumerate `/auth/**`** (§11) — done in #3656; wildcard ratchet 60 → 3.
13. **Enumerate `/ai/**`** — the last dynamic family, 3 SDK methods.

Each gap closed must flip its ledger row to `sdk` and lower the ratchet bound
in the conformance test — the guard enforces both directions from PR-1 onward.
30 changes: 21 additions & 9 deletions packages/client/src/client-url-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
/**
* Client-URL conformance — the capstone guard (#3642).
*
* WHAT THE OTHER FOUR GUARDS DO NOT ASK. The dispatcher (#3563), REST (#3587)
* WHAT THE SERVER-SIDE GUARDS DO NOT ASK. The dispatcher (#3563), REST (#3587)
* and service-mount (#3636) ledgers all run server → client: enumerate what a
* surface mounts, demand a reviewed disposition, and for `sdk` rows demand the
* named client method exists. *The method exists* is not *the method can be
Expand All @@ -21,8 +21,9 @@
*
* This suite closes the direction. It drives every method on a real client
* with a recording `fetch`, then matches each captured URL against the UNION
* of all four ledgers. A union, not an intersection: a route mounted by only
* one surface is still legitimately reachable.
* of all five ledgers — dispatcher, REST, storage, i18n, auth. A union, not an
* intersection: a route mounted by only one surface is still legitimately
* reachable.
*
* WHY A REAL DRIVE, NOT A DECLARATION. Asserting "method X targets route Y" in
* a table would be an assertion *about* the code that the code can drift away
Expand All @@ -48,6 +49,7 @@ import { ROUTE_LEDGER } from '../../runtime/src/route-ledger';
import { REST_ROUTE_LEDGER } from '../../rest/src/rest-route-ledger';
import { STORAGE_ROUTE_LEDGER } from '../../services/service-storage/src/storage-route-ledger';
import { I18N_ROUTE_LEDGER } from '../../services/service-i18n/src/i18n-route-ledger';
import { AUTH_ROUTE_LEDGER } from '../../plugins/plugin-auth/src/auth-route-ledger';

const BASE = 'http://localhost:9';

Expand Down Expand Up @@ -106,7 +108,14 @@ const PATTERNS: Pattern[] = [
...REST_ROUTE_LEDGER.map((r) => r.route).flatMap((r) => compile(r, '', 'rest')),
...STORAGE_ROUTE_LEDGER.map((r) => r.route).flatMap((r) => compile(r, '', 'storage')),
...I18N_ROUTE_LEDGER.map((r) => r.route).flatMap((r) => compile(r, '', 'i18n')),
];
...AUTH_ROUTE_LEDGER.map((r) => r.route).flatMap((r) => compile(r, '', 'auth')),
]
// Exact rows before wildcard families, so a URL that a real route covers is
// never CREDITED to a `**` prefix claim that happens to sit earlier in the
// list. Without this, adding the auth ledger would change nothing: every
// `/api/v1/auth/*` URL would still be absorbed by the dispatcher's
// `* /auth/**` row and keep counting as weak evidence.
.sort((a, b) => Number(a.route.includes('**')) - Number(b.route.includes('**')));

function matches(verb: string, path: string): Pattern | undefined {
return PATTERNS.find((p) => (p.verb === '*' || p.verb === verb) && p.re.test(path));
Expand Down Expand Up @@ -360,14 +369,17 @@ describe('client URL conformance ↔ the union of all four route ledgers (#3642)
expect(controlPlane.length, 'the projects namespace should still be reaching the control plane').toBeGreaterThan(0);

// HOW STRONG IS THIS GUARD, HONESTLY. A `**` row asserts only that a prefix
// family is claimed, not that the specific URL resolves — `/auth/**` alone
// covers 26 SDK methods. Those matches are real but weak, so the count is
// ratcheted: enumerating a dynamic family (or dropping one) may lower it,
// and nothing may raise it without a deliberate decision.
// family is CLAIMED, not that the specific URL resolves. That was this
// guard's biggest weakness at #3642: 60 of ~196 matched calls rested on
// nothing better, 54 of them on `* /auth/**`. #3656 enumerated better-auth's
// real route table, so those now match exact rows and the bound fell 60 → 3.
// What remains is `* /ai/**`, whose routes service-ai builds at plugin start.
// Ratcheted: enumerating that family lowers it; nothing raises it without a
// deliberate decision.
expect(
wildcardOnly.length,
'methods matched only by a wildcard `**` family — weaker evidence than an exact ' +
`route. Enumerate a dynamic family to lower this bound; do not raise it:\n${wildcardOnly.join('\n')}`,
).toBeLessThanOrEqual(60);
).toBeLessThanOrEqual(3);
});
});
179 changes: 179 additions & 0 deletions packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Auth route-ledger conformance (#3656) — the guard over the one surface the
* #3642 capstone could only cover with a wildcard.
*
* The route table here belongs to better-auth, and the plugin mounts it with a
* single catch-all, so there are no registration calls to capture the way
* tranche 3 captured `registerStorageRoutes`. The enumeration seam is
* `auth.api`: every better-auth endpoint carries `.path` and `.options.method`,
* so a live instance IS the route table. Still a real enumeration — nothing
* here is a list someone maintains by hand and hopes is current.
*
* Two directions, checked differently on purpose (see the ledger header):
*
* 1. STRICT — every reviewed `better-auth` row must exist upstream. This is
* the rename detector: `auth.me` targets `/get-session`, and if better-auth
* renames it, 26 `auth.*` SDK methods start 404-ing. Today nothing notices.
* 2. EXACT — the mounted-surface inventory must equal the live enumeration in
* both directions. The catch-all publishes whatever upstream adds, so a
* version bump that introduces endpoints has to surface as a reviewable
* diff rather than as silently-exposed auth surface.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { AuthManager } from './auth-manager';
import { AUTH_ROUTE_LEDGER, BETTER_AUTH_MOUNTED_SURFACE } from './auth-route-ledger';

const BASE_PATH = '/api/v1/auth';

/**
* The configuration the inventory is pinned at: every better-auth plugin the
* SDK targets, turned on explicitly. The maximal surface, so no route the SDK
* can reach is missing from the enumeration.
*/
const LEDGERED_PLUGIN_CONFIG = {
organization: true,
twoFactor: true,
admin: true,
oidcProvider: true,
passkeys: true,
magicLink: true,
deviceAuthorization: true,
};

/**
* `buildPluginList` reads env vars that can flip plugins on top of the config
* above (`OS_SSO_ENABLED`, `OS_SCIM_ENABLED`, …). A developer with any of them
* exported would enumerate a different surface and see a spurious diff, so they
* are cleared for the duration of the suite.
*/
const ENV_KEYS = [
'OS_SSO_ENABLED', 'OS_SSO_DOMAIN_VERIFICATION', 'OS_SCIM_ENABLED',
'OS_AUTH_TWO_FACTOR', 'OS_AUTH_PASSWORD_REJECT_BREACHED',
'OS_OIDC_PROVIDER_ENABLED', 'OS_MCP_SERVER_ENABLED',
];
const savedEnv: Record<string, string | undefined> = {};

let live: Set<string>;

beforeAll(async () => {
for (const k of ENV_KEYS) { savedEnv[k] = process.env[k]; delete process.env[k]; }

const manager = new AuthManager({
secret: 'route-ledger-conformance-secret-32-chars',
baseUrl: 'http://localhost:9',
plugins: LEDGERED_PLUGIN_CONFIG,
} as never);
// No dataEngine: createDatabaseConfig() falls back to better-auth's in-memory
// adapter. Route registration does not touch the database — only request
// handling would — so the table enumerates identically.
const auth = (await manager.getAuthInstance()) as unknown as {
api: Record<string, { path?: string; options?: { method?: string | string[] } }>;
};

live = new Set<string>();
for (const endpoint of Object.values(auth.api ?? {})) {
if (typeof endpoint?.path !== 'string') continue;
// The OIDC/OAuth discovery documents are mounted at the app ROOT by
// auth-plugin.ts (RFC 8414 and OpenID Discovery both require well-known to
// sit on the origin), not under the auth base path.
const wire = endpoint.path.startsWith('/.well-known/')
? endpoint.path
: `${BASE_PATH}${endpoint.path}`;
const method = endpoint.options?.method;
for (const verb of Array.isArray(method) ? method : [method ?? 'POST']) {
live.add(`${verb} ${wire}`);
}
}
}, 60_000);

afterAll(() => {
for (const k of ENV_KEYS) {
if (savedEnv[k] === undefined) delete process.env[k];
else process.env[k] = savedEnv[k];
}
});

describe('auth route ledger ↔ the live better-auth route table', () => {
it('enumeration produced a real table (guard the guard)', () => {
// An empty or tiny `auth.api` — a construction failure, a mocked module —
// would make every assertion below vacuously pass.
expect(live.size).toBeGreaterThan(100);
});

it('every reviewed better-auth row still exists upstream', () => {
const missing = AUTH_ROUTE_LEDGER
.filter((e) => e.source === 'better-auth')
.map((e) => e.route)
.filter((r) => !live.has(r));
expect(
missing,
'ledgered auth routes better-auth no longer serves — the SDK methods naming them are ' +
`now wire-level 404s:\n${missing.join('\n')}`,
).toEqual([]);
});

it('the mounted-surface inventory matches what the catch-all actually publishes', () => {
const pinned = new Set(BETTER_AUTH_MOUNTED_SURFACE);
const added = [...live].filter((r) => !pinned.has(r)).sort();
const removed = [...pinned].filter((r) => !live.has(r)).sort();

expect(
added,
'better-auth now publishes auth endpoints the ledger does not list. These are mounted ' +
'PUBLICLY by the catch-all, so review them before pinning — then add them to ' +
`BETTER_AUTH_MOUNTED_SURFACE:\n${added.join('\n')}`,
).toEqual([]);

expect(
removed,
'pinned auth endpoints better-auth no longer publishes — anything depending on them ' +
`is now a 404:\n${removed.join('\n')}`,
).toEqual([]);
});
});

describe('auth route ledger hygiene', () => {
it('every `sdk` entry names its client method; every non-sdk entry carries a rationale', () => {
const sdkWithout = AUTH_ROUTE_LEDGER.filter((e) => e.disposition === 'sdk' && !e.client).map((e) => e.route);
expect(sdkWithout, 'sdk-disposition entries missing a client method name').toEqual([]);

const bareNonSdk = AUTH_ROUTE_LEDGER.filter((e) => e.disposition !== 'sdk' && !e.note).map((e) => e.route);
expect(bareNonSdk, 'non-sdk entries must say WHY they are not SDK surface').toEqual([]);
});

it('no route is ledgered twice, and every row is under the auth base path', () => {
const seen = new Set<string>();
const dupes = AUTH_ROUTE_LEDGER.map((e) => e.route).filter((r) => !seen.add(r));
expect(dupes, `duplicate auth-route-ledger rows: ${dupes.join(', ')}`).toEqual([]);

const stray = AUTH_ROUTE_LEDGER
.filter((e) => !e.route.includes(` ${BASE_PATH}/`))
.map((e) => e.route);
expect(stray, `rows outside ${BASE_PATH}: ${stray.join(', ')}`).toEqual([]);
});

it('the two objectstack-mounted rows are the ones auth-plugin.ts serves itself', () => {
// /config and /bootstrap-status are mounted on the raw app AHEAD of the
// catch-all, so they are absent from `auth.api` by construction. Pinned so
// the `source` split stays honest rather than becoming a place to park a
// row that failed the upstream check.
const own = AUTH_ROUTE_LEDGER.filter((e) => e.source === 'objectstack').map((e) => e.route).sort();
expect(own).toEqual([
'GET /api/v1/auth/bootstrap-status',
'GET /api/v1/auth/config',
]);
for (const route of own) {
expect(live.has(route), `${route} should NOT come from better-auth`).toBe(false);
}
});

it('gap and mismatch counts only shrink', () => {
// The surface audited clean at #3656: every SDK-reachable auth route
// resolves against the live better-auth table.
expect(AUTH_ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length).toBeLessThanOrEqual(0);
expect(AUTH_ROUTE_LEDGER.filter((e) => e.disposition === 'mismatch').length).toBeLessThanOrEqual(0);
});
});
Loading
Loading