+
+
+
+
{
+ setGitlabUrl(e.target.value);
+ if (validationError) setValidationError(null);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && canSubmit && !isLoading) {
+ e.preventDefault();
+ handleAnalyze();
+ }
+ }}
+ disabled={isLoading}
+ placeholder="https://gitlab.com/owner/repo"
+ autoComplete="url"
+ spellCheck={false}
+ className="flex-1 border-none bg-transparent font-mono text-sm text-text-primary outline-none placeholder:text-text-muted disabled:opacity-50"
+ />
+ {gitlabUrl.length > 10 && (
+
+ {isValidGitlabUrl(gitlabUrl) ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ Supports GitLab.com and self-hosted GitLab instances.
+
+
+ )}
+
{/* Local folder input */}
{showInput && mode === 'local' && (
diff --git a/gitnexus-web/src/lib/lucide-icons.tsx b/gitnexus-web/src/lib/lucide-icons.tsx
index 05676e50d..dc69b279f 100644
--- a/gitnexus-web/src/lib/lucide-icons.tsx
+++ b/gitnexus-web/src/lib/lucide-icons.tsx
@@ -123,6 +123,67 @@ export {
* defaults to `currentColor`, so Tailwind `text-*` utilities work the same as
* with any other icon in this module.
*/
+/**
+ * GitLab tanuki mark — SVG path data from simple-icons (CC0-1.0).
+ *
+ * GitLab's logo (the tanuki/fox-head) is a registered trademark of GitLab Inc.
+ * We use it here only to indicate GitLab source-repo integration.
+ *
+ * API-compatible with `lucide-react` icons (`LucideProps`).
+ */
+export const Gitlab = forwardRef
(function Gitlab(
+ {
+ size = 24,
+ color = 'currentColor',
+ className,
+ strokeWidth: _strokeWidth,
+ absoluteStrokeWidth: _absoluteStrokeWidth,
+ ...rest
+ },
+ ref,
+) {
+ const numericSize = typeof size === 'string' ? Number.parseFloat(size) : size;
+ const useSmallVariant = Number.isFinite(numericSize) && (numericSize as number) <= 16;
+
+ if (useSmallVariant) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+});
+
export const Github = forwardRef(function Github(
{
size = 24,
diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts
index a1be43070..4c55536ed 100644
--- a/gitnexus/src/server/api.ts
+++ b/gitnexus/src/server/api.ts
@@ -695,6 +695,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,
@@ -709,22 +717,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');
+ });
+});