diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1610e01..7ad65bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,9 +24,26 @@ jobs: - run: npm run typecheck - run: npm test + web: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install and build frontend + working-directory: web + run: | + npm ci + npm run build + docker: runs-on: ubuntu-latest - needs: test + needs: [test, web] steps: - uses: actions/checkout@v4 @@ -38,6 +55,7 @@ jobs: docker run -d --name cb-test -p 8080:8080 cookiebridge:ci sleep 3 curl -sf http://localhost:8080/health + curl -sf http://localhost:8080/ | grep -q '
' docker stop cb-test extension: diff --git a/.gitignore b/.gitignore index 4bb2dac..c1cdb58 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ web/node_modules/ web/dist/ node_modules/ dist/ +public/ data/ *.db *.db-wal diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8cb499e..e9ba8c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,23 @@ npm test # Run test suite npm run typecheck # Type checking only ``` +**Admin panel (frontend):** + +```bash +cd web +npm install +npm run dev # Starts Vite dev server on :5173 (proxies API to backend) +``` + +The frontend dev server proxies `/api`, `/admin`, `/ws`, and `/health` to `http://localhost:8100`. Start the backend first with `npm run dev` (or adjust the proxy target in `web/vite.config.ts`). + +**Full production build:** + +```bash +npm run build:all # Builds backend (tsc) + frontend (vite) → copies to public/ +npm start # Serves everything on :8080 +``` + **Extension:** ```bash diff --git a/Dockerfile b/Dockerfile index 41971ba..8c5472a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,15 @@ +## Stage 1: Build frontend (Vue/Vite) +FROM node:22-alpine AS web-builder + +WORKDIR /app/web + +COPY web/package.json web/package-lock.json ./ +RUN npm ci + +COPY web/ ./ +RUN npm run build + +## Stage 2: Build backend (TypeScript) FROM node:22-alpine AS builder WORKDIR /app @@ -12,7 +24,7 @@ COPY tsconfig.json ./ COPY src/ ./src/ RUN npm run build -# --- Production image --- +## Stage 3: Production image FROM node:22-alpine WORKDIR /app @@ -25,6 +37,7 @@ RUN npm ci --omit=dev --ignore-scripts=false && \ rm -rf /root/.npm /tmp/* COPY --from=builder /app/dist ./dist +COPY --from=web-builder /app/web/dist ./public ENV PORT=8080 ENV HOST=0.0.0.0 diff --git a/README.md b/README.md index afee9ad..1f845ec 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,10 @@ CookieBridge syncs browser cookies across devices through an encrypted relay ser - **Multi-browser support** — Chrome, Firefox, Edge, and Safari extensions. - **Real-time sync** — WebSocket transport with HTTP polling fallback. - **Device pairing** — 6-digit code, 5-minute TTL, X25519 key exchange. +- **Admin panel** — Built-in Vue 3 web UI for server management. - **AI agent API** — Agents can retrieve encrypted cookies with granted access. - **Conflict resolution** — Last-writer-wins with Lamport clocks. +- **Database options** — In-memory (default), SQLite, or MySQL via setup wizard. - **Self-hostable** — Docker image or run directly with Node.js. ## Quick Start @@ -22,7 +24,9 @@ CookieBridge syncs browser cookies across devices through an encrypted relay ser docker compose up -d ``` -The relay server starts on port 8080. Override with `PORT=3000 docker compose up -d`. +The server starts on port 8080 with the admin UI embedded. Override the port with `PORT=3000 docker compose up -d`. + +Open `http://localhost:8080` to access the admin panel and run the setup wizard. ### Docker (manual) @@ -34,11 +38,30 @@ docker run -d -p 8080:8080 --name cookiebridge cookiebridge ### From source ```bash +# Install all dependencies npm install +cd web && npm install && cd .. + +# Build everything (backend + frontend) +npm run build:all + +# Start the server (serves API + admin UI) npm start ``` -Requires Node.js 22+. The server listens on `0.0.0.0:8080` by default. +Requires Node.js 22+. The server listens on `0.0.0.0:8080` by default and serves the admin UI at the root URL. + +### Development mode + +Run the backend and frontend dev servers separately for hot-reload: + +```bash +# Terminal 1: Backend (port 8080) +npm run dev + +# Terminal 2: Frontend dev server (port 5173, proxies API to backend) +cd web && npm run dev +``` ## Environment Variables @@ -77,15 +100,15 @@ Output goes to `extension/build/{browser}/`. Load the unpacked extension from th ``` Browser Extension ──WebSocket/HTTP──▶ Relay Server (stores encrypted blobs) - │ ▲ - ├── Ed25519 signing │ - ├── X25519 key exchange │ - └── XChaCha20-Poly1305 encryption │ - │ -AI Agent ──Bearer token──────────────────────┘ + │ │ + ├── Ed25519 signing ├── Admin UI (Vue 3 SPA) + ├── X25519 key exchange ├── SQLite / MySQL / In-memory + └── XChaCha20-Poly1305 encryption └── Setup wizard + │ +AI Agent ──Bearer token──────────────────┘ ``` -The relay server is a plain Node.js HTTP + WebSocket server with no framework dependencies. See [docs/architecture.md](docs/architecture.md) for the full design and [docs/security.md](docs/security.md) for the threat model. +The relay server is a plain Node.js HTTP + WebSocket server with no framework dependencies. In production, the server embeds the pre-built admin UI and serves it as static files. See [docs/architecture.md](docs/architecture.md) for the full design and [docs/security.md](docs/security.md) for the threat model. ## API Endpoints @@ -104,6 +127,46 @@ The relay server is a plain Node.js HTTP + WebSocket server with no framework de | `GET` | `/health` | Health check | | `WebSocket` | `/ws` | Real-time sync channel | +### Admin Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/admin/setup/init` | Run setup wizard (set password, choose DB) | +| `GET` | `/admin/setup/status` | Check if setup has been completed | +| `POST` | `/admin/login` | Login to admin panel | +| `GET` | `/admin/devices` | List registered devices | +| `GET` | `/admin/connections` | List active WebSocket connections | +| `GET` | `/admin/agents` | List registered agents | +| `GET` | `/admin/stats` | Server statistics | + +## Deployment + +### Build Pipeline + +The Dockerfile uses a multi-stage build: + +1. **web-builder** — Installs frontend dependencies and runs `vite build` +2. **builder** — Compiles the TypeScript backend +3. **production** — Copies compiled backend + built frontend into a minimal image + +The frontend is served from the `/public` directory inside the container. No separate web server (nginx, etc.) is needed. + +### Database Persistence + +By default, CookieBridge starts with in-memory storage. On first access, the setup wizard lets you choose: + +- **In-memory** — No persistence, data resets on restart +- **SQLite** — File-based, mount a volume for persistence +- **MySQL** — Remote database, provide connection details + +For SQLite persistence with Docker: + +```bash +docker run -d -p 8080:8080 -v cookiebridge-data:/app/data cookiebridge +``` + +Database configuration is stored in `data/db-config.json`. + ## Development ```bash @@ -111,6 +174,7 @@ npm install npm run dev # Start with file watching npm test # Run test suite npm run typecheck # Type checking only +npm run build:all # Build backend + frontend ``` ## Project Structure @@ -119,18 +183,21 @@ npm run typecheck # Type checking only src/ cli.ts # Server entry point relay/ - server.ts # HTTP + WebSocket server + server.ts # HTTP + WebSocket server + static file serving + static.ts # Static file serving with SPA fallback connections.ts # WebSocket connection manager auth.ts # Token & challenge-response auth - store.ts # In-memory encrypted cookie storage - tokens.ts # Device & agent registries + admin/ # Admin panel API routes + db/ # Database abstraction (memory, SQLite, MySQL) crypto/ # XChaCha20-Poly1305, Ed25519 pairing/ # Device pairing flow sync/ # Sync engine, conflict resolution protocol/ spec.ts # Protocol types & constants +web/ # Admin panel (Vue 3 + Vite) extension/ # Multi-browser extension source tests/ # Vitest test suite +docs/ # Architecture and security docs ``` ## License diff --git a/package.json b/package.json index 8a64731..1e53b90 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "types": "dist/index.d.ts", "scripts": { "build": "tsc", + "build:web": "cd web && npm run build && cd .. && rm -rf public && cp -r web/dist public", + "build:all": "npm run build && npm run build:web", "start": "tsx src/cli.ts", "dev": "tsx --watch src/cli.ts", "test": "vitest run", diff --git a/src/cli.ts b/src/cli.ts index 4eb6ab3..6cb9d6f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,3 +1,5 @@ +import path from "node:path"; +import fs from "node:fs"; import { RelayServer } from "./relay/index.js"; import { loadDbConfig, createStores } from "./relay/db/index.js"; @@ -16,10 +18,28 @@ async function main() { console.log("Configure a database during setup to enable persistent storage."); } - const server = new RelayServer({ port, host, stores }); + // Resolve frontend public directory: check ./public (production) then ../web/dist (development) + let publicDir: string | undefined; + const candidates = [ + path.resolve("public"), + path.resolve(__dirname, "..", "web", "dist"), + ]; + for (const dir of candidates) { + if (fs.existsSync(path.join(dir, "index.html"))) { + publicDir = dir; + break; + } + } + + const server = new RelayServer({ port, host, stores, publicDir }); await server.start(); console.log(`CookieBridge relay server listening on ${host}:${port}`); + if (publicDir) { + console.log(`Serving admin UI from ${publicDir}`); + } else { + console.log("No frontend build found — admin UI not available. Run: npm run build:web"); + } process.on("SIGINT", async () => { console.log("\nShutting down..."); diff --git a/src/relay/server.ts b/src/relay/server.ts index 6cdb381..e7d57f8 100644 --- a/src/relay/server.ts +++ b/src/relay/server.ts @@ -1,5 +1,6 @@ import { WebSocketServer, WebSocket } from "ws"; import http from "node:http"; +import path from "node:path"; import { ConnectionManager } from "./connections.js"; import { generateChallenge, verifyAuthResponse } from "./auth.js"; import { verify, buildSignablePayload } from "../crypto/signing.js"; @@ -11,6 +12,7 @@ import { PING_INTERVAL_MS, } from "../protocol/spec.js"; import { handleAdminRoute } from "./admin/routes.js"; +import { serveStatic } from "./static.js"; import type { DataStores, ICookieStore, IDeviceStore, IAgentStore, IAdminStore } from "./db/types.js"; import { createMemoryStores } from "./db/memory.js"; @@ -18,6 +20,8 @@ export interface RelayServerConfig { port: number; host?: string; stores?: DataStores; + /** Directory containing pre-built frontend assets. Serves static files + SPA fallback. */ + publicDir?: string; } interface PendingAuth { @@ -182,6 +186,11 @@ export class RelayServer { return; } + // Serve frontend static files (if publicDir configured) + if (this.config.publicDir && method === "GET") { + if (serveStatic(req, res, this.config.publicDir)) return; + } + res.writeHead(404); res.end("Not found"); } diff --git a/src/relay/static.ts b/src/relay/static.ts new file mode 100644 index 0000000..522d9d5 --- /dev/null +++ b/src/relay/static.ts @@ -0,0 +1,78 @@ +import http from "node:http"; +import fs from "node:fs"; +import path from "node:path"; + +const MIME_TYPES: Record = { + ".html": "text/html; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".map": "application/json", +}; + +/** + * Serve static files from `dir`. Returns true if a file was served, + * false if no matching file was found (caller should handle 404). + * + * For SPA support: if no file matches and `spaFallback` is true, + * serves index.html for requests that look like page navigations + * (no file extension). + */ +export function serveStatic( + req: http.IncomingMessage, + res: http.ServerResponse, + dir: string, + spaFallback = true, +): boolean { + const urlPath = (req.url ?? "/").split("?")[0]; + const safePath = path.normalize(urlPath).replace(/^(\.\.[/\\])+/, ""); + let filePath = path.join(dir, safePath); + + // Try exact file first + if (tryServeFile(res, filePath)) return true; + + // Try index.html in directory + if (tryServeFile(res, path.join(filePath, "index.html"))) return true; + + // SPA fallback: serve index.html for paths without a file extension + if (spaFallback && !path.extname(safePath)) { + if (tryServeFile(res, path.join(dir, "index.html"))) return true; + } + + return false; +} + +function tryServeFile(res: http.ServerResponse, filePath: string): boolean { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) return false; + + const ext = path.extname(filePath).toLowerCase(); + const contentType = MIME_TYPES[ext] ?? "application/octet-stream"; + + // Cache hashed assets aggressively, everything else short-lived + const isHashed = filePath.includes("/assets/"); + const cacheControl = isHashed + ? "public, max-age=31536000, immutable" + : "public, max-age=60"; + + res.writeHead(200, { + "Content-Type": contentType, + "Content-Length": stat.size, + "Cache-Control": cacheControl, + }); + fs.createReadStream(filePath).pipe(res); + return true; + } catch { + return false; + } +} diff --git a/vitest.config.ts b/vitest.config.ts index 712cf39..7509188 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,5 +4,6 @@ export default defineConfig({ test: { globals: true, testTimeout: 15_000, + exclude: ["node_modules", "dist", "web", "extension"], }, });