Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc67590a96 |
@@ -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
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
<!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,9 +4,7 @@
|
|||||||
"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",
|
||||||
@@ -21,7 +19,6 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
@@ -31,11 +28,9 @@
|
|||||||
"@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",
|
||||||
"vite": "^8.2.1",
|
"vitest": "^2.0.0"
|
||||||
"vitest": "^3.2.7"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,56 +3,41 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useSearchParams } from 'next/navigation';
|
import { useParams, useSearchParams } from 'next/navigation';
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
|
|
||||||
import { signIn } from '@/lib/auth-client';
|
import { signIn } from '@/lib/auth-client';
|
||||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
import { getSsoProvider } from '@/lib/sso-providers';
|
||||||
|
|
||||||
export default function AuthProviderRedirectPage(): React.ReactElement {
|
export default function AuthProviderRedirectPage(): React.ReactElement {
|
||||||
const params = useParams<{ provider: string }>();
|
const params = useParams<{ provider: string }>();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const providerId = typeof params.provider === 'string' ? params.provider : '';
|
const providerId = typeof params.provider === 'string' ? params.provider : '';
|
||||||
const requestedCallbackURL = searchParams.get('callbackURL');
|
const provider = getSsoProvider(providerId);
|
||||||
const [providerName, setProviderName] = useState<string | null>(null);
|
const callbackURL = searchParams.get('callbackURL') ?? '/chat';
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const currentProvider = provider;
|
||||||
|
|
||||||
|
if (!currentProvider) {
|
||||||
|
setError('Unknown SSO provider.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentProvider.enabled) {
|
||||||
|
setError(`${currentProvider.buttonLabel} is not enabled in this deployment.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeProvider = currentProvider;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
async function redirectToProvider(): Promise<void> {
|
async function redirectToProvider(): Promise<void> {
|
||||||
try {
|
const result = await signIn.oauth2({
|
||||||
const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
|
providerId: activeProvider.id,
|
||||||
const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
|
callbackURL,
|
||||||
if (cancelled) return;
|
});
|
||||||
|
|
||||||
const provider = providers.find((candidate) => candidate.id === providerId);
|
if (!cancelled && result?.error) {
|
||||||
if (!provider) {
|
setError(result.error.message ?? `${activeProvider.buttonLabel} sign in failed.`);
|
||||||
setError('Unknown SSO provider.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setProviderName(provider.name);
|
|
||||||
if (!provider.configured) {
|
|
||||||
setError(`${provider.name} is not enabled in this deployment.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (provider.loginMode !== 'oidc') {
|
|
||||||
setError(`${provider.name} is not available for OIDC sign in.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await signIn.oauth2({
|
|
||||||
providerId: provider.id,
|
|
||||||
callbackURL,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!cancelled && result?.error) {
|
|
||||||
setError(result.error.message ?? `${provider.name} sign in failed.`);
|
|
||||||
}
|
|
||||||
} catch (caught: unknown) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setError(caught instanceof Error ? caught.message : 'Unable to start single sign-on.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,22 +46,19 @@ export default function AuthProviderRedirectPage(): React.ReactElement {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [providerId, requestedCallbackURL]);
|
}, [callbackURL, provider]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
||||||
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
||||||
<p className="mt-2 text-sm text-text-secondary">
|
<p className="mt-2 text-sm text-text-secondary">
|
||||||
{providerName
|
{provider
|
||||||
? `Redirecting you to ${providerName}...`
|
? `Redirecting you to ${provider.buttonLabel.replace('Continue with ', '')}...`
|
||||||
: 'Preparing your sign-in request...'}
|
: 'Preparing your sign-in request...'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div
|
<div className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error">
|
||||||
role="alert"
|
|
||||||
className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
|
||||||
>
|
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { api } from './api';
|
|
||||||
|
|
||||||
describe('api', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetches the supplied relative path with credentials and a JSON body', async () => {
|
|
||||||
const fetchMock = vi.fn<typeof fetch>();
|
|
||||||
fetchMock.mockResolvedValue(
|
|
||||||
new Response(JSON.stringify({ ok: true }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
api<{ ok: boolean }>('/api/projects', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { name: 'Mosaic' },
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({ ok: true });
|
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledOnce();
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
|
||||||
'/api/projects',
|
|
||||||
expect.objectContaining({
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ name: 'Mosaic' }),
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws the gateway JSON error with its statusCode', async () => {
|
|
||||||
const fetchMock = vi.fn<typeof fetch>();
|
|
||||||
fetchMock.mockResolvedValue(
|
|
||||||
new Response(JSON.stringify({ statusCode: 403, message: 'Forbidden' }), {
|
|
||||||
status: 403,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
await expect(api('/api/admin/users')).rejects.toMatchObject({
|
|
||||||
name: 'Error',
|
|
||||||
message: 'Forbidden',
|
|
||||||
statusCode: 403,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
|
||||||
|
|
||||||
export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
|
export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
}
|
}
|
||||||
@@ -23,7 +25,7 @@ export async function api<T>(path: string, init?: ApiRequestInit): Promise<T> {
|
|||||||
headers['Content-Type'] = 'application/json';
|
headers['Content-Type'] = 'application/json';
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(path, {
|
const res = await fetch(`${GATEWAY_URL}${path}`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
...rest,
|
...rest,
|
||||||
headers,
|
headers,
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
describe('auth client origin contract', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
vi.resetModules();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the same-origin BetterAuth mount at /api/auth', async () => {
|
|
||||||
const fetchMock = vi.fn<typeof fetch>();
|
|
||||||
fetchMock.mockResolvedValue(
|
|
||||||
new Response(JSON.stringify({ session: null, user: null }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
const { authClient } = await import('./auth-client');
|
|
||||||
await authClient.getSession();
|
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledOnce();
|
|
||||||
const firstCall = fetchMock.mock.calls.at(0);
|
|
||||||
expect(firstCall).toBeDefined();
|
|
||||||
const requestURL = new URL(String(firstCall?.[0]), window.location.origin);
|
|
||||||
expect(requestURL.origin).toBe(window.location.origin);
|
|
||||||
expect(requestURL.pathname).toBe('/api/auth/get-session');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { createAuthClient } from 'better-auth/react';
|
import { createAuthClient } from 'better-auth/react';
|
||||||
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins';
|
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins';
|
||||||
|
|
||||||
// The gateway and BetterAuth client both use /api/auth. Omitting baseURL keeps
|
|
||||||
// every browser request on the current origin in development and production.
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
|
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242',
|
||||||
plugins: [adminClient(), genericOAuthClient()],
|
plugins: [adminClient(), genericOAuthClient()],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { resolveAuthCallbackURL } from './auth-redirect';
|
|
||||||
|
|
||||||
const CURRENT_ORIGIN = 'https://mosaic.example';
|
|
||||||
|
|
||||||
describe('resolveAuthCallbackURL', () => {
|
|
||||||
it('preserves a canonical same-origin path with search and hash', () => {
|
|
||||||
expect(resolveAuthCallbackURL('/projects?view=active#current', CURRENT_ORIGIN)).toBe(
|
|
||||||
'/projects?view=active#current',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
null,
|
|
||||||
'chat',
|
|
||||||
'//evil.example',
|
|
||||||
'/..//evil.com',
|
|
||||||
'/..//evil.com/x',
|
|
||||||
'/./..//evil.com',
|
|
||||||
'/../..//evil.com',
|
|
||||||
'/foo/..//evil.com',
|
|
||||||
'/\\evil.example',
|
|
||||||
'/\n//evil.example',
|
|
||||||
'/\r//evil.example',
|
|
||||||
'/\t//evil.example',
|
|
||||||
'https://evil.example/phish',
|
|
||||||
])('falls back to chat for an unsafe callback target %#', (candidate) => {
|
|
||||||
expect(resolveAuthCallbackURL(candidate, CURRENT_ORIGIN)).toBe('/chat');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
const DEFAULT_AUTH_CALLBACK_URL = '/chat';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a canonical same-origin path for post-auth navigation.
|
|
||||||
*
|
|
||||||
* Parsing before comparing origins rejects protocol-relative URLs, backslash
|
|
||||||
* variants, and control characters that the WHATWG parser normalizes away.
|
|
||||||
*/
|
|
||||||
export function resolveAuthCallbackURL(candidate: string | null, currentOrigin: string): string {
|
|
||||||
if (!candidate?.startsWith('/')) return DEFAULT_AUTH_CALLBACK_URL;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const expectedOrigin = new URL(currentOrigin).origin;
|
|
||||||
const resolved = new URL(candidate, expectedOrigin);
|
|
||||||
if (resolved.origin !== expectedOrigin || resolved.pathname.startsWith('//')) {
|
|
||||||
return DEFAULT_AUTH_CALLBACK_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${resolved.pathname}${resolved.search}${resolved.hash}`;
|
|
||||||
} catch {
|
|
||||||
return DEFAULT_AUTH_CALLBACK_URL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
const { ioMock } = vi.hoisted(() => ({
|
|
||||||
ioMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('socket.io-client', () => ({
|
|
||||||
io: ioMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { destroySocket, getSocket } from './socket';
|
|
||||||
|
|
||||||
describe('chat socket', () => {
|
|
||||||
let disconnectHandler: (() => void) | undefined;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
disconnectHandler = undefined;
|
|
||||||
ioMock.mockReset();
|
|
||||||
|
|
||||||
const mockSocket = {
|
|
||||||
on: vi.fn((event: string, handler: () => void) => {
|
|
||||||
if (event === 'disconnect') disconnectHandler = handler;
|
|
||||||
return mockSocket;
|
|
||||||
}),
|
|
||||||
offAny: vi.fn(() => mockSocket),
|
|
||||||
disconnect: vi.fn(() => mockSocket),
|
|
||||||
};
|
|
||||||
|
|
||||||
ioMock.mockReturnValue(mockSocket);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
destroySocket();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates one same-origin /chat namespace socket until it disconnects', () => {
|
|
||||||
const first = getSocket();
|
|
||||||
const second = getSocket();
|
|
||||||
|
|
||||||
expect(first).toBe(second);
|
|
||||||
expect(ioMock).toHaveBeenCalledOnce();
|
|
||||||
expect(ioMock).toHaveBeenCalledWith('/chat', {
|
|
||||||
withCredentials: true,
|
|
||||||
autoConnect: false,
|
|
||||||
transports: ['websocket', 'polling'],
|
|
||||||
});
|
|
||||||
|
|
||||||
disconnectHandler?.();
|
|
||||||
getSocket();
|
|
||||||
expect(ioMock).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { io, type Socket } from 'socket.io-client';
|
import { io, type Socket } from 'socket.io-client';
|
||||||
|
|
||||||
|
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
|
||||||
|
|
||||||
let socket: Socket | null = null;
|
let socket: Socket | null = null;
|
||||||
|
|
||||||
export function getSocket(): Socket {
|
export function getSocket(): Socket {
|
||||||
if (!socket) {
|
if (!socket) {
|
||||||
socket = io('/chat', {
|
socket = io(`${GATEWAY_URL}/chat`, {
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
autoConnect: false,
|
autoConnect: false,
|
||||||
transports: ['websocket', 'polling'],
|
transports: ['websocket', 'polling'],
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getEnabledSsoProviders, getSsoProvider } from './sso-providers';
|
||||||
|
|
||||||
|
describe('sso-providers', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the enabled providers in login button order', () => {
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'true');
|
||||||
|
|
||||||
|
expect(getEnabledSsoProviders()).toEqual([
|
||||||
|
{
|
||||||
|
id: 'workos',
|
||||||
|
buttonLabel: 'Continue with WorkOS',
|
||||||
|
description: 'Enterprise SSO via WorkOS',
|
||||||
|
enabled: true,
|
||||||
|
href: '/auth/provider/workos',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'keycloak',
|
||||||
|
buttonLabel: 'Continue with Keycloak',
|
||||||
|
description: 'Enterprise SSO via Keycloak',
|
||||||
|
enabled: true,
|
||||||
|
href: '/auth/provider/keycloak',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks disabled providers without exposing them in the enabled list', () => {
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'false');
|
||||||
|
|
||||||
|
expect(getEnabledSsoProviders().map((provider) => provider.id)).toEqual(['workos']);
|
||||||
|
expect(getSsoProvider('keycloak')).toEqual({
|
||||||
|
id: 'keycloak',
|
||||||
|
buttonLabel: 'Continue with Keycloak',
|
||||||
|
description: 'Enterprise SSO via Keycloak',
|
||||||
|
enabled: false,
|
||||||
|
href: '/auth/provider/keycloak',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unknown providers', () => {
|
||||||
|
expect(getSsoProvider('authentik')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
export type SsoProviderId = 'workos' | 'keycloak';
|
||||||
|
|
||||||
|
export interface SsoProvider {
|
||||||
|
id: SsoProviderId;
|
||||||
|
buttonLabel: string;
|
||||||
|
description: string;
|
||||||
|
enabled: boolean;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDER_METADATA: Record<SsoProviderId, Omit<SsoProvider, 'enabled' | 'href'>> = {
|
||||||
|
workos: {
|
||||||
|
id: 'workos',
|
||||||
|
buttonLabel: 'Continue with WorkOS',
|
||||||
|
description: 'Enterprise SSO via WorkOS',
|
||||||
|
},
|
||||||
|
keycloak: {
|
||||||
|
id: 'keycloak',
|
||||||
|
buttonLabel: 'Continue with Keycloak',
|
||||||
|
description: 'Enterprise SSO via Keycloak',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getEnabledSsoProviders(): SsoProvider[] {
|
||||||
|
return (Object.keys(PROVIDER_METADATA) as SsoProviderId[])
|
||||||
|
.map((providerId) => getSsoProvider(providerId))
|
||||||
|
.filter((provider): provider is SsoProvider => provider?.enabled === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSsoProvider(providerId: string): SsoProvider | null {
|
||||||
|
if (!isSsoProviderId(providerId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...PROVIDER_METADATA[providerId],
|
||||||
|
enabled: isSsoProviderEnabled(providerId),
|
||||||
|
href: `/auth/provider/${providerId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSsoProviderId(value: string): value is SsoProviderId {
|
||||||
|
return value === 'workos' || value === 'keycloak';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSsoProviderEnabled(providerId: SsoProviderId): boolean {
|
||||||
|
switch (providerId) {
|
||||||
|
case 'workos':
|
||||||
|
return process.env['NEXT_PUBLIC_WORKOS_ENABLED'] === 'true';
|
||||||
|
case 'keycloak':
|
||||||
|
return process.env['NEXT_PUBLIC_KEYCLOAK_ENABLED'] === 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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>,
|
|
||||||
);
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
import { createBrowserRouter, Navigate, Outlet, type RouteObject } from 'react-router-dom';
|
|
||||||
import { LoginPage } from '@/spa/pages/login';
|
|
||||||
import { RegisterPage } from '@/spa/pages/register';
|
|
||||||
import { SsoCallbackPage } from '@/spa/pages/sso-callback';
|
|
||||||
import { AuthGuard, GuestGuard } from '@/spa/guards';
|
|
||||||
import { Placeholder } from '@/spa/placeholder';
|
|
||||||
|
|
||||||
function GuestLayout(): ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center bg-surface-bg px-4 py-8">
|
|
||||||
<div className="w-full max-w-md rounded-xl border border-surface-border bg-surface-card p-8 shadow-lg">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const routes: RouteObject[] = [
|
|
||||||
{
|
|
||||||
element: <GuestGuard />,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
element: <GuestLayout />,
|
|
||||||
children: [
|
|
||||||
{ path: '/login', element: <LoginPage /> },
|
|
||||||
{ path: '/register', element: <RegisterPage /> },
|
|
||||||
{ path: '/auth/provider/:provider', element: <SsoCallbackPage /> },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import { act } from 'react';
|
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
|
||||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
const { useSessionMock } = vi.hoisted(() => ({
|
|
||||||
useSessionMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth-client', () => ({
|
|
||||||
useSession: useSessionMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { AuthGuard, GuestGuard } from './guards';
|
|
||||||
|
|
||||||
interface RenderedRouter {
|
|
||||||
container: HTMLDivElement;
|
|
||||||
router: ReturnType<typeof createMemoryRouter>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mountedRoots: Root[] = [];
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
|
||||||
configurable: true,
|
|
||||||
value: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
async function renderRouter(
|
|
||||||
routeObjects: RouteObject[],
|
|
||||||
initialEntry: string,
|
|
||||||
): Promise<RenderedRouter> {
|
|
||||||
const container = document.createElement('div');
|
|
||||||
document.body.append(container);
|
|
||||||
const router = createMemoryRouter(routeObjects, { initialEntries: [initialEntry] });
|
|
||||||
const root = createRoot(container);
|
|
||||||
mountedRoots.push(root);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root.render(<RouterProvider router={router} />);
|
|
||||||
});
|
|
||||||
|
|
||||||
return { container, router };
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
for (const root of mountedRoots.splice(0)) {
|
|
||||||
await act(async () => {
|
|
||||||
root.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
document.body.replaceChildren();
|
|
||||||
useSessionMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
const guestRoutes: RouteObject[] = [
|
|
||||||
{
|
|
||||||
path: '/login',
|
|
||||||
element: <GuestGuard />,
|
|
||||||
children: [{ index: true, element: <p>Guest page</p> }],
|
|
||||||
},
|
|
||||||
{ path: '/chat', element: <p>Chat page</p> },
|
|
||||||
];
|
|
||||||
|
|
||||||
const authenticatedRoutes: RouteObject[] = [
|
|
||||||
{
|
|
||||||
path: '/chat',
|
|
||||||
element: <AuthGuard />,
|
|
||||||
children: [{ index: true, element: <p>Private page</p> }],
|
|
||||||
},
|
|
||||||
{ path: '/login', element: <p>Login page</p> },
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('GuestGuard', () => {
|
|
||||||
it('renders the guest outlet while session lookup is pending', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: null, isPending: true });
|
|
||||||
|
|
||||||
const view = await renderRouter(guestRoutes, '/login');
|
|
||||||
|
|
||||||
expect(view.container.textContent).toContain('Guest page');
|
|
||||||
expect(view.router.state.location.pathname).toBe('/login');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the guest outlet when no session exists', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
|
||||||
|
|
||||||
const view = await renderRouter(guestRoutes, '/login');
|
|
||||||
|
|
||||||
expect(view.container.textContent).toContain('Guest page');
|
|
||||||
expect(view.router.state.location.pathname).toBe('/login');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('redirects an authenticated session to chat', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
|
||||||
|
|
||||||
const view = await renderRouter(guestRoutes, '/login');
|
|
||||||
|
|
||||||
expect(view.container.textContent).toContain('Chat page');
|
|
||||||
expect(view.router.state.location.pathname).toBe('/chat');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('AuthGuard', () => {
|
|
||||||
it('renders the existing loading treatment while session lookup is pending', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: null, isPending: true });
|
|
||||||
|
|
||||||
const view = await renderRouter(authenticatedRoutes, '/chat');
|
|
||||||
|
|
||||||
expect(view.container.textContent).toContain('Loading...');
|
|
||||||
expect(view.router.state.location.pathname).toBe('/chat');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('redirects an unauthenticated visitor to login', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
|
||||||
|
|
||||||
const view = await renderRouter(authenticatedRoutes, '/chat');
|
|
||||||
|
|
||||||
expect(view.container.textContent).toContain('Login page');
|
|
||||||
expect(view.router.state.location.pathname).toBe('/login');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the authenticated outlet when a session exists', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
|
||||||
|
|
||||||
const view = await renderRouter(authenticatedRoutes, '/chat');
|
|
||||||
|
|
||||||
expect(view.container.textContent).toContain('Private page');
|
|
||||||
expect(view.router.state.location.pathname).toBe('/chat');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
import { Navigate, Outlet } from 'react-router-dom';
|
|
||||||
import { useSession } from '@/lib/auth-client';
|
|
||||||
|
|
||||||
export function GuestGuard(): ReactElement {
|
|
||||||
const { data: session } = useSession();
|
|
||||||
|
|
||||||
return session ? <Navigate to="/chat" replace /> : <Outlet />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthGuard(): ReactElement {
|
|
||||||
const { data: session, isPending } = useSession();
|
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
|
||||||
<div className="text-sm text-text-muted">Loading...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return session ? <Outlet /> : <Navigate to="/login" replace />;
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import { useEffect, useState, type FormEvent, type ReactElement } from 'react';
|
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
|
||||||
import { SsoProviderButtons } from '@/components/auth/sso-provider-buttons';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { authClient, signIn } from '@/lib/auth-client';
|
|
||||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
|
||||||
|
|
||||||
export function LoginPage(): ReactElement {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
|
|
||||||
const [ssoLoadingProviderId, setSsoLoadingProviderId] = useState<
|
|
||||||
SsoProviderDiscovery['id'] | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true;
|
|
||||||
|
|
||||||
void api<SsoProviderDiscovery[]>('/api/sso/providers').then(
|
|
||||||
(providers) => {
|
|
||||||
if (active) setSsoProviders(providers.filter((provider) => provider.configured));
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
if (active) setSsoProviders([]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
|
||||||
event.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const form = new FormData(event.currentTarget);
|
|
||||||
const email = String(form.get('email') ?? '');
|
|
||||||
const password = String(form.get('password') ?? '');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await signIn.email({ email, password });
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
setError(result.error.message ?? 'Sign in failed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate('/chat', { replace: true });
|
|
||||||
} catch (caught: unknown) {
|
|
||||||
setError(caught instanceof Error ? caught.message : 'Sign in failed');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSsoSignIn(providerId: SsoProviderDiscovery['id']): Promise<void> {
|
|
||||||
setError(null);
|
|
||||||
setSsoLoadingProviderId(providerId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await authClient.signIn.oauth2({
|
|
||||||
providerId,
|
|
||||||
callbackURL: '/chat',
|
|
||||||
newUserCallbackURL: '/chat',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
setError(result.error.message ?? `Sign in with ${providerId} failed`);
|
|
||||||
setSsoLoadingProviderId(null);
|
|
||||||
}
|
|
||||||
} catch (caught: unknown) {
|
|
||||||
setError(caught instanceof Error ? caught.message : `Sign in with ${providerId} failed`);
|
|
||||||
setSsoLoadingProviderId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold">Sign in</h1>
|
|
||||||
<p className="mt-1 text-sm text-text-secondary">Sign in to your Mosaic account</p>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
autoComplete="email"
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
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-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
placeholder="[email protected]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
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-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? 'Signing in...' : 'Sign in'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<SsoProviderButtons
|
|
||||||
providers={ssoProviders}
|
|
||||||
loadingProviderId={ssoLoadingProviderId}
|
|
||||||
onOidcSignIn={(providerId) => {
|
|
||||||
void handleSsoSignIn(providerId);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="mt-4 text-center text-sm text-text-muted">
|
|
||||||
Don't have an account?{' '}
|
|
||||||
<Link to="/register" className="text-blue-400 hover:text-blue-300">
|
|
||||||
Sign up
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import { useState, type FormEvent, type ReactElement } from 'react';
|
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
|
||||||
import { signUp } from '@/lib/auth-client';
|
|
||||||
|
|
||||||
export function RegisterPage(): ReactElement {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
|
||||||
event.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const form = new FormData(event.currentTarget);
|
|
||||||
const name = String(form.get('name') ?? '');
|
|
||||||
const email = String(form.get('email') ?? '');
|
|
||||||
const password = String(form.get('password') ?? '');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await signUp.email({ name, email, password });
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
setError(result.error.message ?? 'Registration failed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate('/chat', { replace: true });
|
|
||||||
} catch (caught: unknown) {
|
|
||||||
setError(caught instanceof Error ? caught.message : 'Registration failed');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold">Create account</h1>
|
|
||||||
<p className="mt-1 text-sm text-text-secondary">Get started with Mosaic</p>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-text-secondary">
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
type="text"
|
|
||||||
autoComplete="name"
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
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-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
placeholder="Your name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
autoComplete="email"
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
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-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
placeholder="[email protected]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
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-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? 'Creating account...' : 'Create account'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p className="mt-4 text-center text-sm text-text-muted">
|
|
||||||
Already have an account?{' '}
|
|
||||||
<Link to="/login" className="text-blue-400 hover:text-blue-300">
|
|
||||||
Sign in
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { act } from 'react';
|
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
|
||||||
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
|
||||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
const { apiMock, oauth2Mock } = vi.hoisted(() => ({
|
|
||||||
apiMock: vi.fn(),
|
|
||||||
oauth2Mock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/api', () => ({
|
|
||||||
api: apiMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth-client', () => ({
|
|
||||||
signIn: { oauth2: oauth2Mock },
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { SsoCallbackPage } from './sso-callback';
|
|
||||||
|
|
||||||
const mountedRoots: Root[] = [];
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
|
||||||
configurable: true,
|
|
||||||
value: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
apiMock.mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 'authentik',
|
|
||||||
name: 'Authentik',
|
|
||||||
protocols: ['oidc'],
|
|
||||||
configured: true,
|
|
||||||
loginMode: 'oidc',
|
|
||||||
callbackPath: '/api/auth/oauth2/callback/authentik',
|
|
||||||
teamSync: { enabled: false, claim: null },
|
|
||||||
samlFallback: { configured: false, loginUrl: null },
|
|
||||||
warnings: [],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
oauth2Mock.mockResolvedValue({ data: null, error: null });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
for (const root of mountedRoots.splice(0)) {
|
|
||||||
await act(async () => {
|
|
||||||
root.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
document.body.replaceChildren();
|
|
||||||
apiMock.mockReset();
|
|
||||||
oauth2Mock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('SsoCallbackPage', () => {
|
|
||||||
it('rejects a control-character callback that normalizes to an external origin', async () => {
|
|
||||||
const router = createMemoryRouter(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
path: '/auth/provider/:provider',
|
|
||||||
element: <SsoCallbackPage />,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
{
|
|
||||||
initialEntries: ['/auth/provider/authentik?callbackURL=%2F%0A%2F%2Fevil.example'],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const container = document.createElement('div');
|
|
||||||
document.body.append(container);
|
|
||||||
const root = createRoot(container);
|
|
||||||
mountedRoots.push(root);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root.render(<RouterProvider router={router} />);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(oauth2Mock).toHaveBeenCalledWith({
|
|
||||||
providerId: 'authentik',
|
|
||||||
callbackURL: '/chat',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { useEffect, useState, type ReactElement } from 'react';
|
|
||||||
import { Link, useParams, useSearchParams } from 'react-router-dom';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
|
|
||||||
import { signIn } from '@/lib/auth-client';
|
|
||||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
|
||||||
|
|
||||||
export function SsoCallbackPage(): ReactElement {
|
|
||||||
const { provider: providerId = '' } = useParams<'provider'>();
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const requestedCallbackURL = searchParams.get('callbackURL');
|
|
||||||
const [providerName, setProviderName] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
async function redirectToProvider(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
|
|
||||||
const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
const provider = providers.find((candidate) => candidate.id === providerId);
|
|
||||||
if (!provider) {
|
|
||||||
setError('Unknown SSO provider.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setProviderName(provider.name);
|
|
||||||
if (!provider.configured) {
|
|
||||||
setError(`${provider.name} is not enabled in this deployment.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (provider.loginMode !== 'oidc') {
|
|
||||||
setError(`${provider.name} is not available for OIDC sign in.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await signIn.oauth2({
|
|
||||||
providerId: provider.id,
|
|
||||||
callbackURL,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!cancelled && result?.error) {
|
|
||||||
setError(result.error.message ?? `${provider.name} sign in failed.`);
|
|
||||||
}
|
|
||||||
} catch (caught: unknown) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setError(caught instanceof Error ? caught.message : 'Unable to start single sign-on.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void redirectToProvider();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [providerId, requestedCallbackURL]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
|
||||||
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
|
||||||
<p className="mt-2 text-sm text-text-secondary">
|
|
||||||
{providerName
|
|
||||||
? `Redirecting you to ${providerName}...`
|
|
||||||
: 'Preparing your sign-in request...'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
|
||||||
>
|
|
||||||
<p>{error}</p>
|
|
||||||
<Link
|
|
||||||
to="/login"
|
|
||||||
className="mt-3 inline-block font-medium text-blue-400 hover:text-blue-300"
|
|
||||||
>
|
|
||||||
Return to login
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-6 rounded-lg border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-secondary">
|
|
||||||
If the redirect does not start automatically, return to the login page and try again.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { isValidElement } from 'react';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import type { RouteObject } from 'react-router-dom';
|
|
||||||
import { routes } from '@/routes';
|
|
||||||
import { Placeholder } from '@/spa/placeholder';
|
|
||||||
|
|
||||||
function collectPaths(routeObjects: RouteObject[]): string[] {
|
|
||||||
return routeObjects.flatMap((route) => [
|
|
||||||
...(route.path ? [route.path] : []),
|
|
||||||
...(route.children ? collectPaths(route.children) : []),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function findRoute(routeObjects: RouteObject[], path: string): RouteObject | undefined {
|
|
||||||
for (const route of routeObjects) {
|
|
||||||
if (route.path === path) return route;
|
|
||||||
const nested = route.children ? findRoute(route.children, path) : undefined;
|
|
||||||
if (nested) return nested;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('SPA route table', () => {
|
|
||||||
it('covers every v1 parity route from the Phase P RFC', () => {
|
|
||||||
expect(collectPaths(routes).sort()).toEqual(
|
|
||||||
[
|
|
||||||
'/',
|
|
||||||
'/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');
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['/login', '/register', '/auth/provider/:provider'])(
|
|
||||||
'renders a real guest page instead of the P1 placeholder at %s',
|
|
||||||
(path) => {
|
|
||||||
const element = findRoute(routes, path)?.element;
|
|
||||||
expect(isValidElement(element)).toBe(true);
|
|
||||||
if (!isValidElement(element)) throw new Error(`Missing route element for ${path}`);
|
|
||||||
expect(element.type).not.toBe(Placeholder);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
describe('Vitest abort-controller realm', () => {
|
|
||||||
it('provides a global signal accepted by Node native Request', () => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const request = new Request('https://mosaic.invalid/navigation', {
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(request.signal).toBeInstanceOf(AbortSignal);
|
|
||||||
controller.abort();
|
|
||||||
expect(request.signal.aborted).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { transferableAbortController } from 'node:util';
|
|
||||||
|
|
||||||
// jsdom installs realm-local abort constructors while Node's undici Request
|
|
||||||
// remains native. React Router passes a global AbortSignal to Request, so both
|
|
||||||
// constructors must come from Node's native realm during tests.
|
|
||||||
const nativeController = transferableAbortController();
|
|
||||||
const nativeAbortController = nativeController.constructor;
|
|
||||||
const nativeAbortSignal = nativeController.signal.constructor;
|
|
||||||
|
|
||||||
for (const target of [globalThis, window]) {
|
|
||||||
Object.defineProperties(target, {
|
|
||||||
AbortController: {
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
value: nativeAbortController,
|
|
||||||
},
|
|
||||||
AbortSignal: {
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
value: nativeAbortSignal,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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,21 +1,9 @@
|
|||||||
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',
|
||||||
setupFiles: ['./src/test/setup.ts'],
|
|
||||||
isolate: true,
|
|
||||||
exclude: ['e2e/**', 'node_modules/**'],
|
exclude: ['e2e/**', 'node_modules/**'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,114 +0,0 @@
|
|||||||
# WebUI Fleet Bridge Planning Scratchpad
|
|
||||||
|
|
||||||
**Mode:** Task 0 docs-only decision PR authorized; Task 1 and all executable/runtime work remain blocked.
|
|
||||||
**Owner:** Velma
|
|
||||||
**Opened:** 2026-08-09
|
|
||||||
**Scope:** One enrolled Agent Host launching one native `mosaic claude` OAuth session and streaming it into `apps/web` through `apps/gateway`.
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Turn the approved direction in `jarvis-brain/docs/scratchpads/MOSAIC-WEBUI-FLEET-BRIDGE.md` into a test-first implementation plan while preserving Fred's harness-home/launcher contract and Scooby's greenfield safety findings.
|
|
||||||
|
|
||||||
## Source reconciliation
|
|
||||||
|
|
||||||
- Current `origin/main`: `b0f7d26dd9c14d91eaaefc35d6c9fd6618a0bd92`.
|
|
||||||
- Current `origin/next`: `4df478cdd150fdf8d52ea109f02ade5d85017acd`.
|
|
||||||
- Branches currently diverge (`main` has 11 unique commits; `next` has 13). `next` contains local-tier Redis fix #689; `main` contains later fleet/shell fixes.
|
|
||||||
- Fred's three-root harness-home design and promotion stack are not yet fully present on either baseline.
|
|
||||||
- Therefore neither current SHA is an acceptable implementation pin. Code may begin only from a Fred-certified reconciled SHA containing the required launcher/home contract and safe Gateway startup prerequisites.
|
|
||||||
- The deployed `mosaic.woltje.com` v0.0.20 UI remains reference-only.
|
|
||||||
|
|
||||||
## Verified seams
|
|
||||||
|
|
||||||
- Current browser chat uses an in-process Pi SDK session.
|
|
||||||
- `AgentRuntimeProvider` supports list/tree/stream/send/attach/detach/terminate, but not create/start.
|
|
||||||
- `InteractionController` enrolls an already-existing runtime session; it cannot launch one.
|
|
||||||
- Hermes is the only runtime provider registered in Gateway.
|
|
||||||
- Tmux streaming is explicitly unsupported and remains out of scope.
|
|
||||||
- `mosaic claude` is the authoritative launcher and accepts Claude's machine-facing stream-json flags.
|
|
||||||
- Installed discovery version: Claude Code 2.1.226. Target Distrobox version must be independently pinned and certified.
|
|
||||||
|
|
||||||
## Non-negotiable dependencies
|
|
||||||
|
|
||||||
1. Fred approves the machine-facing launcher/seat-home contract before code.
|
|
||||||
2. No WebUI/Gateway direct read of lease broker state, daemon socket, or state files (F-V3).
|
|
||||||
3. No provider OAuth token leaves the Agent Host.
|
|
||||||
4. No local Gateway/Web startup around the KBN/database hold.
|
|
||||||
5. Greenfield work runs in a Debian Distrobox with an isolated home.
|
|
||||||
6. The initial plan PR targets `next`; Fred binds D1–D15 on its exact head before issue/PRD/tracker completion or implementation work.
|
|
||||||
|
|
||||||
## Reproducible evidence
|
|
||||||
|
|
||||||
Run from a clean Stack clone:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git fetch origin main next
|
|
||||||
git rev-parse origin/main origin/next
|
|
||||||
git rev-list --left-right --count origin/main...origin/next
|
|
||||||
rg -n "interface AgentRuntimeProvider|createSession|streamEvents|terminate" \
|
|
||||||
packages/types/src/agent packages/agent/src apps/gateway/src/agent
|
|
||||||
rg -n "AgentService\.prompt|interaction_sessions|createRuntimeTerminationApproval" \
|
|
||||||
apps/gateway/src packages/db/src/schema.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Primary inspected source seams:
|
|
||||||
|
|
||||||
- `packages/types/src/agent/agent-runtime-provider.ts`
|
|
||||||
- `packages/agent/src/{runtime-provider-registry,hermes-runtime-provider,matrix-native-runtime-provider,tmux-fleet-runtime-provider}.ts`
|
|
||||||
- `apps/gateway/src/agent/{runtime-provider-registry.service,interaction.controller,durable-session.repository,durable-session.service}.ts`
|
|
||||||
- `apps/gateway/src/chat/chat.gateway.ts`
|
|
||||||
- `packages/mosaic/src/commands/{launch,interaction}.ts`
|
|
||||||
- `packages/mosaic/src/fleet/generated-env-boundary.ts`
|
|
||||||
- `packages/db/src/schema.ts`
|
|
||||||
- `apps/web/src/app/(dashboard)/chat/page.tsx`
|
|
||||||
|
|
||||||
Planning-only investigation transcripts are local and intentionally uncommitted:
|
|
||||||
|
|
||||||
- `/tmp/velma-plan-stack-surface.txt`
|
|
||||||
- `/tmp/velma-plan-structure.txt`
|
|
||||||
- `/tmp/velma-plan-scooby.txt`
|
|
||||||
- `/tmp/velma-plan-runtime-contract.txt`
|
|
||||||
|
|
||||||
## Source findings that constrain the design
|
|
||||||
|
|
||||||
- `interaction_sessions.id` is the stable primary key; there are no create/policy/enrollment/state columns.
|
|
||||||
- `interaction_outbox` has a unique `(session_id, idempotency_key)` index and only `pending | processing | delivered`.
|
|
||||||
- Baseline `DurableSessionRepository.create()` can replace provider/runtime identity for the same owner; M1 must remove that implicit mutation.
|
|
||||||
- Baseline termination approval is Redis-backed and currently consumes separately from PostgreSQL; M1 therefore needs durable authorization acceptance before destructive token deletion/dispatch.
|
|
||||||
- Existing interaction HTTP base is `/api/interaction/:agentName`; the plan extends it rather than inventing a second route family.
|
|
||||||
- Baseline `launch.ts`/lease launcher still use ambient lookup/literal interpreters. Section 4.1 is non-binding consumer input to Fred's W-F design; W-F's final resolved-launch contract must exist in the certified base before Velma can certify it.
|
|
||||||
- Root `pnpm test` is not KBN-safe: it includes PGlite migration and framework-shell/lease-broker suites.
|
|
||||||
|
|
||||||
## Independent draft review
|
|
||||||
|
|
||||||
Seven adversarial review rounds found and drove explicit fixes for:
|
|
||||||
|
|
||||||
- a candidate resolved-seat consumer descriptor and threat model, now explicitly non-binding input to Fred's W-F-owned launcher design;
|
|
||||||
- one active launch per stable conversation, exact pending/failed encodings, durable pre-dispatch reservation, CAS activation, and crash lookup without a migration;
|
|
||||||
- generation-bound enrollment, command/event revalidation, `SIGHUP` config epochs, and stale-epoch rejection;
|
|
||||||
- Redis/PostgreSQL exact-stop crash safety via non-destructive verify, durable authorization acceptance, atomic claim/`GETDEL`, and same-operation status reconciliation;
|
|
||||||
- sequence gaps/reorder limits, deterministic UUIDv5 completion, and Gateway restart fail-closed behavior;
|
|
||||||
- shared streaming redaction before host ring/transport and again before Gateway persistence/browser;
|
|
||||||
- D2/D12-selected path-free provenance and a separate safe browser DTO—never raw or hashed path strings;
|
|
||||||
- exact F-V3 boundary: bridge has no broker API, while Fred's sealed launcher may enforce broker policy internally;
|
|
||||||
- migration-free focused tests in Tasks 1–8, with live OAuth, repository transaction, Gateway/Web, and Playwright restricted to the Fred/Scooby-certified Task 9 path;
|
|
||||||
- candidate private/public commitment and artifact-binding mechanisms that W-F may accept, simplify, replace, or defer;
|
|
||||||
- JCS event-digest recomputation and equal/different duplicate handling in both accepted and future-buffer states;
|
|
||||||
- a safe browser presentation DTO for host/workspace/seat/persona labels, readiness, and connection state;
|
|
||||||
- explicit `OnApplicationBootstrap` create/stop recovery enumeration with no auto-launch;
|
|
||||||
- all POSIX/Windows/UNC/file/tilde path classes in the streaming redactor and definitive failed-stop response semantics;
|
|
||||||
- the full 14-column canonical task schema, fake-only Task 5 repository tests, migration-free certified Task 9 DB test, server-owned operation correlations/routes, and exact merged-SHA smoke;
|
|
||||||
- per-commit independent review, queue guards, exact-head PR review, squash merge, exact merged-next SHA/CI wait, worktree-bound smoke, issue-state readback, and reviewed tracker-closure PR with its own merged-next CI;
|
|
||||||
- a private DB URL loader captured/exported per session without recording or echoing the credential;
|
|
||||||
- an attached Task 9 implementation branch, provider-filtered exact issue-state readback, and machine-verified smoke JSON binding source/worktree/deployed SHA;
|
|
||||||
- a capped, duplicate-key-rejecting, exact-key/type smoke schema so report extensions or JSON boolean/float coercion cannot smuggle data or fake child-count evidence.
|
|
||||||
|
|
||||||
The plan remains deliberately **decision-PR-ready, not implementation-ready**. Fred authorized only Task 0's initial two-document PR on `next`. He must still return every Section 2 value, replace all `[FRED-GATE]` entries, certify storage/startup, and provide the W-F-dependent `IMPLEMENTATION_BASE_SHA` before Task 0 closes or Task 1 starts.
|
|
||||||
|
|
||||||
## Current status
|
|
||||||
|
|
||||||
- Gitea principal verified as `velma`; helper and API wrapper resolution are fail-closed and correct.
|
|
||||||
- Fred authorized the initial Task 0 docs-only PR in `comms/20260809T094952Z__from-fred__ec0e85.md` and confirmed its `next` lane/W-F descriptor corrections in `comms/20260809T095437Z__from-fred__4ee79d.md`.
|
|
||||||
- Provisional decisions: D1/D8/D9/D10/D13/D14 approved; D4 tool labels exactly `{Read, Grep, Glob}`; D5/D12 provisional; D11 remains a single-operator seam; D2/D3/D6/D7 and `IMPLEMENTATION_BASE_SHA` are `BLOCKED-ON-W-F`.
|
|
||||||
- Awaiting exact plan-PR-head review and Fred's complete D1–D15 binding contract.
|
|
||||||
- No source code, database, deployment, or live runtime changes made.
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
# WebUI Phase P — P2 Data + Auth Scratchpad
|
|
||||||
|
|
||||||
**Task ID:** WEBUI-P2
|
|
||||||
**Tracking ref:** stacked on PR #1143 (`feat/webui-p1-vite-skeleton`); no separate issue specified in the author brief
|
|
||||||
**Branch:** `feat/webui-p2-data-auth`
|
|
||||||
**Started:** 2026-08-09
|
|
||||||
**Role:** P2 author worker (must not modify `docs/TASKS.md`)
|
|
||||||
|
|
||||||
## Original tasking
|
|
||||||
|
|
||||||
> Read `/home/jwoltje/briefs/P2-brief.md` and execute it fully in `/home/jwoltje/src/stack-p1` on branch `feat/webui-p2-data-auth`. Run every listed verification gate, commit locally only, and do not push.
|
|
||||||
|
|
||||||
Container path mapping:
|
|
||||||
|
|
||||||
- Brief: `/home/jwoltje/distrobox-homes/mosaic-dev/briefs/P2-brief.md`
|
|
||||||
- Repo: `/home/jwoltje/distrobox-homes/mosaic-dev/src/stack-p1`
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Make the P1 Vite SPA authenticate against the Mosaic Gateway by same-origin relative paths, replace guest-route placeholders with real login/register/SSO callback pages, and enforce guest/authenticated route guards without modifying the parallel Next app tree except for unavoidable shared-library import fixes.
|
|
||||||
|
|
||||||
## Scope and invariants
|
|
||||||
|
|
||||||
- All SPA HTTP and Socket.IO access remains origin-relative (`/api/...`, `/api/auth/...`, `/chat`).
|
|
||||||
- No `NEXT_PUBLIC_*`, `VITE_*` origin setting, or hard-coded `http://localhost:14242` under `apps/web/src/`.
|
|
||||||
- Verify the Gateway BetterAuth mount path from source before choosing auth-client configuration.
|
|
||||||
- Delete the legacy static SSO-provider discovery module and test; runtime `/api/sso/providers` is canonical.
|
|
||||||
- Preserve the Next build while adding React Router guest pages and session guards.
|
|
||||||
- Never push; stage named files only; do not touch the modified `.mosaic/orchestrator/session.lock`.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Inspect P1 SPA structure, shared libraries, legacy Next auth pages/components, Gateway auth mount, and current test setup.
|
|
||||||
2. Add/adjust tests first for relative API behavior, auth-client origin configuration, relative Socket.IO singleton behavior, and all guard session states; run focused tests and capture expected RED failures.
|
|
||||||
3. Implement shared-library relative networking and remove the legacy SSO-provider module/imports.
|
|
||||||
4. Port login, register, and provider callback pages to React Router and wire routes.
|
|
||||||
5. Implement session-aware guest/auth guards and satisfy focused tests.
|
|
||||||
6. Run focused tests, all brief verification gates, invariant searches, and an independent code/security review; remediate and re-run affected gates.
|
|
||||||
7. Update this scratchpad with evidence, stage named files only, and commit locally.
|
|
||||||
|
|
||||||
## Testing strategy
|
|
||||||
|
|
||||||
- TDD is required because this increment changes authentication/session behavior.
|
|
||||||
- Primary situational evidence: jsdom router guard state tests plus guest-page/auth-flow contract tests already present or added as needed.
|
|
||||||
- Baseline gates: web tests, Vite build, Next build, typecheck, lint, and root format check exactly as listed in the brief.
|
|
||||||
- Browser automation is not required by the brief for P2; if used, it will be headless only.
|
|
||||||
|
|
||||||
## Budget
|
|
||||||
|
|
||||||
No explicit user token cap was provided. Working soft cap: **30K tokens**, derived from a multi-file auth/frontend increment with tests, dual builds, review, and remediation. Keep implementation within the brief; no unrelated refactors or dependencies.
|
|
||||||
|
|
||||||
## Progress / evidence
|
|
||||||
|
|
||||||
- [x] Loaded mission protocol, active MVP manifest/scratchpad, top-level tasks, PRD, relevant frontend/auth/testing/type/review guides, and matching skills.
|
|
||||||
- [x] Resolved host paths to the Distrobox-mapped repo and brief.
|
|
||||||
- [x] Confirmed branch `feat/webui-p2-data-auth`, stacked at P1 commit `068d0f9b`.
|
|
||||||
- [x] Source and Gateway mount inspection complete.
|
|
||||||
- [x] RED tests observed.
|
|
||||||
- [x] Implementation complete.
|
|
||||||
- [x] Independent review complete and findings remediated.
|
|
||||||
- [x] Verification gates complete.
|
|
||||||
- [x] Local implementation commit created: `46d68e1f`.
|
|
||||||
|
|
||||||
### Source decisions
|
|
||||||
|
|
||||||
- Gateway source mounts BetterAuth at `/api/auth/` in `apps/gateway/src/auth/auth.controller.ts`; `packages/auth/src/auth.ts` configures `basePath: '/api/auth'`.
|
|
||||||
- BetterAuth 1.5.5 defaults its browser client to `/api/auth` when `baseURL` is omitted. `src/lib/auth-client.ts` therefore omits `baseURL`, preserving same-origin behavior without encoding any gateway origin.
|
|
||||||
- The only `src/app/` edit is the transitional Next provider redirect page. Deleting `src/lib/sso-providers.ts` required that mechanical consumer migration; it now uses the same runtime `/api/sso/providers` discovery and callback sanitizer as the SPA.
|
|
||||||
- GuestGuard pending behavior intentionally follows the approved brief: if no session object exists (including pending), render `<Outlet />`; only a present session redirects to `/chat`. AuthGuard alone renders the specified pending treatment.
|
|
||||||
|
|
||||||
### TDD evidence
|
|
||||||
|
|
||||||
- Initial focused run: 9 expected failures, proving absolute API/auth/socket origins, P1 guest placeholders, and missing guard redirects/loading behavior.
|
|
||||||
- After implementation: focused contract suite 15/15 passed.
|
|
||||||
- Security remediation RED: `%2F%0A%2F%2Fevil.example` reached `signIn.oauth2` as an external-normalizing callback target before the fix.
|
|
||||||
- Security remediation GREEN: shared `resolveAuthCallbackURL` unit suite 9/9 plus SPA callback regression 1/1 passed; both SPA and Next consumers use the shared helper.
|
|
||||||
|
|
||||||
### Independent review
|
|
||||||
|
|
||||||
- Primary Codex wrappers could not run because `jq` is absent; direct Codex fallback then failed authentication with HTTP 401. No review result was claimed from those attempts.
|
|
||||||
- Independent Claude Sonnet code review found no implementation-scope blocker, one pending-state UX suggestion, and one pre-existing `api.ts` type-assertion suggestion. The pending-state suggestion was rejected because it contradicts the brief's explicit GuestGuard contract; the API cleanup is outside P2's preserve-contract scope.
|
|
||||||
- Independent Claude Sonnet security review found a high-severity control-character open redirect in the callback prefix check shared by the newly ported SPA logic and transitional Next consumer.
|
|
||||||
- Remediation centralized WHATWG URL parsing plus exact current-origin comparison in `src/lib/auth-redirect.ts`, returns only path/search/hash, and added the RED-first regression above.
|
|
||||||
- Fresh code re-review: `approve`, 0 blockers, 0 should-fix findings.
|
|
||||||
- Fresh security re-review: `low`, 0 critical/high/medium/low findings.
|
|
||||||
|
|
||||||
### Documentation checklist disposition
|
|
||||||
|
|
||||||
- `docs/PRD.md` exists and P2 aligns to FR-8 / AC-7 authentication requirements.
|
|
||||||
- No Gateway endpoint, DTO, permission, or API schema changed; existing `/api/auth/*` and `/api/sso/providers` contracts are consumed unchanged, so OpenAPI/API-index updates are not applicable.
|
|
||||||
- Route paths were already present in the P1 route table; P2 replaces placeholders without changing site-map navigation, so `docs/SITEMAP.md` is unchanged.
|
|
||||||
- User/admin auth behavior is parity with the still-live Next implementation, not a new workflow. Implementation decisions, failure behavior, testing, and migration compatibility are documented here; no publishing action is in scope.
|
|
||||||
- Documentation remains in-repo; no generated publishing output was created.
|
|
||||||
|
|
||||||
### Final verification evidence
|
|
||||||
|
|
||||||
Run fresh after remediation from the locations required by the brief:
|
|
||||||
|
|
||||||
- `apps/web: pnpm test` — PASS: `Test Files 9 passed (9)`; `Tests 27 passed (27)`.
|
|
||||||
- `apps/web: pnpm build:vite` — PASS: `✓ 113 modules transformed`; `✓ built in 565ms`.
|
|
||||||
- `apps/web: pnpm build` — PASS: `✓ Compiled successfully in 4.6s`; 10/10 static pages generated; dynamic provider/project routes retained.
|
|
||||||
- `apps/web: pnpm typecheck` — PASS: `tsc --noEmit` exited 0.
|
|
||||||
- `apps/web: pnpm lint` — PASS: `eslint src` exited 0.
|
|
||||||
- Root `pnpm format:check` — PASS: `All matched files use Prettier code style!`.
|
|
||||||
- Invariant scan — PASS: no `NEXT_PUBLIC_*`, `VITE_*`, `GATEWAY_URL`, `http://localhost:14242`, or `sso-providers` references under `apps/web/src`.
|
|
||||||
- `git diff --check` — PASS.
|
|
||||||
- All six required gates above were repeated successfully after implementation commit `46d68e1f`, proving the exact committed source tree.
|
|
||||||
|
|
||||||
## Risks / blockers
|
|
||||||
|
|
||||||
- `.mosaic/orchestrator/session.lock` is modified by the active harness and must remain unstaged.
|
|
||||||
- P2 changes shared `src/lib/` modules consumed by both Vite and Next, so the Next build is a required compatibility gate.
|
|
||||||
|
|
||||||
## Remediation 1 — independent Node 26 verification failure
|
|
||||||
|
|
||||||
**Correction received:** 2026-08-09
|
|
||||||
|
|
||||||
The orchestrator rejected the P2 verification claim after an independent run under Node 26.4.0 produced 2 failed redirect tests and 2 unhandled errors. React Router passed jsdom's realm-local `AbortSignal` to Node 26's native undici `Request`, which rejects non-native signals. Production guard behavior is correct and must not change.
|
|
||||||
|
|
||||||
### Remediation constraints
|
|
||||||
|
|
||||||
- Preserve both redirect assertions and all `guards.tsx` behavior; no skips, weakening, or test deletion.
|
|
||||||
- Add the smallest Vitest environment repair so global `AbortController` / `AbortSignal` are constructors accepted by native undici `Request`.
|
|
||||||
- Run all six required gates, commit named files locally without pushing, leave `.mosaic/orchestrator/session.lock` untouched, then run `apps/web: pnpm test` as the final worktree action.
|
|
||||||
- Completion requires zero failed tests, zero test errors, and zero unhandled rejections.
|
|
||||||
|
|
||||||
### Remediation plan
|
|
||||||
|
|
||||||
1. Reproduce under Node 26.4.0 if an ephemeral matching runtime is available.
|
|
||||||
2. Add a Vitest `setupFiles` module deriving Node-native abort constructors from `node:util` and register it in `apps/web/vitest.config.ts`.
|
|
||||||
3. Add a direct regression assertion that a global controller's signal is accepted by native `Request`, while retaining the existing redirect behavior tests unchanged.
|
|
||||||
4. Run focused Node 22 and Node 26 tests, independent review, all required gates, and local commit(s).
|
|
||||||
5. After every edit/commit/status check is complete, run `pnpm test` from `apps/web` as the last command.
|
|
||||||
|
|
||||||
### Remediation implementation and evidence
|
|
||||||
|
|
||||||
- Reproduced under ephemeral Node `v26.4.0`: `Test Files 1 failed (1)`, `Tests 2 failed | 4 passed (6)`, `Errors 2 errors`, with the exact undici `AbortSignal` realm rejection from the remediation brief.
|
|
||||||
- Added `apps/web/src/test/setup.ts`, registered through `vitest.config.ts#setupFiles`. It derives Node-native abort constructors from the built-in `node:util.transferableAbortController()` and aligns both `globalThis` and jsdom `window`; it does not replace `Request`, `Response`, or `fetch`.
|
|
||||||
- Explicitly pinned Vitest `isolate: true` so the test-only global constructors cannot leak between test-file environments.
|
|
||||||
- Added `src/test/setup.spec.ts`, which proves a global controller signal is accepted by Node's native `Request` and that abort propagation remains functional.
|
|
||||||
- Existing `guards.spec.tsx` and production `guards.tsx` remain unchanged.
|
|
||||||
- Focused Node 26.4.0 remediation run: `Test Files 2 passed (2)` and `Tests 7 passed (7)`, with zero errors/unhandled rejections.
|
|
||||||
- Preliminary full Node 26.4.0 run: `Test Files 9 passed (9)` and `Tests 27 passed (27)`, with zero errors/unhandled rejections before the direct setup regression was added.
|
|
||||||
- No dependency was added; `node:util` is a Node built-in.
|
|
||||||
|
|
||||||
### Remediation independent review
|
|
||||||
|
|
||||||
- First code review: approve, 0 blockers, 0 should-fix; suggested a direct Request regression and version-neutral comment.
|
|
||||||
- First security/integrity review: low risk; suggested pinning test isolation explicitly.
|
|
||||||
- All suggestions were applied.
|
|
||||||
- Fresh code re-review: approve, 0 blockers, 0 should-fix, no findings.
|
|
||||||
- Fresh security/integrity re-review: low risk, 0 findings.
|
|
||||||
|
|
||||||
### Remediation pre-commit gate evidence
|
|
||||||
|
|
||||||
- Node 26.4.0 `pnpm test`: `Test Files 10 passed (10)`; `Tests 28 passed (28)`; zero errors and zero unhandled rejections.
|
|
||||||
- `pnpm typecheck`: `tsc --noEmit` exited 0.
|
|
||||||
- `pnpm build:vite`: `✓ 113 modules transformed`; `✓ built in 960ms`.
|
|
||||||
- `pnpm build` (Next): `✓ Compiled successfully in 4.8s`; static pages generated 10/10.
|
|
||||||
- `pnpm lint`: `eslint src` exited 0.
|
|
||||||
- Root `pnpm format:check`: `All matched files use Prettier code style!`.
|
|
||||||
- Final post-commit non-test gates and final-action Node 26 `pnpm test` remain required before reporting completion.
|
|
||||||
|
|
||||||
### Remediation committed verification
|
|
||||||
|
|
||||||
- Implementation commit: `e16c08aa` (`test(web): align jsdom abort signals with Node`).
|
|
||||||
- Post-commit `pnpm typecheck`: `tsc --noEmit` exited 0.
|
|
||||||
- Post-commit `pnpm build:vite`: `✓ 113 modules transformed`; `✓ built in 323ms`.
|
|
||||||
- Post-commit `pnpm build` (Next): `✓ Compiled successfully in 4.6s`; static pages generated 10/10.
|
|
||||||
- Post-commit `pnpm lint`: `eslint src` exited 0.
|
|
||||||
- Post-commit root `pnpm format:check`: `All matched files use Prettier code style!`.
|
|
||||||
- Final Node 26 `pnpm test` will be the last worktree action and its verbatim output will be reported to the orchestrator.
|
|
||||||
@@ -193,16 +193,19 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
|||||||
'Your timezone': 'UTC',
|
'Your timezone': 'UTC',
|
||||||
});
|
});
|
||||||
|
|
||||||
await runWizard({
|
await expect(
|
||||||
mosaicHome: tmpDir,
|
runWizard({
|
||||||
sourceDir: tmpDir,
|
mosaicHome: tmpDir,
|
||||||
prompter,
|
sourceDir: tmpDir,
|
||||||
configService: createConfigService(tmpDir, tmpDir),
|
prompter,
|
||||||
skipGatewayNpmInstall: true,
|
configService: createConfigService(tmpDir, tmpDir),
|
||||||
});
|
skipGatewayNpmInstall: true,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Gateway configuration failed');
|
||||||
|
|
||||||
const logs = prompter.getLogs();
|
const logs = prompter.getLogs();
|
||||||
expect(logs.some((line) => line.includes('Gateway did not become healthy'))).toBe(true);
|
expect(logs.some((line) => line.includes('Gateway did not become healthy'))).toBe(true);
|
||||||
|
expect(logs.some((line) => line.includes('Gateway configuration failed'))).toBe(true);
|
||||||
expect(logs.some((line) => line.includes('Installation Summary'))).toBe(false);
|
expect(logs.some((line) => line.includes('Installation Summary'))).toBe(false);
|
||||||
expect(logs.some((line) => line.includes('Mosaic is ready.'))).toBe(false);
|
expect(logs.some((line) => line.includes('Mosaic is ready.'))).toBe(false);
|
||||||
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
|
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -68,8 +68,6 @@ export async function quickStartPath(
|
|||||||
|
|
||||||
// Gateway config + bootstrap
|
// Gateway config + bootstrap
|
||||||
if (!options.skipGateway) {
|
if (!options.skipGateway) {
|
||||||
const headlessRun = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const configResult = await gatewayConfigStage(prompter, state, {
|
const configResult = await gatewayConfigStage(prompter, state, {
|
||||||
host: options.gatewayHost ?? 'localhost',
|
host: options.gatewayHost ?? 'localhost',
|
||||||
@@ -81,11 +79,9 @@ export async function quickStartPath(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!configResult.ready || !configResult.host || !configResult.port) {
|
if (!configResult.ready || !configResult.host || !configResult.port) {
|
||||||
if (headlessRun) {
|
const message = 'Gateway configuration failed — aborting wizard.';
|
||||||
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
|
prompter.warn(message);
|
||||||
process.exit(1);
|
throw new Error(message);
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||||
@@ -93,9 +89,9 @@ export async function quickStartPath(
|
|||||||
port: configResult.port,
|
port: configResult.port,
|
||||||
});
|
});
|
||||||
if (!bootstrapResult.completed) {
|
if (!bootstrapResult.completed) {
|
||||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
const message = 'Admin bootstrap failed — aborting wizard.';
|
||||||
process.exit(1);
|
prompter.warn(message);
|
||||||
return;
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
finalizeResult.showSummary();
|
finalizeResult.showSummary();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -348,18 +348,21 @@ async function runFinishPath(
|
|||||||
providerType: state.providerType,
|
providerType: state.providerType,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (configResult.ready && configResult.host && configResult.port) {
|
if (!configResult.ready || !configResult.host || !configResult.port) {
|
||||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
const message = 'Gateway configuration failed — aborting wizard.';
|
||||||
host: configResult.host,
|
prompter.warn(message);
|
||||||
port: configResult.port,
|
throw new Error(message);
|
||||||
});
|
|
||||||
if (!bootstrapResult.completed) {
|
|
||||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
|
||||||
process.exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
finalizeResult.showSummary();
|
|
||||||
}
|
}
|
||||||
|
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||||
|
host: configResult.host,
|
||||||
|
port: configResult.port,
|
||||||
|
});
|
||||||
|
if (!bootstrapResult.completed) {
|
||||||
|
const message = 'Admin bootstrap failed — aborting wizard.';
|
||||||
|
prompter.warn(message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
finalizeResult.showSummary();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
throw err;
|
throw err;
|
||||||
@@ -420,9 +423,9 @@ async function runHeadlessPath(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!configResult.ready || !configResult.host || !configResult.port) {
|
if (!configResult.ready || !configResult.host || !configResult.port) {
|
||||||
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
|
const message = 'Gateway configuration failed in headless mode — aborting wizard.';
|
||||||
process.exit(1);
|
prompter.warn(message);
|
||||||
return;
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||||
@@ -430,9 +433,9 @@ async function runHeadlessPath(
|
|||||||
port: configResult.port,
|
port: configResult.port,
|
||||||
});
|
});
|
||||||
if (!bootstrapResult.completed) {
|
if (!bootstrapResult.completed) {
|
||||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
const message = 'Admin bootstrap failed — aborting wizard.';
|
||||||
process.exit(1);
|
prompter.warn(message);
|
||||||
return;
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
finalizeResult.showSummary();
|
finalizeResult.showSummary();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -477,18 +480,21 @@ async function runKeepPath(
|
|||||||
skipInstall: options.skipGatewayNpmInstall,
|
skipInstall: options.skipGatewayNpmInstall,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (configResult.ready && configResult.host && configResult.port) {
|
if (!configResult.ready || !configResult.host || !configResult.port) {
|
||||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
const message = 'Gateway configuration failed — aborting wizard.';
|
||||||
host: configResult.host,
|
prompter.warn(message);
|
||||||
port: configResult.port,
|
throw new Error(message);
|
||||||
});
|
|
||||||
if (!bootstrapResult.completed) {
|
|
||||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
|
||||||
process.exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
finalizeResult.showSummary();
|
|
||||||
}
|
}
|
||||||
|
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||||
|
host: configResult.host,
|
||||||
|
port: configResult.port,
|
||||||
|
});
|
||||||
|
if (!bootstrapResult.completed) {
|
||||||
|
const message = 'Admin bootstrap failed — aborting wizard.';
|
||||||
|
prompter.warn(message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
finalizeResult.showSummary();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
Generated
+68
-771
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}`;
|
||||||
|
|||||||
+4
-1
@@ -762,9 +762,12 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
|
|||||||
if "$MOSAIC_CMD" wizard; then
|
if "$MOSAIC_CMD" wizard; then
|
||||||
ok "Wizard complete."
|
ok "Wizard complete."
|
||||||
else
|
else
|
||||||
warn "Wizard exited non-zero."
|
fail "Wizard failed; installation is incomplete."
|
||||||
|
echo " Completed: framework and CLI installation"
|
||||||
|
echo " Failed: gateway configuration or admin bootstrap"
|
||||||
echo " You can retry with: ${C}mosaic wizard${RESET}"
|
echo " You can retry with: ${C}mosaic wizard${RESET}"
|
||||||
echo " Or run gateway install alone: ${C}mosaic gateway install${RESET}"
|
echo " Or run gateway install alone: ${C}mosaic gateway install${RESET}"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
|
|||||||
Reference in New Issue
Block a user