salvage: uncommitted work from /home/jwoltje/distrobox-homes/mosaic-dev/src/stack-p5a (disk cleanup #824, 2026-11-06)

This commit is contained in:
mos committed 2026-11-06 09:55:29 -06:00
1 parent 216cd72226
commit c0500a9b9b
47 files changed
+6359 -80

No files matched your search

+5 -2
View File
@@ -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",
+3 -3
View File
@@ -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>(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(
+2
View File
@@ -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],
},
+4 -3
View File
@@ -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 {
+94
View File
@@ -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',
);
});
});
});
+27
View File
@@ -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;
}
+34 -6
View File
@@ -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<void> {
const logger = new Logger('Bootstrap');
@@ -45,13 +48,30 @@ async function bootstrap(): Promise<void> {
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<void> {
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}`);
+280
View File
@@ -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 = '<main id="spa-shell">bundle-matrix</main>';
interface BundleFixture {
indexHtml?: string;
manifest?: unknown;
files?: Record<string, string>;
}
async function buildBundle(fixture: BundleFixture): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'mosaic-bundle-matrix-'));
const indexHtml =
fixture.indexHtml ??
`<!doctype html>${INDEX_MARKER}<script type="module" src="/assets/app.js"></script>`;
const manifest = fixture.manifest ?? { 'index.html': { file: 'assets/app.js', isEntry: true } };
const writeRelative = async (relativePath: string, contents: string): Promise<void> => {
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<void>,
): Promise<void> {
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<string, string>;
}
const cases: AcceptCase[] = [
{
name: 'module script loads the manifest entry (baseline)',
indexHtml: `<!doctype html>${INDEX_MARKER}<script type="module" src="/assets/app.js"></script>`,
},
{
name: 'classic script loads the manifest entry (IIFE-style build)',
indexHtml: `<!doctype html>${INDEX_MARKER}<script src="/assets/app.js"></script>`,
},
{
name: 'module script plus nomodule legacy fallback',
indexHtml: `<!doctype html>${INDEX_MARKER}<script type="module" src="/assets/app.js"></script><script nomodule src="/assets/legacy.js"></script>`,
files: { 'assets/legacy.js': 'window.bootstrap();' },
},
{
name: 'entry referenced with a percent-encoded spelling',
indexHtml: `<!doctype html>${INDEX_MARKER}<script type="module" src="/assets/%61pp.js"></script>`,
},
{
name: 'entry referenced with a dot-relative spelling',
indexHtml: `<!doctype html>${INDEX_MARKER}<script type="module" src="./assets/app.js"></script>`,
},
{
name: 'entry referenced with a cache-busting query string',
indexHtml: `<!doctype html>${INDEX_MARKER}<script type="module" src="/assets/app.js?v=1"></script>`,
},
{
name: 'entry also preloaded via modulepreload link',
indexHtml: `<!doctype html>${INDEX_MARKER}<link rel="modulepreload" href="/assets/app.js"><script type="module" src="/assets/app.js"></script>`,
},
{
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: `<div>${'<div>'.repeat(100)}${INDEX_MARKER}${'</div>'.repeat(100)}</div><script type="module" src="/assets/app.js"></script>`,
},
];
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('<div id="root"></div>');
} 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<string, string>;
reason: RegExp;
}
const cases: RejectCase[] = [
{
name: 'index with no script elements at all',
round5: 'accepted',
indexHtml: `<!doctype html>${INDEX_MARKER}<title>shell</title>`,
reason: /must load the Vite entry chunk/u,
},
{
name: 'index whose only script is theme-init.js',
round5: 'accepted',
indexHtml: `<!doctype html>${INDEX_MARKER}<script src="/theme-init.js"></script>`,
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: `<!doctype html>${INDEX_MARKER}<script src="https://cdn.example.com/assets/app.js"></script>`,
reason: /must load the Vite entry chunk/u,
},
{
name: 'sole script is protocol-relative',
round5: 'accepted',
indexHtml: `<!doctype html>${INDEX_MARKER}<script src="//cdn.example.com/assets/app.js"></script>`,
reason: /must load the Vite entry chunk/u,
},
{
name: 'sole script is a data: URI',
round5: 'accepted',
indexHtml: `<!doctype html>${INDEX_MARKER}<script src="data:text/javascript,export {}"></script>`,
reason: /must load the Vite entry chunk/u,
},
{
name: 'entry spelled relative under an external base href',
round5: 'accepted',
indexHtml: `<!doctype html><base href="https://cdn.example.com/">${INDEX_MARKER}<script src="assets/app.js"></script>`,
reason: /must load the Vite entry chunk/u,
},
{
name: 'entry referenced only through a modulepreload link',
round5: 'accepted',
indexHtml: `<!doctype html>${INDEX_MARKER}<link rel="modulepreload" href="/assets/app.js">`,
reason: /must load the Vite entry chunk/u,
},
{
name: 'entry referenced only through a stylesheet link',
round5: 'accepted',
indexHtml: `<!doctype html>${INDEX_MARKER}<link rel="stylesheet" href="/assets/app.js">`,
reason: /must load the Vite entry chunk/u,
},
{
name: 'only an inline module, no external entry script (singlefile shape)',
round5: 'accepted',
indexHtml: `<!doctype html>${INDEX_MARKER}<script type="module">import "/assets/app.js"</script>`,
reason: /must load the Vite entry chunk/u,
},
{
name: 'entry loaded but a second script is missing (existence check still fires)',
round5: 'rejected',
indexHtml: `<!doctype html>${INDEX_MARKER}<script type="module" src="/assets/app.js"></script><script src="/assets/gone.js"></script>`,
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: `<div>${'<div>'.repeat(300)}${'</div>'.repeat(300)}</div><script type="module" src="/assets/app.js"></script>`,
reason: /nests deeper than 128 levels/u,
},
{
name: 'index node count exceeds the node budget (2,100 flat elements)',
round5: 'accepted',
indexHtml: `${'<p>x</p>'.repeat(2_100)}<script type="module" src="/assets/app.js"></script>`,
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: `<!doctype html>${INDEX_MARKER}<title>shell</title>` },
async (root) => {
await expect(assertWebUiBundle(root)).rejects.toThrow(/must load the Vite entry chunk/u);
await writeFile(
join(root, 'index.html'),
`<!doctype html>${INDEX_MARKER}<title>shell</title><script type="module" src="/assets/app.js"></script>`,
'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);
});
});
+547
View File
@@ -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 = '<main id="spa-shell">containment-fixture</main>';
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 "/<uuid>" 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<void> => {
const path = join(root, relativePath);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, contents, 'utf8');
};
await write(
webRoot,
'index.html',
`<!doctype html>${INDEX_MARKER}<script type="module" src="/assets/app.js"></script>`,
);
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);
});
});
});
+785
View File
@@ -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 = '<main id="spa-shell">gateway-spa-fixture</main>';
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<void> {
const path = join(root, relativePath);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, contents, 'utf8');
}
async function expectStaticServingIsActive(app: FastifyInstance): Promise<void> {
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',
`<!doctype html>${INDEX_MARKER}<script type="module" src="/assets/app.js"></script>`,
);
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<NestFastifyApplication>(
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', `<!doctype html>${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',
`<!doctype html>${INDEX_MARKER}<script type="module" src="/assets/app-a1b2c3d4.js"></script>`,
);
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',
`<!doctype html>${INDEX_MARKER}<script type="module" src="/assets/missing.js"></script>`,
);
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',
`<!doctype html><!-- <script src="/assets/decoy.js"></script> -->${INDEX_MARKER}<script src = /assets/app.js></script>`,
);
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', `<!doctype 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', `<!doctype 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<string, unknown> };
};
registerWebUiMock: ReturnType<typeof vi.fn>;
}
async function runMainBootstrap(
webUiMode: 'disabled' | 'required',
corsOrigins: string[] = [],
): Promise<MainBootstrapResult> {
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<void> => {
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);
});
});
+619
View File
@@ -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<string>(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<string>;
}
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<void> {
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<string> {
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<string>, 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<DefaultTreeAdapterMap> {
const elementDepths = new WeakMap<object, number>();
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 <script src>.
* Only script elements execute; link relations (modulepreload, stylesheet,
* ...) fetch but do not boot the SPA, so they are excluded from the returned
* set.
*/
async function assertIndexResourceFiles(
root: string,
indexHtml: string,
): Promise<ReadonlySet<string>> {
const document = parse(indexHtml, { treeAdapter: createBoundedIndexTreeAdapter() });
let documentBase = new URL('/', BUNDLE_DOCUMENT_ORIGIN);
let hasDocumentBase = false;
const resources: string[] = [];
const scriptReferences: string[] = [];
walkHtml(document, (element) => {
if (element.tagName === 'base') {
const href = htmlAttribute(element, 'href');
if (href !== undefined && !hasDocumentBase) {
try {
documentBase = new URL(href, documentBase);
hasDocumentBase = true;
} catch {
throw new Error('bundle index.html contains an invalid base URL');
}
}
return;
}
if (element.tagName === 'script') {
const src = htmlAttribute(element, 'src');
if (src !== undefined) {
resources.push(src);
scriptReferences.push(src);
}
return;
}
if (element.tagName === 'link') {
const relations = (htmlAttribute(element, 'rel') ?? '').toLowerCase().split(/\s+/u);
if (!relations.some((relation) => LINK_RESOURCE_RELATIONS.has(relation))) return;
const href = htmlAttribute(element, 'href');
if (href !== undefined) resources.push(href);
}
});
const localScriptPaths = new Set<string>();
for (const resource of resources) {
const relativePath = localBundleResourcePath(resource, documentBase);
if (relativePath !== undefined) {
await assertRealBundleFile(root, relativePath, 'bundle index.html resource');
if (scriptReferences.includes(resource)) localScriptPaths.add(relativePath);
}
}
return localScriptPaths;
}
async function inspectWebUiBundle(webRoot: string): Promise<WebUiBundle> {
try {
const rootPath = resolve(webRoot);
const canonicalWebRoot = await realpath(rootPath);
const rootStats = await lstat(canonicalWebRoot);
if (!rootStats.isDirectory()) throw new Error('bundle path must resolve to a directory');
await assertRealBundleFile(canonicalWebRoot, 'index.html', 'bundle index.html');
const indexHtml = await readBoundedUtf8(
join(canonicalWebRoot, 'index.html'),
MAX_INDEX_HTML_BYTES,
'bundle index.html',
);
await assertRealBundleFile(canonicalWebRoot, VITE_MANIFEST_PATH, 'bundle Vite manifest');
const parsed: unknown = JSON.parse(
await readBoundedUtf8(
join(canonicalWebRoot, VITE_MANIFEST_PATH),
MAX_VITE_MANIFEST_BYTES,
'bundle Vite manifest',
),
);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('bundle Vite manifest must be an object');
}
const entries = parsed as Record<string, unknown>;
const indexEntryValue = entries['index.html'];
if (
typeof indexEntryValue !== 'object' ||
indexEntryValue === null ||
Array.isArray(indexEntryValue)
) {
throw new Error('bundle Vite manifest has no index.html entry');
}
const indexEntry = indexEntryValue as ViteManifestEntry;
if (indexEntry.isEntry !== true) {
throw new Error('bundle Vite manifest index.html is not an entry');
}
const immutableAssetPaths = new Set<string>();
for (const value of Object.values(entries)) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error('bundle Vite manifest contains an invalid entry');
}
const entry = value as ViteManifestEntry;
addManifestAssetPath(immutableAssetPaths, entry.file);
if (entry.css !== undefined) {
if (!Array.isArray(entry.css)) {
throw new Error('bundle Vite manifest contains an invalid css list');
}
entry.css.forEach((path) => addManifestAssetPath(immutableAssetPaths, path));
}
if (entry.assets !== undefined) {
if (!Array.isArray(entry.assets)) {
throw new Error('bundle Vite manifest contains an invalid assets list');
}
entry.assets.forEach((path) => addManifestAssetPath(immutableAssetPaths, path));
}
}
for (const assetPath of immutableAssetPaths) {
await assertRealBundleFile(canonicalWebRoot, assetPath, 'bundle Vite asset');
}
const indexScriptPaths = await assertIndexResourceFiles(canonicalWebRoot, indexHtml);
// A manifest entry plus resolving references is not bootable on its own:
// the browser only executes scripts, so the manifest's entry chunk must be
// loaded by a <script> that resolves same-origin inside this bundle root.
// External spellings are excluded by CSP `script-src 'self'` and relative
// spellings under an external <base> resolve off-origin, so a bundle whose
// index never names the entry locally cannot boot React from this gateway.
const indexEntryPath = assetPathSegments(indexEntry.file).join('/');
if (!indexScriptPaths.has(indexEntryPath)) {
throw new Error(
`bundle index.html must load the Vite entry chunk "${indexEntryPath}" as a same-origin script`,
);
}
return { canonicalWebRoot, immutableAssetPaths };
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(
`Web UI bundle is unavailable at ${webRoot}. Run "pnpm --filter @mosaicstack/web build:vite" before starting the gateway. (${reason})`,
{ cause: error },
);
}
}
export async function assertWebUiBundle(webRoot: string): Promise<void> {
await inspectWebUiBundle(webRoot);
}
function pathnameFromUrl(url: string): string | undefined {
const delimiterIndex = url.search(/[?#]/u);
const encodedPathname = delimiterIndex === -1 ? url : url.slice(0, delimiterIndex);
if (!encodedPathname.startsWith('/')) return undefined;
try {
// The reserved-prefix guarantee is deliberately case-sensitive and covers
// the literal path plus one percent-decoding pass. Case, Unicode, trailing
// aliases, and double-encoded spellings remain ordinary static names; they
// are never eligible for the SPA history fallback.
return decodeURIComponent(encodedPathname);
} catch {
return undefined;
}
}
function isReservedPath(url: string): boolean {
const pathname = pathnameFromUrl(url);
if (!pathname) return true;
return RESERVED_PATH_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
);
}
function normalizeReactRouterPath(pathname: string): string {
let normalizedPathname = pathname;
if (pathname !== '/') normalizedPathname = pathname.replace(/\/+$/u, '');
return normalizedPathname;
}
type SpaRoute = (typeof SPA_ROUTES)[keyof typeof SPA_ROUTES];
function routeParameter(
routePattern: string,
parameter: string,
normalizedPathname: string,
): string | undefined {
const placeholder = `:${parameter}`;
if (!routePattern.endsWith(placeholder)) return undefined;
const prefix = routePattern.slice(0, -placeholder.length);
if (!normalizedPathname.toLowerCase().startsWith(prefix.toLowerCase())) return undefined;
return normalizedPathname.slice(prefix.length);
}
function matchesSpaRoute(route: SpaRoute, normalizedPathname: string): boolean {
switch (route.gatewayMatch) {
case 'exact':
return normalizedPathname.toLowerCase() === route.path.toLowerCase();
case 'project-id': {
const projectId = routeParameter(route.path, 'id', normalizedPathname);
return projectId !== undefined && PROJECT_ID_PATTERN.test(projectId);
}
case 'supported-auth-provider': {
const provider = routeParameter(route.path, 'provider', normalizedPathname);
return provider !== undefined && SPA_AUTH_PROVIDERS.has(provider.toLowerCase());
}
default: {
const exhaustive: never = route;
throw new Error(`Unsupported SPA Gateway matcher: ${JSON.stringify(exhaustive)}`);
}
}
}
function isSpaRoute(url: string): boolean {
const pathname = pathnameFromUrl(url);
if (!pathname) return false;
const normalizedPathname = normalizeReactRouterPath(pathname);
return Object.values(SPA_ROUTES).some((route) => matchesSpaRoute(route, normalizedPathname));
}
function cacheControlForFile(
filePath: string,
webRoot: string,
immutableAssetPaths: ReadonlySet<string>,
): string {
const relativePath = relative(webRoot, filePath).split('\\').join('/');
if (immutableAssetPaths.has(relativePath)) return 'public, max-age=2592000, immutable';
return 'no-cache';
}
function resolvesInsideRoot(pathname: string, webRoot: string): boolean {
try {
const target = realpathSync(join(webRoot, `.${pathname}`));
const relativeTarget = relative(webRoot, target);
return (
relativeTarget === '' || (!relativeTarget.startsWith('..') && !isAbsolute(relativeTarget))
);
} catch {
return true;
}
}
async function resolveStaticFile(pathname: string, webRoot: string): Promise<string | undefined> {
const segments = pathname.split('/').slice(1);
if (
segments.length === 0 ||
segments.some(
(segment) => segment === '' || segment === '.' || segment === '..' || segment.startsWith('.'),
)
) {
return undefined;
}
try {
const target = await realpath(join(webRoot, ...segments));
const relativeTarget = relative(webRoot, target);
if (
relativeTarget === '' ||
relativeTarget.startsWith('..') ||
isAbsolute(relativeTarget) ||
!resolvesInsideRoot(`/${segments.join('/')}`, webRoot)
) {
return undefined;
}
const targetStats = await lstat(target);
if (!targetStats.isFile()) return undefined;
return relativeTarget.split('\\').join('/');
} catch {
return undefined;
}
}
function acceptsHtml(request: FastifyRequest): boolean {
const accept = request.headers.accept;
if (!accept) return false;
return accept.split(',').some((entry) => {
const [mediaType = '', ...parameters] = entry.split(';').map((part) => part.trim());
const quality = parameters.find((parameter) => parameter.startsWith('q='));
if (quality && Number(quality.slice(2)) === 0) return false;
return mediaType === 'text/html' || mediaType === 'application/xhtml+xml';
});
}
function sendJsonNotFound(request: FastifyRequest, reply: FastifyReply): FastifyReply {
const pathname = pathnameFromUrl(request.originalUrl) ?? '/';
return reply
.code(404)
.type('application/json')
.send({
message: `Cannot ${request.method} ${pathname}`,
error: 'Not Found',
statusCode: 404,
});
}
/**
* Register only after Nest has initialized its HTTP and WebSocket routes. The
* Web UI uses an explicit Fastify catch-all route instead of replacing Nest's
* not-found handler. Fastify gives controller routes precedence over the
* catch-all, while reserved paths are denied before static or history lookup.
*/
export async function registerWebUiForMode(
fastify: FastifyInstance,
webRoot: string,
mode = resolveWebUiMode(),
): Promise<void> {
if (mode === 'disabled') {
try {
await lstat(join(webRoot, 'index.html'));
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT' || code === 'ENOTDIR') return;
throw error;
}
throw new Error(
`GATEWAY_WEB_UI_MODE is "disabled", but a Web UI bundle is present at ${webRoot}. ` +
`Remove the stale bundle (for example: rm -rf "${webRoot}") or set GATEWAY_WEB_UI_MODE="required".`,
);
}
await registerWebUi(fastify, webRoot);
}
export async function registerWebUi(fastify: FastifyInstance, webRoot: string): Promise<void> {
assertSpaAuthProviderRegistry();
const { canonicalWebRoot, immutableAssetPaths } = await inspectWebUiBundle(webRoot);
fastify.register(fastifyStatic, {
root: canonicalWebRoot,
cacheControl: false,
dotfiles: 'deny',
serve: false,
setHeaders: (response, filePath) => {
response.setHeader(
'Cache-Control',
cacheControlForFile(filePath, canonicalWebRoot, immutableAssetPaths),
);
},
});
fastify.get('/', (request, reply) => {
if (pathnameFromUrl(request.originalUrl) !== '/') return sendJsonNotFound(request, reply);
return reply.header('Cache-Control', 'no-cache').sendFile('index.html');
});
fastify.get('/*', async (request, reply) => {
const pathname = pathnameFromUrl(request.originalUrl);
if (pathname && !isReservedPath(request.originalUrl)) {
const staticFile = await resolveStaticFile(pathname, canonicalWebRoot);
if (staticFile) return reply.sendFile(staticFile);
if (isSpaRoute(request.originalUrl) && acceptsHtml(request)) {
return reply.header('Cache-Control', 'no-cache').sendFile('index.html');
}
}
return sendJsonNotFound(request, reply);
});
await fastify.ready();
}
+1 -11
View File
@@ -11,17 +11,7 @@
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap"
/>
<script>
// set data-theme before first paint so the stored theme never flashes
(function () {
try {
var theme = window.localStorage.getItem('mosaic-theme') || 'dark';
document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark');
} catch (error) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
<script src="/theme-init.js"></script>
</head>
<body>
<div id="root"></div>
+7
View File
@@ -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');
}
+47 -15
View File
@@ -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: <GuestLayout />,
children: [
{ path: '/login', element: <LoginPage /> },
{ path: '/register', element: <RegisterPage /> },
{ path: '/auth/provider/:provider', element: <SsoCallbackPage /> },
{ path: SPA_ROUTES.login.path, element: <LoginPage /> },
{ path: SPA_ROUTES.register.path, element: <RegisterPage /> },
{ path: SPA_ROUTES.authProvider.path, element: <SsoCallbackPage /> },
],
},
],
@@ -43,21 +48,48 @@ export const routes: RouteObject[] = [
{
element: <AuthGuard />,
children: [
{ path: '/', element: <Navigate to="/chat" replace /> },
{ path: '/chat', element: <ChatPage />, errorElement: <ChatRouteErrorBoundary /> },
{
path: '/projects',
element: <ProjectsPage />,
errorElement: <ProjectsRouteErrorBoundary />,
path: SPA_ROUTES.root.path,
element: <Navigate to={SPA_ROUTES.chat.path} replace />,
},
// 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: <ProjectDetailPage />,
errorElement: <ProjectDetailRouteErrorBoundary />,
element: <AppShellLayout />,
children: [
{
path: SPA_ROUTES.chat.path,
element: <ChatPage />,
errorElement: <ChatRouteErrorBoundary />,
},
{
path: SPA_ROUTES.projects.path,
element: <ProjectsPage />,
errorElement: <ProjectsRouteErrorBoundary />,
},
{
path: SPA_ROUTES.projectDetail.path,
element: <ProjectDetailPage />,
errorElement: <ProjectDetailRouteErrorBoundary />,
},
{
path: SPA_ROUTES.tasks.path,
element: <TasksPage />,
errorElement: <TasksRouteErrorBoundary />,
},
{
path: SPA_ROUTES.settings.path,
element: <SettingsPage />,
errorElement: <SettingsRouteErrorBoundary />,
},
{
path: SPA_ROUTES.admin.path,
element: <AdminPage />,
errorElement: <AdminRouteErrorBoundary />,
},
],
},
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
{ path: '/settings', element: <Placeholder title="Settings" /> },
{ path: '/admin', element: <Placeholder title="Admin" /> },
],
},
];
+37 -3
View File
@@ -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 ? <Navigate to="/chat" replace /> : <Outlet />;
return session ? <Navigate to={SPA_ROUTES.chat.path} replace /> : <Outlet />;
}
export function AuthGuard(): ReactElement {
@@ -19,5 +20,38 @@ export function AuthGuard(): ReactElement {
);
}
return session ? <Outlet /> : <Navigate to="/login" replace />;
return session ? <Outlet /> : <Navigate to={SPA_ROUTES.login.path} replace />;
}
// 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<typeof session>['user'] & { role?: string })
| undefined;
if (isPending) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-sm text-text-muted">Loading...</div>
</div>
);
}
if (!session) {
return <Navigate to={SPA_ROUTES.login.path} replace />;
}
if (user?.role !== 'admin') {
return <Navigate to={SPA_ROUTES.root.path} replace />;
}
return <>{children}</>;
}
+561
View File
@@ -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 <main>Chat page</main>;
},
}));
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: '[email protected]',
role: 'admin',
},
};
interface RenderedRouter {
container: HTMLDivElement;
router: ReturnType<typeof createMemoryRouter>;
}
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<string, string>();
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<RenderedRouter> {
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(
<ThemeProvider>
<RouterProvider router={router} />
</ThemeProvider>,
);
});
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<HTMLAnchorElement>('nav a')];
}
function clickSignOut(view: RenderedRouter): Promise<void> {
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('[email protected]');
});
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<HTMLButtonElement>(
'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();
});
});
+35
View File
@@ -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 (
<div className="app-shell" data-sidebar-hidden={!isMobile && collapsed ? 'true' : undefined}>
<Topbar />
<Sidebar />
<div className="app-main">
<main className="h-full overflow-y-auto p-6">
<Outlet />
</main>
</div>
</div>
);
}
export function AppShellLayout(): ReactElement {
return (
<SidebarProvider>
<AppShellFrame />
</SidebarProvider>
);
}
+182
View File
@@ -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 (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M3 4.5A2.5 2.5 0 0 1 5.5 2h5A2.5 2.5 0 0 1 13 4.5v3A2.5 2.5 0 0 1 10.5 10H8l-3.5 3v-3H5.5A2.5 2.5 0 0 1 3 7.5z" />
</svg>
);
}
function IconTasks(): React.JSX.Element {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M6 3h7M6 8h7M6 13h7" />
<path d="M2.5 3.5 3.5 4.5 5 2.5M2.5 8.5 3.5 9.5 5 7.5M2.5 13.5 3.5 14.5 5 12.5" />
</svg>
);
}
function IconProjects(): React.JSX.Element {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M2 4.5A1.5 1.5 0 0 1 3.5 3h3l1.5 1.5h4A1.5 1.5 0 0 1 13.5 6v5.5A1.5 1.5 0 0 1 12 13H3.5A1.5 1.5 0 0 1 2 11.5z" />
</svg>
);
}
function IconSettings(): React.JSX.Element {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<circle cx="8" cy="8" r="2.25" />
<path d="M8 1.5v2M8 12.5v2M1.5 8h2M12.5 8h2M3.05 3.05l1.4 1.4M11.55 11.55l1.4 1.4M3.05 12.95l1.4-1.4M11.55 4.45l1.4-1.4" />
</svg>
);
}
function IconAdmin(): React.JSX.Element {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M8 1.75 13 3.5v3.58c0 3.12-1.88 5.94-5 7.17-3.12-1.23-5-4.05-5-7.17V3.5z" />
<path d="M6.25 7.75 7.5 9l2.5-2.5" />
</svg>
);
}
// 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: <IconChat /> },
{ label: 'Tasks', href: SPA_ROUTES.tasks.path, icon: <IconTasks /> },
{ label: 'Projects', href: SPA_ROUTES.projects.path, icon: <IconProjects /> },
{ label: 'Settings', href: SPA_ROUTES.settings.path, icon: <IconSettings /> },
{ label: 'Admin', href: SPA_ROUTES.admin.path, icon: <IconAdmin /> },
];
export function Sidebar(): React.JSX.Element {
const { pathname } = useLocation();
const { mobileOpen, setMobileOpen } = useSidebar();
return (
<>
<aside
className="app-sidebar"
data-mobile-open={mobileOpen ? 'true' : undefined}
style={{
width: 'var(--sidebar-w)',
background: 'var(--surface)',
borderRightColor: 'var(--border)',
}}
>
<div
className="flex h-16 items-center gap-3 border-b px-5"
style={{ borderColor: 'var(--border)' }}
>
<MosaicLogo size={32} />
<div className="flex min-w-0 flex-col">
<span className="text-sm font-semibold uppercase tracking-[0.12em] text-[var(--text)]">
Mosaic
</span>
<span className="text-xs text-[var(--muted)]">Mission Control</span>
</div>
</div>
<nav className="flex-1 px-3 py-4">
<div className="mb-3 px-2 text-[11px] font-medium uppercase tracking-[0.18em] text-[var(--muted)]">
Workspace
</div>
<div className="space-y-1.5">
{workspaceNavItems.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.href}
to={item.href}
onClick={() => setMobileOpen(false)}
aria-current={isActive ? 'page' : undefined}
className={cn(
'group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition-all duration-150',
isActive ? 'font-medium' : 'hover:bg-white/4',
)}
style={
isActive
? {
background: 'color-mix(in srgb, var(--primary) 18%, transparent)',
color: 'var(--primary)',
}
: { color: 'var(--text-2)' }
}
>
<span className="shrink-0" aria-hidden="true">
{item.icon}
</span>
<span>{item.label}</span>
</Link>
);
})}
</div>
</nav>
<div className="border-t px-5 py-4" style={{ borderColor: 'var(--border)' }}>
<p className="text-xs text-[var(--muted)]">Mosaic Stack v0.0.4</p>
</div>
</aside>
{mobileOpen ? (
<button
type="button"
aria-label="Close sidebar"
className="fixed inset-0 z-40 bg-black/40 md:hidden"
onClick={() => setMobileOpen(false)}
/>
) : null}
</>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { useState } from 'react';
import { SPA_ROUTES } from '@mosaicstack/types';
import { signOut, useSession } from '@/lib/auth-client';
import { ThemeToggle } from '@/components/layout/theme-toggle';
import { useSidebar } from '@/components/layout/sidebar-context';
function MenuIcon(): React.JSX.Element {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M2 4h12M2 8h12M2 12h12" />
</svg>
);
}
export function Topbar(): React.JSX.Element {
const { data: session } = useSession();
const [signOutFailed, setSignOutFailed] = useState(false);
const { isMobile, mobileOpen, setMobileOpen, toggleCollapsed } = useSidebar();
async function handleSignOut(): Promise<void> {
setSignOutFailed(false);
// better-auth's client leaves better-fetch's catchAllError unset, so HTTP
// failures (e.g. 500/403 on session revocation) resolve as
// { data: null, error } and only network-level failures reject. Either
// way the user must stay here: navigating to /login with a live session
// strands them on a signed-out URL while still authenticated.
try {
const result = await signOut();
if (result.error) {
setSignOutFailed(true);
return;
}
} catch {
setSignOutFailed(true);
return;
}
// Full document teardown, not a soft navigate: better-auth clears the
// session atom only on a delayed /get-session refetch after the sign-out
// POST, so a soft navigate would land on /login with the atom still
// populated and GuestGuard would bounce the user back through a
// re-mounted authenticated shell (new socket). Replacing the document
// discards the atom, the socket singleton and all provider state in one
// step, letting the guards bootstrap from a clean state.
window.location.replace(SPA_ROUTES.login.path);
}
function handleSidebarToggle(): void {
if (isMobile) {
setMobileOpen(!mobileOpen);
return;
}
toggleCollapsed();
}
return (
<header
className="app-header justify-between border-b px-4 md:px-6"
style={{
height: 'var(--topbar-h)',
background: 'color-mix(in srgb, var(--surface) 88%, transparent)',
borderBottomColor: 'var(--border)',
}}
>
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleSidebarToggle}
className="btn-ghost rounded-lg border p-2"
style={{ borderColor: 'var(--border)', color: 'var(--text-2)' }}
aria-label="Toggle sidebar"
>
<MenuIcon />
</button>
<div className="hidden sm:block">
<div className="text-sm font-medium text-[var(--text)]">Workspace</div>
<div className="text-xs text-[var(--muted)]">Unified agent operations</div>
</div>
</div>
<div className="flex items-center gap-3">
<ThemeToggle />
{session?.user ? (
<>
{signOutFailed ? (
<span
role="alert"
className="rounded-md border border-error/30 bg-error/10 px-2.5 py-1 text-xs text-error"
>
Sign out failed. Try again.
</span>
) : null}
<span className="hidden text-sm text-[var(--text-2)] sm:block">
{session.user.name ?? session.user.email}
</span>
<button
type="button"
onClick={handleSignOut}
className="rounded-md px-3 py-1.5 text-sm transition-colors"
style={{ color: 'var(--muted)' }}
>
Sign out
</button>
</>
) : (
<span className="text-sm text-[var(--muted)]">Not signed in</span>
)}
</div>
</header>
);
}
+238
View File
@@ -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: '[email protected]', role: 'admin' },
};
const memberSession = {
user: { id: 'user-2', name: 'Alan', email: '[email protected]', role: 'member' },
};
const userFixtures = [
{
id: 'admin-1',
name: 'Grace',
email: '[email protected]',
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: '[email protected]',
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<ReturnType<typeof createMemoryRouter>> {
const routes: RouteObject[] = [
{ path: SPA_ROUTES.admin.path, element: <AdminPage /> },
{ path: SPA_ROUTES.root.path, element: <p>Root redirect target</p> },
{ path: SPA_ROUTES.login.path, element: <p>Login redirect target</p> },
];
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(<RouterProvider router={router} />);
});
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('[email protected]');
expect(container.textContent).toContain('[email protected]');
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('[email protected]'),
);
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('[email protected]'),
);
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('[email protected]');
apiMock.mockResolvedValueOnce({ users: userFixtures, total: 2 });
await act(async () => {
clickButtonByText('Retry');
});
expect(container.textContent).toContain('[email protected]');
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');
});
});
+536
View File
@@ -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 (
<AdminRoleGuard>
<AdminContent />
</AdminRoleGuard>
);
}
function AdminContent(): ReactElement {
const [activeTab, setActiveTab] = useState<'users' | 'health'>('users');
return (
<div className="mx-auto max-w-5xl space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-text-primary">Admin Panel</h1>
</div>
<div className="flex gap-1 border-b border-surface-border">
{(['users', 'health'] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={cn(
'px-4 py-2 text-sm font-medium capitalize transition-colors',
activeTab === tab
? 'border-b-2 border-blue-500 text-blue-400'
: 'text-text-secondary hover:text-text-primary',
)}
>
{tab === 'users' ? 'User Management' : 'System Health'}
</button>
))}
</div>
{activeTab === 'users' ? <UsersTab /> : <HealthTab />}
</div>
);
}
// ── Users Tab ──────────────────────────────────────────────────────────────────
function UsersTab(): ReactElement {
const [users, setUsers] = useState<UserDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const loadUsers = useCallback(async (): Promise<void> => {
setLoading(true);
setError(null);
try {
const data = await api<UserListDto>('/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<void> {
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<void> {
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<void> {
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 <p className="text-sm text-text-muted">Loading users...</p>;
}
if (error) {
return (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4">
<p className="text-sm text-red-400">{error}</p>
<button
type="button"
onClick={() => void loadUsers()}
className="mt-2 text-xs text-red-300 underline hover:no-underline"
>
Retry
</button>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-text-muted">{users.length} user(s)</p>
<button
type="button"
onClick={() => setShowCreate(true)}
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-blue-700"
>
+ New User
</button>
</div>
{showCreate && (
<CreateUserForm
onCancel={() => setShowCreate(false)}
onCreated={() => {
setShowCreate(false);
void loadUsers();
}}
/>
)}
{users.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-6 text-center">
<p className="text-sm text-text-muted">No users found</p>
</div>
) : (
<div className="overflow-hidden rounded-lg border border-surface-border">
<table className="w-full">
<thead>
<tr className="border-b border-surface-border bg-surface-elevated text-left text-xs text-text-muted">
<th className="px-4 py-2 font-medium">Name / Email</th>
<th className="px-4 py-2 font-medium">Role</th>
<th className="hidden px-4 py-2 font-medium md:table-cell">Status</th>
<th className="hidden px-4 py-2 font-medium md:table-cell">Created</th>
<th className="px-4 py-2 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b border-surface-border last:border-b-0">
<td className="px-4 py-3">
<div className="text-sm font-medium text-text-primary">{user.name}</div>
<div className="text-xs text-text-muted">{user.email}</div>
</td>
<td className="px-4 py-3">
<span
className={cn(
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
user.role === 'admin'
? 'bg-purple-500/20 text-purple-400'
: 'bg-surface-elevated text-text-secondary',
)}
>
{user.role}
</span>
</td>
<td className="hidden px-4 py-3 md:table-cell">
{user.banned ? (
<span className="inline-flex rounded-full bg-red-500/20 px-2 py-0.5 text-xs font-medium text-red-400">
Banned
</span>
) : (
<span className="inline-flex rounded-full bg-green-500/20 px-2 py-0.5 text-xs font-medium text-green-400">
Active
</span>
)}
</td>
<td className="hidden px-4 py-3 text-xs text-text-muted md:table-cell">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void handleRoleToggle(user)}
className="text-xs text-blue-400 hover:text-blue-300"
title={user.role === 'admin' ? 'Demote to member' : 'Promote to admin'}
>
{user.role === 'admin' ? 'Demote' : 'Promote'}
</button>
<button
type="button"
onClick={() => void handleBanToggle(user)}
className={cn(
'text-xs',
user.banned
? 'text-green-400 hover:text-green-300'
: 'text-yellow-400 hover:text-yellow-300',
)}
>
{user.banned ? 'Unban' : 'Ban'}
</button>
<button
type="button"
onClick={() => void handleDelete(user)}
className="text-xs text-red-400 hover:text-red-300"
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
// ── 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<string | null>(null);
async function handleSubmit(e: FormEvent): Promise<void> {
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 (
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
<h3 className="mb-3 text-sm font-medium text-text-primary">Create New User</h3>
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-3">
{error && <p className="text-xs text-red-400">{error}</p>}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs text-text-muted">Name</label>
<input
type="text"
required
value={name}
onChange={(e) => 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"
/>
</div>
<div>
<label className="mb-1 block text-xs text-text-muted">Email</label>
<input
type="email"
required
value={email}
onChange={(e) => 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"
/>
</div>
<div>
<label className="mb-1 block text-xs text-text-muted">Password</label>
<input
type="password"
required
value={password}
onChange={(e) => 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"
/>
</div>
<div>
<label className="mb-1 block text-xs text-text-muted">Role</label>
<select
value={role}
onChange={(e) => setRole(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"
>
<option value="member">member</option>
<option value="admin">admin</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="rounded-md px-3 py-1.5 text-sm text-text-muted hover:text-text-primary"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
{submitting ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</div>
);
}
// ── Health Tab ────────────────────────────────────────────────────────────────
function HealthTab(): ReactElement {
const [health, setHealth] = useState<HealthStatusDto | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadHealth = useCallback(async (): Promise<void> => {
setLoading(true);
setError(null);
try {
const data = await api<HealthStatusDto>('/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 <p className="text-sm text-text-muted">Loading health status...</p>;
}
if (error) {
return (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4">
<p className="text-sm text-red-400">{error}</p>
<button
type="button"
onClick={() => void loadHealth()}
className="mt-2 text-xs text-red-300 underline hover:no-underline"
>
Retry
</button>
</div>
);
}
if (!health) return <></>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<StatusBadge status={health.status} />
<span className="text-sm text-text-muted">
Last checked: {new Date(health.checkedAt).toLocaleTimeString()}
</span>
</div>
<button
type="button"
onClick={() => void loadHealth()}
className="text-xs text-blue-400 hover:text-blue-300"
>
Refresh
</button>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* Database */}
<HealthCard title="Database (PostgreSQL)" status={health.database.status}>
{health.database.latencyMs !== undefined && (
<p className="text-xs text-text-muted">Latency: {health.database.latencyMs}ms</p>
)}
{health.database.error && <p className="text-xs text-red-400">{health.database.error}</p>}
</HealthCard>
{/* Cache */}
<HealthCard title="Cache (Valkey)" status={health.cache.status}>
{health.cache.latencyMs !== undefined && (
<p className="text-xs text-text-muted">Latency: {health.cache.latencyMs}ms</p>
)}
{health.cache.error && <p className="text-xs text-red-400">{health.cache.error}</p>}
</HealthCard>
{/* Agent Pool */}
<HealthCard title="Agent Pool" status="ok">
<p className="text-xs text-text-muted">
Active sessions: {health.agentPool.activeSessions}
</p>
</HealthCard>
{/* Providers */}
<HealthCard
title="LLM Providers"
status={health.providers.some((p) => p.available) ? 'ok' : 'error'}
>
{health.providers.length === 0 ? (
<p className="text-xs text-text-muted">No providers configured</p>
) : (
<ul className="space-y-1">
{health.providers.map((p) => (
<li key={p.id} className="flex items-center justify-between text-xs">
<span className="text-text-secondary">{p.name}</span>
<span
className={cn(
'rounded-full px-1.5 py-0.5',
p.available ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400',
)}
>
{p.available ? `${p.modelCount} models` : 'unavailable'}
</span>
</li>
))}
</ul>
)}
</HealthCard>
</div>
</div>
);
}
// ── 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 (
<span className={cn('rounded-full px-2 py-0.5 text-xs font-medium capitalize', map[status])}>
{status}
</span>
);
}
interface HealthCardProps {
title: string;
status: 'ok' | 'error';
children?: ReactNode;
}
function HealthCard({ title, status, children }: HealthCardProps): ReactElement {
return (
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-medium text-text-primary">{title}</h3>
<span
className={cn('h-2 w-2 rounded-full', status === 'ok' ? 'bg-green-400' : 'bg-red-400')}
/>
</div>
{children}
</div>
);
}
@@ -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(<RouterProvider router={router} />);
root?.render(
<ThemeProvider>
<RouterProvider router={router} />
</ThemeProvider>,
);
});
expect(consoleErrorSpy).toHaveBeenCalled();
+28
View File
@@ -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',
+158 -5
View File
@@ -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<T>(): Deferred<T> {
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<string, unknown>): 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<ReturnType<typeof createMemoryRoute
return router;
}
/**
* Returns the value a StatCard renders under the given label. Asserting the
* label-to-value pairing (instead of a bare substring of page text) ensures
* each stat's assertion can only be satisfied by that stat's own element.
*/
function statValue(label: string): string | null {
const labelElement = [...container.querySelectorAll('p')].find(
(element) => 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<typeof projectFixtures>();
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<typeof coordMissionStatusFixture>();
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');
});
});
});
+137
View File
@@ -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<CoordMissionStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void api<CoordMissionStatus>('/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 (
<section className="mt-8">
<h2 className="mb-4 text-lg font-semibold">Active Mission</h2>
{loading ? (
<p className="text-sm text-text-muted">Loading mission status...</p>
) : error ? (
<div role="alert" className="rounded-lg border border-error/40 px-4 py-3 text-sm">
{error}
</div>
) : !mission ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-6 text-center">
<p className="text-sm text-text-muted">No active mission detected</p>
</div>
) : (
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard label="Mission" value={mission.mission.id} />
<StatCard label="Phase" value={mission.milestones.current?.name ?? '—'} />
<StatCard label="Tasks" value={`${mission.tasks.done} / ${mission.tasks.total}`} />
<StatCard label="Status" value={mission.mission.status} />
</div>
</div>
)}
</section>
);
}
function StatCard({ label, value }: { label: string; value: string }): ReactElement {
return (
<div className="rounded-lg bg-surface-elevated p-3">
<p className="text-xs text-text-muted">{label}</p>
<p className="mt-1 text-sm font-medium text-text-primary">{value}</p>
</div>
);
}
export function ProjectsPage(): ReactElement {
const navigate = useNavigate();
const [projects, setProjects] = useState<Project[]>([]);
@@ -65,6 +200,8 @@ export function ProjectsPage(): ReactElement {
))}
</div>
)}
<MissionStatus />
</div>
);
}
@@ -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(<RouterProvider router={router} />);
root?.render(
<ThemeProvider>
<RouterProvider router={router} />
</ThemeProvider>,
);
});
expect(consoleErrorSpy).toHaveBeenCalled();
@@ -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 (
<ResourceRouteErrorBoundary
message="Something went wrong loading projects."
href="/projects"
href={SPA_ROUTES.projects.path}
linkLabel="Reload projects"
/>
);
@@ -38,7 +39,7 @@ export function ProjectDetailRouteErrorBoundary(): ReactElement {
return (
<ResourceRouteErrorBoundary
message="Something went wrong loading this project."
href="/projects"
href={SPA_ROUTES.projects.path}
linkLabel="Back to projects"
/>
);
@@ -48,8 +49,28 @@ export function TasksRouteErrorBoundary(): ReactElement {
return (
<ResourceRouteErrorBoundary
message="Something went wrong loading tasks."
href="/tasks"
href={SPA_ROUTES.tasks.path}
linkLabel="Reload tasks"
/>
);
}
export function SettingsRouteErrorBoundary(): ReactElement {
return (
<ResourceRouteErrorBoundary
message="Something went wrong loading settings."
href={SPA_ROUTES.settings.path}
linkLabel="Reload settings"
/>
);
}
export function AdminRouteErrorBoundary(): ReactElement {
return (
<ResourceRouteErrorBoundary
message="Something went wrong loading the admin panel."
href={SPA_ROUTES.admin.path}
linkLabel="Reload admin panel"
/>
);
}
+308
View File
@@ -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: '[email protected]', 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<ReturnType<typeof createMemoryRouter>> {
const routes: RouteObject[] = [
{ path: SPA_ROUTES.settings.path, element: <SettingsPage /> },
{ path: SPA_ROUTES.root.path, element: <p>Root</p> },
];
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(<RouterProvider router={router} />);
});
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<HTMLInputElement>('#profile-name');
expect(nameInput?.value).toBe('Ada Lovelace');
const emailInput = container.querySelector<HTMLInputElement>('#profile-email');
expect(emailInput?.value).toBe('[email protected]');
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<HTMLInputElement>('#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');
});
});
+820
View File
@@ -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<T>(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<Tab>('profile');
const tabs: { id: Tab; label: string }[] = [
{ id: 'profile', label: 'Profile' },
{ id: 'appearance', label: 'Appearance' },
{ id: 'notifications', label: 'Notifications' },
{ id: 'providers', label: 'Providers' },
];
return (
<div className="mx-auto max-w-3xl space-y-6">
<h1 className="text-2xl font-semibold">Settings</h1>
{/* Tab bar */}
<div className="flex gap-1 border-b border-surface-border">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === tab.id
? 'border-b-2 border-accent text-accent'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{tab.label}
</button>
))}
</div>
{activeTab === 'profile' && <ProfileTab session={session} />}
{activeTab === 'appearance' && <AppearanceTab />}
{activeTab === 'notifications' && <NotificationsTab />}
{activeTab === 'providers' && <ProvidersTab />}
</div>
);
}
// ─── 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<SaveState>('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<void> => {
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 (
<section className="space-y-4">
<h2 className="text-lg font-medium text-text-secondary">Profile</h2>
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-4">
<FormField label="Display Name" id="profile-name">
<input
id="profile-name"
type="text"
value={name}
onChange={(e) => 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"
/>
</FormField>
<FormField label="Email" id="profile-email">
<input
id="profile-email"
type="email"
value={session?.user.email ?? ''}
disabled
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-muted opacity-60 cursor-not-allowed"
/>
<p className="mt-1 text-xs text-text-muted">Email cannot be changed here.</p>
</FormField>
<FormField label="Avatar URL" id="profile-image">
<input
id="profile-image"
type="url"
value={image}
onChange={(e) => 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"
/>
</FormField>
<div className="flex items-center gap-3 pt-2">
<SaveButton state={saveState} onClick={handleSave} />
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
</div>
</div>
</section>
);
}
// ─── Appearance Tab ───────────────────────────────────────────────────────────
function AppearanceTab(): ReactElement {
const [loading, setLoading] = useState(true);
const [theme, setTheme] = useState<Theme>('system');
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [defaultModel, setDefaultModel] = useState('');
const [saveState, setSaveState] = useState<SaveState>('idle');
const [errorMsg, setErrorMsg] = useState('');
useEffect(() => {
api<Preference[]>('/api/memory/preferences?category=appearance')
.catch(() => [] as Preference[])
.then((p) => {
setTheme(prefValue<Theme>(p, 'ui.theme', 'system'));
setSidebarCollapsed(prefValue<boolean>(p, 'ui.sidebar_collapsed', false));
setDefaultModel(prefValue<string>(p, 'ui.default_model', ''));
})
.finally(() => setLoading(false));
}, []);
const handleSave = async (): Promise<void> => {
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 (
<section>
<h2 className="mb-4 text-lg font-medium text-text-secondary">Appearance</h2>
<p className="text-sm text-text-muted">Loading preferences...</p>
</section>
);
}
return (
<section className="space-y-4">
<h2 className="text-lg font-medium text-text-secondary">Appearance</h2>
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-6">
{/* Theme */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2">Theme</label>
<div className="flex gap-3">
{(['system', 'light', 'dark'] as Theme[]).map((t) => (
<button
key={t}
type="button"
onClick={() => setTheme(t)}
className={`rounded-lg border px-4 py-2 text-sm capitalize transition-colors ${
theme === t
? 'border-accent bg-accent/10 text-accent'
: 'border-surface-border bg-surface-elevated text-text-secondary hover:border-accent/50'
}`}
>
{t}
</button>
))}
</div>
</div>
{/* Sidebar collapsed default */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-text-primary">Collapse sidebar by default</p>
<p className="text-xs text-text-muted">Start with sidebar collapsed on page load</p>
</div>
<Toggle checked={sidebarCollapsed} onChange={setSidebarCollapsed} />
</div>
{/* Default model */}
<FormField label="Default Model" id="default-model">
<input
id="default-model"
type="text"
value={defaultModel}
onChange={(e) => 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"
/>
<p className="mt-1 text-xs text-text-muted">
Model ID to pre-select for new conversations.
</p>
</FormField>
<div className="flex items-center gap-3 pt-2">
<SaveButton state={saveState} onClick={handleSave} />
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
</div>
</div>
</section>
);
}
// ─── 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<SaveState>('idle');
const [errorMsg, setErrorMsg] = useState('');
useEffect(() => {
api<Preference[]>('/api/memory/preferences?category=communication')
.catch(() => [] as Preference[])
.then((p) => {
setEmailAgentComplete(prefValue<boolean>(p, 'notify.email_agent_complete', false));
setEmailMentions(prefValue<boolean>(p, 'notify.email_mentions', true));
setEmailDigest(prefValue<boolean>(p, 'notify.email_digest', false));
})
.finally(() => setLoading(false));
}, []);
const handleSave = async (): Promise<void> => {
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 (
<section>
<h2 className="mb-4 text-lg font-medium text-text-secondary">Notifications</h2>
<p className="text-sm text-text-muted">Loading preferences...</p>
</section>
);
}
return (
<section className="space-y-4">
<h2 className="text-lg font-medium text-text-secondary">Notifications</h2>
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-6">
<p className="text-xs text-text-muted">Configure when you receive email notifications.</p>
<NotifyRow
label="Agent task completed"
description="Email when an agent finishes a task"
checked={emailAgentComplete}
onChange={setEmailAgentComplete}
/>
<NotifyRow
label="Mentions"
description="Email when you are mentioned in a conversation"
checked={emailMentions}
onChange={setEmailMentions}
/>
<NotifyRow
label="Weekly digest"
description="Weekly summary of activity"
checked={emailDigest}
onChange={setEmailDigest}
/>
<div className="flex items-center gap-3 pt-2">
<SaveButton state={saveState} onClick={handleSave} />
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
</div>
</div>
</section>
);
}
// ─── Providers Tab ────────────────────────────────────────────────────────────
function ProvidersTab(): ReactElement {
const [providers, setProviders] = useState<ProviderInfo[]>([]);
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
const [loading, setLoading] = useState(true);
const [ssoLoading, setSsoLoading] = useState(true);
const [testStatuses, setTestStatuses] = useState<Record<string, ProviderTestStatus>>({});
useEffect(() => {
api<ProviderInfo[]>('/api/providers')
.catch(() => [] as ProviderInfo[])
.then((p) => setProviders(p))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
api<SsoProviderDiscovery[]>('/api/sso/providers')
.catch(() => [] as SsoProviderDiscovery[])
.then((providers) => setSsoProviders(providers))
.finally(() => setSsoLoading(false));
}, []);
const testConnection = useCallback(async (providerId: string): Promise<void> => {
setTestStatuses((prev) => ({
...prev,
[providerId]: { state: 'testing' },
}));
try {
const result = await api<TestConnectionResult>('/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 (
<section className="space-y-6">
<div className="space-y-4">
<h2 className="text-lg font-medium text-text-secondary">SSO Providers</h2>
<SsoProviderSection providers={ssoProviders} loading={ssoLoading} />
</div>
<div className="space-y-4">
<h2 className="text-lg font-medium text-text-secondary">LLM Providers</h2>
{loading ? (
<p className="text-sm text-text-muted">Loading providers...</p>
) : providers.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
<p className="text-sm text-text-muted">
No providers configured. Set{' '}
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
OLLAMA_BASE_URL
</code>{' '}
or{' '}
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
MOSAIC_CUSTOM_PROVIDERS
</code>{' '}
to add providers.
</p>
</div>
) : (
<div className="space-y-4">
{providers.map((provider) => (
<ProviderCard
key={provider.id}
provider={provider}
defaultModel={defaultModel}
testStatus={testStatuses[provider.id] ?? { state: 'idle' }}
onTest={() => void testConnection(provider.id)}
/>
))}
</div>
)}
</div>
</section>
);
}
// ─── Shared UI Components ─────────────────────────────────────────────────────
function FormField({
label,
id,
children,
}: {
label: string;
id: string;
children: ReactNode;
}): ReactElement {
return (
<div>
<label htmlFor={id} className="block text-sm font-medium text-text-primary">
{label}
</label>
{children}
</div>
);
}
function Toggle({
checked,
onChange,
}: {
checked: boolean;
onChange: (v: boolean) => void;
}): ReactElement {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-surface-card ${
checked ? 'bg-accent' : 'bg-surface-border'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
checked ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
);
}
function NotifyRow({
label,
description,
checked,
onChange,
}: {
label: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
}): ReactElement {
return (
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-text-primary">{label}</p>
<p className="text-xs text-text-muted">{description}</p>
</div>
<Toggle checked={checked} onChange={onChange} />
</div>
);
}
function SaveButton({ state, onClick }: { state: SaveState; onClick: () => void }): ReactElement {
return (
<button
type="button"
onClick={onClick}
disabled={state === 'saving'}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{state === 'saving' ? 'Saving...' : state === 'saved' ? 'Saved!' : 'Save changes'}
</button>
);
}
// ─── 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 (
<div className="rounded-lg border border-surface-border bg-surface-card">
{/* Header row */}
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
<ProviderAvatar id={provider.id} />
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text-primary">{provider.name}</span>
<ProviderStatusBadge available={provider.available} />
</div>
<p className="text-xs text-text-muted">
{provider.models.length} model{provider.models.length !== 1 ? 's' : ''}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<TestConnectionButton status={testStatus} onTest={onTest} />
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="rounded px-2 py-1 text-xs text-text-muted transition-colors hover:bg-surface-elevated hover:text-text-primary"
aria-expanded={expanded}
aria-label={expanded ? 'Collapse models' : 'Expand models'}
>
{expanded ? '▲ Hide' : '▼ Models'}
</button>
</div>
</div>
{/* Test result banner */}
{testStatus.state !== 'idle' && testStatus.state !== 'testing' && testStatus.result && (
<TestResultBanner result={testStatus.result} />
)}
{/* Model list */}
{expanded && (
<div className="border-t border-surface-border">
<table className="w-full">
<thead>
<tr className="bg-surface-elevated text-left text-xs text-text-muted">
<th className="px-4 py-2 font-medium">Model</th>
<th className="hidden px-4 py-2 font-medium md:table-cell">Capabilities</th>
<th className="hidden px-4 py-2 font-medium md:table-cell">Context</th>
<th className="hidden px-4 py-2 font-medium md:table-cell">Cost (in/out)</th>
<th className="px-4 py-2 font-medium">Default</th>
</tr>
</thead>
<tbody>
{provider.models.map((model) => (
<ModelRow
key={model.id}
model={model}
isDefault={
defaultModel?.id === model.id && defaultModel?.provider === model.provider
}
/>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
interface ModelRowProps {
model: ModelInfo;
isDefault: boolean;
}
function ModelRow({ model, isDefault }: ModelRowProps): ReactElement {
return (
<tr className="border-t border-surface-border">
<td className="px-4 py-2">
<span className="text-sm text-text-primary">{model.name}</span>
</td>
<td className="hidden px-4 py-2 md:table-cell">
<div className="flex flex-wrap gap-1">
<CapabilityBadge label="chat" />
{model.reasoning && <CapabilityBadge label="reasoning" color="purple" />}
{model.inputTypes.includes('image') && <CapabilityBadge label="vision" color="blue" />}
</div>
</td>
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
{formatContext(model.contextWindow)}
</td>
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
{model.cost.input === 0 && model.cost.output === 0
? 'free'
: `$${model.cost.input} / $${model.cost.output}`}
</td>
<td className="px-4 py-2 text-center">
{isDefault && (
<span
className="inline-block rounded-full bg-accent/20 px-2 py-0.5 text-xs font-medium text-accent"
title="Default model used for new sessions"
>
default
</span>
)}
</td>
</tr>
);
}
function ProviderAvatar({ id }: { id: string }): ReactElement {
const letter = id.charAt(0).toUpperCase();
return (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-elevated text-sm font-semibold text-text-secondary">
{letter}
</div>
);
}
function ProviderStatusBadge({ available }: { available: boolean }): ReactElement {
return (
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
available ? 'bg-success/20 text-success' : 'bg-surface-elevated text-text-muted'
}`}
>
{available ? 'Active' : 'Inactive'}
</span>
);
}
interface TestConnectionButtonProps {
status: ProviderTestStatus;
onTest: () => void;
}
function TestConnectionButton({ status, onTest }: TestConnectionButtonProps): ReactElement {
const isTesting = status.state === 'testing';
return (
<button
type="button"
onClick={onTest}
disabled={isTesting}
className="rounded px-2 py-1 text-xs transition-colors hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-50"
title="Test connection"
>
{isTesting ? (
<span className="text-text-muted">Testing…</span>
) : status.state === 'success' ? (
<span className="text-success">✓ Reachable</span>
) : status.state === 'error' ? (
<span className="text-error">✗ Unreachable</span>
) : (
<span className="text-text-muted">Test</span>
)}
</button>
);
}
function TestResultBanner({ result }: { result: TestConnectionResult }): ReactElement {
return (
<div
className={`px-4 py-2 text-xs ${
result.reachable ? 'bg-success/10 text-success' : 'bg-error/10 text-error'
}`}
>
{result.reachable ? (
<>
Connected
{result.latencyMs !== undefined && (
<span className="ml-1 opacity-70">({result.latencyMs}ms)</span>
)}
{result.discoveredModels && result.discoveredModels.length > 0 && (
<span className="ml-2 opacity-70">
— {result.discoveredModels.length} model
{result.discoveredModels.length !== 1 ? 's' : ''} discovered
</span>
)}
</>
) : (
<>Connection failed{result.error ? `: ${result.error}` : ''}</>
)}
</div>
);
}
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 <span className={`rounded px-1.5 py-0.5 text-xs ${colorClass}`}>{label}</span>;
}
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);
}
+9 -16
View File
@@ -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);
+47
View File
@@ -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<string, string>();
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,
});
}
}
}
+5
View File
@@ -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)),