From f86e29141648a8b5cbbf80f85194e25ce4389b33 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 13:39:13 +0000 Subject: [PATCH] =?UTF-8?q?test(plugin-auth):=20enumerate=20better-auth's?= =?UTF-8?q?=20route=20table=20=E2=80=94=20the=20`/auth/**`=20wildcard=20be?= =?UTF-8?q?comes=2055=20exact=20rows=20(#3656)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 — 54 of them 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 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 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. The rename detector. - 129-PATH MOUNTED-SURFACE INVENTORY — exact equality both ways, so a version bump that adds publicly-mounted auth endpoints becomes a reviewable CI diff. The asymmetry is a decision: 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 visible. 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. 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 be absorbed by `* /auth/**` and the new ledger would have changed nothing. Wildcard-only matches fall 60 → 3 and 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. Refs #3656, #3642, #3563. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K35y3ovfWtCkBHYFCqUfAt --- .changeset/auth-route-ledger.md | 44 +++ ...026-07-dispatcher-client-route-coverage.md | 41 ++- .../client/src/client-url-conformance.test.ts | 30 +- .../src/auth-route-ledger.conformance.test.ts | 179 +++++++++++ .../plugin-auth/src/auth-route-ledger.ts | 283 ++++++++++++++++++ packages/runtime/src/route-ledger.ts | 10 +- 6 files changed, 571 insertions(+), 16 deletions(-) create mode 100644 .changeset/auth-route-ledger.md create mode 100644 packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts create mode 100644 packages/plugins/plugin-auth/src/auth-route-ledger.ts diff --git a/.changeset/auth-route-ledger.md b/.changeset/auth-route-ledger.md new file mode 100644 index 0000000000..6fcf1a3766 --- /dev/null +++ b/.changeset/auth-route-ledger.md @@ -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. diff --git a/docs/audits/2026-07-dispatcher-client-route-coverage.md b/docs/audits/2026-07-dispatcher-client-route-coverage.md index eb88ba63c6..e4c67b687b 100644 --- a/docs/audits/2026-07-dispatcher-client-route-coverage.md +++ b/docs/audits/2026-07-dispatcher-client-route-coverage.md @@ -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) @@ -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. diff --git a/packages/client/src/client-url-conformance.test.ts b/packages/client/src/client-url-conformance.test.ts index 8e617433f6..65d9de3067 100644 --- a/packages/client/src/client-url-conformance.test.ts +++ b/packages/client/src/client-url-conformance.test.ts @@ -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 @@ -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 @@ -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'; @@ -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)); @@ -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); }); }); diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts new file mode 100644 index 0000000000..613632a648 --- /dev/null +++ b/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts @@ -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 = {}; + +let live: Set; + +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; + }; + + live = new Set(); + 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(); + 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); + }); +}); diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.ts new file mode 100644 index 0000000000..e30645eb75 --- /dev/null +++ b/packages/plugins/plugin-auth/src/auth-route-ledger.ts @@ -0,0 +1,283 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Auth route ledger — the audited disposition of the `/api/v1/auth/*` surface + * (#3656, closing the widest hole the #3642 capstone measured). + * + * WHY THIS ONE IS DIFFERENT. Tranches 1–3 ledgered routes this repo declares. + * These are declared by **better-auth**, a third-party dependency on its own + * release cadence, and the plugin mounts them with a single 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` instead: every better-auth endpoint object carries `.path` and + * `.options.method`, so the live instance IS the route table. + * + * WHAT IT REPLACES. Until #3656 this whole surface was one `* /auth/**` row in + * the dispatcher ledger. That row claims a PREFIX, not a resolvable route, and + * the capstone guard measured what that cost: 54 SDK methods — the largest and + * most security-relevant namespace in the client — matched on nothing stronger + * than "something under /auth is claimed". `auth.me` builds + * `/api/v1/auth/get-session`; a prefix claim cannot tell you better-auth still + * calls it that. The risk is not hypothetical — this repo already chased + * better-auth 1.7 column drift in #3624 / #3647. + * + * TWO HALVES, DELIBERATELY ASYMMETRIC. `auth-route-ledger.conformance.test.ts` + * checks them differently, and the asymmetry is a decision, not an oversight: + * + * 1. `AUTH_ROUTE_LEDGER` — the reviewed rows: every route the SDK actually + * calls, each naming its client method. Checked STRICTLY — a row whose + * path better-auth no longer serves fails CI. This is what catches an + * upstream rename before it reaches users. + * 2. `BETTER_AUTH_MOUNTED_SURFACE` — the full inventory of what the catch-all + * exposes. Checked for EXACT equality against the live enumeration, in + * both directions. It is a machine-maintained list, not reviewed prose: + * requiring a hand-written rationale for all ~129 endpoints would make + * every better-auth upgrade a hundred-row review and the ledger would rot + * into rubber-stamping. But the catch-all publishes whatever upstream + * adds, so a version bump that introduces endpoints MUST be visible — + * equality makes it a CI diff someone has to look at. + * + * ENUMERATION IS CONFIG-DEPENDENT. better-auth plugins are opt-in, so the route + * set varies with `AuthPluginConfig`. The inventory is pinned at the + * configuration that turns on every plugin the SDK targets (see + * `LEDGERED_PLUGIN_CONFIG` in the conformance test) — the maximal surface, + * so nothing the SDK can reach is missing from it. A deployment running fewer + * plugins serves a subset; that is correct and is why the SDK rows for gated + * families carry `requires`. + * + * This module is package-internal (not exported from the index): it is the + * guard's data, not public API. It must stay import-free — the client-side + * guard imports it as a relative SOURCE file. + */ + +/** Disposition of a single auth route. Same vocabulary as the other ledgers. */ +export type AuthRouteDisposition = + /** Expressed by the SDK — `client` names the method (dotted path). */ + | 'sdk' + /** Should be in the SDK and is not — an open, acknowledged gap. */ + | 'gap' + /** Deliberately not SDK surface. */ + | 'server-only' + /** Public, unauthenticated browser-facing route. */ + | 'public' + /** Server and client disagree on the shape — needs reconciliation. */ + | 'mismatch'; + +export interface AuthRouteLedgerEntry { + /** `VERB /api/v1/auth/...` — full wire path at the default base. */ + route: string; + /** Grouping for diff messages. */ + family: string; + /** + * Who declares the route. `better-auth` rows are verified against the live + * `auth.api` table; `objectstack` rows are mounted by `auth-plugin.ts` + * directly on the raw app AHEAD of the catch-all, so they never appear there. + */ + source: 'better-auth' | 'objectstack'; + disposition: AuthRouteDisposition; + /** Dotted method path on `ObjectStackClient` — required when disposition is `sdk`. */ + client?: string; + /** Optional better-auth plugin this route needs (absent = always mounted). */ + requires?: string; + /** One-line rationale. Required for every non-`sdk` disposition. */ + note?: string; +} + +export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [ + { route: 'POST /api/v1/auth/change-email', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.changeEmail' }, + { route: 'POST /api/v1/auth/change-password', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.changePassword' }, + { route: 'POST /api/v1/auth/delete-user', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.deleteUser' }, + { route: 'GET /api/v1/auth/get-session', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.me', note: 'auth.me and auth.refreshToken both target it' }, + { route: 'POST /api/v1/auth/link-social', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.accounts.linkSocial' }, + { route: 'GET /api/v1/auth/list-accounts', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.accounts.list' }, + { route: 'GET /api/v1/auth/list-sessions', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.sessions.list' }, + { route: 'POST /api/v1/auth/revoke-other-sessions', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.sessions.revokeOthers' }, + { route: 'POST /api/v1/auth/revoke-session', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.sessions.revoke' }, + { route: 'POST /api/v1/auth/revoke-sessions', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.sessions.revokeAll' }, + { route: 'POST /api/v1/auth/send-verification-email', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.sendVerificationEmail' }, + { route: 'POST /api/v1/auth/sign-in/email', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.login' }, + { route: 'POST /api/v1/auth/sign-in/social', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.signInWithProvider' }, + { route: 'POST /api/v1/auth/sign-out', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.logout' }, + { route: 'POST /api/v1/auth/sign-up/email', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.register' }, + { route: 'POST /api/v1/auth/unlink-account', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.accounts.unlink' }, + { route: 'POST /api/v1/auth/update-user', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.updateUser' }, + { route: 'GET /api/v1/auth/verify-email', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.verifyEmail' }, + { route: 'POST /api/v1/auth/oauth2/consent', family: 'oauth-provider', source: 'better-auth', disposition: 'sdk', client: 'oauth.consent', requires: 'oidcProvider' }, + { route: 'POST /api/v1/auth/oauth2/create-client', family: 'oauth-provider', source: 'better-auth', disposition: 'sdk', client: 'oauth.applications.register', requires: 'oidcProvider' }, + { route: 'POST /api/v1/auth/oauth2/delete-client', family: 'oauth-provider', source: 'better-auth', disposition: 'sdk', client: 'oauth.applications.delete', requires: 'oidcProvider' }, + { route: 'GET /api/v1/auth/oauth2/get-client', family: 'oauth-provider', source: 'better-auth', disposition: 'sdk', client: 'oauth.applications.get', requires: 'oidcProvider' }, + { route: 'GET /api/v1/auth/oauth2/get-clients', family: 'oauth-provider', source: 'better-auth', disposition: 'sdk', client: 'oauth.applications.list', requires: 'oidcProvider' }, + { route: 'GET /api/v1/auth/oauth2/public-client', family: 'oauth-provider', source: 'better-auth', disposition: 'sdk', client: 'oauth.applications.getPublic', requires: 'oidcProvider' }, + { route: 'GET /api/v1/auth/bootstrap-status', family: 'objectstack-mount', source: 'objectstack', disposition: 'sdk', client: 'auth.bootstrapStatus' }, + { route: 'GET /api/v1/auth/config', family: 'objectstack-mount', source: 'objectstack', disposition: 'sdk', client: 'auth.getConfig' }, + { route: 'POST /api/v1/auth/organization/accept-invitation', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.invitations.accept', requires: 'organization' }, + { route: 'POST /api/v1/auth/organization/add-team-member', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.teams.addMember', requires: 'organization' }, + { route: 'POST /api/v1/auth/organization/cancel-invitation', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.invitations.cancel', requires: 'organization' }, + { route: 'POST /api/v1/auth/organization/create', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.create', requires: 'organization' }, + { route: 'POST /api/v1/auth/organization/create-team', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.teams.create', requires: 'organization' }, + { route: 'POST /api/v1/auth/organization/delete', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.delete', requires: 'organization' }, + { route: 'GET /api/v1/auth/organization/get-active-member', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.getActiveMember', requires: 'organization' }, + { route: 'GET /api/v1/auth/organization/get-full-organization', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.get', requires: 'organization' }, + { route: 'POST /api/v1/auth/organization/invite-member', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.invitations.resend', requires: 'organization', note: 'organizations.invitations.resend and organizations.invite both target it' }, + { route: 'POST /api/v1/auth/organization/leave', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.leave', requires: 'organization' }, + { route: 'GET /api/v1/auth/organization/list', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.list', requires: 'organization' }, + { route: 'GET /api/v1/auth/organization/list-invitations', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.invitations.list', requires: 'organization' }, + { route: 'GET /api/v1/auth/organization/list-members', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.listMembers', requires: 'organization' }, + { route: 'GET /api/v1/auth/organization/list-team-members', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.teams.listMembers', requires: 'organization' }, + { route: 'GET /api/v1/auth/organization/list-teams', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.teams.list', requires: 'organization' }, + { route: 'GET /api/v1/auth/organization/list-user-invitations', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.invitations.listMine', requires: 'organization' }, + { route: 'POST /api/v1/auth/organization/reject-invitation', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.invitations.reject', requires: 'organization' }, + { route: 'POST /api/v1/auth/organization/remove-member', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.removeMember', requires: 'organization' }, + { route: 'POST /api/v1/auth/organization/remove-team', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.teams.delete', requires: 'organization' }, + { route: 'POST /api/v1/auth/organization/remove-team-member', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.teams.removeMember', requires: 'organization' }, + { route: 'POST /api/v1/auth/organization/set-active', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.setActive', requires: 'organization' }, + { route: 'POST /api/v1/auth/organization/update', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.update', requires: 'organization' }, + { route: 'POST /api/v1/auth/organization/update-member-role', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.updateMemberRole', requires: 'organization' }, + { route: 'POST /api/v1/auth/organization/update-team', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.teams.update', requires: 'organization' }, + { route: 'POST /api/v1/auth/two-factor/disable', family: 'two-factor', source: 'better-auth', disposition: 'sdk', client: 'auth.twoFactor.disable', requires: 'twoFactor' }, + { route: 'POST /api/v1/auth/two-factor/enable', family: 'two-factor', source: 'better-auth', disposition: 'sdk', client: 'auth.twoFactor.enable', requires: 'twoFactor' }, + { route: 'POST /api/v1/auth/two-factor/generate-backup-codes', family: 'two-factor', source: 'better-auth', disposition: 'sdk', client: 'auth.twoFactor.generateBackupCodes', requires: 'twoFactor' }, + { route: 'POST /api/v1/auth/two-factor/verify-backup-code', family: 'two-factor', source: 'better-auth', disposition: 'sdk', client: 'auth.twoFactor.verifyBackupCode', requires: 'twoFactor' }, + { route: 'POST /api/v1/auth/two-factor/verify-totp', family: 'two-factor', source: 'better-auth', disposition: 'sdk', client: 'auth.twoFactor.verifyTotp', requires: 'twoFactor' },]; + + +/** + * Every route the better-auth catch-all publishes at `LEDGERED_PLUGIN_CONFIG`, + * as `VERB /wire/path`. Machine-maintained: regenerate from the conformance + * test's failure diff after a better-auth upgrade, then REVIEW the diff — a new + * entry here is a new publicly-mounted auth endpoint, which is a security + * surface change even when it is an intended one. + * + * The two `/.well-known/*` entries are not under the base path: `auth-plugin.ts` + * mounts those discovery documents at the app root (RFC 8414 / OIDC require it). + */ +export const BETTER_AUTH_MOUNTED_SURFACE: readonly string[] = [ + 'DELETE /api/v1/auth/admin/oauth2/resources/:identifier', + 'DELETE /api/v1/auth/admin/oauth2/resources/:identifier/clients/:client_id', + 'GET /.well-known/oauth-authorization-server', + 'GET /.well-known/openid-configuration', + 'GET /api/v1/auth/account-info', + 'GET /api/v1/auth/admin/get-user', + 'GET /api/v1/auth/admin/list-users', + 'GET /api/v1/auth/admin/oauth2/resources', + 'GET /api/v1/auth/admin/oauth2/resources/:identifier', + 'GET /api/v1/auth/callback/:id', + 'GET /api/v1/auth/delete-user/callback', + 'GET /api/v1/auth/device', + 'GET /api/v1/auth/error', + 'GET /api/v1/auth/get-session', + 'GET /api/v1/auth/jwks', + 'GET /api/v1/auth/list-accounts', + 'GET /api/v1/auth/list-sessions', + 'GET /api/v1/auth/magic-link/verify', + 'GET /api/v1/auth/oauth2/authorize', + 'GET /api/v1/auth/oauth2/end-session', + 'GET /api/v1/auth/oauth2/get-client', + 'GET /api/v1/auth/oauth2/get-clients', + 'GET /api/v1/auth/oauth2/get-consent', + 'GET /api/v1/auth/oauth2/get-consents', + 'GET /api/v1/auth/oauth2/public-client', + 'GET /api/v1/auth/oauth2/userinfo', + 'GET /api/v1/auth/ok', + 'GET /api/v1/auth/organization/get-active-member', + 'GET /api/v1/auth/organization/get-active-member-role', + 'GET /api/v1/auth/organization/get-full-organization', + 'GET /api/v1/auth/organization/get-invitation', + 'GET /api/v1/auth/organization/get-organization', + 'GET /api/v1/auth/organization/list', + 'GET /api/v1/auth/organization/list-invitations', + 'GET /api/v1/auth/organization/list-members', + 'GET /api/v1/auth/organization/list-team-members', + 'GET /api/v1/auth/organization/list-teams', + 'GET /api/v1/auth/organization/list-user-invitations', + 'GET /api/v1/auth/organization/list-user-teams', + 'GET /api/v1/auth/reset-password/:token', + 'GET /api/v1/auth/token', + 'GET /api/v1/auth/verify-email', + 'PATCH /api/v1/auth/admin/oauth2/resources/:identifier', + 'PATCH /api/v1/auth/admin/oauth2/update-client', + 'POST /api/v1/auth/admin/ban-user', + 'POST /api/v1/auth/admin/create-user', + 'POST /api/v1/auth/admin/has-permission', + 'POST /api/v1/auth/admin/impersonate-user', + 'POST /api/v1/auth/admin/list-user-sessions', + 'POST /api/v1/auth/admin/oauth2/create-client', + 'POST /api/v1/auth/admin/oauth2/resources', + 'POST /api/v1/auth/admin/oauth2/resources/:identifier/clients/:client_id', + 'POST /api/v1/auth/admin/remove-user', + 'POST /api/v1/auth/admin/revoke-user-session', + 'POST /api/v1/auth/admin/revoke-user-sessions', + 'POST /api/v1/auth/admin/set-role', + 'POST /api/v1/auth/admin/set-user-password', + 'POST /api/v1/auth/admin/stop-impersonating', + 'POST /api/v1/auth/admin/unban-user', + 'POST /api/v1/auth/admin/update-user', + 'POST /api/v1/auth/callback/:id', + 'POST /api/v1/auth/change-email', + 'POST /api/v1/auth/change-password', + 'POST /api/v1/auth/delete-user', + 'POST /api/v1/auth/device/approve', + 'POST /api/v1/auth/device/code', + 'POST /api/v1/auth/device/deny', + 'POST /api/v1/auth/device/token', + 'POST /api/v1/auth/get-access-token', + 'POST /api/v1/auth/get-session', + 'POST /api/v1/auth/link-social', + 'POST /api/v1/auth/oauth2/authorize', + 'POST /api/v1/auth/oauth2/client/rotate-secret', + 'POST /api/v1/auth/oauth2/consent', + 'POST /api/v1/auth/oauth2/continue', + 'POST /api/v1/auth/oauth2/create-client', + 'POST /api/v1/auth/oauth2/delete-client', + 'POST /api/v1/auth/oauth2/delete-consent', + 'POST /api/v1/auth/oauth2/introspect', + 'POST /api/v1/auth/oauth2/public-client-prelogin', + 'POST /api/v1/auth/oauth2/register', + 'POST /api/v1/auth/oauth2/revoke', + 'POST /api/v1/auth/oauth2/token', + 'POST /api/v1/auth/oauth2/update-client', + 'POST /api/v1/auth/oauth2/update-consent', + 'POST /api/v1/auth/oauth2/userinfo', + 'POST /api/v1/auth/organization/accept-invitation', + 'POST /api/v1/auth/organization/add-team-member', + 'POST /api/v1/auth/organization/cancel-invitation', + 'POST /api/v1/auth/organization/check-slug', + 'POST /api/v1/auth/organization/create', + 'POST /api/v1/auth/organization/create-team', + 'POST /api/v1/auth/organization/delete', + 'POST /api/v1/auth/organization/has-permission', + 'POST /api/v1/auth/organization/invite-member', + 'POST /api/v1/auth/organization/leave', + 'POST /api/v1/auth/organization/reject-invitation', + 'POST /api/v1/auth/organization/remove-member', + 'POST /api/v1/auth/organization/remove-team', + 'POST /api/v1/auth/organization/remove-team-member', + 'POST /api/v1/auth/organization/set-active', + 'POST /api/v1/auth/organization/set-active-team', + 'POST /api/v1/auth/organization/update', + 'POST /api/v1/auth/organization/update-member-role', + 'POST /api/v1/auth/organization/update-team', + 'POST /api/v1/auth/refresh-token', + 'POST /api/v1/auth/request-password-reset', + 'POST /api/v1/auth/reset-password', + 'POST /api/v1/auth/revoke-other-sessions', + 'POST /api/v1/auth/revoke-session', + 'POST /api/v1/auth/revoke-sessions', + 'POST /api/v1/auth/send-verification-email', + 'POST /api/v1/auth/sign-in/email', + 'POST /api/v1/auth/sign-in/magic-link', + 'POST /api/v1/auth/sign-in/social', + 'POST /api/v1/auth/sign-out', + 'POST /api/v1/auth/sign-up/email', + 'POST /api/v1/auth/two-factor/disable', + 'POST /api/v1/auth/two-factor/enable', + 'POST /api/v1/auth/two-factor/generate-backup-codes', + 'POST /api/v1/auth/two-factor/get-totp-uri', + 'POST /api/v1/auth/two-factor/send-otp', + 'POST /api/v1/auth/two-factor/verify-backup-code', + 'POST /api/v1/auth/two-factor/verify-otp', + 'POST /api/v1/auth/two-factor/verify-totp', + 'POST /api/v1/auth/unlink-account', + 'POST /api/v1/auth/update-session', + 'POST /api/v1/auth/update-user', + 'POST /api/v1/auth/verify-password',]; diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index ae10cc07ef..133271f568 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -32,9 +32,11 @@ * drives every SDK method and matches the URL it builds against the UNION of * all four ledgers — so the `route` strings below are now load-bearing for the * client half too, not just for dispatcher enumeration. Note what that guard - * can and cannot do with a `dynamic` row: `* /ai/**` and `* /auth/**` claim a - * prefix family, not a resolvable route, and 60 SDK methods match on nothing - * stronger than that (ratcheted in the guard; #3656 tracks enumerating them). + * can and cannot do with a `dynamic` row: a `**` row claims a prefix family, + * not a resolvable route. `* /auth/**` was the worst of it — 54 SDK methods + * resting on a prefix claim — until #3656 enumerated better-auth's real table + * into `plugin-auth/src/auth-route-ledger.ts`, dropping the guard's + * wildcard-only count 60 → 3. The `* /ai/**` row below is what remains. * * This module is runtime-internal (not exported from the package index): it is * the guard's data, not public API. Promotion to `@objectstack/spec` is a @@ -176,7 +178,7 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ // ── auth (better-auth passthrough) ──────────────────────────────────────── { route: '* /auth/**', domain: '/auth', disposition: 'sdk', client: 'auth.me', - note: 'wholesale delegate to the auth service; the client expresses 26 selected better-auth endpoints (auth/oauth/organizations surfaces) — not enumerable route-by-route here' }, + note: 'wholesale delegate to the auth service. Not enumerable route-by-route HERE, but no longer unenumerated: since #3656 plugin-auth/src/auth-route-ledger.ts carries the 55 SDK-reached routes plus the full mounted inventory, read off better-auth\'s live auth.api table' }, // ── ai (dynamic route table) ────────────────────────────────────────────── { route: '* /ai/**', domain: '/ai', disposition: 'dynamic',