From 9558098d65c6c1739e73ce71e5db71836e1d7ef6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 14:07:47 +0000 Subject: [PATCH 1/2] fix(runtime)!: the /auth domain answers 501 instead of fabricating a login (#4113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `domains/auth.ts` carried a `mockAuthFallback` that answered `POST /auth/sign-up/email`, `/register`, `/sign-in/email`, `/login`, `GET /get-session` and `POST /sign-out` with 200 and a fabricated user plus a 24-hour `mock_token_*` session — for ANY email and ANY password, which was never read. It shipped in `@objectstack/runtime`, not behind a dev-only plugin, and gated on nothing but an empty `auth` slot, so `os serve --preset minimal` and any embedder that mounts the dispatcher without plugin-auth served it. It was never a bypass: no session store backs that token, so `resolve-execution-context.ts` still resolved anonymous and `shouldDenyAnonymous` still denied data access. It was wrong in a different way — it told the client the one thing a server must never lie about, that it had authenticated someone, while discovery simultaneously reported `auth: unavailable` and advertised no `routes.auth`. A `declared ≠ enforced` gap pointing the opposite direction from the usual one. Its stated justification ("MSW/browser-only environments") had no consumer in this repo or in objectui, whose auth tests mock at the HTTP client layer. The only things pinning it were tests asserting the mock itself — nine of them, across three describe blocks. ADR-0115 retired this class of fabricating fallback inside plugin-dev. This was its last surviving member and the only one that shipped to production; the lineage before it (the #3891 analytics shim, #4000's dev stub, the three in #4058/#4086, #4126's security trio) was retired the same way: deleted, not put behind a flag. A flag would have kept the code path in every release and become the next thing nobody audits — ADR-0078's silently-inert declaration in environment-variable form. 501, not 404, following `/i18n` — the nearest precedent in shape: a core capability, a dispatcher-owned domain, an optional plugin behind it, and a route discovery already declines to advertise when the slot is empty. The route IS mounted; what is missing is the implementation behind it, which is what 501 states and 404 would misdescribe. It also keeps faith with the one true observation the mock was built on — its comment said it existed to keep sign-in "from 404ing", and a bare 404 there does send an operator hunting for a routing bug — without the lie it used to answer that concern with. The message names the remedy. The `randomUUID` helper goes with it: a CSPRNG built solely to mint mock session ids (its own comment says "these ids feed mock session tokens"), dead the moment the mock is. `domains/auth.ts` drops from 141 lines to 81. A wrong-shaped occupant — a service in the slot without the contract's `handleRequest` — takes the same 501, and that is the sharper case: the slot is FILLED, so discovery advertises `routes.auth` and reports auth available, while the request used to get a fabricated session. #4143 had already narrowed the mock's reach by fixing that probe (`handler(req, res)`, a method no implementation has, to the contract's `handleRequest`); before it, even a correctly registered auth service was bypassed in favour of the mock. Tests replace the nine that pinned the mock: all six formerly-mocked paths parametrized so a partial re-introduction names itself, no `mock_`/user/session anywhere in the body, the remedy named, an identical answer for any credentials (a 501 that varied by password would be an oracle), a registered service still delegated to, and the wrong-shaped-occupant case. Verified: runtime 940, plugin-hono-server 149, plugin-auth 579, rest 512 pass; build 71/71; eslint clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018UNGqBQcdJ2RYHtWgntJ9B --- .changeset/retire-the-dispatcher-auth-mock.md | 15 ++ ...0115-plugin-dev-assembly-not-stub-table.md | 2 +- .../src/domain-handler-registry.test.ts | 14 +- packages/runtime/src/domains/auth.ts | 132 +++++------------- packages/runtime/src/http-dispatcher.test.ts | 100 +++++++------ 5 files changed, 121 insertions(+), 142 deletions(-) create mode 100644 .changeset/retire-the-dispatcher-auth-mock.md diff --git a/.changeset/retire-the-dispatcher-auth-mock.md b/.changeset/retire-the-dispatcher-auth-mock.md new file mode 100644 index 0000000000..b17b26a9b3 --- /dev/null +++ b/.changeset/retire-the-dispatcher-auth-mock.md @@ -0,0 +1,15 @@ +--- +'@objectstack/runtime': major +--- + +The `/auth` domain no longer fabricates a login. With no auth service registered it answers **501**; the mock that answered 200 with a made-up session is deleted (#4113). + +`packages/runtime/src/domains/auth.ts` carried a `mockAuthFallback` that answered `POST /auth/sign-up/email`, `/register`, `/sign-in/email`, `/login`, `GET /get-session` and `POST /sign-out` with **200 and a fabricated user plus a 24-hour `mock_token_*` session — for any email and any password, which was never read**. It shipped in `@objectstack/runtime` rather than behind a dev-only plugin, and gated on nothing but an empty `auth` slot, so `os serve --preset minimal` and any embedder that mounts the dispatcher without `@objectstack/plugin-auth` served it. + +It was never a bypass: no session store backs the token, so `resolve-execution-context.ts` still resolved anonymous and `shouldDenyAnonymous` still denied data access. It was worse in a different way — it told the client the one thing a server must never lie about, that it had authenticated someone, while discovery simultaneously reported `auth: unavailable` and advertised no `routes.auth`. Its stated justification ("MSW/browser-only environments") had no consumer in this repository or in `objectui`, whose auth tests mock at the HTTP client layer; the only things pinning it were tests asserting the mock itself. + +ADR-0115 retired this whole class of fabricating fallback inside `plugin-dev`. This was its last surviving member and the only one that shipped to production; the lineage before it — the #3891 analytics shim, #4000's dev stub, the three in #4058/#4086, #4126's security trio — was retired the same way: deleted, not put behind a flag. + +**501 rather than 404**, following `/i18n`, the nearest precedent in shape: a core capability, a dispatcher-owned domain, an optional plugin behind it, and a route discovery already declines to advertise when the slot is empty. The route is mounted; what is missing is the implementation behind it — which is what 501 states and 404 would misdescribe. A wrong-shaped occupant (a service without the contract's `handleRequest`) takes the same 501, which is the sharper case: the slot is filled, so discovery advertises `routes.auth`, and that request previously got a fabricated session. + +FROM → TO: a deployment without an auth service now gets `501 "Auth service not available — register @objectstack/plugin-auth to enable authentication"` on `/api/v1/auth/*` instead of a 200 carrying a session that never worked. Install `@objectstack/plugin-auth` (it is in the default `os serve` preset), or treat the absence as production already required — the 200 never produced a session the identity path accepted, so no working flow depended on it. Front-ends that mocked auth through this fallback should mock at the HTTP client layer or with an MSW handler, as `objectui` already does. diff --git a/docs/adr/0115-plugin-dev-assembly-not-stub-table.md b/docs/adr/0115-plugin-dev-assembly-not-stub-table.md index 328a395014..5b8cb2394f 100644 --- a/docs/adr/0115-plugin-dev-assembly-not-stub-table.md +++ b/docs/adr/0115-plugin-dev-assembly-not-stub-table.md @@ -115,7 +115,7 @@ Three facts changed between ratification and execution, all shrinking the work: 1. **#4086's merged form deleted nothing.** It gated the six dispatcher domains on `handlerReady` and explicitly deferred "should the fabricators keep occupying slots" to this ADR's Tier A. So `ai` / `automation` / `notification` join Tier A's deletion list here — the Context table's "retired" row described their HTTP surface (gated to empty-slot answers since #4086), not their registration. 2. **#4089 closed at the source.** #4082 had already given core's five fallbacks their `__serviceInfo` self-descriptions, so Tier B's core half was done before PR-2 existed; what remained of Tier B was only plugin-dev's `metadata` copy and the four wrappers. 3. **With the core half gone, the PR-1/PR-2 boundary lost its reason** (Tier B no longer reached into core), and the maintainer directed completing all remaining work at once. The implementation therefore landed as one PR: Tiers A+B+C, the D6 guard, the D4 assembly auto-wire, and the D7 docs convergence — the full end state above, under a single FROM → TO changeset narrative. -4. **#4126 landed the D2 security trio + D6 guard as a first subset while the full PR was in flight.** Its choices are canonical where they overlap: the escape hatch is `OS_ALLOW_DEV_PLUGIN` (not the longer name this ADR first wrote), the guard is a module-level `assertNotProduction()`, and an empty security slot gets one loud boot-log warn ("RBAC, row-level security and field masking are NOT enforced") instead of silence. It also filed #4113 — the dispatcher's `/auth` domain carries its own mock fallback in `packages/runtime` — as the remaining fabricator OUTSIDE plugin-dev; retiring plugin-dev's `auth` stub neither worsens nor fixes that path (both the stub and the runtime mock fabricate a 200; neither yields a session the identity resolver accepts), so #4113 stays the one place that class of fake survives. +4. **#4126 landed the D2 security trio + D6 guard as a first subset while the full PR was in flight.** Its choices are canonical where they overlap: the escape hatch is `OS_ALLOW_DEV_PLUGIN` (not the longer name this ADR first wrote), the guard is a module-level `assertNotProduction()`, and an empty security slot gets one loud boot-log warn ("RBAC, row-level security and field masking are NOT enforced") instead of silence. It also filed #4113 — the dispatcher's `/auth` domain carries its own mock fallback in `packages/runtime` — as the remaining fabricator OUTSIDE plugin-dev; retiring plugin-dev's `auth` stub neither worsens nor fixes that path (both the stub and the runtime mock fabricate a 200; neither yields a session the identity resolver accepts), so #4113 stays the one place that class of fake survives. *(Update: #4113 is now closed too — the `/auth` mock is deleted and an unserved `auth` slot answers 501. That makes this ADR's rule hold platform-wide rather than only inside plugin-dev, and it was the one member of the class that shipped to production rather than to a dev assembly. 501 rather than the 404 an empty optional slot usually takes, following `/i18n`: the route is mounted, the implementation behind it is not, and 404 would misdescribe that.)* ## Consequences diff --git a/packages/runtime/src/domain-handler-registry.test.ts b/packages/runtime/src/domain-handler-registry.test.ts index 344012b41a..3128210dfb 100644 --- a/packages/runtime/src/domain-handler-registry.test.ts +++ b/packages/runtime/src/domain-handler-registry.test.ts @@ -638,11 +638,17 @@ describe('HttpDispatcher extracted domains (PR-7: auth/ai)', () => { expect(handleRequest).toHaveBeenCalledTimes(1); }); - it('/auth mock fallback serves sign-up when no auth service is registered', async () => { + // [#4113] Was: "mock fallback serves sign-up when no auth service is + // registered" — asserting a 200 whose `session.token` matched + // /^mock_token_/. That mock is retired; an empty slot now answers 501 and + // mints nothing. Routed through `dispatch` (not the domain body directly) + // so the whole registry path is covered. + it('/auth answers 501 — and no session — when no auth service is registered', async () => { const result = await makeDispatcher().dispatch('POST', '/auth/sign-up/email', { email: 'a@b.c', name: 'A' }, {}, {} as any); - expect(result.response?.status).toBe(200); - expect(result.response?.body?.user?.email).toBe('a@b.c'); - expect(result.response?.body?.session?.token).toMatch(/^mock_token_/); + expect(result.response?.status).toBe(501); + expect(result.response?.body?.user).toBeUndefined(); + expect(result.response?.body?.session).toBeUndefined(); + expect(JSON.stringify(result.response?.body ?? {})).not.toMatch(/mock_token_/); }); it('/ai/agents returns an empty list (not 404) when no AI service is configured', async () => { diff --git a/packages/runtime/src/domains/auth.ts b/packages/runtime/src/domains/auth.ts index a95a5ea451..941260f900 100644 --- a/packages/runtime/src/domains/auth.ts +++ b/packages/runtime/src/domains/auth.ts @@ -2,42 +2,14 @@ /** * `/auth` domain — extracted dispatcher body (ADR-0076 D11 step ③, PR-7). - * Bridges to the `auth` service's better-auth handler; when no auth service - * is registered (MSW / browser-only mock environments) a minimal mock - * fallback keeps core sign-up/sign-in/session flows from 404ing. + * Bridges to the `auth` service's contract handler. With no auth service + * registered the domain answers 501 — it never fabricates a session (#4113). */ import { CoreServiceName } from '@objectstack/spec/system'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; -/** - * Browser-safe UUID generator — prefers Web Crypto's `randomUUID`, falls back - * to an RFC 4122 v4 built from `crypto.getRandomValues` (available everywhere - * `randomUUID` might be missing, e.g. non-secure contexts). The legacy - * `Math.random()` fallback was a latent CodeQL js/insecure-randomness hit - * surfaced by the extraction — these ids feed mock session tokens, so use - * CSPRNG bytes regardless. - */ -function randomUUID(): string { - const c: Crypto | undefined = globalThis.crypto; - if (c && typeof c.randomUUID === 'function') { - return c.randomUUID(); - } - const bytes = new Uint8Array(16); - if (c && typeof c.getRandomValues === 'function') { - c.getRandomValues(bytes); - } else { - // No crypto at all (ancient runtime) — mock-only path; still avoid - // Math.random by deriving from the only entropy available. - for (let i = 0; i < 16; i++) bytes[i] = (Date.now() + i * 7919) & 0xff; - } - bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 - bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10 - const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; -} - export function createAuthDomain(deps: DomainHandlerDeps): DomainRoute { return { prefix: '/auth', @@ -64,78 +36,46 @@ export async function handleAuthRequest(deps: DomainHandlerDeps, path: string, m // the fabricated shape, not the declared one (the same test-side hole that // kept #4087 green, catalogued in #4127's last section). // - // Reading the contract also makes the branch reachable for the first time. + // Reading the contract also made this branch reachable for the first time. // The Hono adapter calls `handleRequest` itself and only falls through to // this dispatcher when no usable auth service answered, so nothing was - // silently served by the mock below in that deployment — but a host that - // reaches `handleAuth` directly WITH an auth service registered used to get - // `mockAuthFallback`'s `mock_` session instead of real authentication. - // It now gets the auth service. + // silently served by the since-retired mock in that deployment — but a host + // that reached `handleAuth` directly WITH an auth service registered used to + // get that mock's `mock_` session instead of real authentication. It + // now gets the auth service; #4113 removed the mock entirely (see below). const authService = await deps.getService(CoreServiceName.enum.auth); if (authService && typeof authService.handleRequest === 'function') { const response = await authService.handleRequest(context.request as Request); return { handled: true, result: response }; } - // 2. Mock fallback for MSW/test environments when no auth service is registered - const normalizedPath = path.replace(/^\/+/, ''); - return mockAuthFallback(normalizedPath, method, body); -} - -/** - * Provides mock auth responses for core better-auth endpoints when - * AuthPlugin is not loaded (e.g. MSW/browser-only environments). - * This ensures registration/sign-in flows do not 404 in mock mode. - */ -function mockAuthFallback(path: string, method: string, body: any): HttpDispatcherResult { - const m = method.toUpperCase(); - const MOCK_SESSION_EXPIRY_MS = 86_400_000; // 24 hours - - // POST sign-up/email - if ((path === 'sign-up/email' || path === 'register') && m === 'POST') { - const id = `mock_${randomUUID()}`; - return { - handled: true, - response: { - status: 200, - body: { - user: { id, name: body?.name || 'Mock User', email: body?.email || 'mock@test.local', emailVerified: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, - session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + MOCK_SESSION_EXPIRY_MS).toISOString() }, - }, - }, - }; - } - - // POST sign-in/email or login - if ((path === 'sign-in/email' || path === 'login') && m === 'POST') { - const id = `mock_${randomUUID()}`; - return { - handled: true, - response: { - status: 200, - body: { - user: { id, name: 'Mock User', email: body?.email || 'mock@test.local', emailVerified: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, - session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + MOCK_SESSION_EXPIRY_MS).toISOString() }, - }, - }, - }; - } - - // GET get-session - if (path === 'get-session' && m === 'GET') { - return { - handled: true, - response: { status: 200, body: { session: null, user: null } }, - }; - } - - // POST sign-out - if (path === 'sign-out' && m === 'POST') { - return { - handled: true, - response: { status: 200, body: { success: true } }, - }; - } - - return { handled: false }; + // 2. No auth service — 501, never a fabricated session (#4113). + // + // This used to answer `POST /auth/sign-in/email` (and sign-up, get-session, + // sign-out) with 200 and a `mock_` user + a 24-hour `mock_token_*` + // session, for ANY email and ANY password — the password was never read. + // It shipped in `packages/runtime`, not behind a dev-only plugin, and it + // gated on nothing but "is the slot empty", so `os serve --preset minimal` + // and any embedder without plugin-auth got it. Not a bypass — no session + // store backs the token, so `resolve-execution-context.ts` still resolves + // anonymous and `shouldDenyAnonymous` still denies — but it told the client + // the one thing a server must never lie about: that it had authenticated + // someone. Its own justification ("MSW/browser-only environments") had no + // consumer in this repo or in `objectui`, whose auth tests mock at the HTTP + // client layer; only two tests pinned it, and they pinned the mock itself. + // + // ADR-0115 retired this whole class inside plugin-dev; this was the last + // member, and the only one that shipped to production. Its lineage — the + // #3891 analytics shim, #4000's dev stub, #4058/#4086's three, #4126's + // security trio — was retired the same way: deleted, not flagged. + // + // 501, not 404, following `/i18n` — the nearest precedent in shape (a core + // capability, a dispatcher-owned domain, an optional plugin behind it, and + // a route discovery already declines to advertise when the slot is empty). + // The route IS mounted here; what is missing is the implementation behind + // it, which is what 501 states and 404 would misdescribe. It also keeps + // faith with the one true observation the mock was built on — that a bare + // 404 on sign-in sends the operator hunting for a routing bug — without + // the lie it used to answer that concern. + return { handled: true, response: deps.error('Auth service not available — register @objectstack/plugin-auth to enable authentication', 501) }; } diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 514744402d..09736afcaa 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -961,74 +961,92 @@ describe('HttpDispatcher', () => { expect(mockAuth.handleRequest).toHaveBeenCalled(); }); - it('should fallback to mock auth when async auth service has no handleRequest', async () => { + // [#4113] Was: "should fallback to mock auth when async auth + // service has no handleRequest", asserting 200 + a fabricated user. + // A wrong-shaped occupant is the sharpest case for the retired + // mock — the slot is FILLED, so discovery advertises `routes.auth` + // and reports auth available, while the request got a fabricated + // session. It now takes the same 501 an empty slot takes. + it('answers 501 when the async auth service does not implement the contract', async () => { (kernel as any).getService = vi.fn().mockResolvedValue({}); const result = await dispatcher.handleAuth('/login', 'POST', { email: 'test@example.com' }, { request: {} }); expect(result.handled).toBe(true); - // Falls through to mock auth fallback (sign-in behavior) - expect(result.response?.status).toBe(200); - expect(result.response?.body?.user).toBeDefined(); + expect(result.response?.status).toBe(501); + expect(result.response?.body?.user).toBeUndefined(); + expect(result.response?.body?.session).toBeUndefined(); }); - it('should return unhandled when auth service not registered and no legacy match', async () => { + // [#4113] Was: "should return unhandled …" — with the mock gone + // every /auth path answers 501 rather than falling through, so the + // domain gives one answer for "no auth here" instead of two that + // differ by path. + it('answers 501 for any auth path when no service is registered', async () => { (kernel as any).getService = vi.fn().mockResolvedValue(null); (kernel as any).services = new Map(); const result = await dispatcher.handleAuth('/profile', 'GET', {}, { request: {} }); - expect(result.handled).toBe(false); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(501); }); }); - describe('handleAuth mock fallback (MSW/test mode)', () => { + // [#4113] The mock this block used to pin is GONE. It answered + // sign-up/sign-in/get-session/sign-out with 200 + a fabricated user and + // a 24h `mock_token_*` session for ANY email and ANY password (the + // password was never read), from `packages/runtime` — not a dev-only + // plugin — gated on nothing but an empty `auth` slot. ADR-0115 retired + // this class inside plugin-dev; this was its last member and the only + // one that shipped to production. + describe('no auth service: 501, never a fabricated session (#4113)', () => { beforeEach(() => { - // No auth service — simulates MSW/mock mode (kernel as any).getService = vi.fn().mockResolvedValue(null); (kernel as any).services = new Map(); }); - it('should mock sign-up/email endpoint', async () => { - const result = await dispatcher.handleAuth('/sign-up/email', 'POST', { email: 'test@example.com', name: 'Test' }, { request: {} }); - expect(result.handled).toBe(true); - expect(result.response?.status).toBe(200); - expect(result.response?.body.user).toBeDefined(); - expect(result.response?.body.user.email).toBe('test@example.com'); - expect(result.response?.body.session).toBeDefined(); - }); + // Every path the mock used to answer 200 on. Parametrized so a + // re-introduction of any single one fails with its own name. + const FORMERLY_MOCKED = [ + ['/sign-up/email', 'POST', { email: 'test@example.com', name: 'Test' }], + ['/register', 'POST', { email: 'test@example.com' }], + ['/sign-in/email', 'POST', { email: 'test@example.com', password: 'anything' }], + ['/login', 'POST', { email: 'test@example.com', password: 'anything' }], + ['/get-session', 'GET', {}], + ['/sign-out', 'POST', {}], + ] as const; - it('should mock sign-in/email endpoint', async () => { - const result = await dispatcher.handleAuth('/sign-in/email', 'POST', { email: 'test@example.com' }, { request: {} }); + it.each(FORMERLY_MOCKED)('%s %s answers 501 with no session', async (path, method, body) => { + const result = await dispatcher.handleAuth(path, method, body, { request: {} }); expect(result.handled).toBe(true); - expect(result.response?.status).toBe(200); - expect(result.response?.body.user).toBeDefined(); - expect(result.response?.body.session).toBeDefined(); + expect(result.response?.status).toBe(501); + // The point of the issue: no user, no session, no token, ever. + const serialized = JSON.stringify(result.response?.body ?? {}); + expect(serialized).not.toContain('mock_'); + expect(result.response?.body?.user).toBeUndefined(); + expect(result.response?.body?.session).toBeUndefined(); }); - it('should mock get-session endpoint', async () => { - const result = await dispatcher.handleAuth('/get-session', 'GET', {}, { request: {} }); - expect(result.handled).toBe(true); - expect(result.response?.status).toBe(200); - expect(result.response?.body).toEqual({ session: null, user: null }); + it('names the remedy rather than just refusing', async () => { + const result = await dispatcher.handleAuth('/sign-in/email', 'POST', { email: 'a@b.c' }, { request: {} }); + expect(JSON.stringify(result.response?.body)).toContain('plugin-auth'); }); - it('should mock sign-out endpoint', async () => { - const result = await dispatcher.handleAuth('/sign-out', 'POST', {}, { request: {} }); - expect(result.handled).toBe(true); - expect(result.response?.status).toBe(200); - expect(result.response?.body).toEqual({ success: true }); + // The honest answer must not depend on the credentials — a 501 that + // varied by password would be an oracle. + it('answers identically for any credentials', async () => { + const a = await dispatcher.handleAuth('/sign-in/email', 'POST', { email: 'real@user.com', password: 'correct' }, { request: {} }); + const b = await dispatcher.handleAuth('/sign-in/email', 'POST', { email: 'nobody@nowhere', password: '' }, { request: {} }); + expect(a.response?.status).toBe(b.response?.status); + expect(JSON.stringify(a.response?.body)).toBe(JSON.stringify(b.response?.body)); }); - it('should mock login fallback when no auth service registered', async () => { - const result = await dispatcher.handleAuth('/login', 'POST', { email: 'test@example.com' }, { request: {} }); + it('still delegates to a registered auth service instead of 501ing', async () => { + const handleRequest = vi.fn().mockResolvedValue({ ok: true }); + (kernel as any).getService = vi.fn().mockResolvedValue({ handleRequest }); + const result = await dispatcher.handleAuth('/sign-in/email', 'POST', { email: 'a@b.c' }, { request: {} }); expect(result.handled).toBe(true); - expect(result.response?.status).toBe(200); - expect(result.response?.body.user).toBeDefined(); - expect(result.response?.body.session).toBeDefined(); - }); - - it('should return unhandled for unknown auth path in mock mode', async () => { - const result = await dispatcher.handleAuth('/unknown', 'GET', {}, { request: {} }); - expect(result.handled).toBe(false); + expect(handleRequest).toHaveBeenCalledTimes(1); + expect(result.response?.status).toBeUndefined(); }); }); From 87787acaae72efb5ae18a80717b5d1e14eb1d11e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 14:12:55 +0000 Subject: [PATCH 2/2] fix(runtime): lower the auth.ts hand-built-response ledger to 0, and retire the docs section describing the mock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the deletion left behind, both caught after the fact — the route-envelope ledger by CI, the docs by the drift check. `scripts/check-route-envelope.mjs` declared `handBuilt: 4` for `domains/auth.ts`, exempting the mock's four better-auth-shaped bodies (`{ user, session }`, `{ session: null, user: null }`, `{ success: true }`) from BaseResponseSchema on the grounds that better-auth's client parses ITS shapes. That was a sound exemption for a real bridge — but those four bodies were the MOCK's, not the bridge's: the real service returns its own `Response` as `result`, built nowhere in this file. With the mock gone the count is 0 and the exemption goes with it, which is the ledger working exactly as intended (`found 0, declared 4` — it refuses to let a stale allowance sit there). `content/docs/permissions/authentication.mdx` carried a "Mock Fallback Endpoints" table documenting all six fabricating endpoints as a feature, and told readers it "ensures that registration and sign-in flows do not return 404 errors in MSW/browser-only environments". It now documents the 501, says why the mock was removed, and points anyone who relied on it at the two real options: mock at the HTTP client layer (what the console does), or load AuthPlugin, which needs no HTTP server. Verified: pnpm lint clean; all ten check:* scripts pass (the CI job that failed runs check:org-identifier, check:authz-resolver and check:route-envelope alongside eslint — all green now). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018UNGqBQcdJ2RYHtWgntJ9B --- content/docs/permissions/authentication.mdx | 23 ++++++++++----------- scripts/check-route-envelope.mjs | 11 ++++++---- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index 2093f35325..58f95f3d36 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -1068,22 +1068,21 @@ await kernel.bootstrap(); > ⚠️ **Warning:** The secret above is for **local development only**. In production, always use a strong random secret from an environment variable (`process.env.OS_AUTH_SECRET`). -### Mock Fallback Endpoints +### No auth service? `/auth/*` answers 501 — it never fabricates a session -When no auth service handler is registered and the legacy broker login is unavailable, `HttpDispatcher.handleAuth()` automatically provides mock responses for: +The dispatcher used to carry a **mock fallback**: with no auth service in the slot, `sign-up/email`, `register`, `sign-in/email`, `login`, `get-session` and `sign-out` all answered `200` with a made-up user and a 24-hour `mock_token_*` session — for any email and any password, which was never read. -| Endpoint | Method | Description | -|:---|:---:|:---| -| `sign-up/email` | POST | Returns mock user + session | -| `sign-in/email` | POST | Returns mock user + session | -| `login` | POST | Legacy login — returns mock user + session | -| `register` | POST | Alias for sign-up | -| `get-session` | GET | Returns `{ session: null, user: null }` | -| `sign-out` | POST | Returns `{ success: true }` | +**That is removed** ([#4113](https://github.com/objectstack-ai/objectstack/issues/4113)). An unserved `auth` slot now answers: -This ensures that registration and sign-in flows do not return 404 errors in MSW/browser-only environments. +``` +501 Auth service not available — register @objectstack/plugin-auth to enable authentication +``` + +It was never an authentication bypass — no session store backed that token, so the identity path still resolved anonymous and anonymous data access was still denied. The problem was that it told the client it had authenticated someone when it had not, while discovery simultaneously reported `auth: unavailable` and advertised no `routes.auth`. A capability the runtime does not have must not be advertised by pretending to serve it (ADR-0076 D12, ADR-0115). + +A service registered in the slot but not implementing the contract's `handleRequest` takes the same 501. -> **Note:** In server mode with AuthPlugin loaded, the auth service handler takes priority and the mock fallback is never reached. The mock fallback only activates when AuthPlugin is not loaded (e.g. browser-only Console/MSW builds where `better-auth` is unavailable). +**If you were relying on the mock** for a browser-only or MSW build: mock at the HTTP client layer, or with an MSW handler in your own test setup, which is what the console does. Load `AuthPlugin` (it needs no HTTP server — see above) and the real service answers instead. ## Next Steps diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index 34d8421054..90689102fd 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -227,10 +227,13 @@ const DISPATCHER_DOMAINS = { }, // Kind 3 — a foreign wire format, all four in the mock/fallback path. - 'auth.ts': { - handBuilt: 4, - note: "bridges better-auth, whose client parses ITS shapes (`{ user, session }`, `{ session: null, user: null }`, `{ success: true }`) — BaseResponseSchema does not govern them", - }, + // [#4113] Was 4, for the mock fallback's better-auth-shaped bodies + // (`{ user, session }`, `{ session: null, user: null }`, `{ success: true }`). + // That mock is deleted — it fabricated a 200 + a 24h session for any + // credentials — so the exemption it needed is gone with it. What remains is + // the real service's own `Response`, returned as `result` rather than built + // here, plus one `deps.error` 501. + 'auth.ts': { handBuilt: 0 }, // Kind 2 — the `{ agents: [] }` fallback moved onto `deps.success` in #4053; // what remains is the passthrough of the AI service's own result.