Compare commits

...

12 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
徐枫
1420c4ecfa fix: make all 112 Playwright E2E tests pass (RCA-19)
Some checks failed
CI / test (22) (push) Has been cancelled
CI / docker (push) Has been cancelled
CI / extension (push) Has been cancelled
- Fix mock-api data shapes to match actual Vue component interfaces
- Replace HeadlessUI TransitionRoot with v-if in SetupView (unmount fix)
- Restructure CookiesView to detail-replaces-list pattern (strict mode)
- Add ARIA attributes for Playwright selectors (role=switch, aria-label)
- Fix 401 interceptor to skip login endpoint redirects
- Add confirmation dialogs, error states, and missing UI fields
- Rename conflicting button/label text to avoid strict mode violations

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-18 02:52:57 +08:00
徐枫
6504d3c7b9 fix: resolve 6 QA bugs in frontend admin panel (RCA-19)
Bug 1: Dashboard child route path "" → "dashboard" + redirect from /
Bug 2: Test localStorage key "admin_token" → "cb_admin_token"
Bug 3: Router setup check data.isSetUp → data.initialised
Bug 4: Setup wizard button text to match test selectors
Bug 5: loginViaAPI helper sets localStorage directly instead of hitting relay
Bug 6: Login button disabled when fields are empty

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-18 01:47:21 +08:00
徐枫
147f9d4761 feat: enhance all frontend management views
Dashboard (RCA-15):
- Stats cards: server status, devices (online/offline breakdown), cookies
  by domain count, WebSocket connections
- Device status table with online badges
- Quick action cards linking to cookies, devices, settings

Cookies (RCA-16):
- Domain-grouped collapsible list with expand/collapse
- Search by domain or cookie name
- Right-side detail panel showing all cookie fields (Headless UI transition)
- Checkbox selection + batch delete
- Per-cookie inline delete

Devices (RCA-17):
- Card grid with platform icons (CH/FF/ED/SA), online/offline badges
- Status filter tabs (All/Online/Offline)
- Expandable details (full device ID, platform, registration date)
- Two-step revoke confirmation dialog inline

Settings (RCA-18):
- Headless UI Tab component with 3 tabs: Sync, Security, Appearance
- Sync: auto-sync toggle, frequency selector (real-time/1m/5m/manual)
- Security: change password form, max devices
- Appearance: theme picker (light/dark/system), language selector
- Save with success toast notification

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-17 20:35:45 +08:00
徐枫
1a6d61ec36 feat: add setup wizard and enhance login flow
- Add /setup route with 4-step wizard: welcome, admin account creation,
  basic config (port, HTTPS), completion
- Router auto-detects first-time setup via GET /admin/setup/status
- Redirects to /setup if not configured, blocks /setup after init
- Uses Headless UI TransitionRoot for step animations
- Password confirmation with mismatch validation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-17 20:31:34 +08:00
徐枫
a320f7ad97 feat: add admin REST API layer for frontend management panel
Implement JWT-protected /admin/* routes on the relay server:
- Auth: login, logout, me, setup/status, setup/init (first-time config)
- Dashboard: stats overview (connections, devices, cookies, domains)
- Cookies: paginated list with domain/search filter, detail, delete, batch delete
- Devices: list with online status, revoke
- Settings: get/update (sync interval, max devices, theme)

Uses scrypt for password hashing and jsonwebtoken for JWT.
Adds listAll/revoke to DeviceRegistry, getAll/getById/deleteById to CookieBlobStore,
disconnect to ConnectionManager. Updates frontend to use /admin/* endpoints.

All 38 existing tests pass.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-17 20:28:56 +08:00
徐枫
f4144c96f1 test(web): add Playwright E2E and admin API test suite for RCA-19
Prepares the full QA test infrastructure for the admin frontend before
all prerequisite feature tasks (RCA-12–18) are complete.

- playwright.config.ts: 6 browser/device projects (Chromium, Firefox,
  WebKit, mobile Chrome, mobile Safari, tablet)
- tests/e2e/01-login.spec.ts: login form, route guards, setup wizard
- tests/e2e/02-dashboard.spec.ts: stats cards, device list, quick actions
- tests/e2e/03-cookies.spec.ts: cookie list, search, detail panel, delete
- tests/e2e/04-devices.spec.ts: device cards, revoke flow, status filter
- tests/e2e/05-settings.spec.ts: three-tab layout, save/error toasts
- tests/e2e/06-responsive.spec.ts: no horizontal scroll on mobile/tablet
- tests/api/admin-api.spec.ts: REST API contract tests for all /admin/* endpoints
- helpers/auth.ts: loginViaUI + loginViaAPI helpers
- helpers/mock-api.ts: route intercept fixtures for all pages

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-17 20:24:22 +08:00
徐枫
e3a9d9f63c feat: scaffold Vue 3 + TypeScript + Vite frontend admin panel
Set up web/ directory with complete frontend scaffolding:
- Vue 3 + TypeScript + Vite with Tailwind CSS v4
- Vue Router with auth guard (redirects to /login when unauthenticated)
- Pinia stores: auth, cookies, devices, settings
- Axios HTTP client with token interceptor
- Views: Login, Dashboard, Cookies, Devices, Settings
- Vite dev server proxy to relay API on port 8100
- Headless UI and Heroicons dependencies

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-17 20:22:35 +08:00
徐枫
b6fbf7a921 feat: implement M4 self-hosting, Docker & documentation
Some checks failed
CI / test (22) (push) Has been cancelled
CI / docker (push) Has been cancelled
CI / extension (push) Has been cancelled
- Dockerfile with multi-stage build (Node 22 Alpine, sodium-native)
- docker-compose.yml with health check for easy self-hosting
- README with setup guide, API reference, and project overview
- Architecture docs (data flow, component breakdown, protocol constants)
- Security model docs (threat model, crypto primitives, self-hosting checklist)
- GitHub Actions CI pipeline (test, typecheck, Docker smoke test, extension builds)
- GitHub Actions release pipeline (GHCR push, extension zip artifacts)
- CONTRIBUTING.md with dev setup and code style guidelines

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-17 18:34:53 +08:00
徐枫
f39ff8c215 feat: implement M3 multi-browser support (Firefox, Edge, Safari)
Add browser abstraction layer (compat.ts) that normalizes Chrome/Firefox/
Edge/Safari extension APIs behind a unified promise-based interface.
Replace all direct chrome.* calls with compat layer across service worker,
sync engine, badge, storage, popup, and options modules.

- Browser-specific manifests in manifests/ (Firefox MV3 with gecko settings,
  Edge/Safari variants)
- Multi-target build system: `npm run build` produces all four browser
  builds in build/<browser>/
- Per-browser build scripts: build:chrome, build:firefox, build:edge, build:safari
- Auto-detects browser at runtime for platform-specific device registration
- All 38 existing tests pass, typecheck clean

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-17 16:58:44 +08:00
徐枫
dc3be4d73f feat: implement M2 Chrome browser extension
Build the CookieBridge Chrome extension (Manifest V3) with:

- Background service worker: cookie monitoring via chrome.cookies.onChanged,
  WebSocket connection to relay server with auto-reconnect, HTTP polling
  fallback, device registration and pairing flow
- Browser-compatible crypto: libsodium-wrappers-sumo for XChaCha20-Poly1305
  encryption, Ed25519 signing, X25519 key exchange (mirrors server's
  sodium-native API)
- Popup UI: device registration, connection status indicator (gray/blue/
  green/red), cookie/device/sync stats, one-click current site sync,
  whitelist quick-add, device pairing with 6-digit code
- Options page: server URL config, connection mode (auto/WS/polling),
  poll interval slider, auto-sync toggle, domain whitelist/blacklist
  management, paired device list, key export/import, data clearing
- Sync engine: LWW conflict resolution with Lamport clocks (same as
  server), bidirectional cookie sync with all paired peers, echo
  suppression to prevent sync loops
- Badge management: icon color reflects state (gray=not logged in,
  blue=connected, green=syncing with count, red=error)
- Build system: esbuild bundling for Chrome 120+, TypeScript with
  strict mode, clean type checking

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-17 16:30:18 +08:00
101 changed files with 13939 additions and 83 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
dist
extension
tests
.git
.github
docs
*.md
!package.json
!package-lock.json
.DS_Store

82
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,82 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- 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, web]
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t cookiebridge:ci .
- name: Smoke test
run: |
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:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: extension/package-lock.json
- name: Install extension dependencies
working-directory: extension
run: npm ci
- name: Build all browsers
working-directory: extension
run: |
node esbuild.config.mjs --browser=chrome
node esbuild.config.mjs --browser=firefox
node esbuild.config.mjs --browser=edge
node esbuild.config.mjs --browser=safari

83
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,83 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
packages: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm test
docker:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract version from tag
id: version
run: echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:${{ steps.version.outputs.version }}
ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
github-release:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: extension/package-lock.json
- name: Build extensions
working-directory: extension
run: |
npm ci
for browser in chrome firefox edge safari; do
node esbuild.config.mjs --browser=$browser
done
- name: Package extensions
run: |
cd extension/build
for browser in chrome firefox edge safari; do
zip -r "../../cookiebridge-${browser}-${{ github.ref_name }}.zip" "$browser/"
done
- uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
files: cookiebridge-*-${{ github.ref_name }}.zip

9
.gitignore vendored Normal file
View File

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

93
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,93 @@
# Contributing to CookieBridge
## Getting Started
1. Fork and clone the repository.
2. Install dependencies: `npm install`
3. Start the dev server: `npm run dev`
4. Run tests: `npm test`
## Development Setup
**Prerequisites:**
- Node.js 22+
- npm
**Relay server:**
```bash
npm install
npm run dev # Starts with file watching on :8080
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
cd extension
npm install
node esbuild.config.mjs --browser=chrome
```
Load the unpacked extension from `extension/build/chrome/` in your browser.
## Making Changes
1. Create a branch from `main`.
2. Make focused, small commits. Each commit should do one thing.
3. Run `npm test` and `npm run typecheck` before pushing.
4. Open a pull request against `main`.
## Code Style
- TypeScript strict mode everywhere.
- No frameworks on the server — use Node.js built-ins where possible.
- Use libsodium for all cryptographic operations. No `crypto.subtle` or OpenSSL.
- Keep dependencies minimal.
## Testing
Tests use Vitest. Run with `npm test`.
- `tests/crypto.test.ts` — Encryption and signing primitives.
- `tests/pairing.test.ts` — Device pairing flow.
- `tests/conflict.test.ts` — LWW conflict resolution.
- `tests/integration.test.ts` — Full server integration tests.
Add tests for new features. Integration tests should start a real server instance.
## Commit Messages
Use clear, imperative commit messages:
```
feat: add cookie expiry sync support
fix: handle WebSocket reconnect on network change
docs: update self-hosting guide
```
## Reporting Issues
Open an issue with:
- What you expected to happen.
- What actually happened.
- Steps to reproduce.
- Browser and OS version (for extension issues).

48
Dockerfile Normal file
View File

@@ -0,0 +1,48 @@
## 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
# sodium-native needs build tools on Alpine
RUN apk add --no-cache python3 make g++
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts=false
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
## Stage 3: Production image
FROM node:22-alpine
WORKDIR /app
RUN apk add --no-cache python3 make g++
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts=false && \
apk del python3 make g++ && \
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
EXPOSE 8080
USER node
CMD ["node", "dist/cli.js"]

205
README.md Normal file
View File

@@ -0,0 +1,205 @@
# CookieBridge
Cross-device cookie synchronization with end-to-end encryption. Login once, be logged in everywhere.
CookieBridge syncs browser cookies across devices through an encrypted relay server. The server stores only encrypted blobs — it cannot read your cookie data. Devices pair using a short code and derive a shared secret locally via X25519 key exchange.
## Features
- **End-to-end encryption** — XChaCha20-Poly1305 (AEAD). The relay server is zero-knowledge.
- **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
### Docker (recommended)
```bash
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)
```bash
docker build -t cookiebridge .
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 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
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `8080` | Server listen port |
| `HOST` | `0.0.0.0` | Server bind address |
## Browser Extensions
Extensions live in `extension/` and support Chrome, Firefox, Edge, and Safari.
### Building
```bash
cd extension
npm install
# Build for a specific browser
node esbuild.config.mjs --browser=chrome
node esbuild.config.mjs --browser=firefox
node esbuild.config.mjs --browser=edge
node esbuild.config.mjs --browser=safari
```
Output goes to `extension/build/{browser}/`. Load the unpacked extension from there.
### Installing
- **Chrome**: `chrome://extensions` → Enable Developer Mode → Load unpacked → select `extension/build/chrome`
- **Firefox**: `about:debugging#/runtime/this-firefox` → Load Temporary Add-on → select `extension/build/firefox/manifest.json`
- **Edge**: `edge://extensions` → Enable Developer Mode → Load unpacked → select `extension/build/edge`
- **Safari**: Requires Xcode. Convert with `xcrun safari-web-extension-converter extension/build/safari`
## Architecture
```
Browser Extension ──WebSocket/HTTP──▶ Relay Server (stores encrypted blobs)
│ │
├── 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. 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
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/devices/register` | Register a device, receive API token |
| `POST` | `/api/pair` | Start a pairing session |
| `POST` | `/api/pair/accept` | Accept pairing with code |
| `POST` | `/api/cookies` | Push encrypted cookies |
| `GET` | `/api/cookies` | Pull cookies for a device |
| `GET` | `/api/cookies/updates` | Poll for updates since timestamp |
| `DELETE` | `/api/cookies` | Delete a cookie entry |
| `POST` | `/api/agent/tokens` | Create an agent access token |
| `POST` | `/api/agent/grant` | Grant agent access to a device |
| `GET` | `/api/agent/cookies` | Agent retrieves cookies |
| `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
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
```
src/
cli.ts # Server entry point
relay/
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
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
MIT

15
docker-compose.yml Normal file
View File

@@ -0,0 +1,15 @@
services:
relay:
build: .
ports:
- "${PORT:-8080}:8080"
environment:
- PORT=8080
- HOST=0.0.0.0
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s

139
docs/architecture.md Normal file
View File

@@ -0,0 +1,139 @@
# CookieBridge Architecture
## Overview
CookieBridge is a cross-device cookie sync system built on a zero-knowledge relay architecture. The relay server transports and stores encrypted cookie blobs but cannot decrypt them. All cryptographic operations happen client-side.
## Components
### Relay Server (`src/relay/`)
A plain Node.js HTTP + WebSocket server. No frameworks — just `http`, `ws`, and `sodium-native`.
- **server.ts** — Routes HTTP requests and upgrades WebSocket connections. Single entry point for all API traffic.
- **connections.ts** — Manages active WebSocket connections. Maps device IDs to sockets for real-time push.
- **auth.ts** — Two auth methods:
- **Token auth**: Bearer tokens issued at device registration. Used for HTTP and WebSocket.
- **Challenge-response**: Server sends random bytes, client signs with Ed25519. Used for WebSocket upgrade.
- **store.ts** — In-memory encrypted cookie blob storage. Keyed by `(deviceId, domain, cookieName, path)`. Limit: 10,000 cookies per device.
- **tokens.ts** — Device and agent registries. Tracks device metadata, pairing relationships, and agent access grants.
### Cryptography (`src/crypto/`)
All crypto uses libsodium via `sodium-native` (server) or `libsodium-wrappers-sumo` (extension).
| Operation | Algorithm | Purpose |
|-----------|-----------|---------|
| Encryption | XChaCha20-Poly1305 | Cookie payload encryption (AEAD) |
| Signing | Ed25519 | Message authentication, device identity |
| Key exchange | X25519 | Deriving shared secrets between paired devices |
| Key derivation | BLAKE2b (generic hash) | Deriving encryption keys from ECDH output |
### Sync Engine (`src/sync/`)
- **envelope.ts** — Wire format for WebSocket messages. Each message is signed and encrypted.
- **conflict.ts** — Last-writer-wins (LWW) conflict resolution. Uses Lamport clock timestamps. Ties broken by lexicographic device ID comparison.
### Browser Extension (`extension/`)
Single TypeScript codebase compiled per-browser via esbuild.
- **service-worker.ts** — Extension lifecycle, device registration, pairing orchestration, sync triggers.
- **api-client.ts** — HTTP client for relay server REST API.
- **connection.ts** — WebSocket manager with auto-reconnect and exponential backoff.
- **crypto.ts** — Client-side encryption/decryption using libsodium-wrappers-sumo.
- **sync.ts** — Processes incoming cookie updates, applies them via `chrome.cookies` API.
- **compat.ts** — Cross-browser abstraction layer for Chrome, Firefox, Edge, Safari API differences.
## Data Flow
### Device Registration
```
Extension Relay Server
│ │
│ POST /api/devices/register │
│ { deviceId, name, platform, │
│ encPub } │
│──────────────────────────────────▶│
│ │
│ { token, deviceId, ... } │
│◀──────────────────────────────────│
```
### Pairing
```
Device A Relay Server Device B
│ │ │
│ POST /api/pair │ │
│ { deviceId, x25519Pub,│ │
│ pairingCode } │ │
│──────────────────────▶│ │
│ │ │
│ │ POST /api/pair/accept │
│ │ { deviceId, x25519Pub,│
│ │ pairingCode } │
│ │◀──────────────────────│
│ │ │
│ { peerX25519PubKey } │ { peerX25519PubKey } │
│◀──────────────────────│──────────────────────▶│
│ │ │
│ derive shared secret │ derive shared secret │
│ (X25519 ECDH) │ (X25519 ECDH) │
```
### Cookie Sync (WebSocket)
```
Device A Relay Server Device B
│ │ │
│ cookie_sync envelope │ │
│ (signed + encrypted) │ │
│──────────────────────▶│ │
│ │ store encrypted blob │
│ │ │
│ │ forward envelope │
│ │──────────────────────▶│
│ │ │
│ │ decrypt│
│ │ apply │
│ ack │ │
│◀──────────────────────│◀──────────────────────│
```
### Cookie Sync (HTTP Polling)
```
Device Relay Server
│ │
│ POST /api/cookies │
│ { encrypted blob } │
│───────────────────────────────▶│
│ │
│ GET /api/cookies/updates │
│ ?since=<timestamp> │
│───────────────────────────────▶│
│ │
│ [ encrypted blobs ] │
│◀───────────────────────────────│
```
## Storage
All storage is **in-memory**. The relay server does not persist data to disk. Restarting the server clears all registrations, pairings, and stored cookies. Devices re-register and re-sync automatically on reconnection.
This is intentional for the current version — the server is a transient relay, not a database. Persistent storage may be added in a future milestone.
## Protocol Constants
| Constant | Value | Purpose |
|----------|-------|---------|
| `PROTOCOL_VERSION` | `2.0.0` | Wire protocol version |
| `MAX_STORED_COOKIES_PER_DEVICE` | 10,000 | Per-device cookie limit |
| `PAIRING_CODE_LENGTH` | 6 digits | Pairing code size |
| `PAIRING_TTL_MS` | 5 minutes | Pairing session expiry |
| `NONCE_BYTES` | 24 | XChaCha20 nonce size |
| `PING_INTERVAL_MS` | 30 seconds | WebSocket keepalive |
| `PONG_TIMEOUT_MS` | 10 seconds | Pong deadline |
| `POLL_INTERVAL_MS` | 5 seconds | HTTP polling default |

115
docs/security.md Normal file
View File

@@ -0,0 +1,115 @@
# CookieBridge Security Model
## Design Principles
1. **Zero-knowledge relay** — The server stores and forwards encrypted blobs. It never possesses decryption keys and cannot read cookie data.
2. **Client-side crypto only** — All encryption, decryption, signing, and key exchange happen in the browser extension or agent client.
3. **Minimal trust surface** — The server is trusted only for availability and message delivery, not confidentiality.
## Cryptographic Primitives
| Primitive | Algorithm | Library |
|-----------|-----------|---------|
| Symmetric encryption | XChaCha20-Poly1305 (AEAD) | libsodium |
| Digital signatures | Ed25519 | libsodium |
| Key exchange | X25519 (ECDH) | libsodium |
| Key derivation | BLAKE2b (generic hash) | libsodium |
| Random nonces | 24-byte crypto random | libsodium |
All algorithms are from the libsodium suite (`sodium-native` on the server, `libsodium-wrappers-sumo` in the extension).
## Identity
Each device generates two keypairs at first launch:
- **Ed25519** — Signing keypair. The public key (hex) serves as the `deviceId`. Used to sign all WebSocket messages and authenticate via challenge-response.
- **X25519** — Encryption keypair. Used in ECDH key exchange during pairing to derive a shared secret.
Keys are stored in `chrome.storage.local` (extension) and never leave the device except for public key exchange during registration and pairing.
## Authentication
### Token Auth (HTTP)
On device registration, the server issues a random Bearer token. This token authenticates all subsequent HTTP requests. Tokens are stored server-side in memory and associated with the device ID.
### Challenge-Response (WebSocket)
1. Server sends a random challenge (32 bytes, hex).
2. Client signs the challenge with its Ed25519 private key.
3. Server verifies the signature against the registered Ed25519 public key.
This proves the client possesses the private key corresponding to the registered device ID.
## Pairing Security
1. Device A creates a pairing session and generates a 6-digit code.
2. The code must be communicated out-of-band (e.g., user reads it from one device and types it on another).
3. Device B submits the code along with its X25519 public key.
4. The server brokers the exchange — both devices receive each other's X25519 public key.
5. Each device derives the shared secret locally via X25519 ECDH + BLAKE2b key derivation.
**Mitigations:**
- Pairing sessions expire after 5 minutes (`PAIRING_TTL_MS`).
- Codes are 6 digits (1M combinations). Rate limiting should be applied in production.
- The server sees the X25519 public keys but not the derived shared secret.
## Encryption
Cookie data is encrypted with XChaCha20-Poly1305 before leaving the extension:
1. A fresh 24-byte random nonce is generated per message.
2. The cookie payload is encrypted with the shared secret derived from pairing.
3. The nonce and ciphertext are sent to the server.
4. The server stores the encrypted blob without the ability to decrypt it.
**AEAD properties:**
- Confidentiality: only holders of the shared secret can decrypt.
- Integrity: any tampering with the ciphertext is detected.
- Nonce uniqueness: random 24-byte nonces provide negligible collision probability.
## Message Signing
Every WebSocket message is signed with the sender's Ed25519 private key. The recipient verifies the signature against the sender's registered public key before processing. This prevents message forgery even if the server is compromised.
## Threat Model
### What the server knows
- Device IDs (Ed25519 public keys), device names, platform strings.
- Pairing relationships (which devices are paired).
- Cookie metadata: domain, cookie name, path (stored in plaintext for query routing).
- Encrypted cookie values (ciphertext — cannot decrypt).
- Timing: when cookies are synced, update frequency.
### What the server does NOT know
- Cookie values (encrypted end-to-end).
- Shared secrets (derived client-side via ECDH).
- Private keys (never transmitted).
### Threats and Mitigations
| Threat | Mitigation |
|--------|-----------|
| Server compromise | Zero-knowledge design. Attacker gets encrypted blobs and metadata, but not cookie values. |
| Man-in-the-middle on pairing | Pairing code is out-of-band. ECDH prevents passive eavesdroppers. Active MITM requires server collusion — mitigated by self-hosting. |
| Replay attacks | Each message has a unique nonce. Lamport clocks provide causal ordering. |
| Message forgery | Ed25519 signatures on all WebSocket messages. |
| Cookie metadata leakage | Domain and cookie names are plaintext on the server. Self-hosting eliminates third-party access to this metadata. |
| Brute-force pairing code | 6-digit code with 5-minute TTL. Apply rate limiting in production (not yet implemented). |
### Current Limitations
- **In-memory storage** — Server restart clears all data. No persistence layer.
- **No rate limiting** — Pairing endpoint and registration are not rate-limited. Deploy behind a reverse proxy with rate limiting for production use.
- **Plaintext metadata** — Domain names, cookie names, and paths are stored unencrypted for query routing. A future version may support encrypted metadata queries.
- **No TLS termination** — The relay server is plain HTTP. Run behind a TLS-terminating reverse proxy (nginx, Caddy, Cloudflare Tunnel) for production.
## Self-Hosting Security Checklist
1. **TLS** — Place the relay behind a reverse proxy with TLS (e.g., Caddy with automatic HTTPS).
2. **Rate limiting** — Configure rate limiting on `/api/pair`, `/api/devices/register`, and `/ws` endpoints.
3. **Firewall** — Restrict access to the relay port. Only allow traffic from your devices/networks.
4. **Updates** — Watch for CookieBridge releases and update promptly.
5. **Monitoring** — Use the `/health` endpoint for uptime monitoring.

3
extension/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
build/

View File

@@ -0,0 +1,101 @@
import * as esbuild from "esbuild";
import { cpSync, mkdirSync, existsSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const isWatch = process.argv.includes("--watch");
const targetBrowser = process.argv.find((a) => a.startsWith("--browser="))?.split("=")[1] || "all";
const browsers = targetBrowser === "all"
? ["chrome", "firefox", "edge", "safari"]
: [targetBrowser];
// Browser-specific esbuild targets
const browserTargets = {
chrome: "chrome120",
edge: "chrome120", // Edge is Chromium-based
firefox: "firefox109",
safari: "safari16",
};
const sharedBuildOptions = {
entryPoints: [
"src/background/service-worker.ts",
"src/popup/popup.ts",
"src/options/options.ts",
],
bundle: true,
format: "esm",
sourcemap: true,
minify: !isWatch,
// Force CJS resolution for libsodium (ESM entry has broken sibling import)
alias: {
"libsodium-wrappers-sumo":
"./node_modules/libsodium-wrappers-sumo/dist/modules-sumo/libsodium-wrappers.js",
},
};
/**
* Copy static assets (HTML, icons, manifest) to a browser-specific build directory.
*/
function copyStaticAssets(browser) {
const outDir = join(__dirname, "build", browser);
mkdirSync(outDir, { recursive: true });
// Copy manifest
const manifestSrc = join(__dirname, "manifests", `${browser}.json`);
if (existsSync(manifestSrc)) {
cpSync(manifestSrc, join(outDir, "manifest.json"));
}
// Copy src (HTML, icons)
const srcDir = join(__dirname, "src");
cpSync(srcDir, join(outDir, "src"), { recursive: true, filter: (src) => !src.endsWith(".ts") });
}
async function buildBrowser(browser) {
const outDir = join("build", browser, "dist");
const options = {
...sharedBuildOptions,
outdir: outDir,
target: browserTargets[browser] || "es2020",
};
if (isWatch && browsers.length === 1) {
const ctx = await esbuild.context(options);
await ctx.watch();
console.log(`Watching for changes (${browser})...`);
} else {
await esbuild.build(options);
}
copyStaticAssets(browser);
console.log(`Build complete: ${browser} → build/${browser}/`);
}
// Also build the default dist/ for backwards compatibility (Chrome)
async function buildDefault() {
const options = {
...sharedBuildOptions,
outdir: "dist",
target: "chrome120",
};
if (isWatch) {
const ctx = await esbuild.context(options);
await ctx.watch();
console.log("Watching for changes (default)...");
} else {
await esbuild.build(options);
console.log("Build complete (default dist/).");
}
}
// Build all targets
await buildDefault();
for (const browser of browsers) {
await buildBrowser(browser);
}

View File

@@ -0,0 +1,139 @@
/**
* Generate simple PNG icons for the extension.
* Creates colored circle icons with a "C" letter.
* Run: node generate-icons.mjs
*/
import { writeFileSync } from "fs";
// Minimal PNG encoder for simple icons
function createPNG(size, r, g, b) {
// Create raw RGBA pixel data
const pixels = new Uint8Array(size * size * 4);
const center = size / 2;
const radius = size / 2 - 1;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const idx = (y * size + x) * 4;
const dist = Math.sqrt((x - center) ** 2 + (y - center) ** 2);
if (dist <= radius) {
pixels[idx] = r;
pixels[idx + 1] = g;
pixels[idx + 2] = b;
pixels[idx + 3] = 255;
} else if (dist <= radius + 1) {
// Anti-aliased edge
const alpha = Math.max(0, Math.round((radius + 1 - dist) * 255));
pixels[idx] = r;
pixels[idx + 1] = g;
pixels[idx + 2] = b;
pixels[idx + 3] = alpha;
}
}
}
// Encode as PNG
return encodePNG(size, size, pixels);
}
function encodePNG(width, height, pixels) {
const SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) {
c ^= buf[i];
for (let j = 0; j < 8; j++) {
c = (c >>> 1) ^ (c & 1 ? 0xedb88320 : 0);
}
}
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const typeBytes = Buffer.from(type);
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const combined = Buffer.concat([typeBytes, data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(combined));
return Buffer.concat([len, combined, crc]);
}
function adler32(buf) {
let a = 1, b = 0;
for (let i = 0; i < buf.length; i++) {
a = (a + buf[i]) % 65521;
b = (b + a) % 65521;
}
return ((b << 16) | a) >>> 0;
}
// IHDR
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // RGBA
ihdr[10] = 0; // compression
ihdr[11] = 0; // filter
ihdr[12] = 0; // interlace
// IDAT - raw pixel data with filter bytes
const rawData = Buffer.alloc(height * (1 + width * 4));
for (let y = 0; y < height; y++) {
rawData[y * (1 + width * 4)] = 0; // no filter
for (let x = 0; x < width * 4; x++) {
rawData[y * (1 + width * 4) + 1 + x] = pixels[y * width * 4 + x];
}
}
// Deflate with store (no compression) - simple but works
const blocks = [];
let offset = 0;
while (offset < rawData.length) {
const remaining = rawData.length - offset;
const blockSize = Math.min(remaining, 65535);
const isLast = offset + blockSize >= rawData.length;
const header = Buffer.alloc(5);
header[0] = isLast ? 1 : 0;
header.writeUInt16LE(blockSize, 1);
header.writeUInt16LE(blockSize ^ 0xffff, 3);
blocks.push(header);
blocks.push(rawData.subarray(offset, offset + blockSize));
offset += blockSize;
}
const zlibHeader = Buffer.from([0x78, 0x01]); // deflate, no compression
const adler = Buffer.alloc(4);
adler.writeUInt32BE(adler32(rawData));
const compressed = Buffer.concat([zlibHeader, ...blocks, adler]);
// IEND
const iend = Buffer.alloc(0);
return Buffer.concat([
SIGNATURE,
chunk("IHDR", ihdr),
chunk("IDAT", compressed),
chunk("IEND", iend),
]);
}
const colors = {
gray: [156, 163, 175],
blue: [59, 130, 246],
green: [34, 197, 94],
red: [239, 68, 68],
};
const sizes = [16, 48, 128];
for (const [name, [r, g, b]] of Object.entries(colors)) {
for (const size of sizes) {
const png = createPNG(size, r, g, b);
const path = `src/icons/icon-${name}-${size}.png`;
writeFileSync(path, png);
console.log(`Generated ${path}`);
}
}

32
extension/manifest.json Normal file
View File

@@ -0,0 +1,32 @@
{
"manifest_version": 3,
"name": "CookieBridge",
"version": "0.1.0",
"description": "Cross-device cookie synchronization with end-to-end encryption",
"permissions": [
"cookies",
"storage",
"alarms",
"tabs",
"activeTab"
],
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "dist/background/service-worker.js",
"type": "module"
},
"action": {
"default_popup": "src/popup/popup.html",
"default_icon": {
"16": "src/icons/icon-gray-16.png",
"48": "src/icons/icon-gray-48.png",
"128": "src/icons/icon-gray-128.png"
}
},
"options_page": "src/options/options.html",
"icons": {
"16": "src/icons/icon-blue-16.png",
"48": "src/icons/icon-blue-48.png",
"128": "src/icons/icon-blue-128.png"
}
}

View File

@@ -0,0 +1,32 @@
{
"manifest_version": 3,
"name": "CookieBridge",
"version": "0.1.0",
"description": "Cross-device cookie synchronization with end-to-end encryption",
"permissions": [
"cookies",
"storage",
"alarms",
"tabs",
"activeTab"
],
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "dist/background/service-worker.js",
"type": "module"
},
"action": {
"default_popup": "src/popup/popup.html",
"default_icon": {
"16": "src/icons/icon-gray-16.png",
"48": "src/icons/icon-gray-48.png",
"128": "src/icons/icon-gray-128.png"
}
},
"options_page": "src/options/options.html",
"icons": {
"16": "src/icons/icon-blue-16.png",
"48": "src/icons/icon-blue-48.png",
"128": "src/icons/icon-blue-128.png"
}
}

View File

@@ -0,0 +1,32 @@
{
"manifest_version": 3,
"name": "CookieBridge",
"version": "0.1.0",
"description": "Cross-device cookie synchronization with end-to-end encryption",
"permissions": [
"cookies",
"storage",
"alarms",
"tabs",
"activeTab"
],
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "dist/background/service-worker.js",
"type": "module"
},
"action": {
"default_popup": "src/popup/popup.html",
"default_icon": {
"16": "src/icons/icon-gray-16.png",
"48": "src/icons/icon-gray-48.png",
"128": "src/icons/icon-gray-128.png"
}
},
"options_page": "src/options/options.html",
"icons": {
"16": "src/icons/icon-blue-16.png",
"48": "src/icons/icon-blue-48.png",
"128": "src/icons/icon-blue-128.png"
}
}

View File

@@ -0,0 +1,41 @@
{
"manifest_version": 3,
"name": "CookieBridge",
"version": "0.1.0",
"description": "Cross-device cookie synchronization with end-to-end encryption",
"browser_specific_settings": {
"gecko": {
"id": "cookiebridge@rc707agency.com",
"strict_min_version": "109.0"
}
},
"permissions": [
"cookies",
"storage",
"alarms",
"tabs",
"activeTab"
],
"host_permissions": ["<all_urls>"],
"background": {
"scripts": ["dist/background/service-worker.js"],
"type": "module"
},
"action": {
"default_popup": "src/popup/popup.html",
"default_icon": {
"16": "src/icons/icon-gray-16.png",
"48": "src/icons/icon-gray-48.png",
"128": "src/icons/icon-gray-128.png"
}
},
"options_ui": {
"page": "src/options/options.html",
"open_in_tab": true
},
"icons": {
"16": "src/icons/icon-blue-16.png",
"48": "src/icons/icon-blue-48.png",
"128": "src/icons/icon-blue-128.png"
}
}

View File

@@ -0,0 +1,36 @@
{
"manifest_version": 3,
"name": "CookieBridge",
"version": "0.1.0",
"description": "Cross-device cookie synchronization with end-to-end encryption",
"permissions": [
"cookies",
"storage",
"alarms",
"tabs",
"activeTab"
],
"host_permissions": ["<all_urls>"],
"background": {
"scripts": ["dist/background/service-worker.js"],
"type": "module",
"persistent": false
},
"action": {
"default_popup": "src/popup/popup.html",
"default_icon": {
"16": "src/icons/icon-gray-16.png",
"48": "src/icons/icon-gray-48.png",
"128": "src/icons/icon-gray-128.png"
}
},
"options_ui": {
"page": "src/options/options.html",
"open_in_tab": true
},
"icons": {
"16": "src/icons/icon-blue-16.png",
"48": "src/icons/icon-blue-48.png",
"128": "src/icons/icon-blue-128.png"
}
}

550
extension/package-lock.json generated Normal file
View File

@@ -0,0 +1,550 @@
{
"name": "cookiebridge-extension",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cookiebridge-extension",
"version": "0.1.0",
"dependencies": {
"libsodium-wrappers-sumo": "^0.7.15"
},
"devDependencies": {
"@types/chrome": "^0.0.287",
"esbuild": "^0.24.2",
"typescript": "^5.9.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/chrome": {
"version": "0.0.287",
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.287.tgz",
"integrity": "sha512-wWhBNPNXZHwycHKNYnexUcpSbrihVZu++0rdp6GEk5ZgAglenLx+RwdEouh6FrHS0XQiOxSd62yaujM1OoQlZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/filesystem": "*",
"@types/har-format": "*"
}
},
"node_modules/@types/filesystem": {
"version": "0.0.36",
"resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz",
"integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/filewriter": "*"
}
},
"node_modules/@types/filewriter": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz",
"integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/har-format": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
"integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.2",
"@esbuild/android-arm": "0.24.2",
"@esbuild/android-arm64": "0.24.2",
"@esbuild/android-x64": "0.24.2",
"@esbuild/darwin-arm64": "0.24.2",
"@esbuild/darwin-x64": "0.24.2",
"@esbuild/freebsd-arm64": "0.24.2",
"@esbuild/freebsd-x64": "0.24.2",
"@esbuild/linux-arm": "0.24.2",
"@esbuild/linux-arm64": "0.24.2",
"@esbuild/linux-ia32": "0.24.2",
"@esbuild/linux-loong64": "0.24.2",
"@esbuild/linux-mips64el": "0.24.2",
"@esbuild/linux-ppc64": "0.24.2",
"@esbuild/linux-riscv64": "0.24.2",
"@esbuild/linux-s390x": "0.24.2",
"@esbuild/linux-x64": "0.24.2",
"@esbuild/netbsd-arm64": "0.24.2",
"@esbuild/netbsd-x64": "0.24.2",
"@esbuild/openbsd-arm64": "0.24.2",
"@esbuild/openbsd-x64": "0.24.2",
"@esbuild/sunos-x64": "0.24.2",
"@esbuild/win32-arm64": "0.24.2",
"@esbuild/win32-ia32": "0.24.2",
"@esbuild/win32-x64": "0.24.2"
}
},
"node_modules/libsodium-sumo": {
"version": "0.7.16",
"resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.7.16.tgz",
"integrity": "sha512-x6atrz2AdXCJg6G709x9W9TTJRI6/0NcL5dD0l5GGVqNE48UJmDsjO4RUWYTeyXXUpg+NXZ2SHECaZnFRYzwGA==",
"license": "ISC"
},
"node_modules/libsodium-wrappers-sumo": {
"version": "0.7.16",
"resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.7.16.tgz",
"integrity": "sha512-gR0JEFPeN3831lB9+ogooQk0KH4K5LSMIO5Prd5Q5XYR2wHFtZfPg0eP7t1oJIWq+UIzlU4WVeBxZ97mt28tXw==",
"license": "ISC",
"dependencies": {
"libsodium-sumo": "^0.7.16"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

23
extension/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "cookiebridge-extension",
"version": "0.1.0",
"private": true,
"description": "CookieBridge Chrome Extension — cross-device cookie sync with E2E encryption",
"scripts": {
"build": "node esbuild.config.mjs",
"build:chrome": "node esbuild.config.mjs --browser=chrome",
"build:firefox": "node esbuild.config.mjs --browser=firefox",
"build:edge": "node esbuild.config.mjs --browser=edge",
"build:safari": "node esbuild.config.mjs --browser=safari",
"watch": "node esbuild.config.mjs --watch --browser=chrome",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"libsodium-wrappers-sumo": "^0.7.15"
},
"devDependencies": {
"@types/chrome": "^0.0.287",
"esbuild": "^0.24.2",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,317 @@
/**
* CookieBridge background service worker.
* Manages device identity, connection lifecycle, and cookie sync.
* Uses the browser-agnostic compat layer for cross-browser support.
*/
import {
generateKeyPair,
deviceIdFromKeys,
serializeKeyPair,
deserializeKeyPair,
type DeviceKeyPair,
} from "../lib/crypto";
import { getState, setState, type PeerDevice } from "../lib/storage";
import { ConnectionManager, type ConnectionStatus } from "../lib/connection";
import { ApiClient } from "../lib/api-client";
import { SyncEngine } from "../lib/sync";
import { setIconState, clearSyncBadge } from "../lib/badge";
import { MESSAGE_TYPES, POLL_INTERVAL_MS, type Envelope } from "../lib/protocol";
import { alarms, runtime, cookies, getPlatformName } from "../lib/compat";
let connection: ConnectionManager | null = null;
let syncEngine: SyncEngine | null = null;
let api: ApiClient | null = null;
let pollAlarmName = "cookiebridge-poll";
// --- Initialization ---
async function initialize() {
const state = await getState();
if (!state.keys || !state.apiToken) {
await setIconState("not_logged_in");
return;
}
const keys = deserializeKeyPair(state.keys);
api = new ApiClient(state.serverUrl, state.apiToken);
connection = new ConnectionManager(state.serverUrl, state.apiToken, keys, {
onMessage: (envelope) => handleIncomingMessage(envelope, state.peers),
onStatusChange: handleStatusChange,
});
syncEngine = new SyncEngine(keys, connection, api);
syncEngine.start();
if (state.connectionMode === "polling") {
startPolling(state.pollIntervalSec);
} else {
connection.connect();
}
}
function handleStatusChange(status: ConnectionStatus) {
switch (status) {
case "connected":
setIconState("connected");
break;
case "disconnected":
case "connecting":
setIconState("not_logged_in");
break;
case "error":
setIconState("error");
break;
}
}
async function handleIncomingMessage(envelope: Envelope, peers: PeerDevice[]) {
if (
envelope.type === MESSAGE_TYPES.COOKIE_SYNC ||
envelope.type === MESSAGE_TYPES.COOKIE_DELETE
) {
if (!syncEngine) return;
await syncEngine.handleIncomingEnvelope(envelope, peers);
await setIconState("syncing", 1);
clearSyncBadge();
}
}
function startPolling(intervalSec: number) {
alarms.create(pollAlarmName, {
periodInMinutes: Math.max(intervalSec / 60, 1 / 60),
});
}
function stopPolling() {
alarms.clear(pollAlarmName);
}
// --- Alarm handler for HTTP polling ---
alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name !== pollAlarmName) return;
if (!api) return;
const state = await getState();
if (!state.lastSyncAt) return;
try {
const updates = await api.pullUpdates(state.lastSyncAt);
if (updates.length > 0) {
await setState({ lastSyncAt: new Date().toISOString() });
await setIconState("syncing", updates.length);
clearSyncBadge();
}
} catch {
// Polling failure — will retry on next alarm
}
});
// --- Message handling from popup/options ---
export interface ExtensionMessage {
type: string;
payload?: unknown;
}
runtime.onMessage.addListener(
(message: ExtensionMessage, _sender: any, sendResponse: (response: any) => void) => {
handleMessage(message).then(sendResponse).catch((err) => {
sendResponse({ error: err.message });
});
return true; // async response
},
);
async function handleMessage(msg: ExtensionMessage): Promise<unknown> {
switch (msg.type) {
case "GET_STATUS":
return getStatus();
case "REGISTER_DEVICE":
return registerDevice(msg.payload as { name: string });
case "INITIATE_PAIRING":
return initiatePairing();
case "ACCEPT_PAIRING":
return acceptPairing(msg.payload as { code: string });
case "SYNC_CURRENT_TAB":
if (syncEngine) await syncEngine.syncCurrentTab();
return { ok: true };
case "SYNC_DOMAIN":
if (syncEngine) {
await syncEngine.syncDomain(
(msg.payload as { domain: string }).domain,
);
}
return { ok: true };
case "ADD_WHITELIST": {
const state = await getState();
const domain = (msg.payload as { domain: string }).domain;
if (!state.whitelist.includes(domain)) {
await setState({ whitelist: [...state.whitelist, domain] });
}
return { ok: true };
}
case "DISCONNECT":
connection?.disconnect();
stopPolling();
return { ok: true };
case "RECONNECT":
await initialize();
return { ok: true };
case "EXPORT_KEYS": {
const state = await getState();
return { keys: state.keys };
}
case "IMPORT_KEYS": {
const keys = msg.payload as {
signPub: string;
signSec: string;
encPub: string;
encSec: string;
};
const kp = deserializeKeyPair(keys);
await setState({
keys,
deviceId: deviceIdFromKeys(kp),
});
await initialize();
return { ok: true };
}
case "LOGOUT":
connection?.disconnect();
syncEngine?.stop();
stopPolling();
await setState({
keys: null,
deviceId: null,
deviceName: null,
apiToken: null,
peers: [],
syncCount: 0,
lastSyncAt: null,
lamportClock: 0,
});
await setIconState("not_logged_in");
return { ok: true };
default:
throw new Error(`Unknown message type: ${msg.type}`);
}
}
async function getStatus(): Promise<{
loggedIn: boolean;
deviceId: string | null;
deviceName: string | null;
connectionStatus: ConnectionStatus;
peerCount: number;
syncCount: number;
lastSyncAt: string | null;
cookieCount: number;
}> {
const state = await getState();
// Count tracked cookies
let cookieCount = 0;
if (state.whitelist.length > 0) {
for (const domain of state.whitelist) {
const domainCookies = await cookies.getAll({ domain });
cookieCount += domainCookies.length;
}
}
return {
loggedIn: !!state.apiToken,
deviceId: state.deviceId,
deviceName: state.deviceName,
connectionStatus: connection?.status ?? "disconnected",
peerCount: state.peers.length,
syncCount: state.syncCount,
lastSyncAt: state.lastSyncAt,
cookieCount,
};
}
async function registerDevice(params: { name: string }) {
const keys = await generateKeyPair();
const serialized = serializeKeyPair(keys);
const deviceId = deviceIdFromKeys(keys);
const state = await getState();
const client = new ApiClient(state.serverUrl);
const device = await client.registerDevice({
deviceId,
name: params.name,
platform: getPlatformName(),
encPub: serialized.encPub,
});
await setState({
keys: serialized,
deviceId,
deviceName: params.name,
apiToken: device.token,
});
await initialize();
return { deviceId, name: params.name };
}
async function initiatePairing() {
const state = await getState();
if (!state.keys || !api) throw new Error("Not registered");
const result = await api.initiatePairing(
state.deviceId!,
state.keys.encPub,
);
return { pairingCode: result.pairingCode };
}
async function acceptPairing(params: { code: string }) {
const state = await getState();
if (!state.keys || !api) throw new Error("Not registered");
const result = await api.acceptPairing(
state.deviceId!,
state.keys.encPub,
params.code,
);
const peer: PeerDevice = {
deviceId: result.peerDeviceId,
name: "Paired Device",
platform: "unknown",
encPub: result.peerX25519PubKey,
pairedAt: new Date().toISOString(),
};
await setState({ peers: [...state.peers, peer] });
return { peerId: result.peerDeviceId };
}
// --- Start on install/startup ---
runtime.onInstalled.addListener(() => {
initialize();
});
runtime.onStartup.addListener(() => {
initialize();
});
// Initialize immediately for service worker restart
initialize();

View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html>
<head><title>Generate CookieBridge Icons</title></head>
<body>
<script>
// Run this in a browser to generate icon PNGs, or use the canvas-based approach in service worker
const sizes = [16, 48, 128];
const colors = {
gray: '#9CA3AF',
blue: '#3B82F6',
green: '#22C55E',
red: '#EF4444',
};
for (const [colorName, color] of Object.entries(colors)) {
for (const size of sizes) {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
// Background circle
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2 - 1, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
// Cookie icon (simplified)
ctx.fillStyle = '#FFFFFF';
ctx.font = `bold ${size * 0.5}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('C', size / 2, size / 2);
// Download
const link = document.createElement('a');
link.download = `icon-${colorName}-${size}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
}
}
document.body.textContent = 'Icons generated! Check downloads.';
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@@ -0,0 +1,119 @@
/**
* HTTP client for the CookieBridge relay server REST API.
*/
import type {
DeviceRegisterRequest,
DeviceInfo,
PairingResult,
EncryptedCookieBlob,
} from "./protocol";
export class ApiClient {
constructor(
private baseUrl: string,
private token: string | null = null,
) {}
setToken(token: string) {
this.token = token;
}
setBaseUrl(url: string) {
this.baseUrl = url;
}
private async request<T>(
method: string,
path: string,
body?: unknown,
): Promise<T> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (this.token) {
headers["Authorization"] = `Bearer ${this.token}`;
}
const res = await fetch(`${this.baseUrl}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`API ${method} ${path}: ${res.status} ${text}`);
}
return res.json();
}
// --- Device Registration ---
async registerDevice(req: DeviceRegisterRequest): Promise<DeviceInfo> {
return this.request("POST", "/api/devices/register", req);
}
// --- Pairing ---
async initiatePairing(
deviceId: string,
x25519PubKey: string,
): Promise<{ pairingCode: string }> {
return this.request("POST", "/api/pair", { deviceId, x25519PubKey });
}
async acceptPairing(
deviceId: string,
x25519PubKey: string,
pairingCode: string,
): Promise<PairingResult> {
return this.request("POST", "/api/pair/accept", {
deviceId,
x25519PubKey,
pairingCode,
});
}
// --- Cookie Storage (HTTP polling) ---
async pushCookies(
blobs: Array<{
domain: string;
cookieName: string;
path: string;
nonce: string;
ciphertext: string;
lamportTs: number;
}>,
): Promise<void> {
await this.request("POST", "/api/cookies", { cookies: blobs });
}
async pullCookies(domain?: string): Promise<EncryptedCookieBlob[]> {
const params = domain ? `?domain=${encodeURIComponent(domain)}` : "";
return this.request("GET", `/api/cookies${params}`);
}
async pullUpdates(since: string): Promise<EncryptedCookieBlob[]> {
return this.request(
"GET",
`/api/cookies/updates?since=${encodeURIComponent(since)}`,
);
}
async deleteCookie(
domain: string,
cookieName: string,
path: string,
): Promise<void> {
await this.request("DELETE", "/api/cookies", {
domain,
cookieName,
path,
});
}
// --- Health ---
async health(): Promise<{ status: string; connections: number }> {
return this.request("GET", "/health");
}
}

View File

@@ -0,0 +1,71 @@
/**
* Badge/icon management — updates extension icon color and badge text
* based on connection status and sync activity.
* Uses the browser-agnostic compat layer.
*
* States:
* - gray: Not logged in / no device identity
* - blue: Connected, idle
* - green: Syncing (with count badge)
* - red: Error / disconnected
*/
import { action, storage } from "./compat";
type IconColor = "gray" | "blue" | "green" | "red";
function iconPath(color: IconColor, size: number): string {
return `src/icons/icon-${color}-${size}.png`;
}
function iconSet(color: IconColor): Record<string, string> {
return {
"16": iconPath(color, 16),
"48": iconPath(color, 48),
"128": iconPath(color, 128),
};
}
export async function setIconState(
state: "not_logged_in" | "connected" | "syncing" | "error",
syncCount?: number,
) {
switch (state) {
case "not_logged_in":
await action.setIcon({ path: iconSet("gray") });
await action.setBadgeText({ text: "" });
break;
case "connected":
await action.setIcon({ path: iconSet("blue") });
await action.setBadgeText({ text: "" });
break;
case "syncing":
await action.setIcon({ path: iconSet("green") });
if (syncCount && syncCount > 0) {
await action.setBadgeText({
text: syncCount > 99 ? "99+" : String(syncCount),
});
await action.setBadgeBackgroundColor({ color: "#22C55E" });
}
break;
case "error":
await action.setIcon({ path: iconSet("red") });
await action.setBadgeText({ text: "!" });
await action.setBadgeBackgroundColor({ color: "#EF4444" });
break;
}
}
/** Clear the sync badge after a delay. */
export function clearSyncBadge(delayMs = 3000) {
setTimeout(async () => {
const state = await storage.get(["apiToken"]);
if (state.apiToken) {
await setIconState("connected");
} else {
await setIconState("not_logged_in");
}
}, delayMs);
}

278
extension/src/lib/compat.ts Normal file
View File

@@ -0,0 +1,278 @@
/**
* Browser compatibility layer — provides a unified API across Chrome, Firefox, Edge, and Safari.
*
* Firefox uses the `browser.*` namespace with promise-based APIs.
* Chrome/Edge use `chrome.*` with callback-based APIs.
* Safari uses `browser.*` (WebExtensions) with some limitations.
*
* This module detects the environment and exports a normalized `browserAPI`
* that always returns promises and works across all supported browsers.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
type BrowserType = "chrome" | "firefox" | "safari" | "edge" | "unknown";
function detectBrowser(): BrowserType {
if (typeof globalThis !== "undefined") {
const ua =
typeof navigator !== "undefined" ? navigator.userAgent || "" : "";
// Safari: check before Chrome since Safari UA may contain "Chrome" in some contexts
if (
ua.includes("Safari") &&
!ua.includes("Chrome") &&
!ua.includes("Chromium")
) {
return "safari";
}
// Firefox
if (
typeof (globalThis as any).browser !== "undefined" &&
typeof (globalThis as any).browser.runtime !== "undefined" &&
ua.includes("Firefox")
) {
return "firefox";
}
// Edge (Chromium-based)
if (ua.includes("Edg/")) {
return "edge";
}
// Chrome
if (
typeof (globalThis as any).chrome !== "undefined" &&
typeof (globalThis as any).chrome.runtime !== "undefined"
) {
return "chrome";
}
}
return "unknown";
}
export const BROWSER: BrowserType = detectBrowser();
/**
* Get the raw extension API object (`browser` on Firefox/Safari, `chrome` on Chrome/Edge).
*/
function getRawAPI(): any {
if (
typeof (globalThis as any).browser !== "undefined" &&
(globalThis as any).browser.runtime
) {
return (globalThis as any).browser;
}
if (
typeof (globalThis as any).chrome !== "undefined" &&
(globalThis as any).chrome.runtime
) {
return (globalThis as any).chrome;
}
throw new Error("No extension API found");
}
const raw = getRawAPI();
/**
* Wraps a callback-style Chrome API call into a promise.
* Firefox's `browser.*` already returns promises so we detect and pass through.
*/
function promisify<T>(fn: (...args: any[]) => any, ...args: any[]): Promise<T> {
const result = fn(...args);
if (result && typeof result.then === "function") {
return result;
}
return new Promise<T>((resolve, reject) => {
fn(...args, (val: T) => {
if (raw.runtime.lastError) {
reject(new Error(raw.runtime.lastError.message));
} else {
resolve(val);
}
});
});
}
// ─── Storage ───
export const storage = {
async get(keys: string | string[] | null): Promise<Record<string, any>> {
return promisify((k: any, cb?: any) => raw.storage.local.get(k, cb), keys);
},
async set(items: Record<string, any>): Promise<void> {
return promisify((i: any, cb?: any) => raw.storage.local.set(i, cb), items);
},
async clear(): Promise<void> {
return promisify((cb?: any) => raw.storage.local.clear(cb));
},
};
// ─── Cookies ───
export interface CompatCookie {
domain: string;
name: string;
value: string;
path: string;
secure: boolean;
httpOnly: boolean;
sameSite: string;
expirationDate?: number;
}
export interface CompatCookieChangeInfo {
cookie: CompatCookie;
removed: boolean;
cause: string;
}
export const cookies = {
async getAll(details: { domain?: string; url?: string }): Promise<CompatCookie[]> {
return promisify(
(d: any, cb?: any) => raw.cookies.getAll(d, cb),
details,
);
},
async set(details: Record<string, any>): Promise<CompatCookie | null> {
return promisify(
(d: any, cb?: any) => raw.cookies.set(d, cb),
details,
);
},
async remove(details: { url: string; name: string }): Promise<any> {
return promisify(
(d: any, cb?: any) => raw.cookies.remove(d, cb),
details,
);
},
onChanged: {
addListener(callback: (changeInfo: CompatCookieChangeInfo) => void) {
raw.cookies.onChanged.addListener(callback);
},
removeListener(callback: (changeInfo: CompatCookieChangeInfo) => void) {
raw.cookies.onChanged.removeListener(callback);
},
},
};
// ─── Tabs ───
export const tabs = {
async query(queryInfo: {
active?: boolean;
currentWindow?: boolean;
}): Promise<Array<{ url?: string; id?: number }>> {
return promisify(
(q: any, cb?: any) => raw.tabs.query(q, cb),
queryInfo,
);
},
};
// ─── Alarms ───
export const alarms = {
create(name: string, alarmInfo: { periodInMinutes: number }): void {
raw.alarms.create(name, alarmInfo);
},
clear(name: string): void {
raw.alarms.clear(name);
},
onAlarm: {
addListener(callback: (alarm: { name: string }) => void): void {
raw.alarms.onAlarm.addListener(callback);
},
},
};
// ─── Runtime ───
export const runtime = {
onMessage: {
addListener(
callback: (
message: any,
sender: any,
sendResponse: (response: any) => void,
) => boolean | void,
): void {
raw.runtime.onMessage.addListener(callback);
},
},
sendMessage(message: any): Promise<any> {
// Firefox returns a promise, Chrome uses callbacks
const result = raw.runtime.sendMessage(message);
if (result && typeof result.then === "function") {
return result;
}
return new Promise((resolve, reject) => {
raw.runtime.sendMessage(message, (response: any) => {
if (raw.runtime.lastError) {
reject(new Error(raw.runtime.lastError.message));
} else {
resolve(response);
}
});
});
},
onInstalled: {
addListener(callback: () => void): void {
raw.runtime.onInstalled.addListener(callback);
},
},
onStartup: {
addListener(callback: () => void): void {
raw.runtime.onStartup.addListener(callback);
},
},
openOptionsPage(): void {
raw.runtime.openOptionsPage();
},
get lastError(): { message?: string } | null | undefined {
return raw.runtime.lastError;
},
};
// ─── Action (browserAction on older Firefox) ───
function getActionAPI(): any {
return raw.action || raw.browserAction;
}
export const action = {
async setIcon(details: { path: Record<string, string> }): Promise<void> {
const api = getActionAPI();
if (!api) return;
return promisify((d: any, cb?: any) => api.setIcon(d, cb), details);
},
async setBadgeText(details: { text: string }): Promise<void> {
const api = getActionAPI();
if (!api) return;
return promisify((d: any, cb?: any) => api.setBadgeText(d, cb), details);
},
async setBadgeBackgroundColor(details: {
color: string | [number, number, number, number];
}): Promise<void> {
const api = getActionAPI();
if (!api) return;
return promisify(
(d: any, cb?: any) => api.setBadgeBackgroundColor(d, cb),
details,
);
},
};
// ─── Platform detection for device registration ───
export function getPlatformName(): string {
switch (BROWSER) {
case "firefox":
return "firefox-extension";
case "safari":
return "safari-extension";
case "edge":
return "edge-extension";
case "chrome":
default:
return "chrome-extension";
}
}

View File

@@ -0,0 +1,168 @@
/**
* Connection manager — handles WebSocket and HTTP polling connections to the relay server.
*/
import { toHex } from "./hex";
import type { DeviceKeyPair } from "./crypto";
import { deviceIdFromKeys, sign } from "./crypto";
import {
MESSAGE_TYPES,
PING_INTERVAL_MS,
PONG_TIMEOUT_MS,
type Envelope,
} from "./protocol";
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
export interface ConnectionEvents {
onMessage: (envelope: Envelope) => void;
onStatusChange: (status: ConnectionStatus) => void;
}
export class ConnectionManager {
private ws: WebSocket | null = null;
private pingTimer: ReturnType<typeof setInterval> | null = null;
private pongTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectDelay = 1000;
private _status: ConnectionStatus = "disconnected";
private serverUrl: string;
private token: string;
private keys: DeviceKeyPair;
private events: ConnectionEvents;
constructor(
serverUrl: string,
token: string,
keys: DeviceKeyPair,
events: ConnectionEvents,
) {
this.serverUrl = serverUrl;
this.token = token;
this.keys = keys;
this.events = events;
}
get status(): ConnectionStatus {
return this._status;
}
private setStatus(s: ConnectionStatus) {
this._status = s;
this.events.onStatusChange(s);
}
/** Connect via WebSocket. */
connect() {
if (this.ws) this.disconnect();
this.setStatus("connecting");
const wsUrl = this.serverUrl
.replace(/^http/, "ws")
.replace(/\/$/, "");
this.ws = new WebSocket(`${wsUrl}/ws?token=${encodeURIComponent(this.token)}`);
this.ws.onopen = () => {
this.setStatus("connected");
this.reconnectDelay = 1000;
this.startPing();
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data as string);
if (data.type === MESSAGE_TYPES.PONG) {
this.handlePong();
return;
}
if (data.type === MESSAGE_TYPES.PING) {
this.sendRaw({ type: MESSAGE_TYPES.PONG });
return;
}
this.events.onMessage(data as Envelope);
} catch {
// Ignore malformed messages
}
};
this.ws.onclose = () => {
this.cleanup();
this.setStatus("disconnected");
this.scheduleReconnect();
};
this.ws.onerror = () => {
this.cleanup();
this.setStatus("error");
this.scheduleReconnect();
};
}
disconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.cleanup();
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.setStatus("disconnected");
}
/** Send an envelope over WebSocket. */
send(envelope: Envelope) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(envelope));
}
}
private sendRaw(data: unknown) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
private startPing() {
this.stopPing();
this.pingTimer = setInterval(() => {
this.sendRaw({ type: MESSAGE_TYPES.PING });
this.pongTimer = setTimeout(() => {
// No pong received, connection is dead
this.ws?.close();
}, PONG_TIMEOUT_MS);
}, PING_INTERVAL_MS);
}
private stopPing() {
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
if (this.pongTimer) {
clearTimeout(this.pongTimer);
this.pongTimer = null;
}
}
private handlePong() {
if (this.pongTimer) {
clearTimeout(this.pongTimer);
this.pongTimer = null;
}
}
private cleanup() {
this.stopPing();
}
private scheduleReconnect() {
if (this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, this.reconnectDelay);
// Exponential backoff, max 30s
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
}
}

217
extension/src/lib/crypto.ts Normal file
View File

@@ -0,0 +1,217 @@
/**
* Browser-compatible crypto module using libsodium-wrappers-sumo.
* Mirrors the server's sodium-native API but runs in the browser.
*/
import _sodium from "libsodium-wrappers-sumo";
import { toHex, fromHex, toBase64, fromBase64 } from "./hex";
let sodiumReady: Promise<typeof _sodium> | null = null;
async function getSodium(): Promise<typeof _sodium> {
if (!sodiumReady) {
sodiumReady = _sodium.ready.then(() => _sodium);
}
return sodiumReady;
}
// --- Key Types ---
export interface DeviceKeyPair {
signPub: Uint8Array;
signSec: Uint8Array;
encPub: Uint8Array;
encSec: Uint8Array;
}
export interface SerializedKeyPair {
signPub: string; // hex
signSec: string; // hex
encPub: string; // hex
encSec: string; // hex
}
// --- Key Generation ---
export async function generateKeyPair(): Promise<DeviceKeyPair> {
const sodium = await getSodium();
const signKp = sodium.crypto_sign_keypair();
const encKp = sodium.crypto_box_keypair();
return {
signPub: signKp.publicKey,
signSec: signKp.privateKey,
encPub: encKp.publicKey,
encSec: encKp.privateKey,
};
}
export function deviceIdFromKeys(keys: DeviceKeyPair): string {
return toHex(keys.signPub);
}
export function serializeKeyPair(keys: DeviceKeyPair): SerializedKeyPair {
return {
signPub: toHex(keys.signPub),
signSec: toHex(keys.signSec),
encPub: toHex(keys.encPub),
encSec: toHex(keys.encSec),
};
}
export function deserializeKeyPair(data: SerializedKeyPair): DeviceKeyPair {
return {
signPub: fromHex(data.signPub),
signSec: fromHex(data.signSec),
encPub: fromHex(data.encPub),
encSec: fromHex(data.encSec),
};
}
// --- Encryption ---
export async function deriveSharedKey(
ourEncSec: Uint8Array,
peerEncPub: Uint8Array,
): Promise<Uint8Array> {
const sodium = await getSodium();
const raw = sodium.crypto_scalarmult(ourEncSec, peerEncPub);
return sodium.crypto_generichash(32, raw);
}
export async function encrypt(
plaintext: Uint8Array,
sharedKey: Uint8Array,
): Promise<{ nonce: Uint8Array; ciphertext: Uint8Array }> {
const sodium = await getSodium();
const nonce = sodium.randombytes_buf(
sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES,
);
const ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
plaintext,
null, // no additional data
null, // unused nsec
nonce,
sharedKey,
);
return { nonce, ciphertext };
}
export async function decrypt(
ciphertext: Uint8Array,
nonce: Uint8Array,
sharedKey: Uint8Array,
): Promise<Uint8Array> {
const sodium = await getSodium();
return sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(
null, // unused nsec
ciphertext,
null, // no additional data
nonce,
sharedKey,
);
}
// --- Signing ---
export async function sign(
message: Uint8Array,
signSec: Uint8Array,
): Promise<Uint8Array> {
const sodium = await getSodium();
return sodium.crypto_sign_detached(message, signSec);
}
export async function verify(
message: Uint8Array,
sig: Uint8Array,
signPub: Uint8Array,
): Promise<boolean> {
const sodium = await getSodium();
try {
return sodium.crypto_sign_verify_detached(sig, message, signPub);
} catch {
return false;
}
}
export function buildSignablePayload(fields: {
type: string;
from: string;
to: string;
nonce: string;
payload: string;
timestamp: string;
}): Uint8Array {
const str =
fields.type +
fields.from +
fields.to +
fields.nonce +
fields.payload +
fields.timestamp;
return new TextEncoder().encode(str);
}
// --- Envelope helpers ---
export async function buildEnvelope(
type: string,
payload: object,
senderKeys: DeviceKeyPair,
peerEncPub: Uint8Array,
peerDeviceId: string,
): Promise<{
type: string;
from: string;
to: string;
nonce: string;
payload: string;
timestamp: string;
sig: string;
}> {
const fromId = deviceIdFromKeys(senderKeys);
const sharedKey = await deriveSharedKey(senderKeys.encSec, peerEncPub);
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
const { nonce, ciphertext } = await encrypt(plaintext, sharedKey);
const timestamp = new Date().toISOString();
const nonceHex = toHex(nonce);
const payloadB64 = toBase64(ciphertext);
const signable = buildSignablePayload({
type,
from: fromId,
to: peerDeviceId,
nonce: nonceHex,
payload: payloadB64,
timestamp,
});
const sig = await sign(signable, senderKeys.signSec);
return {
type,
from: fromId,
to: peerDeviceId,
nonce: nonceHex,
payload: payloadB64,
timestamp,
sig: toHex(sig),
};
}
export async function openEnvelope(
envelope: {
nonce: string;
payload: string;
},
receiverKeys: DeviceKeyPair,
peerEncPub: Uint8Array,
): Promise<unknown> {
const sharedKey = await deriveSharedKey(receiverKeys.encSec, peerEncPub);
const nonce = fromHex(envelope.nonce);
const ciphertext = fromBase64(envelope.payload);
const plaintext = await decrypt(ciphertext, nonce, sharedKey);
return JSON.parse(new TextDecoder().decode(plaintext));
}
// Re-export hex utils
export { toHex, fromHex, toBase64, fromBase64 };

34
extension/src/lib/hex.ts Normal file
View File

@@ -0,0 +1,34 @@
/** Convert Uint8Array to hex string. */
export function toHex(buf: Uint8Array): string {
return Array.from(buf)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/** Convert hex string to Uint8Array. */
export function fromHex(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return bytes;
}
/** Convert Uint8Array to base64 string. */
export function toBase64(buf: Uint8Array): string {
let binary = "";
for (let i = 0; i < buf.length; i++) {
binary += String.fromCharCode(buf[i]);
}
return btoa(binary);
}
/** Convert base64 string to Uint8Array. */
export function fromBase64(b64: string): Uint8Array {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}

View File

@@ -0,0 +1,40 @@
declare module "libsodium-wrappers-sumo" {
interface KeyPair {
publicKey: Uint8Array;
privateKey: Uint8Array;
keyType: string;
}
interface Sodium {
ready: Promise<void>;
crypto_sign_keypair(): KeyPair;
crypto_box_keypair(): KeyPair;
crypto_scalarmult(privateKey: Uint8Array, publicKey: Uint8Array): Uint8Array;
crypto_generichash(hashLength: number, message: Uint8Array): Uint8Array;
crypto_aead_xchacha20poly1305_ietf_encrypt(
message: Uint8Array,
additionalData: Uint8Array | null,
nsec: Uint8Array | null,
nonce: Uint8Array,
key: Uint8Array,
): Uint8Array;
crypto_aead_xchacha20poly1305_ietf_decrypt(
nsec: Uint8Array | null,
ciphertext: Uint8Array,
additionalData: Uint8Array | null,
nonce: Uint8Array,
key: Uint8Array,
): Uint8Array;
crypto_aead_xchacha20poly1305_ietf_NPUBBYTES: number;
crypto_sign_detached(message: Uint8Array, privateKey: Uint8Array): Uint8Array;
crypto_sign_verify_detached(
signature: Uint8Array,
message: Uint8Array,
publicKey: Uint8Array,
): boolean;
randombytes_buf(length: number): Uint8Array;
}
const sodium: Sodium;
export default sodium;
}

View File

@@ -0,0 +1,83 @@
/**
* Protocol types — mirrors the server's protocol/spec.ts for use in the extension.
*/
export const PROTOCOL_VERSION = "2.0.0";
export const MAX_STORED_COOKIES_PER_DEVICE = 10_000;
export const PAIRING_CODE_LENGTH = 6;
export const PAIRING_TTL_MS = 5 * 60 * 1000;
export const NONCE_BYTES = 24;
export const PING_INTERVAL_MS = 30_000;
export const PONG_TIMEOUT_MS = 10_000;
export const POLL_INTERVAL_MS = 5_000;
export const MESSAGE_TYPES = {
COOKIE_SYNC: "cookie_sync",
COOKIE_DELETE: "cookie_delete",
ACK: "ack",
PING: "ping",
PONG: "pong",
ERROR: "error",
} as const;
export type MessageType = (typeof MESSAGE_TYPES)[keyof typeof MESSAGE_TYPES];
export interface Envelope {
type: MessageType;
from: string;
to: string;
nonce: string;
payload: string;
timestamp: string;
sig: string;
}
export interface CookieEntry {
domain: string;
name: string;
value: string;
path: string;
secure: boolean;
httpOnly: boolean;
sameSite: "strict" | "lax" | "none";
expiresAt: string | null;
}
export interface CookieSyncPayload {
action: "set" | "delete";
cookies: CookieEntry[];
lamportTs: number;
}
export interface EncryptedCookieBlob {
id: string;
deviceId: string;
domain: string;
cookieName: string;
path: string;
nonce: string;
ciphertext: string;
lamportTs: number;
updatedAt: string;
}
export interface DeviceRegisterRequest {
deviceId: string;
name: string;
platform: string;
encPub: string;
}
export interface DeviceInfo {
deviceId: string;
name: string;
platform: string;
encPub: string;
token: string;
createdAt: string;
}
export interface PairingResult {
peerDeviceId: string;
peerX25519PubKey: string;
}

View File

@@ -0,0 +1,111 @@
/**
* Storage wrapper — provides typed access to extension state.
* Uses the browser-agnostic compat layer.
*/
import type { SerializedKeyPair } from "./crypto";
import { storage } from "./compat";
export interface PeerDevice {
deviceId: string;
name: string;
platform: string;
encPub: string; // hex
pairedAt: string;
}
export interface ExtensionState {
// Device identity
keys: SerializedKeyPair | null;
deviceId: string | null;
deviceName: string | null;
apiToken: string | null;
// Server config
serverUrl: string;
connectionMode: "auto" | "websocket" | "polling";
pollIntervalSec: number;
// Sync settings
autoSync: boolean;
whitelist: string[]; // domains to sync
blacklist: string[]; // domains to never sync (banks, etc.)
// Paired devices
peers: PeerDevice[];
// Stats
syncCount: number;
lastSyncAt: string | null;
// Lamport clock
lamportClock: number;
}
const DEFAULT_STATE: ExtensionState = {
keys: null,
deviceId: null,
deviceName: null,
apiToken: null,
serverUrl: "http://localhost:3000",
connectionMode: "auto",
pollIntervalSec: 5,
autoSync: true,
whitelist: [],
blacklist: [
"*.bank.*",
"*.paypal.com",
"*.stripe.com",
"accounts.google.com",
"login.microsoftonline.com",
],
peers: [],
syncCount: 0,
lastSyncAt: null,
lamportClock: 0,
};
/** Get full extension state, merging defaults. */
export async function getState(): Promise<ExtensionState> {
const data = await storage.get(null);
return { ...DEFAULT_STATE, ...data } as ExtensionState;
}
/** Update specific state fields. */
export async function setState(
partial: Partial<ExtensionState>,
): Promise<void> {
await storage.set(partial);
}
/** Reset all state to defaults. */
export async function clearState(): Promise<void> {
await storage.clear();
}
/** Check if a domain matches a pattern (supports leading wildcard *.). */
function matchDomain(pattern: string, domain: string): boolean {
if (pattern.startsWith("*.")) {
const suffix = pattern.slice(1); // ".bank." or ".paypal.com"
return domain.includes(suffix);
}
return domain === pattern;
}
/** Check if a domain is allowed for syncing based on whitelist/blacklist. */
export function isDomainAllowed(
domain: string,
whitelist: string[],
blacklist: string[],
): boolean {
// Blacklist always wins
for (const pattern of blacklist) {
if (matchDomain(pattern, domain)) return false;
}
// If whitelist is empty, allow all (except blacklisted)
if (whitelist.length === 0) return true;
// If whitelist is non-empty, only allow whitelisted
for (const pattern of whitelist) {
if (matchDomain(pattern, domain)) return true;
}
return false;
}

271
extension/src/lib/sync.ts Normal file
View File

@@ -0,0 +1,271 @@
/**
* Cookie sync engine — monitors browser cookies, syncs with relay server.
* Uses the browser-agnostic compat layer.
*/
import type { CookieEntry, CookieSyncPayload, Envelope } from "./protocol";
import { MESSAGE_TYPES } from "./protocol";
import type { DeviceKeyPair } from "./crypto";
import {
deviceIdFromKeys,
buildEnvelope,
openEnvelope,
fromHex,
} from "./crypto";
import type { ConnectionManager } from "./connection";
import type { ApiClient } from "./api-client";
import { getState, setState, isDomainAllowed, type PeerDevice } from "./storage";
import { cookies, tabs, type CompatCookie, type CompatCookieChangeInfo } from "./compat";
type CookieKey = string;
interface TrackedCookie {
entry: CookieEntry;
lamportTs: number;
sourceDeviceId: string;
}
function cookieKey(domain: string, name: string, path: string): CookieKey {
return `${domain}|${name}|${path}`;
}
function browserCookieToEntry(cookie: CompatCookie): CookieEntry {
return {
domain: cookie.domain,
name: cookie.name,
value: cookie.value,
path: cookie.path,
secure: cookie.secure,
httpOnly: cookie.httpOnly,
sameSite: cookie.sameSite === "strict"
? "strict"
: cookie.sameSite === "lax"
? "lax"
: "none",
expiresAt: cookie.expirationDate
? new Date(cookie.expirationDate * 1000).toISOString()
: null,
};
}
export class SyncEngine {
private cookieMap = new Map<CookieKey, TrackedCookie>();
private lamportClock = 0;
private deviceId: string;
private keys: DeviceKeyPair;
private connection: ConnectionManager;
private api: ApiClient;
private suppressLocal = new Set<string>(); // keys to skip on local change (avoid echo)
constructor(
keys: DeviceKeyPair,
connection: ConnectionManager,
api: ApiClient,
) {
this.keys = keys;
this.deviceId = deviceIdFromKeys(keys);
this.connection = connection;
this.api = api;
}
/** Start monitoring cookie changes. */
start() {
cookies.onChanged.addListener(this.handleCookieChange);
}
stop() {
cookies.onChanged.removeListener(this.handleCookieChange);
}
/** Handle incoming envelope from WebSocket. */
async handleIncomingEnvelope(envelope: Envelope, peers: PeerDevice[]) {
const peer = peers.find((p) => p.deviceId === envelope.from);
if (!peer) return; // Unknown peer, ignore
const peerEncPub = fromHex(peer.encPub);
const payload = (await openEnvelope(
envelope,
this.keys,
peerEncPub,
)) as CookieSyncPayload;
const applied = this.applyRemote(payload, envelope.from);
for (const entry of applied) {
await this.applyCookieToBrowser(entry, payload.action);
}
// Update stats
const state = await getState();
await setState({
syncCount: state.syncCount + applied.length,
lastSyncAt: new Date().toISOString(),
lamportClock: this.lamportClock,
});
}
/** Sync a specific domain's cookies to all peers. */
async syncDomain(domain: string) {
const allCookies = await cookies.getAll({ domain });
if (allCookies.length === 0) return;
const entries = allCookies.map(browserCookieToEntry);
await this.syncEntriesToPeers(entries, "set");
}
/** Sync the current tab's cookies. */
async syncCurrentTab() {
const [tab] = await tabs.query({
active: true,
currentWindow: true,
});
if (!tab?.url) return;
try {
const url = new URL(tab.url);
await this.syncDomain(url.hostname);
} catch {
// Invalid URL
}
}
get currentLamportTs(): number {
return this.lamportClock;
}
private handleCookieChange = async (
changeInfo: CompatCookieChangeInfo,
) => {
const { cookie, removed, cause } = changeInfo;
// Skip changes caused by us applying remote cookies
const key = cookieKey(cookie.domain, cookie.name, cookie.path);
if (this.suppressLocal.has(key)) {
this.suppressLocal.delete(key);
return;
}
// Only sync user-initiated changes (not expired, not evicted)
if (cause === "expired" || cause === "evicted") return;
const state = await getState();
if (!state.autoSync) return;
if (!isDomainAllowed(cookie.domain, state.whitelist, state.blacklist))
return;
const entry = browserCookieToEntry(cookie);
const action = removed ? "delete" : "set";
if (!removed) {
this.lamportClock++;
this.cookieMap.set(key, {
entry,
lamportTs: this.lamportClock,
sourceDeviceId: this.deviceId,
});
} else {
this.cookieMap.delete(key);
}
await this.syncEntriesToPeers([entry], action);
await setState({ lamportClock: this.lamportClock });
};
private async syncEntriesToPeers(entries: CookieEntry[], action: "set" | "delete") {
const state = await getState();
this.lamportClock++;
const payload: CookieSyncPayload = {
action,
cookies: entries,
lamportTs: this.lamportClock,
};
for (const peer of state.peers) {
const peerEncPub = fromHex(peer.encPub);
const envelope = await buildEnvelope(
MESSAGE_TYPES.COOKIE_SYNC,
payload,
this.keys,
peerEncPub,
peer.deviceId,
);
this.connection.send(envelope as unknown as Envelope);
}
}
private applyRemote(
payload: CookieSyncPayload,
sourceDeviceId: string,
): CookieEntry[] {
this.lamportClock = Math.max(this.lamportClock, payload.lamportTs) + 1;
const applied: CookieEntry[] = [];
for (const entry of payload.cookies) {
const key = cookieKey(entry.domain, entry.name, entry.path);
const existing = this.cookieMap.get(key);
if (payload.action === "delete") {
if (this.shouldApply(existing, payload.lamportTs, sourceDeviceId)) {
this.cookieMap.delete(key);
applied.push(entry);
}
continue;
}
if (this.shouldApply(existing, payload.lamportTs, sourceDeviceId)) {
this.cookieMap.set(key, {
entry,
lamportTs: payload.lamportTs,
sourceDeviceId,
});
applied.push(entry);
}
}
return applied;
}
private shouldApply(
existing: TrackedCookie | undefined,
incomingTs: number,
incomingDeviceId: string,
): boolean {
if (!existing) return true;
if (incomingTs > existing.lamportTs) return true;
if (incomingTs === existing.lamportTs) {
return incomingDeviceId > existing.sourceDeviceId;
}
return false;
}
private async applyCookieToBrowser(entry: CookieEntry, action: "set" | "delete") {
const key = cookieKey(entry.domain, entry.name, entry.path);
this.suppressLocal.add(key);
const url = `http${entry.secure ? "s" : ""}://${entry.domain.replace(/^\./, "")}${entry.path}`;
if (action === "delete") {
await cookies.remove({ url, name: entry.name });
return;
}
const details: Record<string, any> = {
url,
name: entry.name,
value: entry.value,
path: entry.path,
secure: entry.secure,
httpOnly: entry.httpOnly,
sameSite: entry.sameSite === "strict"
? "strict"
: entry.sameSite === "lax"
? "lax"
: "no_restriction",
};
if (entry.expiresAt) {
details.expirationDate = new Date(entry.expiresAt).getTime() / 1000;
}
await cookies.set(details);
}
}

View File

@@ -0,0 +1,305 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
color: #1f2937;
background: #f9fafb;
line-height: 1.5;
}
.container {
max-width: 640px;
margin: 0 auto;
padding: 32px 24px;
}
header {
margin-bottom: 32px;
}
header h1 {
font-size: 24px;
font-weight: 700;
color: #111827;
}
/* Sections */
.section {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
.section h2 {
font-size: 16px;
font-weight: 600;
color: #374151;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid #f3f4f6;
}
/* Fields */
.field {
margin-bottom: 14px;
}
.field label {
display: block;
font-size: 13px;
font-weight: 500;
color: #374151;
margin-bottom: 4px;
}
.field .value {
font-size: 14px;
color: #6b7280;
}
.field .value.mono {
font-family: monospace;
font-size: 12px;
word-break: break-all;
}
.field input[type="text"],
.field select {
width: 100%;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
outline: none;
transition: border-color 0.15s;
}
.field input[type="text"]:focus,
.field select:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.field-hint {
font-size: 11px;
color: #9ca3af;
margin-top: 4px;
}
.field-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* Range */
.range-group {
display: flex;
align-items: center;
gap: 12px;
}
.range-group input[type="range"] {
flex: 1;
}
.range-group span {
font-size: 13px;
font-weight: 600;
color: #374151;
min-width: 40px;
}
/* Toggle */
.toggle-label {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
}
.toggle-label input[type="checkbox"] {
display: none;
}
.toggle {
width: 44px;
height: 24px;
background: #d1d5db;
border-radius: 12px;
position: relative;
transition: background 0.2s;
}
.toggle::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: #ffffff;
border-radius: 50%;
transition: transform 0.2s;
}
.toggle-label input:checked + .toggle {
background: #3b82f6;
}
.toggle-label input:checked + .toggle::after {
transform: translateX(20px);
}
/* Tags */
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
min-height: 28px;
}
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: #eff6ff;
color: #1d4ed8;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
}
.tag .remove {
cursor: pointer;
font-size: 14px;
color: #93c5fd;
margin-left: 2px;
}
.tag .remove:hover {
color: #dc2626;
}
.add-tag {
display: flex;
gap: 8px;
}
.add-tag input {
flex: 1;
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 13px;
outline: none;
}
.add-tag input:focus {
border-color: #3b82f6;
}
/* Peer List */
.peer-list {
min-height: 40px;
}
.peer-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #f3f4f6;
}
.peer-item:last-child {
border-bottom: none;
}
.peer-info .peer-name {
font-weight: 500;
font-size: 14px;
}
.peer-info .peer-id {
font-size: 11px;
color: #9ca3af;
font-family: monospace;
}
.peer-info .peer-date {
font-size: 11px;
color: #9ca3af;
}
.empty-state {
color: #9ca3af;
font-size: 13px;
text-align: center;
padding: 12px;
}
/* Buttons */
.btn {
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-primary {
background: #3b82f6;
color: #ffffff;
}
.btn-primary:hover {
background: #2563eb;
}
.btn-secondary {
background: #f3f4f6;
color: #374151;
}
.btn-secondary:hover {
background: #e5e7eb;
}
.btn-danger {
background: #fef2f2;
color: #dc2626;
}
.btn-danger:hover {
background: #fee2e2;
}
.btn-small {
padding: 6px 12px;
font-size: 12px;
}
/* Save Bar */
.save-bar {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
}
.save-status {
font-size: 13px;
color: #059669;
}

View File

@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CookieBridge Settings</title>
<link rel="stylesheet" href="options.css">
</head>
<body>
<div class="container">
<header>
<h1>CookieBridge Settings</h1>
</header>
<!-- Account Section -->
<section class="section">
<h2>Account</h2>
<div class="field">
<label>Device Name</label>
<span id="opt-device-name" class="value"></span>
</div>
<div class="field">
<label>Device ID</label>
<span id="opt-device-id" class="value mono"></span>
</div>
<div class="field-actions">
<button id="btn-logout" class="btn btn-danger">Log Out</button>
</div>
</section>
<!-- Connection Section -->
<section class="section">
<h2>Connection</h2>
<div class="field">
<label for="opt-server-url">Server URL</label>
<input type="text" id="opt-server-url" placeholder="http://localhost:3000" />
</div>
<div class="field">
<label for="opt-connection-mode">Connection Mode</label>
<select id="opt-connection-mode">
<option value="auto">Auto (WebSocket preferred)</option>
<option value="websocket">WebSocket Only</option>
<option value="polling">HTTP Polling</option>
</select>
</div>
<div class="field" id="field-poll-interval">
<label for="opt-poll-interval">Poll Interval</label>
<div class="range-group">
<input type="range" id="opt-poll-interval" min="1" max="60" value="5" />
<span id="opt-poll-interval-label">5s</span>
</div>
</div>
</section>
<!-- Sync Section -->
<section class="section">
<h2>Sync</h2>
<div class="field">
<label class="toggle-label">
<span>Auto-sync cookies</span>
<input type="checkbox" id="opt-auto-sync" />
<span class="toggle"></span>
</label>
</div>
<div class="field">
<label>Whitelist (sync only these domains)</label>
<div class="tag-list" id="whitelist-tags"></div>
<div class="add-tag">
<input type="text" id="whitelist-input" placeholder="example.com" />
<button id="btn-add-whitelist" class="btn btn-small btn-secondary">Add</button>
</div>
<p class="field-hint">Leave empty to sync all domains (except blacklisted)</p>
</div>
<div class="field">
<label>Blacklist (never sync these domains)</label>
<div class="tag-list" id="blacklist-tags"></div>
<div class="add-tag">
<input type="text" id="blacklist-input" placeholder="*.bank.com" />
<button id="btn-add-blacklist" class="btn btn-small btn-secondary">Add</button>
</div>
</div>
</section>
<!-- Paired Devices Section -->
<section class="section">
<h2>Paired Devices</h2>
<div id="peer-list" class="peer-list">
<p class="empty-state">No paired devices yet</p>
</div>
</section>
<!-- Security Section -->
<section class="section">
<h2>Security</h2>
<div class="field-actions">
<button id="btn-export-keys" class="btn btn-secondary">Export Keys</button>
<button id="btn-import-keys" class="btn btn-secondary">Import Keys</button>
<input type="file" id="file-import-keys" accept=".json" style="display: none" />
</div>
<div class="field-actions" style="margin-top: 12px;">
<button id="btn-clear-data" class="btn btn-danger">Clear All Local Data</button>
</div>
</section>
<!-- Save -->
<div class="save-bar">
<button id="btn-save" class="btn btn-primary">Save Settings</button>
<span id="save-status" class="save-status"></span>
</div>
</div>
<script src="../../dist/options/options.js" type="module"></script>
</body>
</html>

View File

@@ -0,0 +1,252 @@
/**
* Options page script — manages extension settings.
* Uses the browser-agnostic compat layer.
*/
import { runtime, storage } from "../lib/compat";
export {};
// --- Messaging helper ---
async function sendMessage(type: string, payload?: unknown): Promise<any> {
const response = await runtime.sendMessage({ type, payload });
if (response?.error) {
throw new Error(response.error);
}
return response;
}
// --- Elements ---
const optDeviceName = document.getElementById("opt-device-name")!;
const optDeviceId = document.getElementById("opt-device-id")!;
const btnLogout = document.getElementById("btn-logout") as HTMLButtonElement;
const optServerUrl = document.getElementById("opt-server-url") as HTMLInputElement;
const optConnectionMode = document.getElementById("opt-connection-mode") as HTMLSelectElement;
const optPollInterval = document.getElementById("opt-poll-interval") as HTMLInputElement;
const optPollIntervalLabel = document.getElementById("opt-poll-interval-label")!;
const fieldPollInterval = document.getElementById("field-poll-interval")!;
const optAutoSync = document.getElementById("opt-auto-sync") as HTMLInputElement;
const whitelistTags = document.getElementById("whitelist-tags")!;
const whitelistInput = document.getElementById("whitelist-input") as HTMLInputElement;
const btnAddWhitelist = document.getElementById("btn-add-whitelist") as HTMLButtonElement;
const blacklistTags = document.getElementById("blacklist-tags")!;
const blacklistInput = document.getElementById("blacklist-input") as HTMLInputElement;
const btnAddBlacklist = document.getElementById("btn-add-blacklist") as HTMLButtonElement;
const peerList = document.getElementById("peer-list")!;
const btnExportKeys = document.getElementById("btn-export-keys") as HTMLButtonElement;
const btnImportKeys = document.getElementById("btn-import-keys") as HTMLButtonElement;
const fileImportKeys = document.getElementById("file-import-keys") as HTMLInputElement;
const btnClearData = document.getElementById("btn-clear-data") as HTMLButtonElement;
const btnSave = document.getElementById("btn-save") as HTMLButtonElement;
const saveStatus = document.getElementById("save-status")!;
// --- State ---
let whitelist: string[] = [];
let blacklist: string[] = [];
// --- Load Settings ---
async function loadSettings() {
const state = await storage.get(null);
optDeviceName.textContent = state.deviceName || "—";
optDeviceId.textContent = state.deviceId || "—";
optServerUrl.value = state.serverUrl || "http://localhost:3000";
optConnectionMode.value = state.connectionMode || "auto";
optPollInterval.value = String(state.pollIntervalSec || 5);
optPollIntervalLabel.textContent = `${state.pollIntervalSec || 5}s`;
updatePollVisibility();
optAutoSync.checked = state.autoSync !== false;
whitelist = state.whitelist || [];
blacklist = state.blacklist || [
"*.bank.*",
"*.paypal.com",
"*.stripe.com",
"accounts.google.com",
"login.microsoftonline.com",
];
renderTags(whitelistTags, whitelist, "whitelist");
renderTags(blacklistTags, blacklist, "blacklist");
renderPeers(state.peers || []);
}
function renderTags(container: HTMLElement, items: string[], listName: string) {
container.innerHTML = "";
for (const item of items) {
const tag = document.createElement("span");
tag.className = "tag";
tag.innerHTML = `${item}<span class="remove" data-list="${listName}" data-value="${item}">&times;</span>`;
container.appendChild(tag);
}
}
function renderPeers(peers: Array<{ deviceId: string; name: string; platform: string; pairedAt: string }>) {
if (peers.length === 0) {
peerList.innerHTML = '<p class="empty-state">No paired devices yet</p>';
return;
}
peerList.innerHTML = "";
for (const peer of peers) {
const item = document.createElement("div");
item.className = "peer-item";
item.innerHTML = `
<div class="peer-info">
<div class="peer-name">${peer.name} (${peer.platform})</div>
<div class="peer-id">${peer.deviceId.slice(0, 16)}...</div>
<div class="peer-date">Paired: ${new Date(peer.pairedAt).toLocaleDateString()}</div>
</div>
`;
peerList.appendChild(item);
}
}
function updatePollVisibility() {
fieldPollInterval.style.display =
optConnectionMode.value === "polling" ? "block" : "none";
}
// --- Event Handlers ---
optConnectionMode.addEventListener("change", updatePollVisibility);
optPollInterval.addEventListener("input", () => {
optPollIntervalLabel.textContent = `${optPollInterval.value}s`;
});
// Tag removal (event delegation)
document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (!target.classList.contains("remove")) return;
const listName = target.dataset.list;
const value = target.dataset.value;
if (!listName || !value) return;
if (listName === "whitelist") {
whitelist = whitelist.filter((d) => d !== value);
renderTags(whitelistTags, whitelist, "whitelist");
} else {
blacklist = blacklist.filter((d) => d !== value);
renderTags(blacklistTags, blacklist, "blacklist");
}
});
btnAddWhitelist.addEventListener("click", () => {
const domain = whitelistInput.value.trim();
if (domain && !whitelist.includes(domain)) {
whitelist.push(domain);
renderTags(whitelistTags, whitelist, "whitelist");
whitelistInput.value = "";
}
});
btnAddBlacklist.addEventListener("click", () => {
const domain = blacklistInput.value.trim();
if (domain && !blacklist.includes(domain)) {
blacklist.push(domain);
renderTags(blacklistTags, blacklist, "blacklist");
blacklistInput.value = "";
}
});
// Save
btnSave.addEventListener("click", async () => {
await storage.set({
serverUrl: optServerUrl.value.trim(),
connectionMode: optConnectionMode.value,
pollIntervalSec: parseInt(optPollInterval.value),
autoSync: optAutoSync.checked,
whitelist,
blacklist,
});
// Reconnect with new settings
await sendMessage("RECONNECT");
saveStatus.textContent = "Saved!";
setTimeout(() => {
saveStatus.textContent = "";
}, 2000);
});
// Logout
btnLogout.addEventListener("click", async () => {
if (!confirm("Are you sure you want to log out? This will remove your device identity.")) {
return;
}
await sendMessage("LOGOUT");
window.close();
});
// Export keys
btnExportKeys.addEventListener("click", async () => {
const result = await sendMessage("EXPORT_KEYS");
if (!result?.keys) {
alert("No keys to export");
return;
}
const blob = new Blob([JSON.stringify(result.keys, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "cookiebridge-keys.json";
a.click();
URL.revokeObjectURL(url);
});
// Import keys
btnImportKeys.addEventListener("click", () => {
fileImportKeys.click();
});
fileImportKeys.addEventListener("change", async () => {
const file = fileImportKeys.files?.[0];
if (!file) return;
try {
const text = await file.text();
const keys = JSON.parse(text);
if (!keys.signPub || !keys.signSec || !keys.encPub || !keys.encSec) {
throw new Error("Invalid key file");
}
await sendMessage("IMPORT_KEYS", keys);
alert("Keys imported successfully. The extension will reconnect.");
await loadSettings();
} catch (err) {
alert(`Failed to import keys: ${(err as Error).message}`);
}
});
// Clear data
btnClearData.addEventListener("click", async () => {
if (
!confirm(
"This will delete ALL local data including your encryption keys. Are you sure?",
)
) {
return;
}
await sendMessage("LOGOUT");
await storage.clear();
alert("All data cleared.");
window.close();
});
// --- Init ---
loadSettings();

View File

@@ -0,0 +1,317 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 340px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
color: #1f2937;
background: #ffffff;
}
.view {
padding: 16px;
}
/* Login View */
.logo-section {
text-align: center;
padding: 24px 0 16px;
}
.logo {
font-size: 48px;
margin-bottom: 8px;
}
h1 {
font-size: 20px;
font-weight: 600;
color: #111827;
}
.subtitle {
font-size: 12px;
color: #6b7280;
margin-top: 4px;
}
.login-section {
display: flex;
flex-direction: column;
gap: 10px;
}
input[type="text"] {
width: 100%;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
outline: none;
transition: border-color 0.15s;
}
input[type="text"]:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.link {
text-align: center;
font-size: 12px;
color: #3b82f6;
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
/* Buttons */
.btn {
width: 100%;
padding: 10px 16px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: #3b82f6;
color: #ffffff;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: #f3f4f6;
color: #374151;
}
.btn-secondary:hover:not(:disabled) {
background: #e5e7eb;
}
.btn-ghost {
background: transparent;
color: #6b7280;
}
.btn-ghost:hover {
color: #374151;
}
.btn-small {
width: auto;
padding: 6px 12px;
font-size: 12px;
}
/* Header */
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
}
.avatar {
width: 36px;
height: 36px;
background: #eff6ff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.user-details {
display: flex;
flex-direction: column;
}
.name {
font-weight: 600;
font-size: 14px;
}
.device-id {
font-size: 10px;
color: #9ca3af;
font-family: monospace;
}
/* Status Badge */
.status-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
}
.status-badge .dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-badge.connected {
background: #ecfdf5;
color: #059669;
}
.status-badge.connected .dot {
background: #22c55e;
}
.status-badge.disconnected {
background: #f3f4f6;
color: #6b7280;
}
.status-badge.disconnected .dot {
background: #9ca3af;
}
.status-badge.error {
background: #fef2f2;
color: #dc2626;
}
.status-badge.error .dot {
background: #ef4444;
}
.status-badge.connecting {
background: #fffbeb;
color: #d97706;
}
.status-badge.connecting .dot {
background: #f59e0b;
}
/* Stats Row */
.stats-row {
display: flex;
justify-content: space-around;
padding: 12px 0;
margin-bottom: 16px;
border-top: 1px solid #f3f4f6;
border-bottom: 1px solid #f3f4f6;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.stat-value {
font-size: 20px;
font-weight: 700;
color: #111827;
}
.stat-label {
font-size: 11px;
color: #6b7280;
}
/* Actions */
.actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
/* Pairing Dialog */
.dialog {
background: #f9fafb;
border-radius: 10px;
padding: 16px;
margin-bottom: 16px;
}
.dialog-title {
font-weight: 600;
font-size: 13px;
margin-bottom: 8px;
text-align: center;
}
.pairing-code {
font-size: 32px;
font-weight: 700;
font-family: monospace;
text-align: center;
letter-spacing: 8px;
color: #3b82f6;
padding: 12px;
}
.pairing-input {
text-align: center;
font-size: 24px;
font-weight: 600;
letter-spacing: 6px;
font-family: monospace;
}
.dialog-hint {
font-size: 11px;
color: #6b7280;
text-align: center;
margin-top: 8px;
}
.dialog-actions {
display: flex;
gap: 8px;
margin-top: 12px;
justify-content: center;
}
/* Footer */
.footer {
display: flex;
justify-content: space-between;
padding-top: 12px;
border-top: 1px solid #f3f4f6;
}
.footer-link {
font-size: 12px;
color: #6b7280;
text-decoration: none;
}
.footer-link:hover {
color: #3b82f6;
}

View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CookieBridge</title>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<!-- Not Logged In View -->
<div id="view-login" class="view" style="display: none;">
<div class="logo-section">
<div class="logo">🍪</div>
<h1>CookieBridge</h1>
<p class="subtitle">Cross-device cookie sync</p>
</div>
<div class="login-section">
<input type="text" id="device-name" placeholder="Device name (e.g. MacBook Pro)" />
<button id="btn-register" class="btn btn-primary">Register Device</button>
<a href="#" id="link-first-use" class="link">First time setup guide</a>
</div>
</div>
<!-- Logged In View -->
<div id="view-main" class="view" style="display: none;">
<!-- Header -->
<div class="header">
<div class="user-info">
<div class="avatar">🍪</div>
<div class="user-details">
<span id="display-name" class="name"></span>
<span id="display-device-id" class="device-id"></span>
</div>
</div>
<div id="connection-status" class="status-badge disconnected">
<span class="dot"></span>
<span class="label">Disconnected</span>
</div>
</div>
<!-- Stats -->
<div class="stats-row">
<div class="stat">
<span id="stat-cookies" class="stat-value">0</span>
<span class="stat-label">Cookies</span>
</div>
<div class="stat">
<span id="stat-devices" class="stat-value">0</span>
<span class="stat-label">Devices</span>
</div>
<div class="stat">
<span id="stat-syncs" class="stat-value">0</span>
<span class="stat-label">Syncs</span>
</div>
</div>
<!-- Actions -->
<div class="actions">
<button id="btn-sync-tab" class="btn btn-primary">Sync Current Site</button>
<button id="btn-add-whitelist" class="btn btn-secondary">Add to Whitelist</button>
<button id="btn-pair" class="btn btn-secondary">Pair Device</button>
</div>
<!-- Pairing Dialog (hidden by default) -->
<div id="pairing-dialog" class="dialog" style="display: none;">
<div id="pairing-initiate" style="display: none;">
<p class="dialog-title">Your Pairing Code</p>
<div id="pairing-code" class="pairing-code"></div>
<p class="dialog-hint">Enter this code on the other device within 5 minutes</p>
</div>
<div id="pairing-accept">
<p class="dialog-title">Enter Pairing Code</p>
<input type="text" id="input-pairing-code" placeholder="000000" maxlength="6" class="pairing-input" />
<div class="dialog-actions">
<button id="btn-pairing-accept" class="btn btn-primary btn-small">Pair</button>
<button id="btn-pairing-generate" class="btn btn-secondary btn-small">Generate Code</button>
<button id="btn-pairing-cancel" class="btn btn-ghost btn-small">Cancel</button>
</div>
</div>
</div>
<!-- Footer -->
<div class="footer">
<a href="#" id="btn-settings" class="footer-link">Settings</a>
<a href="https://github.com/Rc707Agency/cookiebridge" target="_blank" class="footer-link">Help</a>
</div>
</div>
<script src="../../dist/popup/popup.js" type="module"></script>
</body>
</html>

View File

@@ -0,0 +1,201 @@
/**
* Popup script — controls the popup UI interactions.
* Uses the browser-agnostic compat layer.
*/
import { runtime, tabs } from "../lib/compat";
export {};
// --- Elements ---
const viewLogin = document.getElementById("view-login")!;
const viewMain = document.getElementById("view-main")!;
// Login
const inputDeviceName = document.getElementById("device-name") as HTMLInputElement;
const btnRegister = document.getElementById("btn-register") as HTMLButtonElement;
// Main header
const displayName = document.getElementById("display-name")!;
const displayDeviceId = document.getElementById("display-device-id")!;
const connectionStatus = document.getElementById("connection-status")!;
// Stats
const statCookies = document.getElementById("stat-cookies")!;
const statDevices = document.getElementById("stat-devices")!;
const statSyncs = document.getElementById("stat-syncs")!;
// Actions
const btnSyncTab = document.getElementById("btn-sync-tab") as HTMLButtonElement;
const btnAddWhitelist = document.getElementById("btn-add-whitelist") as HTMLButtonElement;
const btnPair = document.getElementById("btn-pair") as HTMLButtonElement;
const btnSettings = document.getElementById("btn-settings")!;
// Pairing
const pairingDialog = document.getElementById("pairing-dialog")!;
const pairingInitiate = document.getElementById("pairing-initiate")!;
const pairingAccept = document.getElementById("pairing-accept")!;
const pairingCode = document.getElementById("pairing-code")!;
const inputPairingCode = document.getElementById("input-pairing-code") as HTMLInputElement;
const btnPairingAccept = document.getElementById("btn-pairing-accept") as HTMLButtonElement;
const btnPairingGenerate = document.getElementById("btn-pairing-generate") as HTMLButtonElement;
const btnPairingCancel = document.getElementById("btn-pairing-cancel") as HTMLButtonElement;
// --- Messaging helper ---
async function sendMessage(type: string, payload?: unknown): Promise<any> {
const response = await runtime.sendMessage({ type, payload });
if (response?.error) {
throw new Error(response.error);
}
return response;
}
// --- UI Updates ---
function showView(loggedIn: boolean) {
viewLogin.style.display = loggedIn ? "none" : "block";
viewMain.style.display = loggedIn ? "block" : "none";
}
function updateConnectionBadge(status: string) {
connectionStatus.className = `status-badge ${status}`;
const label = connectionStatus.querySelector(".label")!;
const labels: Record<string, string> = {
connected: "Connected",
disconnected: "Disconnected",
connecting: "Connecting...",
error: "Error",
};
label.textContent = labels[status] || status;
}
async function refreshStatus() {
try {
const status = await sendMessage("GET_STATUS");
showView(status.loggedIn);
if (status.loggedIn) {
displayName.textContent = status.deviceName || "My Device";
displayDeviceId.textContent = status.deviceId
? status.deviceId.slice(0, 12) + "..."
: "";
updateConnectionBadge(status.connectionStatus);
statCookies.textContent = String(status.cookieCount);
statDevices.textContent = String(status.peerCount);
statSyncs.textContent = String(status.syncCount);
}
} catch (err) {
console.error("Failed to get status:", err);
showView(false);
}
}
// --- Event Handlers ---
btnRegister.addEventListener("click", async () => {
const name = inputDeviceName.value.trim();
if (!name) {
inputDeviceName.focus();
return;
}
btnRegister.disabled = true;
btnRegister.textContent = "Registering...";
try {
await sendMessage("REGISTER_DEVICE", { name });
await refreshStatus();
} catch (err) {
console.error("Registration failed:", err);
btnRegister.textContent = "Registration Failed";
setTimeout(() => {
btnRegister.textContent = "Register Device";
btnRegister.disabled = false;
}, 2000);
}
});
btnSyncTab.addEventListener("click", async () => {
btnSyncTab.disabled = true;
btnSyncTab.textContent = "Syncing...";
try {
await sendMessage("SYNC_CURRENT_TAB");
btnSyncTab.textContent = "Synced!";
setTimeout(() => {
btnSyncTab.textContent = "Sync Current Site";
btnSyncTab.disabled = false;
}, 1500);
} catch (err) {
console.error("Sync failed:", err);
btnSyncTab.textContent = "Sync Failed";
setTimeout(() => {
btnSyncTab.textContent = "Sync Current Site";
btnSyncTab.disabled = false;
}, 2000);
}
});
btnAddWhitelist.addEventListener("click", async () => {
const [tab] = await tabs.query({ active: true, currentWindow: true });
if (!tab?.url) return;
try {
const url = new URL(tab.url);
await sendMessage("ADD_WHITELIST", { domain: url.hostname });
btnAddWhitelist.textContent = "Added!";
setTimeout(() => {
btnAddWhitelist.textContent = "Add to Whitelist";
}, 1500);
} catch (err) {
console.error("Failed to add whitelist:", err);
}
});
// Pairing
btnPair.addEventListener("click", () => {
pairingDialog.style.display = "block";
pairingInitiate.style.display = "none";
pairingAccept.style.display = "block";
inputPairingCode.value = "";
inputPairingCode.focus();
});
btnPairingGenerate.addEventListener("click", async () => {
try {
const result = await sendMessage("INITIATE_PAIRING");
pairingAccept.style.display = "none";
pairingInitiate.style.display = "block";
pairingCode.textContent = result.pairingCode;
} catch (err) {
console.error("Failed to generate pairing code:", err);
}
});
btnPairingAccept.addEventListener("click", async () => {
const code = inputPairingCode.value.trim();
if (code.length !== 6) return;
btnPairingAccept.disabled = true;
try {
await sendMessage("ACCEPT_PAIRING", { code });
pairingDialog.style.display = "none";
await refreshStatus();
} catch (err) {
console.error("Pairing failed:", err);
btnPairingAccept.disabled = false;
}
});
btnPairingCancel.addEventListener("click", () => {
pairingDialog.style.display = "none";
});
btnSettings.addEventListener("click", (e) => {
e.preventDefault();
runtime.openOptionsPage();
});
// --- Init ---
refreshStatus();

18
extension/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"rootDir": "src",
"declaration": false,
"sourceMap": true,
"lib": ["ES2022", "DOM"],
"types": ["chrome"]
},
"include": ["src/**/*.ts"]
}

716
package-lock.json generated
View File

@@ -1,20 +1,25 @@
{ {
"name": "cookiebridge", "name": "cookiebridge",
"version": "1.0.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cookiebridge", "name": "cookiebridge",
"version": "1.0.0", "version": "0.1.0",
"license": "ISC", "license": "MIT",
"dependencies": { "dependencies": {
"better-sqlite3": "^12.8.0",
"jsonwebtoken": "^9.0.3",
"mysql2": "^3.20.0",
"sodium-native": "^5.1.0", "sodium-native": "^5.1.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"ws": "^8.19.0" "ws": "^8.19.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@types/sodium-native": "^2.3.9", "@types/sodium-native": "^2.3.9",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
@@ -823,6 +828,16 @@
"tslib": "^2.4.0" "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": { "node_modules/@types/chai": {
"version": "5.2.3", "version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -848,11 +863,28 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "25.5.0", "version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
@@ -1008,6 +1040,15 @@
"node": ">=12" "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": { "node_modules/b4a": {
"version": "1.8.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz",
@@ -1147,6 +1188,90 @@
"bare": ">=1.2.0" "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",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/chai": { "node_modules/chai": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -1157,6 +1282,12 @@
"node": ">=18" "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": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1164,16 +1295,66 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"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": { "node_modules/es-module-lexer": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
@@ -1242,6 +1423,15 @@
"bare-events": "^2.7.0" "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": { "node_modules/expect-type": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -1276,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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1291,6 +1493,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "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": { "node_modules/get-tsconfig": {
"version": "4.13.6", "version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
@@ -1304,6 +1515,109 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" "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",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -1565,6 +1879,69 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1575,6 +1952,73 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -1594,6 +2038,24 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "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": { "node_modules/obug": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@@ -1605,6 +2067,15 @@
], ],
"license": "MIT" "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": { "node_modules/pathe": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -1661,6 +2132,72 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/require-addon": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.2.0.tgz", "resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.2.0.tgz",
@@ -1717,6 +2254,44 @@
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"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/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",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/siginfo": { "node_modules/siginfo": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -1724,6 +2299,51 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/sodium-native": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-5.1.0.tgz", "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-5.1.0.tgz",
@@ -1748,6 +2368,21 @@
"node": ">=0.10.0" "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": { "node_modules/stackback": {
"version": "0.0.2", "version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -1773,6 +2408,52 @@
"text-decoder": "^1.1.0" "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": { "node_modules/teex": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
@@ -1863,6 +2544,18 @@
"fsevents": "~2.3.3" "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": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1880,7 +2573,12 @@
"version": "7.18.2", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "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" "license": "MIT"
}, },
"node_modules/uuid": { "node_modules/uuid": {
@@ -2080,6 +2778,12 @@
"node": ">=8" "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": { "node_modules/ws": {
"version": "8.19.0", "version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",

View File

@@ -6,23 +6,35 @@
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"scripts": { "scripts": {
"build": "tsc", "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", "start": "tsx src/cli.ts",
"dev": "tsx --watch src/cli.ts", "dev": "tsx --watch src/cli.ts",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"keywords": ["cookies", "sync", "encryption", "browser-extension"], "keywords": [
"cookies",
"sync",
"encryption",
"browser-extension"
],
"author": "Rc707Agency", "author": "Rc707Agency",
"license": "MIT", "license": "MIT",
"type": "commonjs", "type": "commonjs",
"dependencies": { "dependencies": {
"better-sqlite3": "^12.8.0",
"jsonwebtoken": "^9.0.3",
"mysql2": "^3.20.0",
"sodium-native": "^5.1.0", "sodium-native": "^5.1.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"ws": "^8.19.0" "ws": "^8.19.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@types/sodium-native": "^2.3.9", "@types/sodium-native": "^2.3.9",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",

View File

@@ -1,21 +1,59 @@
import path from "node:path";
import fs from "node:fs";
import { RelayServer } from "./relay/index.js"; import { RelayServer } from "./relay/index.js";
import { loadDbConfig, createStores } from "./relay/db/index.js";
const port = parseInt(process.env.PORT ?? "8080", 10); const port = parseInt(process.env.PORT ?? "8080", 10);
const host = process.env.HOST ?? "0.0.0.0"; 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}`); 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 () => { process.on("SIGINT", async () => {
console.log("\nShutting down..."); console.log("\nShutting down...");
await server.stop(); await server.stop();
process.exit(0); process.exit(0);
}); });
process.on("SIGTERM", async () => { process.on("SIGTERM", async () => {
await server.stop(); await server.stop();
process.exit(0); process.exit(0);
});
}
main().catch((err) => {
console.error("Failed to start server:", err);
process.exit(1);
}); });

102
src/relay/admin/auth.ts Normal file
View File

@@ -0,0 +1,102 @@
import crypto from "node:crypto";
import jwt from "jsonwebtoken";
const SCRYPT_KEYLEN = 64;
const SCRYPT_COST = 16384;
const SCRYPT_BLOCK_SIZE = 8;
const SCRYPT_PARALLELISM = 1;
export interface AdminUser {
username: string;
passwordHash: string; // scrypt hash, hex
salt: string; // hex
createdAt: string;
}
export interface AdminSettings {
syncIntervalMs: number;
maxDevices: number;
autoSync: boolean;
theme: "light" | "dark" | "system";
}
const DEFAULT_SETTINGS: AdminSettings = {
syncIntervalMs: 30_000,
maxDevices: 10,
autoSync: true,
theme: "system",
};
/**
* In-memory admin state. Stores admin user, JWT secret, and settings.
* In production this would be persisted to disk/database.
*/
export class AdminStore {
private adminUser: AdminUser | null = null;
private jwtSecret: string;
private settings: AdminSettings = { ...DEFAULT_SETTINGS };
constructor() {
this.jwtSecret = crypto.randomBytes(32).toString("hex");
}
get isSetUp(): boolean {
return this.adminUser !== null;
}
/** First-time setup: create the admin account. */
async setup(username: string, password: string): Promise<void> {
if (this.adminUser) throw new Error("Already configured");
const salt = crypto.randomBytes(16).toString("hex");
const hash = await this.hashPassword(password, salt);
this.adminUser = {
username,
passwordHash: hash,
salt,
createdAt: new Date().toISOString(),
};
}
/** Authenticate and return a JWT. */
async login(username: string, password: string): Promise<string> {
if (!this.adminUser) throw new Error("Not configured");
if (this.adminUser.username !== username) throw new Error("Invalid credentials");
const hash = await this.hashPassword(password, this.adminUser.salt);
if (hash !== this.adminUser.passwordHash) throw new Error("Invalid credentials");
return jwt.sign({ sub: username, role: "admin" }, this.jwtSecret, { expiresIn: "24h" });
}
/** Verify a JWT and return the payload. */
verifyToken(token: string): { sub: string; role: string } {
return jwt.verify(token, this.jwtSecret) as { sub: string; role: string };
}
getUser(): { username: string; createdAt: string } | null {
if (!this.adminUser) return null;
return { username: this.adminUser.username, createdAt: this.adminUser.createdAt };
}
getSettings(): AdminSettings {
return { ...this.settings };
}
updateSettings(patch: Partial<AdminSettings>): AdminSettings {
Object.assign(this.settings, patch);
return { ...this.settings };
}
private 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"));
},
);
});
}
}

355
src/relay/admin/routes.ts Normal file
View File

@@ -0,0 +1,355 @@
import http from "node:http";
import type { ConnectionManager } from "../connections.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: IAdminStore;
connections: ConnectionManager;
cookieStore: ICookieStore;
deviceRegistry: IDeviceStore;
server: RelayServer;
}
/**
* Handle /admin/* routes. Returns true if the route was handled.
*/
export function handleAdminRoute(
req: http.IncomingMessage,
res: http.ServerResponse,
deps: AdminDeps,
): boolean {
const url = req.url ?? "";
const method = req.method ?? "";
if (!url.startsWith("/admin/")) return false;
// --- Public routes (no auth) ---
if (method === "GET" && url === "/admin/setup/status") {
const dbConfig = loadDbConfig();
json(res, 200, {
isSetUp: deps.adminStore.isSetUp,
dbConfigured: dbConfig !== null,
dbType: dbConfig?.type ?? null,
});
return true;
}
if (method === "POST" && url === "/admin/setup/init") {
handleSetupInit(req, res, deps);
return true;
}
if (method === "POST" && url === "/admin/auth/login") {
handleLogin(req, res, deps);
return true;
}
// --- Protected routes ---
const user = authenticate(req, deps.adminStore);
if (!user) {
json(res, 401, { error: "Unauthorized" });
return true;
}
if (method === "POST" && url === "/admin/auth/logout") {
json(res, 200, { ok: true });
return true;
}
if (method === "GET" && url === "/admin/auth/me") {
const info = deps.adminStore.getUser();
json(res, 200, info);
return true;
}
if (method === "GET" && url === "/admin/dashboard") {
handleDashboard(res, deps);
return true;
}
// Cookie management
if (method === "GET" && url.startsWith("/admin/cookies")) {
handleCookieList(req, res, deps);
return true;
}
if (method === "DELETE" && url.startsWith("/admin/cookies/")) {
handleCookieDeleteById(req, res, deps);
return true;
}
if (method === "DELETE" && url === "/admin/cookies") {
handleCookieBatchDelete(req, res, deps);
return true;
}
// Device management
if (method === "GET" && url === "/admin/devices") {
handleDeviceList(res, deps);
return true;
}
if (method === "POST" && url.match(/^\/admin\/devices\/[^/]+\/revoke$/)) {
handleDeviceRevoke(req, res, deps);
return true;
}
// Settings
if (method === "GET" && url === "/admin/settings") {
json(res, 200, deps.adminStore.getSettings());
return true;
}
if (method === "PATCH" && url === "/admin/settings") {
handleSettingsUpdate(req, res, deps);
return true;
}
json(res, 404, { error: "Admin route not found" });
return true;
}
// --- Auth helpers ---
function authenticate(
req: http.IncomingMessage,
store: IAdminStore,
): { sub: string; role: string } | null {
const auth = req.headers.authorization;
if (!auth?.startsWith("Bearer ")) return null;
try {
return store.verifyToken(auth.slice(7));
} catch {
return null;
}
}
// --- Route handlers ---
function handleSetupInit(
req: http.IncomingMessage,
res: http.ServerResponse,
deps: AdminDeps,
): void {
readBody(req, async (body) => {
try {
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;
}
await deps.adminStore.setup(username, password);
const token = await deps.adminStore.login(username, password);
json(res, 201, { token, username });
} catch {
json(res, 400, { error: "Invalid request" });
}
});
}
function handleLogin(
req: http.IncomingMessage,
res: http.ServerResponse,
deps: AdminDeps,
): void {
readBody(req, async (body) => {
try {
const { username, password } = JSON.parse(body);
if (!username || !password) {
json(res, 400, { error: "Missing username or password" });
return;
}
const token = await deps.adminStore.login(username, password);
json(res, 200, { token });
} catch {
json(res, 401, { error: "Invalid credentials" });
}
});
}
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 = await deps.cookieStore.getAll();
const domains = new Set(allCookies.map((c) => c.domain));
json(res, 200, {
connections: deps.connections.connectedCount,
totalDevices: devices.length,
onlineDevices: onlineDeviceIds.length,
totalCookies: allCookies.length,
uniqueDomains: domains.size,
});
}
async function handleCookieList(
req: http.IncomingMessage,
res: http.ServerResponse,
deps: AdminDeps,
): 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 = await deps.cookieStore.getById(idMatch[1]);
if (!cookie) {
json(res, 404, { error: "Cookie not found" });
return;
}
json(res, 200, cookie);
return;
}
const domain = parsed.searchParams.get("domain") ?? undefined;
const search = parsed.searchParams.get("q") ?? undefined;
const page = parseInt(parsed.searchParams.get("page") ?? "1", 10);
const limit = Math.min(parseInt(parsed.searchParams.get("limit") ?? "50", 10), 200);
let cookies = await deps.cookieStore.getAll();
if (domain) {
cookies = cookies.filter((c) => c.domain === domain);
}
if (search) {
const q = search.toLowerCase();
cookies = cookies.filter(
(c) => c.domain.toLowerCase().includes(q) || c.cookieName.toLowerCase().includes(q),
);
}
const total = cookies.length;
const offset = (page - 1) * limit;
const items = cookies.slice(offset, offset + limit);
json(res, 200, { items, total, page, limit });
}
async function handleCookieDeleteById(
_req: http.IncomingMessage,
res: http.ServerResponse,
deps: AdminDeps,
): 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 = await deps.cookieStore.deleteById(idMatch[1]);
json(res, 200, { deleted });
}
function handleCookieBatchDelete(
req: http.IncomingMessage,
res: http.ServerResponse,
deps: AdminDeps,
): void {
readBody(req, async (body) => {
try {
const { ids } = JSON.parse(body) as { ids: string[] };
if (!ids || !Array.isArray(ids)) {
json(res, 400, { error: "Missing ids array" });
return;
}
let count = 0;
for (const id of ids) {
if (await deps.cookieStore.deleteById(id)) count++;
}
json(res, 200, { deleted: count });
} catch {
json(res, 400, { error: "Invalid JSON" });
}
});
}
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,
createdAt: d.createdAt,
online: deps.connections.isOnline(d.deviceId),
}));
json(res, 200, { devices });
}
async function handleDeviceRevoke(
req: http.IncomingMessage,
res: http.ServerResponse,
deps: AdminDeps,
): Promise<void> {
const parsed = new URL(req.url ?? "", `http://${req.headers.host}`);
const match = parsed.pathname.match(/^\/admin\/devices\/([^/]+)\/revoke$/);
if (!match) {
json(res, 400, { error: "Invalid device ID" });
return;
}
const deviceId = match[1];
const revoked = await deps.deviceRegistry.revoke(deviceId);
if (revoked) {
deps.connections.disconnect(deviceId);
}
json(res, 200, { revoked });
}
function handleSettingsUpdate(
req: http.IncomingMessage,
res: http.ServerResponse,
deps: AdminDeps,
): void {
readBody(req, (body) => {
try {
const patch = JSON.parse(body);
const updated = deps.adminStore.updateSettings(patch);
json(res, 200, updated);
} catch {
json(res, 400, { error: "Invalid JSON" });
}
});
}
// --- Helpers ---
function json(res: http.ServerResponse, status: number, data: unknown): void {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
}
function readBody(req: http.IncomingMessage, cb: (body: string) => void): void {
let data = "";
req.on("data", (chunk: Buffer) => {
data += chunk.toString();
if (data.length > 64 * 1024) req.destroy();
});
req.on("end", () => cb(data));
}

View File

@@ -61,6 +61,15 @@ export class ConnectionManager {
return conn !== undefined && conn.ws.readyState === 1; return conn !== undefined && conn.ws.readyState === 1;
} }
/** Forcibly disconnect a device. */
disconnect(deviceId: string): void {
const conn = this.connections.get(deviceId);
if (conn) {
conn.ws.close(4004, "Revoked");
this.connections.delete(deviceId);
}
}
/** Get count of connected devices. */ /** Get count of connected devices. */
get connectedCount(): number { get connectedCount(): number {
return this.connections.size; return this.connections.size;

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 { ConnectionManager } from "./connections.js";
export { CookieBlobStore } from "./store.js"; export { CookieBlobStore } from "./store.js";
export { DeviceRegistry, AgentRegistry } from "./tokens.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,21 +1,27 @@
import { WebSocketServer, WebSocket } from "ws"; import { WebSocketServer, WebSocket } from "ws";
import http from "node:http"; import http from "node:http";
import path from "node:path";
import { ConnectionManager } from "./connections.js"; import { ConnectionManager } from "./connections.js";
import { generateChallenge, verifyAuthResponse } from "./auth.js"; import { generateChallenge, verifyAuthResponse } from "./auth.js";
import { verify, buildSignablePayload } from "../crypto/signing.js"; import { verify, buildSignablePayload } from "../crypto/signing.js";
import { PairingStore } from "../pairing/pairing.js"; import { PairingStore } from "../pairing/pairing.js";
import { CookieBlobStore } from "./store.js";
import { DeviceRegistry, AgentRegistry } from "./tokens.js";
import { import {
type Envelope, type Envelope,
type EncryptedCookieBlob, type EncryptedCookieBlob,
MESSAGE_TYPES, MESSAGE_TYPES,
PING_INTERVAL_MS, PING_INTERVAL_MS,
} from "../protocol/spec.js"; } from "../protocol/spec.js";
import { handleAdminRoute } from "./admin/routes.js";
import { serveStatic } from "./static.js";
import type { DataStores, ICookieStore, IDeviceStore, IAgentStore, IAdminStore } from "./db/types.js";
import { createMemoryStores } from "./db/memory.js";
export interface RelayServerConfig { export interface RelayServerConfig {
port: number; port: number;
host?: string; host?: string;
stores?: DataStores;
/** Directory containing pre-built frontend assets. Serves static files + SPA fallback. */
publicDir?: string;
} }
interface PendingAuth { interface PendingAuth {
@@ -47,9 +53,11 @@ export class RelayServer {
private wss: WebSocketServer; private wss: WebSocketServer;
readonly connections: ConnectionManager; readonly connections: ConnectionManager;
readonly pairingStore: PairingStore; readonly pairingStore: PairingStore;
readonly cookieStore: CookieBlobStore; cookieStore: ICookieStore;
readonly deviceRegistry: DeviceRegistry; deviceRegistry: IDeviceStore;
readonly agentRegistry: AgentRegistry; agentRegistry: IAgentStore;
adminStore: IAdminStore;
private stores: DataStores;
private pendingAuths = new Map<WebSocket, PendingAuth>(); private pendingAuths = new Map<WebSocket, PendingAuth>();
private authenticatedDevices = new Map<WebSocket, string>(); private authenticatedDevices = new Map<WebSocket, string>();
private pingIntervals = new Map<WebSocket, ReturnType<typeof setInterval>>(); private pingIntervals = new Map<WebSocket, ReturnType<typeof setInterval>>();
@@ -57,15 +65,27 @@ export class RelayServer {
constructor(private config: RelayServerConfig) { constructor(private config: RelayServerConfig) {
this.connections = new ConnectionManager(); this.connections = new ConnectionManager();
this.pairingStore = new PairingStore(); this.pairingStore = new PairingStore();
this.cookieStore = new CookieBlobStore();
this.deviceRegistry = new DeviceRegistry(); this.stores = config.stores ?? createMemoryStores();
this.agentRegistry = new AgentRegistry(); 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.httpServer = http.createServer(this.handleHttp.bind(this));
this.wss = new WebSocketServer({ server: this.httpServer }); this.wss = new WebSocketServer({ server: this.httpServer });
this.wss.on("connection", this.handleConnection.bind(this)); 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> { start(): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
this.httpServer.listen( this.httpServer.listen(
@@ -76,15 +96,16 @@ export class RelayServer {
}); });
} }
stop(): Promise<void> { async stop(): Promise<void> {
return new Promise((resolve) => { for (const interval of this.pingIntervals.values()) {
for (const interval of this.pingIntervals.values()) { clearInterval(interval);
clearInterval(interval); }
} await new Promise<void>((resolve) => {
this.wss.close(() => { this.wss.close(() => {
this.httpServer.close(() => resolve()); this.httpServer.close(() => resolve());
}); });
}); });
await this.stores.close();
} }
get port(): number { get port(): number {
@@ -99,6 +120,18 @@ export class RelayServer {
const url = req.url ?? ""; const url = req.url ?? "";
const method = req.method ?? ""; const method = req.method ?? "";
// Admin routes
if (url.startsWith("/admin/")) {
handleAdminRoute(req, res, {
adminStore: this.adminStore,
connections: this.connections,
cookieStore: this.cookieStore,
deviceRegistry: this.deviceRegistry,
server: this,
});
return;
}
// Health // Health
if (method === "GET" && url === "/health") { if (method === "GET" && url === "/health") {
this.json(res, 200, { status: "ok", connections: this.connections.connectedCount }); this.json(res, 200, { status: "ok", connections: this.connections.connectedCount });
@@ -153,6 +186,11 @@ export class RelayServer {
return; 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.writeHead(404);
res.end("Not found"); res.end("Not found");
} }
@@ -169,12 +207,13 @@ export class RelayServer {
this.json(res, 401, { error: "Missing Authorization header" }); this.json(res, 401, { error: "Missing Authorization header" });
return; return;
} }
const device = this.deviceRegistry.getByToken(token); this.deviceRegistry.getByToken(token).then((device) => {
if (!device) { if (!device) {
this.json(res, 401, { error: "Invalid token" }); this.json(res, 401, { error: "Invalid token" });
return; return;
} }
handler({ deviceId: device.deviceId }); handler({ deviceId: device.deviceId });
});
} }
private extractBearerToken(req: http.IncomingMessage): string | null { private extractBearerToken(req: http.IncomingMessage): string | null {
@@ -186,14 +225,14 @@ export class RelayServer {
// --- Device Registration --- // --- Device Registration ---
private handleDeviceRegister(req: http.IncomingMessage, res: http.ServerResponse): void { private handleDeviceRegister(req: http.IncomingMessage, res: http.ServerResponse): void {
this.readBody(req, (body) => { this.readBody(req, async (body) => {
try { try {
const { deviceId, name, platform, encPub } = JSON.parse(body); const { deviceId, name, platform, encPub } = JSON.parse(body);
if (!deviceId || !name || !platform || !encPub) { if (!deviceId || !name || !platform || !encPub) {
this.json(res, 400, { error: "Missing required fields: deviceId, name, platform, encPub" }); this.json(res, 400, { error: "Missing required fields: deviceId, name, platform, encPub" });
return; return;
} }
const info = this.deviceRegistry.register(deviceId, name, platform, encPub); const info = await this.deviceRegistry.register(deviceId, name, platform, encPub);
this.json(res, 201, { this.json(res, 201, {
deviceId: info.deviceId, deviceId: info.deviceId,
token: info.token, token: info.token,
@@ -224,7 +263,7 @@ export class RelayServer {
} }
private handlePairAccept(req: http.IncomingMessage, res: http.ServerResponse): void { private handlePairAccept(req: http.IncomingMessage, res: http.ServerResponse): void {
this.readBody(req, (body) => { this.readBody(req, async (body) => {
try { try {
const { deviceId, x25519PubKey, pairingCode } = JSON.parse(body); const { deviceId, x25519PubKey, pairingCode } = JSON.parse(body);
if (!deviceId || !x25519PubKey || !pairingCode) { if (!deviceId || !x25519PubKey || !pairingCode) {
@@ -238,7 +277,7 @@ export class RelayServer {
} }
// Record the pairing in device registry // Record the pairing in device registry
this.deviceRegistry.addPairing(session.deviceId, deviceId); await this.deviceRegistry.addPairing(session.deviceId, deviceId);
this.json(res, 200, { this.json(res, 200, {
initiator: { deviceId: session.deviceId, x25519PubKey: session.x25519PubKey }, initiator: { deviceId: session.deviceId, x25519PubKey: session.x25519PubKey },
@@ -257,7 +296,7 @@ export class RelayServer {
res: http.ServerResponse, res: http.ServerResponse,
device: { deviceId: string }, device: { deviceId: string },
): void { ): void {
this.readBody(req, (body) => { this.readBody(req, async (body) => {
try { try {
const { cookies } = JSON.parse(body) as { const { cookies } = JSON.parse(body) as {
cookies: Array<Omit<EncryptedCookieBlob, "id" | "updatedAt" | "deviceId">>; cookies: Array<Omit<EncryptedCookieBlob, "id" | "updatedAt" | "deviceId">>;
@@ -267,12 +306,13 @@ export class RelayServer {
return; return;
} }
const stored = cookies.map((c) => const stored: EncryptedCookieBlob[] = [];
this.cookieStore.upsert({ ...c, deviceId: device.deviceId }), for (const c of cookies) {
); stored.push(await this.cookieStore.upsert({ ...c, deviceId: device.deviceId }));
}
// Notify paired devices via WebSocket if connected // 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) { for (const peerId of pairedDevices) {
if (this.connections.isOnline(peerId)) { if (this.connections.isOnline(peerId)) {
this.connections.send(peerId, { this.connections.send(peerId, {
@@ -294,26 +334,26 @@ export class RelayServer {
}); });
} }
private handleCookiePull( private async handleCookiePull(
_req: http.IncomingMessage, _req: http.IncomingMessage,
res: http.ServerResponse, res: http.ServerResponse,
device: { deviceId: string }, device: { deviceId: string },
): void { ): Promise<void> {
const url = new URL(_req.url ?? "", `http://${_req.headers.host}`); const url = new URL(_req.url ?? "", `http://${_req.headers.host}`);
const domain = url.searchParams.get("domain") ?? undefined; const domain = url.searchParams.get("domain") ?? undefined;
// Get cookies from all paired devices // Get cookies from all paired devices
const group = this.deviceRegistry.getPairingGroup(device.deviceId); const group = await this.deviceRegistry.getPairingGroup(device.deviceId);
const blobs = this.cookieStore.getByDevices(group, domain); const blobs = await this.cookieStore.getByDevices(group, domain);
this.json(res, 200, { cookies: blobs }); this.json(res, 200, { cookies: blobs });
} }
private handleCookiePoll( private async handleCookiePoll(
_req: http.IncomingMessage, _req: http.IncomingMessage,
res: http.ServerResponse, res: http.ServerResponse,
device: { deviceId: string }, device: { deviceId: string },
): void { ): Promise<void> {
const url = new URL(_req.url ?? "", `http://${_req.headers.host}`); const url = new URL(_req.url ?? "", `http://${_req.headers.host}`);
const since = url.searchParams.get("since"); const since = url.searchParams.get("since");
if (!since) { if (!since) {
@@ -321,8 +361,8 @@ export class RelayServer {
return; return;
} }
const group = this.deviceRegistry.getPairingGroup(device.deviceId); const group = await this.deviceRegistry.getPairingGroup(device.deviceId);
const blobs = this.cookieStore.getUpdatedSince(group, since); const blobs = await this.cookieStore.getUpdatedSince(group, since);
this.json(res, 200, { cookies: blobs, serverTime: new Date().toISOString() }); this.json(res, 200, { cookies: blobs, serverTime: new Date().toISOString() });
} }
@@ -332,14 +372,14 @@ export class RelayServer {
res: http.ServerResponse, res: http.ServerResponse,
device: { deviceId: string }, device: { deviceId: string },
): void { ): void {
this.readBody(req, (body) => { this.readBody(req, async (body) => {
try { try {
const { domain, cookieName, path } = JSON.parse(body); const { domain, cookieName, path } = JSON.parse(body);
if (!domain || !cookieName || !path) { if (!domain || !cookieName || !path) {
this.json(res, 400, { error: "Missing domain, cookieName, or path" }); this.json(res, 400, { error: "Missing domain, cookieName, or path" });
return; 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 }); this.json(res, 200, { deleted });
} catch { } catch {
this.json(res, 400, { error: "Invalid JSON" }); this.json(res, 400, { error: "Invalid JSON" });
@@ -354,22 +394,22 @@ export class RelayServer {
res: http.ServerResponse, res: http.ServerResponse,
_device: { deviceId: string }, _device: { deviceId: string },
): void { ): void {
this.readBody(req, (body) => { this.readBody(req, async (body) => {
try { try {
const { name, encPub, allowedDomains } = JSON.parse(body); const { name, encPub, allowedDomains } = JSON.parse(body);
if (!name || !encPub) { if (!name || !encPub) {
this.json(res, 400, { error: "Missing name or encPub" }); this.json(res, 400, { error: "Missing name or encPub" });
return; 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 // 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 // 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) { 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 }); this.json(res, 201, { id: agent.id, token: agent.token, name: agent.name });
@@ -384,14 +424,14 @@ export class RelayServer {
res: http.ServerResponse, res: http.ServerResponse,
device: { deviceId: string }, device: { deviceId: string },
): void { ): void {
this.readBody(req, (body) => { this.readBody(req, async (body) => {
try { try {
const { agentId } = JSON.parse(body); const { agentId } = JSON.parse(body);
if (!agentId) { if (!agentId) {
this.json(res, 400, { error: "Missing agentId" }); this.json(res, 400, { error: "Missing agentId" });
return; return;
} }
this.agentRegistry.grantAccess(agentId, device.deviceId); await this.agentRegistry.grantAccess(agentId, device.deviceId);
this.json(res, 200, { granted: true }); this.json(res, 200, { granted: true });
} catch { } catch {
this.json(res, 400, { error: "Invalid JSON" }); this.json(res, 400, { error: "Invalid JSON" });
@@ -399,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); const token = this.extractBearerToken(req);
if (!token) { if (!token) {
this.json(res, 401, { error: "Missing Authorization header" }); this.json(res, 401, { error: "Missing Authorization header" });
return; return;
} }
const agent = this.agentRegistry.getByToken(token); const agent = await this.agentRegistry.getByToken(token);
if (!agent) { if (!agent) {
this.json(res, 401, { error: "Invalid agent token" }); this.json(res, 401, { error: "Invalid agent token" });
return; return;
@@ -420,8 +460,8 @@ export class RelayServer {
return; return;
} }
const deviceIds = this.agentRegistry.getAccessibleDevices(agent.id); const deviceIds = await this.agentRegistry.getAccessibleDevices(agent.id);
const blobs = this.cookieStore.getByDevices(deviceIds, domain); const blobs = await this.cookieStore.getByDevices(deviceIds, domain);
this.json(res, 200, { cookies: blobs, agentEncPub: agent.encPub }); this.json(res, 200, { cookies: blobs, agentEncPub: agent.encPub });
} }
@@ -495,23 +535,24 @@ export class RelayServer {
return; return;
} }
const device = this.deviceRegistry.getByToken(token); this.deviceRegistry.getByToken(token).then((device) => {
if (!device) { if (!device) {
ws.close(4003, "Invalid token"); ws.close(4003, "Invalid token");
return; 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 }));
} }
}, 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 { 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

@@ -80,6 +80,38 @@ export class CookieBlobStore {
return result; return result;
} }
/** Get all stored blobs across all devices. */
getAll(): EncryptedCookieBlob[] {
const result: EncryptedCookieBlob[] = [];
for (const deviceMap of this.store.values()) {
result.push(...deviceMap.values());
}
return result;
}
/** Get a single blob by its ID. */
getById(id: string): EncryptedCookieBlob | null {
for (const deviceMap of this.store.values()) {
for (const blob of deviceMap.values()) {
if (blob.id === id) return blob;
}
}
return null;
}
/** Delete a blob by its ID. Returns true if found and deleted. */
deleteById(id: string): boolean {
for (const deviceMap of this.store.values()) {
for (const [key, blob] of deviceMap) {
if (blob.id === id) {
deviceMap.delete(key);
return true;
}
}
}
return false;
}
/** Get all blobs updated after a given timestamp (for polling). */ /** Get all blobs updated after a given timestamp (for polling). */
getUpdatedSince(deviceIds: string[], since: string): EncryptedCookieBlob[] { getUpdatedSince(deviceIds: string[], since: string): EncryptedCookieBlob[] {
const result: EncryptedCookieBlob[] = []; const result: EncryptedCookieBlob[] = [];

View File

@@ -72,6 +72,28 @@ export class DeviceRegistry {
const paired = this.getPairedDevices(deviceId); const paired = this.getPairedDevices(deviceId);
return [deviceId, ...paired]; return [deviceId, ...paired];
} }
/** List all registered devices. */
listAll(): DeviceInfo[] {
return Array.from(this.devices.values());
}
/** Revoke a device: remove its token and registration. Returns true if it existed. */
revoke(deviceId: string): boolean {
const device = this.devices.get(deviceId);
if (!device) return false;
this.tokenToDevice.delete(device.token);
this.devices.delete(deviceId);
// Clean up pairings
const paired = this.pairings.get(deviceId);
if (paired) {
for (const peerId of paired) {
this.pairings.get(peerId)?.delete(deviceId);
}
this.pairings.delete(deviceId);
}
return true;
}
} }
/** /**

View File

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

11
web/env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<
Record<string, unknown>,
Record<string, unknown>,
unknown
>;
export default component;
}

12
web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CookieBridge Admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2694
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
web/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "cookiebridge-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"test:e2e": "playwright test tests/e2e/",
"test:e2e:ui": "playwright test tests/e2e/ --ui",
"test:e2e:headed": "playwright test tests/e2e/ --headed",
"test:api": "playwright test tests/api/ --project=chromium",
"test:all": "playwright test"
},
"dependencies": {
"@headlessui/vue": "^1.7.0",
"@heroicons/vue": "^2.2.0",
"axios": "^1.8.0",
"pinia": "^3.0.0",
"vue": "^3.5.0",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-vue": "^5.2.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.9.0",
"vite": "^6.2.0",
"vue-tsc": "^2.2.0"
}
}

60
web/playwright.config.ts Normal file
View File

@@ -0,0 +1,60 @@
import { defineConfig, devices } from "@playwright/test";
/**
* CookieBridge Admin Frontend - Playwright E2E Test Configuration
*
* Prerequisites: RCA-12 (scaffold), RCA-13 (API), RCA-14 (login),
* RCA-15 (dashboard), RCA-16 (cookies), RCA-17 (devices), RCA-18 (settings)
* must all be complete before running these tests.
*
* Usage:
* npm run test:e2e — run all tests headless
* npm run test:e2e:ui — interactive UI mode
* npm run test:e2e:headed — run with browser visible
*/
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [["html", { open: "never" }], ["list"]],
use: {
baseURL: process.env.BASE_URL ?? "http://localhost:5173",
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
{
name: "mobile-chrome",
use: { ...devices["Pixel 5"] },
},
{
name: "mobile-safari",
use: { ...devices["iPhone 12"] },
},
{
name: "tablet",
use: { ...devices["iPad Pro 11"] },
},
],
webServer: {
command: "npm run dev",
url: "http://localhost:5173",
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
});

3
web/src/App.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

30
web/src/api/client.ts Normal file
View File

@@ -0,0 +1,30 @@
import axios from "axios";
const api = axios.create({
baseURL: "/admin",
headers: { "Content-Type": "application/json" },
});
// Attach auth token to requests
api.interceptors.request.use((config) => {
const token = localStorage.getItem("cb_admin_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle 401 responses (skip login endpoint — 401 there means bad credentials, not expired session)
api.interceptors.response.use(
(response) => response,
(error) => {
const url = error.config?.url ?? "";
if (error.response?.status === 401 && !url.includes("/auth/login")) {
localStorage.removeItem("cb_admin_token");
window.location.href = "/login";
}
return Promise.reject(error);
},
);
export default api;

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";
const router = useRouter();
const auth = useAuthStore();
const navItems = [
{ name: "Dashboard", path: "/", icon: "📊" },
{ name: "Cookies", path: "/cookies", icon: "🍪" },
{ name: "Devices", path: "/devices", icon: "📱" },
{ name: "Settings", path: "/settings", icon: "⚙️" },
];
function logout() {
auth.logout();
router.push("/login");
}
</script>
<template>
<div class="flex h-screen bg-gray-50">
<!-- Sidebar -->
<aside class="w-64 border-r border-gray-200 bg-white">
<div class="flex h-16 items-center border-b border-gray-200 px-6">
<h1 class="text-lg font-semibold text-gray-900">CookieBridge</h1>
</div>
<nav class="mt-4 space-y-1 px-3">
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100"
active-class="!bg-blue-50 !text-blue-700"
>
<span>{{ item.icon }}</span>
<span>{{ item.name }}</span>
</router-link>
</nav>
<div class="absolute bottom-0 w-64 border-t border-gray-200 p-4">
<button
class="w-full rounded-lg px-3 py-2 text-left text-sm font-medium text-gray-600 hover:bg-gray-100"
@click="logout"
>
Sign Out
</button>
</div>
</aside>
<!-- Main content -->
<main class="flex-1 overflow-auto">
<router-view />
</main>
</div>
</template>

10
web/src/main.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import router from "./router";
import App from "./App.vue";
import "./style.css";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");

94
web/src/router/index.ts Normal file
View File

@@ -0,0 +1,94 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import api from "@/api/client";
const routes: RouteRecordRaw[] = [
{
path: "/login",
name: "login",
component: () => import("@/views/LoginView.vue"),
meta: { requiresAuth: false },
},
{
path: "/setup",
name: "setup",
component: () => import("@/views/SetupView.vue"),
meta: { requiresAuth: false },
},
{
path: "/",
component: () => import("@/components/layout/AppLayout.vue"),
meta: { requiresAuth: true },
redirect: "/dashboard",
children: [
{
path: "dashboard",
name: "dashboard",
component: () => import("@/views/DashboardView.vue"),
},
{
path: "cookies",
name: "cookies",
component: () => import("@/views/CookiesView.vue"),
},
{
path: "devices",
name: "devices",
component: () => import("@/views/DevicesView.vue"),
},
{
path: "settings",
name: "settings",
component: () => import("@/views/SettingsView.vue"),
},
],
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
let setupChecked = false;
let isSetUp = false;
router.beforeEach(async (to) => {
const auth = useAuthStore();
// Check setup status once on first navigation
if (!setupChecked) {
try {
const { data } = await api.get("/setup/status");
isSetUp = data.initialised;
} catch {
// If server unreachable, assume setup done
isSetUp = true;
}
setupChecked = true;
}
// Redirect to setup if not configured (unless already on setup page)
if (!isSetUp && to.name !== "setup") {
return { name: "setup" };
}
// After setup is done, don't allow revisiting setup
if (isSetUp && to.name === "setup") {
return { name: "login" };
}
if (to.meta.requiresAuth !== false && !auth.isAuthenticated) {
return { name: "login" };
}
if (to.name === "login" && auth.isAuthenticated) {
return { name: "dashboard" };
}
});
// Allow marking setup as complete from the setup view
export function markSetupComplete(): void {
isSetUp = true;
}
export default router;

22
web/src/stores/auth.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import api from "@/api/client";
export const useAuthStore = defineStore("auth", () => {
const token = ref<string | null>(localStorage.getItem("cb_admin_token"));
const isAuthenticated = computed(() => !!token.value);
async function login(username: string, password: string): Promise<void> {
const { data } = await api.post("/auth/login", { username, password }, { baseURL: "/admin" });
token.value = data.token;
localStorage.setItem("cb_admin_token", data.token);
}
function logout(): void {
token.value = null;
localStorage.removeItem("cb_admin_token");
}
return { token, isAuthenticated, login, logout };
});

60
web/src/stores/cookies.ts Normal file
View File

@@ -0,0 +1,60 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import api from "@/api/client";
import type { EncryptedCookieBlob } from "@/types/api";
export const useCookiesStore = defineStore("cookies", () => {
const cookies = ref<EncryptedCookieBlob[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const domains = computed(() => {
const set = new Set(cookies.value.map((c) => c.domain));
return Array.from(set).sort();
});
const byDomain = computed(() => {
const map = new Map<string, EncryptedCookieBlob[]>();
for (const cookie of cookies.value) {
const list = map.get(cookie.domain) ?? [];
list.push(cookie);
map.set(cookie.domain, list);
}
return map;
});
async function fetchCookies(domain?: string): Promise<void> {
loading.value = true;
error.value = null;
try {
const params: Record<string, string> = { limit: "200" };
if (domain) params.domain = domain;
const { data } = await api.get("/cookies", { params });
cookies.value = data.items ?? data.cookies ?? [];
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : "Failed to fetch cookies";
} finally {
loading.value = false;
}
}
async function deleteCookie(
domain: string,
cookieName: string,
path: string,
): Promise<void> {
// Find the cookie ID first, then delete by ID
const cookie = cookies.value.find(
(c) => c.domain === domain && c.cookieName === cookieName && c.path === path,
);
if (cookie) {
await api.delete(`/cookies/${cookie.id}`);
}
cookies.value = cookies.value.filter(
(c) =>
!(c.domain === domain && c.cookieName === cookieName && c.path === path),
);
}
return { cookies, loading, error, domains, byDomain, fetchCookies, deleteCookie };
});

30
web/src/stores/devices.ts Normal file
View File

@@ -0,0 +1,30 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import api from "@/api/client";
import type { DeviceInfo } from "@/types/api";
export const useDevicesStore = defineStore("devices", () => {
const devices = ref<DeviceInfo[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
async function fetchDevices(): Promise<void> {
loading.value = true;
error.value = null;
try {
const { data } = await api.get("/devices");
devices.value = data.devices;
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : "Failed to fetch devices";
} finally {
loading.value = false;
}
}
async function revokeDevice(deviceId: string): Promise<void> {
await api.post(`/devices/${deviceId}/revoke`);
devices.value = devices.value.filter((d) => d.deviceId !== deviceId);
}
return { devices, loading, error, fetchDevices, revokeDevice };
});

View File

@@ -0,0 +1,43 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import api from "@/api/client";
export interface AppSettings {
syncIntervalMs: number;
maxDevices: number;
autoSync: boolean;
theme: "light" | "dark" | "system";
sessionTimeoutMinutes: number;
language: string;
}
const DEFAULT_SETTINGS: AppSettings = {
syncIntervalMs: 30_000,
maxDevices: 10,
autoSync: true,
theme: "system",
sessionTimeoutMinutes: 60,
language: "en",
};
export const useSettingsStore = defineStore("settings", () => {
const settings = ref<AppSettings>({ ...DEFAULT_SETTINGS });
const loading = ref(false);
async function fetchSettings(): Promise<void> {
loading.value = true;
try {
const { data } = await api.get("/settings");
settings.value = { ...DEFAULT_SETTINGS, ...data };
} finally {
loading.value = false;
}
}
async function updateSettings(patch: Partial<AppSettings>): Promise<void> {
const { data } = await api.patch("/settings", patch);
settings.value = { ...settings.value, ...data };
}
return { settings, loading, fetchSettings, updateSettings };
});

1
web/src/style.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

62
web/src/types/api.ts Normal file
View File

@@ -0,0 +1,62 @@
/** Device registration response */
export interface DeviceInfo {
deviceId: string;
name: string;
platform: string;
encPub: string;
token: string;
createdAt: string;
}
/** Encrypted cookie blob stored on the relay server */
export interface EncryptedCookieBlob {
id: string;
deviceId: string;
domain: string;
cookieName: string;
path: string;
ciphertext: string;
nonce: string;
lamportTs: number;
updatedAt: string;
}
/** Agent token */
export interface AgentToken {
id: string;
name: string;
token: string;
encPub: string;
allowedDomains: string[];
createdAt: string;
}
/** Pairing session */
export interface PairingSession {
pairingCode: string;
expiresAt: string;
}
/** Pairing accept response */
export interface PairingResult {
initiator: { deviceId: string; x25519PubKey: string };
acceptor: { deviceId: string; x25519PubKey: string };
}
/** Health check response */
export interface HealthStatus {
status: string;
connections: number;
}
/** Login credentials for admin auth */
export interface LoginCredentials {
username: string;
password: string;
}
/** Auth token response */
export interface AuthResponse {
token: string;
expiresAt: string;
}

View File

@@ -0,0 +1,363 @@
<script setup lang="ts">
import { onMounted, ref, computed, watch } from "vue";
import { useCookiesStore } from "@/stores/cookies";
import type { EncryptedCookieBlob } from "@/types/api";
const store = useCookiesStore();
const search = ref("");
const selectedDomain = ref<string | null>(null);
const selectedCookie = ref<EncryptedCookieBlob | null>(null);
const selectedIds = ref<Set<string>>(new Set());
const expandedDomains = ref<Set<string>>(new Set());
const confirmingDeleteCookie = ref<EncryptedCookieBlob | null>(null);
const confirmingBatchDelete = ref(false);
onMounted(() => store.fetchCookies());
// Auto-expand all domains when cookies load
watch(
() => store.cookies,
(cookies) => {
const domains = new Set(cookies.map((c) => c.domain));
expandedDomains.value = domains;
},
{ immediate: true },
);
const filteredCookies = computed(() => {
let list = store.cookies;
if (search.value) {
const q = search.value.toLowerCase();
list = list.filter(
(c) => c.domain.toLowerCase().includes(q) || c.cookieName.toLowerCase().includes(q),
);
}
return list;
});
const groupedByDomain = computed(() => {
const map = new Map<string, EncryptedCookieBlob[]>();
for (const cookie of filteredCookies.value) {
const list = map.get(cookie.domain) ?? [];
list.push(cookie);
map.set(cookie.domain, list);
}
return map;
});
function selectDomain(domain: string | null) {
selectedDomain.value = domain;
store.fetchCookies(domain ?? undefined);
}
function toggleDomain(domain: string) {
if (expandedDomains.value.has(domain)) {
expandedDomains.value.delete(domain);
} else {
expandedDomains.value.add(domain);
}
}
function selectCookie(cookie: EncryptedCookieBlob) {
selectedCookie.value = cookie;
}
function closeDetail() {
selectedCookie.value = null;
}
function toggleSelect(id: string) {
if (selectedIds.value.has(id)) {
selectedIds.value.delete(id);
} else {
selectedIds.value.add(id);
}
}
function toggleSelectAll() {
if (selectedIds.value.size === filteredCookies.value.length) {
selectedIds.value.clear();
} else {
selectedIds.value = new Set(filteredCookies.value.map((c) => c.id));
}
}
function requestDelete(cookie: EncryptedCookieBlob) {
confirmingDeleteCookie.value = cookie;
}
async function confirmDelete() {
const cookie = confirmingDeleteCookie.value;
if (!cookie) return;
await store.deleteCookie(cookie.domain, cookie.cookieName, cookie.path);
if (selectedCookie.value?.id === cookie.id) {
selectedCookie.value = null;
}
confirmingDeleteCookie.value = null;
}
function cancelDelete() {
confirmingDeleteCookie.value = null;
}
function requestBatchDelete() {
if (selectedIds.value.size === 0) return;
confirmingBatchDelete.value = true;
}
async function confirmBatchDelete() {
for (const id of selectedIds.value) {
const c = store.cookies.find((x) => x.id === id);
if (c) await store.deleteCookie(c.domain, c.cookieName, c.path);
}
selectedIds.value.clear();
confirmingBatchDelete.value = false;
}
function cancelBatchDelete() {
confirmingBatchDelete.value = false;
}
</script>
<template>
<div class="h-full overflow-auto p-8">
<!-- Detail panel (replaces list when a cookie is selected) -->
<div v-if="selectedCookie">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold text-gray-900">Cookie Details</h2>
<button
class="rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50"
@click="closeDetail"
>
Back to list
</button>
</div>
<div class="mt-6 max-w-lg rounded-xl bg-white p-6 ring-1 ring-gray-200">
<dl class="space-y-4 text-sm">
<div>
<dt class="font-medium text-gray-500">Name</dt>
<dd class="mt-0.5 text-gray-900">{{ selectedCookie.cookieName }}</dd>
</div>
<div>
<dt class="font-medium text-gray-500">Value</dt>
<dd class="mt-0.5 font-mono text-xs text-gray-400 break-all max-h-24 overflow-auto">
{{ selectedCookie.ciphertext?.slice(0, 80) }}...
</dd>
</div>
<div>
<dt class="font-medium text-gray-500">Domain</dt>
<dd class="mt-0.5 font-mono text-xs text-gray-900">{{ selectedCookie.domain }}</dd>
</div>
<div>
<dt class="font-medium text-gray-500">Path</dt>
<dd class="mt-0.5 font-mono text-xs text-gray-900">{{ selectedCookie.path }}</dd>
</div>
<div>
<dt class="font-medium text-gray-500">Expires</dt>
<dd class="mt-0.5 text-gray-900">
{{ (selectedCookie as any).expires ? new Date((selectedCookie as any).expires).toLocaleString() : "Session" }}
</dd>
</div>
<div>
<dt class="font-medium text-gray-500">Secure</dt>
<dd class="mt-0.5 text-gray-900">{{ (selectedCookie as any).secure ? "Yes" : "No" }}</dd>
</div>
<div>
<dt class="font-medium text-gray-500">HttpOnly</dt>
<dd class="mt-0.5 text-gray-900">{{ (selectedCookie as any).httpOnly ? "Yes" : "No" }}</dd>
</div>
<div>
<dt class="font-medium text-gray-500">Device ID</dt>
<dd class="mt-0.5 font-mono text-xs text-gray-600 break-all">
{{ selectedCookie.deviceId }}
</dd>
</div>
<div>
<dt class="font-medium text-gray-500">Lamport Timestamp</dt>
<dd class="mt-0.5 text-gray-900">{{ selectedCookie.lamportTs }}</dd>
</div>
<div>
<dt class="font-medium text-gray-500">Updated At</dt>
<dd class="mt-0.5 text-gray-900">
{{ new Date(selectedCookie.updatedAt).toLocaleString() }}
</dd>
</div>
</dl>
<button
class="mt-6 w-full rounded-lg border border-red-200 px-3 py-2 text-xs font-medium text-red-600 hover:bg-red-50"
@click="requestDelete(selectedCookie)"
>
Delete Cookie
</button>
</div>
</div>
<!-- Cookie list (shown when no cookie selected) -->
<div v-else>
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-semibold text-gray-900">Cookies</h2>
<p class="mt-1 text-sm text-gray-500">
{{ store.cookies.length }} cookies across {{ store.domains.length }} domains
</p>
</div>
<div class="flex items-center gap-3">
<button
v-if="selectedIds.size > 0"
class="rounded-lg bg-red-600 px-3 py-2 text-xs font-medium text-white hover:bg-red-700"
@click="requestBatchDelete"
>
Delete Selected ({{ selectedIds.size }})
</button>
<input
v-model="search"
type="text"
placeholder="Search domain or name..."
class="w-64 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"
/>
</div>
</div>
<!-- Error state -->
<div v-if="store.error" role="alert" class="mt-4 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
{{ store.error }}
</div>
<!-- Grouped cookie list -->
<div class="mt-6 space-y-3">
<div v-if="store.loading" class="rounded-xl bg-white p-8 text-center text-sm text-gray-500 ring-1 ring-gray-200">
Loading...
</div>
<div v-else-if="filteredCookies.length === 0 && !store.error" class="rounded-xl bg-white p-8 text-center text-sm text-gray-500 ring-1 ring-gray-200">
No cookies found
</div>
<div
v-for="[domain, cookies] in groupedByDomain"
:key="domain"
class="overflow-hidden rounded-xl bg-white ring-1 ring-gray-200"
>
<!-- Domain header -->
<button
class="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-gray-50"
@click="toggleDomain(domain)"
>
<div class="flex items-center gap-2">
<span
class="text-xs text-gray-400 transition-transform"
:class="expandedDomains.has(domain) ? 'rotate-90' : ''"
>&#9654;</span>
<span class="font-mono text-sm font-medium text-gray-900">{{ domain }}</span>
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
{{ cookies.length }}
</span>
</div>
</button>
<!-- Cookies table (expanded) -->
<div v-show="expandedDomains.has(domain)">
<table class="w-full text-left text-sm">
<thead class="border-y border-gray-100 bg-gray-50">
<tr>
<th class="w-8 px-4 py-2">
<input
type="checkbox"
class="rounded border-gray-300"
@change="toggleSelectAll()"
/>
</th>
<th class="px-4 py-2 font-medium text-gray-600">Cookie</th>
<th class="px-4 py-2 font-medium text-gray-600">Location</th>
<th class="px-4 py-2 font-medium text-gray-600">Device</th>
<th class="px-4 py-2 font-medium text-gray-600">Updated</th>
<th class="px-4 py-2 font-medium text-gray-600"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
<tr
v-for="cookie in cookies"
:key="cookie.id"
class="cursor-pointer hover:bg-blue-50"
@click="selectCookie(cookie)"
>
<td class="px-4 py-2" @click.stop>
<input
type="checkbox"
class="rounded border-gray-300"
:checked="selectedIds.has(cookie.id)"
@change="toggleSelect(cookie.id)"
/>
</td>
<td class="px-4 py-2 font-medium text-gray-900">{{ cookie.cookieName }}</td>
<td class="px-4 py-2 font-mono text-xs text-gray-600">{{ cookie.path }}</td>
<td class="px-4 py-2 font-mono text-xs text-gray-500 truncate max-w-[100px]">
{{ cookie.deviceId.slice(0, 12) }}...
</td>
<td class="px-4 py-2 text-xs text-gray-500">
{{ new Date(cookie.updatedAt).toLocaleString() }}
</td>
<td class="px-4 py-2" @click.stop>
<button
class="text-red-600 hover:text-red-800 text-xs font-medium"
@click="requestDelete(cookie)"
>
Delete
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Single delete confirmation dialog -->
<div v-if="confirmingDeleteCookie" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div role="dialog" class="w-80 rounded-xl bg-white p-6 shadow-lg">
<p class="text-sm text-gray-600">
Are you sure you want to delete this cookie?
</p>
<div class="mt-4 flex gap-3">
<button
class="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50"
@click="cancelDelete"
>
Cancel
</button>
<button
class="flex-1 rounded-lg bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-700"
@click="confirmDelete"
>
Confirm Delete
</button>
</div>
</div>
</div>
<!-- Batch delete confirmation dialog -->
<div v-if="confirmingBatchDelete" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div role="dialog" class="w-80 rounded-xl bg-white p-6 shadow-lg">
<p class="text-sm text-gray-600">
Are you sure you want to delete {{ selectedIds.size }} cookies?
</p>
<div class="mt-4 flex gap-3">
<button
class="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50"
@click="cancelBatchDelete"
>
Cancel
</button>
<button
class="flex-1 rounded-lg bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-700"
@click="confirmBatchDelete"
>
Confirm Delete
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,238 @@
<script setup lang="ts">
import { onMounted, ref, computed } from "vue";
import api from "@/api/client";
interface DashboardData {
connections: number;
totalDevices: number;
onlineDevices: number;
totalCookies: number;
uniqueDomains: number;
syncCount: number;
uptimeSeconds: number;
}
interface DeviceSummary {
deviceId: string;
name: string;
platform: string;
online: boolean;
createdAt: string;
}
const dashboard = ref<DashboardData | null>(null);
const devices = ref<DeviceSummary[]>([]);
const loading = ref(true);
const error = ref<string | null>(null);
const offlineDevices = computed(
() => (dashboard.value?.totalDevices ?? 0) - (dashboard.value?.onlineDevices ?? 0),
);
function platformIcon(platform: string): string {
const p = platform.toLowerCase();
if (p.includes("chrome")) return "chrome";
if (p.includes("firefox")) return "firefox";
if (p.includes("edge")) return "edge";
if (p.includes("safari")) return "safari";
return "device";
}
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
if (d > 0) return `${d}d ${h}h`;
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
async function fetchData() {
loading.value = true;
error.value = null;
try {
const dashRes = await api.get("/dashboard");
dashboard.value = dashRes.data;
} catch {
error.value = "Failed to load dashboard data";
}
try {
const devRes = await api.get("/devices");
devices.value = devRes.data.devices ?? [];
} catch {
// Devices list is optional — dashboard still shows stats
}
loading.value = false;
}
onMounted(fetchData);
</script>
<template>
<div class="p-8">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-semibold text-gray-900">Dashboard</h2>
<p class="mt-1 text-sm text-gray-500">CookieBridge relay server overview</p>
</div>
<button
class="rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50"
@click="fetchData"
>
Refresh
</button>
</div>
<div v-if="loading" class="mt-8 text-sm text-gray-500">Loading...</div>
<div v-else-if="error" role="alert" class="mt-8 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
{{ error }}
</div>
<template v-else>
<!-- Stat cards -->
<div class="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
<p class="text-sm font-medium text-gray-500">Devices</p>
<p class="mt-2 text-3xl font-semibold text-gray-900">
{{ dashboard?.onlineDevices ?? 0 }}
<span class="text-base font-normal text-gray-400">
/ {{ dashboard?.totalDevices ?? 0 }}
</span>
</p>
<div class="mt-2 flex gap-3 text-xs">
<span class="flex items-center gap-1 text-green-600">
<span class="h-1.5 w-1.5 rounded-full bg-green-500" />
{{ dashboard?.onlineDevices ?? 0 }} online
</span>
<span class="flex items-center gap-1 text-gray-400">
<span class="h-1.5 w-1.5 rounded-full bg-gray-300" />
{{ offlineDevices }} offline
</span>
</div>
</div>
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
<p class="text-sm font-medium text-gray-500">Cookies</p>
<p class="mt-2 text-3xl font-semibold text-gray-900">
{{ dashboard?.totalCookies ?? 0 }}
</p>
<p class="mt-1 text-xs text-gray-500">
across {{ dashboard?.uniqueDomains ?? 0 }} domains
</p>
</div>
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
<p class="text-sm font-medium text-gray-500">Sync Activity</p>
<p class="mt-2 text-3xl font-semibold text-gray-900">
{{ dashboard?.syncCount ?? 0 }}
</p>
<p class="mt-1 text-xs text-gray-500">total sync operations</p>
</div>
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
<p class="text-sm font-medium text-gray-500">Uptime</p>
<p class="mt-2 text-3xl font-semibold text-gray-900">
{{ dashboard?.uptimeSeconds ? formatUptime(dashboard.uptimeSeconds) : "—" }}
</p>
<p class="mt-1 text-xs text-gray-500">server running time</p>
</div>
</div>
<!-- Device status list -->
<div class="mt-8">
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900">Device Status</h3>
<router-link to="/devices" class="text-sm font-medium text-blue-600 hover:text-blue-800">
View all
</router-link>
</div>
<div
v-if="devices.length === 0"
class="mt-4 rounded-xl bg-white p-6 text-center text-sm text-gray-500 ring-1 ring-gray-200"
>
No devices registered yet
</div>
<div v-else class="mt-4 overflow-hidden rounded-xl bg-white ring-1 ring-gray-200">
<table class="w-full text-left text-sm">
<thead class="border-b border-gray-200 bg-gray-50">
<tr>
<th class="px-4 py-3 font-medium text-gray-600">Device</th>
<th class="px-4 py-3 font-medium text-gray-600">Platform</th>
<th class="px-4 py-3 font-medium text-gray-600">Status</th>
<th class="px-4 py-3 font-medium text-gray-600">Registered</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr v-for="device in devices.slice(0, 10)" :key="device.deviceId" class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-900">{{ device.name }}</td>
<td class="px-4 py-3 text-gray-600 capitalize">{{ platformIcon(device.platform) }}</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium"
:class="device.online
? 'bg-green-50 text-green-700'
: 'bg-gray-100 text-gray-500'"
>
<span
class="h-1.5 w-1.5 rounded-full"
:class="device.online ? 'bg-green-500' : 'bg-gray-400'"
/>
{{ device.online ? "Online" : "Offline" }}
</span>
</td>
<td class="px-4 py-3 text-gray-500">
{{ new Date(device.createdAt).toLocaleDateString() }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Quick actions -->
<div class="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-3">
<router-link
to="/cookies"
class="flex items-center gap-3 rounded-xl bg-white p-5 ring-1 ring-gray-200 hover:ring-blue-300 transition-shadow"
>
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50 text-lg">
&#127850;
</div>
<div>
<p class="text-sm font-medium text-gray-900">View Cookies</p>
<p class="text-xs text-gray-500">Manage synced cookies</p>
</div>
</router-link>
<router-link
to="/devices"
class="flex items-center gap-3 rounded-xl bg-white p-5 ring-1 ring-gray-200 hover:ring-blue-300 transition-shadow"
>
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-green-50 text-lg">
&#128241;
</div>
<div>
<p class="text-sm font-medium text-gray-900">Manage Devices</p>
<p class="text-xs text-gray-500">View and revoke devices</p>
</div>
</router-link>
<router-link
to="/settings"
class="flex items-center gap-3 rounded-xl bg-white p-5 ring-1 ring-gray-200 hover:ring-blue-300 transition-shadow"
>
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 text-lg">
&#9881;&#65039;
</div>
<div>
<p class="text-sm font-medium text-gray-900">Settings</p>
<p class="text-xs text-gray-500">Configure sync and security</p>
</div>
</router-link>
</div>
</template>
</div>
</template>

View File

@@ -0,0 +1,220 @@
<script setup lang="ts">
import { onMounted, ref, computed } from "vue";
import api from "@/api/client";
interface DeviceEntry {
deviceId: string;
name: string;
platform: string;
createdAt: string;
online: boolean;
lastSeen?: string;
ipAddress?: string | null;
extensionVersion?: string;
}
const devices = ref<DeviceEntry[]>([]);
const loading = ref(true);
const error = ref<string | null>(null);
const filter = ref<"all" | "online" | "offline">("all");
const expandedId = ref<string | null>(null);
const revoking = ref<string | null>(null);
const confirmRevoke = ref<string | null>(null);
const filtered = computed(() => {
if (filter.value === "online") return devices.value.filter((d) => d.online);
if (filter.value === "offline") return devices.value.filter((d) => !d.online);
return devices.value;
});
function platformLabel(platform: string): string {
const p = platform.toLowerCase();
if (p.includes("chrome")) return "Chrome";
if (p.includes("firefox")) return "Firefox";
if (p.includes("edge")) return "Edge";
if (p.includes("safari")) return "Safari";
return platform;
}
function toggleExpand(id: string) {
expandedId.value = expandedId.value === id ? null : id;
}
async function handleRevoke(deviceId: string) {
revoking.value = deviceId;
try {
await api.post(`/devices/${deviceId}/revoke`);
devices.value = devices.value.filter((d) => d.deviceId !== deviceId);
} catch {
error.value = "Failed to revoke device";
} finally {
revoking.value = null;
confirmRevoke.value = null;
}
}
onMounted(async () => {
try {
const { data } = await api.get("/devices");
devices.value = data.devices ?? [];
} catch {
error.value = "Failed to load devices";
} finally {
loading.value = false;
}
});
</script>
<template>
<div class="p-8">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-semibold text-gray-900">Devices</h2>
<p class="mt-1 text-sm text-gray-500">
{{ devices.length }} registered devices
</p>
</div>
<!-- Status filter -->
<div class="flex rounded-lg border border-gray-200 bg-white p-0.5">
<button
v-for="f in (['all', 'online', 'offline'] as const)"
:key="f"
class="rounded-md px-3 py-1.5 text-xs font-medium capitalize"
:class="filter === f ? 'bg-gray-100 text-gray-900' : 'text-gray-500 hover:text-gray-700'"
@click="filter = f"
>
{{ f }}
</button>
</div>
</div>
<div v-if="loading" class="mt-6 text-sm text-gray-500">Loading...</div>
<div v-else-if="error" role="alert" class="mt-6 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
{{ error }}
</div>
<div v-else-if="filtered.length === 0" class="mt-6 text-sm text-gray-500">
No devices {{ filter !== "all" ? `(${filter})` : "" }}
</div>
<div v-else class="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="device in filtered"
:key="device.deviceId"
class="device-card rounded-xl bg-white ring-1 ring-gray-200 transition-shadow hover:shadow-sm"
>
<!-- Card header -->
<div class="p-5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 text-sm font-bold text-gray-600">
{{ platformLabel(device.platform).slice(0, 2).toUpperCase() }}
</div>
<div>
<h3 class="font-medium text-gray-900">{{ device.name }}</h3>
<p class="text-xs text-gray-500">{{ platformLabel(device.platform) }}</p>
</div>
</div>
<span
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium"
:class="device.online
? 'bg-green-50 text-green-700'
: 'bg-gray-100 text-gray-500'"
>
<span
class="h-1.5 w-1.5 rounded-full"
:class="device.online ? 'bg-green-500' : 'bg-gray-400'"
/>
{{ device.online ? "Online" : "Offline" }}
</span>
</div>
<dl class="mt-4 space-y-1.5 text-sm">
<div class="flex justify-between">
<dt class="text-gray-500">Registered</dt>
<dd class="text-gray-900">{{ new Date(device.createdAt).toLocaleDateString() }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Last Seen</dt>
<dd class="text-gray-900">
{{ device.lastSeen ? new Date(device.lastSeen).toLocaleString() : "—" }}
</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Device ID</dt>
<dd class="font-mono text-xs text-gray-600">{{ device.deviceId.slice(0, 16) }}...</dd>
</div>
</dl>
<!-- Expand toggle -->
<button
class="mt-3 text-xs font-medium text-blue-600 hover:text-blue-800"
@click="toggleExpand(device.deviceId)"
>
{{ expandedId === device.deviceId ? "Hide details" : "Show details" }}
</button>
<!-- Expanded details -->
<div v-if="expandedId === device.deviceId" class="mt-3 rounded-lg bg-gray-50 p-3 text-xs">
<dl class="space-y-1">
<div class="flex justify-between">
<dt class="text-gray-500">Full Device ID</dt>
<dd class="font-mono text-gray-700 break-all max-w-[200px] text-right">
{{ device.deviceId }}
</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Platform</dt>
<dd class="text-gray-700">{{ device.platform }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Registered</dt>
<dd class="text-gray-700">{{ new Date(device.createdAt).toLocaleString() }}</dd>
</div>
<div v-if="device.extensionVersion" class="flex justify-between">
<dt class="text-gray-500">Extension Version</dt>
<dd class="text-gray-700">{{ device.extensionVersion }}</dd>
</div>
<div v-if="device.ipAddress" class="flex justify-between">
<dt class="text-gray-500">IP Address</dt>
<dd class="font-mono text-gray-700">{{ device.ipAddress }}</dd>
</div>
</dl>
</div>
</div>
<!-- Revoke action -->
<div class="border-t border-gray-100 px-5 py-3">
<template v-if="confirmRevoke === device.deviceId">
<p class="mb-2 text-xs text-red-600">
This will disconnect the device and revoke its token. Continue?
</p>
<div class="flex gap-2">
<button
class="flex-1 rounded-lg bg-red-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-red-700 disabled:opacity-50"
:disabled="revoking === device.deviceId"
@click="handleRevoke(device.deviceId)"
>
{{ revoking === device.deviceId ? "Revoking..." : "Confirm" }}
</button>
<button
class="flex-1 rounded-lg border border-gray-200 px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-50"
@click="confirmRevoke = null"
>
Cancel
</button>
</div>
</template>
<button
v-else
class="w-full rounded-lg border border-red-200 px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50"
@click="confirmRevoke = device.deviceId"
>
Sign Out Device
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";
const router = useRouter();
const auth = useAuthStore();
const username = ref("");
const password = ref("");
const error = ref("");
const loading = ref(false);
async function handleLogin() {
error.value = "";
loading.value = true;
try {
await auth.login(username.value, password.value);
router.push({ name: "dashboard" });
} catch {
error.value = "Invalid credentials";
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center bg-gray-50">
<div class="w-full max-w-sm rounded-xl bg-white p-8 shadow-sm ring-1 ring-gray-200">
<h1 class="mb-1 text-xl font-semibold text-gray-900">CookieBridge</h1>
<p class="mb-6 text-sm text-gray-500">Sign in to the admin panel</p>
<form @submit.prevent="handleLogin" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700" for="username">
Username
</label>
<input
id="username"
v-model="username"
type="text"
required
autocomplete="username"
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"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700" for="password">
Password
</label>
<input
id="password"
v-model="password"
type="password"
required
autocomplete="current-password"
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"
/>
</div>
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
<button
type="submit"
:disabled="loading || !username || !password"
class="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50"
>
{{ loading ? "Signing in..." : "Sign In" }}
</button>
</form>
</div>
</div>
</template>

View File

@@ -0,0 +1,316 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from "@headlessui/vue";
import { useSettingsStore } from "@/stores/settings";
import api from "@/api/client";
const store = useSettingsStore();
const saving = ref(false);
const saved = ref(false);
const saveError = ref("");
const loadError = ref("");
const passwordError = ref("");
// Password change
const currentPassword = ref("");
const newPassword = ref("");
const confirmNewPassword = ref("");
const syncFrequencyOptions = [
{ label: "Real-time", value: 0 },
{ label: "Every minute", value: 60_000 },
{ label: "Every 5 minutes", value: 300_000 },
{ label: "Manual only", value: -1 },
];
onMounted(async () => {
try {
await store.fetchSettings();
} catch {
loadError.value = "Failed to load settings";
}
});
async function save() {
saving.value = true;
saved.value = false;
saveError.value = "";
try {
await store.updateSettings(store.settings);
saved.value = true;
setTimeout(() => (saved.value = false), 2000);
} catch {
saveError.value = "Failed to save settings";
} finally {
saving.value = false;
}
}
async function changePassword() {
passwordError.value = "";
if (newPassword.value !== confirmNewPassword.value) {
passwordError.value = "New passwords do not match";
return;
}
if (newPassword.value.length < 8) {
passwordError.value = "Password must be at least 8 characters";
return;
}
try {
await api.post("/auth/change-password", {
currentPassword: currentPassword.value,
newPassword: newPassword.value,
});
currentPassword.value = "";
newPassword.value = "";
confirmNewPassword.value = "";
passwordError.value = "";
saved.value = true;
setTimeout(() => (saved.value = false), 2000);
} catch {
passwordError.value = "Current password is incorrect";
}
}
</script>
<template>
<div class="p-8">
<h2 class="text-2xl font-semibold text-gray-900">Settings</h2>
<p class="mt-1 text-sm text-gray-500">Configure sync, security, and appearance</p>
<!-- Success toast -->
<div
v-if="saved"
class="fixed right-8 top-8 z-50 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-lg"
>
Settings saved
</div>
<!-- Error toast -->
<div
v-if="saveError"
class="fixed right-8 top-8 z-50 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-lg"
>
{{ saveError }}
</div>
<!-- Load error -->
<div v-if="loadError" role="alert" class="mt-6 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
{{ loadError }}
</div>
<div class="mt-6 max-w-2xl">
<TabGroup>
<TabList class="flex gap-1 rounded-xl bg-gray-100 p-1">
<Tab
v-for="tab in ['Sync', 'Security', 'Appearance']"
:key="tab"
v-slot="{ selected }"
as="template"
>
<button
class="w-full rounded-lg px-4 py-2 text-sm font-medium transition-colors"
:class="selected ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
>
{{ tab }}
</button>
</Tab>
</TabList>
<TabPanels class="mt-4">
<!-- Sync Settings -->
<TabPanel class="space-y-6">
<section class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
<h3 class="font-medium text-gray-900">Sync Configuration</h3>
<div class="mt-4 space-y-5">
<!-- Auto-sync toggle -->
<div class="flex items-center justify-between">
<div>
<label id="auto-sync-label" class="text-sm font-medium text-gray-700">Auto-sync</label>
<p class="text-xs text-gray-500">Automatically sync cookies between devices</p>
</div>
<button
role="switch"
:aria-checked="store.settings.autoSync"
aria-labelledby="auto-sync-label"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="store.settings.autoSync ? 'bg-blue-600' : 'bg-gray-200'"
@click="store.settings.autoSync = !store.settings.autoSync"
>
<span
class="inline-block h-4 w-4 rounded-full bg-white transition-transform"
:class="store.settings.autoSync ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
<!-- Sync frequency -->
<div>
<label for="sync-frequency" class="block text-sm font-medium text-gray-700">Sync Frequency</label>
<select
id="sync-frequency"
v-model="store.settings.syncIntervalMs"
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
>
<option
v-for="opt in syncFrequencyOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
</div>
</div>
</section>
<button
:disabled="saving"
class="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
@click="save"
>
{{ saving ? "Saving..." : "Save Sync Settings" }}
</button>
</TabPanel>
<!-- Security Settings -->
<TabPanel class="space-y-6">
<!-- Change password -->
<section class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
<h3 class="font-medium text-gray-900">Change Password</h3>
<form class="mt-4 space-y-4" @submit.prevent="changePassword">
<div>
<label for="current-password" class="block text-sm text-gray-700">Current Password</label>
<input
id="current-password"
v-model="currentPassword"
type="password"
autocomplete="current-password"
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label for="new-password" class="block text-sm text-gray-700">New Password</label>
<input
id="new-password"
v-model="newPassword"
type="password"
autocomplete="new-password"
minlength="8"
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label for="confirm-password" class="block text-sm text-gray-700">Confirm Password</label>
<input
id="confirm-password"
v-model="confirmNewPassword"
type="password"
autocomplete="new-password"
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<p v-if="passwordError" class="text-sm text-red-600">{{ passwordError }}</p>
<button
type="submit"
class="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Change Password
</button>
</form>
</section>
<!-- Other security settings -->
<section class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
<h3 class="font-medium text-gray-900">Device Security</h3>
<div class="mt-4 space-y-4">
<div>
<label for="session-timeout" class="block text-sm text-gray-700">Session Timeout (minutes)</label>
<input
id="session-timeout"
v-model.number="store.settings.sessionTimeoutMinutes"
type="number"
min="1"
max="1440"
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
<p class="mt-1 text-xs text-gray-500">Auto-logout after inactivity</p>
</div>
<div>
<label for="max-devices" class="block text-sm text-gray-700">Max Devices</label>
<input
id="max-devices"
v-model.number="store.settings.maxDevices"
type="number"
min="1"
max="50"
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
<p class="mt-1 text-xs text-gray-500">Maximum number of devices that can register</p>
</div>
</div>
</section>
<button
:disabled="saving"
class="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
@click="save"
>
{{ saving ? "Saving..." : "Save Security Settings" }}
</button>
</TabPanel>
<!-- Appearance Settings -->
<TabPanel class="space-y-6">
<section class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
<h3 class="font-medium text-gray-900">Appearance</h3>
<div class="mt-4 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Theme</label>
<div class="mt-2 grid grid-cols-3 gap-3">
<button
v-for="t in (['light', 'dark', 'system'] as const)"
:key="t"
class="rounded-lg border-2 px-4 py-3 text-center text-sm font-medium capitalize transition-colors"
:class="store.settings.theme === t
? 'border-blue-600 bg-blue-50 text-blue-700'
: 'border-gray-200 text-gray-600 hover:border-gray-300'"
@click="store.settings.theme = t"
>
{{ t }}
</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Language</label>
<div class="mt-2 grid grid-cols-2 gap-3">
<button
v-for="lang in [{ value: 'en', label: 'English' }, { value: 'zh', label: '中文' }]"
:key="lang.value"
class="rounded-lg border-2 px-4 py-3 text-center text-sm font-medium transition-colors"
:class="store.settings.language === lang.value
? 'border-blue-600 bg-blue-50 text-blue-700'
: 'border-gray-200 text-gray-600 hover:border-gray-300'"
@click="store.settings.language = lang.value"
>
{{ lang.label }}
</button>
</div>
</div>
</div>
</section>
<button
:disabled="saving"
class="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
@click="save"
>
{{ saving ? "Saving..." : "Save Appearance" }}
</button>
</TabPanel>
</TabPanels>
</TabGroup>
</div>
</div>
</template>

444
web/src/views/SetupView.vue Normal file
View File

@@ -0,0 +1,444 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { useRouter } from "vue-router";
import api from "@/api/client";
import { markSetupComplete } from "@/router";
const router = useRouter();
const step = ref(1);
const totalSteps = 5;
// 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 4: Basic config
const listenPort = ref(8100);
const enableHttps = ref(false);
const error = ref("");
const loading = ref(false);
const passwordMismatch = computed(
() => confirmPassword.value.length > 0 && password.value !== confirmPassword.value,
);
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 === 3 && passwordMismatch.value) {
error.value = "Passwords do not match";
return;
}
step.value = Math.min(step.value + 1, totalSteps);
}
function prevStep() {
error.value = "";
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;
try {
await api.post("/setup/init", {
username: username.value,
password: password.value,
dbConfig: buildDbConfig(),
});
markSetupComplete();
step.value = totalSteps;
} 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;
}
}
function goToLogin() {
router.push("/login");
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center bg-gray-50">
<div class="w-full max-w-lg rounded-xl bg-white p-8 shadow-sm ring-1 ring-gray-200">
<!-- Progress bar -->
<div class="mb-6 flex gap-2">
<div
v-for="i in totalSteps"
:key="i"
class="h-1.5 flex-1 rounded-full"
:class="i <= step ? 'bg-blue-600' : 'bg-gray-200'"
/>
</div>
<!-- Step 1: Welcome -->
<div v-if="step === 1">
<h1 class="text-2xl font-semibold text-gray-900">Welcome to CookieBridge</h1>
<p class="mt-3 text-sm leading-relaxed text-gray-600">
Synchronize your browser cookies across devices with end-to-end encryption.
Login once on any device, and stay logged in everywhere.
</p>
<ul class="mt-4 space-y-2 text-sm text-gray-600">
<li class="flex gap-2">
<span class="text-green-500">&#10003;</span>
End-to-end encrypted the server never sees your data
</li>
<li class="flex gap-2">
<span class="text-green-500">&#10003;</span>
Multi-browser support (Chrome, Firefox, Edge, Safari)
</li>
<li class="flex gap-2">
<span class="text-green-500">&#10003;</span>
AI agent integration via Agent Skill API
</li>
</ul>
<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="nextStep"
>
Continue
</button>
</div>
<!-- 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>
<form class="mt-5 space-y-4" @submit.prevent="nextStep">
<div>
<label class="block text-sm font-medium text-gray-700" for="setup-username">
Username
</label>
<input
id="setup-username"
v-model="username"
type="text"
required
minlength="3"
autocomplete="username"
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="admin"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700" for="setup-password">
Password
</label>
<input
id="setup-password"
v-model="password"
type="password"
required
minlength="8"
autocomplete="new-password"
aria-label="Password"
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="At least 8 characters"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700" for="setup-confirm">
Confirm Password
</label>
<input
id="setup-confirm"
v-model="confirmPassword"
type="password"
required
autocomplete="new-password"
aria-label="Confirm Password"
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"
:class="passwordMismatch ? 'border-red-300 focus:border-red-500 focus:ring-red-500' : ''"
/>
<p v-if="passwordMismatch" class="mt-1 text-xs text-red-600">
Passwords do not match
</p>
</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
type="submit"
: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
</button>
</div>
</form>
</div>
<!-- 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>
<div class="mt-5 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700" for="setup-port">
Listen Port
</label>
<input
id="setup-port"
v-model.number="listenPort"
type="number"
min="1"
max="65535"
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"
/>
<p class="mt-1 text-xs text-gray-500">Default: 8100</p>
</div>
<div class="flex items-center justify-between rounded-lg border border-gray-200 px-4 py-3">
<div>
<p class="text-sm font-medium text-gray-700">Enable HTTPS</p>
<p class="text-xs text-gray-500">Recommended for production use</p>
</div>
<button
type="button"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="enableHttps ? 'bg-blue-600' : 'bg-gray-200'"
@click="enableHttps = !enableHttps"
>
<span
class="inline-block h-4 w-4 rounded-full bg-white transition-transform"
:class="enableHttps ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</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="loading"
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..." : "Complete Setup" }}
</button>
</div>
</div>
</div>
<!-- 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>
<h2 class="mt-4 text-xl font-semibold text-gray-900">Setup Complete!</h2>
<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"
>
Go to Login
</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,273 @@
import { test, expect } from "@playwright/test";
/**
* Admin REST API integration tests (RCA-13)
*
* These tests call the relay server's /admin/* endpoints directly
* via Playwright's APIRequestContext, without a browser.
*
* Run with: npx playwright test tests/api/ --project=chromium
*
* Requires:
* - Relay server running at BASE_URL (default http://localhost:8100)
* - TEST_ADMIN_USER and TEST_ADMIN_PASS env vars (or defaults admin/testpassword123)
*
* NOTE: These tests assume a clean server state. Run against a dedicated
* test instance, not production.
*/
const API_BASE = process.env.RELAY_BASE_URL ?? "http://localhost:8100";
const ADMIN_USER = process.env.TEST_ADMIN_USER ?? "admin";
const ADMIN_PASS = process.env.TEST_ADMIN_PASS ?? "testpassword123";
let adminToken = "";
test.describe("Admin Auth API", () => {
test("POST /admin/auth/login — valid credentials returns JWT", async ({ request }) => {
const res = await request.post(`${API_BASE}/admin/auth/login`, {
data: { username: ADMIN_USER, password: ADMIN_PASS },
});
expect(res.status()).toBe(200);
const body = await res.json();
expect(body).toHaveProperty("token");
expect(typeof body.token).toBe("string");
expect(body.token.length).toBeGreaterThan(10);
expect(body).toHaveProperty("expiresAt");
adminToken = body.token;
});
test("POST /admin/auth/login — wrong password returns 401", async ({ request }) => {
const res = await request.post(`${API_BASE}/admin/auth/login`, {
data: { username: ADMIN_USER, password: "wrongpassword" },
});
expect(res.status()).toBe(401);
const body = await res.json();
expect(body).toHaveProperty("error");
});
test("POST /admin/auth/login — missing fields returns 400", async ({ request }) => {
const res = await request.post(`${API_BASE}/admin/auth/login`, {
data: { username: ADMIN_USER },
});
expect(res.status()).toBe(400);
});
test("GET /admin/auth/me — valid token returns user info", async ({ request }) => {
// Ensure we have a token
if (!adminToken) {
const login = await request.post(`${API_BASE}/admin/auth/login`, {
data: { username: ADMIN_USER, password: ADMIN_PASS },
});
adminToken = (await login.json()).token;
}
const res = await request.get(`${API_BASE}/admin/auth/me`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.status()).toBe(200);
const body = await res.json();
expect(body).toHaveProperty("username", ADMIN_USER);
});
test("GET /admin/auth/me — no token returns 401", async ({ request }) => {
const res = await request.get(`${API_BASE}/admin/auth/me`);
expect(res.status()).toBe(401);
});
test("POST /admin/auth/logout — clears session", async ({ request }) => {
const login = await request.post(`${API_BASE}/admin/auth/login`, {
data: { username: ADMIN_USER, password: ADMIN_PASS },
});
const token = (await login.json()).token;
const res = await request.post(`${API_BASE}/admin/auth/logout`, {
headers: { Authorization: `Bearer ${token}` },
});
expect([200, 204]).toContain(res.status());
// Token should now be invalid
const me = await request.get(`${API_BASE}/admin/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(me.status()).toBe(401);
});
});
test.describe("Setup API", () => {
test("GET /admin/setup/status returns initialised flag", async ({ request }) => {
const res = await request.get(`${API_BASE}/admin/setup/status`);
expect(res.status()).toBe(200);
const body = await res.json();
expect(body).toHaveProperty("initialised");
expect(typeof body.initialised).toBe("boolean");
});
});
test.describe("Dashboard API", () => {
test.beforeAll(async ({ request }) => {
const login = await request.post(`${API_BASE}/admin/auth/login`, {
data: { username: ADMIN_USER, password: ADMIN_PASS },
});
adminToken = (await login.json()).token;
});
test("GET /admin/dashboard — returns stats shape", async ({ request }) => {
const res = await request.get(`${API_BASE}/admin/dashboard`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.status()).toBe(200);
const body = await res.json();
expect(body).toHaveProperty("devices");
expect(body).toHaveProperty("cookies");
expect(body).toHaveProperty("syncCount");
expect(body).toHaveProperty("uptimeSeconds");
expect(typeof body.syncCount).toBe("number");
expect(typeof body.uptimeSeconds).toBe("number");
});
test("GET /admin/dashboard — unauthenticated returns 401", async ({ request }) => {
const res = await request.get(`${API_BASE}/admin/dashboard`);
expect(res.status()).toBe(401);
});
});
test.describe("Cookies API", () => {
test.beforeAll(async ({ request }) => {
const login = await request.post(`${API_BASE}/admin/auth/login`, {
data: { username: ADMIN_USER, password: ADMIN_PASS },
});
adminToken = (await login.json()).token;
});
test("GET /admin/cookies — returns list with pagination fields", async ({ request }) => {
const res = await request.get(`${API_BASE}/admin/cookies`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.status()).toBe(200);
const body = await res.json();
expect(body).toHaveProperty("cookies");
expect(Array.isArray(body.cookies)).toBe(true);
expect(body).toHaveProperty("total");
expect(typeof body.total).toBe("number");
});
test("GET /admin/cookies?domain=xxx — filters by domain", async ({ request }) => {
const res = await request.get(`${API_BASE}/admin/cookies?domain=nonexistent.example`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.status()).toBe(200);
const body = await res.json();
// All returned cookies should match the domain filter
for (const cookie of body.cookies) {
expect(cookie.domain).toBe("nonexistent.example");
}
});
test("DELETE /admin/cookies/:id — removes specific cookie", async ({ request }) => {
// First: push a cookie via the device API so we have something to delete
// (Depends on RCA-13 admin API — if there's a test cookie fixture, use that)
// This test is a placeholder that verifies the endpoint contract:
const res = await request.delete(`${API_BASE}/admin/cookies/nonexistent-id`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
// 404 for nonexistent, or 200 if the implementation ignores missing IDs
expect([200, 404]).toContain(res.status());
});
test("DELETE /admin/cookies — bulk delete requires body", async ({ request }) => {
const res = await request.delete(`${API_BASE}/admin/cookies`, {
headers: { Authorization: `Bearer ${adminToken}` },
data: { ids: [] },
});
expect([200, 400]).toContain(res.status());
});
test("GET /admin/cookies — unauthenticated returns 401", async ({ request }) => {
const res = await request.get(`${API_BASE}/admin/cookies`);
expect(res.status()).toBe(401);
});
});
test.describe("Devices API", () => {
test.beforeAll(async ({ request }) => {
const login = await request.post(`${API_BASE}/admin/auth/login`, {
data: { username: ADMIN_USER, password: ADMIN_PASS },
});
adminToken = (await login.json()).token;
});
test("GET /admin/devices — returns list of devices", async ({ request }) => {
const res = await request.get(`${API_BASE}/admin/devices`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.status()).toBe(200);
const body = await res.json();
expect(body).toHaveProperty("devices");
expect(Array.isArray(body.devices)).toBe(true);
// Each device should have the expected shape
for (const device of body.devices) {
expect(device).toHaveProperty("id");
expect(device).toHaveProperty("name");
expect(device).toHaveProperty("platform");
expect(device).toHaveProperty("online");
expect(typeof device.online).toBe("boolean");
}
});
test("POST /admin/devices/:id/revoke — returns 404 for unknown device", async ({
request,
}) => {
const res = await request.post(`${API_BASE}/admin/devices/nonexistent/revoke`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
expect([404, 400]).toContain(res.status());
});
test("GET /admin/devices — unauthenticated returns 401", async ({ request }) => {
const res = await request.get(`${API_BASE}/admin/devices`);
expect(res.status()).toBe(401);
});
});
test.describe("Settings API", () => {
test.beforeAll(async ({ request }) => {
const login = await request.post(`${API_BASE}/admin/auth/login`, {
data: { username: ADMIN_USER, password: ADMIN_PASS },
});
adminToken = (await login.json()).token;
});
test("GET /admin/settings — returns settings object", async ({ request }) => {
const res = await request.get(`${API_BASE}/admin/settings`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.status()).toBe(200);
const body = await res.json();
expect(body).toHaveProperty("sync");
expect(body).toHaveProperty("security");
expect(body).toHaveProperty("appearance");
});
test("PATCH /admin/settings — partial update is accepted", async ({ request }) => {
const res = await request.patch(`${API_BASE}/admin/settings`, {
headers: { Authorization: `Bearer ${adminToken}` },
data: { sync: { autoSync: true } },
});
expect([200, 204]).toContain(res.status());
});
test("PATCH /admin/settings — unknown fields are ignored or rejected gracefully", async ({
request,
}) => {
const res = await request.patch(`${API_BASE}/admin/settings`, {
headers: { Authorization: `Bearer ${adminToken}` },
data: { unknownField: "value" },
});
expect([200, 204, 400]).toContain(res.status());
});
test("GET /admin/settings — unauthenticated returns 401", async ({ request }) => {
const res = await request.get(`${API_BASE}/admin/settings`);
expect(res.status()).toBe(401);
});
});

View File

@@ -0,0 +1,239 @@
import { test, expect } from "@playwright/test";
/**
* RCA-14: Login page + first-run setup wizard
*
* Covers:
* - Login / logout flow
* - Form validation and error display
* - Route guard: unauthenticated redirect to /login
* - Route guard: authenticated redirect away from /login → dashboard
* - First-run setup wizard (GET /admin/setup/status → redirect to /setup)
*/
test.describe("Login page", () => {
test("shows username and password fields", async ({ page }) => {
await page.goto("/login");
await expect(page.getByLabel(/username/i)).toBeVisible();
await expect(page.getByLabel(/password/i)).toBeVisible();
await expect(
page.getByRole("button", { name: /log in|sign in/i }),
).toBeVisible();
});
test("disables submit while fields are empty", async ({ page }) => {
await page.goto("/login");
const btn = page.getByRole("button", { name: /log in|sign in/i });
// Should either be disabled or clicking it shows a validation error
const isEmpty = (await btn.getAttribute("disabled")) !== null;
if (!isEmpty) {
await btn.click();
// At least one validation error should appear
const hasError = await page
.getByRole("alert")
.or(page.locator("[class*=error]"))
.or(page.locator("[class*=invalid]"))
.count();
expect(hasError).toBeGreaterThan(0);
}
});
test("shows error on invalid credentials", async ({ page }) => {
await page.route("**/admin/auth/login", (route) =>
route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ error: "Invalid credentials" }),
}),
);
await page.goto("/login");
await page.getByLabel(/username/i).fill("wrong");
await page.getByLabel(/password/i).fill("wrong");
await page.getByRole("button", { name: /log in|sign in/i }).click();
await expect(page.getByText(/invalid credentials|wrong|incorrect/i)).toBeVisible();
// Should remain on /login
await expect(page).toHaveURL(/\/login/);
});
test("submits form on Enter key", async ({ page }) => {
await page.route("**/admin/auth/login", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ token: "test-jwt", expiresAt: "2099-01-01" }),
}),
);
await page.goto("/login");
await page.getByLabel(/username/i).fill("admin");
await page.getByLabel(/password/i).fill("password");
await page.getByLabel(/password/i).press("Enter");
await expect(page).toHaveURL(/\/dashboard/);
});
test("redirects to dashboard on successful login", async ({ page }) => {
await page.route("**/admin/auth/login", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ token: "test-jwt", expiresAt: "2099-01-01" }),
}),
);
await page.goto("/login");
await page.getByLabel(/username/i).fill("admin");
await page.getByLabel(/password/i).fill("password");
await page.getByRole("button", { name: /log in|sign in/i }).click();
await expect(page).toHaveURL(/\/dashboard/);
});
});
test.describe("Route guards", () => {
test("unauthenticated user is redirected to /login from protected routes", async ({
page,
}) => {
for (const route of ["/dashboard", "/cookies", "/devices", "/settings"]) {
await page.goto(route);
await expect(page).toHaveURL(/\/login/);
}
});
test("authenticated user visiting /login is redirected to /dashboard", async ({
page,
}) => {
// Seed a token so the app thinks we're logged in
await page.goto("/login");
await page.evaluate(() => localStorage.setItem("cb_admin_token", "fake-jwt"));
// Mock /admin/auth/me to return a valid user
await page.route("**/admin/auth/me", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ username: "admin" }),
}),
);
await page.goto("/login");
await expect(page).toHaveURL(/\/dashboard/);
});
});
test.describe("First-run setup wizard", () => {
test("redirects to /setup when not yet initialised", async ({ page }) => {
await page.route("**/admin/setup/status", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ initialised: false }),
}),
);
await page.goto("/");
await expect(page).toHaveURL(/\/setup/);
});
test("wizard has 4 steps and can be completed", async ({ page }) => {
await page.route("**/admin/setup/status", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ initialised: false }),
}),
);
await page.route("**/admin/setup/init", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ ok: true }),
}),
);
await page.goto("/setup");
// Step 1: Welcome
await expect(page.getByText(/welcome|cookiebridge/i)).toBeVisible();
await page.getByRole("button", { name: /next|continue/i }).click();
// Step 2: Create admin account
await page.getByLabel(/username/i).fill("admin");
await page.getByLabel(/^password$/i).fill("Secure123!");
await page.getByLabel(/confirm password/i).fill("Secure123!");
await page.getByRole("button", { name: /next|continue/i }).click();
// Step 3: Basic config (port, HTTPS)
await expect(
page.getByLabel(/port/i).or(page.getByText(/port|https/i)),
).toBeVisible();
await page.getByRole("button", { name: /next|continue/i }).click();
// Step 4: Completion
await expect(page.getByText(/done|complete|finish/i)).toBeVisible();
await page.getByRole("button", { name: /go to login|finish/i }).click();
await expect(page).toHaveURL(/\/login/);
});
test("password mismatch in setup shows error", async ({ page }) => {
await page.route("**/admin/setup/status", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ initialised: false }),
}),
);
await page.goto("/setup");
await page.getByRole("button", { name: /next|continue/i }).click();
await page.getByLabel(/username/i).fill("admin");
await page.getByLabel(/^password$/i).fill("Secure123!");
await page.getByLabel(/confirm password/i).fill("Mismatch999!");
await page.getByRole("button", { name: /next|continue/i }).click();
await expect(page.getByText(/do not match|mismatch|must match/i)).toBeVisible();
});
});
test.describe("Logout", () => {
test("logout clears session and redirects to /login", async ({ page }) => {
await page.route("**/admin/auth/login", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ token: "test-jwt", expiresAt: "2099-01-01" }),
}),
);
await page.route("**/admin/auth/logout", (route) =>
route.fulfill({ status: 204 }),
);
await page.route("**/admin/dashboard", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ devices: {}, cookies: {}, syncCount: 0, uptimeSeconds: 0 }),
}),
);
// Log in first
await page.goto("/login");
await page.getByLabel(/username/i).fill("admin");
await page.getByLabel(/password/i).fill("password");
await page.getByRole("button", { name: /log in|sign in/i }).click();
await expect(page).toHaveURL(/\/dashboard/);
// Log out
const logoutBtn = page
.getByRole("button", { name: /log ?out|sign ?out/i })
.or(page.getByRole("link", { name: /log ?out|sign ?out/i }));
await logoutBtn.click();
await expect(page).toHaveURL(/\/login/);
// Token should be gone
const token = await page.evaluate(() => localStorage.getItem("cb_admin_token"));
expect(token).toBeNull();
});
});

View File

@@ -0,0 +1,116 @@
import { test, expect } from "@playwright/test";
import { loginViaAPI } from "./helpers/auth.js";
import { mockDashboard, mockAPIError } from "./helpers/mock-api.js";
/**
* RCA-15: Dashboard
*
* Covers:
* - Stats cards render with correct values
* - Device status list
* - Quick-action links navigate to correct routes
* - Data refresh works
* - Error state when API fails
*/
test.describe("Dashboard", () => {
test.beforeEach(async ({ page, request }) => {
await loginViaAPI(page, request);
await mockDashboard(page);
});
test("shows all four stats cards", async ({ page }) => {
await page.goto("/dashboard");
// Connected devices
await expect(page.getByText(/connected devices|devices/i).first()).toBeVisible();
// Cookie count
await expect(page.getByText(/cookie|cookies/i).first()).toBeVisible();
// Sync count
await expect(page.getByText(/sync/i).first()).toBeVisible();
// Uptime
await expect(page.getByText(/uptime|running/i).first()).toBeVisible();
});
test("stats cards display values from the API", async ({ page }) => {
await page.goto("/dashboard");
// Our mock returns: devices total=3, cookies total=142, syncCount=57
await expect(page.getByText("3")).toBeVisible();
await expect(page.getByText("142")).toBeVisible();
await expect(page.getByText("57")).toBeVisible();
});
test("device status list shows online/offline badges", async ({ page }) => {
await page.route("**/admin/devices*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
devices: [
{ id: "d1", name: "Chrome on macOS", platform: "chrome", online: true, lastSeen: new Date().toISOString() },
{ id: "d2", name: "Firefox on Windows", platform: "firefox", online: false, lastSeen: "2026-03-15T10:00:00Z" },
],
}),
}),
);
await page.goto("/dashboard");
await expect(page.getByText("Chrome on macOS")).toBeVisible();
await expect(page.getByText("Firefox on Windows")).toBeVisible();
// At least one online/offline indicator
const badges = page.getByText(/online|offline/i);
await expect(badges.first()).toBeVisible();
});
test("quick action 'View all cookies' navigates to /cookies", async ({ page }) => {
await page.goto("/dashboard");
await page.getByRole("link", { name: /view all cookie|all cookie|cookie/i }).first().click();
await expect(page).toHaveURL(/\/cookies/);
});
test("quick action 'Manage devices' navigates to /devices", async ({ page }) => {
await page.goto("/dashboard");
await page.getByRole("link", { name: /manage device|devices/i }).first().click();
await expect(page).toHaveURL(/\/devices/);
});
test("quick action 'Settings' navigates to /settings", async ({ page }) => {
await page.goto("/dashboard");
await page.getByRole("link", { name: /setting|settings/i }).first().click();
await expect(page).toHaveURL(/\/settings/);
});
test("refresh button re-fetches dashboard data", async ({ page }) => {
let callCount = 0;
await page.route("**/admin/dashboard", (route) => {
callCount++;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ devices: {}, cookies: {}, syncCount: callCount, uptimeSeconds: 0 }),
});
});
await page.goto("/dashboard");
const refreshBtn = page.getByRole("button", { name: /refresh/i });
if (await refreshBtn.isVisible()) {
const before = callCount;
await refreshBtn.click();
expect(callCount).toBeGreaterThan(before);
}
});
test("shows error message when dashboard API fails", async ({ page }) => {
await page.unroute("**/admin/dashboard");
await mockAPIError(page, "**/admin/dashboard", 500, "Server error");
await page.goto("/dashboard");
await expect(
page
.getByRole("alert")
.or(page.getByText(/error|failed|unavailable/i))
.first(),
).toBeVisible();
});
});

View File

@@ -0,0 +1,186 @@
import { test, expect } from "@playwright/test";
import { loginViaAPI } from "./helpers/auth.js";
import { mockCookies, mockAPIError } from "./helpers/mock-api.js";
/**
* RCA-16: Cookie management page
*
* Covers:
* - Cookies grouped by domain
* - Search by domain name
* - Search by cookie name
* - Detail panel shows all fields
* - Delete single cookie with confirmation
* - Bulk delete
* - Domain group collapse/expand
* - Pagination / scroll
* - API error state
*/
test.describe("Cookie management", () => {
test.beforeEach(async ({ page, request }) => {
await loginViaAPI(page, request);
await mockCookies(page);
});
test("lists cookies grouped by domain", async ({ page }) => {
await page.goto("/cookies");
await expect(page.getByText("example.com")).toBeVisible();
await expect(page.getByText("other.io")).toBeVisible();
});
test("search by domain filters results", async ({ page }) => {
await page.goto("/cookies");
const searchInput = page
.getByPlaceholder(/search/i)
.or(page.getByRole("searchbox"))
.or(page.getByLabel(/search/i));
await searchInput.fill("other.io");
await expect(page.getByText("other.io")).toBeVisible();
await expect(page.getByText("example.com")).not.toBeVisible();
});
test("search by cookie name filters results", async ({ page }) => {
await page.goto("/cookies");
const searchInput = page
.getByPlaceholder(/search/i)
.or(page.getByRole("searchbox"))
.or(page.getByLabel(/search/i));
await searchInput.fill("session");
// "session" cookie under example.com should be visible
await expect(page.getByText("session")).toBeVisible();
// "token" under other.io should not be visible
await expect(page.getByText("token")).not.toBeVisible();
});
test("clicking a cookie shows detail panel with all fields", async ({ page }) => {
await page.goto("/cookies");
// Click the "session" cookie row
await page.getByText("session").first().click();
// Detail panel should show all cookie fields
await expect(page.getByText(/name/i)).toBeVisible();
await expect(page.getByText(/value/i)).toBeVisible();
await expect(page.getByText(/domain/i)).toBeVisible();
await expect(page.getByText(/path/i)).toBeVisible();
await expect(page.getByText(/expires/i)).toBeVisible();
await expect(page.getByText(/secure/i)).toBeVisible();
await expect(page.getByText(/httponly/i)).toBeVisible();
});
test("deletes a single cookie after confirmation", async ({ page }) => {
let deleteCalled = false;
await page.route("**/admin/cookies/c1", (route) => {
if (route.request().method() === "DELETE") {
deleteCalled = true;
return route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
}
return route.continue();
});
await page.goto("/cookies");
// Click the first cookie's delete button
const deleteBtn = page
.getByRole("button", { name: /delete/i })
.first();
await deleteBtn.click();
// Confirmation dialog should appear
await expect(
page.getByRole("dialog").or(page.getByText(/confirm|are you sure/i)),
).toBeVisible();
// Confirm deletion
await page.getByRole("button", { name: /confirm|yes|delete/i }).last().click();
expect(deleteCalled).toBe(true);
});
test("cancel on delete dialog does not delete the cookie", async ({ page }) => {
let deleteCalled = false;
await page.route("**/admin/cookies/*", (route) => {
if (route.request().method() === "DELETE") {
deleteCalled = true;
}
return route.continue();
});
await page.goto("/cookies");
const deleteBtn = page.getByRole("button", { name: /delete/i }).first();
await deleteBtn.click();
await page
.getByRole("button", { name: /cancel|no/i })
.last()
.click();
expect(deleteCalled).toBe(false);
});
test("can select multiple cookies and bulk delete", async ({ page }) => {
let bulkDeleteCalled = false;
await page.route("**/admin/cookies", (route) => {
if (route.request().method() === "DELETE") {
bulkDeleteCalled = true;
return route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
}
return route.continue();
});
await page.goto("/cookies");
// Select checkboxes
const checkboxes = page.getByRole("checkbox");
const count = await checkboxes.count();
if (count > 0) {
await checkboxes.first().check();
if (count > 1) await checkboxes.nth(1).check();
const bulkBtn = page.getByRole("button", { name: /delete selected|bulk delete/i });
if (await bulkBtn.isVisible()) {
await bulkBtn.click();
await page.getByRole("button", { name: /confirm|yes|delete/i }).last().click();
expect(bulkDeleteCalled).toBe(true);
}
}
});
test("domain group collapses and expands", async ({ page }) => {
await page.goto("/cookies");
// Find a domain group header and click to collapse
const groupHeader = page.getByText("example.com").first();
await groupHeader.click();
// After collapse, cookies within that domain should be hidden
// (exact selector depends on implementation — check one of the children)
const sessionCookie = page.getByText("session");
// It may be hidden or removed; either is acceptable
const isVisible = await sessionCookie.isVisible().catch(() => false);
// Click again to expand
await groupHeader.click();
await expect(page.getByText("session")).toBeVisible();
});
test("shows error message when cookies API fails", async ({ page }) => {
await page.unroute("**/admin/cookies*");
await mockAPIError(page, "**/admin/cookies*", 500, "Failed to load cookies");
await page.goto("/cookies");
await expect(
page
.getByRole("alert")
.or(page.getByText(/error|failed|could not load/i))
.first(),
).toBeVisible();
});
});

View File

@@ -0,0 +1,171 @@
import { test, expect } from "@playwright/test";
import { loginViaAPI } from "./helpers/auth.js";
import { mockDevices, mockAPIError } from "./helpers/mock-api.js";
/**
* RCA-17: Device management page
*
* Covers:
* - Device card grid layout
* - Online/offline status badge
* - Platform icons (chrome, firefox, edge, safari)
* - Last seen time displayed
* - Remote revoke with confirmation dialog
* - Device detail expansion
* - Filter by online status
* - API error state
*/
test.describe("Device management", () => {
test.beforeEach(async ({ page, request }) => {
await loginViaAPI(page, request);
await mockDevices(page);
});
test("displays device cards in a grid", async ({ page }) => {
await page.goto("/devices");
await expect(page.getByText("Chrome on macOS")).toBeVisible();
await expect(page.getByText("Firefox on Windows")).toBeVisible();
});
test("shows online badge for online device", async ({ page }) => {
await page.goto("/devices");
// Find the Chrome on macOS card and verify it has an online indicator
const chromeCard = page.locator("[class*=card], [class*=device]").filter({
hasText: "Chrome on macOS",
});
await expect(chromeCard).toBeVisible();
await expect(
chromeCard.getByText(/online/i).or(chromeCard.locator("[class*=online]")),
).toBeVisible();
});
test("shows offline badge for offline device", async ({ page }) => {
await page.goto("/devices");
const ffCard = page.locator("[class*=card], [class*=device]").filter({
hasText: "Firefox on Windows",
});
await expect(ffCard).toBeVisible();
await expect(
ffCard.getByText(/offline/i).or(ffCard.locator("[class*=offline]")),
).toBeVisible();
});
test("shows last active time for each device", async ({ page }) => {
await page.goto("/devices");
await expect(page.getByText(/last seen|last active/i).first()).toBeVisible();
});
test("remote revoke opens confirmation dialog", async ({ page }) => {
await page.goto("/devices");
const revokeBtn = page
.getByRole("button", { name: /revoke|logout|sign out/i })
.first();
await revokeBtn.click();
await expect(
page
.getByRole("dialog")
.or(page.getByText(/confirm|are you sure|revoke/i))
.first(),
).toBeVisible();
});
test("confirming revoke calls POST /admin/devices/:id/revoke", async ({ page }) => {
let revokeCalled = false;
await page.route("**/admin/devices/d1/revoke", (route) => {
if (route.request().method() === "POST") {
revokeCalled = true;
return route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
}
return route.continue();
});
await page.goto("/devices");
const revokeBtn = page
.getByRole("button", { name: /revoke|logout|sign out/i })
.first();
await revokeBtn.click();
await page
.getByRole("button", { name: /confirm|yes|revoke/i })
.last()
.click();
expect(revokeCalled).toBe(true);
});
test("cancelling revoke dialog does not call API", async ({ page }) => {
let revokeCalled = false;
await page.route("**/admin/devices/*/revoke", (route) => {
revokeCalled = true;
return route.continue();
});
await page.goto("/devices");
const revokeBtn = page
.getByRole("button", { name: /revoke|logout|sign out/i })
.first();
await revokeBtn.click();
await page
.getByRole("button", { name: /cancel|no/i })
.last()
.click();
expect(revokeCalled).toBe(false);
});
test("device detail expansion shows extra fields", async ({ page }) => {
await page.goto("/devices");
// Click a device card or expand button to reveal detail
const card = page
.locator("[class*=card], [class*=device]")
.filter({ hasText: "Chrome on macOS" });
await card.click();
await expect(
page
.getByText(/extension version|version/i)
.or(page.getByText(/registered|first seen/i))
.first(),
).toBeVisible();
});
test("filter by 'online' shows only online devices", async ({ page }) => {
await page.goto("/devices");
const filterSelect = page
.getByLabel(/filter|status/i)
.or(page.getByRole("combobox"))
.or(page.getByRole("listbox"));
if ((await filterSelect.count()) > 0) {
await filterSelect.first().selectOption({ label: /online/i });
await expect(page.getByText("Chrome on macOS")).toBeVisible();
await expect(page.getByText("Firefox on Windows")).not.toBeVisible();
}
});
test("shows error message when devices API fails", async ({ page }) => {
await page.unroute("**/admin/devices*");
await mockAPIError(page, "**/admin/devices*", 500, "Failed to load devices");
await page.goto("/devices");
await expect(
page
.getByRole("alert")
.or(page.getByText(/error|failed|could not load/i))
.first(),
).toBeVisible();
});
});

View File

@@ -0,0 +1,210 @@
import { test, expect } from "@playwright/test";
import { loginViaAPI } from "./helpers/auth.js";
import { mockSettings, mockAPIError } from "./helpers/mock-api.js";
/**
* RCA-18: Settings page
*
* Covers:
* - Three tabs: Sync / Security / Appearance
* - Settings are pre-populated from GET /admin/settings
* - Changes saved via PATCH /admin/settings
* - Success toast on save
* - Password change (security tab)
* - Theme selection (appearance tab)
* - Language selection (appearance tab)
* - API error on save
*/
test.describe("Settings page", () => {
test.beforeEach(async ({ page, request }) => {
await loginViaAPI(page, request);
await mockSettings(page);
});
test("displays three tabs: sync, security, appearance", async ({ page }) => {
await page.goto("/settings");
await expect(page.getByRole("tab", { name: /sync/i })).toBeVisible();
await expect(page.getByRole("tab", { name: /security/i })).toBeVisible();
await expect(page.getByRole("tab", { name: /appearance/i })).toBeVisible();
});
// --- Sync tab ---
test("sync tab: auto-sync toggle reflects saved value", async ({ page }) => {
await page.goto("/settings");
await page.getByRole("tab", { name: /sync/i }).click();
const toggle = page
.getByRole("switch", { name: /auto.?sync/i })
.or(page.getByLabel(/auto.?sync/i));
// Mock returns autoSync: true
await expect(toggle).toBeChecked();
});
test("sync tab: frequency selector shows current value", async ({ page }) => {
await page.goto("/settings");
await page.getByRole("tab", { name: /sync/i }).click();
// Mock returns frequency: "realtime"
const select = page
.getByLabel(/frequency/i)
.or(page.getByRole("combobox").filter({ hasText: /realtime/i }));
await expect(select).toBeVisible();
});
test("sync tab: saving calls PATCH /admin/settings", async ({ page }) => {
let patchCalled = false;
await page.route("**/admin/settings", (route) => {
if (route.request().method() === "PATCH") {
patchCalled = true;
return route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
}
return route.continue();
});
await page.goto("/settings");
await page.getByRole("tab", { name: /sync/i }).click();
// Toggle auto-sync off
const toggle = page
.getByRole("switch", { name: /auto.?sync/i })
.or(page.getByLabel(/auto.?sync/i));
await toggle.click();
// Some implementations save immediately; others have an explicit Save button
const saveBtn = page.getByRole("button", { name: /save/i });
if (await saveBtn.isVisible()) await saveBtn.click();
expect(patchCalled).toBe(true);
});
test("sync tab: success toast appears after save", async ({ page }) => {
await page.route("**/admin/settings", (route) => {
if (route.request().method() === "PATCH") {
return route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
}
return route.continue();
});
await page.goto("/settings");
await page.getByRole("tab", { name: /sync/i }).click();
const toggle = page
.getByRole("switch", { name: /auto.?sync/i })
.or(page.getByLabel(/auto.?sync/i));
await toggle.click();
const saveBtn = page.getByRole("button", { name: /save/i });
if (await saveBtn.isVisible()) await saveBtn.click();
await expect(
page.getByText(/saved|success|updated/i).first(),
).toBeVisible({ timeout: 5000 });
});
// --- Security tab ---
test("security tab: change password requires current + new + confirm", async ({ page }) => {
await page.goto("/settings");
await page.getByRole("tab", { name: /security/i }).click();
await expect(page.getByLabel(/current password/i)).toBeVisible();
await expect(page.getByLabel(/new password/i)).toBeVisible();
await expect(page.getByLabel(/confirm.*(new )?password/i)).toBeVisible();
});
test("security tab: password change with mismatch shows error", async ({ page }) => {
await page.goto("/settings");
await page.getByRole("tab", { name: /security/i }).click();
await page.getByLabel(/current password/i).fill("oldPass123");
await page.getByLabel(/new password/i).fill("NewPass456!");
await page.getByLabel(/confirm.*(new )?password/i).fill("Different789!");
await page.getByRole("button", { name: /change|save password|update/i }).click();
await expect(page.getByText(/do not match|mismatch|must match/i)).toBeVisible();
});
test("security tab: session timeout field accepts numeric input", async ({ page }) => {
await page.goto("/settings");
await page.getByRole("tab", { name: /security/i }).click();
const timeoutField = page
.getByLabel(/session timeout/i)
.or(page.getByRole("spinbutton").filter({ hasText: /timeout/i }));
if (await timeoutField.isVisible()) {
await timeoutField.fill("120");
await expect(timeoutField).toHaveValue("120");
}
});
// --- Appearance tab ---
test("appearance tab: theme options present (light/dark/system)", async ({ page }) => {
await page.goto("/settings");
await page.getByRole("tab", { name: /appearance/i }).click();
await expect(page.getByText(/light/i)).toBeVisible();
await expect(page.getByText(/dark/i)).toBeVisible();
await expect(page.getByText(/system/i)).toBeVisible();
});
test("appearance tab: language selector shows Chinese and English options", async ({ page }) => {
await page.goto("/settings");
await page.getByRole("tab", { name: /appearance/i }).click();
await expect(
page.getByText(/chinese|中文/i).or(page.getByText("zh")),
).toBeVisible();
await expect(
page.getByText(/english/i).or(page.getByText("en")),
).toBeVisible();
});
// --- Error states ---
test("shows error message when settings fail to load", async ({ page }) => {
await page.unroute("**/admin/settings*");
await mockAPIError(page, "**/admin/settings*", 500, "Failed to load settings");
await page.goto("/settings");
await expect(
page
.getByRole("alert")
.or(page.getByText(/error|failed|could not load/i))
.first(),
).toBeVisible();
});
test("shows error toast when save fails", async ({ page }) => {
await page.route("**/admin/settings", (route) => {
if (route.request().method() === "PATCH") {
return route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ error: "Server error" }),
});
}
return route.continue();
});
await page.goto("/settings");
await page.getByRole("tab", { name: /sync/i }).click();
const toggle = page
.getByRole("switch", { name: /auto.?sync/i })
.or(page.getByLabel(/auto.?sync/i));
await toggle.click();
const saveBtn = page.getByRole("button", { name: /save/i });
if (await saveBtn.isVisible()) await saveBtn.click();
await expect(
page.getByText(/error|failed|could not save/i).first(),
).toBeVisible({ timeout: 5000 });
});
});

View File

@@ -0,0 +1,63 @@
import { test, expect, devices } from "@playwright/test";
import { loginViaAPI } from "./helpers/auth.js";
import { mockDashboard, mockCookies, mockDevices, mockSettings } from "./helpers/mock-api.js";
/**
* Responsive layout tests
*
* These run on the default desktop viewport; the Playwright projects
* in playwright.config.ts also exercise mobile-chrome, mobile-safari,
* and tablet viewports automatically.
*
* This file adds explicit viewport-override tests for key layout expectations.
*/
const PAGES = [
{ path: "/dashboard", name: "Dashboard" },
{ path: "/cookies", name: "Cookies" },
{ path: "/devices", name: "Devices" },
{ path: "/settings", name: "Settings" },
];
for (const { path, name } of PAGES) {
test.describe(`Responsive — ${name}`, () => {
test.beforeEach(async ({ page, request }) => {
await loginViaAPI(page, request);
await mockDashboard(page);
await mockCookies(page);
await mockDevices(page);
await mockSettings(page);
});
test("renders without horizontal scroll on mobile (375px)", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto(path);
await page.waitForLoadState("networkidle");
const scrollWidth = await page.evaluate(() => document.body.scrollWidth);
const clientWidth = await page.evaluate(() => document.body.clientWidth);
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1); // 1px tolerance
});
test("renders without horizontal scroll on tablet (768px)", async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
await page.goto(path);
await page.waitForLoadState("networkidle");
const scrollWidth = await page.evaluate(() => document.body.scrollWidth);
const clientWidth = await page.evaluate(() => document.body.clientWidth);
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
});
test("navigation is reachable on mobile", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto(path);
// On mobile there's typically a hamburger menu or bottom nav
const nav = page
.getByRole("navigation")
.or(page.getByRole("button", { name: /menu|nav/i }));
await expect(nav.first()).toBeVisible();
});
});
}

View File

@@ -0,0 +1,46 @@
import { type Page, type APIRequestContext, expect } from "@playwright/test";
export const TEST_ADMIN = {
username: process.env.TEST_ADMIN_USER ?? "admin",
password: process.env.TEST_ADMIN_PASS ?? "testpassword123",
};
/**
* Log in via the UI login form and wait for the dashboard to load.
*/
export async function loginViaUI(page: Page): Promise<void> {
await page.goto("/login");
await page.getByLabel(/username/i).fill(TEST_ADMIN.username);
await page.getByLabel(/password/i).fill(TEST_ADMIN.password);
await page.getByRole("button", { name: /log in|sign in/i }).click();
await expect(page).toHaveURL(/\/dashboard/);
}
/**
* Log in via the admin API directly and store the token in localStorage.
* Faster than UI login for tests that only need an authenticated session.
*/
export async function loginViaAPI(
page: Page,
_request?: APIRequestContext,
): Promise<string> {
const token = "test-jwt-token";
await page.goto("/");
await page.evaluate(
({ t }) => localStorage.setItem("cb_admin_token", t),
{ t: token },
);
return token;
}
/**
* Log out via the UI and confirm redirect to /login.
*/
export async function logoutViaUI(page: Page): Promise<void> {
// Common patterns: a "Logout" button in the nav/header
const logoutBtn = page
.getByRole("button", { name: /log ?out|sign ?out/i })
.or(page.getByRole("link", { name: /log ?out|sign ?out/i }));
await logoutBtn.click();
await expect(page).toHaveURL(/\/login/);
}

View File

@@ -0,0 +1,164 @@
import { type Page } from "@playwright/test";
/**
* Intercept /admin/dashboard and return a canned response so UI tests
* don't depend on a running relay server with real data.
*/
export async function mockDashboard(page: Page): Promise<void> {
await page.route("**/admin/dashboard", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
totalDevices: 3,
onlineDevices: 2,
totalCookies: 142,
uniqueDomains: 8,
connections: 2,
syncCount: 57,
uptimeSeconds: 86400,
}),
}),
);
}
/**
* Intercept /admin/cookies and return a paginated list.
*/
export async function mockCookies(page: Page): Promise<void> {
await page.route("**/admin/cookies*", (route) => {
if (route.request().method() === "DELETE") {
return route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
cookies: [
{
id: "c1",
deviceId: "dev-001",
domain: "example.com",
cookieName: "session",
path: "/",
ciphertext: "encrypted-abc123",
nonce: "nonce1",
lamportTs: 1,
updatedAt: "2026-03-01T00:00:00Z",
expires: "2027-01-01T00:00:00Z",
secure: true,
httpOnly: true,
},
{
id: "c2",
deviceId: "dev-001",
domain: "example.com",
cookieName: "pref",
path: "/",
ciphertext: "encrypted-dark",
nonce: "nonce2",
lamportTs: 2,
updatedAt: "2026-03-02T00:00:00Z",
expires: "2027-06-01T00:00:00Z",
secure: false,
httpOnly: false,
},
{
id: "c3",
deviceId: "dev-002",
domain: "other.io",
cookieName: "token",
path: "/",
ciphertext: "encrypted-xyz",
nonce: "nonce3",
lamportTs: 3,
updatedAt: "2026-03-03T00:00:00Z",
expires: null,
secure: true,
httpOnly: true,
},
],
total: 3,
page: 1,
}),
});
});
}
/**
* Intercept /admin/devices and return device list.
*/
export async function mockDevices(page: Page): Promise<void> {
await page.route("**/admin/devices*", (route) => {
if (route.request().method() !== "GET") return route.continue();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
devices: [
{
deviceId: "d1",
name: "Chrome on macOS",
platform: "chrome",
online: true,
lastSeen: new Date().toISOString(),
createdAt: "2026-01-01T00:00:00Z",
ipAddress: "192.168.1.10",
extensionVersion: "2.0.0",
},
{
deviceId: "d2",
name: "Firefox on Windows",
platform: "firefox",
online: false,
lastSeen: "2026-03-15T10:00:00Z",
createdAt: "2026-02-01T00:00:00Z",
ipAddress: null,
extensionVersion: "2.0.0",
},
],
}),
});
});
}
/**
* Intercept /admin/settings and return settings object.
*/
export async function mockSettings(page: Page): Promise<void> {
await page.route("**/admin/settings*", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
autoSync: true,
syncIntervalMs: 0,
maxDevices: 10,
theme: "system",
sessionTimeoutMinutes: 60,
language: "zh",
}),
});
}
return route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
});
}
/**
* Simulate a 500 error on the given path — used for error-handling tests.
*/
export async function mockAPIError(
page: Page,
urlPattern: string,
status = 500,
message = "Internal Server Error",
): Promise<void> {
await page.route(urlPattern, (route) =>
route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify({ error: message }),
}),
);
}

24
web/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"importHelpers": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"isolatedModules": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"paths": {
"@/*": ["./src/*"]
},
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "env.d.ts"],
"exclude": ["node_modules", "dist"]
}

Some files were not shown because too many files have changed in this diff Show More