diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index 796fa04e1..b908bc1e9 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -692,6 +692,14 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => // local-bound default). app.set('trust proxy', 'loopback, linklocal, uniquelocal'); + // Chromium Private Network Access (required since Chrome 130+). Must run before + // cors: the cors middleware ends OPTIONS preflight responses, so this header + // has to be set on res before cors writes the preflight reply. + app.use((_req, res, next) => { + res.setHeader('Access-Control-Allow-Private-Network', 'true'); + next(); + }); + // CORS: allow localhost, private/LAN networks, and the deployed site. // Non-browser requests (curl, server-to-server) have no origin and are allowed. // Disallowed origins get the response without Access-Control-Allow-Origin, @@ -706,22 +714,6 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => ); app.use(express.json({ limit: '10mb' })); - // Support Chromium Private Network Access (required since Chrome 130+). - // Without this header, Chrome/Edge/Brave/Arc block public->loopback requests - // which breaks bridge mode entirely. - app.use((_req, res, next) => { - res.setHeader('Access-Control-Allow-Private-Network', 'true'); - next(); - }); - - // Handle PNA preflight: Chromium sends Access-Control-Request-Private-Network - // on OPTIONS requests and expects the allow header in the response. - // Note: the actual Allow-Private-Network header is already set by the global - // middleware above, so we just need to call next() here. - app.options('*', (_req, res, next) => { - next(); - }); - // Initialize MCP backend (multi-repo, shared across all MCP sessions) const backend = new LocalBackend(); await backend.init(); diff --git a/gitnexus/test/integration/server-http-startup.test.ts b/gitnexus/test/integration/server-http-startup.test.ts new file mode 100644 index 000000000..ef36dc944 --- /dev/null +++ b/gitnexus/test/integration/server-http-startup.test.ts @@ -0,0 +1,141 @@ +/** + * HTTP serve startup — proves createServer() boots under Express 5. + * + * Spawns the built CLI (`gitnexus serve`) and probes GET /api/health. + * Catches regressions like invalid route patterns that throw at registration + * time before LadybugDB or MCP initialize. + */ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..', '..'); +const DIST_CLI = path.join(REPO_ROOT, 'dist', 'cli', 'index.js'); + +const STARTUP_BUDGET_MS = process.env.CI ? 30_000 : 15_000; + +const allocateFreePort = (): Promise => + new Promise((resolve, reject) => { + const probe = http.createServer(); + probe.once('error', reject); + probe.listen(0, '127.0.0.1', () => { + const addr = probe.address(); + if (typeof addr !== 'object' || !addr) { + probe.close(); + reject(new Error('could not allocate ephemeral port')); + return; + } + const port = addr.port; + probe.close((err) => (err ? reject(err) : resolve(port))); + }); + }); + +const probeHealth = (port: number): Promise<{ status: number; body: string }> => + new Promise((resolve, reject) => { + const req = http.get(`http://127.0.0.1:${port}/api/health`, (res) => { + const chunks: Buffer[] = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { + resolve({ + status: res.statusCode ?? 0, + body: Buffer.concat(chunks).toString('utf8'), + }); + }); + }); + req.on('error', reject); + req.setTimeout(5_000, () => { + req.destroy(); + reject(new Error('health probe timed out')); + }); + }); + +// Child-process serve + health probe is reliable on Linux CI (where e2e failed). +// On Windows, spawned `serve` can print "running" before the listen socket is +// reachable from the parent; unit tests in server-cors-stack.test.ts cover the +// Express 5 registration path on all platforms. +const describeServeStartup = process.platform === 'win32' ? describe.skip : describe; + +describeServeStartup('gitnexus serve HTTP startup (Express 5)', () => { + let proc: ChildProcessWithoutNullStreams | undefined; + let homeDir: string | undefined; + + afterEach(async () => { + if (proc && !proc.killed) { + proc.kill('SIGTERM'); + await new Promise((resolve) => { + const timer = setTimeout(() => { + proc?.kill('SIGKILL'); + resolve(); + }, 3_000); + proc?.on('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); + } + proc = undefined; + + if (homeDir) { + fs.rmSync(homeDir, { recursive: true, force: true }); + homeDir = undefined; + } + }); + + it('serve boots and GET /api/health returns ok', async () => { + if (!fs.existsSync(DIST_CLI)) { + throw new Error(`Missing ${DIST_CLI} — run npm run build before integration tests`); + } + + const port = await allocateFreePort(); + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-serve-home-')); + + proc = spawn( + process.execPath, + [DIST_CLI, 'serve', '--port', String(port), '--host', '127.0.0.1'], + { + cwd: REPO_ROOT, + env: { ...process.env, GITNEXUS_HOME: homeDir, NODE_OPTIONS: '' }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + + let stdout = ''; + let stderr = ''; + + proc.stdout.on('data', (buf) => { + stdout += buf.toString(); + }); + proc.stderr.on('data', (buf) => { + stderr += buf.toString(); + }); + + const startedAt = Date.now(); + let status = 0; + let body = ''; + + while (Date.now() - startedAt < STARTUP_BUDGET_MS) { + if (proc.exitCode !== null) { + throw new Error( + `serve exited ${proc.exitCode} before ready.\nstdout:\n${stdout}\nstderr:\n${stderr}`, + ); + } + try { + ({ status, body } = await probeHealth(port)); + if (status === 200) { + break; + } + } catch { + // Server still starting — retry until budget expires. + } + await new Promise((r) => setTimeout(r, 100)); + } + + expect(status).toBe(200); + expect(body).toContain('"status":"ok"'); + }, 60_000); +}); diff --git a/gitnexus/test/unit/rate-limit.test.ts b/gitnexus/test/unit/rate-limit.test.ts index 03679045f..e7b6ada81 100644 --- a/gitnexus/test/unit/rate-limit.test.ts +++ b/gitnexus/test/unit/rate-limit.test.ts @@ -262,6 +262,17 @@ describe('production routes — rate-limit middleware wiring', () => { ); }); + it('does not register Express-4-only app.options("*") (Express 5 path-to-regexp)', () => { + expect(apiSource).not.toMatch(/app\.options\(\s*'\*'/); + expect(apiSource).not.toMatch(/app\.options\(\s*'\/\*'/); + }); + + it('sets PNA header middleware before cors (preflight must include Allow-Private-Network)', () => { + expect(apiSource).toMatch( + /Access-Control-Allow-Private-Network[\s\S]*?app\.use\(\s*\n?\s*cors\(/, + ); + }); + it('embed route flushes WAL via flushWAL, not inline executeQuery (#1376)', () => { // The embed handler must call the consolidated helper, not hand-roll // its own try/catch around executeQuery('CHECKPOINT'). diff --git a/gitnexus/test/unit/server-cors-stack.test.ts b/gitnexus/test/unit/server-cors-stack.test.ts new file mode 100644 index 000000000..a732d7b09 --- /dev/null +++ b/gitnexus/test/unit/server-cors-stack.test.ts @@ -0,0 +1,101 @@ +/** + * Regression tests for createServer() CORS + PNA middleware (Express 5). + * + * Express 5 / path-to-regexp v8 rejects bare `app.options('*')`, which broke + * `gitnexus serve` in CI after #872. These tests mirror the registration order + * in createServer() without booting LadybugDB or MCP. + */ +import express from 'express'; +import cors from 'cors'; +import http from 'node:http'; +import { afterEach, describe, expect, it } from 'vitest'; +import { isAllowedOrigin } from '../../src/server/api.js'; + +/** Mirrors createServer() trust proxy + PNA + cors + json stack. */ +const buildCreateServerCorsStack = (): express.Express => { + const app = express(); + app.disable('x-powered-by'); + app.set('trust proxy', 'loopback, linklocal, uniquelocal'); + app.use((_req, res, next) => { + res.setHeader('Access-Control-Allow-Private-Network', 'true'); + next(); + }); + app.use( + cors({ + origin: (origin, callback) => { + callback(null, isAllowedOrigin(origin)); + }, + }), + ); + app.use(express.json({ limit: '10mb' })); + return app; +}; + +describe('createServer CORS/PNA stack — Express 5 registration', () => { + it('registers without path-to-regexp wildcard errors', () => { + expect(() => buildCreateServerCorsStack()).not.toThrow(); + }); +}); + +describe('createServer CORS/PNA stack — OPTIONS preflight', () => { + let server: http.Server | undefined; + let baseUrl = ''; + + afterEach( + () => + new Promise((resolve, reject) => { + if (!server) { + resolve(); + return; + } + server.close((err) => (err ? reject(err) : resolve())); + }), + ); + + const start = (app: express.Express): Promise => + new Promise((resolve) => { + server = app.listen(0, '127.0.0.1', () => { + const addr = server!.address(); + if (typeof addr === 'object' && addr) { + baseUrl = `http://127.0.0.1:${addr.port}`; + } + resolve(); + }); + }); + + it('OPTIONS /api/repos includes PNA and ACAO for allowed origin', async () => { + const app = buildCreateServerCorsStack(); + await start(app); + + const res = await fetch(`${baseUrl}/api/repos`, { + method: 'OPTIONS', + headers: { + Origin: 'https://gitnexus.vercel.app', + 'Access-Control-Request-Method': 'GET', + 'Access-Control-Request-Private-Network': 'true', + }, + }); + + expect(res.status).toBe(204); + expect(res.headers.get('access-control-allow-origin')).toBe('https://gitnexus.vercel.app'); + expect(res.headers.get('access-control-allow-private-network')).toBe('true'); + }); + + it('OPTIONS / includes PNA header for localhost bridge', async () => { + const app = buildCreateServerCorsStack(); + await start(app); + + const res = await fetch(`${baseUrl}/`, { + method: 'OPTIONS', + headers: { + Origin: 'http://localhost:5173', + 'Access-Control-Request-Method': 'GET', + 'Access-Control-Request-Private-Network': 'true', + }, + }); + + expect(res.status).toBe(204); + expect(res.headers.get('access-control-allow-origin')).toBe('http://localhost:5173'); + expect(res.headers.get('access-control-allow-private-network')).toBe('true'); + }); +});