Compare commits

..

2 Commits

Author SHA1 Message Date
徐枫
fb5c841282 feat: embed frontend into backend server with full-stack build pipeline (RCA-20)
Some checks failed
CI / test (22) (push) Has been cancelled
CI / web (push) Has been cancelled
CI / extension (push) Has been cancelled
CI / docker (push) Has been cancelled
The backend now serves the Vue admin UI as static files with SPA fallback,
eliminating the need for a separate web server. Dockerfile builds both
frontend and backend in a multi-stage pipeline. Added build:web and
build:all scripts, updated CI to verify frontend builds, and fixed
vitest config to exclude Playwright tests.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-18 12:33:41 +08:00
徐枫
1093d64724 feat: add SQLite and MySQL database support with setup wizard selection (RCA-21)
Replace in-memory storage with a database abstraction layer supporting SQLite
and MySQL. Users choose their preferred database during the first-time setup
wizard. The server persists the database config to data/db-config.json and
loads it automatically on restart.

- Add database abstraction interfaces (ICookieStore, IDeviceStore, IAgentStore, IAdminStore)
- Implement SQLite driver using better-sqlite3 with WAL mode
- Implement MySQL driver using mysql2 connection pooling
- Keep memory-backed driver for backwards compatibility and testing
- Add database selection step (step 2) to the setup wizard UI
- Update setup API to accept dbConfig and initialize the chosen database
- Update RelayServer to use async store interfaces with runtime store replacement

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-18 11:55:59 +08:00
19 changed files with 2374 additions and 136 deletions

View File

@@ -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 '<div id="app">'
docker stop cb-test
extension:

7
.gitignore vendored
View File

@@ -1,2 +1,9 @@
web/node_modules/
web/dist/
node_modules/
dist/
public/
data/
*.db
*.db-wal
*.db-shm

View File

@@ -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

View File

@@ -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

View File

@@ -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

552
package-lock.json generated
View File

@@ -9,13 +9,16 @@
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"better-sqlite3": "^12.8.0",
"jsonwebtoken": "^9.0.3",
"mysql2": "^3.20.0",
"sodium-native": "^5.1.0",
"typescript": "^5.9.3",
"uuid": "^13.0.0",
"ws": "^8.19.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.5.0",
"@types/sodium-native": "^2.3.9",
@@ -825,6 +828,16 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -872,7 +885,6 @@
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
@@ -1028,6 +1040,15 @@
"node": ">=12"
}
},
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/b4a": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz",
@@ -1167,6 +1188,84 @@
"bare": ">=1.2.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "12.8.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz",
"integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -1183,6 +1282,12 @@
"node": ">=18"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1190,11 +1295,43 @@
"dev": true,
"license": "MIT"
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -1209,6 +1346,15 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/es-module-lexer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
@@ -1277,6 +1423,15 @@
"bare-events": "^2.7.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -1311,6 +1466,18 @@
}
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1326,6 +1493,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"license": "MIT",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
@@ -1339,6 +1515,66 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -1685,6 +1921,27 @@
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lru.min": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1695,12 +1952,73 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/mysql2": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.20.0.tgz",
"integrity": "sha512-eCLUs7BNbgA6nf/MZXsaBO1SfGs0LtLVrJD3WeWq+jPLDWkSufTD+aGMwykfUVPdZnblaUK1a8G/P63cl9FkKg==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.2",
"denque": "^2.1.0",
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.2",
"long": "^5.3.2",
"lru.min": "^1.1.4",
"named-placeholders": "^1.1.6",
"sql-escaper": "^1.3.3"
},
"engines": {
"node": ">= 8.0"
},
"peerDependencies": {
"@types/node": ">= 8"
}
},
"node_modules/named-placeholders": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
"license": "MIT",
"dependencies": {
"lru.min": "^1.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -1720,6 +2038,24 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/node-abi": {
"version": "3.89.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
"integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@@ -1731,6 +2067,15 @@
],
"license": "MIT"
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -1787,6 +2132,72 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/require-addon": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.2.0.tgz",
@@ -1863,6 +2274,12 @@
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -1882,6 +2299,51 @@
"dev": true,
"license": "ISC"
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/sodium-native": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-5.1.0.tgz",
@@ -1906,6 +2368,21 @@
"node": ">=0.10.0"
}
},
"node_modules/sql-escaper": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
"integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=2.0.0",
"node": ">=12.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -1931,6 +2408,52 @@
"text-decoder": "^1.1.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/teex": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
@@ -2021,6 +2544,18 @@
"fsevents": "~2.3.3"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2038,7 +2573,12 @@
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uuid": {
@@ -2238,6 +2778,12 @@
"node": ">=8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",

View File

@@ -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",
@@ -22,13 +24,16 @@
"license": "MIT",
"type": "commonjs",
"dependencies": {
"better-sqlite3": "^12.8.0",
"jsonwebtoken": "^9.0.3",
"mysql2": "^3.20.0",
"sodium-native": "^5.1.0",
"typescript": "^5.9.3",
"uuid": "^13.0.0",
"ws": "^8.19.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.5.0",
"@types/sodium-native": "^2.3.9",

View File

@@ -1,21 +1,59 @@
import path from "node:path";
import fs from "node:fs";
import { RelayServer } from "./relay/index.js";
import { loadDbConfig, createStores } from "./relay/db/index.js";
const port = parseInt(process.env.PORT ?? "8080", 10);
const host = process.env.HOST ?? "0.0.0.0";
const server = new RelayServer({ port, host });
async function main() {
// Load saved database config (if any)
const dbConfig = loadDbConfig();
let stores;
if (dbConfig) {
console.log(`Loading ${dbConfig.type} database...`);
stores = await createStores(dbConfig);
} else {
console.log("No database configured — starting with in-memory storage.");
console.log("Configure a database during setup to enable persistent storage.");
}
server.start().then(() => {
// 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...");
await server.stop();
process.exit(0);
});
process.on("SIGINT", async () => {
console.log("\nShutting down...");
await server.stop();
process.exit(0);
});
process.on("SIGTERM", async () => {
await server.stop();
process.exit(0);
process.on("SIGTERM", async () => {
await server.stop();
process.exit(0);
});
}
main().catch((err) => {
console.error("Failed to start server:", err);
process.exit(1);
});

View File

@@ -1,14 +1,15 @@
import http from "node:http";
import type { AdminStore } from "./auth.js";
import type { ConnectionManager } from "../connections.js";
import type { CookieBlobStore } from "../store.js";
import type { DeviceRegistry } from "../tokens.js";
import type { ICookieStore, IDeviceStore, IAdminStore, DbConfig } from "../db/types.js";
import { saveDbConfig, loadDbConfig, createStores } from "../db/index.js";
import type { RelayServer } from "../server.js";
export interface AdminDeps {
adminStore: AdminStore;
adminStore: IAdminStore;
connections: ConnectionManager;
cookieStore: CookieBlobStore;
deviceRegistry: DeviceRegistry;
cookieStore: ICookieStore;
deviceRegistry: IDeviceStore;
server: RelayServer;
}
/**
@@ -27,7 +28,12 @@ export function handleAdminRoute(
// --- Public routes (no auth) ---
if (method === "GET" && url === "/admin/setup/status") {
json(res, 200, { isSetUp: deps.adminStore.isSetUp });
const dbConfig = loadDbConfig();
json(res, 200, {
isSetUp: deps.adminStore.isSetUp,
dbConfigured: dbConfig !== null,
dbType: dbConfig?.type ?? null,
});
return true;
}
@@ -107,7 +113,7 @@ export function handleAdminRoute(
function authenticate(
req: http.IncomingMessage,
store: AdminStore,
store: IAdminStore,
): { sub: string; role: string } | null {
const auth = req.headers.authorization;
if (!auth?.startsWith("Bearer ")) return null;
@@ -127,11 +133,32 @@ function handleSetupInit(
): void {
readBody(req, async (body) => {
try {
const { username, password } = JSON.parse(body);
const { username, password, dbConfig } = JSON.parse(body) as {
username: string;
password: string;
dbConfig?: DbConfig;
};
if (!username || !password) {
json(res, 400, { error: "Missing username or password" });
return;
}
// If a database config is provided, initialize the database first
if (dbConfig) {
try {
const stores = await createStores(dbConfig);
saveDbConfig(dbConfig);
deps.server.replaceStores(stores);
// Update deps references to point to new stores
deps.adminStore = stores.adminStore;
deps.cookieStore = stores.cookieStore;
deps.deviceRegistry = stores.deviceStore;
} catch (err) {
json(res, 500, { error: `Database connection failed: ${err instanceof Error ? err.message : String(err)}` });
return;
}
}
if (deps.adminStore.isSetUp) {
json(res, 409, { error: "Already configured" });
return;
@@ -165,13 +192,13 @@ function handleLogin(
});
}
function handleDashboard(res: http.ServerResponse, deps: AdminDeps): void {
const devices = deps.deviceRegistry.listAll();
async function handleDashboard(res: http.ServerResponse, deps: AdminDeps): Promise<void> {
const devices = await deps.deviceRegistry.listAll();
const onlineDeviceIds = devices
.filter((d) => deps.connections.isOnline(d.deviceId))
.map((d) => d.deviceId);
const allCookies = deps.cookieStore.getAll();
const allCookies = await deps.cookieStore.getAll();
const domains = new Set(allCookies.map((c) => c.domain));
@@ -184,17 +211,17 @@ function handleDashboard(res: http.ServerResponse, deps: AdminDeps): void {
});
}
function handleCookieList(
async function handleCookieList(
req: http.IncomingMessage,
res: http.ServerResponse,
deps: AdminDeps,
): void {
): Promise<void> {
const parsed = new URL(req.url ?? "", `http://${req.headers.host}`);
// Check if this is a single cookie detail request: /admin/cookies/:id
const idMatch = parsed.pathname.match(/^\/admin\/cookies\/([a-f0-9]+)$/);
if (idMatch) {
const cookie = deps.cookieStore.getById(idMatch[1]);
const cookie = await deps.cookieStore.getById(idMatch[1]);
if (!cookie) {
json(res, 404, { error: "Cookie not found" });
return;
@@ -208,7 +235,7 @@ function handleCookieList(
const page = parseInt(parsed.searchParams.get("page") ?? "1", 10);
const limit = Math.min(parseInt(parsed.searchParams.get("limit") ?? "50", 10), 200);
let cookies = deps.cookieStore.getAll();
let cookies = await deps.cookieStore.getAll();
if (domain) {
cookies = cookies.filter((c) => c.domain === domain);
@@ -227,18 +254,18 @@ function handleCookieList(
json(res, 200, { items, total, page, limit });
}
function handleCookieDeleteById(
async function handleCookieDeleteById(
_req: http.IncomingMessage,
res: http.ServerResponse,
deps: AdminDeps,
): void {
): Promise<void> {
const parsed = new URL(_req.url ?? "", `http://${_req.headers.host}`);
const idMatch = parsed.pathname.match(/^\/admin\/cookies\/([a-f0-9]+)$/);
if (!idMatch) {
json(res, 400, { error: "Invalid cookie ID" });
return;
}
const deleted = deps.cookieStore.deleteById(idMatch[1]);
const deleted = await deps.cookieStore.deleteById(idMatch[1]);
json(res, 200, { deleted });
}
@@ -247,7 +274,7 @@ function handleCookieBatchDelete(
res: http.ServerResponse,
deps: AdminDeps,
): void {
readBody(req, (body) => {
readBody(req, async (body) => {
try {
const { ids } = JSON.parse(body) as { ids: string[] };
if (!ids || !Array.isArray(ids)) {
@@ -256,7 +283,7 @@ function handleCookieBatchDelete(
}
let count = 0;
for (const id of ids) {
if (deps.cookieStore.deleteById(id)) count++;
if (await deps.cookieStore.deleteById(id)) count++;
}
json(res, 200, { deleted: count });
} catch {
@@ -265,8 +292,8 @@ function handleCookieBatchDelete(
});
}
function handleDeviceList(res: http.ServerResponse, deps: AdminDeps): void {
const devices = deps.deviceRegistry.listAll().map((d) => ({
async function handleDeviceList(res: http.ServerResponse, deps: AdminDeps): Promise<void> {
const devices = (await deps.deviceRegistry.listAll()).map((d) => ({
deviceId: d.deviceId,
name: d.name,
platform: d.platform,
@@ -276,11 +303,11 @@ function handleDeviceList(res: http.ServerResponse, deps: AdminDeps): void {
json(res, 200, { devices });
}
function handleDeviceRevoke(
async function handleDeviceRevoke(
req: http.IncomingMessage,
res: http.ServerResponse,
deps: AdminDeps,
): void {
): Promise<void> {
const parsed = new URL(req.url ?? "", `http://${req.headers.host}`);
const match = parsed.pathname.match(/^\/admin\/devices\/([^/]+)\/revoke$/);
if (!match) {
@@ -288,7 +315,7 @@ function handleDeviceRevoke(
return;
}
const deviceId = match[1];
const revoked = deps.deviceRegistry.revoke(deviceId);
const revoked = await deps.deviceRegistry.revoke(deviceId);
if (revoked) {
deps.connections.disconnect(deviceId);
}

58
src/relay/db/index.ts Normal file
View File

@@ -0,0 +1,58 @@
import fs from "node:fs";
import path from "node:path";
import type { DataStores, DbConfig } from "./types.js";
import { createMemoryStores } from "./memory.js";
import { createSqliteStores } from "./sqlite.js";
import { createMysqlStores } from "./mysql.js";
export type { DataStores, DbConfig, DbType, SqliteConfig, MysqlConfig, ICookieStore, IDeviceStore, IAgentStore, IAdminStore } from "./types.js";
const DEFAULT_CONFIG_DIR = path.join(process.cwd(), "data");
const CONFIG_FILE = "db-config.json";
export function getConfigPath(): string {
return path.join(DEFAULT_CONFIG_DIR, CONFIG_FILE);
}
/** Load saved database config, or null if not yet configured. */
export function loadDbConfig(): DbConfig | null {
const configPath = getConfigPath();
if (!fs.existsSync(configPath)) return null;
try {
const raw = fs.readFileSync(configPath, "utf-8");
return JSON.parse(raw) as DbConfig;
} catch {
return null;
}
}
/** Save database config to disk. */
export function saveDbConfig(config: DbConfig): void {
const configPath = getConfigPath();
const dir = path.dirname(configPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
}
/** Create data stores from config. If no config exists, returns null (setup required). */
export async function createStores(config: DbConfig): Promise<DataStores> {
switch (config.type) {
case "sqlite": {
// Ensure the directory for the SQLite file exists
const dir = path.dirname(config.path);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
return createSqliteStores(config);
}
case "mysql":
return createMysqlStores(config);
default:
throw new Error(`Unknown database type: ${(config as DbConfig).type}`);
}
}
/** Create in-memory stores (for backwards compatibility / testing). */
export { createMemoryStores } from "./memory.js";

126
src/relay/db/memory.ts Normal file
View File

@@ -0,0 +1,126 @@
import { CookieBlobStore } from "../store.js";
import { DeviceRegistry, AgentRegistry } from "../tokens.js";
import { AdminStore } from "../admin/auth.js";
import type {
ICookieStore,
IDeviceStore,
IAgentStore,
IAdminStore,
DataStores,
} from "./types.js";
import type { EncryptedCookieBlob, DeviceInfo, AgentToken } from "../../protocol/spec.js";
import type { AdminSettings } from "../admin/auth.js";
class MemoryCookieStore implements ICookieStore {
private inner = new CookieBlobStore();
async upsert(blob: Omit<EncryptedCookieBlob, "id" | "updatedAt">): Promise<EncryptedCookieBlob> {
return this.inner.upsert(blob);
}
async delete(deviceId: string, domain: string, cookieName: string, path: string): Promise<boolean> {
return this.inner.delete(deviceId, domain, cookieName, path);
}
async deleteById(id: string): Promise<boolean> {
return this.inner.deleteById(id);
}
async getByDevice(deviceId: string, domain?: string): Promise<EncryptedCookieBlob[]> {
return this.inner.getByDevice(deviceId, domain);
}
async getByDevices(deviceIds: string[], domain?: string): Promise<EncryptedCookieBlob[]> {
return this.inner.getByDevices(deviceIds, domain);
}
async getAll(): Promise<EncryptedCookieBlob[]> {
return this.inner.getAll();
}
async getById(id: string): Promise<EncryptedCookieBlob | null> {
return this.inner.getById(id);
}
async getUpdatedSince(deviceIds: string[], since: string): Promise<EncryptedCookieBlob[]> {
return this.inner.getUpdatedSince(deviceIds, since);
}
}
class MemoryDeviceStore implements IDeviceStore {
private inner = new DeviceRegistry();
async register(deviceId: string, name: string, platform: string, encPub: string): Promise<DeviceInfo> {
return this.inner.register(deviceId, name, platform, encPub);
}
async getByToken(token: string): Promise<DeviceInfo | null> {
return this.inner.getByToken(token);
}
async getById(deviceId: string): Promise<DeviceInfo | null> {
return this.inner.getById(deviceId);
}
async addPairing(deviceIdA: string, deviceIdB: string): Promise<void> {
this.inner.addPairing(deviceIdA, deviceIdB);
}
async getPairedDevices(deviceId: string): Promise<string[]> {
return this.inner.getPairedDevices(deviceId);
}
async getPairingGroup(deviceId: string): Promise<string[]> {
return this.inner.getPairingGroup(deviceId);
}
async listAll(): Promise<DeviceInfo[]> {
return this.inner.listAll();
}
async revoke(deviceId: string): Promise<boolean> {
return this.inner.revoke(deviceId);
}
}
class MemoryAgentStore implements IAgentStore {
private inner = new AgentRegistry();
async create(name: string, encPub: string, allowedDomains: string[]): Promise<AgentToken> {
return this.inner.create(name, encPub, allowedDomains);
}
async getByToken(token: string): Promise<AgentToken | null> {
return this.inner.getByToken(token);
}
async grantAccess(agentId: string, deviceId: string): Promise<void> {
this.inner.grantAccess(agentId, deviceId);
}
async getAccessibleDevices(agentId: string): Promise<string[]> {
return this.inner.getAccessibleDevices(agentId);
}
async revokeAccess(agentId: string, deviceId: string): Promise<void> {
this.inner.revokeAccess(agentId, deviceId);
}
}
class MemoryAdminStore implements IAdminStore {
private inner = new AdminStore();
get isSetUp(): boolean {
return this.inner.isSetUp;
}
async setup(username: string, password: string): Promise<void> {
return this.inner.setup(username, password);
}
async login(username: string, password: string): Promise<string> {
return this.inner.login(username, password);
}
verifyToken(token: string): { sub: string; role: string } {
return this.inner.verifyToken(token);
}
getUser(): { username: string; createdAt: string } | null {
return this.inner.getUser();
}
getSettings(): AdminSettings {
return this.inner.getSettings();
}
updateSettings(patch: Partial<AdminSettings>): AdminSettings {
return this.inner.updateSettings(patch);
}
}
export function createMemoryStores(): DataStores {
return {
cookieStore: new MemoryCookieStore(),
deviceStore: new MemoryDeviceStore(),
agentStore: new MemoryAgentStore(),
adminStore: new MemoryAdminStore(),
async close() { /* no-op */ },
};
}

530
src/relay/db/mysql.ts Normal file
View File

@@ -0,0 +1,530 @@
import mysql from "mysql2/promise";
import crypto from "node:crypto";
import jwt from "jsonwebtoken";
import sodium from "sodium-native";
import type { EncryptedCookieBlob, DeviceInfo, AgentToken } from "../../protocol/spec.js";
import { MAX_STORED_COOKIES_PER_DEVICE } from "../../protocol/spec.js";
import type { AdminSettings } from "../admin/auth.js";
import type {
ICookieStore,
IDeviceStore,
IAgentStore,
IAdminStore,
DataStores,
MysqlConfig,
} from "./types.js";
const SCRYPT_KEYLEN = 64;
const SCRYPT_COST = 16384;
const SCRYPT_BLOCK_SIZE = 8;
const SCRYPT_PARALLELISM = 1;
const DEFAULT_SETTINGS: AdminSettings = {
syncIntervalMs: 30_000,
maxDevices: 10,
autoSync: true,
theme: "system",
};
function generateId(): string {
const buf = Buffer.alloc(16);
sodium.randombytes_buf(buf);
return buf.toString("hex");
}
function generateToken(): string {
const buf = Buffer.alloc(32);
sodium.randombytes_buf(buf);
return "cb_" + buf.toString("hex");
}
function hashPassword(password: string, salt: string): Promise<string> {
return new Promise((resolve, reject) => {
crypto.scrypt(
password,
Buffer.from(salt, "hex"),
SCRYPT_KEYLEN,
{ N: SCRYPT_COST, r: SCRYPT_BLOCK_SIZE, p: SCRYPT_PARALLELISM },
(err, derived) => {
if (err) reject(err);
else resolve(derived.toString("hex"));
},
);
});
}
async function initSchema(pool: mysql.Pool): Promise<void> {
await pool.execute(`
CREATE TABLE IF NOT EXISTS cookies (
id VARCHAR(64) PRIMARY KEY,
device_id VARCHAR(128) NOT NULL,
domain VARCHAR(255) NOT NULL,
cookie_name VARCHAR(255) NOT NULL,
path VARCHAR(512) NOT NULL,
nonce TEXT NOT NULL,
ciphertext LONGTEXT NOT NULL,
lamport_ts BIGINT NOT NULL,
updated_at VARCHAR(64) NOT NULL,
UNIQUE KEY uk_cookie (device_id, domain, cookie_name, path(191)),
INDEX idx_cookies_device (device_id),
INDEX idx_cookies_domain (domain),
INDEX idx_cookies_updated (updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`);
await pool.execute(`
CREATE TABLE IF NOT EXISTS devices (
device_id VARCHAR(128) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
platform VARCHAR(64) NOT NULL,
enc_pub VARCHAR(128) NOT NULL,
token VARCHAR(128) NOT NULL,
created_at VARCHAR(64) NOT NULL,
UNIQUE KEY uk_device_token (token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`);
await pool.execute(`
CREATE TABLE IF NOT EXISTS pairings (
device_id_a VARCHAR(128) NOT NULL,
device_id_b VARCHAR(128) NOT NULL,
PRIMARY KEY (device_id_a, device_id_b)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`);
await pool.execute(`
CREATE TABLE IF NOT EXISTS agents (
id VARCHAR(64) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
token VARCHAR(128) NOT NULL,
enc_pub VARCHAR(128) NOT NULL,
allowed_domains TEXT NOT NULL,
created_at VARCHAR(64) NOT NULL,
UNIQUE KEY uk_agent_token (token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`);
await pool.execute(`
CREATE TABLE IF NOT EXISTS agent_device_access (
agent_id VARCHAR(64) NOT NULL,
device_id VARCHAR(128) NOT NULL,
PRIMARY KEY (agent_id, device_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`);
await pool.execute(`
CREATE TABLE IF NOT EXISTS admin_users (
username VARCHAR(255) PRIMARY KEY,
password_hash VARCHAR(255) NOT NULL,
salt VARCHAR(64) NOT NULL,
created_at VARCHAR(64) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`);
await pool.execute(`
CREATE TABLE IF NOT EXISTS admin_settings (
\`key\` VARCHAR(64) PRIMARY KEY,
value TEXT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`);
await pool.execute(`
CREATE TABLE IF NOT EXISTS admin_meta (
\`key\` VARCHAR(64) PRIMARY KEY,
value TEXT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`);
}
function toBlob(row: Record<string, unknown>): EncryptedCookieBlob {
return {
id: row.id as string,
deviceId: row.device_id as string,
domain: row.domain as string,
cookieName: row.cookie_name as string,
path: row.path as string,
nonce: row.nonce as string,
ciphertext: row.ciphertext as string,
lamportTs: Number(row.lamport_ts),
updatedAt: row.updated_at as string,
};
}
function toDevice(row: Record<string, unknown>): DeviceInfo {
return {
deviceId: row.device_id as string,
name: row.name as string,
platform: row.platform as string,
encPub: row.enc_pub as string,
token: row.token as string,
createdAt: row.created_at as string,
};
}
function toAgent(row: Record<string, unknown>): AgentToken {
return {
id: row.id as string,
name: row.name as string,
token: row.token as string,
encPub: row.enc_pub as string,
allowedDomains: JSON.parse(row.allowed_domains as string),
createdAt: row.created_at as string,
};
}
// --- MySQL Cookie Store ---
class MysqlCookieStore implements ICookieStore {
constructor(private pool: mysql.Pool) {}
async upsert(blob: Omit<EncryptedCookieBlob, "id" | "updatedAt">): Promise<EncryptedCookieBlob> {
const [rows] = await this.pool.execute(
"SELECT * FROM cookies WHERE device_id = ? AND domain = ? AND cookie_name = ? AND path = ?",
[blob.deviceId, blob.domain, blob.cookieName, blob.path],
);
const existing = (rows as Record<string, unknown>[])[0];
if (existing && Number(existing.lamport_ts) >= blob.lamportTs) {
return toBlob(existing);
}
const id = existing ? existing.id as string : generateId();
const updatedAt = new Date().toISOString();
await this.pool.execute(
`INSERT INTO cookies (id, device_id, domain, cookie_name, path, nonce, ciphertext, lamport_ts, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
nonce = VALUES(nonce),
ciphertext = VALUES(ciphertext),
lamport_ts = VALUES(lamport_ts),
updated_at = VALUES(updated_at)`,
[id, blob.deviceId, blob.domain, blob.cookieName, blob.path, blob.nonce, blob.ciphertext, blob.lamportTs, updatedAt],
);
// Enforce per-device limit
const [countRows] = await this.pool.execute(
"SELECT COUNT(*) as cnt FROM cookies WHERE device_id = ?",
[blob.deviceId],
);
const count = Number((countRows as Record<string, unknown>[])[0].cnt);
if (count > MAX_STORED_COOKIES_PER_DEVICE) {
await this.pool.execute(
"DELETE FROM cookies WHERE id IN (SELECT id FROM (SELECT id FROM cookies WHERE device_id = ? ORDER BY updated_at ASC LIMIT ?) AS tmp)",
[blob.deviceId, count - MAX_STORED_COOKIES_PER_DEVICE],
);
}
return { ...blob, id, updatedAt };
}
async delete(deviceId: string, domain: string, cookieName: string, path: string): Promise<boolean> {
const [result] = await this.pool.execute(
"DELETE FROM cookies WHERE device_id = ? AND domain = ? AND cookie_name = ? AND path = ?",
[deviceId, domain, cookieName, path],
);
return (result as mysql.ResultSetHeader).affectedRows > 0;
}
async deleteById(id: string): Promise<boolean> {
const [result] = await this.pool.execute("DELETE FROM cookies WHERE id = ?", [id]);
return (result as mysql.ResultSetHeader).affectedRows > 0;
}
async getByDevice(deviceId: string, domain?: string): Promise<EncryptedCookieBlob[]> {
if (domain) {
const [rows] = await this.pool.execute(
"SELECT * FROM cookies WHERE device_id = ? AND domain = ?",
[deviceId, domain],
);
return (rows as Record<string, unknown>[]).map(toBlob);
}
const [rows] = await this.pool.execute("SELECT * FROM cookies WHERE device_id = ?", [deviceId]);
return (rows as Record<string, unknown>[]).map(toBlob);
}
async getByDevices(deviceIds: string[], domain?: string): Promise<EncryptedCookieBlob[]> {
if (deviceIds.length === 0) return [];
const placeholders = deviceIds.map(() => "?").join(",");
if (domain) {
const [rows] = await this.pool.execute(
`SELECT * FROM cookies WHERE device_id IN (${placeholders}) AND domain = ?`,
[...deviceIds, domain],
);
return (rows as Record<string, unknown>[]).map(toBlob);
}
const [rows] = await this.pool.execute(
`SELECT * FROM cookies WHERE device_id IN (${placeholders})`,
deviceIds,
);
return (rows as Record<string, unknown>[]).map(toBlob);
}
async getAll(): Promise<EncryptedCookieBlob[]> {
const [rows] = await this.pool.execute("SELECT * FROM cookies");
return (rows as Record<string, unknown>[]).map(toBlob);
}
async getById(id: string): Promise<EncryptedCookieBlob | null> {
const [rows] = await this.pool.execute("SELECT * FROM cookies WHERE id = ?", [id]);
const row = (rows as Record<string, unknown>[])[0];
return row ? toBlob(row) : null;
}
async getUpdatedSince(deviceIds: string[], since: string): Promise<EncryptedCookieBlob[]> {
if (deviceIds.length === 0) return [];
const placeholders = deviceIds.map(() => "?").join(",");
const [rows] = await this.pool.execute(
`SELECT * FROM cookies WHERE device_id IN (${placeholders}) AND updated_at > ?`,
[...deviceIds, since],
);
return (rows as Record<string, unknown>[]).map(toBlob);
}
}
// --- MySQL Device Store ---
class MysqlDeviceStore implements IDeviceStore {
constructor(private pool: mysql.Pool) {}
async register(deviceId: string, name: string, platform: string, encPub: string): Promise<DeviceInfo> {
const [rows] = await this.pool.execute("SELECT * FROM devices WHERE device_id = ?", [deviceId]);
const existing = (rows as Record<string, unknown>[])[0];
if (existing) return toDevice(existing);
const token = generateToken();
const createdAt = new Date().toISOString();
await this.pool.execute(
"INSERT INTO devices (device_id, name, platform, enc_pub, token, created_at) VALUES (?, ?, ?, ?, ?, ?)",
[deviceId, name, platform, encPub, token, createdAt],
);
return { deviceId, name, platform, encPub, token, createdAt };
}
async getByToken(token: string): Promise<DeviceInfo | null> {
const [rows] = await this.pool.execute("SELECT * FROM devices WHERE token = ?", [token]);
const row = (rows as Record<string, unknown>[])[0];
return row ? toDevice(row) : null;
}
async getById(deviceId: string): Promise<DeviceInfo | null> {
const [rows] = await this.pool.execute("SELECT * FROM devices WHERE device_id = ?", [deviceId]);
const row = (rows as Record<string, unknown>[])[0];
return row ? toDevice(row) : null;
}
async addPairing(deviceIdA: string, deviceIdB: string): Promise<void> {
await this.pool.execute(
"INSERT IGNORE INTO pairings (device_id_a, device_id_b) VALUES (?, ?)",
[deviceIdA, deviceIdB],
);
await this.pool.execute(
"INSERT IGNORE INTO pairings (device_id_a, device_id_b) VALUES (?, ?)",
[deviceIdB, deviceIdA],
);
}
async getPairedDevices(deviceId: string): Promise<string[]> {
const [rows] = await this.pool.execute(
"SELECT device_id_b FROM pairings WHERE device_id_a = ?",
[deviceId],
);
return (rows as { device_id_b: string }[]).map((r) => r.device_id_b);
}
async getPairingGroup(deviceId: string): Promise<string[]> {
const paired = await this.getPairedDevices(deviceId);
return [deviceId, ...paired];
}
async listAll(): Promise<DeviceInfo[]> {
const [rows] = await this.pool.execute("SELECT * FROM devices");
return (rows as Record<string, unknown>[]).map(toDevice);
}
async revoke(deviceId: string): Promise<boolean> {
const [result] = await this.pool.execute("DELETE FROM devices WHERE device_id = ?", [deviceId]);
if ((result as mysql.ResultSetHeader).affectedRows === 0) return false;
await this.pool.execute(
"DELETE FROM pairings WHERE device_id_a = ? OR device_id_b = ?",
[deviceId, deviceId],
);
return true;
}
}
// --- MySQL Agent Store ---
class MysqlAgentStore implements IAgentStore {
constructor(private pool: mysql.Pool) {}
async create(name: string, encPub: string, allowedDomains: string[]): Promise<AgentToken> {
const id = generateId();
const token = generateToken();
const createdAt = new Date().toISOString();
await this.pool.execute(
"INSERT INTO agents (id, name, token, enc_pub, allowed_domains, created_at) VALUES (?, ?, ?, ?, ?, ?)",
[id, name, token, encPub, JSON.stringify(allowedDomains), createdAt],
);
return { id, name, token, encPub, allowedDomains, createdAt };
}
async getByToken(token: string): Promise<AgentToken | null> {
const [rows] = await this.pool.execute("SELECT * FROM agents WHERE token = ?", [token]);
const row = (rows as Record<string, unknown>[])[0];
return row ? toAgent(row) : null;
}
async grantAccess(agentId: string, deviceId: string): Promise<void> {
await this.pool.execute(
"INSERT IGNORE INTO agent_device_access (agent_id, device_id) VALUES (?, ?)",
[agentId, deviceId],
);
}
async getAccessibleDevices(agentId: string): Promise<string[]> {
const [rows] = await this.pool.execute(
"SELECT device_id FROM agent_device_access WHERE agent_id = ?",
[agentId],
);
return (rows as { device_id: string }[]).map((r) => r.device_id);
}
async revokeAccess(agentId: string, deviceId: string): Promise<void> {
await this.pool.execute(
"DELETE FROM agent_device_access WHERE agent_id = ? AND device_id = ?",
[agentId, deviceId],
);
}
}
// --- MySQL Admin Store ---
class MysqlAdminStore implements IAdminStore {
private jwtSecret!: string;
private _settings: AdminSettings = { ...DEFAULT_SETTINGS };
private _isSetUp: boolean = false;
private _user: { username: string; createdAt: string } | null = null;
constructor(private pool: mysql.Pool) {}
async initialize(): Promise<void> {
// Load or generate JWT secret
const [metaRows] = await this.pool.execute(
"SELECT value FROM admin_meta WHERE `key` = 'jwt_secret'",
);
const meta = (metaRows as { value: string }[])[0];
if (meta) {
this.jwtSecret = meta.value;
} else {
this.jwtSecret = crypto.randomBytes(32).toString("hex");
await this.pool.execute(
"INSERT INTO admin_meta (`key`, value) VALUES ('jwt_secret', ?)",
[this.jwtSecret],
);
}
// Load settings
const [settingsRows] = await this.pool.execute(
"SELECT value FROM admin_settings WHERE `key` = 'settings'",
);
const settingsRow = (settingsRows as { value: string }[])[0];
if (settingsRow) {
Object.assign(this._settings, JSON.parse(settingsRow.value));
}
// Check setup status
const [countRows] = await this.pool.execute("SELECT COUNT(*) as cnt FROM admin_users");
this._isSetUp = Number((countRows as { cnt: number }[])[0].cnt) > 0;
if (this._isSetUp) {
const [userRows] = await this.pool.execute(
"SELECT username, created_at FROM admin_users LIMIT 1",
);
const userRow = (userRows as Record<string, unknown>[])[0];
if (userRow) {
this._user = { username: userRow.username as string, createdAt: userRow.created_at as string };
}
}
}
get isSetUp(): boolean {
return this._isSetUp;
}
async setup(username: string, password: string): Promise<void> {
if (this._isSetUp) throw new Error("Already configured");
const salt = crypto.randomBytes(16).toString("hex");
const hash = await hashPassword(password, salt);
const createdAt = new Date().toISOString();
await this.pool.execute(
"INSERT INTO admin_users (username, password_hash, salt, created_at) VALUES (?, ?, ?, ?)",
[username, hash, salt, createdAt],
);
this._isSetUp = true;
this._user = { username, createdAt };
}
async login(username: string, password: string): Promise<string> {
const [rows] = await this.pool.execute(
"SELECT * FROM admin_users WHERE username = ?",
[username],
);
const user = (rows as Record<string, unknown>[])[0];
if (!user) throw new Error("Invalid credentials");
const hash = await hashPassword(password, user.salt as string);
if (hash !== user.password_hash) throw new Error("Invalid credentials");
return jwt.sign({ sub: username, role: "admin" }, this.jwtSecret, { expiresIn: "24h" });
}
verifyToken(token: string): { sub: string; role: string } {
return jwt.verify(token, this.jwtSecret) as { sub: string; role: string };
}
getUser(): { username: string; createdAt: string } | null {
return this._user;
}
getSettings(): AdminSettings {
return { ...this._settings };
}
updateSettings(patch: Partial<AdminSettings>): AdminSettings {
Object.assign(this._settings, patch);
// Fire and forget the DB write
this.pool.execute(
"INSERT INTO admin_settings (`key`, value) VALUES ('settings', ?) ON DUPLICATE KEY UPDATE value = VALUES(value)",
[JSON.stringify(this._settings)],
);
return { ...this._settings };
}
}
// --- Factory ---
export async function createMysqlStores(config: MysqlConfig): Promise<DataStores> {
const pool = mysql.createPool({
host: config.host,
port: config.port,
user: config.user,
password: config.password,
database: config.database,
waitForConnections: true,
connectionLimit: 10,
});
await initSchema(pool);
const adminStore = new MysqlAdminStore(pool);
await adminStore.initialize();
return {
cookieStore: new MysqlCookieStore(pool),
deviceStore: new MysqlDeviceStore(pool),
agentStore: new MysqlAgentStore(pool),
adminStore,
async close() {
await pool.end();
},
};
}

431
src/relay/db/sqlite.ts Normal file
View File

@@ -0,0 +1,431 @@
import Database from "better-sqlite3";
import crypto from "node:crypto";
import jwt from "jsonwebtoken";
import sodium from "sodium-native";
import type { EncryptedCookieBlob, DeviceInfo, AgentToken } from "../../protocol/spec.js";
import { MAX_STORED_COOKIES_PER_DEVICE } from "../../protocol/spec.js";
import type { AdminSettings } from "../admin/auth.js";
import type {
ICookieStore,
IDeviceStore,
IAgentStore,
IAdminStore,
DataStores,
SqliteConfig,
} from "./types.js";
const SCRYPT_KEYLEN = 64;
const SCRYPT_COST = 16384;
const SCRYPT_BLOCK_SIZE = 8;
const SCRYPT_PARALLELISM = 1;
const DEFAULT_SETTINGS: AdminSettings = {
syncIntervalMs: 30_000,
maxDevices: 10,
autoSync: true,
theme: "system",
};
function generateId(): string {
const buf = Buffer.alloc(16);
sodium.randombytes_buf(buf);
return buf.toString("hex");
}
function generateToken(): string {
const buf = Buffer.alloc(32);
sodium.randombytes_buf(buf);
return "cb_" + buf.toString("hex");
}
function hashPassword(password: string, salt: string): Promise<string> {
return new Promise((resolve, reject) => {
crypto.scrypt(
password,
Buffer.from(salt, "hex"),
SCRYPT_KEYLEN,
{ N: SCRYPT_COST, r: SCRYPT_BLOCK_SIZE, p: SCRYPT_PARALLELISM },
(err, derived) => {
if (err) reject(err);
else resolve(derived.toString("hex"));
},
);
});
}
function initSchema(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS cookies (
id TEXT PRIMARY KEY,
device_id TEXT NOT NULL,
domain TEXT NOT NULL,
cookie_name TEXT NOT NULL,
path TEXT NOT NULL,
nonce TEXT NOT NULL,
ciphertext TEXT NOT NULL,
lamport_ts INTEGER NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(device_id, domain, cookie_name, path)
);
CREATE INDEX IF NOT EXISTS idx_cookies_device ON cookies(device_id);
CREATE INDEX IF NOT EXISTS idx_cookies_domain ON cookies(domain);
CREATE INDEX IF NOT EXISTS idx_cookies_updated ON cookies(updated_at);
CREATE TABLE IF NOT EXISTS devices (
device_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
platform TEXT NOT NULL,
enc_pub TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS pairings (
device_id_a TEXT NOT NULL,
device_id_b TEXT NOT NULL,
PRIMARY KEY (device_id_a, device_id_b)
);
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
enc_pub TEXT NOT NULL,
allowed_domains TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS agent_device_access (
agent_id TEXT NOT NULL,
device_id TEXT NOT NULL,
PRIMARY KEY (agent_id, device_id)
);
CREATE TABLE IF NOT EXISTS admin_users (
username TEXT PRIMARY KEY,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS admin_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS admin_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
}
function toBlob(row: Record<string, unknown>): EncryptedCookieBlob {
return {
id: row.id as string,
deviceId: row.device_id as string,
domain: row.domain as string,
cookieName: row.cookie_name as string,
path: row.path as string,
nonce: row.nonce as string,
ciphertext: row.ciphertext as string,
lamportTs: row.lamport_ts as number,
updatedAt: row.updated_at as string,
};
}
function toDevice(row: Record<string, unknown>): DeviceInfo {
return {
deviceId: row.device_id as string,
name: row.name as string,
platform: row.platform as string,
encPub: row.enc_pub as string,
token: row.token as string,
createdAt: row.created_at as string,
};
}
function toAgent(row: Record<string, unknown>): AgentToken {
return {
id: row.id as string,
name: row.name as string,
token: row.token as string,
encPub: row.enc_pub as string,
allowedDomains: JSON.parse(row.allowed_domains as string),
createdAt: row.created_at as string,
};
}
// --- SQLite Cookie Store ---
class SqliteCookieStore implements ICookieStore {
constructor(private db: Database.Database) {}
async upsert(blob: Omit<EncryptedCookieBlob, "id" | "updatedAt">): Promise<EncryptedCookieBlob> {
const existing = this.db.prepare(
"SELECT * FROM cookies WHERE device_id = ? AND domain = ? AND cookie_name = ? AND path = ?",
).get(blob.deviceId, blob.domain, blob.cookieName, blob.path) as Record<string, unknown> | undefined;
if (existing && (existing.lamport_ts as number) >= blob.lamportTs) {
return toBlob(existing);
}
const id = existing ? existing.id as string : generateId();
const updatedAt = new Date().toISOString();
this.db.prepare(`
INSERT INTO cookies (id, device_id, domain, cookie_name, path, nonce, ciphertext, lamport_ts, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(device_id, domain, cookie_name, path) DO UPDATE SET
nonce = excluded.nonce,
ciphertext = excluded.ciphertext,
lamport_ts = excluded.lamport_ts,
updated_at = excluded.updated_at
`).run(id, blob.deviceId, blob.domain, blob.cookieName, blob.path, blob.nonce, blob.ciphertext, blob.lamportTs, updatedAt);
// Enforce per-device limit
const count = (this.db.prepare("SELECT COUNT(*) as cnt FROM cookies WHERE device_id = ?").get(blob.deviceId) as { cnt: number }).cnt;
if (count > MAX_STORED_COOKIES_PER_DEVICE) {
this.db.prepare(
"DELETE FROM cookies WHERE id IN (SELECT id FROM cookies WHERE device_id = ? ORDER BY updated_at ASC LIMIT ?)",
).run(blob.deviceId, count - MAX_STORED_COOKIES_PER_DEVICE);
}
return { ...blob, id, updatedAt };
}
async delete(deviceId: string, domain: string, cookieName: string, path: string): Promise<boolean> {
const result = this.db.prepare(
"DELETE FROM cookies WHERE device_id = ? AND domain = ? AND cookie_name = ? AND path = ?",
).run(deviceId, domain, cookieName, path);
return result.changes > 0;
}
async deleteById(id: string): Promise<boolean> {
const result = this.db.prepare("DELETE FROM cookies WHERE id = ?").run(id);
return result.changes > 0;
}
async getByDevice(deviceId: string, domain?: string): Promise<EncryptedCookieBlob[]> {
if (domain) {
return (this.db.prepare("SELECT * FROM cookies WHERE device_id = ? AND domain = ?").all(deviceId, domain) as Record<string, unknown>[]).map(toBlob);
}
return (this.db.prepare("SELECT * FROM cookies WHERE device_id = ?").all(deviceId) as Record<string, unknown>[]).map(toBlob);
}
async getByDevices(deviceIds: string[], domain?: string): Promise<EncryptedCookieBlob[]> {
if (deviceIds.length === 0) return [];
const placeholders = deviceIds.map(() => "?").join(",");
if (domain) {
return (this.db.prepare(`SELECT * FROM cookies WHERE device_id IN (${placeholders}) AND domain = ?`).all(...deviceIds, domain) as Record<string, unknown>[]).map(toBlob);
}
return (this.db.prepare(`SELECT * FROM cookies WHERE device_id IN (${placeholders})`).all(...deviceIds) as Record<string, unknown>[]).map(toBlob);
}
async getAll(): Promise<EncryptedCookieBlob[]> {
return (this.db.prepare("SELECT * FROM cookies").all() as Record<string, unknown>[]).map(toBlob);
}
async getById(id: string): Promise<EncryptedCookieBlob | null> {
const row = this.db.prepare("SELECT * FROM cookies WHERE id = ?").get(id) as Record<string, unknown> | undefined;
return row ? toBlob(row) : null;
}
async getUpdatedSince(deviceIds: string[], since: string): Promise<EncryptedCookieBlob[]> {
if (deviceIds.length === 0) return [];
const placeholders = deviceIds.map(() => "?").join(",");
return (this.db.prepare(
`SELECT * FROM cookies WHERE device_id IN (${placeholders}) AND updated_at > ?`,
).all(...deviceIds, since) as Record<string, unknown>[]).map(toBlob);
}
}
// --- SQLite Device Store ---
class SqliteDeviceStore implements IDeviceStore {
constructor(private db: Database.Database) {}
async register(deviceId: string, name: string, platform: string, encPub: string): Promise<DeviceInfo> {
const existing = this.db.prepare("SELECT * FROM devices WHERE device_id = ?").get(deviceId) as Record<string, unknown> | undefined;
if (existing) return toDevice(existing);
const token = generateToken();
const createdAt = new Date().toISOString();
this.db.prepare(
"INSERT INTO devices (device_id, name, platform, enc_pub, token, created_at) VALUES (?, ?, ?, ?, ?, ?)",
).run(deviceId, name, platform, encPub, token, createdAt);
return { deviceId, name, platform, encPub, token, createdAt };
}
async getByToken(token: string): Promise<DeviceInfo | null> {
const row = this.db.prepare("SELECT * FROM devices WHERE token = ?").get(token) as Record<string, unknown> | undefined;
return row ? toDevice(row) : null;
}
async getById(deviceId: string): Promise<DeviceInfo | null> {
const row = this.db.prepare("SELECT * FROM devices WHERE device_id = ?").get(deviceId) as Record<string, unknown> | undefined;
return row ? toDevice(row) : null;
}
async addPairing(deviceIdA: string, deviceIdB: string): Promise<void> {
this.db.prepare(
"INSERT OR IGNORE INTO pairings (device_id_a, device_id_b) VALUES (?, ?)",
).run(deviceIdA, deviceIdB);
this.db.prepare(
"INSERT OR IGNORE INTO pairings (device_id_a, device_id_b) VALUES (?, ?)",
).run(deviceIdB, deviceIdA);
}
async getPairedDevices(deviceId: string): Promise<string[]> {
const rows = this.db.prepare(
"SELECT device_id_b FROM pairings WHERE device_id_a = ?",
).all(deviceId) as { device_id_b: string }[];
return rows.map((r) => r.device_id_b);
}
async getPairingGroup(deviceId: string): Promise<string[]> {
const paired = await this.getPairedDevices(deviceId);
return [deviceId, ...paired];
}
async listAll(): Promise<DeviceInfo[]> {
return (this.db.prepare("SELECT * FROM devices").all() as Record<string, unknown>[]).map(toDevice);
}
async revoke(deviceId: string): Promise<boolean> {
const result = this.db.prepare("DELETE FROM devices WHERE device_id = ?").run(deviceId);
if (result.changes === 0) return false;
this.db.prepare("DELETE FROM pairings WHERE device_id_a = ? OR device_id_b = ?").run(deviceId, deviceId);
return true;
}
}
// --- SQLite Agent Store ---
class SqliteAgentStore implements IAgentStore {
constructor(private db: Database.Database) {}
async create(name: string, encPub: string, allowedDomains: string[]): Promise<AgentToken> {
const id = generateId();
const token = generateToken();
const createdAt = new Date().toISOString();
this.db.prepare(
"INSERT INTO agents (id, name, token, enc_pub, allowed_domains, created_at) VALUES (?, ?, ?, ?, ?, ?)",
).run(id, name, token, encPub, JSON.stringify(allowedDomains), createdAt);
return { id, name, token, encPub, allowedDomains, createdAt };
}
async getByToken(token: string): Promise<AgentToken | null> {
const row = this.db.prepare("SELECT * FROM agents WHERE token = ?").get(token) as Record<string, unknown> | undefined;
return row ? toAgent(row) : null;
}
async grantAccess(agentId: string, deviceId: string): Promise<void> {
this.db.prepare(
"INSERT OR IGNORE INTO agent_device_access (agent_id, device_id) VALUES (?, ?)",
).run(agentId, deviceId);
}
async getAccessibleDevices(agentId: string): Promise<string[]> {
const rows = this.db.prepare(
"SELECT device_id FROM agent_device_access WHERE agent_id = ?",
).all(agentId) as { device_id: string }[];
return rows.map((r) => r.device_id);
}
async revokeAccess(agentId: string, deviceId: string): Promise<void> {
this.db.prepare(
"DELETE FROM agent_device_access WHERE agent_id = ? AND device_id = ?",
).run(agentId, deviceId);
}
}
// --- SQLite Admin Store ---
class SqliteAdminStore implements IAdminStore {
private jwtSecret: string;
private _settings: AdminSettings;
constructor(private db: Database.Database) {
// Load or generate JWT secret
const meta = this.db.prepare("SELECT value FROM admin_meta WHERE key = 'jwt_secret'").get() as { value: string } | undefined;
if (meta) {
this.jwtSecret = meta.value;
} else {
this.jwtSecret = crypto.randomBytes(32).toString("hex");
this.db.prepare("INSERT INTO admin_meta (key, value) VALUES ('jwt_secret', ?)").run(this.jwtSecret);
}
// Load settings
this._settings = { ...DEFAULT_SETTINGS };
const settingsRow = this.db.prepare("SELECT value FROM admin_settings WHERE key = 'settings'").get() as { value: string } | undefined;
if (settingsRow) {
Object.assign(this._settings, JSON.parse(settingsRow.value));
}
}
get isSetUp(): boolean {
const row = this.db.prepare("SELECT COUNT(*) as cnt FROM admin_users").get() as { cnt: number };
return row.cnt > 0;
}
async setup(username: string, password: string): Promise<void> {
if (this.isSetUp) throw new Error("Already configured");
const salt = crypto.randomBytes(16).toString("hex");
const hash = await hashPassword(password, salt);
this.db.prepare(
"INSERT INTO admin_users (username, password_hash, salt, created_at) VALUES (?, ?, ?, ?)",
).run(username, hash, salt, new Date().toISOString());
}
async login(username: string, password: string): Promise<string> {
const user = this.db.prepare("SELECT * FROM admin_users WHERE username = ?").get(username) as Record<string, unknown> | undefined;
if (!user) throw new Error("Invalid credentials");
const hash = await hashPassword(password, user.salt as string);
if (hash !== user.password_hash) throw new Error("Invalid credentials");
return jwt.sign({ sub: username, role: "admin" }, this.jwtSecret, { expiresIn: "24h" });
}
verifyToken(token: string): { sub: string; role: string } {
return jwt.verify(token, this.jwtSecret) as { sub: string; role: string };
}
getUser(): { username: string; createdAt: string } | null {
const row = this.db.prepare("SELECT username, created_at FROM admin_users LIMIT 1").get() as Record<string, unknown> | undefined;
if (!row) return null;
return { username: row.username as string, createdAt: row.created_at as string };
}
getSettings(): AdminSettings {
return { ...this._settings };
}
updateSettings(patch: Partial<AdminSettings>): AdminSettings {
Object.assign(this._settings, patch);
this.db.prepare(
"INSERT INTO admin_settings (key, value) VALUES ('settings', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
).run(JSON.stringify(this._settings));
return { ...this._settings };
}
}
// --- Factory ---
export function createSqliteStores(config: SqliteConfig): DataStores {
const db = new Database(config.path);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
initSchema(db);
return {
cookieStore: new SqliteCookieStore(db),
deviceStore: new SqliteDeviceStore(db),
agentStore: new SqliteAgentStore(db),
adminStore: new SqliteAdminStore(db),
async close() {
db.close();
},
};
}

72
src/relay/db/types.ts Normal file
View File

@@ -0,0 +1,72 @@
import type { EncryptedCookieBlob, DeviceInfo, AgentToken } from "../../protocol/spec.js";
import type { AdminSettings, AdminUser } from "../admin/auth.js";
// --- Database configuration ---
export type DbType = "sqlite" | "mysql";
export interface SqliteConfig {
type: "sqlite";
path: string; // file path, e.g. "./data/cookiebridge.db"
}
export interface MysqlConfig {
type: "mysql";
host: string;
port: number;
user: string;
password: string;
database: string;
}
export type DbConfig = SqliteConfig | MysqlConfig;
// --- Store interfaces ---
export interface ICookieStore {
upsert(blob: Omit<EncryptedCookieBlob, "id" | "updatedAt">): Promise<EncryptedCookieBlob>;
delete(deviceId: string, domain: string, cookieName: string, path: string): Promise<boolean>;
deleteById(id: string): Promise<boolean>;
getByDevice(deviceId: string, domain?: string): Promise<EncryptedCookieBlob[]>;
getByDevices(deviceIds: string[], domain?: string): Promise<EncryptedCookieBlob[]>;
getAll(): Promise<EncryptedCookieBlob[]>;
getById(id: string): Promise<EncryptedCookieBlob | null>;
getUpdatedSince(deviceIds: string[], since: string): Promise<EncryptedCookieBlob[]>;
}
export interface IDeviceStore {
register(deviceId: string, name: string, platform: string, encPub: string): Promise<DeviceInfo>;
getByToken(token: string): Promise<DeviceInfo | null>;
getById(deviceId: string): Promise<DeviceInfo | null>;
addPairing(deviceIdA: string, deviceIdB: string): Promise<void>;
getPairedDevices(deviceId: string): Promise<string[]>;
getPairingGroup(deviceId: string): Promise<string[]>;
listAll(): Promise<DeviceInfo[]>;
revoke(deviceId: string): Promise<boolean>;
}
export interface IAgentStore {
create(name: string, encPub: string, allowedDomains: string[]): Promise<AgentToken>;
getByToken(token: string): Promise<AgentToken | null>;
grantAccess(agentId: string, deviceId: string): Promise<void>;
getAccessibleDevices(agentId: string): Promise<string[]>;
revokeAccess(agentId: string, deviceId: string): Promise<void>;
}
export interface IAdminStore {
readonly isSetUp: boolean;
setup(username: string, password: string): Promise<void>;
login(username: string, password: string): Promise<string>;
verifyToken(token: string): { sub: string; role: string };
getUser(): { username: string; createdAt: string } | null;
getSettings(): AdminSettings;
updateSettings(patch: Partial<AdminSettings>): AdminSettings;
}
export interface DataStores {
cookieStore: ICookieStore;
deviceStore: IDeviceStore;
agentStore: IAgentStore;
adminStore: IAdminStore;
close(): Promise<void>;
}

View File

@@ -3,3 +3,5 @@ export type { RelayServerConfig } from "./server.js";
export { ConnectionManager } from "./connections.js";
export { CookieBlobStore } from "./store.js";
export { DeviceRegistry, AgentRegistry } from "./tokens.js";
export type { DataStores, DbConfig, DbType, ICookieStore, IDeviceStore, IAgentStore, IAdminStore } from "./db/index.js";
export { createStores, createMemoryStores, loadDbConfig, saveDbConfig } from "./db/index.js";

View File

@@ -1,23 +1,27 @@
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";
import { PairingStore } from "../pairing/pairing.js";
import { CookieBlobStore } from "./store.js";
import { DeviceRegistry, AgentRegistry } from "./tokens.js";
import {
type Envelope,
type EncryptedCookieBlob,
MESSAGE_TYPES,
PING_INTERVAL_MS,
} from "../protocol/spec.js";
import { AdminStore } from "./admin/auth.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";
export interface RelayServerConfig {
port: number;
host?: string;
stores?: DataStores;
/** Directory containing pre-built frontend assets. Serves static files + SPA fallback. */
publicDir?: string;
}
interface PendingAuth {
@@ -49,10 +53,11 @@ export class RelayServer {
private wss: WebSocketServer;
readonly connections: ConnectionManager;
readonly pairingStore: PairingStore;
readonly cookieStore: CookieBlobStore;
readonly deviceRegistry: DeviceRegistry;
readonly agentRegistry: AgentRegistry;
readonly adminStore: AdminStore;
cookieStore: ICookieStore;
deviceRegistry: IDeviceStore;
agentRegistry: IAgentStore;
adminStore: IAdminStore;
private stores: DataStores;
private pendingAuths = new Map<WebSocket, PendingAuth>();
private authenticatedDevices = new Map<WebSocket, string>();
private pingIntervals = new Map<WebSocket, ReturnType<typeof setInterval>>();
@@ -60,16 +65,27 @@ export class RelayServer {
constructor(private config: RelayServerConfig) {
this.connections = new ConnectionManager();
this.pairingStore = new PairingStore();
this.cookieStore = new CookieBlobStore();
this.deviceRegistry = new DeviceRegistry();
this.agentRegistry = new AgentRegistry();
this.adminStore = new AdminStore();
this.stores = config.stores ?? createMemoryStores();
this.cookieStore = this.stores.cookieStore;
this.deviceRegistry = this.stores.deviceStore;
this.agentRegistry = this.stores.agentStore;
this.adminStore = this.stores.adminStore;
this.httpServer = http.createServer(this.handleHttp.bind(this));
this.wss = new WebSocketServer({ server: this.httpServer });
this.wss.on("connection", this.handleConnection.bind(this));
}
/** Replace the data stores at runtime (used during setup when DB is first configured). */
replaceStores(stores: DataStores): void {
this.stores = stores;
this.cookieStore = stores.cookieStore;
this.deviceRegistry = stores.deviceStore;
this.agentRegistry = stores.agentStore;
this.adminStore = stores.adminStore;
}
start(): Promise<void> {
return new Promise((resolve) => {
this.httpServer.listen(
@@ -80,15 +96,16 @@ export class RelayServer {
});
}
stop(): Promise<void> {
return new Promise((resolve) => {
for (const interval of this.pingIntervals.values()) {
clearInterval(interval);
}
async stop(): Promise<void> {
for (const interval of this.pingIntervals.values()) {
clearInterval(interval);
}
await new Promise<void>((resolve) => {
this.wss.close(() => {
this.httpServer.close(() => resolve());
});
});
await this.stores.close();
}
get port(): number {
@@ -110,6 +127,7 @@ export class RelayServer {
connections: this.connections,
cookieStore: this.cookieStore,
deviceRegistry: this.deviceRegistry,
server: this,
});
return;
}
@@ -168,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");
}
@@ -184,12 +207,13 @@ export class RelayServer {
this.json(res, 401, { error: "Missing Authorization header" });
return;
}
const device = this.deviceRegistry.getByToken(token);
if (!device) {
this.json(res, 401, { error: "Invalid token" });
return;
}
handler({ deviceId: device.deviceId });
this.deviceRegistry.getByToken(token).then((device) => {
if (!device) {
this.json(res, 401, { error: "Invalid token" });
return;
}
handler({ deviceId: device.deviceId });
});
}
private extractBearerToken(req: http.IncomingMessage): string | null {
@@ -201,14 +225,14 @@ export class RelayServer {
// --- Device Registration ---
private handleDeviceRegister(req: http.IncomingMessage, res: http.ServerResponse): void {
this.readBody(req, (body) => {
this.readBody(req, async (body) => {
try {
const { deviceId, name, platform, encPub } = JSON.parse(body);
if (!deviceId || !name || !platform || !encPub) {
this.json(res, 400, { error: "Missing required fields: deviceId, name, platform, encPub" });
return;
}
const info = this.deviceRegistry.register(deviceId, name, platform, encPub);
const info = await this.deviceRegistry.register(deviceId, name, platform, encPub);
this.json(res, 201, {
deviceId: info.deviceId,
token: info.token,
@@ -239,7 +263,7 @@ export class RelayServer {
}
private handlePairAccept(req: http.IncomingMessage, res: http.ServerResponse): void {
this.readBody(req, (body) => {
this.readBody(req, async (body) => {
try {
const { deviceId, x25519PubKey, pairingCode } = JSON.parse(body);
if (!deviceId || !x25519PubKey || !pairingCode) {
@@ -253,7 +277,7 @@ export class RelayServer {
}
// Record the pairing in device registry
this.deviceRegistry.addPairing(session.deviceId, deviceId);
await this.deviceRegistry.addPairing(session.deviceId, deviceId);
this.json(res, 200, {
initiator: { deviceId: session.deviceId, x25519PubKey: session.x25519PubKey },
@@ -272,7 +296,7 @@ export class RelayServer {
res: http.ServerResponse,
device: { deviceId: string },
): void {
this.readBody(req, (body) => {
this.readBody(req, async (body) => {
try {
const { cookies } = JSON.parse(body) as {
cookies: Array<Omit<EncryptedCookieBlob, "id" | "updatedAt" | "deviceId">>;
@@ -282,12 +306,13 @@ export class RelayServer {
return;
}
const stored = cookies.map((c) =>
this.cookieStore.upsert({ ...c, deviceId: device.deviceId }),
);
const stored: EncryptedCookieBlob[] = [];
for (const c of cookies) {
stored.push(await this.cookieStore.upsert({ ...c, deviceId: device.deviceId }));
}
// Notify paired devices via WebSocket if connected
const pairedDevices = this.deviceRegistry.getPairedDevices(device.deviceId);
const pairedDevices = await this.deviceRegistry.getPairedDevices(device.deviceId);
for (const peerId of pairedDevices) {
if (this.connections.isOnline(peerId)) {
this.connections.send(peerId, {
@@ -309,26 +334,26 @@ export class RelayServer {
});
}
private handleCookiePull(
private async handleCookiePull(
_req: http.IncomingMessage,
res: http.ServerResponse,
device: { deviceId: string },
): void {
): Promise<void> {
const url = new URL(_req.url ?? "", `http://${_req.headers.host}`);
const domain = url.searchParams.get("domain") ?? undefined;
// Get cookies from all paired devices
const group = this.deviceRegistry.getPairingGroup(device.deviceId);
const blobs = this.cookieStore.getByDevices(group, domain);
const group = await this.deviceRegistry.getPairingGroup(device.deviceId);
const blobs = await this.cookieStore.getByDevices(group, domain);
this.json(res, 200, { cookies: blobs });
}
private handleCookiePoll(
private async handleCookiePoll(
_req: http.IncomingMessage,
res: http.ServerResponse,
device: { deviceId: string },
): void {
): Promise<void> {
const url = new URL(_req.url ?? "", `http://${_req.headers.host}`);
const since = url.searchParams.get("since");
if (!since) {
@@ -336,8 +361,8 @@ export class RelayServer {
return;
}
const group = this.deviceRegistry.getPairingGroup(device.deviceId);
const blobs = this.cookieStore.getUpdatedSince(group, since);
const group = await this.deviceRegistry.getPairingGroup(device.deviceId);
const blobs = await this.cookieStore.getUpdatedSince(group, since);
this.json(res, 200, { cookies: blobs, serverTime: new Date().toISOString() });
}
@@ -347,14 +372,14 @@ export class RelayServer {
res: http.ServerResponse,
device: { deviceId: string },
): void {
this.readBody(req, (body) => {
this.readBody(req, async (body) => {
try {
const { domain, cookieName, path } = JSON.parse(body);
if (!domain || !cookieName || !path) {
this.json(res, 400, { error: "Missing domain, cookieName, or path" });
return;
}
const deleted = this.cookieStore.delete(device.deviceId, domain, cookieName, path);
const deleted = await this.cookieStore.delete(device.deviceId, domain, cookieName, path);
this.json(res, 200, { deleted });
} catch {
this.json(res, 400, { error: "Invalid JSON" });
@@ -369,22 +394,22 @@ export class RelayServer {
res: http.ServerResponse,
_device: { deviceId: string },
): void {
this.readBody(req, (body) => {
this.readBody(req, async (body) => {
try {
const { name, encPub, allowedDomains } = JSON.parse(body);
if (!name || !encPub) {
this.json(res, 400, { error: "Missing name or encPub" });
return;
}
const agent = this.agentRegistry.create(name, encPub, allowedDomains ?? []);
const agent = await this.agentRegistry.create(name, encPub, allowedDomains ?? []);
// Automatically grant the creating device's access
this.agentRegistry.grantAccess(agent.id, _device.deviceId);
await this.agentRegistry.grantAccess(agent.id, _device.deviceId);
// Also grant access to all paired devices
const paired = this.deviceRegistry.getPairedDevices(_device.deviceId);
const paired = await this.deviceRegistry.getPairedDevices(_device.deviceId);
for (const peerId of paired) {
this.agentRegistry.grantAccess(agent.id, peerId);
await this.agentRegistry.grantAccess(agent.id, peerId);
}
this.json(res, 201, { id: agent.id, token: agent.token, name: agent.name });
@@ -399,14 +424,14 @@ export class RelayServer {
res: http.ServerResponse,
device: { deviceId: string },
): void {
this.readBody(req, (body) => {
this.readBody(req, async (body) => {
try {
const { agentId } = JSON.parse(body);
if (!agentId) {
this.json(res, 400, { error: "Missing agentId" });
return;
}
this.agentRegistry.grantAccess(agentId, device.deviceId);
await this.agentRegistry.grantAccess(agentId, device.deviceId);
this.json(res, 200, { granted: true });
} catch {
this.json(res, 400, { error: "Invalid JSON" });
@@ -414,13 +439,13 @@ export class RelayServer {
});
}
private handleAgentCookies(req: http.IncomingMessage, res: http.ServerResponse): void {
private async handleAgentCookies(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
const token = this.extractBearerToken(req);
if (!token) {
this.json(res, 401, { error: "Missing Authorization header" });
return;
}
const agent = this.agentRegistry.getByToken(token);
const agent = await this.agentRegistry.getByToken(token);
if (!agent) {
this.json(res, 401, { error: "Invalid agent token" });
return;
@@ -435,8 +460,8 @@ export class RelayServer {
return;
}
const deviceIds = this.agentRegistry.getAccessibleDevices(agent.id);
const blobs = this.cookieStore.getByDevices(deviceIds, domain);
const deviceIds = await this.agentRegistry.getAccessibleDevices(agent.id);
const blobs = await this.cookieStore.getByDevices(deviceIds, domain);
this.json(res, 200, { cookies: blobs, agentEncPub: agent.encPub });
}
@@ -510,23 +535,24 @@ export class RelayServer {
return;
}
const device = this.deviceRegistry.getByToken(token);
if (!device) {
ws.close(4003, "Invalid token");
return;
}
this.pendingAuths.delete(ws);
this.authenticatedDevices.set(ws, device.deviceId);
this.connections.register(device.deviceId, ws);
ws.send(JSON.stringify({ type: "auth_ok", deviceId: device.deviceId }));
const interval = setInterval(() => {
if (ws.readyState === 1) {
ws.send(JSON.stringify({ type: MESSAGE_TYPES.PING }));
this.deviceRegistry.getByToken(token).then((device) => {
if (!device) {
ws.close(4003, "Invalid token");
return;
}
}, PING_INTERVAL_MS);
this.pingIntervals.set(ws, interval);
this.pendingAuths.delete(ws);
this.authenticatedDevices.set(ws, device.deviceId);
this.connections.register(device.deviceId, ws);
ws.send(JSON.stringify({ type: "auth_ok", deviceId: device.deviceId }));
const interval = setInterval(() => {
if (ws.readyState === 1) {
ws.send(JSON.stringify({ type: MESSAGE_TYPES.PING }));
}
}, PING_INTERVAL_MS);
this.pingIntervals.set(ws, interval);
});
}
private handleAuthResponse(ws: WebSocket, msg: Record<string, unknown>): void {

78
src/relay/static.ts Normal file
View File

@@ -0,0 +1,78 @@
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
const MIME_TYPES: Record<string, string> = {
".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;
}
}

View File

@@ -4,5 +4,6 @@ export default defineConfig({
test: {
globals: true,
testTimeout: 15_000,
exclude: ["node_modules", "dist", "web", "extension"],
},
});

View File

@@ -7,14 +7,24 @@ import { markSetupComplete } from "@/router";
const router = useRouter();
const step = ref(1);
const totalSteps = 4;
const totalSteps = 5;
// Step 2: Admin account
// Step 2: Database config
type DbType = "sqlite" | "mysql";
const dbType = ref<DbType>("sqlite");
const sqlitePath = ref("./data/cookiebridge.db");
const mysqlHost = ref("localhost");
const mysqlPort = ref(3306);
const mysqlUser = ref("root");
const mysqlPassword = ref("");
const mysqlDatabase = ref("cookiebridge");
// Step 3: Admin account
const username = ref("");
const password = ref("");
const confirmPassword = ref("");
// Step 3: Basic config
// Step 4: Basic config
const listenPort = ref(8100);
const enableHttps = ref(false);
@@ -25,16 +35,28 @@ const passwordMismatch = computed(
() => confirmPassword.value.length > 0 && password.value !== confirmPassword.value,
);
const canProceedStep2 = computed(
const canProceedStep3 = computed(
() =>
username.value.length >= 3 &&
password.value.length >= 8 &&
password.value === confirmPassword.value,
);
const canProceedStep2 = computed(() => {
if (dbType.value === "sqlite") {
return sqlitePath.value.length > 0;
}
return (
mysqlHost.value.length > 0 &&
mysqlPort.value > 0 &&
mysqlUser.value.length > 0 &&
mysqlDatabase.value.length > 0
);
});
function nextStep() {
error.value = "";
if (step.value === 2 && passwordMismatch.value) {
if (step.value === 3 && passwordMismatch.value) {
error.value = "Passwords do not match";
return;
}
@@ -46,6 +68,20 @@ function prevStep() {
step.value = Math.max(step.value - 1, 1);
}
function buildDbConfig() {
if (dbType.value === "sqlite") {
return { type: "sqlite" as const, path: sqlitePath.value };
}
return {
type: "mysql" as const,
host: mysqlHost.value,
port: mysqlPort.value,
user: mysqlUser.value,
password: mysqlPassword.value,
database: mysqlDatabase.value,
};
}
async function completeSetup() {
error.value = "";
loading.value = true;
@@ -53,11 +89,13 @@ async function completeSetup() {
await api.post("/setup/init", {
username: username.value,
password: password.value,
dbConfig: buildDbConfig(),
});
markSetupComplete();
step.value = totalSteps;
} catch {
error.value = "Setup failed. Please try again.";
} catch (e: unknown) {
const axiosError = e as { response?: { data?: { error?: string } } };
error.value = axiosError.response?.data?.error ?? "Setup failed. Please try again.";
} finally {
loading.value = false;
}
@@ -110,8 +148,143 @@ function goToLogin() {
</button>
</div>
<!-- Step 2: Admin account -->
<!-- Step 2: Database selection -->
<div v-if="step === 2">
<h2 class="text-xl font-semibold text-gray-900">Database Configuration</h2>
<p class="mt-1 text-sm text-gray-500">Choose how to store your data</p>
<div class="mt-5 space-y-4">
<!-- Database type selection -->
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class="rounded-lg border-2 px-4 py-3 text-left transition-colors"
:class="
dbType === 'sqlite'
? 'border-blue-600 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
"
@click="dbType = 'sqlite'"
>
<p class="text-sm font-medium text-gray-900">SQLite</p>
<p class="mt-0.5 text-xs text-gray-500">Simple, no setup required</p>
</button>
<button
type="button"
class="rounded-lg border-2 px-4 py-3 text-left transition-colors"
:class="
dbType === 'mysql'
? 'border-blue-600 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
"
@click="dbType = 'mysql'"
>
<p class="text-sm font-medium text-gray-900">MySQL</p>
<p class="mt-0.5 text-xs text-gray-500">For production deployments</p>
</button>
</div>
<!-- SQLite config -->
<div v-if="dbType === 'sqlite'">
<label class="block text-sm font-medium text-gray-700" for="sqlite-path">
Database File Path
</label>
<input
id="sqlite-path"
v-model="sqlitePath"
type="text"
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="./data/cookiebridge.db"
/>
<p class="mt-1 text-xs text-gray-500">
File will be created automatically. Relative paths are from the server directory.
</p>
</div>
<!-- MySQL config -->
<div v-if="dbType === 'mysql'" class="space-y-3">
<div class="grid grid-cols-3 gap-3">
<div class="col-span-2">
<label class="block text-sm font-medium text-gray-700" for="mysql-host">Host</label>
<input
id="mysql-host"
v-model="mysqlHost"
type="text"
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="localhost"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700" for="mysql-port">Port</label>
<input
id="mysql-port"
v-model.number="mysqlPort"
type="number"
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="3306"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700" for="mysql-user">Username</label>
<input
id="mysql-user"
v-model="mysqlUser"
type="text"
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="root"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700" for="mysql-password">
Password
</label>
<input
id="mysql-password"
v-model="mysqlPassword"
type="password"
autocomplete="off"
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Optional"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700" for="mysql-database">
Database Name
</label>
<input
id="mysql-database"
v-model="mysqlDatabase"
type="text"
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="cookiebridge"
/>
</div>
</div>
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
<div class="flex gap-3 pt-2">
<button
type="button"
class="flex-1 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
@click="prevStep"
>
Back
</button>
<button
:disabled="!canProceedStep2"
class="flex-1 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
@click="nextStep"
>
Next
</button>
</div>
</div>
</div>
<!-- Step 3: Admin account -->
<div v-if="step === 3">
<h2 class="text-xl font-semibold text-gray-900">Create Admin Account</h2>
<p class="mt-1 text-sm text-gray-500">Set up your administrator credentials</p>
@@ -178,7 +351,7 @@ function goToLogin() {
</button>
<button
type="submit"
:disabled="!canProceedStep2"
:disabled="!canProceedStep3"
class="flex-1 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
Next
@@ -187,8 +360,8 @@ function goToLogin() {
</form>
</div>
<!-- Step 3: Basic config -->
<div v-if="step === 3">
<!-- Step 4: Basic config -->
<div v-if="step === 4">
<h2 class="text-xl font-semibold text-gray-900">Basic Configuration</h2>
<p class="mt-1 text-sm text-gray-500">Configure your relay server</p>
@@ -241,14 +414,14 @@ function goToLogin() {
class="flex-1 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
@click="completeSetup"
>
{{ loading ? "Setting up..." : "Next" }}
{{ loading ? "Setting up..." : "Complete Setup" }}
</button>
</div>
</div>
</div>
<!-- Step 4: Done -->
<div v-if="step === 4" class="text-center">
<!-- Step 5: Done -->
<div v-if="step === 5" class="text-center">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<span class="text-2xl text-green-600">&#10003;</span>
</div>
@@ -256,6 +429,9 @@ function goToLogin() {
<p class="mt-2 text-sm text-gray-500">
Your CookieBridge server is ready. Sign in with your admin credentials.
</p>
<p class="mt-1 text-xs text-gray-400">
Database: {{ dbType === 'sqlite' ? 'SQLite' : 'MySQL' }}
</p>
<button
class="mt-6 w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-blue-700"
@click="goToLogin"