From 46d68e1ff495cd29b558db095b2815f6ace068cd Mon Sep 17 00:00:00 2001 From: "shaggy (mosaic-dev box)" Date: Sun, 9 Aug 2026 20:00:22 -0500 Subject: [PATCH 1/5] feat(web): add same-origin SPA authentication --- .../src/app/auth/provider/[provider]/page.tsx | 70 +++++--- apps/web/src/lib/api.spec.ts | 57 +++++++ apps/web/src/lib/api.ts | 4 +- apps/web/src/lib/auth-client.spec.ts | 29 ++++ apps/web/src/lib/auth-client.ts | 3 +- apps/web/src/lib/auth-redirect.spec.ts | 25 +++ apps/web/src/lib/auth-redirect.ts | 21 +++ apps/web/src/lib/socket.spec.ts | 52 ++++++ apps/web/src/lib/socket.ts | 4 +- apps/web/src/lib/sso-providers.test.ts | 48 ------ apps/web/src/lib/sso-providers.ts | 53 ------ apps/web/src/routes.tsx | 27 +++- apps/web/src/spa/guards.spec.tsx | 135 ++++++++++++++++ apps/web/src/spa/guards.tsx | 21 ++- apps/web/src/spa/pages/login.tsx | 152 ++++++++++++++++++ apps/web/src/spa/pages/register.tsx | 116 +++++++++++++ apps/web/src/spa/pages/sso-callback.spec.tsx | 90 +++++++++++ apps/web/src/spa/pages/sso-callback.tsx | 91 +++++++++++ apps/web/src/spa/routes.spec.tsx | 21 +++ docs/scratchpads/webui-p2-data-auth.md | 111 +++++++++++++ 20 files changed, 987 insertions(+), 143 deletions(-) create mode 100644 apps/web/src/lib/api.spec.ts create mode 100644 apps/web/src/lib/auth-client.spec.ts create mode 100644 apps/web/src/lib/auth-redirect.spec.ts create mode 100644 apps/web/src/lib/auth-redirect.ts create mode 100644 apps/web/src/lib/socket.spec.ts delete mode 100644 apps/web/src/lib/sso-providers.test.ts delete mode 100644 apps/web/src/lib/sso-providers.ts create mode 100644 apps/web/src/spa/guards.spec.tsx create mode 100644 apps/web/src/spa/pages/login.tsx create mode 100644 apps/web/src/spa/pages/register.tsx create mode 100644 apps/web/src/spa/pages/sso-callback.spec.tsx create mode 100644 apps/web/src/spa/pages/sso-callback.tsx create mode 100644 docs/scratchpads/webui-p2-data-auth.md diff --git a/apps/web/src/app/auth/provider/[provider]/page.tsx b/apps/web/src/app/auth/provider/[provider]/page.tsx index b4eebce4..d2771843 100644 --- a/apps/web/src/app/auth/provider/[provider]/page.tsx +++ b/apps/web/src/app/auth/provider/[provider]/page.tsx @@ -3,41 +3,56 @@ import Link from 'next/link'; import { useEffect, useState } from 'react'; import { useParams, useSearchParams } from 'next/navigation'; +import { api } from '@/lib/api'; +import { resolveAuthCallbackURL } from '@/lib/auth-redirect'; import { signIn } from '@/lib/auth-client'; -import { getSsoProvider } from '@/lib/sso-providers'; +import type { SsoProviderDiscovery } from '@/lib/sso'; export default function AuthProviderRedirectPage(): React.ReactElement { const params = useParams<{ provider: string }>(); const searchParams = useSearchParams(); const providerId = typeof params.provider === 'string' ? params.provider : ''; - const provider = getSsoProvider(providerId); - const callbackURL = searchParams.get('callbackURL') ?? '/chat'; + const requestedCallbackURL = searchParams.get('callbackURL'); + const [providerName, setProviderName] = useState(null); const [error, setError] = useState(null); useEffect(() => { - const currentProvider = provider; - - if (!currentProvider) { - setError('Unknown SSO provider.'); - return; - } - - if (!currentProvider.enabled) { - setError(`${currentProvider.buttonLabel} is not enabled in this deployment.`); - return; - } - - const activeProvider = currentProvider; let cancelled = false; async function redirectToProvider(): Promise { - const result = await signIn.oauth2({ - providerId: activeProvider.id, - callbackURL, - }); + try { + const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin); + const providers = await api('/api/sso/providers'); + if (cancelled) return; - if (!cancelled && result?.error) { - setError(result.error.message ?? `${activeProvider.buttonLabel} sign in failed.`); + const provider = providers.find((candidate) => candidate.id === providerId); + if (!provider) { + setError('Unknown SSO provider.'); + return; + } + + setProviderName(provider.name); + if (!provider.configured) { + setError(`${provider.name} is not enabled in this deployment.`); + return; + } + if (provider.loginMode !== 'oidc') { + setError(`${provider.name} is not available for OIDC sign in.`); + return; + } + + const result = await signIn.oauth2({ + providerId: provider.id, + callbackURL, + }); + + if (!cancelled && result?.error) { + setError(result.error.message ?? `${provider.name} sign in failed.`); + } + } catch (caught: unknown) { + if (!cancelled) { + setError(caught instanceof Error ? caught.message : 'Unable to start single sign-on.'); + } } } @@ -46,19 +61,22 @@ export default function AuthProviderRedirectPage(): React.ReactElement { return () => { cancelled = true; }; - }, [callbackURL, provider]); + }, [providerId, requestedCallbackURL]); return (

Single sign-on

- {provider - ? `Redirecting you to ${provider.buttonLabel.replace('Continue with ', '')}...` + {providerName + ? `Redirecting you to ${providerName}...` : 'Preparing your sign-in request...'}

{error ? ( -
+

{error}

{ + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('fetches the supplied relative path with credentials and a JSON body', async () => { + const fetchMock = vi.fn(); + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + await expect( + api<{ ok: boolean }>('/api/projects', { + method: 'POST', + body: { name: 'Mosaic' }, + }), + ).resolves.toEqual({ ok: true }); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith( + '/api/projects', + expect.objectContaining({ + method: 'POST', + credentials: 'include', + body: JSON.stringify({ name: 'Mosaic' }), + headers: expect.objectContaining({ + Accept: 'application/json', + 'Content-Type': 'application/json', + }), + }), + ); + }); + + it('throws the gateway JSON error with its statusCode', async () => { + const fetchMock = vi.fn(); + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ statusCode: 403, message: 'Forbidden' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + await expect(api('/api/admin/users')).rejects.toMatchObject({ + name: 'Error', + message: 'Forbidden', + statusCode: 403, + }); + }); +}); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index c9d270f6..df092e26 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,5 +1,3 @@ -const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242'; - export interface ApiRequestInit extends Omit { body?: unknown; } @@ -25,7 +23,7 @@ export async function api(path: string, init?: ApiRequestInit): Promise { headers['Content-Type'] = 'application/json'; } - const res = await fetch(`${GATEWAY_URL}${path}`, { + const res = await fetch(path, { credentials: 'include', ...rest, headers, diff --git a/apps/web/src/lib/auth-client.spec.ts b/apps/web/src/lib/auth-client.spec.ts new file mode 100644 index 00000000..cbf87f29 --- /dev/null +++ b/apps/web/src/lib/auth-client.spec.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +describe('auth client origin contract', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it('uses the same-origin BetterAuth mount at /api/auth', async () => { + const fetchMock = vi.fn(); + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ session: null, user: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const { authClient } = await import('./auth-client'); + await authClient.getSession(); + + expect(fetchMock).toHaveBeenCalledOnce(); + const firstCall = fetchMock.mock.calls.at(0); + expect(firstCall).toBeDefined(); + const requestURL = new URL(String(firstCall?.[0]), window.location.origin); + expect(requestURL.origin).toBe(window.location.origin); + expect(requestURL.pathname).toBe('/api/auth/get-session'); + }); +}); diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts index f04fe567..d9f94887 100644 --- a/apps/web/src/lib/auth-client.ts +++ b/apps/web/src/lib/auth-client.ts @@ -1,8 +1,9 @@ import { createAuthClient } from 'better-auth/react'; import { adminClient, genericOAuthClient } from 'better-auth/client/plugins'; +// The gateway and BetterAuth client both use /api/auth. Omitting baseURL keeps +// every browser request on the current origin in development and production. export const authClient = createAuthClient({ - baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242', plugins: [adminClient(), genericOAuthClient()], }); diff --git a/apps/web/src/lib/auth-redirect.spec.ts b/apps/web/src/lib/auth-redirect.spec.ts new file mode 100644 index 00000000..ba7170b2 --- /dev/null +++ b/apps/web/src/lib/auth-redirect.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { resolveAuthCallbackURL } from './auth-redirect'; + +const CURRENT_ORIGIN = 'https://mosaic.example'; + +describe('resolveAuthCallbackURL', () => { + it('preserves a canonical same-origin path with search and hash', () => { + expect(resolveAuthCallbackURL('/projects?view=active#current', CURRENT_ORIGIN)).toBe( + '/projects?view=active#current', + ); + }); + + it.each([ + null, + 'chat', + '//evil.example', + '/\\evil.example', + '/\n//evil.example', + '/\r//evil.example', + '/\t//evil.example', + 'https://evil.example/phish', + ])('falls back to chat for an unsafe callback target %#', (candidate) => { + expect(resolveAuthCallbackURL(candidate, CURRENT_ORIGIN)).toBe('/chat'); + }); +}); diff --git a/apps/web/src/lib/auth-redirect.ts b/apps/web/src/lib/auth-redirect.ts new file mode 100644 index 00000000..74b24d0b --- /dev/null +++ b/apps/web/src/lib/auth-redirect.ts @@ -0,0 +1,21 @@ +const DEFAULT_AUTH_CALLBACK_URL = '/chat'; + +/** + * Return a canonical same-origin path for post-auth navigation. + * + * Parsing before comparing origins rejects protocol-relative URLs, backslash + * variants, and control characters that the WHATWG parser normalizes away. + */ +export function resolveAuthCallbackURL(candidate: string | null, currentOrigin: string): string { + if (!candidate?.startsWith('/')) return DEFAULT_AUTH_CALLBACK_URL; + + try { + const expectedOrigin = new URL(currentOrigin).origin; + const resolved = new URL(candidate, expectedOrigin); + if (resolved.origin !== expectedOrigin) return DEFAULT_AUTH_CALLBACK_URL; + + return `${resolved.pathname}${resolved.search}${resolved.hash}`; + } catch { + return DEFAULT_AUTH_CALLBACK_URL; + } +} diff --git a/apps/web/src/lib/socket.spec.ts b/apps/web/src/lib/socket.spec.ts new file mode 100644 index 00000000..815b2472 --- /dev/null +++ b/apps/web/src/lib/socket.spec.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { ioMock } = vi.hoisted(() => ({ + ioMock: vi.fn(), +})); + +vi.mock('socket.io-client', () => ({ + io: ioMock, +})); + +import { destroySocket, getSocket } from './socket'; + +describe('chat socket', () => { + let disconnectHandler: (() => void) | undefined; + + beforeEach(() => { + disconnectHandler = undefined; + ioMock.mockReset(); + + const mockSocket = { + on: vi.fn((event: string, handler: () => void) => { + if (event === 'disconnect') disconnectHandler = handler; + return mockSocket; + }), + offAny: vi.fn(() => mockSocket), + disconnect: vi.fn(() => mockSocket), + }; + + ioMock.mockReturnValue(mockSocket); + }); + + afterEach(() => { + destroySocket(); + }); + + it('creates one same-origin /chat namespace socket until it disconnects', () => { + const first = getSocket(); + const second = getSocket(); + + expect(first).toBe(second); + expect(ioMock).toHaveBeenCalledOnce(); + expect(ioMock).toHaveBeenCalledWith('/chat', { + withCredentials: true, + autoConnect: false, + transports: ['websocket', 'polling'], + }); + + disconnectHandler?.(); + getSocket(); + expect(ioMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/lib/socket.ts b/apps/web/src/lib/socket.ts index 6d966c9c..66cf64f4 100644 --- a/apps/web/src/lib/socket.ts +++ b/apps/web/src/lib/socket.ts @@ -1,12 +1,10 @@ import { io, type Socket } from 'socket.io-client'; -const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242'; - let socket: Socket | null = null; export function getSocket(): Socket { if (!socket) { - socket = io(`${GATEWAY_URL}/chat`, { + socket = io('/chat', { withCredentials: true, autoConnect: false, transports: ['websocket', 'polling'], diff --git a/apps/web/src/lib/sso-providers.test.ts b/apps/web/src/lib/sso-providers.test.ts deleted file mode 100644 index 076d32be..00000000 --- a/apps/web/src/lib/sso-providers.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { getEnabledSsoProviders, getSsoProvider } from './sso-providers'; - -describe('sso-providers', () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it('returns the enabled providers in login button order', () => { - vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true'); - vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'true'); - - expect(getEnabledSsoProviders()).toEqual([ - { - id: 'workos', - buttonLabel: 'Continue with WorkOS', - description: 'Enterprise SSO via WorkOS', - enabled: true, - href: '/auth/provider/workos', - }, - { - id: 'keycloak', - buttonLabel: 'Continue with Keycloak', - description: 'Enterprise SSO via Keycloak', - enabled: true, - href: '/auth/provider/keycloak', - }, - ]); - }); - - it('marks disabled providers without exposing them in the enabled list', () => { - vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true'); - vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'false'); - - expect(getEnabledSsoProviders().map((provider) => provider.id)).toEqual(['workos']); - expect(getSsoProvider('keycloak')).toEqual({ - id: 'keycloak', - buttonLabel: 'Continue with Keycloak', - description: 'Enterprise SSO via Keycloak', - enabled: false, - href: '/auth/provider/keycloak', - }); - }); - - it('returns null for unknown providers', () => { - expect(getSsoProvider('authentik')).toBeNull(); - }); -}); diff --git a/apps/web/src/lib/sso-providers.ts b/apps/web/src/lib/sso-providers.ts deleted file mode 100644 index a9f42026..00000000 --- a/apps/web/src/lib/sso-providers.ts +++ /dev/null @@ -1,53 +0,0 @@ -export type SsoProviderId = 'workos' | 'keycloak'; - -export interface SsoProvider { - id: SsoProviderId; - buttonLabel: string; - description: string; - enabled: boolean; - href: string; -} - -const PROVIDER_METADATA: Record> = { - workos: { - id: 'workos', - buttonLabel: 'Continue with WorkOS', - description: 'Enterprise SSO via WorkOS', - }, - keycloak: { - id: 'keycloak', - buttonLabel: 'Continue with Keycloak', - description: 'Enterprise SSO via Keycloak', - }, -}; - -export function getEnabledSsoProviders(): SsoProvider[] { - return (Object.keys(PROVIDER_METADATA) as SsoProviderId[]) - .map((providerId) => getSsoProvider(providerId)) - .filter((provider): provider is SsoProvider => provider?.enabled === true); -} - -export function getSsoProvider(providerId: string): SsoProvider | null { - if (!isSsoProviderId(providerId)) { - return null; - } - - return { - ...PROVIDER_METADATA[providerId], - enabled: isSsoProviderEnabled(providerId), - href: `/auth/provider/${providerId}`, - }; -} - -function isSsoProviderId(value: string): value is SsoProviderId { - return value === 'workos' || value === 'keycloak'; -} - -function isSsoProviderEnabled(providerId: SsoProviderId): boolean { - switch (providerId) { - case 'workos': - return process.env['NEXT_PUBLIC_WORKOS_ENABLED'] === 'true'; - case 'keycloak': - return process.env['NEXT_PUBLIC_KEYCLOAK_ENABLED'] === 'true'; - } -} diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index a6f6535e..20556bad 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -1,14 +1,33 @@ -import { createBrowserRouter, Navigate, type RouteObject } from 'react-router-dom'; +import type { ReactElement } from 'react'; +import { createBrowserRouter, Navigate, Outlet, type RouteObject } from 'react-router-dom'; +import { LoginPage } from '@/spa/pages/login'; +import { RegisterPage } from '@/spa/pages/register'; +import { SsoCallbackPage } from '@/spa/pages/sso-callback'; import { AuthGuard, GuestGuard } from '@/spa/guards'; import { Placeholder } from '@/spa/placeholder'; +function GuestLayout(): ReactElement { + return ( +
+
+ +
+
+ ); +} + export const routes: RouteObject[] = [ { element: , children: [ - { path: '/login', element: }, - { path: '/register', element: }, - { path: '/auth/provider/:provider', element: }, + { + element: , + children: [ + { path: '/login', element: }, + { path: '/register', element: }, + { path: '/auth/provider/:provider', element: }, + ], + }, ], }, { diff --git a/apps/web/src/spa/guards.spec.tsx b/apps/web/src/spa/guards.spec.tsx new file mode 100644 index 00000000..162bbed6 --- /dev/null +++ b/apps/web/src/spa/guards.spec.tsx @@ -0,0 +1,135 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +const { useSessionMock } = vi.hoisted(() => ({ + useSessionMock: vi.fn(), +})); + +vi.mock('@/lib/auth-client', () => ({ + useSession: useSessionMock, +})); + +import { AuthGuard, GuestGuard } from './guards'; + +interface RenderedRouter { + container: HTMLDivElement; + router: ReturnType; +} + +const mountedRoots: Root[] = []; + +beforeAll(() => { + Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { + configurable: true, + value: true, + }); +}); + +afterAll(() => { + Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); +}); + +async function renderRouter( + routeObjects: RouteObject[], + initialEntry: string, +): Promise { + const container = document.createElement('div'); + document.body.append(container); + const router = createMemoryRouter(routeObjects, { initialEntries: [initialEntry] }); + const root = createRoot(container); + mountedRoots.push(root); + + await act(async () => { + root.render(); + }); + + return { container, router }; +} + +afterEach(async () => { + for (const root of mountedRoots.splice(0)) { + await act(async () => { + root.unmount(); + }); + } + document.body.replaceChildren(); + useSessionMock.mockReset(); +}); + +const guestRoutes: RouteObject[] = [ + { + path: '/login', + element: , + children: [{ index: true, element:

Guest page

}], + }, + { path: '/chat', element:

Chat page

}, +]; + +const authenticatedRoutes: RouteObject[] = [ + { + path: '/chat', + element: , + children: [{ index: true, element:

Private page

}], + }, + { path: '/login', element:

Login page

}, +]; + +describe('GuestGuard', () => { + it('renders the guest outlet while session lookup is pending', async () => { + useSessionMock.mockReturnValue({ data: null, isPending: true }); + + const view = await renderRouter(guestRoutes, '/login'); + + expect(view.container.textContent).toContain('Guest page'); + expect(view.router.state.location.pathname).toBe('/login'); + }); + + it('renders the guest outlet when no session exists', async () => { + useSessionMock.mockReturnValue({ data: null, isPending: false }); + + const view = await renderRouter(guestRoutes, '/login'); + + expect(view.container.textContent).toContain('Guest page'); + expect(view.router.state.location.pathname).toBe('/login'); + }); + + it('redirects an authenticated session to chat', async () => { + useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false }); + + const view = await renderRouter(guestRoutes, '/login'); + + expect(view.container.textContent).toContain('Chat page'); + expect(view.router.state.location.pathname).toBe('/chat'); + }); +}); + +describe('AuthGuard', () => { + it('renders the existing loading treatment while session lookup is pending', async () => { + useSessionMock.mockReturnValue({ data: null, isPending: true }); + + const view = await renderRouter(authenticatedRoutes, '/chat'); + + expect(view.container.textContent).toContain('Loading...'); + expect(view.router.state.location.pathname).toBe('/chat'); + }); + + it('redirects an unauthenticated visitor to login', async () => { + useSessionMock.mockReturnValue({ data: null, isPending: false }); + + const view = await renderRouter(authenticatedRoutes, '/chat'); + + expect(view.container.textContent).toContain('Login page'); + expect(view.router.state.location.pathname).toBe('/login'); + }); + + it('renders the authenticated outlet when a session exists', async () => { + useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false }); + + const view = await renderRouter(authenticatedRoutes, '/chat'); + + expect(view.container.textContent).toContain('Private page'); + expect(view.router.state.location.pathname).toBe('/chat'); + }); +}); diff --git a/apps/web/src/spa/guards.tsx b/apps/web/src/spa/guards.tsx index 2395b4f5..71ea0fea 100644 --- a/apps/web/src/spa/guards.tsx +++ b/apps/web/src/spa/guards.tsx @@ -1,12 +1,23 @@ import type { ReactElement } from 'react'; -import { Outlet } from 'react-router-dom'; - -// P1 shells: session-aware redirects arrive with the reworked auth client in P2. +import { Navigate, Outlet } from 'react-router-dom'; +import { useSession } from '@/lib/auth-client'; export function GuestGuard(): ReactElement { - return ; + const { data: session } = useSession(); + + return session ? : ; } export function AuthGuard(): ReactElement { - return ; + const { data: session, isPending } = useSession(); + + if (isPending) { + return ( +
+
Loading...
+
+ ); + } + + return session ? : ; } diff --git a/apps/web/src/spa/pages/login.tsx b/apps/web/src/spa/pages/login.tsx new file mode 100644 index 00000000..ae377c44 --- /dev/null +++ b/apps/web/src/spa/pages/login.tsx @@ -0,0 +1,152 @@ +import { useEffect, useState, type FormEvent, type ReactElement } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { SsoProviderButtons } from '@/components/auth/sso-provider-buttons'; +import { api } from '@/lib/api'; +import { authClient, signIn } from '@/lib/auth-client'; +import type { SsoProviderDiscovery } from '@/lib/sso'; + +export function LoginPage(): ReactElement { + const navigate = useNavigate(); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [ssoProviders, setSsoProviders] = useState([]); + const [ssoLoadingProviderId, setSsoLoadingProviderId] = useState< + SsoProviderDiscovery['id'] | null + >(null); + + useEffect(() => { + let active = true; + + void api('/api/sso/providers').then( + (providers) => { + if (active) setSsoProviders(providers.filter((provider) => provider.configured)); + }, + () => { + if (active) setSsoProviders([]); + }, + ); + + return () => { + active = false; + }; + }, []); + + async function handleSubmit(event: FormEvent): Promise { + event.preventDefault(); + setError(null); + setLoading(true); + + const form = new FormData(event.currentTarget); + const email = String(form.get('email') ?? ''); + const password = String(form.get('password') ?? ''); + + try { + const result = await signIn.email({ email, password }); + + if (result.error) { + setError(result.error.message ?? 'Sign in failed'); + return; + } + + navigate('/chat', { replace: true }); + } catch (caught: unknown) { + setError(caught instanceof Error ? caught.message : 'Sign in failed'); + } finally { + setLoading(false); + } + } + + async function handleSsoSignIn(providerId: SsoProviderDiscovery['id']): Promise { + setError(null); + setSsoLoadingProviderId(providerId); + + try { + const result = await authClient.signIn.oauth2({ + providerId, + callbackURL: '/chat', + newUserCallbackURL: '/chat', + }); + + if (result.error) { + setError(result.error.message ?? `Sign in with ${providerId} failed`); + setSsoLoadingProviderId(null); + } + } catch (caught: unknown) { + setError(caught instanceof Error ? caught.message : `Sign in with ${providerId} failed`); + setSsoLoadingProviderId(null); + } + } + + return ( +
+

Sign in

+

Sign in to your Mosaic account

+ + {error ? ( +
+ {error} +
+ ) : null} + +
+
+ + +
+ +
+ + +
+ + +
+ + { + void handleSsoSignIn(providerId); + }} + /> + +

+ Don't have an account?{' '} + + Sign up + +

+
+ ); +} diff --git a/apps/web/src/spa/pages/register.tsx b/apps/web/src/spa/pages/register.tsx new file mode 100644 index 00000000..4275cf8e --- /dev/null +++ b/apps/web/src/spa/pages/register.tsx @@ -0,0 +1,116 @@ +import { useState, type FormEvent, type ReactElement } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { signUp } from '@/lib/auth-client'; + +export function RegisterPage(): ReactElement { + const navigate = useNavigate(); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function handleSubmit(event: FormEvent): Promise { + event.preventDefault(); + setError(null); + setLoading(true); + + const form = new FormData(event.currentTarget); + const name = String(form.get('name') ?? ''); + const email = String(form.get('email') ?? ''); + const password = String(form.get('password') ?? ''); + + try { + const result = await signUp.email({ name, email, password }); + + if (result.error) { + setError(result.error.message ?? 'Registration failed'); + return; + } + + navigate('/chat', { replace: true }); + } catch (caught: unknown) { + setError(caught instanceof Error ? caught.message : 'Registration failed'); + } finally { + setLoading(false); + } + } + + return ( +
+

Create account

+

Get started with Mosaic

+ + {error ? ( +
+ {error} +
+ ) : null} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +

+ Already have an account?{' '} + + Sign in + +

+
+ ); +} diff --git a/apps/web/src/spa/pages/sso-callback.spec.tsx b/apps/web/src/spa/pages/sso-callback.spec.tsx new file mode 100644 index 00000000..4a034860 --- /dev/null +++ b/apps/web/src/spa/pages/sso-callback.spec.tsx @@ -0,0 +1,90 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { apiMock, oauth2Mock } = vi.hoisted(() => ({ + apiMock: vi.fn(), + oauth2Mock: vi.fn(), +})); + +vi.mock('@/lib/api', () => ({ + api: apiMock, +})); + +vi.mock('@/lib/auth-client', () => ({ + signIn: { oauth2: oauth2Mock }, +})); + +import { SsoCallbackPage } from './sso-callback'; + +const mountedRoots: Root[] = []; + +beforeAll(() => { + Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { + configurable: true, + value: true, + }); +}); + +beforeEach(() => { + apiMock.mockResolvedValue([ + { + id: 'authentik', + name: 'Authentik', + protocols: ['oidc'], + configured: true, + loginMode: 'oidc', + callbackPath: '/api/auth/oauth2/callback/authentik', + teamSync: { enabled: false, claim: null }, + samlFallback: { configured: false, loginUrl: null }, + warnings: [], + }, + ]); + oauth2Mock.mockResolvedValue({ data: null, error: null }); +}); + +afterEach(async () => { + for (const root of mountedRoots.splice(0)) { + await act(async () => { + root.unmount(); + }); + } + document.body.replaceChildren(); + apiMock.mockReset(); + oauth2Mock.mockReset(); +}); + +afterAll(() => { + Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); +}); + +describe('SsoCallbackPage', () => { + it('rejects a control-character callback that normalizes to an external origin', async () => { + const router = createMemoryRouter( + [ + { + path: '/auth/provider/:provider', + element: , + }, + ], + { + initialEntries: ['/auth/provider/authentik?callbackURL=%2F%0A%2F%2Fevil.example'], + }, + ); + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + mountedRoots.push(root); + + await act(async () => { + root.render(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(oauth2Mock).toHaveBeenCalledWith({ + providerId: 'authentik', + callbackURL: '/chat', + }); + }); +}); diff --git a/apps/web/src/spa/pages/sso-callback.tsx b/apps/web/src/spa/pages/sso-callback.tsx new file mode 100644 index 00000000..fc5ccba7 --- /dev/null +++ b/apps/web/src/spa/pages/sso-callback.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState, type ReactElement } from 'react'; +import { Link, useParams, useSearchParams } from 'react-router-dom'; +import { api } from '@/lib/api'; +import { resolveAuthCallbackURL } from '@/lib/auth-redirect'; +import { signIn } from '@/lib/auth-client'; +import type { SsoProviderDiscovery } from '@/lib/sso'; + +export function SsoCallbackPage(): ReactElement { + const { provider: providerId = '' } = useParams<'provider'>(); + const [searchParams] = useSearchParams(); + const requestedCallbackURL = searchParams.get('callbackURL'); + const [providerName, setProviderName] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function redirectToProvider(): Promise { + try { + const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin); + const providers = await api('/api/sso/providers'); + if (cancelled) return; + + const provider = providers.find((candidate) => candidate.id === providerId); + if (!provider) { + setError('Unknown SSO provider.'); + return; + } + + setProviderName(provider.name); + if (!provider.configured) { + setError(`${provider.name} is not enabled in this deployment.`); + return; + } + if (provider.loginMode !== 'oidc') { + setError(`${provider.name} is not available for OIDC sign in.`); + return; + } + + const result = await signIn.oauth2({ + providerId: provider.id, + callbackURL, + }); + + if (!cancelled && result?.error) { + setError(result.error.message ?? `${provider.name} sign in failed.`); + } + } catch (caught: unknown) { + if (!cancelled) { + setError(caught instanceof Error ? caught.message : 'Unable to start single sign-on.'); + } + } + } + + void redirectToProvider(); + + return () => { + cancelled = true; + }; + }, [providerId, requestedCallbackURL]); + + return ( +
+

Single sign-on

+

+ {providerName + ? `Redirecting you to ${providerName}...` + : 'Preparing your sign-in request...'} +

+ + {error ? ( +
+

{error}

+ + Return to login + +
+ ) : ( +
+ If the redirect does not start automatically, return to the login page and try again. +
+ )} +
+ ); +} diff --git a/apps/web/src/spa/routes.spec.tsx b/apps/web/src/spa/routes.spec.tsx index 02a74e5a..c6207771 100644 --- a/apps/web/src/spa/routes.spec.tsx +++ b/apps/web/src/spa/routes.spec.tsx @@ -1,6 +1,8 @@ +import { isValidElement } from 'react'; import { describe, expect, it } from 'vitest'; import type { RouteObject } from 'react-router-dom'; import { routes } from '@/routes'; +import { Placeholder } from '@/spa/placeholder'; function collectPaths(routeObjects: RouteObject[]): string[] { return routeObjects.flatMap((route) => [ @@ -9,6 +11,15 @@ function collectPaths(routeObjects: RouteObject[]): string[] { ]); } +function findRoute(routeObjects: RouteObject[], path: string): RouteObject | undefined { + for (const route of routeObjects) { + if (route.path === path) return route; + const nested = route.children ? findRoute(route.children, path) : undefined; + if (nested) return nested; + } + return undefined; +} + describe('SPA route table', () => { it('covers every v1 parity route from the Phase P RFC', () => { expect(collectPaths(routes).sort()).toEqual( @@ -34,4 +45,14 @@ describe('SPA route table', () => { expect(guestPaths).not.toContain('/chat'); expect(authPaths).toContain('/chat'); }); + + it.each(['/login', '/register', '/auth/provider/:provider'])( + 'renders a real guest page instead of the P1 placeholder at %s', + (path) => { + const element = findRoute(routes, path)?.element; + expect(isValidElement(element)).toBe(true); + if (!isValidElement(element)) throw new Error(`Missing route element for ${path}`); + expect(element.type).not.toBe(Placeholder); + }, + ); }); diff --git a/docs/scratchpads/webui-p2-data-auth.md b/docs/scratchpads/webui-p2-data-auth.md new file mode 100644 index 00000000..52230948 --- /dev/null +++ b/docs/scratchpads/webui-p2-data-auth.md @@ -0,0 +1,111 @@ +# WebUI Phase P — P2 Data + Auth Scratchpad + +**Task ID:** WEBUI-P2 +**Tracking ref:** stacked on PR #1143 (`feat/webui-p1-vite-skeleton`); no separate issue specified in the author brief +**Branch:** `feat/webui-p2-data-auth` +**Started:** 2026-08-09 +**Role:** P2 author worker (must not modify `docs/TASKS.md`) + +## Original tasking + +> Read `/home/jwoltje/briefs/P2-brief.md` and execute it fully in `/home/jwoltje/src/stack-p1` on branch `feat/webui-p2-data-auth`. Run every listed verification gate, commit locally only, and do not push. + +Container path mapping: + +- Brief: `/home/jwoltje/distrobox-homes/mosaic-dev/briefs/P2-brief.md` +- Repo: `/home/jwoltje/distrobox-homes/mosaic-dev/src/stack-p1` + +## Objective + +Make the P1 Vite SPA authenticate against the Mosaic Gateway by same-origin relative paths, replace guest-route placeholders with real login/register/SSO callback pages, and enforce guest/authenticated route guards without modifying the parallel Next app tree except for unavoidable shared-library import fixes. + +## Scope and invariants + +- All SPA HTTP and Socket.IO access remains origin-relative (`/api/...`, `/api/auth/...`, `/chat`). +- No `NEXT_PUBLIC_*`, `VITE_*` origin setting, or hard-coded `http://localhost:14242` under `apps/web/src/`. +- Verify the Gateway BetterAuth mount path from source before choosing auth-client configuration. +- Delete the legacy static SSO-provider discovery module and test; runtime `/api/sso/providers` is canonical. +- Preserve the Next build while adding React Router guest pages and session guards. +- Never push; stage named files only; do not touch the modified `.mosaic/orchestrator/session.lock`. + +## Plan + +1. Inspect P1 SPA structure, shared libraries, legacy Next auth pages/components, Gateway auth mount, and current test setup. +2. Add/adjust tests first for relative API behavior, auth-client origin configuration, relative Socket.IO singleton behavior, and all guard session states; run focused tests and capture expected RED failures. +3. Implement shared-library relative networking and remove the legacy SSO-provider module/imports. +4. Port login, register, and provider callback pages to React Router and wire routes. +5. Implement session-aware guest/auth guards and satisfy focused tests. +6. Run focused tests, all brief verification gates, invariant searches, and an independent code/security review; remediate and re-run affected gates. +7. Update this scratchpad with evidence, stage named files only, and commit locally. + +## Testing strategy + +- TDD is required because this increment changes authentication/session behavior. +- Primary situational evidence: jsdom router guard state tests plus guest-page/auth-flow contract tests already present or added as needed. +- Baseline gates: web tests, Vite build, Next build, typecheck, lint, and root format check exactly as listed in the brief. +- Browser automation is not required by the brief for P2; if used, it will be headless only. + +## Budget + +No explicit user token cap was provided. Working soft cap: **30K tokens**, derived from a multi-file auth/frontend increment with tests, dual builds, review, and remediation. Keep implementation within the brief; no unrelated refactors or dependencies. + +## Progress / evidence + +- [x] Loaded mission protocol, active MVP manifest/scratchpad, top-level tasks, PRD, relevant frontend/auth/testing/type/review guides, and matching skills. +- [x] Resolved host paths to the Distrobox-mapped repo and brief. +- [x] Confirmed branch `feat/webui-p2-data-auth`, stacked at P1 commit `068d0f9b`. +- [x] Source and Gateway mount inspection complete. +- [x] RED tests observed. +- [x] Implementation complete. +- [x] Independent review complete and findings remediated. +- [x] Verification gates complete. +- [ ] Local commit created. + +### Source decisions + +- Gateway source mounts BetterAuth at `/api/auth/` in `apps/gateway/src/auth/auth.controller.ts`; `packages/auth/src/auth.ts` configures `basePath: '/api/auth'`. +- BetterAuth 1.5.5 defaults its browser client to `/api/auth` when `baseURL` is omitted. `src/lib/auth-client.ts` therefore omits `baseURL`, preserving same-origin behavior without encoding any gateway origin. +- The only `src/app/` edit is the transitional Next provider redirect page. Deleting `src/lib/sso-providers.ts` required that mechanical consumer migration; it now uses the same runtime `/api/sso/providers` discovery and callback sanitizer as the SPA. +- GuestGuard pending behavior intentionally follows the approved brief: if no session object exists (including pending), render ``; only a present session redirects to `/chat`. AuthGuard alone renders the specified pending treatment. + +### TDD evidence + +- Initial focused run: 9 expected failures, proving absolute API/auth/socket origins, P1 guest placeholders, and missing guard redirects/loading behavior. +- After implementation: focused contract suite 15/15 passed. +- Security remediation RED: `%2F%0A%2F%2Fevil.example` reached `signIn.oauth2` as an external-normalizing callback target before the fix. +- Security remediation GREEN: shared `resolveAuthCallbackURL` unit suite 9/9 plus SPA callback regression 1/1 passed; both SPA and Next consumers use the shared helper. + +### Independent review + +- Primary Codex wrappers could not run because `jq` is absent; direct Codex fallback then failed authentication with HTTP 401. No review result was claimed from those attempts. +- Independent Claude Sonnet code review found no implementation-scope blocker, one pending-state UX suggestion, and one pre-existing `api.ts` type-assertion suggestion. The pending-state suggestion was rejected because it contradicts the brief's explicit GuestGuard contract; the API cleanup is outside P2's preserve-contract scope. +- Independent Claude Sonnet security review found a high-severity control-character open redirect in the callback prefix check shared by the newly ported SPA logic and transitional Next consumer. +- Remediation centralized WHATWG URL parsing plus exact current-origin comparison in `src/lib/auth-redirect.ts`, returns only path/search/hash, and added the RED-first regression above. +- Fresh code re-review: `approve`, 0 blockers, 0 should-fix findings. +- Fresh security re-review: `low`, 0 critical/high/medium/low findings. + +### Documentation checklist disposition + +- `docs/PRD.md` exists and P2 aligns to FR-8 / AC-7 authentication requirements. +- No Gateway endpoint, DTO, permission, or API schema changed; existing `/api/auth/*` and `/api/sso/providers` contracts are consumed unchanged, so OpenAPI/API-index updates are not applicable. +- Route paths were already present in the P1 route table; P2 replaces placeholders without changing site-map navigation, so `docs/SITEMAP.md` is unchanged. +- User/admin auth behavior is parity with the still-live Next implementation, not a new workflow. Implementation decisions, failure behavior, testing, and migration compatibility are documented here; no publishing action is in scope. +- Documentation remains in-repo; no generated publishing output was created. + +### Final verification evidence + +Run fresh after remediation from the locations required by the brief: + +- `apps/web: pnpm test` — PASS: `Test Files 9 passed (9)`; `Tests 27 passed (27)`. +- `apps/web: pnpm build:vite` — PASS: `✓ 113 modules transformed`; `✓ built in 712ms`. +- `apps/web: pnpm build` — PASS: `✓ Compiled successfully in 4.4s`; 10/10 static pages generated; dynamic provider/project routes retained. +- `apps/web: pnpm typecheck` — PASS: `tsc --noEmit` exited 0. +- `apps/web: pnpm lint` — PASS: `eslint src` exited 0. +- Root `pnpm format:check` — PASS: `All matched files use Prettier code style!`. +- Invariant scan — PASS: no `NEXT_PUBLIC_*`, `VITE_*`, `GATEWAY_URL`, `http://localhost:14242`, or `sso-providers` references under `apps/web/src`. +- `git diff --check` — PASS. + +## Risks / blockers + +- `.mosaic/orchestrator/session.lock` is modified by the active harness and must remain unstaged. +- P2 changes shared `src/lib/` modules consumed by both Vite and Next, so the Next build is a required compatibility gate. -- 2.54.0 From a4861c221fb165b99a3834115033c230d526a16e Mon Sep 17 00:00:00 2001 From: "shaggy (mosaic-dev box)" Date: Sun, 9 Aug 2026 20:02:22 -0500 Subject: [PATCH 2/5] docs(scratchpad): record WebUI P2 verification --- docs/scratchpads/webui-p2-data-auth.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/scratchpads/webui-p2-data-auth.md b/docs/scratchpads/webui-p2-data-auth.md index 52230948..5fec0d29 100644 --- a/docs/scratchpads/webui-p2-data-auth.md +++ b/docs/scratchpads/webui-p2-data-auth.md @@ -59,7 +59,7 @@ No explicit user token cap was provided. Working soft cap: **30K tokens**, deriv - [x] Implementation complete. - [x] Independent review complete and findings remediated. - [x] Verification gates complete. -- [ ] Local commit created. +- [x] Local implementation commit created: `46d68e1f`. ### Source decisions @@ -97,13 +97,14 @@ No explicit user token cap was provided. Working soft cap: **30K tokens**, deriv Run fresh after remediation from the locations required by the brief: - `apps/web: pnpm test` — PASS: `Test Files 9 passed (9)`; `Tests 27 passed (27)`. -- `apps/web: pnpm build:vite` — PASS: `✓ 113 modules transformed`; `✓ built in 712ms`. -- `apps/web: pnpm build` — PASS: `✓ Compiled successfully in 4.4s`; 10/10 static pages generated; dynamic provider/project routes retained. +- `apps/web: pnpm build:vite` — PASS: `✓ 113 modules transformed`; `✓ built in 565ms`. +- `apps/web: pnpm build` — PASS: `✓ Compiled successfully in 4.6s`; 10/10 static pages generated; dynamic provider/project routes retained. - `apps/web: pnpm typecheck` — PASS: `tsc --noEmit` exited 0. - `apps/web: pnpm lint` — PASS: `eslint src` exited 0. - Root `pnpm format:check` — PASS: `All matched files use Prettier code style!`. - Invariant scan — PASS: no `NEXT_PUBLIC_*`, `VITE_*`, `GATEWAY_URL`, `http://localhost:14242`, or `sso-providers` references under `apps/web/src`. - `git diff --check` — PASS. +- All six required gates above were repeated successfully after implementation commit `46d68e1f`, proving the exact committed source tree. ## Risks / blockers -- 2.54.0 From e16c08aa9f868ce8b529a227c716a14fe27e525a Mon Sep 17 00:00:00 2001 From: "shaggy (mosaic-dev box)" Date: Sun, 9 Aug 2026 20:20:30 -0500 Subject: [PATCH 3/5] test(web): align jsdom abort signals with Node --- apps/web/src/test/setup.spec.ts | 14 ++++++++ apps/web/src/test/setup.ts | 23 ++++++++++++ apps/web/vitest.config.ts | 2 ++ docs/scratchpads/webui-p2-data-auth.md | 50 ++++++++++++++++++++++++++ 4 files changed, 89 insertions(+) create mode 100644 apps/web/src/test/setup.spec.ts create mode 100644 apps/web/src/test/setup.ts diff --git a/apps/web/src/test/setup.spec.ts b/apps/web/src/test/setup.spec.ts new file mode 100644 index 00000000..7d370530 --- /dev/null +++ b/apps/web/src/test/setup.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; + +describe('Vitest abort-controller realm', () => { + it('provides a global signal accepted by Node native Request', () => { + const controller = new AbortController(); + const request = new Request('https://mosaic.invalid/navigation', { + signal: controller.signal, + }); + + expect(request.signal).toBeInstanceOf(AbortSignal); + controller.abort(); + expect(request.signal.aborted).toBe(true); + }); +}); diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts new file mode 100644 index 00000000..751a20d7 --- /dev/null +++ b/apps/web/src/test/setup.ts @@ -0,0 +1,23 @@ +import { transferableAbortController } from 'node:util'; + +// jsdom installs realm-local abort constructors while Node's undici Request +// remains native. React Router passes a global AbortSignal to Request, so both +// constructors must come from Node's native realm during tests. +const nativeController = transferableAbortController(); +const nativeAbortController = nativeController.constructor; +const nativeAbortSignal = nativeController.signal.constructor; + +for (const target of [globalThis, window]) { + Object.defineProperties(target, { + AbortController: { + configurable: true, + writable: true, + value: nativeAbortController, + }, + AbortSignal: { + configurable: true, + writable: true, + value: nativeAbortSignal, + }, + }); +} diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 3166181f..effe0be1 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -14,6 +14,8 @@ export default defineConfig({ test: { globals: true, environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + isolate: true, exclude: ['e2e/**', 'node_modules/**'], }, }); diff --git a/docs/scratchpads/webui-p2-data-auth.md b/docs/scratchpads/webui-p2-data-auth.md index 5fec0d29..a1355926 100644 --- a/docs/scratchpads/webui-p2-data-auth.md +++ b/docs/scratchpads/webui-p2-data-auth.md @@ -110,3 +110,53 @@ Run fresh after remediation from the locations required by the brief: - `.mosaic/orchestrator/session.lock` is modified by the active harness and must remain unstaged. - P2 changes shared `src/lib/` modules consumed by both Vite and Next, so the Next build is a required compatibility gate. + +## Remediation 1 — independent Node 26 verification failure + +**Correction received:** 2026-08-09 + +The orchestrator rejected the P2 verification claim after an independent run under Node 26.4.0 produced 2 failed redirect tests and 2 unhandled errors. React Router passed jsdom's realm-local `AbortSignal` to Node 26's native undici `Request`, which rejects non-native signals. Production guard behavior is correct and must not change. + +### Remediation constraints + +- Preserve both redirect assertions and all `guards.tsx` behavior; no skips, weakening, or test deletion. +- Add the smallest Vitest environment repair so global `AbortController` / `AbortSignal` are constructors accepted by native undici `Request`. +- Run all six required gates, commit named files locally without pushing, leave `.mosaic/orchestrator/session.lock` untouched, then run `apps/web: pnpm test` as the final worktree action. +- Completion requires zero failed tests, zero test errors, and zero unhandled rejections. + +### Remediation plan + +1. Reproduce under Node 26.4.0 if an ephemeral matching runtime is available. +2. Add a Vitest `setupFiles` module deriving Node-native abort constructors from `node:util` and register it in `apps/web/vitest.config.ts`. +3. Add a direct regression assertion that a global controller's signal is accepted by native `Request`, while retaining the existing redirect behavior tests unchanged. +4. Run focused Node 22 and Node 26 tests, independent review, all required gates, and local commit(s). +5. After every edit/commit/status check is complete, run `pnpm test` from `apps/web` as the last command. + +### Remediation implementation and evidence + +- Reproduced under ephemeral Node `v26.4.0`: `Test Files 1 failed (1)`, `Tests 2 failed | 4 passed (6)`, `Errors 2 errors`, with the exact undici `AbortSignal` realm rejection from the remediation brief. +- Added `apps/web/src/test/setup.ts`, registered through `vitest.config.ts#setupFiles`. It derives Node-native abort constructors from the built-in `node:util.transferableAbortController()` and aligns both `globalThis` and jsdom `window`; it does not replace `Request`, `Response`, or `fetch`. +- Explicitly pinned Vitest `isolate: true` so the test-only global constructors cannot leak between test-file environments. +- Added `src/test/setup.spec.ts`, which proves a global controller signal is accepted by Node's native `Request` and that abort propagation remains functional. +- Existing `guards.spec.tsx` and production `guards.tsx` remain unchanged. +- Focused Node 26.4.0 remediation run: `Test Files 2 passed (2)` and `Tests 7 passed (7)`, with zero errors/unhandled rejections. +- Preliminary full Node 26.4.0 run: `Test Files 9 passed (9)` and `Tests 27 passed (27)`, with zero errors/unhandled rejections before the direct setup regression was added. +- No dependency was added; `node:util` is a Node built-in. + +### Remediation independent review + +- First code review: approve, 0 blockers, 0 should-fix; suggested a direct Request regression and version-neutral comment. +- First security/integrity review: low risk; suggested pinning test isolation explicitly. +- All suggestions were applied. +- Fresh code re-review: approve, 0 blockers, 0 should-fix, no findings. +- Fresh security/integrity re-review: low risk, 0 findings. + +### Remediation pre-commit gate evidence + +- Node 26.4.0 `pnpm test`: `Test Files 10 passed (10)`; `Tests 28 passed (28)`; zero errors and zero unhandled rejections. +- `pnpm typecheck`: `tsc --noEmit` exited 0. +- `pnpm build:vite`: `✓ 113 modules transformed`; `✓ built in 960ms`. +- `pnpm build` (Next): `✓ Compiled successfully in 4.8s`; static pages generated 10/10. +- `pnpm lint`: `eslint src` exited 0. +- Root `pnpm format:check`: `All matched files use Prettier code style!`. +- Final post-commit non-test gates and final-action Node 26 `pnpm test` remain required before reporting completion. -- 2.54.0 From 0aef432052c14ade71c4ca9bb13ca7c6e5953c9d Mon Sep 17 00:00:00 2001 From: "shaggy (mosaic-dev box)" Date: Sun, 9 Aug 2026 20:21:53 -0500 Subject: [PATCH 4/5] docs(scratchpad): record P2 remediation evidence --- docs/scratchpads/webui-p2-data-auth.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/scratchpads/webui-p2-data-auth.md b/docs/scratchpads/webui-p2-data-auth.md index a1355926..869af475 100644 --- a/docs/scratchpads/webui-p2-data-auth.md +++ b/docs/scratchpads/webui-p2-data-auth.md @@ -160,3 +160,13 @@ The orchestrator rejected the P2 verification claim after an independent run und - `pnpm lint`: `eslint src` exited 0. - Root `pnpm format:check`: `All matched files use Prettier code style!`. - Final post-commit non-test gates and final-action Node 26 `pnpm test` remain required before reporting completion. + +### Remediation committed verification + +- Implementation commit: `e16c08aa` (`test(web): align jsdom abort signals with Node`). +- Post-commit `pnpm typecheck`: `tsc --noEmit` exited 0. +- Post-commit `pnpm build:vite`: `✓ 113 modules transformed`; `✓ built in 323ms`. +- Post-commit `pnpm build` (Next): `✓ Compiled successfully in 4.6s`; static pages generated 10/10. +- Post-commit `pnpm lint`: `eslint src` exited 0. +- Post-commit root `pnpm format:check`: `All matched files use Prettier code style!`. +- Final Node 26 `pnpm test` will be the last worktree action and its verbatim output will be reported to the orchestrator. -- 2.54.0 From 90cf286a09fa682dc6b2a9ddd72d88c60a720352 Mon Sep 17 00:00:00 2001 From: "shaggy (mosaic-dev box)" Date: Sun, 9 Aug 2026 20:43:27 -0500 Subject: [PATCH 5/5] fix(web): reject protocol-relative auth callbacks --- apps/web/src/lib/auth-redirect.spec.ts | 5 +++++ apps/web/src/lib/auth-redirect.ts | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/auth-redirect.spec.ts b/apps/web/src/lib/auth-redirect.spec.ts index ba7170b2..2dca7ff8 100644 --- a/apps/web/src/lib/auth-redirect.spec.ts +++ b/apps/web/src/lib/auth-redirect.spec.ts @@ -14,6 +14,11 @@ describe('resolveAuthCallbackURL', () => { null, 'chat', '//evil.example', + '/..//evil.com', + '/..//evil.com/x', + '/./..//evil.com', + '/../..//evil.com', + '/foo/..//evil.com', '/\\evil.example', '/\n//evil.example', '/\r//evil.example', diff --git a/apps/web/src/lib/auth-redirect.ts b/apps/web/src/lib/auth-redirect.ts index 74b24d0b..e9725ac0 100644 --- a/apps/web/src/lib/auth-redirect.ts +++ b/apps/web/src/lib/auth-redirect.ts @@ -12,7 +12,9 @@ export function resolveAuthCallbackURL(candidate: string | null, currentOrigin: try { const expectedOrigin = new URL(currentOrigin).origin; const resolved = new URL(candidate, expectedOrigin); - if (resolved.origin !== expectedOrigin) return DEFAULT_AUTH_CALLBACK_URL; + if (resolved.origin !== expectedOrigin || resolved.pathname.startsWith('//')) { + return DEFAULT_AUTH_CALLBACK_URL; + } return `${resolved.pathname}${resolved.search}${resolved.hash}`; } catch { -- 2.54.0