Skip to content

Commit 984396b

Browse files
authored
test(plugin-auth): enumerate better-auth's route table — the /auth/** wildcard becomes 55 exact rows (#3656) (#3669)
The widest hole the #3642 capstone measured: 60 of ~196 matched SDK calls rested only on a `**` prefix claim, 54 of them on `* /auth/**` — the largest and most security-relevant namespace in the client. `auth.me` builds /api/v1/auth/get-session, and a prefix claim cannot tell you better-auth still calls it that; better-auth is a third-party dependency on its own release cadence (cf. the 1.7 column drift chased in #3624 / #3647). plugin-auth mounts it with one catch-all, so there are no per-route registration calls to capture. 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) verified strictly as a rename detector, plus a 129-path mounted-surface inventory checked for exact equality so a version bump that adds publicly mounted auth endpoints becomes a reviewable diff rather than silent surface. Enumeration is config-dependent, so the inventory is pinned at the configuration enabling every plugin the SDK targets, with the participating OS_* env vars cleared. Mutation-checked: renaming a ledgered route fails the suite. The capstone guard now includes this ledger and prefers exact rows over wildcard families when matching — without that ordering fix every /auth/* URL would still be absorbed by `* /auth/**`. Wildcard-only matches fall 60 → 3. No runtime change. Closes #3656.
1 parent b04b458 commit 984396b

6 files changed

Lines changed: 571 additions & 16 deletions

File tree

.changeset/auth-route-ledger.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
"@objectstack/client": patch
4+
---
5+
6+
test(plugin-auth): enumerate better-auth's route table — the `/auth/**` wildcard becomes 55 exact rows (#3656)
7+
8+
The widest hole the #3642 capstone measured. That guard reports how many SDK
9+
calls match only a `**` prefix family rather than a resolvable route, and the
10+
answer was 60 of ~196 — with 54 on `* /auth/**`, the largest and most
11+
security-relevant namespace in the client. `auth.me` builds
12+
`/api/v1/auth/get-session`; a prefix claim cannot tell you better-auth still
13+
calls it that, and better-auth is a third-party dependency on its own release
14+
cadence (this repo already chased its 1.7 column drift in #3624 / #3647).
15+
16+
`plugin-auth` mounts it with a single catch-all, so there are no per-route
17+
registration calls to capture the way tranche 3 captured
18+
`registerStorageRoutes`. The seam is `auth.api`: every better-auth endpoint
19+
carries `.path` and `.options.method`, so a live instance is the route table.
20+
21+
`auth-route-ledger.ts` reads it, in two halves checked differently on purpose:
22+
23+
- **55 reviewed rows** — every route the SDK calls, each naming its client
24+
method, checked strictly against the live table. This is the rename detector.
25+
- **129-path mounted-surface inventory** — checked for exact equality both
26+
ways, so a version bump that adds publicly-mounted auth endpoints becomes a
27+
reviewable CI diff. Machine-maintained rather than reviewed prose: demanding
28+
a rationale for all 129 would make every better-auth upgrade a hundred-row
29+
review and the ledger would rot into rubber-stamping.
30+
31+
Enumeration is config-dependent, so the inventory is pinned at the
32+
configuration enabling every plugin the SDK targets — the maximal surface —
33+
with the participating `OS_*` env vars cleared so a developer's shell cannot
34+
produce a spurious diff. Mutation-checked: renaming a ledgered route fails the
35+
suite naming it.
36+
37+
The capstone guard now includes this ledger in its union and prefers exact rows
38+
over wildcard families when matching — without that ordering fix every
39+
`/auth/*` URL would still have been absorbed by `* /auth/**` and the new ledger
40+
would have changed nothing. Wildcard-only matches fall **60 → 3**; the ratchet
41+
moves with them. What remains is `* /ai/**`, whose routes `service-ai` builds
42+
at plugin start.
43+
44+
No runtime change: a ledger, a guard, and the header/audit-doc notes.

docs/audits/2026-07-dispatcher-client-route-coverage.md

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,10 +232,44 @@ rots silently:
232232
from both ends: a non-`projects` method reaching `/cloud/` fails. Tracked as
233233
#3655.
234234
- **Dynamic families.** A `**` row claims a prefix, not a resolvable route.
235-
**60 of ~196 matched calls (~31%) rest on nothing stronger** — 54 of them on
235+
**60 of ~196 matched calls (~31%) rested on nothing stronger** — 54 of them on
236236
`* /auth/**`, where the routes come from a third-party dependency on its own
237237
release cadence. The guard counts and ratchets this, so it can only shrink.
238-
Tracked as #3656.
238+
Closed to **3** by #3656, below.
239+
240+
## 11. The auth surface, enumerated (#3656)
241+
242+
The widest hole §10 measured. `plugin-auth` mounts better-auth with one
243+
catch-all — `rawApp.all(`${basePath}/*`)` — so there are no per-route
244+
registration calls to capture the way tranche 3 captured
245+
`registerStorageRoutes`. The seam is **`auth.api`**: every better-auth endpoint
246+
object carries `.path` and `.options.method`, so a live instance *is* the route
247+
table. `packages/plugins/plugin-auth/src/auth-route-ledger.ts` reads it.
248+
249+
Two halves, checked differently on purpose:
250+
251+
| Half | Check | Catches |
252+
|---|---|---|
253+
| `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 |
254+
| `BETTER_AUTH_MOUNTED_SURFACE` — 129 wire paths, the whole inventory | **exact equality**, both directions | a version bump silently **adding** publicly-mounted auth endpoints |
255+
256+
The asymmetry is deliberate: demanding a hand-written rationale for all 129
257+
would turn every better-auth upgrade into a hundred-row review, and the ledger
258+
would rot into rubber-stamping. But the catch-all publishes whatever upstream
259+
adds, so growth still has to be a reviewable CI diff — hence a
260+
machine-maintained inventory rather than reviewed prose.
261+
262+
Enumeration is config-dependent (better-auth plugins are opt-in), so the
263+
inventory is pinned at the configuration enabling every plugin the SDK targets
264+
— the maximal surface — with the participating `OS_*` env vars cleared so a
265+
developer's shell cannot produce a spurious diff. Mutation-checked: renaming a
266+
ledgered route fails the suite naming it.
267+
268+
Effect on §10's guard: the wildcard-only count **fell 60 → 3** (only `* /ai/**`
269+
remains, whose routes `service-ai buildAIRoutes()` builds at plugin start). The
270+
capstone also had to prefer exact rows over wildcard families when matching —
271+
otherwise every `/auth/*` URL would still have been absorbed by `* /auth/**`
272+
and the new ledger would have changed nothing.
239273

240274
## Follow-up slicing (proposed)
241275

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

255290
Each gap closed must flip its ledger row to `sdk` and lower the ratchet bound
256291
in the conformance test — the guard enforces both directions from PR-1 onward.

packages/client/src/client-url-conformance.test.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
/**
44
* Client-URL conformance — the capstone guard (#3642).
55
*
6-
* WHAT THE OTHER FOUR GUARDS DO NOT ASK. The dispatcher (#3563), REST (#3587)
6+
* WHAT THE SERVER-SIDE GUARDS DO NOT ASK. The dispatcher (#3563), REST (#3587)
77
* and service-mount (#3636) ledgers all run server → client: enumerate what a
88
* surface mounts, demand a reviewed disposition, and for `sdk` rows demand the
99
* named client method exists. *The method exists* is not *the method can be
@@ -21,8 +21,9 @@
2121
*
2222
* This suite closes the direction. It drives every method on a real client
2323
* with a recording `fetch`, then matches each captured URL against the UNION
24-
* of all four ledgers. A union, not an intersection: a route mounted by only
25-
* one surface is still legitimately reachable.
24+
* of all five ledgers — dispatcher, REST, storage, i18n, auth. A union, not an
25+
* intersection: a route mounted by only one surface is still legitimately
26+
* reachable.
2627
*
2728
* WHY A REAL DRIVE, NOT A DECLARATION. Asserting "method X targets route Y" in
2829
* a table would be an assertion *about* the code that the code can drift away
@@ -48,6 +49,7 @@ import { ROUTE_LEDGER } from '../../runtime/src/route-ledger';
4849
import { REST_ROUTE_LEDGER } from '../../rest/src/rest-route-ledger';
4950
import { STORAGE_ROUTE_LEDGER } from '../../services/service-storage/src/storage-route-ledger';
5051
import { I18N_ROUTE_LEDGER } from '../../services/service-i18n/src/i18n-route-ledger';
52+
import { AUTH_ROUTE_LEDGER } from '../../plugins/plugin-auth/src/auth-route-ledger';
5153

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

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

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

362371
// HOW STRONG IS THIS GUARD, HONESTLY. A `**` row asserts only that a prefix
363-
// family is claimed, not that the specific URL resolves — `/auth/**` alone
364-
// covers 26 SDK methods. Those matches are real but weak, so the count is
365-
// ratcheted: enumerating a dynamic family (or dropping one) may lower it,
366-
// and nothing may raise it without a deliberate decision.
372+
// family is CLAIMED, not that the specific URL resolves. That was this
373+
// guard's biggest weakness at #3642: 60 of ~196 matched calls rested on
374+
// nothing better, 54 of them on `* /auth/**`. #3656 enumerated better-auth's
375+
// real route table, so those now match exact rows and the bound fell 60 → 3.
376+
// What remains is `* /ai/**`, whose routes service-ai builds at plugin start.
377+
// Ratcheted: enumerating that family lowers it; nothing raises it without a
378+
// deliberate decision.
367379
expect(
368380
wildcardOnly.length,
369381
'methods matched only by a wildcard `**` family — weaker evidence than an exact ' +
370382
`route. Enumerate a dynamic family to lower this bound; do not raise it:\n${wildcardOnly.join('\n')}`,
371-
).toBeLessThanOrEqual(60);
383+
).toBeLessThanOrEqual(3);
372384
});
373385
});
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Auth route-ledger conformance (#3656) — the guard over the one surface the
5+
* #3642 capstone could only cover with a wildcard.
6+
*
7+
* The route table here belongs to better-auth, and the plugin mounts it with a
8+
* single catch-all, so there are no registration calls to capture the way
9+
* tranche 3 captured `registerStorageRoutes`. The enumeration seam is
10+
* `auth.api`: every better-auth endpoint carries `.path` and `.options.method`,
11+
* so a live instance IS the route table. Still a real enumeration — nothing
12+
* here is a list someone maintains by hand and hopes is current.
13+
*
14+
* Two directions, checked differently on purpose (see the ledger header):
15+
*
16+
* 1. STRICT — every reviewed `better-auth` row must exist upstream. This is
17+
* the rename detector: `auth.me` targets `/get-session`, and if better-auth
18+
* renames it, 26 `auth.*` SDK methods start 404-ing. Today nothing notices.
19+
* 2. EXACT — the mounted-surface inventory must equal the live enumeration in
20+
* both directions. The catch-all publishes whatever upstream adds, so a
21+
* version bump that introduces endpoints has to surface as a reviewable
22+
* diff rather than as silently-exposed auth surface.
23+
*/
24+
25+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
26+
import { AuthManager } from './auth-manager';
27+
import { AUTH_ROUTE_LEDGER, BETTER_AUTH_MOUNTED_SURFACE } from './auth-route-ledger';
28+
29+
const BASE_PATH = '/api/v1/auth';
30+
31+
/**
32+
* The configuration the inventory is pinned at: every better-auth plugin the
33+
* SDK targets, turned on explicitly. The maximal surface, so no route the SDK
34+
* can reach is missing from the enumeration.
35+
*/
36+
const LEDGERED_PLUGIN_CONFIG = {
37+
organization: true,
38+
twoFactor: true,
39+
admin: true,
40+
oidcProvider: true,
41+
passkeys: true,
42+
magicLink: true,
43+
deviceAuthorization: true,
44+
};
45+
46+
/**
47+
* `buildPluginList` reads env vars that can flip plugins on top of the config
48+
* above (`OS_SSO_ENABLED`, `OS_SCIM_ENABLED`, …). A developer with any of them
49+
* exported would enumerate a different surface and see a spurious diff, so they
50+
* are cleared for the duration of the suite.
51+
*/
52+
const ENV_KEYS = [
53+
'OS_SSO_ENABLED', 'OS_SSO_DOMAIN_VERIFICATION', 'OS_SCIM_ENABLED',
54+
'OS_AUTH_TWO_FACTOR', 'OS_AUTH_PASSWORD_REJECT_BREACHED',
55+
'OS_OIDC_PROVIDER_ENABLED', 'OS_MCP_SERVER_ENABLED',
56+
];
57+
const savedEnv: Record<string, string | undefined> = {};
58+
59+
let live: Set<string>;
60+
61+
beforeAll(async () => {
62+
for (const k of ENV_KEYS) { savedEnv[k] = process.env[k]; delete process.env[k]; }
63+
64+
const manager = new AuthManager({
65+
secret: 'route-ledger-conformance-secret-32-chars',
66+
baseUrl: 'http://localhost:9',
67+
plugins: LEDGERED_PLUGIN_CONFIG,
68+
} as never);
69+
// No dataEngine: createDatabaseConfig() falls back to better-auth's in-memory
70+
// adapter. Route registration does not touch the database — only request
71+
// handling would — so the table enumerates identically.
72+
const auth = (await manager.getAuthInstance()) as unknown as {
73+
api: Record<string, { path?: string; options?: { method?: string | string[] } }>;
74+
};
75+
76+
live = new Set<string>();
77+
for (const endpoint of Object.values(auth.api ?? {})) {
78+
if (typeof endpoint?.path !== 'string') continue;
79+
// The OIDC/OAuth discovery documents are mounted at the app ROOT by
80+
// auth-plugin.ts (RFC 8414 and OpenID Discovery both require well-known to
81+
// sit on the origin), not under the auth base path.
82+
const wire = endpoint.path.startsWith('/.well-known/')
83+
? endpoint.path
84+
: `${BASE_PATH}${endpoint.path}`;
85+
const method = endpoint.options?.method;
86+
for (const verb of Array.isArray(method) ? method : [method ?? 'POST']) {
87+
live.add(`${verb} ${wire}`);
88+
}
89+
}
90+
}, 60_000);
91+
92+
afterAll(() => {
93+
for (const k of ENV_KEYS) {
94+
if (savedEnv[k] === undefined) delete process.env[k];
95+
else process.env[k] = savedEnv[k];
96+
}
97+
});
98+
99+
describe('auth route ledger ↔ the live better-auth route table', () => {
100+
it('enumeration produced a real table (guard the guard)', () => {
101+
// An empty or tiny `auth.api` — a construction failure, a mocked module —
102+
// would make every assertion below vacuously pass.
103+
expect(live.size).toBeGreaterThan(100);
104+
});
105+
106+
it('every reviewed better-auth row still exists upstream', () => {
107+
const missing = AUTH_ROUTE_LEDGER
108+
.filter((e) => e.source === 'better-auth')
109+
.map((e) => e.route)
110+
.filter((r) => !live.has(r));
111+
expect(
112+
missing,
113+
'ledgered auth routes better-auth no longer serves — the SDK methods naming them are ' +
114+
`now wire-level 404s:\n${missing.join('\n')}`,
115+
).toEqual([]);
116+
});
117+
118+
it('the mounted-surface inventory matches what the catch-all actually publishes', () => {
119+
const pinned = new Set(BETTER_AUTH_MOUNTED_SURFACE);
120+
const added = [...live].filter((r) => !pinned.has(r)).sort();
121+
const removed = [...pinned].filter((r) => !live.has(r)).sort();
122+
123+
expect(
124+
added,
125+
'better-auth now publishes auth endpoints the ledger does not list. These are mounted ' +
126+
'PUBLICLY by the catch-all, so review them before pinning — then add them to ' +
127+
`BETTER_AUTH_MOUNTED_SURFACE:\n${added.join('\n')}`,
128+
).toEqual([]);
129+
130+
expect(
131+
removed,
132+
'pinned auth endpoints better-auth no longer publishes — anything depending on them ' +
133+
`is now a 404:\n${removed.join('\n')}`,
134+
).toEqual([]);
135+
});
136+
});
137+
138+
describe('auth route ledger hygiene', () => {
139+
it('every `sdk` entry names its client method; every non-sdk entry carries a rationale', () => {
140+
const sdkWithout = AUTH_ROUTE_LEDGER.filter((e) => e.disposition === 'sdk' && !e.client).map((e) => e.route);
141+
expect(sdkWithout, 'sdk-disposition entries missing a client method name').toEqual([]);
142+
143+
const bareNonSdk = AUTH_ROUTE_LEDGER.filter((e) => e.disposition !== 'sdk' && !e.note).map((e) => e.route);
144+
expect(bareNonSdk, 'non-sdk entries must say WHY they are not SDK surface').toEqual([]);
145+
});
146+
147+
it('no route is ledgered twice, and every row is under the auth base path', () => {
148+
const seen = new Set<string>();
149+
const dupes = AUTH_ROUTE_LEDGER.map((e) => e.route).filter((r) => !seen.add(r));
150+
expect(dupes, `duplicate auth-route-ledger rows: ${dupes.join(', ')}`).toEqual([]);
151+
152+
const stray = AUTH_ROUTE_LEDGER
153+
.filter((e) => !e.route.includes(` ${BASE_PATH}/`))
154+
.map((e) => e.route);
155+
expect(stray, `rows outside ${BASE_PATH}: ${stray.join(', ')}`).toEqual([]);
156+
});
157+
158+
it('the two objectstack-mounted rows are the ones auth-plugin.ts serves itself', () => {
159+
// /config and /bootstrap-status are mounted on the raw app AHEAD of the
160+
// catch-all, so they are absent from `auth.api` by construction. Pinned so
161+
// the `source` split stays honest rather than becoming a place to park a
162+
// row that failed the upstream check.
163+
const own = AUTH_ROUTE_LEDGER.filter((e) => e.source === 'objectstack').map((e) => e.route).sort();
164+
expect(own).toEqual([
165+
'GET /api/v1/auth/bootstrap-status',
166+
'GET /api/v1/auth/config',
167+
]);
168+
for (const route of own) {
169+
expect(live.has(route), `${route} should NOT come from better-auth`).toBe(false);
170+
}
171+
});
172+
173+
it('gap and mismatch counts only shrink', () => {
174+
// The surface audited clean at #3656: every SDK-reachable auth route
175+
// resolves against the live better-auth table.
176+
expect(AUTH_ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length).toBeLessThanOrEqual(0);
177+
expect(AUTH_ROUTE_LEDGER.filter((e) => e.disposition === 'mismatch').length).toBeLessThanOrEqual(0);
178+
});
179+
});

0 commit comments

Comments
 (0)