import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify'; import fastifyCookie from '@fastify/cookie'; import fastifyWebsocket from '@fastify/websocket'; import fastifyStatic from '@fastify/static'; import { existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { Config } from './config.js'; import type { Db } from './db/index.js'; import { AuthService, LoginRateLimiter, type AuthContext } from './auth/service.js'; import { PtyManager } from './core/pty-manager.js'; import { DiscoveryService } from './core/discovery-service.js'; import { WorktreeManager } from './core/worktree-manager.js'; import { GroupManager } from './core/group-manager.js'; import { PushService } from './core/push-service.js'; import { registerAuthRoutes } from './routes/auth.js'; import { registerSessionRoutes } from './routes/sessions.js'; import { registerRepoRoutes } from './routes/repos.js'; import { registerGroupRoutes } from './routes/groups.js'; import { registerWorktreeRoutes } from './routes/worktrees.js'; import { registerPushRoutes } from './routes/push.js'; import { registerSettingsRoutes } from './routes/settings.js'; import { registerFsRoutes } from './routes/fs.js'; import { registerWsGateway } from './ws/gateway.js'; declare module 'fastify' { interface FastifyRequest { authContext: AuthContext | null; } interface FastifyContextConfig { public?: boolean; } } export interface AppBundle { app: FastifyInstance; auth: AuthService; manager: PtyManager; discovery: DiscoveryService; worktrees: WorktreeManager; groups: GroupManager; push: PushService; } export function buildApp(config: Config, db: Db, serverVersion: string): AppBundle { const app = Fastify({ logger: { level: process.env.ARBORETUM_LOG ?? 'info' } }); const auth = new AuthService(db); const limiter = new LoginRateLimiter(); const push = new PushService(db, config.vapidContact); const manager = new PtyManager(db, config.claudeSessionsDir, push); const discovery = new DiscoveryService({ ptyManager: manager, projectsDir: config.claudeProjectsDir, sessionsDir: config.claudeSessionsDir, }); const worktrees = new WorktreeManager(db, manager, discovery); const groups = new GroupManager(db); void app.register(fastifyCookie); void app.register(fastifyWebsocket, { options: { maxPayload: 1024 * 1024 }, }); const allowedOrigins = new Set([ `http://127.0.0.1:${config.port}`, `http://localhost:${config.port}`, ...config.allowedOrigins, ]); const authenticate = (req: FastifyRequest): AuthContext | null => { const bearer = req.headers.authorization; if (bearer?.startsWith('Bearer ')) { const ctx = auth.verifyRawToken(bearer.slice(7)); if (ctx) return ctx; } return auth.verifyCookie(req.cookies[auth.cookieName]); }; // Garde globale : auth sur tout /api/** et /ws ; check Origin strict quand l'en-tête est présent // (anti cross-site WebSocket hijacking — le cookie SameSite=Strict ne suffit pas pour les upgrades). app.addHook('preValidation', async (req, reply) => { const isApi = req.url.startsWith('/api/'); const isWs = req.url.startsWith('/ws'); if (!isApi && !isWs) return; // statique : public (la SPA gère son écran de login) const origin = req.headers.origin; if (origin && !allowedOrigins.has(origin)) { return reply.status(403).send({ error: { code: 'BAD_ORIGIN', message: `Origin not allowed: ${origin}` } }); } req.authContext = authenticate(req); if (req.routeOptions.config.public) return; if (!req.authContext) { return reply.status(401).send({ error: { code: 'UNAUTHORIZED', message: 'Authentication required' } }); } }); registerAuthRoutes(app, auth, limiter, serverVersion); registerSessionRoutes(app, manager, discovery); registerRepoRoutes(app, worktrees); registerGroupRoutes(app, groups); registerWorktreeRoutes(app, worktrees); registerPushRoutes(app, push); registerSettingsRoutes(app, db, config, serverVersion, push); registerFsRoutes(app); // La route websocket doit être déclarée APRÈS le chargement du plugin (contexte // encapsulé) — sinon le handler reçoit la signature REST (request, reply). void app.register(async (scoped) => { registerWsGateway(scoped, manager, discovery, worktrees, groups, serverVersion); }); // SPA buildée embarquée dans le paquet npm (public/) — absente en dev (vite dev sert le front) const publicDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'public'); if (existsSync(publicDir)) { void app.register(fastifyStatic, { root: publicDir, wildcard: false }); app.setNotFoundHandler((req, reply) => { if (req.url.startsWith('/api/') || req.url.startsWith('/ws')) { return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'Route not found' } }); } return reply.sendFile('index.html'); }); } return { app, auth, manager, discovery, worktrees, groups, push }; }