Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
068d0f9b1c |
@@ -1,104 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import {
|
|
||||||
type CanActivate,
|
|
||||||
type ExecutionContext,
|
|
||||||
type INestApplication,
|
|
||||||
ValidationPipe,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import request from 'supertest';
|
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
|
||||||
import { WorkspaceController } from './workspace.controller.js';
|
|
||||||
|
|
||||||
const bootstrapMock = vi.fn(() =>
|
|
||||||
Promise.resolve({
|
|
||||||
projectId: 'project-1',
|
|
||||||
workspacePath: '/opt/mosaic/.workspaces/users/user-1/project-1',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const authGuard: CanActivate = {
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
|
||||||
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
|
|
||||||
requestContext.user = { id: 'user-1' };
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('POST /api/workspaces repoUrl validation', () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
|
||||||
controllers: [WorkspaceController],
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: ProjectBootstrapService,
|
|
||||||
useValue: { bootstrap: bootstrapMock },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
.overrideGuard(AuthGuard)
|
|
||||||
.useValue(authGuard)
|
|
||||||
.compile();
|
|
||||||
|
|
||||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
|
||||||
app.useGlobalPipes(
|
|
||||||
new ValidationPipe({
|
|
||||||
whitelist: true,
|
|
||||||
forbidNonWhitelisted: true,
|
|
||||||
transform: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await app.init();
|
|
||||||
await app.getHttpAdapter().getInstance().ready();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
bootstrapMock.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['a leading-dash value', '--upload-pack=sh -c id'],
|
|
||||||
['an ext remote helper', 'ext::sh -c id'],
|
|
||||||
['a file URL', 'file:///tmp/repository'],
|
|
||||||
['an unparseable value', 'not a url'],
|
|
||||||
['an SSH shorthand', '[email protected]:acme/repository.git'],
|
|
||||||
['a scheme without //', 'https:example.com/acme/repository.git'],
|
|
||||||
['a hostless git URL', 'git:///tmp/repository'],
|
|
||||||
])('returns 400 for %s', async (_description, repoUrl) => {
|
|
||||||
const response = await request(app.getHttpServer())
|
|
||||||
.post('/api/workspaces')
|
|
||||||
.send({ name: 'Example', repoUrl })
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
expect(bootstrapMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['a plain HTTPS repository URL', 'https://example.com/acme/repository.git'],
|
|
||||||
['a git protocol repository URL', 'git://example.com/acme/repository.git'],
|
|
||||||
])('accepts %s', async (_description, repoUrl) => {
|
|
||||||
const response = await request(app.getHttpServer())
|
|
||||||
.post('/api/workspaces')
|
|
||||||
.send({ name: 'Example', repoUrl })
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
expect(response.status).toBe(201);
|
|
||||||
expect(bootstrapMock).toHaveBeenCalledWith({
|
|
||||||
name: 'Example',
|
|
||||||
description: undefined,
|
|
||||||
userId: 'user-1',
|
|
||||||
teamId: undefined,
|
|
||||||
repoUrl,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
import {
|
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
||||||
ProjectBootstrapService,
|
|
||||||
type BootstrapProjectResult,
|
|
||||||
} from './project-bootstrap.service.js';
|
|
||||||
import { CreateWorkspaceDto } from './workspace.dto.js';
|
|
||||||
|
|
||||||
@Controller('api/workspaces')
|
@Controller('api/workspaces')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@@ -15,14 +11,20 @@ export class WorkspaceController {
|
|||||||
@Post()
|
@Post()
|
||||||
async create(
|
async create(
|
||||||
@CurrentUser() user: { id: string },
|
@CurrentUser() user: { id: string },
|
||||||
@Body() dto: CreateWorkspaceDto,
|
@Body()
|
||||||
): Promise<BootstrapProjectResult> {
|
body: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
teamId?: string;
|
||||||
|
repoUrl?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
return this.bootstrap.bootstrap({
|
return this.bootstrap.bootstrap({
|
||||||
name: dto.name,
|
name: body.name,
|
||||||
description: dto.description,
|
description: body.description,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: dto.teamId,
|
teamId: body.teamId,
|
||||||
repoUrl: dto.repoUrl,
|
repoUrl: body.repoUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { IsOptional, IsString, IsUrl, Matches, MaxLength } from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateWorkspaceDto {
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(255)
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(10_000)
|
|
||||||
description?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
teamId?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@Matches(/^(?:https|git):\/\//i, {
|
|
||||||
message: 'repoUrl must be a valid https:// or git:// URL',
|
|
||||||
})
|
|
||||||
@IsUrl(
|
|
||||||
{
|
|
||||||
protocols: ['https', 'git'],
|
|
||||||
require_host: true,
|
|
||||||
require_protocol: true,
|
|
||||||
require_tld: false,
|
|
||||||
require_valid_protocol: true,
|
|
||||||
},
|
|
||||||
{ message: 'repoUrl must be a valid https:// or git:// URL' },
|
|
||||||
)
|
|
||||||
repoUrl?: string;
|
|
||||||
}
|
|
||||||
@@ -1,33 +1,11 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import fs from 'node:fs/promises';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { WorkspaceService } from './workspace.service.js';
|
import { WorkspaceService } from './workspace.service.js';
|
||||||
|
import path from 'node:path';
|
||||||
type ExecFileMock = (
|
|
||||||
command: string,
|
|
||||||
args: readonly string[],
|
|
||||||
options: { cwd: string },
|
|
||||||
callback: (error: Error | null, stdout: string, stderr: string) => void,
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
const { execFileMock } = vi.hoisted(() => ({
|
|
||||||
execFileMock: vi.fn<ExecFileMock>(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('node:child_process', () => ({
|
|
||||||
execFile: execFileMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('WorkspaceService', () => {
|
describe('WorkspaceService', () => {
|
||||||
let service: WorkspaceService;
|
let service: WorkspaceService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
execFileMock.mockReset();
|
|
||||||
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
|
||||||
callback(null, '', '');
|
|
||||||
});
|
|
||||||
service = new WorkspaceService();
|
service = new WorkspaceService();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,69 +76,4 @@ describe('WorkspaceService', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('create', () => {
|
|
||||||
const project = {
|
|
||||||
id: 'project-1',
|
|
||||||
ownerType: 'user',
|
|
||||||
userId: 'user-1',
|
|
||||||
teamId: null,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
let originalRoot: string | undefined;
|
|
||||||
let temporaryRoot: string;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
originalRoot = process.env['MOSAIC_ROOT'];
|
|
||||||
temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mosaic-workspace-'));
|
|
||||||
process.env['MOSAIC_ROOT'] = temporaryRoot;
|
|
||||||
service = new WorkspaceService();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
if (originalRoot === undefined) {
|
|
||||||
delete process.env['MOSAIC_ROOT'];
|
|
||||||
} else {
|
|
||||||
process.env['MOSAIC_ROOT'] = originalRoot;
|
|
||||||
}
|
|
||||||
await fs.rm(temporaryRoot, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['a leading-dash URL', '--upload-pack=sh -c id'],
|
|
||||||
['an ext remote helper', 'ext::sh -c id'],
|
|
||||||
['a file URL', 'file:///tmp/repository'],
|
|
||||||
['an unparseable value', 'not a url'],
|
|
||||||
['an SSH shorthand', '[email protected]:acme/repository.git'],
|
|
||||||
['a scheme without //', 'https:example.com/acme/repository.git'],
|
|
||||||
['a hostless git URL', 'git:///tmp/repository'],
|
|
||||||
])('rejects %s before invoking git', async (_description, repoUrl) => {
|
|
||||||
await expect(service.create(project, repoUrl)).rejects.toBeInstanceOf(BadRequestException);
|
|
||||||
expect(execFileMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['an HTTPS URL', 'https://example.com/acme/repository.git'],
|
|
||||||
['a git protocol URL', 'git://example.com/acme/repository.git'],
|
|
||||||
])('accepts %s and invokes hardened git clone arguments', async (_description, repoUrl) => {
|
|
||||||
const workspacePath = await service.create(project, repoUrl);
|
|
||||||
|
|
||||||
expect(execFileMock).toHaveBeenCalledOnce();
|
|
||||||
expect(execFileMock).toHaveBeenCalledWith(
|
|
||||||
'git',
|
|
||||||
[
|
|
||||||
'-c',
|
|
||||||
'protocol.ext.allow=never',
|
|
||||||
'-c',
|
|
||||||
'protocol.file.allow=never',
|
|
||||||
'clone',
|
|
||||||
'--',
|
|
||||||
repoUrl,
|
|
||||||
'.',
|
|
||||||
],
|
|
||||||
{ cwd: workspacePath },
|
|
||||||
expect.any(Function),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,30 +1,10 @@
|
|||||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const allowedRepositoryProtocols = new Set(['https:', 'git:']);
|
|
||||||
const repositoryUrlPrefixPattern = /^(?:https|git):\/\//i;
|
|
||||||
const repositoryUrlError = 'repoUrl must be a valid https:// or git:// URL';
|
|
||||||
|
|
||||||
function assertAllowedRepositoryUrl(repoUrl: string): void {
|
|
||||||
if (repoUrl.startsWith('-') || !repositoryUrlPrefixPattern.test(repoUrl)) {
|
|
||||||
throw new BadRequestException(repositoryUrlError);
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsedUrl: URL;
|
|
||||||
try {
|
|
||||||
parsedUrl = new URL(repoUrl);
|
|
||||||
} catch {
|
|
||||||
throw new BadRequestException(repositoryUrlError);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!allowedRepositoryProtocols.has(parsedUrl.protocol) || parsedUrl.hostname.length === 0) {
|
|
||||||
throw new BadRequestException(repositoryUrlError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkspaceProject {
|
export interface WorkspaceProject {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -59,32 +39,14 @@ export class WorkspaceService {
|
|||||||
* If repoUrl is provided, clone instead of init.
|
* If repoUrl is provided, clone instead of init.
|
||||||
*/
|
*/
|
||||||
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
|
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
|
||||||
if (repoUrl !== undefined) {
|
|
||||||
assertAllowedRepositoryUrl(repoUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspacePath = this.resolvePath(project);
|
const workspacePath = this.resolvePath(project);
|
||||||
|
|
||||||
// Create directory
|
// Create directory
|
||||||
await fs.mkdir(workspacePath, { recursive: true });
|
await fs.mkdir(workspacePath, { recursive: true });
|
||||||
|
|
||||||
if (repoUrl !== undefined) {
|
if (repoUrl) {
|
||||||
// Clone existing repo. Defense in depth keeps dangerous local helpers
|
// Clone existing repo
|
||||||
// disabled and terminates option parsing before positional arguments.
|
await execFileAsync('git', ['clone', repoUrl, '.'], { cwd: workspacePath });
|
||||||
await execFileAsync(
|
|
||||||
'git',
|
|
||||||
[
|
|
||||||
'-c',
|
|
||||||
'protocol.ext.allow=never',
|
|
||||||
'-c',
|
|
||||||
'protocol.file.allow=never',
|
|
||||||
'clone',
|
|
||||||
'--',
|
|
||||||
repoUrl,
|
|
||||||
'.',
|
|
||||||
],
|
|
||||||
{ cwd: workspacePath },
|
|
||||||
);
|
|
||||||
this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`);
|
this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`);
|
||||||
} else {
|
} else {
|
||||||
// Init new git repo
|
// Init new git repo
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Mosaic</title>
|
||||||
|
<meta name="description" content="Mosaic Stack Dashboard" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
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>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -4,7 +4,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node ../../scripts/build-web.mjs",
|
"build": "node ../../scripts/build-web.mjs",
|
||||||
|
"build:vite": "vite build",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
"dev:vite": "vite",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests",
|
"test": "vitest run --passWithNoTests",
|
||||||
@@ -19,6 +21,7 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-router-dom": "^7.18.2",
|
||||||
"socket.io-client": "^4.8.0",
|
"socket.io-client": "^4.8.0",
|
||||||
"tailwind-merge": "^3.5.0"
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
@@ -28,9 +31,11 @@
|
|||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^6.0.5",
|
||||||
"jsdom": "^29.0.0",
|
"jsdom": "^29.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vitest": "^2.0.0"
|
"vite": "^8.2.1",
|
||||||
|
"vitest": "^3.2.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { RouterProvider } from 'react-router-dom';
|
||||||
|
import { ThemeProvider } from '@/providers/theme-provider';
|
||||||
|
import { createAppRouter } from '@/routes';
|
||||||
|
import '@/app/globals.css';
|
||||||
|
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
if (!container) {
|
||||||
|
throw new Error('missing #root element');
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(container).render(
|
||||||
|
<StrictMode>
|
||||||
|
<ThemeProvider>
|
||||||
|
<RouterProvider router={createAppRouter()} />
|
||||||
|
</ThemeProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { createBrowserRouter, Navigate, type RouteObject } from 'react-router-dom';
|
||||||
|
import { AuthGuard, GuestGuard } from '@/spa/guards';
|
||||||
|
import { Placeholder } from '@/spa/placeholder';
|
||||||
|
|
||||||
|
export const routes: RouteObject[] = [
|
||||||
|
{
|
||||||
|
element: <GuestGuard />,
|
||||||
|
children: [
|
||||||
|
{ path: '/login', element: <Placeholder title="Login" /> },
|
||||||
|
{ path: '/register', element: <Placeholder title="Register" /> },
|
||||||
|
{ path: '/auth/provider/:provider', element: <Placeholder title="Signing in" /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: <AuthGuard />,
|
||||||
|
children: [
|
||||||
|
{ path: '/', element: <Navigate to="/chat" replace /> },
|
||||||
|
{ path: '/chat', element: <Placeholder title="Chat" /> },
|
||||||
|
{ path: '/projects', element: <Placeholder title="Projects" /> },
|
||||||
|
{ path: '/projects/:id', element: <Placeholder title="Project" /> },
|
||||||
|
{ path: '/tasks', element: <Placeholder title="Tasks" /> },
|
||||||
|
{ path: '/settings', element: <Placeholder title="Settings" /> },
|
||||||
|
{ path: '/admin', element: <Placeholder title="Admin" /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function createAppRouter(): ReturnType<typeof createBrowserRouter> {
|
||||||
|
return createBrowserRouter(routes);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
|
// P1 shells: session-aware redirects arrive with the reworked auth client in P2.
|
||||||
|
|
||||||
|
export function GuestGuard(): ReactElement {
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthGuard(): ReactElement {
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
|
||||||
|
export function Placeholder({ title }: { title: string }): ReactElement {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center">
|
||||||
|
<h1 className="text-xl font-medium">{title}</h1>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { RouteObject } from 'react-router-dom';
|
||||||
|
import { routes } from '@/routes';
|
||||||
|
|
||||||
|
function collectPaths(routeObjects: RouteObject[]): string[] {
|
||||||
|
return routeObjects.flatMap((route) => [
|
||||||
|
...(route.path ? [route.path] : []),
|
||||||
|
...(route.children ? collectPaths(route.children) : []),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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('separates guest and authenticated route groups', () => {
|
||||||
|
const guestPaths = collectPaths(routes.at(0)?.children ?? []);
|
||||||
|
const authPaths = collectPaths(routes.at(1)?.children ?? []);
|
||||||
|
expect(guestPaths).toContain('/login');
|
||||||
|
expect(guestPaths).not.toContain('/chat');
|
||||||
|
expect(authPaths).toContain('/chat');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
// The proxy exists only in dev; in production the SPA is same-origin with the gateway
|
||||||
|
// (served by it under Candidate A, or behind one FQDN under Candidate B) and every
|
||||||
|
// request uses a relative path, so no origin may ever be configured here or in src/.
|
||||||
|
const gatewayTarget = 'http://localhost:14242';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3100,
|
||||||
|
proxy: {
|
||||||
|
'/api': gatewayTarget,
|
||||||
|
'/socket.io': { target: gatewayTarget, ws: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// tsconfig uses "jsx": "preserve" for Next; tests need esbuild to compile it
|
||||||
|
esbuild: {
|
||||||
|
jsx: 'automatic',
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
|||||||
COPY apps/gateway/package.json ./apps/gateway/
|
COPY apps/gateway/package.json ./apps/gateway/
|
||||||
COPY packages/ ./packages/
|
COPY packages/ ./packages/
|
||||||
COPY plugins/ ./plugins/
|
COPY plugins/ ./plugins/
|
||||||
# the root prepare script runs scripts/install-hooks.mjs on install
|
|
||||||
COPY scripts/ ./scripts/
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
COPY . .
|
COPY . .
|
||||||
# Build gateway and all of its workspace dependencies via turbo dependency graph
|
# Build gateway and all of its workspace dependencies via turbo dependency graph
|
||||||
@@ -23,22 +21,11 @@ RUN pnpm --filter @mosaicstack/gateway --prod deploy --legacy /deploy
|
|||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
# WorkspaceService shells out to git at runtime and roots workspaces at
|
|
||||||
# $MOSAIC_ROOT/.workspaces (apps/gateway/src/workspace/workspace.service.ts);
|
|
||||||
# mount a volume over /opt/mosaic to persist workspaces across container restarts.
|
|
||||||
# Intentionally unpinned: Alpine's signed repository is the trust anchor; pinning
|
|
||||||
# git was declined so routine base-image security updates remain maintainable.
|
|
||||||
RUN apk add --no-cache git \
|
|
||||||
&& mkdir -p /opt/mosaic/.workspaces \
|
|
||||||
&& chown -R node:node /opt/mosaic /app
|
|
||||||
ENV MOSAIC_ROOT=/opt/mosaic
|
|
||||||
# Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules
|
# Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules
|
||||||
COPY --chown=node:node --from=builder /deploy/node_modules ./node_modules
|
COPY --from=builder /deploy/node_modules ./node_modules
|
||||||
COPY --chown=node:node --from=builder /deploy/package.json ./package.json
|
COPY --from=builder /deploy/package.json ./package.json
|
||||||
# dist is declared in package.json "files" so pnpm deploy copies it into /deploy;
|
# dist is declared in package.json "files" so pnpm deploy copies it into /deploy;
|
||||||
# copy from builder explicitly as belt-and-suspenders
|
# copy from builder explicitly as belt-and-suspenders
|
||||||
COPY --chown=node:node --from=builder /app/apps/gateway/dist ./dist
|
COPY --from=builder /app/apps/gateway/dist ./dist
|
||||||
# gateway defaults to port 14242 (apps/gateway/src/main.ts)
|
EXPOSE 4000
|
||||||
EXPOSE 14242
|
|
||||||
USER node
|
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
@@ -8,11 +8,9 @@ WORKDIR /app
|
|||||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||||
COPY apps/web/package.json ./apps/web/
|
COPY apps/web/package.json ./apps/web/
|
||||||
COPY packages/ ./packages/
|
COPY packages/ ./packages/
|
||||||
# the root prepare script runs scripts/install-hooks.mjs on install
|
|
||||||
COPY scripts/ ./scripts/
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm --filter @mosaicstack/web build
|
RUN pnpm --filter @mosaic/web build
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
Generated
+774
-71
File diff suppressed because it is too large
Load Diff
@@ -75,16 +75,6 @@ export async function installHooks({
|
|||||||
} = {}) {
|
} = {}) {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
|
|
||||||
try {
|
|
||||||
await execFileAsync('git', ['--version']);
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code === 'ENOENT') {
|
|
||||||
console.warn('git not found; skipping hook installation');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const huskyDir = path.join(root, '.husky');
|
const huskyDir = path.join(root, '.husky');
|
||||||
const active = path.join(huskyDir, '_');
|
const active = path.join(huskyDir, '_');
|
||||||
const nonce = `${Date.now()}-${process.pid}`;
|
const nonce = `${Date.now()}-${process.pid}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user