Repository navigation
Expand file tree
/
Copy pathauth-route-ledger.conformance.test.ts
More file actions
322 lines (295 loc) · 15.3 KB
/
Copy pathauth-route-ledger.conformance.test.ts
File metadata and controls
322 lines (295 loc) · 15.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
// 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>;
/**
* The options object better-auth was actually constructed with — the same one
* its handlers read as `ctx.context.options`, so a capability switch read here
* is the switch the runtime enforces, not a restatement of the ledger.
*/
let liveOptions: {
user?: {
changeEmail?: { enabled?: boolean };
deleteUser?: { enabled?: boolean };
};
};
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[] } }>;
options: typeof liveOptions;
};
liveOptions = auth.options;
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', () => {
// PRESENCE only — `!e.client` is falsy-on-absent, so this cannot tell a
// real method name from a typo'd or renamed one, and nothing here can:
// resolving the name needs an ObjectStackClient, and plugin-auth has no
// dependency on the client (nor should it — the edge would be backwards).
// That half is the client-side guard added by #11359 —
// `packages/client/src/auth-route-ledger-coverage.test.ts` — which imports
// this ledger as a relative source file and asserts every name resolves to
// a real function. Read the two together before trusting this test's title.
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 objectstack-mounted rows are the ones auth-plugin.ts serves itself', () => {
// These are mounted on the raw app AHEAD of the catch-all, so they are
// absent from `auth.api`'s wire table by construction (organization/
// add-member exists on `auth.api` but carries NO path — server-only — so
// the enumeration, which reads `.path`, never sees it either). Pinned so
// the `source` split stays honest rather than becoming a place to park a
// row that failed the upstream check.
//
// [#10534] Grew from 3 to 11, and to 12 with #10974/#10975. A census of
// `auth-plugin.ts` found 17 such
// mounts, of which nine were in NEITHER half of the ledger; eight are
// ledgered now. This pin is the thing that makes the enlarged set
// reviewable: an ObjectStack mount added or removed without a matching
// row fails HERE, naming the route. It is not a substitute for the
// mount-vs-ledger gate, because this list is still hand-written: it
// catches a row that disappears, not a mount that never got one.
//
// That other half now EXISTS — `scripts/check-auth-mount-ledger.mjs`
// (`pnpm check:auth-mount-ledger`, #10534 follow-up 4). It enumerates the
// `rawApp` mounts from `auth-plugin.ts` SOURCE and diffs them against both
// halves of the ledger, so a mount added with no row fails THERE. The two
// are complements rather than duplicates, and both are worth keeping: this
// pin is a reviewed, hand-written statement of what the objectstack-sourced
// set IS, and the gate is a reading of what the plugin actually serves.
// The ninth mount, `POST /api/v1/auth/set-initial-password`, was
// deliberately absent while its disposition was escalated on #10534 rather
// than guessed. It is present now, and it got here by ADDITION on this
// pin's own terms — not by loosening the assertion, deleting the pin, or
// computing the list. Both terms hold for it: `auth-plugin.ts` mounts it
// itself (a `rawApp.post` on the `${basePath}/set-initial-password`
// template, ahead of the catch-all), and the `live.has(route)` loop below
// holds it to the same proof as the other eleven — better-auth does not
// publish it. Its `sdk` disposition names `auth.setInitialPassword`, which
// exists in the same change (#10974 / #10975, combined by the maintainer
// ruling of 2026-08-23).
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',
'POST /api/v1/auth/admin/import-users',
'POST /api/v1/auth/admin/oauth2/toggle-disabled',
// [#16678] The thirteenth, added on this pin's own terms exactly as the
// set-initial-password row below was: `auth-plugin.ts` mounts it itself
// (a `rawApp.post` on the `${basePath}/admin/set-user-manager`
// template, ahead of the catch-all), and the `live.has(route)` loop
// below holds it to the same proof as the other twelve — better-auth
// publishes no manager endpoint at all.
'POST /api/v1/auth/admin/set-user-manager',
'POST /api/v1/auth/admin/sso/register',
'POST /api/v1/auth/admin/sso/register-saml',
'POST /api/v1/auth/admin/sso/request-domain-verification',
'POST /api/v1/auth/admin/sso/verify-domain',
'POST /api/v1/auth/admin/unlock-user',
'POST /api/v1/auth/organization/add-member',
'POST /api/v1/auth/set-initial-password',
'POST /api/v1/auth/sys-oauth-application/register',
// The fourteenth, on this pin's own terms: `auth-plugin.ts` mounts it
// itself (a `rawApp.post` on the `${basePath}/sys-session/revoke`
// template, ahead of the catch-all), and the `live.has(route)` loop
// below holds it to the same proof — better-auth serves no by-id revoke.
'POST /api/v1/auth/sys-session/revoke',
]);
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);
});
});
// ───────────────────────────────────────────────────────────────────────────
/**
* #7735 — the ledger's disposition for a capability-gated route must agree with
* the SWITCH THE RUNTIME READS.
*
* This is the check whose absence was the whole defect. `change-email` and
* `delete-user` are published by the catch-all unconditionally, so every guard
* that asks "does better-auth serve this path" was green while one route
* answered 400 `CHANGE_EMAIL_DISABLED` and the other 404 — mounted, ledgered as
* live SDK surface, and dead. Path existence cannot see a feature switch.
*
* Both sides here are read independently: the left from `auth.options`, the
* object better-auth's own handlers consult (`ctx.context.options.user
* ?.changeEmail?.enabled`), the right from the ledger row. Deleting the config
* in auth-manager.ts turns this red without touching the ledger, and re-booking
* a `disabled` row as `sdk` turns it red without touching the config — a pin
* that derived both sides from the ledger could do neither.
*/
describe('#7735 — capability switches ↔ ledger disposition', () => {
/** The routes whose liveness is a better-auth `user.*` feature switch. */
const CAPABILITY_GATED = [
{
route: 'POST /api/v1/auth/change-email',
switchName: 'user.changeEmail.enabled',
isOn: () => liveOptions?.user?.changeEmail?.enabled === true,
},
{
route: 'POST /api/v1/auth/delete-user',
switchName: 'user.deleteUser.enabled',
isOn: () => liveOptions?.user?.deleteUser?.enabled === true,
},
] as const;
it.each(CAPABILITY_GATED)(
'the row for $route matches the live $switchName',
({ route, switchName, isOn }) => {
const row = AUTH_ROUTE_LEDGER.find((e) => e.route === route);
expect(row, `${route} is missing from AUTH_ROUTE_LEDGER`).toBeDefined();
const on = isOn();
expect(
row!.disposition,
on
? `${switchName} is ON, so ${route} really is a live SDK surface and the ledger must book it as \`sdk\`.`
: `${switchName} is OFF, so ${route} is refused at runtime. Booking it as \`sdk\` is the #7735 defect: `
+ 'either wire the capability, or leave the row `disabled` with the reason.',
).toBe(on ? 'sdk' : 'disabled');
},
);
it('every `disabled` row names its refused capability, and the set is the reviewed one', () => {
const disabled = AUTH_ROUTE_LEDGER.filter((e) => e.disposition === 'disabled');
// Pinned, not counted: a second withheld capability is a product decision,
// so it must arrive as a diff someone reads — the same reason
// BETTER_AUTH_MOUNTED_SURFACE is checked for equality rather than growth.
expect(
disabled.map((e) => e.route).sort(),
'the `disabled` set changed. A route may only sit here with a ruling behind it (#7735); '
+ 'wiring one up means moving it back to `sdk` in the same PR as the config.',
).toEqual(['POST /api/v1/auth/delete-user']);
for (const row of disabled) {
// `note` is what makes the state honest rather than merely quiet — the
// hygiene test above requires one; this requires it to say something.
expect(row.note ?? '', `${row.route} must say WHICH switch is off`).toMatch(/deleteUser|changeEmail/);
}
});
});