salvage: uncommitted work from /home/jwoltje/distrobox-homes/mosaic-dev/src/stack-p5a (disk cleanup #824, 2026-11-06)
This commit is contained in:
1 parent
216cd72226
commit
c0500a9b9b
47 files changed
+6359
-80
No files matched your search
@@ -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,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(
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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" /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
Reference in new issue
Block a user