From c0500a9b9b9b3aa6331f2cd7ecc0f87608a580ad Mon Sep 17 00:00:00 2001 From: Mosaic fleet seat mos Date: Fri, 6 Nov 2026 09:55:29 -0600 Subject: [PATCH] salvage: uncommitted work from /home/jwoltje/distrobox-homes/mosaic-dev/src/stack-p5a (disk cleanup #824, 2026-11-06) --- apps/gateway/package.json | 7 +- apps/gateway/src/auth/auth.controller.ts | 6 +- apps/gateway/src/auth/auth.module.ts | 2 + apps/gateway/src/chat/chat.gateway.ts | 7 +- apps/gateway/src/cors-origins.spec.ts | 94 ++ apps/gateway/src/cors-origins.ts | 27 + apps/gateway/src/main.ts | 40 +- apps/gateway/src/web-ui-bundle.spec.ts | 280 ++++++ apps/gateway/src/web-ui-containment.spec.ts | 547 ++++++++++++ apps/gateway/src/web-ui.spec.ts | 785 +++++++++++++++++ apps/gateway/src/web-ui.ts | 619 +++++++++++++ apps/web/index.html | 12 +- apps/web/public/theme-init.js | 7 + apps/web/src/routes.tsx | 62 +- apps/web/src/spa/guards.tsx | 40 +- apps/web/src/spa/layout/app-shell.spec.tsx | 561 ++++++++++++ apps/web/src/spa/layout/app-shell.tsx | 35 + apps/web/src/spa/layout/sidebar.tsx | 182 ++++ apps/web/src/spa/layout/topbar.tsx | 120 +++ apps/web/src/spa/pages/admin.spec.tsx | 238 +++++ apps/web/src/spa/pages/admin.tsx | 536 ++++++++++++ .../spa/pages/chat-error-boundary.spec.tsx | 7 +- apps/web/src/spa/pages/page-fixtures.ts | 28 + apps/web/src/spa/pages/projects.spec.tsx | 163 +++- apps/web/src/spa/pages/projects.tsx | 137 +++ .../pages/resource-route-boundaries.spec.tsx | 9 +- .../pages/resource-route-error-boundaries.tsx | 27 +- apps/web/src/spa/pages/settings.spec.tsx | 308 +++++++ apps/web/src/spa/pages/settings.tsx | 820 ++++++++++++++++++ apps/web/src/spa/routes.spec.tsx | 25 +- apps/web/src/test/setup.ts | 47 + apps/web/vite.config.ts | 5 + docker/gateway.Dockerfile | 20 + .../webui-p4-2-spa-settings-admin.md | 150 ++++ docs/scratchpads/webui-p4c-remediation.md | 42 + docs/scratchpads/webui-p4c-spa-app-shell.md | 72 ++ .../webui-p4d-spa-projects-active-mission.md | 74 ++ docs/scratchpads/webui-p5a-gateway-static.md | 156 ++++ docs/webui/PHASE-P-STRUCTURE.md | 13 + packages/auth/src/auth.ts | 6 +- packages/auth/src/index.ts | 1 + packages/auth/src/sso.ts | 16 +- .../mosaic/src/stages/gateway-config.spec.ts | 3 + packages/mosaic/src/stages/gateway-config.ts | 5 + packages/types/src/index.ts | 1 + packages/types/src/web-ui-routes.ts | 39 + pnpm-lock.yaml | 58 +- 47 files changed, 6359 insertions(+), 80 deletions(-) create mode 100644 apps/gateway/src/cors-origins.spec.ts create mode 100644 apps/gateway/src/cors-origins.ts create mode 100644 apps/gateway/src/web-ui-bundle.spec.ts create mode 100644 apps/gateway/src/web-ui-containment.spec.ts create mode 100644 apps/gateway/src/web-ui.spec.ts create mode 100644 apps/gateway/src/web-ui.ts create mode 100644 apps/web/public/theme-init.js create mode 100644 apps/web/src/spa/layout/app-shell.spec.tsx create mode 100644 apps/web/src/spa/layout/app-shell.tsx create mode 100644 apps/web/src/spa/layout/sidebar.tsx create mode 100644 apps/web/src/spa/layout/topbar.tsx create mode 100644 apps/web/src/spa/pages/admin.spec.tsx create mode 100644 apps/web/src/spa/pages/admin.tsx create mode 100644 apps/web/src/spa/pages/settings.spec.tsx create mode 100644 apps/web/src/spa/pages/settings.tsx create mode 100644 docs/scratchpads/webui-p4-2-spa-settings-admin.md create mode 100644 docs/scratchpads/webui-p4c-remediation.md create mode 100644 docs/scratchpads/webui-p4c-spa-app-shell.md create mode 100644 docs/scratchpads/webui-p4d-spa-projects-active-mission.md create mode 100644 docs/scratchpads/webui-p5a-gateway-static.md create mode 100644 packages/types/src/web-ui-routes.ts diff --git a/apps/gateway/package.json b/apps/gateway/package.json index 8e5819f99..b4e0f6dd2 100644 --- a/apps/gateway/package.json +++ b/apps/gateway/package.json @@ -20,14 +20,16 @@ }, "scripts": { "build": "tsc", - "dev": "tsx watch src/main.ts", + "dev": "pnpm --filter @mosaicstack/web build:vite && GATEWAY_WEB_UI_MODE=required tsx watch src/main.ts", + "start": "GATEWAY_WEB_UI_MODE=disabled node dist/main.js", "lint": "eslint src", "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", - "test": "vitest run --passWithNoTests" + "test": "pnpm --filter @mosaicstack/web build:vite && vitest run --passWithNoTests" }, "dependencies": { "@anthropic-ai/sdk": "^0.80.0", "@fastify/helmet": "^13.0.2", + "@fastify/static": "^9.0.0", "@mariozechner/pi-ai": "^0.65.0", "@mariozechner/pi-coding-agent": "^0.65.0", "@modelcontextprotocol/sdk": "^1.27.1", @@ -69,6 +71,7 @@ "jose": "^6.2.2", "node-cron": "^4.2.1", "openai": "^6.32.0", + "parse5": "^8.0.0", "postgres": "^3.4.8", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.0", diff --git a/apps/gateway/src/auth/auth.controller.ts b/apps/gateway/src/auth/auth.controller.ts index ed924d555..b6483fb58 100644 --- a/apps/gateway/src/auth/auth.controller.ts +++ b/apps/gateway/src/auth/auth.controller.ts @@ -3,11 +3,12 @@ import { toNodeHandler } from 'better-auth/node'; import type { Auth } from '@mosaicstack/auth'; import type { NestFastifyApplication } from '@nestjs/platform-fastify'; import { AUTH } from './auth.tokens.js'; +import { listGatewayCorsOrigins } from '../cors-origins.js'; export function mountAuthHandler(app: NestFastifyApplication): void { const auth = app.get(AUTH); const nodeHandler = toNodeHandler(auth); - const corsOrigin = process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000'; + const corsOrigins = listGatewayCorsOrigins(); const fastify = app.getHttpAdapter().getInstance(); @@ -27,9 +28,8 @@ export function mountAuthHandler(app: NestFastifyApplication): void { } const origin = req.raw.headers.origin; - const allowed = corsOrigin.split(',').map((o) => o.trim()); - if (origin && allowed.includes(origin)) { + if (origin && corsOrigins.includes(origin)) { reply.raw.setHeader('Access-Control-Allow-Origin', origin); reply.raw.setHeader('Access-Control-Allow-Credentials', 'true'); reply.raw.setHeader( diff --git a/apps/gateway/src/auth/auth.module.ts b/apps/gateway/src/auth/auth.module.ts index 83564c11d..79e5786ff 100644 --- a/apps/gateway/src/auth/auth.module.ts +++ b/apps/gateway/src/auth/auth.module.ts @@ -4,6 +4,7 @@ import type { Db } from '@mosaicstack/db'; import { DB } from '../database/database.module.js'; import { AUTH } from './auth.tokens.js'; import { SsoController } from './sso.controller.js'; +import { listGatewayCorsOrigins } from '../cors-origins.js'; @Global() @Module({ @@ -16,6 +17,7 @@ import { SsoController } from './sso.controller.js'; db, baseURL: process.env['BETTER_AUTH_URL'] ?? 'http://localhost:14242', secret: process.env['BETTER_AUTH_SECRET'], + trustedOrigins: listGatewayCorsOrigins(), }), inject: [DB], }, diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index b086d135a..51fc92205 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -33,6 +33,7 @@ import type { ChannelAttachmentDto, } from '@mosaicstack/types'; import type { ConversationHistoryMessage } from '../agent/agent.service.js'; +import { listGatewayCorsOrigins } from '../cors-origins.js'; import { RUNTIME_PROVIDER_AUDIT_SINK, RuntimeProviderService, @@ -216,10 +217,10 @@ function isChatSocketMessage(value: unknown): value is ChatSocketMessageDto { ); } +const chatCorsOrigins = listGatewayCorsOrigins(); + @WebSocketGateway({ - cors: { - origin: process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000', - }, + ...(chatCorsOrigins.length > 0 ? { cors: { origin: chatCorsOrigins } } : {}), namespace: '/chat', }) export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { diff --git a/apps/gateway/src/cors-origins.spec.ts b/apps/gateway/src/cors-origins.spec.ts new file mode 100644 index 000000000..04bdb8335 --- /dev/null +++ b/apps/gateway/src/cors-origins.spec.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { listGatewayCorsOrigins } from './cors-origins.js'; + +describe('listGatewayCorsOrigins', () => { + const originalValue = process.env['GATEWAY_CORS_ORIGIN']; + + afterEach(() => { + if (originalValue === undefined) delete process.env['GATEWAY_CORS_ORIGIN']; + else process.env['GATEWAY_CORS_ORIGIN'] = originalValue; + }); + + function setGatewayCorsOrigin(value: string | undefined): void { + if (value === undefined) delete process.env['GATEWAY_CORS_ORIGIN']; + else process.env['GATEWAY_CORS_ORIGIN'] = value; + } + + it('returns an empty list when GATEWAY_CORS_ORIGIN is unset', () => { + setGatewayCorsOrigin(undefined); + expect(listGatewayCorsOrigins()).toEqual([]); + }); + + it('returns an empty list when GATEWAY_CORS_ORIGIN is an empty string', () => { + setGatewayCorsOrigin(''); + expect(listGatewayCorsOrigins()).toEqual([]); + }); + + it('returns a single origin unchanged', () => { + setGatewayCorsOrigin('https://app.example.com'); + expect(listGatewayCorsOrigins()).toEqual(['https://app.example.com']); + }); + + // Pins the P5a parsing fix: pre-P5a main.ts passed the raw comma-separated + // string to enableCors as a single origin, so multi-origin values never + // matched a real Origin header. + it('splits a comma-separated list into distinct origins', () => { + setGatewayCorsOrigin('https://app.example.com,https://b.example.com'); + expect(listGatewayCorsOrigins()).toEqual(['https://app.example.com', 'https://b.example.com']); + }); + + it('trims surrounding whitespace from each entry', () => { + setGatewayCorsOrigin(' https://app.example.com , https://b.example.com '); + expect(listGatewayCorsOrigins()).toEqual(['https://app.example.com', 'https://b.example.com']); + }); + + it('drops the empty entry left by a trailing comma', () => { + setGatewayCorsOrigin('https://app.example.com,'); + expect(listGatewayCorsOrigins()).toEqual(['https://app.example.com']); + }); + + it('drops whitespace-only entries', () => { + setGatewayCorsOrigin('https://app.example.com, ,https://b.example.com'); + expect(listGatewayCorsOrigins()).toEqual(['https://app.example.com', 'https://b.example.com']); + }); + + // Gateway layers (REST CORS, the raw BetterAuth mount, the Socket.IO + // handshake) compare these entries literally, while BetterAuth + // trustedOrigins would treat a "*" or "?" entry as a wildcard. A glob entry + // therefore looks locked down to the browser-facing CORS layer while + // opening the CSRF/origin gate underneath it, so it must never parse. + describe('rejecting wildcard patterns', () => { + function expectRejectedEntry(value: string, offendingEntry: string): void { + setGatewayCorsOrigin(value); + let thrown: unknown; + try { + listGatewayCorsOrigins(); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(Error); + const message = (thrown as Error).message; + expect(message).toContain('GATEWAY_CORS_ORIGIN'); + expect(message).toContain(offendingEntry); + } + + it('rejects a wildcard-only entry, naming the variable and the entry', () => { + expectRejectedEntry('*', '*'); + }); + + it('rejects a wildcard-domain entry', () => { + expectRejectedEntry('https://*.example.com', 'https://*.example.com'); + }); + + it('rejects a single-character wildcard entry', () => { + expectRejectedEntry('https://app.example.com:300?', 'https://app.example.com:300?'); + }); + + it('rejects a wildcard mixed into an otherwise valid list', () => { + expectRejectedEntry( + 'https://app.example.com,*.example.net,https://b.example.com', + '*.example.net', + ); + }); + }); +}); diff --git a/apps/gateway/src/cors-origins.ts b/apps/gateway/src/cors-origins.ts new file mode 100644 index 000000000..a170e53c9 --- /dev/null +++ b/apps/gateway/src/cors-origins.ts @@ -0,0 +1,27 @@ +/** + * Gateway CORS origins are matched literally by every consumer layer — REST + * CORS (`enableCors`), the raw BetterAuth mount, and the Socket.IO handshake + * all compare entries as exact strings. BetterAuth `trustedOrigins` would + * instead treat a `*` or `?` entry as a wildcard, so a glob entry makes the + * browser-facing CORS layer look locked down while the origin/CSRF gate + * underneath it accepts every host. Globs are therefore never valid input: + * refuse them here, at the one seam all three consumers share, so startup + * fails closed instead of serving two incompatible interpretations. + */ +export function listGatewayCorsOrigins(): string[] { + const origins = (process.env['GATEWAY_CORS_ORIGIN'] ?? '') + .split(',') + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0); + + for (const origin of origins) { + if (origin.includes('*') || origin.includes('?')) { + throw new Error( + `GATEWAY_CORS_ORIGIN entry "${origin}" is invalid: origins are matched literally and wildcard patterns are not supported. ` + + 'List each origin explicitly, for example GATEWAY_CORS_ORIGIN=https://app.example.com.', + ); + } + } + + return origins; +} diff --git a/apps/gateway/src/main.ts b/apps/gateway/src/main.ts index d1eacf5f3..58945782c 100644 --- a/apps/gateway/src/main.ts +++ b/apps/gateway/src/main.ts @@ -6,6 +6,7 @@ import { NestFactory } from '@nestjs/core'; import { Logger, ValidationPipe } from '@nestjs/common'; import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; import helmet from '@fastify/helmet'; +import type { FastifyInstance } from 'fastify'; import { listSsoStartupWarnings } from '@mosaicstack/auth'; import { loadConfig } from '@mosaicstack/config'; import { AppModule } from './app.module.js'; @@ -14,6 +15,8 @@ import { mountMcpHandler } from './mcp/mcp.controller.js'; import { McpService } from './mcp/mcp.service.js'; import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage'; import { resolveGatewayConfigPath } from './env.js'; +import { listGatewayCorsOrigins } from './cors-origins.js'; +import { registerWebUiForMode, resolveWebDistPath } from './web-ui.js'; async function bootstrap(): Promise { const logger = new Logger('Bootstrap'); @@ -45,13 +48,30 @@ async function bootstrap(): Promise { new FastifyAdapter({ bodyLimit: 1_048_576 }), ); - app.enableCors({ - origin: process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000', - credentials: true, - methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], - }); + const corsOrigins = listGatewayCorsOrigins(); + if (corsOrigins.length > 0) { + app.enableCors({ + origin: corsOrigins, + credentials: true, + methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + }); + } else { + logger.warn( + 'GATEWAY_CORS_ORIGIN is unset or empty: CORS is disabled for REST, auth, and the chat socket. ' + + 'Set GATEWAY_CORS_ORIGIN to a comma-separated origin allowlist if any client is served off-origin.', + ); + } - await app.register(helmet as never, { contentSecurityPolicy: false }); + await app.register(helmet as never, { + contentSecurityPolicy: { + directives: { + // Defaults remain enabled. The Vite SPA currently renders no external + // avatar surface; add https: when that parity feature joins its route graph. + 'img-src': ["'self'", 'data:'], + 'upgrade-insecure-requests': null, + }, + }, + }); app.useGlobalPipes( new ValidationPipe({ whitelist: true, @@ -63,6 +83,14 @@ async function bootstrap(): Promise { mountAuthHandler(app); mountMcpHandler(app, app.get(McpService)); + // Nest installs controller routes and the Socket.IO adapter during init. + // Register the static catch-all only afterward so exact API routes win. + await app.init(); + // Nest's adapter and this workspace currently resolve separate Fastify 5 patch + // type identities; they are the same runtime instance. + const fastify = app.getHttpAdapter().getInstance() as unknown as FastifyInstance; + await registerWebUiForMode(fastify, resolveWebDistPath()); + const port = Number(process.env['GATEWAY_PORT'] ?? 14242); await app.listen(port, '0.0.0.0'); logger.log(`Gateway listening on port ${port}`); diff --git a/apps/gateway/src/web-ui-bundle.spec.ts b/apps/gateway/src/web-ui-bundle.spec.ts new file mode 100644 index 000000000..8851f5764 --- /dev/null +++ b/apps/gateway/src/web-ui-bundle.spec.ts @@ -0,0 +1,280 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { assertWebUiBundle, registerWebUi } from './web-ui.js'; +import Fastify, { type FastifyInstance } from 'fastify'; + +const INDEX_MARKER = '
bundle-matrix
'; + +interface BundleFixture { + indexHtml?: string; + manifest?: unknown; + files?: Record; +} + +async function buildBundle(fixture: BundleFixture): Promise { + const root = await mkdtemp(join(tmpdir(), 'mosaic-bundle-matrix-')); + const indexHtml = + fixture.indexHtml ?? + `${INDEX_MARKER}`; + const manifest = fixture.manifest ?? { 'index.html': { file: 'assets/app.js', isEntry: true } }; + + const writeRelative = async (relativePath: string, contents: string): Promise => { + const path = join(root, relativePath); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, contents, 'utf8'); + }; + + await writeRelative('index.html', indexHtml); + await writeRelative('.vite/manifest.json', JSON.stringify(manifest)); + await writeRelative('assets/app.js', 'export {};\n'); + for (const [relativePath, contents] of Object.entries(fixture.files ?? {})) { + await writeRelative(relativePath, contents); + } + return root; +} + +async function withBundle( + fixture: BundleFixture, + run: (root: string) => Promise, +): Promise { + const root = await buildBundle(fixture); + try { + await run(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +/** + * The round-6 property: a bundle accepted in required mode is one a browser + * can boot the SPA from *on this gateway*. Every ACCEPT row below is that + * sentence holding; every REJECT row is a shape where it would not hold. + */ +describe('Web UI bundle bootability matrix', () => { + describe('accepts bundles the browser can boot', () => { + interface AcceptCase { + name: string; + indexHtml?: string; + manifest?: unknown; + files?: Record; + } + + const cases: AcceptCase[] = [ + { + name: 'module script loads the manifest entry (baseline)', + indexHtml: `${INDEX_MARKER}`, + }, + { + name: 'classic script loads the manifest entry (IIFE-style build)', + indexHtml: `${INDEX_MARKER}`, + }, + { + name: 'module script plus nomodule legacy fallback', + indexHtml: `${INDEX_MARKER}`, + files: { 'assets/legacy.js': 'window.bootstrap();' }, + }, + { + name: 'entry referenced with a percent-encoded spelling', + indexHtml: `${INDEX_MARKER}`, + }, + { + name: 'entry referenced with a dot-relative spelling', + indexHtml: `${INDEX_MARKER}`, + }, + { + name: 'entry referenced with a cache-busting query string', + indexHtml: `${INDEX_MARKER}`, + }, + { + name: 'entry also preloaded via modulepreload link', + indexHtml: `${INDEX_MARKER}`, + }, + { + name: 'entry that is also a dynamic import target', + manifest: { + 'index.html': { file: 'assets/app.js', isEntry: true }, + 'src/feature.ts': { file: 'assets/feature.js', isDynamicEntry: true }, + }, + files: { 'assets/feature.js': 'export {};' }, + }, + { + name: 'multi-page manifest where only the index page is served', + manifest: { + 'index.html': { file: 'assets/app.js', isEntry: true }, + 'admin.html': { file: 'assets/admin.js', isEntry: true }, + }, + files: { 'assets/admin.js': 'export {};' }, + }, + { + name: 'deeply but legally nested markup (100 levels under the cap)', + indexHtml: `
${'
'.repeat(100)}${INDEX_MARKER}${'
'.repeat(100)}
`, + }, + ]; + + it.each(cases)('accepts $name', async ({ indexHtml, manifest, files }) => { + await withBundle({ indexHtml, manifest, files }, async (root) => { + await expect(assertWebUiBundle(root)).resolves.toBeUndefined(); + }); + }); + + it('accepts the real Vite bundle built from the checked-in apps/web sources', async () => { + // The gateway test script builds this bundle before vitest runs, so an + // absent bundle is a defect, not a clean checkout: assert unconditionally. + const distUrl = new URL('../../web/dist/', import.meta.url); + const root = new URL('.', distUrl).pathname; + await expect(assertWebUiBundle(root)).resolves.toBeUndefined(); + + const app: FastifyInstance = Fastify(); + try { + await registerWebUi(app, root); + const response = await app.inject({ method: 'GET', url: '/' }); + expect(response.statusCode).toBe(200); + expect(response.body).toContain('
'); + } finally { + await app.close(); + } + }); + }); + + describe('rejects bundles the browser cannot boot from this gateway', () => { + interface RejectCase { + name: string; + round5: 'accepted' | 'rejected'; + indexHtml?: string; + manifest?: unknown; + files?: Record; + reason: RegExp; + } + + const cases: RejectCase[] = [ + { + name: 'index with no script elements at all', + round5: 'accepted', + indexHtml: `${INDEX_MARKER}shell`, + reason: /must load the Vite entry chunk/u, + }, + { + name: 'index whose only script is theme-init.js', + round5: 'accepted', + indexHtml: `${INDEX_MARKER}`, + files: { 'theme-init.js': 'try {} catch {}' }, + reason: /must load the Vite entry chunk/u, + }, + { + name: 'sole script is an external absolute URL', + round5: 'accepted', + indexHtml: `${INDEX_MARKER}`, + reason: /must load the Vite entry chunk/u, + }, + { + name: 'sole script is protocol-relative', + round5: 'accepted', + indexHtml: `${INDEX_MARKER}`, + reason: /must load the Vite entry chunk/u, + }, + { + name: 'sole script is a data: URI', + round5: 'accepted', + indexHtml: `${INDEX_MARKER}`, + reason: /must load the Vite entry chunk/u, + }, + { + name: 'entry spelled relative under an external base href', + round5: 'accepted', + indexHtml: `${INDEX_MARKER}`, + reason: /must load the Vite entry chunk/u, + }, + { + name: 'entry referenced only through a modulepreload link', + round5: 'accepted', + indexHtml: `${INDEX_MARKER}`, + reason: /must load the Vite entry chunk/u, + }, + { + name: 'entry referenced only through a stylesheet link', + round5: 'accepted', + indexHtml: `${INDEX_MARKER}`, + reason: /must load the Vite entry chunk/u, + }, + { + name: 'only an inline module, no external entry script (singlefile shape)', + round5: 'accepted', + indexHtml: `${INDEX_MARKER}`, + reason: /must load the Vite entry chunk/u, + }, + { + name: 'entry loaded but a second script is missing (existence check still fires)', + round5: 'rejected', + indexHtml: `${INDEX_MARKER}`, + reason: /gone\.js|no such file or directory/u, + }, + { + name: 'manifest index.html entry without isEntry', + round5: 'rejected', + manifest: { 'index.html': { file: 'assets/app.js' } }, + reason: /is not an entry/u, + }, + { + name: 'index nesting exceeds the depth budget (300 levels)', + round5: 'accepted', + indexHtml: `
${'
'.repeat(300)}${'
'.repeat(300)}
`, + reason: /nests deeper than 128 levels/u, + }, + { + name: 'index node count exceeds the node budget (2,100 flat elements)', + round5: 'accepted', + indexHtml: `${'

x

'.repeat(2_100)}`, + reason: /more than 2,?000 nodes/u, + }, + ]; + + it.each(cases)('rejects $name', async ({ indexHtml, manifest, files, reason }) => { + await withBundle({ indexHtml, manifest, files }, async (root) => { + await expect(assertWebUiBundle(root)).rejects.toThrow(reason); + }); + }); + + it('documents that each rejecting row is specific, not incidental', async () => { + // Control arm: the same fixtures with the entry restored to the index + // must pass, proving each rejection above comes from the missing entry + // load rather than from an unrelated property of the fixture. + await withBundle( + { indexHtml: `${INDEX_MARKER}shell` }, + async (root) => { + await expect(assertWebUiBundle(root)).rejects.toThrow(/must load the Vite entry chunk/u); + await writeFile( + join(root, 'index.html'), + `${INDEX_MARKER}shell`, + 'utf8', + ); + await expect(assertWebUiBundle(root)).resolves.toBeUndefined(); + }, + ); + }); + }); + + it('measures the real Vite manifest depth and node count against the parse budgets', async () => { + const distUrl = new URL('../../web/dist/', import.meta.url); + const indexHtml = await readFile(new URL('index.html', distUrl), 'utf8'); + const { parse } = await import('parse5'); + const document = parse(indexHtml); + let nodes = 0; + let maxDepth = 0; + const walk = (node: unknown, depth: number): void => { + const element = node as { nodeName?: string; childNodes?: unknown[] }; + if (typeof element.nodeName === 'string') nodes += 1; + maxDepth = Math.max(maxDepth, depth); + for (const child of element.childNodes ?? []) walk(child, depth + 1); + }; + walk(document, 0); + // Real bundle: dozens of nodes, single-digit depth; budgets are 2,000 and + // 128. Assert the margin explicitly so a future legitimate bundle that + // approaches the budgets fails loudly here instead of in production. + expect(nodes).toBeGreaterThan(0); + expect(maxDepth).toBeGreaterThan(0); + expect(nodes).toBeLessThan(200); + expect(maxDepth).toBeLessThan(20); + }); +}); diff --git a/apps/gateway/src/web-ui-containment.spec.ts b/apps/gateway/src/web-ui-containment.spec.ts new file mode 100644 index 000000000..518443e2c --- /dev/null +++ b/apps/gateway/src/web-ui-containment.spec.ts @@ -0,0 +1,547 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import type { AddressInfo } from 'node:net'; +import net from 'node:net'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { registerWebUi } from './web-ui.js'; + +const INDEX_MARKER = '
containment-fixture
'; +const PROJECT_ID = '01890f47-6c00-7cc2-98c4-dc0c0c07398f'; + +interface ContainmentCase { + name: string; + target: string; + status: number; + /** For 200 rows: exact body content that must be present. */ + body?: string; + /** For non-200 rows: marker proving which layer answered. */ + bodyIncludes?: string; + accept: string; + /** Which layer produces the outcome, for the conformance report. */ + layer: string; +} + +/** + * Request-path containment conformance matrix. Every case is run twice against + * the same registered app: once through `Fastify.inject()` (no HTTP parser in + * the loop) and once through a hand-written request on a real socket to an + * OS-assigned loopback port (Node's llhttp parser in the loop). Agreement + * between the two transports is itself an asserted property; rows where they + * legitimately differ are called out in dedicated tests below. + */ +const CASES: ContainmentCase[] = [ + // --- decoding passes, proven by placement --- + { + name: 'double-encoded %2520 stays a literal file name', + target: '/a%2520b.txt', + status: 200, + body: 'ENCODED-LITERAL', + accept: '*/*', + layer: 'one decode pass: %2520 -> literal "%20" file', + }, + { + name: 'single-encoded %20 reaches the spaced file name', + target: '/a%20b.txt', + status: 200, + body: 'SPACE-NAME', + accept: '*/*', + layer: 'one decode pass: %20 -> space in file name', + }, + { + name: 'encoded question mark stays in the path, so the query cut precedes the decode', + target: '/assets/app.js%3Fv=1', + status: 200, + body: 'ENCODED-QUERY', + accept: '*/*', + layer: 'query/fragment cut happens on the encoded form, then one decode', + }, + + // --- parent-directory references in every spelling --- + { + name: 'decoded dot-dot segment refuses an existing parent file', + target: '/%2e%2e/outside.txt', + status: 404, + accept: '*/*', + layer: 'resolveStaticFile rejects ".." after the single decode', + }, + { + name: 'encoded separator plus dot-dot traversal', + target: '/..%2f..%2foutside.txt', + status: 404, + accept: '*/*', + layer: 'resolveStaticFile rejects ".." after the single decode', + }, + { + name: 'dot-dot traversal after a real directory', + target: '/assets/..%2f..%2foutside.txt', + status: 404, + accept: '*/*', + layer: 'resolveStaticFile rejects ".." after the single decode', + }, + + // --- alternate separators and empty segments --- + { + name: 'encoded slash acts as a separator but stays contained', + target: '/assets%2Fapp.js', + status: 200, + body: 'ASSET', + accept: '*/*', + layer: 'decode-then-segment split; both segments are real and inside root', + }, + { + name: 'empty segment from a doubled slash', + target: '//assets/app.js', + status: 404, + accept: '*/*', + layer: 'resolveStaticFile rejects empty segments', + }, + { + name: 'backslash is not a separator: router refuses the decoded backslash', + target: '/assets%5Capp.js', + status: 404, + accept: '*/*', + layer: + 'Fastify router (find-my-way) fails the wildcard match on the decoded backslash; resolver never consulted', + }, + { + name: 'backslash-named file is unreachable through the router (defense in depth)', + target: '/weird%5Cname.txt', + status: 404, + accept: '*/*', + layer: + 'Fastify router refuses; if it ever passed, POSIX would treat the backslash as a literal name', + }, + { + name: 'NUL byte cannot name a file', + target: '/assets/app.js%00.txt', + status: 404, + accept: '*/*', + layer: 'decoded NUL passes the segment check, then ENOENT (POSIX forbids NUL in names)', + }, + { + name: 'invalid percent escape is refused by the router before the handler', + target: '/%zz', + status: 400, + bodyIncludes: 'FST_ERR_BAD_URL', + accept: '*/*', + layer: 'find-my-way safeDecodeURI throws; Fastify answers 400 FST_ERR_BAD_URL', + }, + + // --- dotfiles --- + { + name: 'Vite manifest directory is denied though the file exists', + target: '/.vite/manifest.json', + status: 404, + accept: '*/*', + layer: 'resolveStaticFile dot-leading segment check (before any sendFile)', + }, + { + name: 'environment file is denied', + target: '/.env', + status: 404, + accept: '*/*', + layer: 'resolveStaticFile dot-leading segment check', + }, + { + name: 'VCS metadata is denied', + target: '/.git/config', + status: 404, + accept: '*/*', + layer: 'resolveStaticFile dot-leading segment check', + }, + { + name: 'dotfile nested under an ordinary directory is denied', + target: '/assets/.hidden', + status: 404, + accept: '*/*', + layer: 'resolveStaticFile dot-leading segment check', + }, + + // --- symlinks --- + { + name: 'leaf symlink pointing outside the root is refused', + target: '/out-link.txt', + status: 404, + accept: '*/*', + layer: 'realpath escapes root; relative() starts with ".." -> undefined', + }, + { + name: 'directory symlink pointing outside the root is refused', + target: '/out-dir/secret.txt', + status: 404, + accept: '*/*', + layer: 'realpath escapes root through the directory link', + }, + { + name: 'symlink resolving inside the root is served (containment, not symlink denial)', + target: '/in-link.txt', + status: 200, + body: 'ASSET', + accept: '*/*', + layer: 'realpath stays inside root; runtime static serving allows it', + }, + + // --- reserved prefixes, exact and lowercase after one decode --- + { + name: 'percent-encoded /api resolves to the reserved prefix and is denied', + target: '/%61pi/x.js', + status: 404, + accept: 'text/html', + layer: 'one decode -> "/api" -> isReservedPath before static lookup', + }, + { + name: 'literal /api denies a file that exists under it', + target: '/api/x.js', + status: 404, + accept: 'text/html', + layer: 'isReservedPath exact-prefix check', + }, + { + name: 'double-encoded api is an ordinary static name and is served', + target: '/%2561pi/x.js', + status: 200, + body: 'DOUBLE-ENCODED-API', + accept: 'text/html', + layer: 'one decode -> literal "%61pi" directory, not reserved', + }, + { + name: 'uppercase /Api alias is an ordinary static name and is served', + target: '/Api/alias.js', + status: 200, + body: 'CASE-ALIAS', + accept: 'text/html', + layer: 'reservation is case-sensitive; alias is an ordinary name', + }, + { + name: 'uppercase /API alias is served (control arm of the reservation rows)', + target: '/API/x.js', + status: 200, + body: 'UPPER-API', + accept: 'text/html', + layer: 'reservation is case-sensitive; alias is an ordinary name', + }, + { + name: 'literal /socket.io denies a file that exists under it', + target: '/socket.io/x.js', + status: 404, + accept: 'text/html', + layer: 'isReservedPath exact-prefix check', + }, + { + name: 'mixed-case /Socket.io alias is served', + target: '/Socket.io/x.js', + status: 200, + body: 'CASE-SOCKET', + accept: 'text/html', + layer: 'reservation is case-sensitive; alias is an ordinary name', + }, + { + name: 'percent-encoded /socket.io resolves to the reserved prefix and is denied', + target: '/%73ocket.io/x.js', + status: 404, + accept: 'text/html', + layer: 'one decode -> "/socket.io" -> reserved', + }, + + // --- derived parameter prefixes on SPA routes --- + { + name: 'exact SPA route serves the index', + target: '/login', + status: 200, + body: INDEX_MARKER, + accept: 'text/html', + layer: 'exact gatewayMatch', + }, + { + name: 'empty provider parameter does not match', + target: '/auth/provider/', + status: 404, + accept: 'text/html', + layer: 'derived prefix "/auth/provider/" leaves nothing to match', + }, + { + name: 'path continuing past the provider parameter does not match', + target: '/auth/provider/authentik/extra', + status: 404, + accept: 'text/html', + layer: 'rest-of-path "authentik/extra" is not a set member', + }, + { + name: 'encoded separator inside the provider parameter does not match', + target: '/auth/provider/authentik%2Fevil', + status: 404, + accept: 'text/html', + layer: 'rest-of-path "authentik/evil" is not a set member', + }, + { + name: 'provider id extending a set member does not match', + target: '/auth/provider/authentikevil', + status: 404, + accept: 'text/html', + layer: 'exact set membership, not prefix', + }, + { + name: 'uppercase provider id matches after lowercasing', + target: '/auth/provider/AUTHENTIK', + status: 200, + body: INDEX_MARKER, + accept: 'text/html', + layer: 'derived prefix compares lowercased', + }, + { + name: 'UUID project deep link serves the index', + target: `/projects/${PROJECT_ID}`, + status: 200, + body: INDEX_MARKER, + accept: 'text/html', + layer: 'project-id gatewayMatch with UUID pattern', + }, + { + name: 'path continuing past the project parameter does not match', + target: `/projects/${PROJECT_ID}/extra`, + status: 404, + accept: 'text/html', + layer: 'rest-of-path fails the UUID pattern', + }, + { + name: 'empty first segment in the project route does not match', + target: `/projects//${PROJECT_ID}`, + status: 404, + accept: 'text/html', + layer: 'rest-of-path "/" fails the UUID pattern', + }, + { + name: 'trailing slash is normalized like React Router', + target: '/login/', + status: 200, + body: INDEX_MARKER, + accept: 'text/html', + layer: 'normalizeReactRouterPath strips the trailing slash', + }, + { + name: 'query string is cut before routing', + target: '/login?next=/x', + status: 200, + body: INDEX_MARKER, + accept: 'text/html', + layer: 'pathnameFromUrl cuts at "?"', + }, + { + name: 'fragment is cut before routing', + target: '/login#section', + status: 200, + body: INDEX_MARKER, + accept: 'text/html', + layer: 'pathnameFromUrl cuts at "#"', + }, + { + name: 'arbitrary unknown path is not a blanket SPA fallback', + target: '/definitely/not/a/route', + status: 404, + accept: 'text/html', + layer: 'history fallback is route-table shaped, not catch-all', + }, + + // --- healthy control rows --- + { + name: 'plain asset serves (control for every deny row above)', + target: '/assets/app.js', + status: 200, + body: 'ASSET', + accept: '*/*', + layer: 'resolveStaticFile happy path', + }, + { + name: 'nested asset serves', + target: '/assets/deep/note.txt', + status: 200, + body: 'NOTE', + accept: '*/*', + layer: 'resolveStaticFile happy path', + }, + { + name: 'nonce absence control (per-run, cannot exist)', + target: `/absent-${process.pid}-${Date.now()}.txt`, + status: 404, + accept: '*/*', + layer: 'control row', + }, +]; + +function rawRequest( + port: number, + target: string, + accept: string, +): Promise<{ status: number; body: string; raw: string }> { + return new Promise((resolve, reject) => { + const socket = net.connect({ port, host: '127.0.0.1' }); + let raw = ''; + socket.setTimeout(5_000, () => { + socket.destroy(); + reject(new Error(`raw request timed out for ${target}`)); + }); + socket.on('error', reject); + socket.on('connect', () => { + socket.write( + `GET ${target} HTTP/1.1\r\nHost: 127.0.0.1\r\nAccept: ${accept}\r\nConnection: close\r\n\r\n`, + ); + }); + socket.on('data', (chunk: Buffer) => { + raw += chunk.toString('utf8'); + }); + socket.on('close', () => { + const statusMatch = /^HTTP\/1\.1 (\d{3})/u.exec(raw); + const body = raw.split('\r\n\r\n').slice(1).join('\r\n\r\n'); + if (!statusMatch) { + reject(new Error(`no status line in raw response for ${target}: ${raw.slice(0, 120)}`)); + return; + } + resolve({ status: Number(statusMatch[1]), body, raw }); + }); + }); +} + +describe('request-path containment conformance', () => { + let app: FastifyInstance; + let webRoot: string; + let outsideRoot: string; + let port: number; + + beforeAll(async () => { + app = Fastify(); + webRoot = await mkdtemp(join(tmpdir(), 'mosaic-containment-')); + outsideRoot = await mkdtemp(join(tmpdir(), 'mosaic-containment-outside-')); + + const write = async (root: string, relativePath: string, contents: string): Promise => { + const path = join(root, relativePath); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, contents, 'utf8'); + }; + + await write( + webRoot, + 'index.html', + `${INDEX_MARKER}`, + ); + await write( + webRoot, + '.vite/manifest.json', + JSON.stringify({ 'index.html': { file: 'assets/app.js', isEntry: true } }), + ); + await write(webRoot, 'assets/app.js', 'ASSET'); + await write(webRoot, 'assets/deep/note.txt', 'NOTE'); + await write(webRoot, 'assets/.hidden', 'DOTFILE'); + await write(webRoot, 'a%20b.txt', 'ENCODED-LITERAL'); + await write(webRoot, 'a b.txt', 'SPACE-NAME'); + await write(webRoot, 'assets/app.js?v=1', 'ENCODED-QUERY'); + await write(webRoot, 'weird\\name.txt', 'BACKSLASH-NAME'); + await write(webRoot, 'Api/alias.js', 'CASE-ALIAS'); + await write(webRoot, 'API/x.js', 'UPPER-API'); + await write(webRoot, 'api/x.js', 'RESERVED-API'); + await write(webRoot, 'Socket.io/x.js', 'CASE-SOCKET'); + await write(webRoot, 'socket.io/x.js', 'RESERVED-SOCKET'); + await write(webRoot, '%61pi/x.js', 'DOUBLE-ENCODED-API'); + await write(webRoot, '.env', 'SECRETISH=1'); + await write(webRoot, '.git/config', '[core]'); + await write(outsideRoot, 'outside.txt', 'OUTSIDE-FILE'); + await write(outsideRoot, 'directory/secret.txt', 'OUTSIDE-SECRET'); + await symlink(join(outsideRoot, 'outside.txt'), join(webRoot, 'out-link.txt')); + await symlink(join(outsideRoot, 'directory'), join(webRoot, 'out-dir')); + await symlink(join(webRoot, 'assets/app.js'), join(webRoot, 'in-link.txt')); + + await registerWebUi(app, webRoot); + await app.listen({ host: '127.0.0.1', port: 0 }); + port = (app.server.address() as AddressInfo).port; + }); + + afterAll(async () => { + await app.close(); + await rm(webRoot, { recursive: true, force: true }); + await rm(outsideRoot, { recursive: true, force: true }); + }); + + it.each(CASES)('injection: $name', async ({ target, status, body, bodyIncludes, accept }) => { + const response = await app.inject({ method: 'GET', url: target, headers: { accept } }); + expect(response.statusCode).toBe(status); + if (status === 200) { + expect(response.body).toContain(body); + } else { + expect(response.headers['content-type']).toContain('application/json'); + expect(response.body).toContain(bodyIncludes ?? 'Not Found'); + } + }); + + it.each(CASES)( + 'raw dispatch: $name', + async ({ target, status, body, bodyIncludes, accept, layer }) => { + const response = await rawRequest(port, target, accept); + expect(response.status, `layer: ${layer}`).toBe(status); + if (status === 200) { + expect(response.body, `layer: ${layer}`).toContain(body); + } else { + expect(response.body, `layer: ${layer}`).toContain(bodyIncludes ?? 'Not Found'); + } + }, + ); + + it('agrees between transports on every case (the matrix is transport-independent)', async () => { + for (const { target, status, accept } of CASES) { + const injected = await app.inject({ method: 'GET', url: target, headers: { accept } }); + const raw = await rawRequest(port, target, accept); + expect( + raw.status, + `transports disagree for ${target}: inject=${injected.statusCode} raw=${raw.status}`, + ).toBe(injected.statusCode); + expect(raw.status).toBe(status); + } + }); + + describe('transport divergence (injection normalizes; raw dispatch does not)', () => { + // light-my-request parses the url with `new URL(url, 'http://localhost')` + // (lib/parse-url.js), which resolves "." and ".." segments and prefixes + // "/" for relative targets. Raw dispatch hands Node's llhttp the bytes as + // written, so the router and our resolver see the path the client sent. + // These rows are why containment is asserted under raw dispatch as well. + it.each([ + { + target: '/assets/./app.js', + note: 'injection serves the asset after URL normalization; raw refuses the dot segment', + }, + { + target: '/a/../assets/app.js', + note: 'injection serves the asset after resolving dot-dot; raw refuses it', + }, + ])('diverges for $target', async ({ target, note }) => { + const injected = await app.inject({ method: 'GET', url: target, headers: { accept: '*/*' } }); + const raw = await rawRequest(port, target, '*/*'); + expect(injected.statusCode, note).toBe(200); + expect(injected.body).toContain('ASSET'); + expect(raw.status, `${target}: raw must refuse`).toBe(404); + expect(raw.body).toContain(`Cannot GET ${target}`); + }); + + it('diverges for a target with no leading slash: parser 400 under raw, normalized 200 under injection', async () => { + const raw = await rawRequest(port, 'assets/app.js', '*/*'); + expect(raw.status).toBe(400); // llhttp rejects the request line before routing + const injected = await app.inject({ method: 'GET', url: 'assets/app.js' }); + expect(injected.statusCode).toBe(200); // new URL() prefixed the slash + expect(injected.body).toContain('ASSET'); + }); + + it('treats an absolute-form request target as unreserved and fails closed', async () => { + const response = await rawRequest(port, `http://127.0.0.1:${port}/assets/app.js`, '*/*'); + // pathnameFromUrl requires a leading "/"; the absolute form does not have + // one, isReservedPath() returns true, and the static lookup is skipped. + expect(response.status).toBe(404); + expect(response.body).toContain('Not Found'); + }); + + it('serves the index through a real socket for the root document', async () => { + const response = await rawRequest(port, '/', 'text/html'); + expect(response.status).toBe(200); + expect(response.body).toContain(INDEX_MARKER); + }); + }); +}); diff --git a/apps/gateway/src/web-ui.spec.ts b/apps/gateway/src/web-ui.spec.ts new file mode 100644 index 000000000..a06b733e7 --- /dev/null +++ b/apps/gateway/src/web-ui.spec.ts @@ -0,0 +1,785 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { SUPPORTED_SSO_PROVIDER_IDS } from '@mosaicstack/auth'; +import { createProjectsRepo } from '@mosaicstack/brain'; +import { createPgliteDb, runPgliteMigrations } from '@mosaicstack/db'; +import { SPA_ROUTES } from '@mosaicstack/types'; +import { Controller, Get, Module } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import Fastify, { type FastifyInstance } from 'fastify'; +import { Server as SocketIoServer } from 'socket.io'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + assertWebUiBundle, + registerWebUi, + registerWebUiForMode, + resolveWebUiMode, +} from './web-ui.js'; + +const INDEX_MARKER = '
gateway-spa-fixture
'; +const ASSET_MARKER = 'window.__gatewayStaticFixture = true;'; +const PROJECT_ID = '123e4567-e89b-42d3-a456-426614174000'; +const UUID_V7_PROJECT_ID = '01890f47-6c00-7cc2-98c4-dc0c0c07398f'; + +function concreteSpaRouteCases(): Array<{ name: string; url: string }> { + return Object.entries(SPA_ROUTES).map(([name, route]) => { + switch (route.gatewayMatch) { + case 'exact': + return { name, url: route.path }; + case 'project-id': + return { name, url: route.path.replace(':id', UUID_V7_PROJECT_ID) }; + case 'supported-auth-provider': + return { name, url: route.path.replace(':provider', 'authentik') }; + default: { + const exhaustive: never = route; + throw new Error(`Unhandled SPA route: ${JSON.stringify(exhaustive)}`); + } + } + }); +} + +async function writeFixture(root: string, relativePath: string, contents: string): Promise { + const path = join(root, relativePath); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, contents, 'utf8'); +} + +async function expectStaticServingIsActive(app: FastifyInstance): Promise { + const response = await app.inject({ method: 'GET', url: '/assets/app.js' }); + expect(response.statusCode).toBe(200); + expect(response.body).toBe(ASSET_MARKER); +} + +@Controller() +class WebUiNestIntegrationController { + @Get('/api/web-ui-integration') + apiRoute(): { source: string } { + return { source: 'nest-controller' }; + } +} + +@Module({ controllers: [WebUiNestIntegrationController] }) +class WebUiNestIntegrationModule {} + +describe('gateway Web UI routing', () => { + let app: FastifyInstance; + let webRoot: string; + + beforeEach(async () => { + app = Fastify(); + webRoot = await mkdtemp(join(tmpdir(), 'mosaic-web-ui-')); + await writeFixture( + webRoot, + 'index.html', + `${INDEX_MARKER}`, + ); + await writeFixture(webRoot, 'assets/app.js', ASSET_MARKER); + await writeFixture( + webRoot, + '.vite/manifest.json', + JSON.stringify({ 'index.html': { file: 'assets/app.js', isEntry: true } }), + ); + }); + + afterEach(async () => { + await app.close(); + await rm(webRoot, { recursive: true, force: true }); + }); + + it('starts required mode on initialized Nest and preserves API-before-SPA precedence', async () => { + const nestApp = await NestFactory.create( + WebUiNestIntegrationModule, + new FastifyAdapter(), + { logger: false }, + ); + + try { + await nestApp.init(); + const nestFastify = nestApp.getHttpAdapter().getInstance() as unknown as FastifyInstance; + + await expect(registerWebUiForMode(nestFastify, webRoot, 'required')).resolves.toBeUndefined(); + + const apiResponse = await nestFastify.inject({ + method: 'GET', + url: '/api/web-ui-integration', + headers: { accept: 'text/html' }, + }); + expect(apiResponse.statusCode).toBe(200); + expect(apiResponse.json()).toEqual({ source: 'nest-controller' }); + expect(apiResponse.body).not.toContain(INDEX_MARKER); + + const deepLinkResponse = await nestFastify.inject({ + method: 'GET', + url: `/projects/${PROJECT_ID}`, + headers: { accept: 'text/html' }, + }); + expect(deepLinkResponse.statusCode).toBe(200); + expect(deepLinkResponse.headers['content-type']).toContain('text/html'); + expect(deepLinkResponse.body).toContain(INDEX_MARKER); + } finally { + await nestApp.close(); + } + }); + + it('denies static files under the reserved API prefix', async () => { + await writeFixture(webRoot, 'api/should-not-be-public.js', 'reserved-static-file'); + await registerWebUi(app, webRoot); + + await expectStaticServingIsActive(app); + const response = await app.inject({ + method: 'GET', + url: '/api/should-not-be-public.js', + headers: { accept: 'text/html' }, + }); + + expect(response.statusCode).toBe(404); + expect(response.headers['content-type']).toContain('application/json'); + expect(response.body).not.toContain('reserved-static-file'); + }); + + it('serves index.html for an unmatched deep link that accepts HTML', async () => { + await registerWebUi(app, webRoot); + + await expectStaticServingIsActive(app); + const response = await app.inject({ + method: 'GET', + url: `/projects/${PROJECT_ID}`, + headers: { accept: 'text/html,application/xhtml+xml' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('text/html'); + expect(response.body).toContain(INDEX_MARKER); + }); + + it('returns JSON 404 for an unknown API path even when HTML is accepted and a file exists', async () => { + await writeFixture(webRoot, 'api/not-a-route', `${INDEX_MARKER}`); + await registerWebUi(app, webRoot); + + await expectStaticServingIsActive(app); + const response = await app.inject({ + method: 'GET', + url: '/api/not-a-route', + headers: { accept: 'text/html' }, + }); + + expect(response.statusCode).toBe(404); + expect(response.headers['content-type']).toContain('application/json'); + expect(response.json()).toMatchObject({ error: 'Not Found', statusCode: 404 }); + expect(response.body).not.toContain(INDEX_MARKER); + }); + + it('keeps a real Socket.IO polling handler active and denies static files on its path', async () => { + await writeFixture(webRoot, 'socket.io/private.js', 'socket-static-file'); + const io = new SocketIoServer(app.server, { path: '/socket.io', serveClient: false }); + await registerWebUi(app, webRoot); + await app.listen({ host: '127.0.0.1', port: 0 }); + + try { + const address = app.server.address() as AddressInfo; + const pollingResponse = await fetch( + `http://127.0.0.1:${address.port}/socket.io/?EIO=4&transport=polling`, + ); + const pollingBody = await pollingResponse.text(); + expect(pollingResponse.status).toBe(200); + expect(pollingBody).toMatch(/^0\{/); + + // inject exercises Fastify directly (Socket.IO operates on the real HTTP + // server above), proving the static layer independently denies its path. + const staticResponse = await app.inject({ + method: 'GET', + url: '/socket.io/private.js', + headers: { accept: 'text/html' }, + }); + expect(staticResponse.statusCode).toBe(404); + expect(staticResponse.headers['content-type']).toContain('application/json'); + expect(staticResponse.body).not.toContain('socket-static-file'); + } finally { + await io.close(); + } + }); + + it('fails bounded file-shaped requests closed', async () => { + await registerWebUi(app, webRoot); + + for (const url of [ + '/assets/gone.js', + '/gone.css', + '/gone.png', + '/missing.ttf', + '/missing.xyz', + ]) { + const missingAsset = await app.inject({ + method: 'GET', + url, + headers: { accept: 'text/html' }, + }); + expect(missingAsset.statusCode).toBe(404); + expect(missingAsset.headers['content-type']).toContain('application/json'); + expect(missingAsset.body).not.toContain(INDEX_MARKER); + } + }); + + it.each(['/missing.mp4', '/missing.woff2', '/missing.json5', '/missing.7z'])( + 'returns JSON 404 for the missing digit-bearing asset %s even when HTML is accepted', + async (url) => { + await registerWebUi(app, webRoot); + + const response = await app.inject({ + method: 'GET', + url, + headers: { accept: 'text/html' }, + }); + + expect(response.statusCode).toBe(404); + expect(response.headers['content-type']).toContain('application/json'); + expect(response.body).not.toContain(INDEX_MARKER); + }, + ); + + it('denies dotfiles and file or directory symlinks that resolve outside the bundle', async () => { + const outsideRoot = await mkdtemp(join(tmpdir(), 'mosaic-web-ui-outside-')); + try { + await writeFixture(webRoot, '.gitkeep', 'dotfile-private'); + await writeFixture(outsideRoot, 'outside.txt', 'outside-file-private'); + await writeFixture(outsideRoot, 'directory/secret.txt', 'outside-directory-private'); + await symlink(join(outsideRoot, 'outside.txt'), join(webRoot, 'linked.txt')); + await symlink(join(outsideRoot, 'directory'), join(webRoot, 'linked-directory')); + await registerWebUi(app, webRoot); + + for (const url of ['/.gitkeep', '/linked.txt', '/linked-directory/secret.txt']) { + const response = await app.inject({ method: 'GET', url }); + expect(response.statusCode).not.toBe(200); + expect(response.body).not.toContain('private'); + } + } finally { + await rm(outsideRoot, { recursive: true, force: true }); + } + }); + + it('derives immutable caching from the Vite manifest rather than filename shape', async () => { + await writeFixture(webRoot, 'theme-init.js', 'theme-init'); + await writeFixture(webRoot, 'assets/app-a1b2c3d4.js', 'fingerprinted'); + await writeFixture(webRoot, 'assets/feature-settings.js', 'mutable-static-asset'); + await writeFixture( + webRoot, + 'index.html', + `${INDEX_MARKER}`, + ); + await writeFixture( + webRoot, + '.vite/manifest.json', + JSON.stringify({ + 'index.html': { file: 'assets/app-a1b2c3d4.js', isEntry: true }, + }), + ); + await registerWebUi(app, webRoot); + + const index = await app.inject({ method: 'GET', url: '/index.html' }); + const themeInit = await app.inject({ method: 'GET', url: '/theme-init.js' }); + const fingerprinted = await app.inject({ + method: 'GET', + url: '/assets/app-a1b2c3d4.js', + }); + const mutableAsset = await app.inject({ + method: 'GET', + url: '/assets/feature-settings.js', + }); + + expect(index.headers['cache-control']).toBe('no-cache'); + expect(themeInit.headers['cache-control']).toBe('no-cache'); + expect(mutableAsset.headers['cache-control']).toBe('no-cache'); + expect(fingerprinted.headers['cache-control']).toBe('public, max-age=2592000, immutable'); + }); + + it.each(concreteSpaRouteCases())( + 'serves the concretized $name entry from the shared SPA route contract', + async ({ url }) => { + await registerWebUi(app, webRoot); + + const response = await app.inject({ + method: 'GET', + url, + headers: { accept: 'text/html' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain(INDEX_MARKER); + }, + ); + + it.each([ + '/settings/', + '/Settings', + `/projects/${PROJECT_ID}/`, + `/Projects/${PROJECT_ID}`, + '/Auth/Provider/Authentik/', + ])('matches React Router case and trailing-slash behavior for %s', async (url) => { + await registerWebUi(app, webRoot); + + const response = await app.inject({ + method: 'GET', + url, + headers: { accept: 'text/html' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain(INDEX_MARKER); + }); + + it.each(SUPPORTED_SSO_PROVIDER_IDS)( + 'serves the auth callback fallback for supported provider %s', + async (provider) => { + await registerWebUi(app, webRoot); + + const response = await app.inject({ + method: 'GET', + url: `/auth/provider/${provider}`, + headers: { accept: 'text/html' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain(INDEX_MARKER); + }, + ); + + it('keeps the auth callback fallback limited to supported provider ids', async () => { + await registerWebUi(app, webRoot); + + const response = await app.inject({ + method: 'GET', + url: '/auth/provider/future-provider', + headers: { accept: 'text/html' }, + }); + + expect(response.statusCode).toBe(404); + expect(response.headers['content-type']).toContain('application/json'); + expect(response.body).not.toContain(INDEX_MARKER); + }); + + it.each([ + ['segment/child', 'a multi-segment id mounts nested routing'], + ['', 'an empty id widens matching to the bare callback path'], + ['x'.repeat(65), 'an oversized id invites unbounded route inputs'], + ['UPPERCASE', 'an uppercase id can never match the lowercased input'], + ['../escape', 'a traversal id would never be reachable'], + ])( + 'refuses to route on an auth provider export that is not a single lowercase segment (%s)', + async (invalidId) => { + vi.resetModules(); + vi.doMock('@mosaicstack/auth', () => ({ + SUPPORTED_SSO_PROVIDER_IDS: ['authentik', invalidId], + })); + try { + const { registerWebUi: registerWithMockedRegistry } = await import('./web-ui.js'); + await expect(registerWithMockedRegistry(app, webRoot)).rejects.toThrow( + /SUPPORTED_SSO_PROVIDER_IDS/u, + ); + } finally { + vi.doUnmock('@mosaicstack/auth'); + vi.resetModules(); + } + }, + ); + + it('resolves a non-v4 UUID that the project repository stores and reads back', async () => { + const handle = createPgliteDb('memory://'); + try { + await runPgliteMigrations(handle); + const projects = createProjectsRepo(handle.db); + const stored = await projects.create({ + id: UUID_V7_PROJECT_ID, + name: 'UUIDv7 route fixture', + }); + const readBack = await projects.findById(UUID_V7_PROJECT_ID); + expect(stored.id).toBe(UUID_V7_PROJECT_ID); + expect(readBack?.id).toBe(UUID_V7_PROJECT_ID); + + await registerWebUi(app, webRoot); + const response = await app.inject({ + method: 'GET', + url: `/projects/${readBack!.id}`, + headers: { accept: 'text/html' }, + }); + expect(response.statusCode).toBe(200); + expect(response.body).toContain(INDEX_MARKER); + } finally { + await handle.close(); + } + }); + + it('rejects non-UUID project detail route segments', async () => { + await registerWebUi(app, webRoot); + + for (const url of [ + '/projects/project-name', + '/projects/%252e%252e', + '/projects/%0A', + `/projects/${'a'.repeat(20_000)}`, + '/projects/%252f', + ]) { + const response = await app.inject({ + method: 'GET', + url, + headers: { accept: 'text/html' }, + }); + expect(response.statusCode, url).not.toBe(200); + expect(response.body, url).not.toContain(INDEX_MARKER); + } + }); + + it('accepts a complete index-to-asset pair and rejects a missing index script despite a valid manifest', async () => { + await expect(assertWebUiBundle(webRoot)).resolves.toBeUndefined(); + + const brokenRoot = await mkdtemp(join(tmpdir(), 'mosaic-web-ui-broken-index-')); + try { + await writeFixture( + brokenRoot, + 'index.html', + `${INDEX_MARKER}`, + ); + await writeFixture(brokenRoot, 'assets/manifest-entry.js', ASSET_MARKER); + await writeFixture( + brokenRoot, + '.vite/manifest.json', + JSON.stringify({ + 'index.html': { file: 'assets/manifest-entry.js', isEntry: true }, + }), + ); + + await expect(assertWebUiBundle(brokenRoot)).rejects.toThrow( + /Web UI bundle is unavailable.*missing\.js/u, + ); + } finally { + await rm(brokenRoot, { recursive: true, force: true }); + } + }); + + it('parses legal script-tag spellings and ignores references inside comments', async () => { + await writeFixture( + webRoot, + 'index.html', + `${INDEX_MARKER}`, + ); + + await expect(registerWebUi(app, webRoot)).resolves.toBeUndefined(); + await expectStaticServingIsActive(app); + }); + + it('rejects the bundle when any later manifest asset is missing', async () => { + await writeFixture( + webRoot, + '.vite/manifest.json', + JSON.stringify({ + 'index.html': { file: 'assets/app.js', isEntry: true }, + 'src/later.ts': { file: 'assets/missing-later.js' }, + }), + ); + + await expect(registerWebUi(app, webRoot)).rejects.toThrow( + /Web UI bundle is unavailable.*missing-later/u, + ); + }); + + it('rejects a symlink in a manifest asset path', async () => { + const linkedBundle = await mkdtemp(join(tmpdir(), 'mosaic-web-ui-linked-asset-')); + const realAssets = await mkdtemp(join(tmpdir(), 'mosaic-web-ui-real-asset-')); + try { + await writeFixture(linkedBundle, 'index.html', `${INDEX_MARKER}`); + await writeFixture( + linkedBundle, + '.vite/manifest.json', + JSON.stringify({ 'index.html': { file: 'assets/app.js', isEntry: true } }), + ); + await writeFixture(realAssets, 'app.js', ASSET_MARKER); + await symlink(realAssets, join(linkedBundle, 'assets')); + + await expect(registerWebUi(app, linkedBundle)).rejects.toThrow( + /Web UI bundle is unavailable.*symbolic links/u, + ); + } finally { + await rm(linkedBundle, { recursive: true, force: true }); + await rm(realAssets, { recursive: true, force: true }); + } + }); + + it('bounds index.html content validation at 1 MiB', async () => { + await writeFixture(webRoot, 'index.html', 'x'.repeat(1_048_577)); + + await expect(registerWebUi(app, webRoot)).rejects.toThrow( + /Web UI bundle is unavailable.*index\.html exceeds its size limit/u, + ); + }); + + it('rejects a directory without the Vite provenance manifest', async () => { + await rm(join(webRoot, '.vite/manifest.json')); + + await expect(registerWebUi(app, webRoot)).rejects.toThrow( + /Web UI bundle is unavailable.*manifest\.json/u, + ); + }); + + it('classifies percent-encoded API and Socket.IO prefixes as reserved', async () => { + await registerWebUi(app, webRoot); + + for (const url of ['/%61pi/unknown', '/%73ocket.io/unknown']) { + const response = await app.inject({ + method: 'GET', + url, + headers: { accept: 'text/html' }, + }); + expect(response.statusCode).toBe(404); + expect(response.headers['content-type']).toContain('application/json'); + expect(response.body).not.toContain(INDEX_MARKER); + } + }); + + it.each([ + ['Api/alias.js', '/Api/alias.js'], + ['api./alias.js', '/api./alias.js'], + ['api /alias.js', '/api%20/alias.js'], + ['/api/alias.js', '/%EF%BC%8Fapi/alias.js'], + ['%61pi/alias.js', '/%2561pi/alias.js'], + ])( + 'treats noncanonical reserved-prefix alias %s as an ordinary static name', + async (relativePath, url) => { + await writeFixture(webRoot, relativePath, 'noncanonical-static-alias'); + await registerWebUi(app, webRoot); + + const response = await app.inject({ method: 'GET', url }); + expect(response.statusCode).toBe(200); + expect(response.body).toBe('noncanonical-static-alias'); + }, + ); + + it('defaults to API-only mode for direct installed CLI launches without a bundled Web UI', () => { + expect(resolveWebUiMode(undefined)).toBe('disabled'); + }); + + it('skips bundle assertion in explicitly disabled API-only mode', async () => { + const missingRoot = join(webRoot, 'missing-dist'); + + await expect(registerWebUiForMode(app, missingRoot, 'disabled')).resolves.toBeUndefined(); + }); + + it('fails startup when the resolved mode is disabled but a stale Web UI payload is present', async () => { + const originalMode = process.env['GATEWAY_WEB_UI_MODE']; + try { + process.env['GATEWAY_WEB_UI_MODE'] = 'disabled'; + await expect(registerWebUiForMode(app, webRoot)).rejects.toThrow( + /GATEWAY_WEB_UI_MODE is "disabled".*bundle is present/u, + ); + } finally { + if (originalMode === undefined) delete process.env['GATEWAY_WEB_UI_MODE']; + else process.env['GATEWAY_WEB_UI_MODE'] = originalMode; + } + }); + + it('fails registration with an actionable error when the bundle is required but missing', async () => { + const missingRoot = join(webRoot, 'missing-dist'); + + await expect(registerWebUiForMode(app, missingRoot, 'required')).rejects.toThrow( + /Web UI bundle is unavailable.*build:vite/, + ); + }); + + it('rejects an empty payload selected for required mode', async () => { + const emptyRoot = join(webRoot, 'empty-dist'); + await mkdir(emptyRoot); + + await expect(registerWebUiForMode(app, emptyRoot, 'required')).rejects.toThrow( + /Web UI bundle is unavailable.*build:vite/, + ); + }); + + it('rejects an index-only partial bundle in required mode', async () => { + const partialRoot = await mkdtemp(join(tmpdir(), 'mosaic-web-ui-partial-')); + try { + await writeFixture(partialRoot, 'index.html', `${INDEX_MARKER}`); + + await expect(registerWebUiForMode(app, partialRoot, 'required')).rejects.toThrow( + /Web UI bundle is unavailable.*build:vite/, + ); + } finally { + await rm(partialRoot, { recursive: true, force: true }); + } + }); + + it('accepts a bundle root symlink after canonicalizing its release target', async () => { + const linkedRoot = join( + tmpdir(), + `mosaic-web-ui-link-${process.pid.toString()}-${Date.now().toString()}`, + ); + await symlink(webRoot, linkedRoot); + try { + await expect(registerWebUiForMode(app, linkedRoot, 'required')).resolves.toBeUndefined(); + await expectStaticServingIsActive(app); + } finally { + await rm(linkedRoot, { force: true }); + } + }); +}); + +interface MainBootstrapResult { + events: string[]; + warnMessages: string[]; + helmetOptions: { + contentSecurityPolicy?: { directives?: Record }; + }; + registerWebUiMock: ReturnType; +} + +async function runMainBootstrap( + webUiMode: 'disabled' | 'required', + corsOrigins: string[] = [], +): Promise { + const originalSecret = process.env['BETTER_AUTH_SECRET']; + const originalMode = process.env['GATEWAY_WEB_UI_MODE']; + const events: string[] = []; + const warnMessages: string[] = []; + let helmetOptions: MainBootstrapResult['helmetOptions'] = {}; + const fastifyInstance = { fixture: 'fastify-instance' }; + const registerWebUiMock = vi.fn(async (): Promise => { + if (webUiMode === 'required') events.push('register-web-ui'); + }); + const app = { + enableCors: vi.fn(), + register: vi.fn(async (_plugin: unknown, options: MainBootstrapResult['helmetOptions']) => { + helmetOptions = options; + events.push('helmet'); + }), + useGlobalPipes: vi.fn(), + get: vi.fn(() => ({})), + init: vi.fn(async () => { + events.push('app-init'); + }), + getHttpAdapter: vi.fn(() => ({ getInstance: () => fastifyInstance })), + listen: vi.fn(async () => { + events.push('app-listen'); + }), + }; + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + + try { + process.env['BETTER_AUTH_SECRET'] = 'test-only-required-value'; + process.env['GATEWAY_WEB_UI_MODE'] = webUiMode; + vi.resetModules(); + vi.doMock('./env.js', () => ({ resolveGatewayConfigPath: () => '/fixture/config.json' })); + vi.doMock('./tracing.js', () => ({})); + vi.doMock('./app.module.js', () => ({ AppModule: class AppModule {} })); + vi.doMock('./auth/auth.controller.js', () => ({ mountAuthHandler: vi.fn() })); + vi.doMock('./mcp/mcp.controller.js', () => ({ mountMcpHandler: vi.fn() })); + vi.doMock('./mcp/mcp.service.js', () => ({ McpService: class McpService {} })); + vi.doMock('./cors-origins.js', () => ({ listGatewayCorsOrigins: () => corsOrigins })); + vi.doMock('./web-ui.js', () => ({ + registerWebUiForMode: registerWebUiMock, + resolveWebDistPath: () => '/fixture/web-dist', + })); + vi.doMock('@nestjs/core', () => ({ + NestFactory: { + create: vi.fn(async () => { + events.push('nest-create'); + return app; + }), + }, + })); + vi.doMock('@nestjs/common', () => ({ + Logger: class Logger { + error(): void {} + log(): void {} + warn(message: string): void { + warnMessages.push(message); + } + }, + ValidationPipe: class ValidationPipe {}, + })); + vi.doMock('@nestjs/platform-fastify', () => ({ FastifyAdapter: class FastifyAdapter {} })); + vi.doMock('@mosaicstack/auth', () => ({ listSsoStartupWarnings: () => [] })); + vi.doMock('@mosaicstack/config', () => ({ + loadConfig: () => ({ + tier: 'local', + storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' }, + queue: { type: 'local', dataDir: '.mosaic/queue' }, + memory: { type: 'keyword' }, + }), + })); + vi.doMock('@mosaicstack/storage', () => ({ + detectAndAssertTier: vi.fn(async () => undefined), + TierDetectionError: class TierDetectionError extends Error {}, + })); + + await import('./main.js'); + await vi.waitFor(() => expect(app.listen).toHaveBeenCalledOnce()); + expect(exitSpy).not.toHaveBeenCalled(); + return { events, warnMessages, helmetOptions, registerWebUiMock }; + } finally { + exitSpy.mockRestore(); + vi.resetModules(); + vi.unmock('./env.js'); + vi.unmock('./tracing.js'); + vi.unmock('./app.module.js'); + vi.unmock('./auth/auth.controller.js'); + vi.unmock('./mcp/mcp.controller.js'); + vi.unmock('./mcp/mcp.service.js'); + vi.unmock('./cors-origins.js'); + vi.unmock('./web-ui.js'); + vi.unmock('@nestjs/core'); + vi.unmock('@nestjs/common'); + vi.unmock('@nestjs/platform-fastify'); + vi.unmock('@mosaicstack/auth'); + vi.unmock('@mosaicstack/config'); + vi.unmock('@mosaicstack/storage'); + if (originalSecret === undefined) delete process.env['BETTER_AUTH_SECRET']; + else process.env['BETTER_AUTH_SECRET'] = originalSecret; + if (originalMode === undefined) delete process.env['GATEWAY_WEB_UI_MODE']; + else process.env['GATEWAY_WEB_UI_MODE'] = originalMode; + } +} + +describe('gateway production Web UI wiring', () => { + it('orders the mocked main.ts init, Web UI registration, and listen calls', async () => { + const result = await runMainBootstrap('required'); + + expect(result.registerWebUiMock).toHaveBeenCalledWith( + { fixture: 'fastify-instance' }, + '/fixture/web-dist', + ); + expect(result.events).toContain('app-init'); + expect(result.events.indexOf('app-init')).toBeLessThan( + result.events.indexOf('register-web-ui'), + ); + expect(result.events.indexOf('register-web-ui')).toBeLessThan( + result.events.indexOf('app-listen'), + ); + }); + + it('limits the current SPA image CSP to same-origin and embedded data', async () => { + const result = await runMainBootstrap('disabled'); + + expect(result.helmetOptions.contentSecurityPolicy?.directives?.['img-src']).toEqual([ + "'self'", + 'data:', + ]); + }); + + it('warns exactly once that CORS is disabled when the origin allowlist is empty', async () => { + const result = await runMainBootstrap('disabled'); + + const corsWarnings = result.warnMessages.filter((message) => + message.includes('GATEWAY_CORS_ORIGIN'), + ); + expect(corsWarnings).toHaveLength(1); + expect(corsWarnings[0]).toContain('CORS is disabled'); + }); + + it('does not warn about CORS when the origin allowlist is populated', async () => { + const result = await runMainBootstrap('disabled', ['https://app.example.com']); + + expect( + result.warnMessages.filter((message) => message.includes('GATEWAY_CORS_ORIGIN')), + ).toHaveLength(0); + }); +}); diff --git a/apps/gateway/src/web-ui.ts b/apps/gateway/src/web-ui.ts new file mode 100644 index 000000000..f927a0355 --- /dev/null +++ b/apps/gateway/src/web-ui.ts @@ -0,0 +1,619 @@ +import { realpathSync } from 'node:fs'; +import { lstat, open, realpath } from 'node:fs/promises'; +import { isAbsolute, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import fastifyStatic from '@fastify/static'; +import { SUPPORTED_SSO_PROVIDER_IDS } from '@mosaicstack/auth'; +import { SPA_ROUTES } from '@mosaicstack/types'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { defaultTreeAdapter, parse, type DefaultTreeAdapterMap, type TreeAdapter } from 'parse5'; + +const WEB_DIST_URL = new URL('../../web/dist/', import.meta.url); +const VITE_MANIFEST_PATH = '.vite/manifest.json'; +const MAX_VITE_MANIFEST_BYTES = 1_048_576; +const MAX_INDEX_HTML_BYTES = 1_048_576; +// Parse cost is not linear in bytes: nesting depth drives both parse5's work +// and traversal recursion. These caps abort construction of a hostile or +// corrupt index.html while it is being parsed, so neither the parse nor the +// walk can run long on input under the byte cap. The real Vite-built index +// clears both with wide margin, and web-ui-bundle.spec.ts asserts that +// margin on every gateway test run (the test script builds the bundle first). +const MAX_INDEX_PARSE_NODES = 2_000; +const MAX_INDEX_PARSE_DEPTH = 128; +const BUNDLE_DOCUMENT_ORIGIN = 'https://mosaic-web-ui.invalid'; +const LINK_RESOURCE_RELATIONS = new Set([ + 'apple-touch-icon', + 'icon', + 'manifest', + 'mask-icon', + 'modulepreload', + 'prefetch', + 'preload', + 'stylesheet', +]); +const RESERVED_PATH_PREFIXES = [ + '/api', // REST namespace + '/socket.io', +] as const; +const SPA_AUTH_PROVIDERS = new Set(SUPPORTED_SSO_PROVIDER_IDS); +const SPA_AUTH_PROVIDER_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/u; +const PROJECT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; + +/** + * The auth package's provider list crosses a package boundary into unauthenticated + * SPA fallback routing. Registration refuses to trust it blindly: each entry + * must be a single lowercase URL segment so a future widening edit in + * @mosaicstack/auth cannot silently mount multi-segment, oversized, or + * never-matching (uppercase) routes here. Shape only — whether Auth actually + * implements a login for an id remains that package's contract. + */ +function assertSpaAuthProviderRegistry(): void { + for (const providerId of SUPPORTED_SSO_PROVIDER_IDS) { + if (!SPA_AUTH_PROVIDER_ID_PATTERN.test(providerId)) { + throw new Error( + `SUPPORTED_SSO_PROVIDER_IDS entry is not a lowercase single-segment id: ${JSON.stringify(providerId)}`, + ); + } + } +} + +export type WebUiMode = 'disabled' | 'required'; + +export function resolveWebUiMode(value = process.env['GATEWAY_WEB_UI_MODE']): WebUiMode { + if (value === undefined || value === '' || value === 'disabled') return 'disabled'; + if (value === 'required') return 'required'; + throw new Error('GATEWAY_WEB_UI_MODE must be either "disabled" or "required"'); +} + +export function resolveWebDistPath(): string { + return fileURLToPath(WEB_DIST_URL); +} + +interface ViteManifestEntry { + file?: unknown; + css?: unknown; + assets?: unknown; + isEntry?: unknown; +} + +interface WebUiBundle { + canonicalWebRoot: string; + immutableAssetPaths: ReadonlySet; +} + +function assetPathSegments(value: unknown): string[] { + if (typeof value !== 'string' || !value.startsWith('assets/')) { + throw new Error('bundle Vite manifest contains an invalid asset path'); + } + const segments = value.split('/'); + if ( + segments.some( + (segment) => segment === '' || segment === '.' || segment === '..' || segment.startsWith('.'), + ) + ) { + throw new Error('bundle Vite manifest contains an invalid asset path'); + } + return segments; +} + +async function assertRealBundleFile( + root: string, + relativePath: string, + description: string, +): Promise { + const segments = relativePath.split('/'); + let currentPath = root; + + for (const [index, segment] of segments.entries()) { + currentPath = join(currentPath, segment); + const stats = await lstat(currentPath); + if (stats.isSymbolicLink()) { + throw new Error(`${description} must not contain symbolic links`); + } + if (index < segments.length - 1 && !stats.isDirectory()) { + throw new Error(`${description} has a non-directory path component`); + } + if (index === segments.length - 1 && !stats.isFile()) { + throw new Error(`${description} must be a real file`); + } + } +} + +async function readBoundedUtf8( + path: string, + maximumBytes: number, + description: string, +): Promise { + const handle = await open(path, 'r'); + try { + const stats = await handle.stat(); + if (!stats.isFile()) throw new Error(`${description} must be a real file`); + if (stats.size > maximumBytes) throw new Error(`${description} exceeds its size limit`); + + const buffer = Buffer.alloc(maximumBytes + 1); + let bytesRead = 0; + while (bytesRead < buffer.length) { + const result = await handle.read(buffer, bytesRead, buffer.length - bytesRead, bytesRead); + if (result.bytesRead === 0) break; + bytesRead += result.bytesRead; + } + if (bytesRead > maximumBytes) throw new Error(`${description} exceeds its size limit`); + return buffer.subarray(0, bytesRead).toString('utf8'); + } finally { + await handle.close(); + } +} + +function addManifestAssetPath(paths: Set, value: unknown): void { + const segments = assetPathSegments(value); + paths.add(segments.join('/')); +} + +type HtmlNode = DefaultTreeAdapterMap['node']; + +function htmlAttribute(node: DefaultTreeAdapterMap['element'], name: string): string | undefined { + return node.attrs.find((attribute) => attribute.name === name)?.value; +} + +function walkHtml( + node: HtmlNode, + visit: (element: DefaultTreeAdapterMap['element']) => void, +): void { + if ('tagName' in node) visit(node); + if ('childNodes' in node) { + node.childNodes.forEach((child) => walkHtml(child, visit)); + } +} + +/** + * parse5 tree adapter that aborts parsing once the document exceeds a node + * budget or a nesting-depth budget. parse5 does not catch adapter exceptions, + * so throwing here unwinds `parse()` immediately; construction of the tree (the + * expensive part for deeply nested input) stops at the cap rather than at the + * byte limit. Depth is tracked per element through append/insert calls, so a + * document can never leave `parse()` deeper than MAX_INDEX_PARSE_DEPTH and the + * recursive `walkHtml` stays bounded with it. + */ +function createBoundedIndexTreeAdapter(): TreeAdapter { + const elementDepths = new WeakMap(); + let nodeCount = 0; + + const countNode = (): void => { + nodeCount += 1; + if (nodeCount > MAX_INDEX_PARSE_NODES) { + throw new Error(`bundle index.html contains more than ${MAX_INDEX_PARSE_NODES} nodes`); + } + }; + + const trackInsert = ( + parentNode: DefaultTreeAdapterMap['parentNode'], + newNode: DefaultTreeAdapterMap['childNode'], + ): void => { + if (!('tagName' in newNode)) return; + const depth = (elementDepths.get(parentNode) ?? 0) + 1; + if (depth > MAX_INDEX_PARSE_DEPTH) { + throw new Error(`bundle index.html nests deeper than ${MAX_INDEX_PARSE_DEPTH} levels`); + } + elementDepths.set(newNode, depth); + }; + + return { + ...defaultTreeAdapter, + createElement(tagName, namespaceURI, attrs) { + const element = defaultTreeAdapter.createElement(tagName, namespaceURI, attrs); + countNode(); + return element; + }, + createTextNode(value) { + const textNode = defaultTreeAdapter.createTextNode(value); + countNode(); + return textNode; + }, + createCommentNode(data) { + const commentNode = defaultTreeAdapter.createCommentNode(data); + countNode(); + return commentNode; + }, + appendChild(parentNode, newNode) { + trackInsert(parentNode, newNode); + defaultTreeAdapter.appendChild(parentNode, newNode); + }, + insertBefore(parentNode, newNode, referenceNode) { + trackInsert(parentNode, newNode); + defaultTreeAdapter.insertBefore(parentNode, newNode, referenceNode); + }, + }; +} + +function localBundleResourcePath(reference: string, documentBase: URL): string | undefined { + let url: URL; + try { + url = new URL(reference, documentBase); + } catch { + throw new Error('bundle index.html contains an invalid resource URL'); + } + if (url.origin !== BUNDLE_DOCUMENT_ORIGIN) return undefined; + + let pathname: string; + try { + pathname = decodeURIComponent(url.pathname); + } catch { + throw new Error('bundle index.html contains an invalid resource path'); + } + + const segments = pathname.split('/').slice(1); + if ( + segments.length === 0 || + segments.some( + (segment) => segment === '' || segment === '.' || segment === '..' || segment.startsWith('.'), + ) + ) { + throw new Error('bundle index.html contains an invalid resource path'); + } + return segments.join('/'); +} + +/** + * Verifies every same-origin resource the index references is a real file and + * returns the set of bundle-relative paths the index loads via +
diff --git a/apps/web/public/theme-init.js b/apps/web/public/theme-init.js new file mode 100644 index 000000000..847dd4e40 --- /dev/null +++ b/apps/web/public/theme-init.js @@ -0,0 +1,7 @@ +// Set data-theme before first paint so the stored theme never flashes. +try { + const theme = window.localStorage.getItem('mosaic-theme') || 'dark'; + document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark'); +} catch { + document.documentElement.setAttribute('data-theme', 'dark'); +} diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index 0dd525ecb..3eb54db0d 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; import { createBrowserRouter, Navigate, Outlet, type RouteObject } from 'react-router-dom'; +import { SPA_ROUTES } from '@mosaicstack/types'; import { LoginPage } from '@/spa/pages/login'; import { RegisterPage } from '@/spa/pages/register'; import { SsoCallbackPage } from '@/spa/pages/sso-callback'; @@ -8,13 +9,17 @@ import { ChatRouteErrorBoundary } from '@/spa/pages/chat-error-boundary'; import { ProjectDetailPage } from '@/spa/pages/project-detail'; import { ProjectsPage } from '@/spa/pages/projects'; import { + AdminRouteErrorBoundary, ProjectDetailRouteErrorBoundary, ProjectsRouteErrorBoundary, + SettingsRouteErrorBoundary, TasksRouteErrorBoundary, } from '@/spa/pages/resource-route-error-boundaries'; import { TasksPage } from '@/spa/pages/tasks'; +import { SettingsPage } from '@/spa/pages/settings'; +import { AdminPage } from '@/spa/pages/admin'; import { AuthGuard, GuestGuard } from '@/spa/guards'; -import { Placeholder } from '@/spa/placeholder'; +import { AppShellLayout } from '@/spa/layout/app-shell'; function GuestLayout(): ReactElement { return ( @@ -33,9 +38,9 @@ export const routes: RouteObject[] = [ { element: , children: [ - { path: '/login', element: }, - { path: '/register', element: }, - { path: '/auth/provider/:provider', element: }, + { path: SPA_ROUTES.login.path, element: }, + { path: SPA_ROUTES.register.path, element: }, + { path: SPA_ROUTES.authProvider.path, element: }, ], }, ], @@ -43,21 +48,48 @@ export const routes: RouteObject[] = [ { element: , children: [ - { path: '/', element: }, - { path: '/chat', element: , errorElement: }, { - path: '/projects', - element: , - errorElement: , + path: SPA_ROUTES.root.path, + element: , }, + // Pathless layout route: the dashboard shell mounts once and persists + // across navigation. Adding no path keeps the route-table contract + // (SPA_ROUTES set-equality) intact. { - path: '/projects/:id', - element: , - errorElement: , + element: , + children: [ + { + path: SPA_ROUTES.chat.path, + element: , + errorElement: , + }, + { + path: SPA_ROUTES.projects.path, + element: , + errorElement: , + }, + { + path: SPA_ROUTES.projectDetail.path, + element: , + errorElement: , + }, + { + path: SPA_ROUTES.tasks.path, + element: , + errorElement: , + }, + { + path: SPA_ROUTES.settings.path, + element: , + errorElement: , + }, + { + path: SPA_ROUTES.admin.path, + element: , + errorElement: , + }, + ], }, - { path: '/tasks', element: , errorElement: }, - { path: '/settings', element: }, - { path: '/admin', element: }, ], }, ]; diff --git a/apps/web/src/spa/guards.tsx b/apps/web/src/spa/guards.tsx index 71ea0fea1..627738830 100644 --- a/apps/web/src/spa/guards.tsx +++ b/apps/web/src/spa/guards.tsx @@ -1,11 +1,12 @@ -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import { Navigate, Outlet } from 'react-router-dom'; +import { SPA_ROUTES } from '@mosaicstack/types'; import { useSession } from '@/lib/auth-client'; export function GuestGuard(): ReactElement { const { data: session } = useSession(); - return session ? : ; + return session ? : ; } export function AuthGuard(): ReactElement { @@ -19,5 +20,38 @@ export function AuthGuard(): ReactElement { ); } - return session ? : ; + return session ? : ; +} + +// The shared `@/components/admin-role-guard` cannot be imported here: it routes +// through `next/navigation`'s `useRouter`, which only works inside the Next.js +// app runtime. This is the same relationship `GuestGuard`/`AuthGuard` above +// have to the Next auth/guest guards: identical policy (pending → loading, no +// session → login, non-admin → root), expressed with react-router navigation +// instead. It stays a children wrapper rather than an `Outlet` layout guard +// because the admin policy gates a single page, not a route group. +export function AdminRoleGuard({ children }: { children: ReactNode }): ReactElement | null { + const { data: session, isPending } = useSession(); + + const user = session?.user as + | (NonNullable['user'] & { role?: string }) + | undefined; + + if (isPending) { + return ( +
+
Loading...
+
+ ); + } + + if (!session) { + return ; + } + + if (user?.role !== 'admin') { + return ; + } + + return <>{children}; } diff --git a/apps/web/src/spa/layout/app-shell.spec.tsx b/apps/web/src/spa/layout/app-shell.spec.tsx new file mode 100644 index 000000000..ab8c275d6 --- /dev/null +++ b/apps/web/src/spa/layout/app-shell.spec.tsx @@ -0,0 +1,561 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi, type Mock } from 'vitest'; +import { SPA_ROUTES } from '@mosaicstack/types'; + +const { apiMock, useSessionMock, signOutMock } = vi.hoisted(() => ({ + apiMock: vi.fn(), + useSessionMock: vi.fn(), + signOutMock: vi.fn(), +})); + +vi.mock('@/lib/auth-client', () => ({ + authClient: { updateUser: vi.fn() }, + useSession: useSessionMock, + signIn: { oauth2: vi.fn() }, + signUp: vi.fn(), + signOut: signOutMock, +})); + +vi.mock('@/lib/api', () => ({ + api: apiMock, +})); + +// The real chat page opens a socket.io connection on mount; the shell spec +// exercises layout mounting, not chat behaviour, which has its own suite. +vi.mock('@/spa/pages/chat', () => ({ + ChatPage: function ChatPageStub() { + return
Chat page
; + }, +})); + +import { routes } from '@/routes'; +import { workspaceNavItems } from '@/spa/layout/sidebar'; +import { ThemeProvider } from '@/providers/theme-provider'; + +const adminSession = { + user: { + id: 'user-1', + name: 'Ada Lovelace', + email: 'ada@example.com', + role: 'admin', + }, +}; + +interface RenderedRouter { + container: HTMLDivElement; + router: ReturnType; +} + +const mountedRoots: Root[] = []; +const installedMatchMedia: Array<() => void> = []; +const installedStorage: Array<() => void> = []; + +beforeAll(() => { + Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { + configurable: true, + value: true, + }); +}); + +afterAll(() => { + Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); +}); + +// jsdom ships no matchMedia; the shell's SidebarProvider (shared with the +// Next baseline) needs it to measure the mobile breakpoint. Desktop default +// mirrors the non-mobile layout. +function installMatchMedia(matches = false): void { + const mediaQueryList: MediaQueryList = { + matches, + media: '', + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + }; + + const original = window.matchMedia; + window.matchMedia = () => mediaQueryList; + installedMatchMedia.push(() => { + window.matchMedia = original; + }); +} + +// vitest's jsdom environment does not supply a usable window.localStorage — see +// src/test/setup.ts, which installs a shared in-memory store. That store is +// process-wide, so it would carry a persisted theme between tests in this file. +// Install a fresh store per render, then restore the descriptor that was in +// place beforehand — deleting the property instead would strip the setup file's +// store from the window for the rest of the file. +function installLocalStorage(): void { + const store = new Map(); + const storage: Storage = { + length: store.size, + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key), + clear: () => void store.clear(), + key: (index: number) => [...store.keys()][index] ?? null, + }; + + const original = Object.getOwnPropertyDescriptor(window, 'localStorage'); + Object.defineProperty(window, 'localStorage', { + configurable: true, + get: () => storage, + }); + installedStorage.push(() => { + if (original) { + Object.defineProperty(window, 'localStorage', original); + } else { + Reflect.deleteProperty(window, 'localStorage'); + } + }); +} + +async function renderApp( + initialEntry: string, + { mobile = false }: { mobile?: boolean } = {}, +): Promise { + installMatchMedia(mobile); + installLocalStorage(); + document.documentElement.removeAttribute('data-theme'); + const router = createMemoryRouter(routes, { initialEntries: [initialEntry] }); + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + mountedRoots.push(root); + + await act(async () => { + root.render( + + + , + ); + }); + + return { container, router }; +} + +afterEach(async () => { + for (const restore of installedStorage.splice(0)) { + restore(); + } + for (const restore of installedMatchMedia.splice(0)) { + restore(); + } + for (const root of mountedRoots.splice(0)) { + await act(async () => { + root.unmount(); + }); + } + document.body.replaceChildren(); + apiMock.mockReset(); + useSessionMock.mockReset(); + signOutMock.mockReset(); +}); + +function mockSignedIn(): void { + useSessionMock.mockReturnValue({ data: adminSession, isPending: false }); + apiMock.mockResolvedValue([]); +} + +function mockSignedOut(): void { + useSessionMock.mockReturnValue({ data: null, isPending: false }); +} + +function findButtonByLabel(container: HTMLElement, label: string): HTMLButtonElement { + const button = [...container.querySelectorAll('button')].find( + (candidate) => candidate.getAttribute('aria-label') === label, + ); + if (!button) { + throw new Error(`Button with aria-label "${label}" not found`); + } + return button; +} + +function navLinks(container: HTMLElement): HTMLAnchorElement[] { + return [...container.querySelectorAll('nav a')]; +} + +function clickSignOut(view: RenderedRouter): Promise { + return act(async () => { + [...view.container.querySelectorAll('button')] + .find((button) => button.textContent === 'Sign out') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); +} + +// Records every router location change after tracking starts. The sign-out +// specs use this to prove no SOFT navigation occurred — half of the hard-handoff +// contract. The other half (that the teardown actually happened) is covered by +// stubDocumentLocation below. +function trackSoftNavigations(view: RenderedRouter): { + navigated: string[]; + stop: () => void; +} { + const navigated: string[] = []; + const unsubscribe = view.router.subscribe(() => { + navigated.push(view.router.state.location.pathname); + }); + return { navigated, stop: unsubscribe }; +} + +// Measured in this environment: `window.location.replace` is unforgeable — its +// own descriptor is configurable=false, writable=false, and both assignment and +// defineProperty on it throw TypeError. `window.location` itself, however, is a +// configurable accessor on `window` and CAN be redefined, so swapping the whole +// object is the seam that makes the sign-out document teardown observable. +// Without it the handler could stop navigating altogether and every sign-out +// test would still pass (verified by mutation). Safe here because the specs use +// a memory router, which never reads the real location. +function stubDocumentLocation(): { replace: Mock; restore: () => void } { + const original = Object.getOwnPropertyDescriptor(window, 'location'); + const { href, origin, pathname, search, hash } = window.location; + const replace = vi.fn(); + + Object.defineProperty(window, 'location', { + configurable: true, + value: { href, origin, pathname, search, hash, replace, assign: vi.fn(), reload: vi.fn() }, + }); + + return { + replace, + restore: () => { + if (original) { + Object.defineProperty(window, 'location', original); + } else { + Reflect.deleteProperty(window, 'location'); + } + }, + }; +} + +describe('AppShellLayout mounting', () => { + it.each([ + SPA_ROUTES.chat.path, + SPA_ROUTES.tasks.path, + SPA_ROUTES.projects.path, + SPA_ROUTES.settings.path, + SPA_ROUTES.admin.path, + SPA_ROUTES.projectDetail.path.replace(':id', 'mission-1'), + ])('renders the dashboard shell around the page at %s', async (path) => { + mockSignedIn(); + + const view = await renderApp(path); + + expect(view.container.querySelector('aside')).not.toBeNull(); + expect(view.container.textContent).toContain('Sign out'); + }); + + it.each([ + SPA_ROUTES.login.path, + SPA_ROUTES.register.path, + SPA_ROUTES.authProvider.path.replace(':provider', 'authentik'), + ])('renders the auth page without the dashboard shell at %s', async (path) => { + mockSignedOut(); + + const view = await renderApp(path); + + expect(view.container.querySelector('aside')).toBeNull(); + expect(view.container.textContent).not.toContain('Sign out'); + expect(view.container.textContent).not.toContain('Mission Control'); + }); +}); + +describe('AppShellLayout sidebar nav', () => { + it('marks only the current destination as the current page', async () => { + mockSignedIn(); + + const view = await renderApp(SPA_ROUTES.tasks.path); + + const current = navLinks(view.container).filter( + (link) => link.getAttribute('aria-current') === 'page', + ); + expect(current).toHaveLength(1); + expect(current[0]?.textContent).toContain('Tasks'); + }); + + it('marks Projects as current on a nested project detail route', async () => { + mockSignedIn(); + + const view = await renderApp(SPA_ROUTES.projectDetail.path.replace(':id', 'mission-1')); + + const current = navLinks(view.container).filter( + (link) => link.getAttribute('aria-current') === 'page', + ); + expect(current).toHaveLength(1); + expect(current[0]?.textContent).toContain('Projects'); + }); + + it('targets exactly the workspace nav destinations', async () => { + mockSignedIn(); + + const view = await renderApp(SPA_ROUTES.chat.path); + + const links = navLinks(view.container); + const renderedHrefs = links.map((link) => link.getAttribute('href')); + expect(renderedHrefs).toEqual(workspaceNavItems.map((item) => item.href)); + // Independent of the href comparison above: every nav item must render a + // non-empty accessible name — href parity alone cannot catch an item + // whose label stops rendering. + for (const link of links) { + expect((link.textContent ?? '').trim().length).toBeGreaterThan(0); + } + }); + + it('covers SPA_ROUTES so a new dashboard route cannot silently miss the nav', () => { + // Routes intentionally absent from the nav: the root redirect, the auth + // group, and the project detail view (reached through the Projects nav). + // Any future SPA_ROUTES entry must join the nav or this exclusion list. + const nonNavRoutes = [ + SPA_ROUTES.root, + SPA_ROUTES.login, + SPA_ROUTES.register, + SPA_ROUTES.authProvider, + SPA_ROUTES.projectDetail, + ]; + + const navAndExcluded = [ + ...workspaceNavItems.map((item) => item.href), + ...nonNavRoutes.map((route) => route.path), + ].sort(); + const contractedPaths = Object.values(SPA_ROUTES) + .map((route) => route.path) + .sort(); + + expect(navAndExcluded).toEqual(contractedPaths); + }); +}); + +describe('AppShellLayout topbar', () => { + it('shows the session name, falling back to email', async () => { + mockSignedIn(); + + const named = await renderApp(SPA_ROUTES.settings.path); + expect(named.container.textContent).toContain('Ada Lovelace'); + + useSessionMock.mockReturnValue({ + data: { ...adminSession, user: { ...adminSession.user, name: null } }, + isPending: false, + }); + + const unnamed = await renderApp(SPA_ROUTES.settings.path); + expect(unnamed.container.textContent).toContain('ada@example.com'); + }); + + it('flips the document theme from the topbar toggle', async () => { + mockSignedIn(); + + const view = await renderApp(SPA_ROUTES.tasks.path); + + expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + + const toggle = view.container.querySelector( + 'button[title="Switch to light mode"]', + ); + expect(toggle).not.toBeNull(); + + await act(async () => { + toggle?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(document.documentElement.getAttribute('data-theme')).toBe('light'); + }); +}); + +describe('AppShellLayout sign-out', () => { + it('tears the document down to the login route without soft-navigating while the session atom is still populated', async () => { + mockSignedIn(); + // Real client ordering (better-auth 1.5.5): the sign-out POST resolves + // while the session atom is still populated — it only clears on a delayed + // /get-session refetch. A soft navigate to /login in that window is the + // GuestGuard bounce the handler must avoid, so the mock must NOT touch + // the session store. + signOutMock.mockResolvedValue({ data: { success: true }, error: null }); + + const view = await renderApp(SPA_ROUTES.chat.path); + const { navigated, stop } = trackSoftNavigations(view); + const location = stubDocumentLocation(); + + try { + await clickSignOut(view); + } finally { + stop(); + location.restore(); + } + + expect(signOutMock).toHaveBeenCalledTimes(1); + // A successful sign-out hands the whole document to the login route via + // window.location.replace — atom, socket singleton and provider state are + // discarded together. + expect(location.replace).toHaveBeenCalledTimes(1); + expect(location.replace).toHaveBeenCalledWith(SPA_ROUTES.login.path); + // ...and it must be that hard handoff, not a soft navigate: the pre-fix + // handler navigated to /login and GuestGuard bounced it straight back + // through a re-mounted authenticated shell. + expect(navigated).toEqual([]); + expect(view.router.state.location.pathname).toBe(SPA_ROUTES.chat.path); + }); + + it('keeps the session shell and surfaces the error when sign-out resolves with an error', async () => { + mockSignedIn(); + // better-fetch resolves HTTP failures (500/403 on session revocation) as + // { data: null, error } without throwing — the user must not be sent to + // a signed-out URL while the session is still live. + signOutMock.mockResolvedValue({ + data: null, + error: { message: 'Server error', status: 500 }, + }); + + const view = await renderApp(SPA_ROUTES.chat.path); + const { navigated, stop } = trackSoftNavigations(view); + const location = stubDocumentLocation(); + + try { + await clickSignOut(view); + } finally { + stop(); + location.restore(); + } + + expect(location.replace).not.toHaveBeenCalled(); + expect(navigated).toEqual([]); + expect(view.router.state.location.pathname).toBe(SPA_ROUTES.chat.path); + expect(view.container.querySelector('[role="alert"]')?.textContent).toContain( + 'Sign out failed', + ); + // The retry affordance stays mounted. + expect( + [...view.container.querySelectorAll('button')].some( + (button) => button.textContent === 'Sign out', + ), + ).toBe(true); + }); + + it('keeps the session shell and surfaces the error when sign-out rejects', async () => { + mockSignedIn(); + // Network-level failures reject (better-auth leaves catchAllError + // unset); before the fix that rejection was unhandled and the button + // silently did nothing. + signOutMock.mockRejectedValue(new Error('network unreachable')); + + const view = await renderApp(SPA_ROUTES.chat.path); + const { navigated, stop } = trackSoftNavigations(view); + const location = stubDocumentLocation(); + + try { + await clickSignOut(view); + } finally { + stop(); + location.restore(); + } + + expect(location.replace).not.toHaveBeenCalled(); + expect(navigated).toEqual([]); + expect(view.router.state.location.pathname).toBe(SPA_ROUTES.chat.path); + expect(view.container.querySelector('[role="alert"]')?.textContent).toContain( + 'Sign out failed', + ); + }); +}); + +describe('AppShellLayout sidebar toggle', () => { + it('collapses and restores the sidebar on desktop from the topbar toggle', async () => { + mockSignedIn(); + + const view = await renderApp(SPA_ROUTES.chat.path); + + const shell = () => view.container.querySelector('.app-shell'); + expect(shell()?.getAttribute('data-sidebar-hidden')).toBeNull(); + + await act(async () => { + findButtonByLabel(view.container, 'Toggle sidebar').dispatchEvent( + new MouseEvent('click', { bubbles: true }), + ); + }); + + expect(shell()?.getAttribute('data-sidebar-hidden')).toBe('true'); + + await act(async () => { + findButtonByLabel(view.container, 'Toggle sidebar').dispatchEvent( + new MouseEvent('click', { bubbles: true }), + ); + }); + + expect(shell()?.getAttribute('data-sidebar-hidden')).toBeNull(); + }); +}); + +describe('AppShellLayout shell persistence', () => { + it('keeps one shell mounted across navigation and retains the collapsed state', async () => { + mockSignedIn(); + + const view = await renderApp(SPA_ROUTES.chat.path); + + // Collapse the sidebar on desktop first. + const shell = () => view.container.querySelector('.app-shell'); + expect(shell()?.getAttribute('data-sidebar-hidden')).toBeNull(); + + await act(async () => { + findButtonByLabel(view.container, 'Toggle sidebar').dispatchEvent( + new MouseEvent('click', { bubbles: true }), + ); + }); + expect(shell()?.getAttribute('data-sidebar-hidden')).toBe('true'); + + // Navigate through the sidebar nav itself, covering nav-driven + // navigation. + const tasksLink = navLinks(view.container).find( + (link) => link.getAttribute('href') === SPA_ROUTES.tasks.path, + ); + expect(tasksLink).toBeDefined(); + + await act(async () => { + tasksLink?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + // The routed page swapped under the shell... + expect(view.router.state.location.pathname).toBe(SPA_ROUTES.tasks.path); + expect(view.container.querySelector('h1')?.textContent).toBe('Tasks'); + expect(view.container.textContent).not.toContain('Chat page'); + + // ...while the same SidebarProvider instance survived: its collapsed + // state is still set and the shell is still mounted. A per-page or + // pathname-keyed shell would reset collapsed to false here. + expect(shell()?.getAttribute('data-sidebar-hidden')).toBe('true'); + expect(view.container.querySelector('aside')).not.toBeNull(); + }); +}); + +describe('AppShellLayout mobile drawer', () => { + it('opens the drawer from the topbar toggle and closes it from the overlay', async () => { + mockSignedIn(); + + const view = await renderApp(SPA_ROUTES.tasks.path, { mobile: true }); + + const sidebar = () => view.container.querySelector('aside'); + expect(sidebar()?.getAttribute('data-mobile-open')).toBeNull(); + + await act(async () => { + findButtonByLabel(view.container, 'Toggle sidebar').dispatchEvent( + new MouseEvent('click', { bubbles: true }), + ); + }); + + expect(sidebar()?.getAttribute('data-mobile-open')).toBe('true'); + expect(view.container.querySelector('[aria-label="Close sidebar"]')).not.toBeNull(); + + await act(async () => { + findButtonByLabel(view.container, 'Close sidebar').dispatchEvent( + new MouseEvent('click', { bubbles: true }), + ); + }); + + expect(sidebar()?.getAttribute('data-mobile-open')).toBeNull(); + expect(view.container.querySelector('[aria-label="Close sidebar"]')).toBeNull(); + }); +}); diff --git a/apps/web/src/spa/layout/app-shell.tsx b/apps/web/src/spa/layout/app-shell.tsx new file mode 100644 index 000000000..4af285adf --- /dev/null +++ b/apps/web/src/spa/layout/app-shell.tsx @@ -0,0 +1,35 @@ +import type { ReactElement } from 'react'; +import { Outlet } from 'react-router-dom'; +import { SidebarProvider, useSidebar } from '@/components/layout/sidebar-context'; +import { Sidebar } from './sidebar'; +import { Topbar } from './topbar'; + +// The shared `@/components/layout/app-shell` cannot be used directly: its +// sidebar/topbar route through `next/link` and `next/navigation`, which only +// work inside the Next.js app runtime. This is the same relationship +// `spa/guards.tsx` has to the Next guards — identical structure, expressed with +// react-router primitives — mounted as a pathless layout route so the shell +// renders once and persists across navigation. +function AppShellFrame(): ReactElement { + const { collapsed, isMobile } = useSidebar(); + + return ( +
+ + +
+
+ +
+
+
+ ); +} + +export function AppShellLayout(): ReactElement { + return ( + + + + ); +} diff --git a/apps/web/src/spa/layout/sidebar.tsx b/apps/web/src/spa/layout/sidebar.tsx new file mode 100644 index 000000000..64632fd59 --- /dev/null +++ b/apps/web/src/spa/layout/sidebar.tsx @@ -0,0 +1,182 @@ +import { Link, useLocation } from 'react-router-dom'; +import { SPA_ROUTES } from '@mosaicstack/types'; +import { cn } from '@/lib/cn'; +import { MosaicLogo } from '@/components/ui/mosaic-logo'; +import { useSidebar } from '@/components/layout/sidebar-context'; + +interface NavItem { + label: string; + href: string; + icon: React.JSX.Element; +} + +function IconChat(): React.JSX.Element { + return ( + + + + ); +} + +function IconTasks(): React.JSX.Element { + return ( + + + + + ); +} + +function IconProjects(): React.JSX.Element { + return ( + + + + ); +} + +function IconSettings(): React.JSX.Element { + return ( + + + + + ); +} + +function IconAdmin(): React.JSX.Element { + return ( + + + + + ); +} + +// Every href resolves through SPA_ROUTES; the workspace nav must never embed a +// literal path. The layout spec asserts this list against SPA_ROUTES so a new +// dashboard route cannot silently miss the nav. +export const workspaceNavItems: NavItem[] = [ + { label: 'Chat', href: SPA_ROUTES.chat.path, icon: }, + { label: 'Tasks', href: SPA_ROUTES.tasks.path, icon: }, + { label: 'Projects', href: SPA_ROUTES.projects.path, icon: }, + { label: 'Settings', href: SPA_ROUTES.settings.path, icon: }, + { label: 'Admin', href: SPA_ROUTES.admin.path, icon: }, +]; + +export function Sidebar(): React.JSX.Element { + const { pathname } = useLocation(); + const { mobileOpen, setMobileOpen } = useSidebar(); + + return ( + <> + + + {mobileOpen ? ( + +
+
Workspace
+
Unified agent operations
+
+ + +
+ + {session?.user ? ( + <> + {signOutFailed ? ( + + Sign out failed. Try again. + + ) : null} + + {session.user.name ?? session.user.email} + + + + ) : ( + Not signed in + )} +
+ + ); +} diff --git a/apps/web/src/spa/pages/admin.spec.tsx b/apps/web/src/spa/pages/admin.spec.tsx new file mode 100644 index 000000000..73e0dd180 --- /dev/null +++ b/apps/web/src/spa/pages/admin.spec.tsx @@ -0,0 +1,238 @@ +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'; +import { SPA_ROUTES } from '@mosaicstack/types'; + +const { apiMock, useSessionMock } = vi.hoisted(() => ({ + apiMock: vi.fn(), + useSessionMock: vi.fn(), +})); + +vi.mock('@/lib/api', () => ({ + api: apiMock, +})); + +vi.mock('@/lib/auth-client', () => ({ + useSession: useSessionMock, +})); + +import { AdminPage } from './admin'; + +const adminSession = { + user: { id: 'admin-1', name: 'Grace', email: 'grace@example.com', role: 'admin' }, +}; + +const memberSession = { + user: { id: 'user-2', name: 'Alan', email: 'alan@example.com', role: 'member' }, +}; + +const userFixtures = [ + { + id: 'admin-1', + name: 'Grace', + email: 'grace@example.com', + role: 'admin', + banned: false, + banReason: null, + createdAt: '2026-03-01T09:00:00.000Z', + updatedAt: '2026-03-01T09:00:00.000Z', + }, + { + id: 'user-2', + name: 'Alan', + email: 'alan@example.com', + role: 'member', + banned: false, + banReason: null, + createdAt: '2026-04-01T09:00:00.000Z', + updatedAt: '2026-04-01T09:00:00.000Z', + }, +]; + +const healthFixture = { + status: 'ok', + database: { status: 'ok', latencyMs: 3 }, + cache: { status: 'ok', latencyMs: 1 }, + agentPool: { activeSessions: 2 }, + providers: [{ id: 'ollama', name: 'Ollama', available: true, modelCount: 5 }], + checkedAt: '2026-04-20T12:00:00.000Z', +}; + +let root: Root | null = null; +let container: HTMLDivElement; + +beforeAll(() => { + Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { + configurable: true, + value: true, + }); +}); + +afterAll(() => { + Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); +}); + +afterEach(async () => { + await act(async () => { + root?.unmount(); + }); + document.body.replaceChildren(); + root = null; + apiMock.mockReset(); + useSessionMock.mockReset(); +}); + +async function renderAdminPage(): Promise> { + const routes: RouteObject[] = [ + { path: SPA_ROUTES.admin.path, element: }, + { path: SPA_ROUTES.root.path, element:

Root redirect target

}, + { path: SPA_ROUTES.login.path, element:

Login redirect target

}, + ]; + const router = createMemoryRouter(routes, { initialEntries: [SPA_ROUTES.admin.path] }); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + + return router; +} + +function clickButtonByText(text: string): void { + const button = [...container.querySelectorAll('button')].find((candidate) => + candidate.textContent?.includes(text), + ); + if (!button) { + throw new Error(`Button containing "${text}" not found`); + } + button.dispatchEvent(new MouseEvent('click', { bubbles: true })); +} + +describe('AdminPage', () => { + it('renders the guard loading state and makes no API calls while the session is pending', async () => { + useSessionMock.mockReturnValue({ data: null, isPending: true }); + + await renderAdminPage(); + + expect(container.textContent).toContain('Loading...'); + expect(container.textContent).not.toContain('Admin Panel'); + expect(apiMock).not.toHaveBeenCalled(); + }); + + it('redirects a signed-out session to the login route', async () => { + useSessionMock.mockReturnValue({ data: null, isPending: false }); + + const router = await renderAdminPage(); + + expect(router.state.location.pathname).toBe(SPA_ROUTES.login.path); + expect(container.textContent).toContain('Login redirect target'); + expect(container.textContent).not.toContain('Admin Panel'); + expect(apiMock).not.toHaveBeenCalled(); + }); + + it('redirects a non-admin session to the app root', async () => { + useSessionMock.mockReturnValue({ data: memberSession, isPending: false }); + + const router = await renderAdminPage(); + + expect(router.state.location.pathname).toBe(SPA_ROUTES.root.path); + expect(container.textContent).toContain('Root redirect target'); + expect(container.textContent).not.toContain('Admin Panel'); + expect(apiMock).not.toHaveBeenCalled(); + }); + + it('loads and renders the user management table for admins', async () => { + useSessionMock.mockReturnValue({ data: adminSession, isPending: false }); + apiMock.mockResolvedValueOnce({ users: userFixtures, total: 2 }); + + await renderAdminPage(); + + expect(apiMock.mock.calls).toEqual([['/api/admin/users']]); + expect(container.textContent).toContain('Admin Panel'); + expect(container.textContent).toContain('2 user(s)'); + expect(container.textContent).toContain('grace@example.com'); + expect(container.textContent).toContain('alan@example.com'); + expect(container.textContent).toContain('Demote'); + expect(container.textContent).toContain('Promote'); + expect(container.textContent).toContain('Active'); + }); + + it('promotes a member through the role endpoint and reloads the user list', async () => { + useSessionMock.mockReturnValue({ data: adminSession, isPending: false }); + apiMock + .mockResolvedValueOnce({ users: userFixtures, total: 2 }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ + users: userFixtures.map((user) => + user.id === 'user-2' ? { ...user, role: 'admin' } : user, + ), + total: 2, + }); + + await renderAdminPage(); + + const alanRow = [...container.querySelectorAll('tr')].find((candidate) => + candidate.textContent?.includes('alan@example.com'), + ); + expect(alanRow?.textContent).toContain('Promote'); + + await act(async () => { + clickButtonByText('Promote'); + }); + + expect(apiMock.mock.calls).toEqual([ + ['/api/admin/users'], + ['/api/admin/users/user-2/role', { method: 'PATCH', body: { role: 'admin' } }], + ['/api/admin/users'], + ]); + + const updatedAlanRow = [...container.querySelectorAll('tr')].find((candidate) => + candidate.textContent?.includes('alan@example.com'), + ); + expect(updatedAlanRow?.textContent).toContain('Demote'); + }); + + it('renders the users error state and recovers through retry', async () => { + useSessionMock.mockReturnValue({ data: adminSession, isPending: false }); + apiMock.mockRejectedValueOnce(new Error('Admin users offline')); + + await renderAdminPage(); + + expect(container.textContent).toContain('Admin users offline'); + expect(container.textContent).not.toContain('grace@example.com'); + + apiMock.mockResolvedValueOnce({ users: userFixtures, total: 2 }); + + await act(async () => { + clickButtonByText('Retry'); + }); + + expect(container.textContent).toContain('grace@example.com'); + expect(container.textContent).toContain('2 user(s)'); + }); + + it('renders system health status for admins', async () => { + useSessionMock.mockReturnValue({ data: adminSession, isPending: false }); + apiMock.mockResolvedValueOnce({ users: [], total: 0 }).mockResolvedValueOnce(healthFixture); + + await renderAdminPage(); + + await act(async () => { + clickButtonByText('System Health'); + }); + + expect(apiMock.mock.calls).toEqual([['/api/admin/users'], ['/api/admin/health']]); + + expect(container.textContent).toContain('Database (PostgreSQL)'); + expect(container.textContent).toContain('Latency: 3ms'); + expect(container.textContent).toContain('Cache (Valkey)'); + expect(container.textContent).toContain('Active sessions: 2'); + expect(container.textContent).toContain('LLM Providers'); + expect(container.textContent).toContain('Ollama'); + expect(container.textContent).toContain('5 models'); + expect(container.textContent).toContain('Refresh'); + }); +}); diff --git a/apps/web/src/spa/pages/admin.tsx b/apps/web/src/spa/pages/admin.tsx new file mode 100644 index 000000000..050c5e2e9 --- /dev/null +++ b/apps/web/src/spa/pages/admin.tsx @@ -0,0 +1,536 @@ +import { + useCallback, + useEffect, + useState, + type FormEvent, + type ReactElement, + type ReactNode, +} from 'react'; +import { api } from '@/lib/api'; +import { cn } from '@/lib/cn'; +import { AdminRoleGuard } from '@/spa/guards'; + +// ── Types ────────────────────────────────────────────────────────────────────── + +interface UserDto { + id: string; + name: string; + email: string; + role: string; + banned: boolean; + banReason: string | null; + createdAt: string; + updatedAt: string; +} + +interface UserListDto { + users: UserDto[]; + total: number; +} + +interface ServiceStatusDto { + status: 'ok' | 'error'; + latencyMs?: number; + error?: string; +} + +interface ProviderStatusDto { + id: string; + name: string; + available: boolean; + modelCount: number; +} + +interface HealthStatusDto { + status: 'ok' | 'degraded' | 'error'; + database: ServiceStatusDto; + cache: ServiceStatusDto; + agentPool: { activeSessions: number }; + providers: ProviderStatusDto[]; + checkedAt: string; +} + +// ── Admin Page ───────────────────────────────────────────────────────────────── + +export function AdminPage(): ReactElement { + return ( + + + + ); +} + +function AdminContent(): ReactElement { + const [activeTab, setActiveTab] = useState<'users' | 'health'>('users'); + + return ( +
+
+

Admin Panel

+
+ +
+ {(['users', 'health'] as const).map((tab) => ( + + ))} +
+ + {activeTab === 'users' ? : } +
+ ); +} + +// ── Users Tab ────────────────────────────────────────────────────────────────── + +function UsersTab(): ReactElement { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showCreate, setShowCreate] = useState(false); + + const loadUsers = useCallback(async (): Promise => { + setLoading(true); + setError(null); + try { + const data = await api('/api/admin/users'); + setUsers(data.users); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load users'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadUsers(); + }, [loadUsers]); + + async function handleRoleToggle(user: UserDto): Promise { + const newRole = user.role === 'admin' ? 'member' : 'admin'; + try { + await api(`/api/admin/users/${user.id}/role`, { + method: 'PATCH', + body: { role: newRole }, + }); + await loadUsers(); + } catch (err) { + alert(err instanceof Error ? err.message : 'Failed to update role'); + } + } + + async function handleBanToggle(user: UserDto): Promise { + const endpoint = user.banned ? 'unban' : 'ban'; + try { + await api(`/api/admin/users/${user.id}/${endpoint}`, { method: 'POST' }); + await loadUsers(); + } catch (err) { + alert(err instanceof Error ? err.message : 'Failed to update ban status'); + } + } + + async function handleDelete(user: UserDto): Promise { + if (!confirm(`Delete user ${user.email}? This cannot be undone.`)) return; + try { + await api(`/api/admin/users/${user.id}`, { method: 'DELETE' }); + await loadUsers(); + } catch (err) { + alert(err instanceof Error ? err.message : 'Failed to delete user'); + } + } + + if (loading) { + return

Loading users...

; + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + return ( +
+
+

{users.length} user(s)

+ +
+ + {showCreate && ( + setShowCreate(false)} + onCreated={() => { + setShowCreate(false); + void loadUsers(); + }} + /> + )} + + {users.length === 0 ? ( +
+

No users found

+
+ ) : ( +
+ + + + + + + + + + + + {users.map((user) => ( + + + + + + + + ))} + +
Name / EmailRoleStatusCreatedActions
+
{user.name}
+
{user.email}
+
+ + {user.role} + + + {user.banned ? ( + + Banned + + ) : ( + + Active + + )} + + {new Date(user.createdAt).toLocaleDateString()} + +
+ + + +
+
+
+ )} +
+ ); +} + +// ── Create User Form ────────────────────────────────────────────────────────── + +interface CreateUserFormProps { + onCancel: () => void; + onCreated: () => void; +} + +function CreateUserForm({ onCancel, onCreated }: CreateUserFormProps): ReactElement { + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [role, setRole] = useState('member'); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(e: FormEvent): Promise { + e.preventDefault(); + setSubmitting(true); + setError(null); + try { + await api('/api/admin/users', { + method: 'POST', + body: { name, email, password, role }, + }); + onCreated(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create user'); + } finally { + setSubmitting(false); + } + } + + return ( +
+

Create New User

+
void handleSubmit(e)} className="space-y-3"> + {error &&

{error}

} +
+
+ + setName(e.target.value)} + className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+
+ + setEmail(e.target.value)} + className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+
+ + setPassword(e.target.value)} + className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+
+ + +
+
+
+ + +
+
+
+ ); +} + +// ── Health Tab ──────────────────────────────────────────────────────────────── + +function HealthTab(): ReactElement { + const [health, setHealth] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadHealth = useCallback(async (): Promise => { + setLoading(true); + setError(null); + try { + const data = await api('/api/admin/health'); + setHealth(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load health'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadHealth(); + }, [loadHealth]); + + if (loading) { + return

Loading health status...

; + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + if (!health) return <>; + + return ( +
+
+
+ + + Last checked: {new Date(health.checkedAt).toLocaleTimeString()} + +
+ +
+ +
+ {/* Database */} + + {health.database.latencyMs !== undefined && ( +

Latency: {health.database.latencyMs}ms

+ )} + {health.database.error &&

{health.database.error}

} +
+ + {/* Cache */} + + {health.cache.latencyMs !== undefined && ( +

Latency: {health.cache.latencyMs}ms

+ )} + {health.cache.error &&

{health.cache.error}

} +
+ + {/* Agent Pool */} + +

+ Active sessions: {health.agentPool.activeSessions} +

+
+ + {/* Providers */} + p.available) ? 'ok' : 'error'} + > + {health.providers.length === 0 ? ( +

No providers configured

+ ) : ( +
    + {health.providers.map((p) => ( +
  • + {p.name} + + {p.available ? `${p.modelCount} models` : 'unavailable'} + +
  • + ))} +
+ )} +
+
+
+ ); +} + +// ── Helper Components ───────────────────────────────────────────────────────── + +function StatusBadge({ status }: { status: 'ok' | 'degraded' | 'error' }): ReactElement { + const map = { + ok: 'bg-green-500/20 text-green-400', + degraded: 'bg-yellow-500/20 text-yellow-400', + error: 'bg-red-500/20 text-red-400', + }; + return ( + + {status} + + ); +} + +interface HealthCardProps { + title: string; + status: 'ok' | 'error'; + children?: ReactNode; +} + +function HealthCard({ title, status, children }: HealthCardProps): ReactElement { + return ( +
+
+

{title}

+ +
+ {children} +
+ ); +} diff --git a/apps/web/src/spa/pages/chat-error-boundary.spec.tsx b/apps/web/src/spa/pages/chat-error-boundary.spec.tsx index 1b627491a..d8bf34aac 100644 --- a/apps/web/src/spa/pages/chat-error-boundary.spec.tsx +++ b/apps/web/src/spa/pages/chat-error-boundary.spec.tsx @@ -12,6 +12,7 @@ vi.mock('@/lib/auth-client', () => ({ })); import { routes } from '@/routes'; +import { ThemeProvider } from '@/providers/theme-provider'; beforeAll(() => { Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { @@ -73,7 +74,11 @@ describe('ChatRouteErrorBoundary', () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); try { await act(async () => { - root?.render(); + root?.render( + + + , + ); }); expect(consoleErrorSpy).toHaveBeenCalled(); diff --git a/apps/web/src/spa/pages/page-fixtures.ts b/apps/web/src/spa/pages/page-fixtures.ts index cd52be5db..43f799593 100644 --- a/apps/web/src/spa/pages/page-fixtures.ts +++ b/apps/web/src/spa/pages/page-fixtures.ts @@ -1,3 +1,4 @@ +import type { CoordMissionStatus } from './projects'; import type { Mission, Project, Task } from '@/lib/types'; export const projectFixtures: Project[] = [ @@ -25,6 +26,33 @@ export const projectFixtures: Project[] = [ }, ]; +export const coordMissionStatusFixture: CoordMissionStatus = { + mission: { + id: 'coord-mission-1', + name: 'Ship the gateway static surface', + status: 'active', + projectPath: '/workspace/stack', + }, + milestones: { + total: 4, + completed: 1, + current: { + id: 'milestone-2', + name: 'Static serving', + status: 'in-progress', + }, + }, + tasks: { + total: 7, + done: 3, + inProgress: 2, + pending: 1, + blocked: 1, + cancelled: 0, + }, + nextTaskId: 'task-5', +}; + export const missionFixtures: Mission[] = [ { id: 'mission-1', diff --git a/apps/web/src/spa/pages/projects.spec.tsx b/apps/web/src/spa/pages/projects.spec.tsx index e030fc22f..01264199e 100644 --- a/apps/web/src/spa/pages/projects.spec.tsx +++ b/apps/web/src/spa/pages/projects.spec.tsx @@ -2,7 +2,7 @@ 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'; -import { projectFixtures } from './page-fixtures'; +import { coordMissionStatusFixture, projectFixtures } from './page-fixtures'; const { apiMock } = vi.hoisted(() => ({ apiMock: vi.fn(), @@ -30,6 +30,22 @@ function createDeferred(): Deferred { return { promise, resolve, reject }; } +/** + * Stubs the gateway API by request path. Every path the page fetches must be + * registered explicitly, so a new unmocked fetch fails loudly instead of + * silently resolving to undefined. + */ +function mockApiByPath(handlers: Record): void { + apiMock.mockImplementation((path: string) => { + if (!(path in handlers)) { + throw new Error(`No API mock registered for ${path}`); + } + const response = handlers[path]; + if (response instanceof Error) return Promise.reject(response); + return Promise.resolve(response); + }); +} + let root: Root | null = null; let container: HTMLDivElement; @@ -71,10 +87,25 @@ async function renderProjectsPage(): Promise element.textContent === label, + ); + return labelElement?.nextElementSibling?.textContent ?? null; +} + describe('ProjectsPage', () => { it('shows a visible loading state while the project request is in flight', async () => { const deferred = createDeferred(); - apiMock.mockReturnValueOnce(deferred.promise); + mockApiByPath({ + '/api/projects': deferred.promise, + '/api/coord/status': coordMissionStatusFixture, + }); await renderProjectsPage(); @@ -87,7 +118,10 @@ describe('ProjectsPage', () => { }); it('renders project cards from the API and navigates to a project detail route on click', async () => { - apiMock.mockResolvedValueOnce(projectFixtures); + mockApiByPath({ + '/api/projects': projectFixtures, + '/api/coord/status': coordMissionStatusFixture, + }); const router = await renderProjectsPage(); @@ -109,7 +143,10 @@ describe('ProjectsPage', () => { }); it('renders the empty state when the API returns no projects', async () => { - apiMock.mockResolvedValueOnce([]); + mockApiByPath({ + '/api/projects': [], + '/api/coord/status': coordMissionStatusFixture, + }); await renderProjectsPage(); @@ -120,7 +157,10 @@ describe('ProjectsPage', () => { }); it('renders a visible alert when the projects request fails', async () => { - apiMock.mockRejectedValueOnce(new Error('Projects are unavailable')); + mockApiByPath({ + '/api/projects': new Error('Projects are unavailable'), + '/api/coord/status': coordMissionStatusFixture, + }); await renderProjectsPage(); @@ -128,4 +168,117 @@ describe('ProjectsPage', () => { expect(alert).toBeTruthy(); expect(alert?.textContent).toContain('Projects are unavailable'); }); + + describe('active mission section', () => { + it('renders the active mission summary from the coord status endpoint below the project grid', async () => { + mockApiByPath({ + '/api/projects': projectFixtures, + '/api/coord/status': coordMissionStatusFixture, + }); + + await renderProjectsPage(); + + expect(apiMock).toHaveBeenCalledWith('/api/coord/status'); + expect(container.textContent).toContain('Active Mission'); + + const pageText = container.textContent ?? ''; + const gridEnd = pageText.indexOf('Agent Runtime'); + const sectionStart = pageText.indexOf('Active Mission'); + expect(gridEnd).toBeGreaterThanOrEqual(0); + expect(sectionStart).toBeGreaterThan(gridEnd); + + expect(pageText).toContain('coord-mission-1'); + expect(pageText).toContain('Static serving'); + expect(pageText).toContain('3 / 7'); + expect(pageText).toContain('active'); + + expect(statValue('Mission')).toBe('coord-mission-1'); + expect(statValue('Phase')).toBe('Static serving'); + expect(statValue('Tasks')).toBe('3 / 7'); + expect(statValue('Status')).toBe('active'); + }); + + it('shows a mission loading state while the coord status request is in flight', async () => { + const deferred = createDeferred(); + mockApiByPath({ + '/api/projects': projectFixtures, + '/api/coord/status': deferred.promise, + }); + + await renderProjectsPage(); + + expect(container.textContent).toContain('Loading mission status...'); + + await act(async () => { + deferred.resolve(coordMissionStatusFixture); + await deferred.promise; + }); + + expect(container.textContent).toContain('coord-mission-1'); + }); + + it('shows the no-active-mission state when the gateway reports no coord mission', async () => { + mockApiByPath({ + '/api/projects': projectFixtures, + '/api/coord/status': Object.assign(new Error('No active coord mission found'), { + statusCode: 404, + }), + }); + + await renderProjectsPage(); + + expect(container.textContent).toContain('No active mission detected'); + expect(container.querySelector('[role="alert"]')).toBeNull(); + }); + + it('surfaces a visible alert when the coord status request fails', async () => { + mockApiByPath({ + '/api/projects': projectFixtures, + '/api/coord/status': new Error('Mission status unavailable'), + }); + + await renderProjectsPage(); + + const alert = container.querySelector('[role="alert"]'); + expect(alert).toBeTruthy(); + expect(alert?.textContent).toContain('Mission status unavailable'); + }); + + it('keeps the project grid rendered when the coord status request fails', async () => { + mockApiByPath({ + '/api/projects': projectFixtures, + '/api/coord/status': new Error('Mission status unavailable'), + }); + + await renderProjectsPage(); + + expect(container.textContent).toContain('Mosaic Stack'); + expect(container.textContent).toContain('Agent Runtime'); + + const alert = container.querySelector('[role="alert"]'); + expect(alert?.textContent).toContain('Mission status unavailable'); + }); + + it('renders a mission alert and keeps the project grid when a 200 returns JSON of the wrong shape', async () => { + mockApiByPath({ + '/api/projects': projectFixtures, + '/api/coord/status': { + mission: { id: 42, status: 'active' }, + milestones: { total: 4, completed: 1 }, + tasks: { total: 'seven', done: 3 }, + }, + }); + + await renderProjectsPage(); + + const alert = container.querySelector('[role="alert"]'); + expect(alert).toBeTruthy(); + expect(alert?.textContent).toContain('malformed'); + + expect(container.textContent).not.toContain('No active mission detected'); + + expect(container.textContent).toContain('Mosaic Stack'); + expect(container.textContent).toContain('Agent Runtime'); + }); + }); }); diff --git a/apps/web/src/spa/pages/projects.tsx b/apps/web/src/spa/pages/projects.tsx index 5ab7a7460..2c67bb685 100644 --- a/apps/web/src/spa/pages/projects.tsx +++ b/apps/web/src/spa/pages/projects.tsx @@ -5,6 +5,141 @@ import { api } from '@/lib/api'; import type { Project } from '@/lib/types'; import { getErrorMessage } from './page-errors'; +export interface CoordMissionStatus { + mission: { + id: string; + name: string; + status: string; + projectPath: string; + }; + milestones: { + total: number; + completed: number; + current?: { + id: string; + name: string; + status: string; + }; + }; + tasks: { + total: number; + done: number; + inProgress: number; + pending: number; + blocked: number; + cancelled: number; + }; + nextTaskId?: string; +} + +function isNoMissionResponse(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + (error as { statusCode?: unknown }).statusCode === 404 + ); +} + +/** + * Narrow runtime shape check for the /api/coord/status payload. `api()` casts + * the JSON body without validation, so a 200 carrying parseable JSON of the + * wrong shape would otherwise throw during render and take the whole route + * (grid included) down via the route error boundary. Only the levels the page + * actually dereferences are checked; `milestones.current` is optional per the + * contract and is already handled with `?.` at the render site. + */ +function isCoordMissionStatus(payload: unknown): payload is CoordMissionStatus { + if (typeof payload !== 'object' || payload === null) return false; + const { mission, milestones, tasks } = payload as { + mission?: unknown; + milestones?: unknown; + tasks?: unknown; + }; + return ( + typeof mission === 'object' && + mission !== null && + typeof (mission as { id?: unknown }).id === 'string' && + typeof (mission as { status?: unknown }).status === 'string' && + typeof milestones === 'object' && + milestones !== null && + typeof tasks === 'object' && + tasks !== null && + typeof (tasks as { done?: unknown }).done === 'number' && + typeof (tasks as { total?: unknown }).total === 'number' + ); +} + +function MissionStatus(): ReactElement { + const [mission, setMission] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + void api('/api/coord/status') + .then((response) => { + if (cancelled) return; + if (!isCoordMissionStatus(response)) { + setError('Mission status response was malformed.'); + return; + } + setMission(response); + }) + .catch((caught: unknown) => { + if (cancelled) return; + if (isNoMissionResponse(caught)) { + setMission(null); + return; + } + setError(getErrorMessage(caught, 'Failed to load mission status.')); + }) + .finally(() => { + if (cancelled) return; + setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); + + return ( +
+

Active Mission

+ {loading ? ( +

Loading mission status...

+ ) : error ? ( +
+ {error} +
+ ) : !mission ? ( +
+

No active mission detected

+
+ ) : ( +
+
+ + + + +
+
+ )} +
+ ); +} + +function StatCard({ label, value }: { label: string; value: string }): ReactElement { + return ( +
+

{label}

+

{value}

+
+ ); +} + export function ProjectsPage(): ReactElement { const navigate = useNavigate(); const [projects, setProjects] = useState([]); @@ -65,6 +200,8 @@ export function ProjectsPage(): ReactElement { ))} )} + + ); } diff --git a/apps/web/src/spa/pages/resource-route-boundaries.spec.tsx b/apps/web/src/spa/pages/resource-route-boundaries.spec.tsx index 912a2b6fd..3ea13a960 100644 --- a/apps/web/src/spa/pages/resource-route-boundaries.spec.tsx +++ b/apps/web/src/spa/pages/resource-route-boundaries.spec.tsx @@ -12,6 +12,7 @@ vi.mock('@/lib/auth-client', () => ({ })); import { routes } from '@/routes'; +import { ThemeProvider } from '@/providers/theme-provider'; function Boom(): never { throw new Error('resource route render blew up'); @@ -54,7 +55,7 @@ afterEach(async () => { }); describe('resource route error boundaries', () => { - it.each(['/projects', '/projects/:id', '/tasks'])( + it.each(['/projects', '/projects/:id', '/tasks', '/settings', '/admin'])( 'renders a recoverable fallback when %s throws during route render', async (path) => { useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false }); @@ -71,7 +72,11 @@ describe('resource route error boundaries', () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); try { await act(async () => { - root?.render(); + root?.render( + + + , + ); }); expect(consoleErrorSpy).toHaveBeenCalled(); diff --git a/apps/web/src/spa/pages/resource-route-error-boundaries.tsx b/apps/web/src/spa/pages/resource-route-error-boundaries.tsx index f1a3b11e8..0063885b9 100644 --- a/apps/web/src/spa/pages/resource-route-error-boundaries.tsx +++ b/apps/web/src/spa/pages/resource-route-error-boundaries.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; import { useRouteError } from 'react-router-dom'; +import { SPA_ROUTES } from '@mosaicstack/types'; interface ResourceRouteErrorBoundaryProps { message: string; @@ -28,7 +29,7 @@ export function ProjectsRouteErrorBoundary(): ReactElement { return ( ); @@ -38,7 +39,7 @@ export function ProjectDetailRouteErrorBoundary(): ReactElement { return ( ); @@ -48,8 +49,28 @@ export function TasksRouteErrorBoundary(): ReactElement { return ( ); } + +export function SettingsRouteErrorBoundary(): ReactElement { + return ( + + ); +} + +export function AdminRouteErrorBoundary(): ReactElement { + return ( + + ); +} diff --git a/apps/web/src/spa/pages/settings.spec.tsx b/apps/web/src/spa/pages/settings.spec.tsx new file mode 100644 index 000000000..023498cf2 --- /dev/null +++ b/apps/web/src/spa/pages/settings.spec.tsx @@ -0,0 +1,308 @@ +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'; +import { SPA_ROUTES } from '@mosaicstack/types'; + +const { apiMock, useSessionMock, updateUserMock } = vi.hoisted(() => ({ + apiMock: vi.fn(), + useSessionMock: vi.fn(), + updateUserMock: vi.fn(), +})); + +vi.mock('@/lib/api', () => ({ + api: apiMock, +})); + +vi.mock('@/lib/auth-client', () => ({ + authClient: { updateUser: updateUserMock }, + useSession: useSessionMock, +})); + +import { SettingsPage } from './settings'; + +const sessionFixture = { + user: { id: 'user-1', name: 'Ada Lovelace', email: 'ada@example.com', image: null }, +}; + +let root: Root | null = null; +let container: HTMLDivElement; + +beforeAll(() => { + Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { + configurable: true, + value: true, + }); +}); + +afterAll(() => { + Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); +}); + +afterEach(async () => { + await act(async () => { + root?.unmount(); + }); + document.body.replaceChildren(); + root = null; + apiMock.mockReset(); + useSessionMock.mockReset(); + updateUserMock.mockReset(); +}); + +async function renderSettingsPage(): Promise> { + const routes: RouteObject[] = [ + { path: SPA_ROUTES.settings.path, element: }, + { path: SPA_ROUTES.root.path, element:

Root

}, + ]; + const router = createMemoryRouter(routes, { initialEntries: [SPA_ROUTES.settings.path] }); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + + return router; +} + +function clickButtonByText(text: string): void { + const button = [...container.querySelectorAll('button')].find((candidate) => + candidate.textContent?.includes(text), + ); + if (!button) { + throw new Error(`Button containing "${text}" not found`); + } + button.dispatchEvent(new MouseEvent('click', { bubbles: true })); +} + +describe('SettingsPage', () => { + it('renders the profile tab from the session and saves edits through authClient.updateUser', async () => { + useSessionMock.mockReturnValue({ data: sessionFixture }); + updateUserMock.mockResolvedValue({ data: { user: sessionFixture.user }, error: null }); + + await renderSettingsPage(); + + expect(apiMock).not.toHaveBeenCalled(); + + const nameInput = container.querySelector('#profile-name'); + expect(nameInput?.value).toBe('Ada Lovelace'); + + const emailInput = container.querySelector('#profile-email'); + expect(emailInput?.value).toBe('ada@example.com'); + expect(emailInput?.disabled).toBe(true); + + // React's controlled inputs ignore synthetic values assigned directly, so the + // native value setter must run before dispatching the input event. + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value', + )?.set; + nativeInputValueSetter?.call(nameInput, 'Ada K.'); + + await act(async () => { + nameInput?.dispatchEvent(new Event('input', { bubbles: true })); + }); + + await act(async () => { + clickButtonByText('Save changes'); + }); + + expect(updateUserMock).toHaveBeenCalledWith({ name: 'Ada K.', image: null }); + expect(container.textContent).toContain('Saved!'); + }); + + it('surfaces a profile save failure from authClient.updateUser as a visible message', async () => { + useSessionMock.mockReturnValue({ data: sessionFixture }); + updateUserMock.mockResolvedValue({ + data: null, + error: { message: 'Profile update rejected' }, + }); + + await renderSettingsPage(); + + await act(async () => { + clickButtonByText('Save changes'); + }); + + expect(container.textContent).toContain('Profile update rejected'); + }); + + it('loads appearance preferences, reflects the stored theme, and saves them back', async () => { + useSessionMock.mockReturnValue({ data: sessionFixture }); + apiMock.mockImplementation((path: string) => { + if (typeof path === 'string' && path.startsWith('/api/memory/preferences?')) { + return Promise.resolve([ + { key: 'ui.theme', value: 'dark', category: 'appearance' }, + { key: 'ui.sidebar_collapsed', value: true, category: 'appearance' }, + { key: 'ui.default_model', value: 'ollama/llama3.2', category: 'appearance' }, + ]); + } + return Promise.resolve([]); + }); + + await renderSettingsPage(); + + await act(async () => { + clickButtonByText('Appearance'); + }); + + expect( + apiMock.mock.calls.find(([path]) => path === '/api/memory/preferences?category=appearance'), + ).toBeTruthy(); + + expect(container.querySelector('[role="switch"]')?.getAttribute('aria-checked')).toBe('true'); + expect(container.querySelector('#default-model')?.value).toBe( + 'ollama/llama3.2', + ); + + await act(async () => { + clickButtonByText('Save changes'); + }); + + const preferencePosts = apiMock.mock.calls + .filter(([path]) => path === '/api/memory/preferences') + .map(([, init]) => init); + expect(preferencePosts.map((init) => init?.body.key)).toEqual([ + 'ui.theme', + 'ui.sidebar_collapsed', + 'ui.default_model', + ]); + expect(preferencePosts.every((init) => init?.method === 'POST')).toBe(true); + + expect(container.textContent).toContain('Saved!'); + }); + + it('loads notification preferences, toggles one, and saves all three keys', async () => { + useSessionMock.mockReturnValue({ data: sessionFixture }); + apiMock.mockImplementation((path: string) => { + if (typeof path === 'string' && path.startsWith('/api/memory/preferences?')) { + return Promise.resolve([ + { key: 'notify.email_agent_complete', value: false, category: 'communication' }, + { key: 'notify.email_mentions', value: true, category: 'communication' }, + { key: 'notify.email_digest', value: false, category: 'communication' }, + ]); + } + return Promise.resolve([]); + }); + + await renderSettingsPage(); + + await act(async () => { + clickButtonByText('Notifications'); + }); + + expect(container.textContent).toContain('Agent task completed'); + + const agentCompleteSwitch = [...container.querySelectorAll('[role="switch"]')][0]; + expect(agentCompleteSwitch?.getAttribute('aria-checked')).toBe('false'); + + await act(async () => { + agentCompleteSwitch?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(agentCompleteSwitch?.getAttribute('aria-checked')).toBe('true'); + + await act(async () => { + clickButtonByText('Save changes'); + }); + + const savedValues = Object.fromEntries( + apiMock.mock.calls + .filter(([path]) => path === '/api/memory/preferences') + .map(([, init]) => [init?.body.key, init?.body.value]), + ); + expect(savedValues).toEqual({ + 'notify.email_agent_complete': true, + 'notify.email_mentions': true, + 'notify.email_digest': false, + }); + }); + + it('renders SSO and LLM providers, expands models, and tests a provider connection', async () => { + useSessionMock.mockReturnValue({ data: sessionFixture }); + apiMock.mockImplementation((path: string) => { + if (path === '/api/sso/providers') { + return Promise.resolve([ + { + 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: [], + }, + ]); + } + if (path === '/api/providers') { + return Promise.resolve([ + { + id: 'ollama', + name: 'Ollama', + available: true, + models: [ + { + id: 'llama3.2', + provider: 'ollama', + name: 'Llama 3.2', + reasoning: false, + contextWindow: 131_072, + maxTokens: 4_096, + inputTypes: ['text' as const], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ], + }, + ]); + } + if (path === '/api/providers/test') { + return Promise.resolve({ + providerId: 'ollama', + reachable: true, + latencyMs: 42, + discoveredModels: ['llama3.2', 'qwen2.5'], + }); + } + return Promise.resolve([]); + }); + + await renderSettingsPage(); + + await act(async () => { + clickButtonByText('Providers'); + }); + + expect(container.textContent).toContain('SSO Providers'); + expect(container.textContent).toContain('Authentik'); + expect(container.textContent).toContain('Enabled'); + + expect(container.textContent).toContain('Ollama'); + expect(container.textContent).toContain('1 model'); + expect(container.querySelector('table')).toBeNull(); + + await act(async () => { + clickButtonByText('▼ Models'); + }); + + expect(container.querySelector('table')?.textContent).toContain('Llama 3.2'); + expect(container.querySelector('table')?.textContent).toContain('131k'); + expect(container.querySelector('table')?.textContent).toContain('default'); + + await act(async () => { + clickButtonByText('Test'); + }); + + expect(apiMock.mock.calls.find(([path]) => path === '/api/providers/test')?.[1]).toEqual({ + method: 'POST', + body: { providerId: 'ollama' }, + }); + + expect(container.textContent).toContain('✓ Reachable'); + expect(container.textContent).toContain('Connected'); + expect(container.textContent).toContain('(42ms)'); + expect(container.textContent).toContain('2 models discovered'); + }); +}); diff --git a/apps/web/src/spa/pages/settings.tsx b/apps/web/src/spa/pages/settings.tsx new file mode 100644 index 000000000..5647f9777 --- /dev/null +++ b/apps/web/src/spa/pages/settings.tsx @@ -0,0 +1,820 @@ +import { useCallback, useEffect, useState, type ReactElement, type ReactNode } from 'react'; +import { api } from '@/lib/api'; +import { authClient, useSession } from '@/lib/auth-client'; +import type { SsoProviderDiscovery } from '@/lib/sso'; +import { SsoProviderSection } from '@/components/settings/sso-provider-section'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface ModelInfo { + id: string; + provider: string; + name: string; + reasoning: boolean; + contextWindow: number; + maxTokens: number; + inputTypes: ('text' | 'image')[]; + cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; +} + +interface ProviderInfo { + id: string; + name: string; + available: boolean; + models: ModelInfo[]; +} + +interface TestConnectionResult { + providerId: string; + reachable: boolean; + latencyMs?: number; + error?: string; + discoveredModels?: string[]; +} + +type TestState = 'idle' | 'testing' | 'success' | 'error'; + +interface ProviderTestStatus { + state: TestState; + result?: TestConnectionResult; +} + +interface Preference { + key: string; + value: unknown; + category: string; +} + +type Theme = 'light' | 'dark' | 'system'; +type SaveState = 'idle' | 'saving' | 'saved' | 'error'; +type Tab = 'profile' | 'appearance' | 'notifications' | 'providers'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function prefValue(prefs: Preference[], key: string, fallback: T): T { + const p = prefs.find((x) => x.key === key); + if (p === undefined) return fallback; + return p.value as T; +} + +// ─── Main Page ──────────────────────────────────────────────────────────────── + +export function SettingsPage(): ReactElement { + const { data: session } = useSession(); + const [activeTab, setActiveTab] = useState('profile'); + + const tabs: { id: Tab; label: string }[] = [ + { id: 'profile', label: 'Profile' }, + { id: 'appearance', label: 'Appearance' }, + { id: 'notifications', label: 'Notifications' }, + { id: 'providers', label: 'Providers' }, + ]; + + return ( +
+

Settings

+ + {/* Tab bar */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {activeTab === 'profile' && } + {activeTab === 'appearance' && } + {activeTab === 'notifications' && } + {activeTab === 'providers' && } +
+ ); +} + +// ─── Profile Tab ────────────────────────────────────────────────────────────── + +function ProfileTab({ + session, +}: { + session: { user: { id: string; name: string; email: string; image?: string | null } } | null; +}): ReactElement { + const [name, setName] = useState(session?.user.name ?? ''); + const [image, setImage] = useState(session?.user.image ?? ''); + const [saveState, setSaveState] = useState('idle'); + const [errorMsg, setErrorMsg] = useState(''); + + // Sync from session when it loads + useEffect(() => { + if (session?.user) { + setName(session.user.name ?? ''); + setImage(session.user.image ?? ''); + } + }, [session]); + + const handleSave = async (): Promise => { + setSaveState('saving'); + setErrorMsg(''); + try { + const result = await authClient.updateUser({ name, image: image || null }); + if (result.error) { + setErrorMsg(result.error.message ?? 'Failed to update profile'); + setSaveState('error'); + return; + } + setSaveState('saved'); + setTimeout(() => setSaveState('idle'), 2000); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to update profile'; + setErrorMsg(message); + setSaveState('error'); + } + }; + + return ( +
+

Profile

+
+ + setName(e.target.value)} + placeholder="Your name" + className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent" + /> + + + + +

Email cannot be changed here.

+
+ + + setImage(e.target.value)} + placeholder="https://example.com/avatar.png" + className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent" + /> + + +
+ + {saveState === 'error' && errorMsg &&

{errorMsg}

} +
+
+
+ ); +} + +// ─── Appearance Tab ─────────────────────────────────────────────────────────── + +function AppearanceTab(): ReactElement { + const [loading, setLoading] = useState(true); + const [theme, setTheme] = useState('system'); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [defaultModel, setDefaultModel] = useState(''); + const [saveState, setSaveState] = useState('idle'); + const [errorMsg, setErrorMsg] = useState(''); + + useEffect(() => { + api('/api/memory/preferences?category=appearance') + .catch(() => [] as Preference[]) + .then((p) => { + setTheme(prefValue(p, 'ui.theme', 'system')); + setSidebarCollapsed(prefValue(p, 'ui.sidebar_collapsed', false)); + setDefaultModel(prefValue(p, 'ui.default_model', '')); + }) + .finally(() => setLoading(false)); + }, []); + + const handleSave = async (): Promise => { + setSaveState('saving'); + setErrorMsg(''); + try { + await Promise.all([ + api('/api/memory/preferences', { + method: 'POST', + body: { key: 'ui.theme', value: theme, category: 'appearance', source: 'user' }, + }), + api('/api/memory/preferences', { + method: 'POST', + body: { + key: 'ui.sidebar_collapsed', + value: sidebarCollapsed, + category: 'appearance', + source: 'user', + }, + }), + ...(defaultModel + ? [ + api('/api/memory/preferences', { + method: 'POST', + body: { + key: 'ui.default_model', + value: defaultModel, + category: 'appearance', + source: 'user', + }, + }), + ] + : []), + ]); + setSaveState('saved'); + setTimeout(() => setSaveState('idle'), 2000); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to save preferences'; + setErrorMsg(message); + setSaveState('error'); + } + }; + + if (loading) { + return ( +
+

Appearance

+

Loading preferences...

+
+ ); + } + + return ( +
+

Appearance

+
+ {/* Theme */} +
+ +
+ {(['system', 'light', 'dark'] as Theme[]).map((t) => ( + + ))} +
+
+ + {/* Sidebar collapsed default */} +
+
+

Collapse sidebar by default

+

Start with sidebar collapsed on page load

+
+ +
+ + {/* Default model */} + + setDefaultModel(e.target.value)} + placeholder="e.g. ollama/llama3.2" + className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent" + /> +

+ Model ID to pre-select for new conversations. +

+
+ +
+ + {saveState === 'error' && errorMsg &&

{errorMsg}

} +
+
+
+ ); +} + +// ─── Notifications Tab ──────────────────────────────────────────────────────── + +function NotificationsTab(): ReactElement { + const [loading, setLoading] = useState(true); + const [emailAgentComplete, setEmailAgentComplete] = useState(false); + const [emailMentions, setEmailMentions] = useState(true); + const [emailDigest, setEmailDigest] = useState(false); + const [saveState, setSaveState] = useState('idle'); + const [errorMsg, setErrorMsg] = useState(''); + + useEffect(() => { + api('/api/memory/preferences?category=communication') + .catch(() => [] as Preference[]) + .then((p) => { + setEmailAgentComplete(prefValue(p, 'notify.email_agent_complete', false)); + setEmailMentions(prefValue(p, 'notify.email_mentions', true)); + setEmailDigest(prefValue(p, 'notify.email_digest', false)); + }) + .finally(() => setLoading(false)); + }, []); + + const handleSave = async (): Promise => { + setSaveState('saving'); + setErrorMsg(''); + try { + await Promise.all([ + api('/api/memory/preferences', { + method: 'POST', + body: { + key: 'notify.email_agent_complete', + value: emailAgentComplete, + category: 'communication', + source: 'user', + }, + }), + api('/api/memory/preferences', { + method: 'POST', + body: { + key: 'notify.email_mentions', + value: emailMentions, + category: 'communication', + source: 'user', + }, + }), + api('/api/memory/preferences', { + method: 'POST', + body: { + key: 'notify.email_digest', + value: emailDigest, + category: 'communication', + source: 'user', + }, + }), + ]); + setSaveState('saved'); + setTimeout(() => setSaveState('idle'), 2000); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to save preferences'; + setErrorMsg(message); + setSaveState('error'); + } + }; + + if (loading) { + return ( +
+

Notifications

+

Loading preferences...

+
+ ); + } + + return ( +
+

Notifications

+
+

Configure when you receive email notifications.

+ + + + + +
+ + {saveState === 'error' && errorMsg &&

{errorMsg}

} +
+
+
+ ); +} + +// ─── Providers Tab ──────────────────────────────────────────────────────────── + +function ProvidersTab(): ReactElement { + const [providers, setProviders] = useState([]); + const [ssoProviders, setSsoProviders] = useState([]); + const [loading, setLoading] = useState(true); + const [ssoLoading, setSsoLoading] = useState(true); + const [testStatuses, setTestStatuses] = useState>({}); + + useEffect(() => { + api('/api/providers') + .catch(() => [] as ProviderInfo[]) + .then((p) => setProviders(p)) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + api('/api/sso/providers') + .catch(() => [] as SsoProviderDiscovery[]) + .then((providers) => setSsoProviders(providers)) + .finally(() => setSsoLoading(false)); + }, []); + + const testConnection = useCallback(async (providerId: string): Promise => { + setTestStatuses((prev) => ({ + ...prev, + [providerId]: { state: 'testing' }, + })); + try { + const result = await api('/api/providers/test', { + method: 'POST', + body: { providerId }, + }); + setTestStatuses((prev) => ({ + ...prev, + [providerId]: { state: result.reachable ? 'success' : 'error', result }, + })); + } catch { + setTestStatuses((prev) => ({ + ...prev, + [providerId]: { + state: 'error', + result: { providerId, reachable: false, error: 'Request failed' }, + }, + })); + } + }, []); + + const defaultModel: ModelInfo | undefined = providers + .flatMap((p) => p.models) + .find((m) => providers.find((p) => p.id === m.provider)?.available); + + return ( +
+
+

SSO Providers

+ +
+ +
+

LLM Providers

+ {loading ? ( +

Loading providers...

+ ) : providers.length === 0 ? ( +
+

+ No providers configured. Set{' '} + + OLLAMA_BASE_URL + {' '} + or{' '} + + MOSAIC_CUSTOM_PROVIDERS + {' '} + to add providers. +

+
+ ) : ( +
+ {providers.map((provider) => ( + void testConnection(provider.id)} + /> + ))} +
+ )} +
+
+ ); +} + +// ─── Shared UI Components ───────────────────────────────────────────────────── + +function FormField({ + label, + id, + children, +}: { + label: string; + id: string; + children: ReactNode; +}): ReactElement { + return ( +
+ + {children} +
+ ); +} + +function Toggle({ + checked, + onChange, +}: { + checked: boolean; + onChange: (v: boolean) => void; +}): ReactElement { + return ( + + ); +} + +function NotifyRow({ + label, + description, + checked, + onChange, +}: { + label: string; + description: string; + checked: boolean; + onChange: (v: boolean) => void; +}): ReactElement { + return ( +
+
+

{label}

+

{description}

+
+ +
+ ); +} + +function SaveButton({ state, onClick }: { state: SaveState; onClick: () => void }): ReactElement { + return ( + + ); +} + +// ─── Provider Card (from original page) ────────────────────────────────────── + +interface ProviderCardProps { + provider: ProviderInfo; + defaultModel: ModelInfo | undefined; + testStatus: ProviderTestStatus; + onTest: () => void; +} + +function ProviderCard({ + provider, + defaultModel, + testStatus, + onTest, +}: ProviderCardProps): ReactElement { + const [expanded, setExpanded] = useState(false); + + return ( +
+ {/* Header row */} +
+
+ +
+
+ {provider.name} + +
+

+ {provider.models.length} model{provider.models.length !== 1 ? 's' : ''} +

+
+
+ +
+ + +
+
+ + {/* Test result banner */} + {testStatus.state !== 'idle' && testStatus.state !== 'testing' && testStatus.result && ( + + )} + + {/* Model list */} + {expanded && ( +
+ + + + + + + + + + + + {provider.models.map((model) => ( + + ))} + +
ModelCapabilitiesContextCost (in/out)Default
+
+ )} +
+ ); +} + +interface ModelRowProps { + model: ModelInfo; + isDefault: boolean; +} + +function ModelRow({ model, isDefault }: ModelRowProps): ReactElement { + return ( + + + {model.name} + + +
+ + {model.reasoning && } + {model.inputTypes.includes('image') && } +
+ + + {formatContext(model.contextWindow)} + + + {model.cost.input === 0 && model.cost.output === 0 + ? 'free' + : `$${model.cost.input} / $${model.cost.output}`} + + + {isDefault && ( + + default + + )} + + + ); +} + +function ProviderAvatar({ id }: { id: string }): ReactElement { + const letter = id.charAt(0).toUpperCase(); + return ( +
+ {letter} +
+ ); +} + +function ProviderStatusBadge({ available }: { available: boolean }): ReactElement { + return ( + + {available ? 'Active' : 'Inactive'} + + ); +} + +interface TestConnectionButtonProps { + status: ProviderTestStatus; + onTest: () => void; +} + +function TestConnectionButton({ status, onTest }: TestConnectionButtonProps): ReactElement { + const isTesting = status.state === 'testing'; + return ( + + ); +} + +function TestResultBanner({ result }: { result: TestConnectionResult }): ReactElement { + return ( +
+ {result.reachable ? ( + <> + Connected + {result.latencyMs !== undefined && ( + ({result.latencyMs}ms) + )} + {result.discoveredModels && result.discoveredModels.length > 0 && ( + + — {result.discoveredModels.length} model + {result.discoveredModels.length !== 1 ? 's' : ''} discovered + + )} + + ) : ( + <>Connection failed{result.error ? `: ${result.error}` : ''} + )} +
+ ); +} + +function CapabilityBadge({ + label, + color = 'default', +}: { + label: string; + color?: 'default' | 'purple' | 'blue'; +}): ReactElement { + const colorClass = + color === 'purple' + ? 'bg-purple-500/20 text-purple-400' + : color === 'blue' + ? 'bg-blue-500/20 text-blue-400' + : 'bg-surface-elevated text-text-muted'; + return {label}; +} + +function formatContext(tokens: number): string { + if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; + if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`; + return String(tokens); +} diff --git a/apps/web/src/spa/routes.spec.tsx b/apps/web/src/spa/routes.spec.tsx index 018386d5f..c40d5efaa 100644 --- a/apps/web/src/spa/routes.spec.tsx +++ b/apps/web/src/spa/routes.spec.tsx @@ -1,11 +1,14 @@ import { isValidElement } from 'react'; +import { SPA_ROUTES } from '@mosaicstack/types'; import { describe, expect, it } from 'vitest'; import type { RouteObject } from 'react-router-dom'; import { routes } from '@/routes'; import { Placeholder } from '@/spa/placeholder'; +import { AdminPage } from '@/spa/pages/admin'; import { ChatPage } from '@/spa/pages/chat'; import { ProjectDetailPage } from '@/spa/pages/project-detail'; import { ProjectsPage } from '@/spa/pages/projects'; +import { SettingsPage } from '@/spa/pages/settings'; import { TasksPage } from '@/spa/pages/tasks'; function collectPaths(routeObjects: RouteObject[]): string[] { @@ -25,21 +28,9 @@ function findRoute(routeObjects: RouteObject[], path: string): RouteObject | und } describe('SPA route table', () => { - it('covers every v1 parity route from the Phase P RFC', () => { - expect(collectPaths(routes).sort()).toEqual( - [ - '/', - '/admin', - '/auth/provider/:provider', - '/chat', - '/login', - '/projects', - '/projects/:id', - '/register', - '/settings', - '/tasks', - ].sort(), - ); + it('implements every route in the shared Gateway fallback contract', () => { + const contractedPaths = Object.values(SPA_ROUTES).map((route) => route.path); + expect(collectPaths(routes).sort()).toEqual(contractedPaths.sort()); }); it('separates guest and authenticated route groups', () => { @@ -75,6 +66,8 @@ describe('SPA route table', () => { ['/projects', ProjectsPage], ['/projects/:id', ProjectDetailPage], ['/tasks', TasksPage], + ['/settings', SettingsPage], + ['/admin', AdminPage], ])( 'renders a real authenticated page instead of the P1 placeholder at %s', (path, expectedType) => { @@ -86,7 +79,7 @@ describe('SPA route table', () => { }, ); - it.each(['/chat', '/projects', '/projects/:id', '/tasks'])( + it.each(['/chat', '/projects', '/projects/:id', '/tasks', '/settings', '/admin'])( 'defines an error boundary for %s', (path) => { const route = findRoute(routes, path); diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts index 751a20d72..08c251411 100644 --- a/apps/web/src/test/setup.ts +++ b/apps/web/src/test/setup.ts @@ -21,3 +21,50 @@ for (const target of [globalThis, window]) { }, }); } + +// jsdom ships no matchMedia; the SPA shell components shared with the Next +// baseline (SidebarProvider) need it to measure the mobile breakpoint. Default +// to the desktop layout; specs exercising the mobile drawer install their own. +if (typeof window.matchMedia !== 'function') { + window.matchMedia = (query: string): MediaQueryList => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + }); +} + +// Measured under vitest's jsdom environment: `window.localStorage` is an own +// ACCESSOR property (get + set, configurable, non-enumerable) whose getter +// returns `undefined`; `globalThis.localStorage` likewise. A standalone +// `new JSDOM('', { url })` does supply a real Storage, so a probe built that way +// does not describe this environment. The SPA's ThemeProvider reads and writes +// `mosaic-theme` on mount, so specs rendering it need a working store. Why the +// getter returns undefined here is NOT measured — do not add a cause. +if (window.localStorage === undefined) { + const entries = new Map(); + const memoryStorage: Storage = { + get length() { + return entries.size; + }, + getItem: (key: string) => entries.get(String(key)) ?? null, + setItem: (key: string, value: string) => void entries.set(String(key), String(value)), + removeItem: (key: string) => void entries.delete(String(key)), + clear: () => void entries.clear(), + key: (index: number) => [...entries.keys()][index] ?? null, + }; + + for (const target of [globalThis, window]) { + if ((target as typeof globalThis).localStorage === undefined) { + Object.defineProperty(target, 'localStorage', { + configurable: true, + writable: true, + value: memoryStorage, + }); + } + } +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index ea042157b..da14e70e6 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -9,6 +9,11 @@ const gatewayTarget = 'http://localhost:14242'; export default defineConfig({ plugins: [react()], + build: { + // The gateway uses this provenance record, not filename heuristics, to + // decide which Vite outputs are safe for immutable caching. + manifest: true, + }, resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), diff --git a/docker/gateway.Dockerfile b/docker/gateway.Dockerfile index 6534f1888..d6592c264 100644 --- a/docker/gateway.Dockerfile +++ b/docker/gateway.Dockerfile @@ -1,3 +1,8 @@ +# One build argument selects both the bundle payload and the runtime mode. P6 +# enables the Web UI by changing this default to "required"; the matching stage +# builds the Vite artifact and the runner then fails startup if it is invalid. +ARG GATEWAY_WEB_UI_MODE=disabled + FROM node:22-alpine AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" @@ -20,9 +25,22 @@ RUN pnpm turbo run build --filter @mosaicstack/gateway... # --legacy is required for pnpm v10 when inject-workspace-packages is not set RUN pnpm --filter @mosaicstack/gateway --prod deploy --legacy /deploy +FROM builder AS web-ui-disabled +RUN mkdir -p /web-ui + +FROM builder AS web-ui-required +RUN pnpm install --frozen-lockfile \ + && pnpm --filter @mosaicstack/web build:vite \ + && mkdir -p /web-ui \ + && cp -R /app/apps/web/dist/. /web-ui/ + +FROM web-ui-${GATEWAY_WEB_UI_MODE} AS web-ui + FROM base AS runner +ARG GATEWAY_WEB_UI_MODE WORKDIR /app ENV NODE_ENV=production +ENV GATEWAY_WEB_UI_MODE=${GATEWAY_WEB_UI_MODE} # WorkspaceService shells out to git at runtime and roots workspaces at # $MOSAIC_ROOT/.workspaces (apps/gateway/src/workspace/workspace.service.ts); # mount a volume over /opt/mosaic to persist workspaces across container restarts. @@ -38,6 +56,8 @@ COPY --chown=node:node --from=builder /deploy/package.json ./package.json # dist is declared in package.json "files" so pnpm deploy copies it into /deploy; # copy from builder explicitly as belt-and-suspenders COPY --chown=node:node --from=builder /app/apps/gateway/dist ./dist +# resolveWebDistPath() maps runner /app/dist/web-ui.js to /web/dist. +COPY --chown=node:node --from=web-ui /web-ui /web/dist # gateway defaults to port 14242 (apps/gateway/src/main.ts) EXPOSE 14242 USER node diff --git a/docs/scratchpads/webui-p4-2-spa-settings-admin.md b/docs/scratchpads/webui-p4-2-spa-settings-admin.md new file mode 100644 index 000000000..632d46374 --- /dev/null +++ b/docs/scratchpads/webui-p4-2-spa-settings-admin.md @@ -0,0 +1,150 @@ +# P4 residual — port /settings and /admin into the Vite SPA + +Date: 2026-04-20 (worker session) +Scope: replace the two `` routes in `apps/web/src/routes.tsx` with real ports of the +Next.js `/settings` (828 lines) and `/admin` (531 lines) pages. No commit/push/PR — working-tree +changes only. P5a uncommitted work (17 modified + 7 untracked) must not be touched except +`apps/web/src/routes.tsx`. + +## Objective + +SPA parity on all six surfaces (login, chat, projects, tasks, settings, admin). + +## Plan + +1. Port settings page → `apps/web/src/spa/pages/settings.tsx` (mechanical: drop `'use client'`, + named export, `ReactElement` imports). +2. Port admin page → `apps/web/src/spa/pages/admin.tsx`. +3. Specs: `settings.spec.tsx`, `admin.spec.tsx` following `tasks.spec.tsx` (createMemoryRouter, + real path). +4. Extend `resource-route-error-boundaries.tsx` with Settings + Admin boundaries. +5. Swap the two placeholders in `routes.tsx` (+errorElements, drop unused `Placeholder` import). +6. Verify: typecheck, lint, test, build (scoped to @mosaicstack/web). + +## Findings (pre-implementation) + +- **Brief discrepancy 1:** `apps/web/src/lib/admin-role.ts` does not exist (not in working tree, + not in any git history). Only `apps/web/src/components/admin-role-guard.tsx` exists. The + "cached-session fail-open" described against `admin-role.ts:6-14` is not present in this + worktree's guard; this guard fails CLOSED for a missing `role` claim (missing role → not admin → + redirect away). Will report; nothing to carry-or-fix here beyond the port itself. +- **Brief discrepancy 2:** the brief claims all imports are "already SPA-consumable". True for + `@/lib/api`, `@/lib/auth-client`, `@/lib/sso`, `@/components/settings/sso-provider-section`, + `@/lib/cn`. FALSE for `@/components/admin-role-guard`, which imports `useRouter` from + `next/navigation` — unusable in the SPA runtime. Codebase precedent: `src/spa/guards.tsx` + re-implements the Next guards (`auth-guard`/`guest-guard`) with react-router-dom primitives. + Decision: page-local `AdminRoleGuard` inside `spa/pages/admin.tsx` using `` with + identical semantics (pending → loading; no session → /login; non-admin → /), paths from + `SPA_ROUTES`. Deviation to be documented in the final report. +- `SPA_ROUTES` table in `packages/types/src/web-ui-routes.ts` (P5a file — read-only for me) already + has `settings` (`/settings`, exact) and `admin` (`/admin`, exact). +- `pnpm --filter @mosaicstack/web build` actually runs `scripts/build-web.mjs` → `next build` + (Next app, full-project typecheck). Vite SPA build is `build:vite`. Will run both `build` (as + briefed) and report what it does; will also run `build:vite` since it is the artifact my pages + ship in. +- Error-boundary module hard-codes hrefs ('/projects', '/tasks'). While extending, convert all + five to `SPA_ROUTES` sources for uniformity (same file I'm instructed to extend; no P5a overlap). + +## Progress log + +- [x] Read exemplars (tasks.tsx/.spec, project-detail.spec, guards, boundaries spec) +- [x] Read both source pages in full +- [x] Written settings.tsx +- [x] Written admin.tsx (page-local AdminRoleGuard; rationale above) +- [x] Written settings.spec.tsx (5 tests) +- [x] Written admin.spec.tsx (7 tests) +- [x] Extended resource-route-error-boundaries.tsx (+ converted hrefs to SPA_ROUTES) +- [x] Swapped routes.tsx placeholders +- [x] typecheck PASS +- [x] lint PASS +- [x] test PASS (suite: 23 files, 217 tests — includes my 12 new: settings 5 + admin 7) + (corrected during remediation — originally logged as 37 files / 252 tests / 10 new; + none of those numbers reproduced at review or since. See remediation addendum.) +- [x] build (next build) PASS +- [x] build:vite PASS (613 modules). The `next/navigation` hazard in + `@/components/admin-role-guard` was caught statically before coding; the page-local + react-router guard avoided it, and the built SPA bundle contains no next/navigation + code (grep of dist/assets: 0 hits). + +## Verification evidence + +See final report; all five commands run from repo root with verbatim output captured. + +## Collision addendum — second concurrent worker (pi, 2026-08-17 ~15:25–15:45 CDT) + +A second agent session (pi runtime) was dispatched the same P4 residual brief while the first +worker was still writing. Observed sequence (checksums + mtimes): + +- Second worker's initial `git status`/`ls` (≈15:26) showed no settings/admin files — the first + worker's files landed ≈15:28–15:31 and overwrote the second worker's drafts of the same four + new files plus the boundaries module; a transient duplicated-import/duplicated-export state in + `resource-route-error-boundaries.tsx` (caught red by `tsc`) was resolved when the first + worker's final write landed. The surviving implementation is the first worker's. +- First worker went quiescent ≈15:35 (verified: 180 s of zero checksum changes across all seven + increment files). No live agent process holds this worktree as cwd other than the second + worker itself; the first worker wrote from a session rooted elsewhere (bridge/IDE-hosted). +- Second worker then: reviewed the surviving implementation against the shared brief (mechanical + port verified by diff against `src/app/(dashboard)/settings/page.tsx` and `admin/page.tsx`), + extended `resource-route-boundaries.spec.tsx` `it.each` to cover `/settings` and `/admin` + (the one gap found), and independently re-ran all gates on the final tree: + + - `pnpm --filter @mosaicstack/web typecheck` — PASS + - `pnpm --filter @mosaicstack/web lint` — PASS + - `pnpm --filter @mosaicstack/web test` — PASS (23 files, 213 tests, incl. settings 5, + admin 7, boundaries 5) + - `pnpm --filter @mosaicstack/web build` (next build via scripts/build-web.mjs) — PASS + - `pnpm --filter @mosaicstack/web build:vite` — PASS; 0 `next/navigation` refs in dist assets + +- Coordinator note: double-dispatch of the same increment burned one full worker pass. The + surviving tree is coherent and gate-green; no rework needed, but the dispatch de-dup should be + checked before the next residual increment. + +## Remediation addendum — author session, post code+security review (2026-04-20) + +Applied the three triaged findings. Fence: `spa/guards.tsx`, `spa/pages/admin.tsx`, +`spa/pages/resource-route-boundaries.spec.tsx`, `spa/routes.spec.tsx`, this scratchpad. No git +state changes (no commit/branch/stash/push/PR); no servers started; `DATABASE_URL` untouched. + +- **R1 — guard relocated.** `AdminRoleGuard` moved verbatim from `spa/pages/admin.tsx:55-86` + into `spa/guards.tsx` (exported, beside `GuestGuard`/`AuthGuard`), imported back as + `@/spa/guards`. Children-wrapper shape kept (NOT converted to an `Outlet` layout guard); + branch logic byte-identical; still resolves via `SPA_ROUTES`. The pre-existing hard-coded + `'/chat'`/`'/login'` in the other two guards were left alone per the no-change list. + `admin.spec.tsx` tests 1–3 (three guard branches) pass unchanged. +- **R2a — owned the reviewer's edit.** `resource-route-boundaries.spec.tsx` `it.each` line + reads `['/projects', '/projects/:id', '/tasks', '/settings', '/admin']` (reviewer edit + 15:40:54, reported as "no files modified"). Confirmed it is what I would have written (it + reuses the file's established Boom-element-swap + `[role="alert"]` pattern) and gated it + myself — see discrimination proof. No change made to the line. +- **R2b — route-wiring rows added (additive only).** `spa/routes.spec.tsx`: + `/settings`+`/admin` rows appended to the "real authenticated page" `it.each` (asserting + `SettingsPage`/`AdminPage` element types, matching the existing tuple style) and both paths + appended to the "defines an error boundary" `it.each`. Two imports added; nothing else in + the file touched. +- **Discrimination proof (red/green).** The wiring under test lives in `apps/web/src/routes.tsx`, + which is outside the fence, so each defect was injected at the module boundary via a + temporary `vi.mock('@/routes', …importActual…)` clone of the REAL table with exactly one + wiring bit removed, inside the in-fence spec files (then fully reverted; grep-verified no + residue): + - boundaries spec, `/admin` row, `errorElement` deleted → exactly that row failed + (`AssertionError: expected null to be truthy`, the `[role="alert"]` assertion); other 4 + green. Restored → 5/5 green. + - routes spec, `/settings` row, element reverted to `` → exactly that row + failed (`expected [Function Placeholder] not to be [Function Placeholder]`); other 16 + green, including `/settings`'s error-boundary row. Restored → 17/17 green. +- **R3 — numbers corrected (measured, not predicted).** Final gate run: + `Test Files 23 passed (23)` / `Tests 217 passed (217)` — decomposition: 211 at review + (boundaries spec at 3 rows) + 2 reviewer rows (R2a) + 4 remediation rows (R2b) = 217. + Progress-log claims corrected: admin.spec.tsx 5→7 tests, "10 new"→"12 new" + (settings 5 + admin 7), "37 files, 252 tests"→"23 files, 217 tests". The collision addendum's + "23 files, 213 tests" describes the pre-R2b tree (boundaries at 5 rows) and is consistent + with 211+2; it is left as the historical record of that run. + +Gates (all five, from repo root, all EXIT=0): + +- `pnpm --filter @mosaicstack/web typecheck` — PASS (`tsc --noEmit`, clean) +- `pnpm --filter @mosaicstack/web lint` — PASS (`eslint src`, clean) +- `pnpm --filter @mosaicstack/web test` — PASS (23 files / 217 tests) +- `pnpm --filter @mosaicstack/web build` — PASS (next build via scripts/build-web.mjs; 10 routes) +- `pnpm --filter @mosaicstack/web build:vite` — PASS (1,213.25 kB js / 42.73 kB css; 0 + `next/navigation` references in dist/assets) diff --git a/docs/scratchpads/webui-p4c-remediation.md b/docs/scratchpads/webui-p4c-remediation.md new file mode 100644 index 000000000..5c9ce9b3d --- /dev/null +++ b/docs/scratchpads/webui-p4c-remediation.md @@ -0,0 +1,42 @@ +# P4c remediation — review findings A1–A8 + +Date: 2026-04-20 (worker session) +Mission: remediate code + security review findings on increment P4c (SPA dashboard shell). +Working-tree state only; no commit/stage/branch/push/PR. Never `git checkout --/restore/stash/reset`. +Fenced: `apps/gateway/**`, `spa/pages/chat.tsx`, `spa/chat/**`, `apps/web/src/app/**`, +`packages/types/src/web-ui-routes.ts`. Not running typecheck/lint/build:vite (reviewer runs those). + +## Plan (priority per brief) + +1. A1+A2 — `spa/layout/topbar.tsx`: sign-out handler — `const { error } = await signOut()` + in try/catch; on error surface `role="alert"` + stay; on success + `window.location.replace(SPA_ROUTES.login.path)` (full teardown; no soft navigate). +2. A5 — `src/test/setup.ts`: delete dead localStorage block (measured: jsdom 29 own + `window.localStorage` works; `=== undefined` guard never fires). +3. A7 — `spa/guards.tsx`: literals `"/chat"`, `"/login"` → `SPA_ROUTES.*.path` (only change authorized). +4. A8 — sign-out spec rework: real-client ordering (signOut resolves WITHOUT touching store). +5. A3 — shell-persistence test: /chat → collapse → click Tasks nav link → content changed + + collapsed survived (same SidebarProvider instance). +6. A4 — desktop toggle test: `data-sidebar-hidden` flips on `.app-shell`. +7. A6 — `installLocalStorage` restore: save original descriptor, reinstall it (kept, not + removed: per-render fresh storage isolates ThemeProvider across tests; jsdom storage + persists within a file). +8. Optional declined item: non-empty accessible-text assertion on nav links — decision below. + +## Evidence protocol + +- Baseline full run (reviewer: 24 files / 234 tests rc=0). +- New tests written first, run against UNFIXED topbar → A8 (and A2 path) must fail. +- Apply code fixes → re-run file-scoped → pass. +- A3/A4 mutation checks (cp aside → mutate → run → cp back → sha256 verify): + - A3: `` forces remount-per-navigation → A3 test must fail. + - A4: no-op desktop branch in `handleSidebarToggle` → A4 test must fail. +- Final full run for gate table. + +## Findings on disk vs brief + +- (fill during work) + +## Log + +- (fill during work) diff --git a/docs/scratchpads/webui-p4c-spa-app-shell.md b/docs/scratchpads/webui-p4c-spa-app-shell.md new file mode 100644 index 000000000..35b7e6423 --- /dev/null +++ b/docs/scratchpads/webui-p4c-spa-app-shell.md @@ -0,0 +1,72 @@ +# P4c — dashboard shell parity in the Vite SPA + +Date: 2026-04-20 (worker session) +Scope: port the Next baseline dashboard layout shell (`src/components/layout/app-shell.tsx`, +`topbar.tsx`, `sidebar.tsx`) into the SPA as a React Router layout route. Working-tree changes +only; no commit/push/PR. P5a uncommitted state must not be touched except `apps/web/src/routes.tsx`. + +## Objective + +Close the four audit gaps: (1) AppShell layout route wrapping the dashboard group, (2) sign-out, +(3) theme toggle UI, (4) session identity display. Port, not redesign. + +## Plan + +1. `apps/web/src/spa/layout/sidebar.tsx` — port of baseline sidebar. `next/link` → react-router + `Link`, `usePathname` → `useLocation().pathname`. Nav hrefs derive from `SPA_ROUTES` + (no literal paths). Reuse baseline `sidebar-context`, `MosaicLogo`, `cn` (all + framework-agnostic). Export `workspaceNavItems` for the drift-guard spec. +2. `apps/web/src/spa/layout/topbar.tsx` — port of baseline topbar. `useRouter().replace` → + `useNavigate` + `SPA_ROUTES.login.path`; `signOut()` then navigate (same order as baseline). + Session identity display (`name ?? email`) and `ThemeToggle` (baseline component reused — + ThemeProvider already mounted in `main.tsx`). +3. `apps/web/src/spa/layout/app-shell.tsx` — layout-route element: `SidebarProvider` + frame + + ``, mirroring baseline `AppShellFrame` structure verbatim. +4. `apps/web/src/routes.tsx` — insert pathless layout route (`element: `) inside + the `AuthGuard` children wrapping chat/projects/projectDetail/tasks/settings/admin. `/` redirect + stays outside the shell (Next parity: `app/page.tsx` is outside the `(dashboard)` group). + Pathless route ⇒ `collectPaths` set-equality in `routes.spec.tsx` unchanged. +5. `apps/web/src/spa/layout/app-shell.spec.tsx` — renders the real `routes` table via + `createMemoryRouter` with `@/lib/auth-client`, `@/lib/api` mocked and the socket-heavy chat page + module mocked to a stub. Covers: shell presence on the 5 dashboard routes (+projectDetail) and + absence on the 3 auth routes; `aria-current` active-state resolution (incl. `/projects/:id` → + Projects); sign-out calls the client and lands on `/login`; nav target set equals + `SPA_ROUTES` minus the explicitly-excluded entries (root redirect, auth group, projectDetail + detail view); mobile drawer open/close. + +## Deviations from baseline (deliberate, minimal) + +- Nav links carry `aria-current="page"` when active. Baseline expresses active state only through + classes/styles; the semantic attribute is the standard accessible marker for "current page" and + is what the spec asserts (asserting literal class strings is the change-detector class we + removed). +- `/` redirect and the auth group remain outside the shell — matches baseline layout grouping + (`app/page.tsx` and `(auth)` are outside `(dashboard)`). +- Two test-environment patches were required because the shared `routes` table now mounts shell + components in every spec that renders it: + - `src/test/setup.ts`: jsdom default `matchMedia` (desktop) and in-memory `localStorage` — jsdom + ships neither (localStorage needs Node's `--localstorage-file`). + - `chat-error-boundary.spec.tsx` / `resource-route-boundaries.spec.tsx`: wrap their render in + `ThemeProvider`, mirroring `main.tsx` composition (Topbar's ThemeToggle throws without it). + Assertions unchanged. + +## Incident log + +- During mutation-testing the layout route, a `git checkout -- apps/web/src/routes.tsx` intended to + revert a temporary mutation instead reverted ALL uncommitted work on that file (P4-2 page swaps + included). Reconstructed faithfully from the in-session read of the pre-edit file plus the layout + edit; verified by the full suite (routes.spec.tsx asserts SPA_ROUTES set-equality and the exact + page element types). Lesson recorded: never git-restore a file carrying others' uncommitted work. + +## Gates + +| Gate | Before | After | +| --- | --- | --- | +| typecheck | clean | clean | +| lint | clean | clean | +| test | 23 files / 217 passed | 24 files / 234 passed (+17, all new) | +| build:vite | ok (pre-existing chunk-size warning) | ok (same warning) | + +Mutation check: replacing `` with a bare `` fails 13 of the 17 new +tests — the suite bites when the shell is not mounted. `routes.spec.tsx` set-equality untouched +and still green. diff --git a/docs/scratchpads/webui-p4d-spa-projects-active-mission.md b/docs/scratchpads/webui-p4d-spa-projects-active-mission.md new file mode 100644 index 000000000..5da634174 --- /dev/null +++ b/docs/scratchpads/webui-p4d-spa-projects-active-mission.md @@ -0,0 +1,74 @@ +# WebUI Phase P — P4d SPA Projects Page "Active Mission" Section Scratchpad + +**Task ID:** P4d (SPA parity increment) +**Tracking ref:** static parity audit finding (dispatcher brief); `docs/TASKS.md` remains orchestrator-only +**Branch:** `feat/webui-p5a-gateway-static` (HEAD `216cd722`, uncommitted working-tree state) +**Started:** 2026-08-12 +**Role:** author worker. No commit / stage / branch / push / PR — working-tree state only (brief fence). + +## Objective + +Restore the "Active Mission" section on the SPA projects page (`apps/web/src/spa/pages/projects.tsx`) +at behavioural parity with the legacy Next baseline +(`apps/web/src/app/(dashboard)/projects/page.tsx`), which renders mission status from +`GET /api/coord/status` below the project grid. + +## Findings from disk (disk wins over brief where they conflict) + +1. **The baseline mission-status component is not importable.** It is a private function inside the + Next page module; the module imports `next/navigation` at top level, and the tree + `apps/web/src/app/**` is fenced. Therefore: **port**, not reuse. (P4c's `ThemeToggle` reuse worked + because `components/layout/theme-toggle.tsx` is a standalone framework-agnostic module; this is not.) +2. **The baseline reads keys the real API never returns.** Baseline reads flat + `missionId` / `currentPhase` / `completedTasks` / `totalTasks` / `status` from a + `Record`. The actual wire contract (`CoordMissionStatusDto` in + `apps/gateway/src/coord/coord.dto.ts`, `MissionStatusSummary` in `packages/coord/src/types.ts`) + is nested: `mission.{id,name,status,projectPath}`, `milestones.{total,completed,current}`, + `tasks.{total,done,...}`. Against the real gateway the baseline renders + "Unknown / — / 0 / 0 / —" — a latent baseline defect. Decision: port against the real + contract (mission id, current milestone as phase, done/total tasks, mission status) and disclose. +3. **No-mission is a 404 on the wire.** `CoordController.missionStatus` throws + `NotFoundException('No active coord mission found')` when there is no mission; a 200 always + carries a mission object. The baseline reaches its "No active mission detected" state via + `.catch(() => setMission(null))`. Decision: in the SPA port, a 404 maps to the quiet + no-mission state (baseline user-visible behavior for the common case); any other failure + surfaces as a `role="alert"` per the projects.tsx convention. This keeps "empty" and "error" + distinct, as the brief's test bar requires. + +## Plan + +1. Port `MissionStatus` + `StatCard` into `apps/web/src/spa/pages/projects.tsx` (private + components, same structure as baseline), with the page's cancelled-flag effect convention. +2. Add `coordMissionStatusFixture` to `page-fixtures.ts` typed by an exported local + `CoordMissionStatus` interface (mirrors gateway DTO; no shared-types change — + `packages/types` is contract-shared with gateway review and adds no route/type here). +3. Convert `projects.spec.tsx` to path-keyed API mocks (page now fetches two endpoints; + Once-queue mocks would be order-fragile because child effects fire before parent effects). + Add behavioral specs: data-render, no-mission(404), error alert, grid-survives-error, loading. +4. Mutation-verify by cp snapshot/restore (never git). Run the four gates before/after. + +## Progress log + +- Baseline measured on disk before edits: test 24 files / 238 tests rc=0 (typecheck/lint/build:vite + clean per dispatcher at same HEAD). +- Implemented port; fixture; spec conversion + 5 new tests. See final gates below. + +## Verification evidence + +| Gate | Before | After | +| --- | --- | --- | +| typecheck | clean (dispatcher @216cd722) | clean, rc=0 | +| lint | clean (dispatcher @216cd722) | clean, rc=0 | +| test | 24 files / 238 tests, rc=0 | 24 files / 243 tests, rc=0 | +| build:vite | clean + pre-existing chunk warning | clean + same pre-existing chunk warning | + +Mutations (cp snapshot → edit → run spec → cp restore → sha256 verified): +- M1: removed `` render from the page → 5 tests failed (all 5 new mission tests). +- M2: removed the 404→no-mission branch (404 falls to generic error) → 1 test failed + (no-active-mission path test). + +## Risks / notes + +- Baseline flat-key defect not replicated (disclosed in report; fixing the legacy Next tree is out + of scope — fenced). +- No new routes; `packages/types/src/web-ui-routes.ts` untouched. diff --git a/docs/scratchpads/webui-p5a-gateway-static.md b/docs/scratchpads/webui-p5a-gateway-static.md new file mode 100644 index 000000000..8e084c7d6 --- /dev/null +++ b/docs/scratchpads/webui-p5a-gateway-static.md @@ -0,0 +1,156 @@ +# WebUI Phase P — P5a Gateway Static Surface + SPA History Fallback Scratchpad + +**Task ID:** P5a (remediation pass) +**Tracking ref:** Phase P increment map (`docs/webui/PHASE-P-STRUCTURE.md` §5); no provider issue +**Branch:** `feat/webui-p5a-gateway-static` (HEAD `216cd722`, uncommitted working-tree state) +**Started:** 2026-08-12 (remediation session) +**Role:** author worker; two independent reviews (security + code, author ≠ reviewer) already +delivered; this pass remediates the confirmed findings. `docs/TASKS.md` remains orchestrator-only. + +## What P5a implements + +The gateway (NestJS + Fastify) gains a production Web UI serving path: it serves the built Vite +bundle (`apps/web/dist`) same-origin on `:14242` behind an explicit mode switch, with a +history-API fallback shaped by the SPA route table instead of a blanket `/*` → `index.html` rule. +Companion changes: `GATEWAY_CORS_ORIGIN` parsing moved into one tested seam +(`apps/gateway/src/cors-origins.ts`) consumed by REST CORS (`main.ts`), the raw BetterAuth mount +(`auth/auth.controller.ts` + `trustedOrigins` in `auth/auth.module.ts`), and the Socket.IO +handshake (`chat/chat.gateway.ts`); CSP tightened for the SPA image surface; `apps/web` build +output cutover prep (vite config, index.html, theme-init). + +## Containment mechanism (author's own words) + +1. **Canonicalize, then compare, never trust spellings.** The bundle root is `realpath`'d once at + registration (`inspectWebUiBundle`), so a symlinked release dir is fine but every later file + lookup resolves through the canonical root. A candidate path is accepted only when + `relative(webRoot, realpath(join(webRoot, …)))` yields a path that is neither empty, nor + `..`-prefixed, nor absolute — traversal, symlink hops, and encoded spellings that resolve outside + the root all fail that comparison. Decode is one `decodeURIComponent` pass on the pathname only; + double-encoded spellings decode to literal file names (and are tested to do exactly that). +2. **Reserved-prefix deny list before any static/history lookup.** `/api` and `/socket.io` (plus + their `//…` children) are denied at the catch-all before static file resolution or the + SPA fallback can claim them. The check is deliberately case-sensitive — `/API` is an ordinary + static name, never a shadow of the API namespace. Segment hygiene (`.`, `..`, dotfile-prefixed) + is enforced twice: at path parsing and again at file resolution; `@fastify/static` is registered + with `dotfiles: 'deny'` as a third layer. +3. **Route-table-shaped fallback.** An unmatched path falls back to `index.html` only when + `isSpaRoute` says the path matches a route from `@mosaicstack/types` `SPA_ROUTES` (exact match, + UUID-shaped `:id`, provider-registry-checked `:provider`) **and** the request accepts HTML. + Everything else gets the standard JSON 404, so the fallback can never paper over an API typo or + serve the SPA shell to non-browser clients. The provider list crossing the `@mosaicstack/auth` + package boundary is shape-checked at registration (`assertSpaAuthProviderRegistry`). + +## GATEWAY_WEB_UI_MODE states + +- `disabled` (default; also for unset/empty): no Web UI routes are registered. If a bundle is + nonetheless present on disk, startup **fails** — a stale `apps/web/dist` must never silently + ship an unvetted UI. The default is `disabled` because the installed/CLI launch path has no + bundle guarantee; the operator must opt in. +- `required`: the bundle is validated (index.html + Vite manifest + real, non-symlink files, entry + chunk actually referenced by a same-origin `