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"],
},
});