P1 spine: monorepo, shared WS protocol, server daemon
- npm workspaces (shared / server / web), TS strict, project refs - @arboretum/shared: multiplexed WS protocol (JSON control + binary output frames: 1B type + u32le channel), flow-control constants (ACK 256K, HIGH 384K, LOW 128K, lagging 2M), REST types - git-arboretum server: Fastify 5 + node:sqlite (single native dep: node-pty prebuilt), token auth (sha256 at rest, HMAC cookie, global login rate limit + backoff), strict Origin check on /api and /ws, PtyManager (2MiB ring with monotonic offset, resync replay = reset + 256KiB tail, pause/resume only when ALL interactive clients exceed HIGH, observers never throttle, lagging clients resync), WS gateway (attach/stdin/resize/ack, heartbeat 30s), SIGTERM→SIGKILL 5s grace - CLI: arboretum [--port 7317] [--bind 127.0.0.1] — non-loopback bind requires an explicit safety flag - Smoke-tested: login/401/403-origin/spawn bash/kill/grace-SIGKILL all green Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
91
packages/server/src/app.ts
Normal file
91
packages/server/src/app.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
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 { registerAuthRoutes } from './routes/auth.js';
|
||||
import { registerSessionRoutes } from './routes/sessions.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;
|
||||
}
|
||||
|
||||
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 manager = new PtyManager(db);
|
||||
|
||||
void app.register(fastifyCookie);
|
||||
void app.register(fastifyWebsocket, {
|
||||
options: { maxPayload: 1024 * 1024 },
|
||||
});
|
||||
|
||||
const allowedOrigins = new Set<string>([
|
||||
`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);
|
||||
registerWsGateway(app, manager, 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user