Compare commits
9 Commits
b6fbf7a921
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb5c841282 | ||
|
|
1093d64724 | ||
|
|
1420c4ecfa | ||
|
|
6504d3c7b9 | ||
|
|
147f9d4761 | ||
|
|
1a6d61ec36 | ||
|
|
a320f7ad97 | ||
|
|
f4144c96f1 | ||
|
|
e3a9d9f63c |
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
@@ -24,9 +24,26 @@ jobs:
|
||||
- run: npm run typecheck
|
||||
- run: npm test
|
||||
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Install and build frontend
|
||||
working-directory: web
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
needs: [test, web]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -38,6 +55,7 @@ jobs:
|
||||
docker run -d --name cb-test -p 8080:8080 cookiebridge:ci
|
||||
sleep 3
|
||||
curl -sf http://localhost:8080/health
|
||||
curl -sf http://localhost:8080/ | grep -q '<div id="app">'
|
||||
docker stop cb-test
|
||||
|
||||
extension:
|
||||
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
node_modules/
|
||||
dist/
|
||||
public/
|
||||
data/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
@@ -22,6 +22,23 @@ npm test # Run test suite
|
||||
npm run typecheck # Type checking only
|
||||
```
|
||||
|
||||
**Admin panel (frontend):**
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run dev # Starts Vite dev server on :5173 (proxies API to backend)
|
||||
```
|
||||
|
||||
The frontend dev server proxies `/api`, `/admin`, `/ws`, and `/health` to `http://localhost:8100`. Start the backend first with `npm run dev` (or adjust the proxy target in `web/vite.config.ts`).
|
||||
|
||||
**Full production build:**
|
||||
|
||||
```bash
|
||||
npm run build:all # Builds backend (tsc) + frontend (vite) → copies to public/
|
||||
npm start # Serves everything on :8080
|
||||
```
|
||||
|
||||
**Extension:**
|
||||
|
||||
```bash
|
||||
|
||||
15
Dockerfile
15
Dockerfile
@@ -1,3 +1,15 @@
|
||||
## Stage 1: Build frontend (Vue/Vite)
|
||||
FROM node:22-alpine AS web-builder
|
||||
|
||||
WORKDIR /app/web
|
||||
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
## Stage 2: Build backend (TypeScript)
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
@@ -12,7 +24,7 @@ COPY tsconfig.json ./
|
||||
COPY src/ ./src/
|
||||
RUN npm run build
|
||||
|
||||
# --- Production image ---
|
||||
## Stage 3: Production image
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
@@ -25,6 +37,7 @@ RUN npm ci --omit=dev --ignore-scripts=false && \
|
||||
rm -rf /root/.npm /tmp/*
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=web-builder /app/web/dist ./public
|
||||
|
||||
ENV PORT=8080
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
91
README.md
91
README.md
@@ -10,8 +10,10 @@ CookieBridge syncs browser cookies across devices through an encrypted relay ser
|
||||
- **Multi-browser support** — Chrome, Firefox, Edge, and Safari extensions.
|
||||
- **Real-time sync** — WebSocket transport with HTTP polling fallback.
|
||||
- **Device pairing** — 6-digit code, 5-minute TTL, X25519 key exchange.
|
||||
- **Admin panel** — Built-in Vue 3 web UI for server management.
|
||||
- **AI agent API** — Agents can retrieve encrypted cookies with granted access.
|
||||
- **Conflict resolution** — Last-writer-wins with Lamport clocks.
|
||||
- **Database options** — In-memory (default), SQLite, or MySQL via setup wizard.
|
||||
- **Self-hostable** — Docker image or run directly with Node.js.
|
||||
|
||||
## Quick Start
|
||||
@@ -22,7 +24,9 @@ CookieBridge syncs browser cookies across devices through an encrypted relay ser
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The relay server starts on port 8080. Override with `PORT=3000 docker compose up -d`.
|
||||
The server starts on port 8080 with the admin UI embedded. Override the port with `PORT=3000 docker compose up -d`.
|
||||
|
||||
Open `http://localhost:8080` to access the admin panel and run the setup wizard.
|
||||
|
||||
### Docker (manual)
|
||||
|
||||
@@ -34,11 +38,30 @@ docker run -d -p 8080:8080 --name cookiebridge cookiebridge
|
||||
### From source
|
||||
|
||||
```bash
|
||||
# Install all dependencies
|
||||
npm install
|
||||
cd web && npm install && cd ..
|
||||
|
||||
# Build everything (backend + frontend)
|
||||
npm run build:all
|
||||
|
||||
# Start the server (serves API + admin UI)
|
||||
npm start
|
||||
```
|
||||
|
||||
Requires Node.js 22+. The server listens on `0.0.0.0:8080` by default.
|
||||
Requires Node.js 22+. The server listens on `0.0.0.0:8080` by default and serves the admin UI at the root URL.
|
||||
|
||||
### Development mode
|
||||
|
||||
Run the backend and frontend dev servers separately for hot-reload:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Backend (port 8080)
|
||||
npm run dev
|
||||
|
||||
# Terminal 2: Frontend dev server (port 5173, proxies API to backend)
|
||||
cd web && npm run dev
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
@@ -77,15 +100,15 @@ Output goes to `extension/build/{browser}/`. Load the unpacked extension from th
|
||||
|
||||
```
|
||||
Browser Extension ──WebSocket/HTTP──▶ Relay Server (stores encrypted blobs)
|
||||
│ ▲
|
||||
├── Ed25519 signing │
|
||||
├── X25519 key exchange │
|
||||
└── XChaCha20-Poly1305 encryption │
|
||||
│
|
||||
AI Agent ──Bearer token──────────────────────┘
|
||||
│ │
|
||||
├── Ed25519 signing ├── Admin UI (Vue 3 SPA)
|
||||
├── X25519 key exchange ├── SQLite / MySQL / In-memory
|
||||
└── XChaCha20-Poly1305 encryption └── Setup wizard
|
||||
│
|
||||
AI Agent ──Bearer token──────────────────┘
|
||||
```
|
||||
|
||||
The relay server is a plain Node.js HTTP + WebSocket server with no framework dependencies. See [docs/architecture.md](docs/architecture.md) for the full design and [docs/security.md](docs/security.md) for the threat model.
|
||||
The relay server is a plain Node.js HTTP + WebSocket server with no framework dependencies. In production, the server embeds the pre-built admin UI and serves it as static files. See [docs/architecture.md](docs/architecture.md) for the full design and [docs/security.md](docs/security.md) for the threat model.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
@@ -104,6 +127,46 @@ The relay server is a plain Node.js HTTP + WebSocket server with no framework de
|
||||
| `GET` | `/health` | Health check |
|
||||
| `WebSocket` | `/ws` | Real-time sync channel |
|
||||
|
||||
### Admin Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `POST` | `/admin/setup/init` | Run setup wizard (set password, choose DB) |
|
||||
| `GET` | `/admin/setup/status` | Check if setup has been completed |
|
||||
| `POST` | `/admin/login` | Login to admin panel |
|
||||
| `GET` | `/admin/devices` | List registered devices |
|
||||
| `GET` | `/admin/connections` | List active WebSocket connections |
|
||||
| `GET` | `/admin/agents` | List registered agents |
|
||||
| `GET` | `/admin/stats` | Server statistics |
|
||||
|
||||
## Deployment
|
||||
|
||||
### Build Pipeline
|
||||
|
||||
The Dockerfile uses a multi-stage build:
|
||||
|
||||
1. **web-builder** — Installs frontend dependencies and runs `vite build`
|
||||
2. **builder** — Compiles the TypeScript backend
|
||||
3. **production** — Copies compiled backend + built frontend into a minimal image
|
||||
|
||||
The frontend is served from the `/public` directory inside the container. No separate web server (nginx, etc.) is needed.
|
||||
|
||||
### Database Persistence
|
||||
|
||||
By default, CookieBridge starts with in-memory storage. On first access, the setup wizard lets you choose:
|
||||
|
||||
- **In-memory** — No persistence, data resets on restart
|
||||
- **SQLite** — File-based, mount a volume for persistence
|
||||
- **MySQL** — Remote database, provide connection details
|
||||
|
||||
For SQLite persistence with Docker:
|
||||
|
||||
```bash
|
||||
docker run -d -p 8080:8080 -v cookiebridge-data:/app/data cookiebridge
|
||||
```
|
||||
|
||||
Database configuration is stored in `data/db-config.json`.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
@@ -111,6 +174,7 @@ npm install
|
||||
npm run dev # Start with file watching
|
||||
npm test # Run test suite
|
||||
npm run typecheck # Type checking only
|
||||
npm run build:all # Build backend + frontend
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
@@ -119,18 +183,21 @@ npm run typecheck # Type checking only
|
||||
src/
|
||||
cli.ts # Server entry point
|
||||
relay/
|
||||
server.ts # HTTP + WebSocket server
|
||||
server.ts # HTTP + WebSocket server + static file serving
|
||||
static.ts # Static file serving with SPA fallback
|
||||
connections.ts # WebSocket connection manager
|
||||
auth.ts # Token & challenge-response auth
|
||||
store.ts # In-memory encrypted cookie storage
|
||||
tokens.ts # Device & agent registries
|
||||
admin/ # Admin panel API routes
|
||||
db/ # Database abstraction (memory, SQLite, MySQL)
|
||||
crypto/ # XChaCha20-Poly1305, Ed25519
|
||||
pairing/ # Device pairing flow
|
||||
sync/ # Sync engine, conflict resolution
|
||||
protocol/
|
||||
spec.ts # Protocol types & constants
|
||||
web/ # Admin panel (Vue 3 + Vite)
|
||||
extension/ # Multi-browser extension source
|
||||
tests/ # Vitest test suite
|
||||
docs/ # Architecture and security docs
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
716
package-lock.json
generated
716
package-lock.json
generated
@@ -1,20 +1,25 @@
|
||||
{
|
||||
"name": "cookiebridge",
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cookiebridge",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mysql2": "^3.20.0",
|
||||
"sodium-native": "^5.1.0",
|
||||
"typescript": "^5.9.3",
|
||||
"uuid": "^13.0.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/sodium-native": "^2.3.9",
|
||||
"@types/uuid": "^10.0.0",
|
||||
@@ -823,6 +828,16 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/better-sqlite3": {
|
||||
"version": "7.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
@@ -848,11 +863,28 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "25.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
@@ -1008,6 +1040,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-ssl-profiles": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/b4a": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz",
|
||||
@@ -1147,6 +1188,90 @@
|
||||
"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": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
@@ -1157,6 +1282,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -1164,16 +1295,66 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
||||
@@ -1242,6 +1423,15 @@
|
||||
"bare-events": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
@@ -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": {
|
||||
"version": "2.3.3",
|
||||
"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_modules/generate-function": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-property": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.6",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
||||
@@ -1304,6 +1515,109 @@
|
||||
"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": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@@ -1565,6 +1879,69 @@
|
||||
"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": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -1575,6 +1952,73 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.20.0",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.20.0.tgz",
|
||||
"integrity": "sha512-eCLUs7BNbgA6nf/MZXsaBO1SfGs0LtLVrJD3WeWq+jPLDWkSufTD+aGMwykfUVPdZnblaUK1a8G/P63cl9FkKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"aws-ssl-profiles": "^1.1.2",
|
||||
"denque": "^2.1.0",
|
||||
"generate-function": "^2.3.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"long": "^5.3.2",
|
||||
"lru.min": "^1.1.4",
|
||||
"named-placeholders": "^1.1.6",
|
||||
"sql-escaper": "^1.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/named-placeholders": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
|
||||
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lru.min": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@@ -1594,6 +2038,24 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.89.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
|
||||
"integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
@@ -1605,6 +2067,15 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
@@ -1661,6 +2132,72 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
"github-from-package": "0.0.0",
|
||||
"minimist": "^1.2.3",
|
||||
"mkdirp-classic": "^0.5.3",
|
||||
"napi-build-utils": "^2.0.0",
|
||||
"node-abi": "^3.3.0",
|
||||
"pump": "^3.0.0",
|
||||
"rc": "^1.2.7",
|
||||
"simple-get": "^4.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tunnel-agent": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prebuild-install": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/require-addon": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.2.0.tgz",
|
||||
@@ -1717,6 +2254,44 @@
|
||||
"@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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
@@ -1724,6 +2299,51 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sodium-native": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-5.1.0.tgz",
|
||||
@@ -1748,6 +2368,21 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sql-escaper": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
|
||||
"integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"bun": ">=1.0.0",
|
||||
"deno": ">=2.0.0",
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
@@ -1773,6 +2408,52 @@
|
||||
"text-decoder": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
"fs-constants": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/teex": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
|
||||
@@ -1863,6 +2544,18 @@
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -1880,7 +2573,12 @@
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
@@ -2080,6 +2778,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
|
||||
14
package.json
14
package.json
@@ -6,23 +6,35 @@
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build:web": "cd web && npm run build && cd .. && rm -rf public && cp -r web/dist public",
|
||||
"build:all": "npm run build && npm run build:web",
|
||||
"start": "tsx src/cli.ts",
|
||||
"dev": "tsx --watch src/cli.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"keywords": ["cookies", "sync", "encryption", "browser-extension"],
|
||||
"keywords": [
|
||||
"cookies",
|
||||
"sync",
|
||||
"encryption",
|
||||
"browser-extension"
|
||||
],
|
||||
"author": "Rc707Agency",
|
||||
"license": "MIT",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mysql2": "^3.20.0",
|
||||
"sodium-native": "^5.1.0",
|
||||
"typescript": "^5.9.3",
|
||||
"uuid": "^13.0.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/sodium-native": "^2.3.9",
|
||||
"@types/uuid": "^10.0.0",
|
||||
|
||||
60
src/cli.ts
60
src/cli.ts
@@ -1,21 +1,59 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { RelayServer } from "./relay/index.js";
|
||||
import { loadDbConfig, createStores } from "./relay/db/index.js";
|
||||
|
||||
const port = parseInt(process.env.PORT ?? "8080", 10);
|
||||
const host = process.env.HOST ?? "0.0.0.0";
|
||||
|
||||
const server = new RelayServer({ port, host });
|
||||
async function main() {
|
||||
// Load saved database config (if any)
|
||||
const dbConfig = loadDbConfig();
|
||||
let stores;
|
||||
if (dbConfig) {
|
||||
console.log(`Loading ${dbConfig.type} database...`);
|
||||
stores = await createStores(dbConfig);
|
||||
} else {
|
||||
console.log("No database configured — starting with in-memory storage.");
|
||||
console.log("Configure a database during setup to enable persistent storage.");
|
||||
}
|
||||
|
||||
server.start().then(() => {
|
||||
// Resolve frontend public directory: check ./public (production) then ../web/dist (development)
|
||||
let publicDir: string | undefined;
|
||||
const candidates = [
|
||||
path.resolve("public"),
|
||||
path.resolve(__dirname, "..", "web", "dist"),
|
||||
];
|
||||
for (const dir of candidates) {
|
||||
if (fs.existsSync(path.join(dir, "index.html"))) {
|
||||
publicDir = dir;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const server = new RelayServer({ port, host, stores, publicDir });
|
||||
|
||||
await server.start();
|
||||
console.log(`CookieBridge relay server listening on ${host}:${port}`);
|
||||
});
|
||||
if (publicDir) {
|
||||
console.log(`Serving admin UI from ${publicDir}`);
|
||||
} else {
|
||||
console.log("No frontend build found — admin UI not available. Run: npm run build:web");
|
||||
}
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
console.log("\nShutting down...");
|
||||
await server.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
process.on("SIGINT", async () => {
|
||||
console.log("\nShutting down...");
|
||||
await server.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
await server.stop();
|
||||
process.exit(0);
|
||||
process.on("SIGTERM", async () => {
|
||||
await server.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Failed to start server:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
102
src/relay/admin/auth.ts
Normal file
102
src/relay/admin/auth.ts
Normal 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
355
src/relay/admin/routes.ts
Normal 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));
|
||||
}
|
||||
@@ -61,6 +61,15 @@ export class ConnectionManager {
|
||||
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 connectedCount(): number {
|
||||
return this.connections.size;
|
||||
|
||||
58
src/relay/db/index.ts
Normal file
58
src/relay/db/index.ts
Normal 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
126
src/relay/db/memory.ts
Normal 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
530
src/relay/db/mysql.ts
Normal 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
431
src/relay/db/sqlite.ts
Normal 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
72
src/relay/db/types.ts
Normal 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>;
|
||||
}
|
||||
@@ -3,3 +3,5 @@ export type { RelayServerConfig } from "./server.js";
|
||||
export { ConnectionManager } from "./connections.js";
|
||||
export { CookieBlobStore } from "./store.js";
|
||||
export { DeviceRegistry, AgentRegistry } from "./tokens.js";
|
||||
export type { DataStores, DbConfig, DbType, ICookieStore, IDeviceStore, IAgentStore, IAdminStore } from "./db/index.js";
|
||||
export { createStores, createMemoryStores, loadDbConfig, saveDbConfig } from "./db/index.js";
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { ConnectionManager } from "./connections.js";
|
||||
import { generateChallenge, verifyAuthResponse } from "./auth.js";
|
||||
import { verify, buildSignablePayload } from "../crypto/signing.js";
|
||||
import { PairingStore } from "../pairing/pairing.js";
|
||||
import { CookieBlobStore } from "./store.js";
|
||||
import { DeviceRegistry, AgentRegistry } from "./tokens.js";
|
||||
import {
|
||||
type Envelope,
|
||||
type EncryptedCookieBlob,
|
||||
MESSAGE_TYPES,
|
||||
PING_INTERVAL_MS,
|
||||
} from "../protocol/spec.js";
|
||||
import { handleAdminRoute } from "./admin/routes.js";
|
||||
import { serveStatic } from "./static.js";
|
||||
import type { DataStores, ICookieStore, IDeviceStore, IAgentStore, IAdminStore } from "./db/types.js";
|
||||
import { createMemoryStores } from "./db/memory.js";
|
||||
|
||||
export interface RelayServerConfig {
|
||||
port: number;
|
||||
host?: string;
|
||||
stores?: DataStores;
|
||||
/** Directory containing pre-built frontend assets. Serves static files + SPA fallback. */
|
||||
publicDir?: string;
|
||||
}
|
||||
|
||||
interface PendingAuth {
|
||||
@@ -47,9 +53,11 @@ export class RelayServer {
|
||||
private wss: WebSocketServer;
|
||||
readonly connections: ConnectionManager;
|
||||
readonly pairingStore: PairingStore;
|
||||
readonly cookieStore: CookieBlobStore;
|
||||
readonly deviceRegistry: DeviceRegistry;
|
||||
readonly agentRegistry: AgentRegistry;
|
||||
cookieStore: ICookieStore;
|
||||
deviceRegistry: IDeviceStore;
|
||||
agentRegistry: IAgentStore;
|
||||
adminStore: IAdminStore;
|
||||
private stores: DataStores;
|
||||
private pendingAuths = new Map<WebSocket, PendingAuth>();
|
||||
private authenticatedDevices = new Map<WebSocket, string>();
|
||||
private pingIntervals = new Map<WebSocket, ReturnType<typeof setInterval>>();
|
||||
@@ -57,15 +65,27 @@ export class RelayServer {
|
||||
constructor(private config: RelayServerConfig) {
|
||||
this.connections = new ConnectionManager();
|
||||
this.pairingStore = new PairingStore();
|
||||
this.cookieStore = new CookieBlobStore();
|
||||
this.deviceRegistry = new DeviceRegistry();
|
||||
this.agentRegistry = new AgentRegistry();
|
||||
|
||||
this.stores = config.stores ?? createMemoryStores();
|
||||
this.cookieStore = this.stores.cookieStore;
|
||||
this.deviceRegistry = this.stores.deviceStore;
|
||||
this.agentRegistry = this.stores.agentStore;
|
||||
this.adminStore = this.stores.adminStore;
|
||||
|
||||
this.httpServer = http.createServer(this.handleHttp.bind(this));
|
||||
this.wss = new WebSocketServer({ server: this.httpServer });
|
||||
this.wss.on("connection", this.handleConnection.bind(this));
|
||||
}
|
||||
|
||||
/** Replace the data stores at runtime (used during setup when DB is first configured). */
|
||||
replaceStores(stores: DataStores): void {
|
||||
this.stores = stores;
|
||||
this.cookieStore = stores.cookieStore;
|
||||
this.deviceRegistry = stores.deviceStore;
|
||||
this.agentRegistry = stores.agentStore;
|
||||
this.adminStore = stores.adminStore;
|
||||
}
|
||||
|
||||
start(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
this.httpServer.listen(
|
||||
@@ -76,15 +96,16 @@ export class RelayServer {
|
||||
});
|
||||
}
|
||||
|
||||
stop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
for (const interval of this.pingIntervals.values()) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
async stop(): Promise<void> {
|
||||
for (const interval of this.pingIntervals.values()) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
this.wss.close(() => {
|
||||
this.httpServer.close(() => resolve());
|
||||
});
|
||||
});
|
||||
await this.stores.close();
|
||||
}
|
||||
|
||||
get port(): number {
|
||||
@@ -99,6 +120,18 @@ export class RelayServer {
|
||||
const url = req.url ?? "";
|
||||
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
|
||||
if (method === "GET" && url === "/health") {
|
||||
this.json(res, 200, { status: "ok", connections: this.connections.connectedCount });
|
||||
@@ -153,6 +186,11 @@ export class RelayServer {
|
||||
return;
|
||||
}
|
||||
|
||||
// Serve frontend static files (if publicDir configured)
|
||||
if (this.config.publicDir && method === "GET") {
|
||||
if (serveStatic(req, res, this.config.publicDir)) return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
}
|
||||
@@ -169,12 +207,13 @@ export class RelayServer {
|
||||
this.json(res, 401, { error: "Missing Authorization header" });
|
||||
return;
|
||||
}
|
||||
const device = this.deviceRegistry.getByToken(token);
|
||||
if (!device) {
|
||||
this.json(res, 401, { error: "Invalid token" });
|
||||
return;
|
||||
}
|
||||
handler({ deviceId: device.deviceId });
|
||||
this.deviceRegistry.getByToken(token).then((device) => {
|
||||
if (!device) {
|
||||
this.json(res, 401, { error: "Invalid token" });
|
||||
return;
|
||||
}
|
||||
handler({ deviceId: device.deviceId });
|
||||
});
|
||||
}
|
||||
|
||||
private extractBearerToken(req: http.IncomingMessage): string | null {
|
||||
@@ -186,14 +225,14 @@ export class RelayServer {
|
||||
// --- Device Registration ---
|
||||
|
||||
private handleDeviceRegister(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
this.readBody(req, (body) => {
|
||||
this.readBody(req, async (body) => {
|
||||
try {
|
||||
const { deviceId, name, platform, encPub } = JSON.parse(body);
|
||||
if (!deviceId || !name || !platform || !encPub) {
|
||||
this.json(res, 400, { error: "Missing required fields: deviceId, name, platform, encPub" });
|
||||
return;
|
||||
}
|
||||
const info = this.deviceRegistry.register(deviceId, name, platform, encPub);
|
||||
const info = await this.deviceRegistry.register(deviceId, name, platform, encPub);
|
||||
this.json(res, 201, {
|
||||
deviceId: info.deviceId,
|
||||
token: info.token,
|
||||
@@ -224,7 +263,7 @@ export class RelayServer {
|
||||
}
|
||||
|
||||
private handlePairAccept(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
this.readBody(req, (body) => {
|
||||
this.readBody(req, async (body) => {
|
||||
try {
|
||||
const { deviceId, x25519PubKey, pairingCode } = JSON.parse(body);
|
||||
if (!deviceId || !x25519PubKey || !pairingCode) {
|
||||
@@ -238,7 +277,7 @@ export class RelayServer {
|
||||
}
|
||||
|
||||
// Record the pairing in device registry
|
||||
this.deviceRegistry.addPairing(session.deviceId, deviceId);
|
||||
await this.deviceRegistry.addPairing(session.deviceId, deviceId);
|
||||
|
||||
this.json(res, 200, {
|
||||
initiator: { deviceId: session.deviceId, x25519PubKey: session.x25519PubKey },
|
||||
@@ -257,7 +296,7 @@ export class RelayServer {
|
||||
res: http.ServerResponse,
|
||||
device: { deviceId: string },
|
||||
): void {
|
||||
this.readBody(req, (body) => {
|
||||
this.readBody(req, async (body) => {
|
||||
try {
|
||||
const { cookies } = JSON.parse(body) as {
|
||||
cookies: Array<Omit<EncryptedCookieBlob, "id" | "updatedAt" | "deviceId">>;
|
||||
@@ -267,12 +306,13 @@ export class RelayServer {
|
||||
return;
|
||||
}
|
||||
|
||||
const stored = cookies.map((c) =>
|
||||
this.cookieStore.upsert({ ...c, deviceId: device.deviceId }),
|
||||
);
|
||||
const stored: EncryptedCookieBlob[] = [];
|
||||
for (const c of cookies) {
|
||||
stored.push(await this.cookieStore.upsert({ ...c, deviceId: device.deviceId }));
|
||||
}
|
||||
|
||||
// Notify paired devices via WebSocket if connected
|
||||
const pairedDevices = this.deviceRegistry.getPairedDevices(device.deviceId);
|
||||
const pairedDevices = await this.deviceRegistry.getPairedDevices(device.deviceId);
|
||||
for (const peerId of pairedDevices) {
|
||||
if (this.connections.isOnline(peerId)) {
|
||||
this.connections.send(peerId, {
|
||||
@@ -294,26 +334,26 @@ export class RelayServer {
|
||||
});
|
||||
}
|
||||
|
||||
private handleCookiePull(
|
||||
private async handleCookiePull(
|
||||
_req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
device: { deviceId: string },
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const url = new URL(_req.url ?? "", `http://${_req.headers.host}`);
|
||||
const domain = url.searchParams.get("domain") ?? undefined;
|
||||
|
||||
// Get cookies from all paired devices
|
||||
const group = this.deviceRegistry.getPairingGroup(device.deviceId);
|
||||
const blobs = this.cookieStore.getByDevices(group, domain);
|
||||
const group = await this.deviceRegistry.getPairingGroup(device.deviceId);
|
||||
const blobs = await this.cookieStore.getByDevices(group, domain);
|
||||
|
||||
this.json(res, 200, { cookies: blobs });
|
||||
}
|
||||
|
||||
private handleCookiePoll(
|
||||
private async handleCookiePoll(
|
||||
_req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
device: { deviceId: string },
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const url = new URL(_req.url ?? "", `http://${_req.headers.host}`);
|
||||
const since = url.searchParams.get("since");
|
||||
if (!since) {
|
||||
@@ -321,8 +361,8 @@ export class RelayServer {
|
||||
return;
|
||||
}
|
||||
|
||||
const group = this.deviceRegistry.getPairingGroup(device.deviceId);
|
||||
const blobs = this.cookieStore.getUpdatedSince(group, since);
|
||||
const group = await this.deviceRegistry.getPairingGroup(device.deviceId);
|
||||
const blobs = await this.cookieStore.getUpdatedSince(group, since);
|
||||
|
||||
this.json(res, 200, { cookies: blobs, serverTime: new Date().toISOString() });
|
||||
}
|
||||
@@ -332,14 +372,14 @@ export class RelayServer {
|
||||
res: http.ServerResponse,
|
||||
device: { deviceId: string },
|
||||
): void {
|
||||
this.readBody(req, (body) => {
|
||||
this.readBody(req, async (body) => {
|
||||
try {
|
||||
const { domain, cookieName, path } = JSON.parse(body);
|
||||
if (!domain || !cookieName || !path) {
|
||||
this.json(res, 400, { error: "Missing domain, cookieName, or path" });
|
||||
return;
|
||||
}
|
||||
const deleted = this.cookieStore.delete(device.deviceId, domain, cookieName, path);
|
||||
const deleted = await this.cookieStore.delete(device.deviceId, domain, cookieName, path);
|
||||
this.json(res, 200, { deleted });
|
||||
} catch {
|
||||
this.json(res, 400, { error: "Invalid JSON" });
|
||||
@@ -354,22 +394,22 @@ export class RelayServer {
|
||||
res: http.ServerResponse,
|
||||
_device: { deviceId: string },
|
||||
): void {
|
||||
this.readBody(req, (body) => {
|
||||
this.readBody(req, async (body) => {
|
||||
try {
|
||||
const { name, encPub, allowedDomains } = JSON.parse(body);
|
||||
if (!name || !encPub) {
|
||||
this.json(res, 400, { error: "Missing name or encPub" });
|
||||
return;
|
||||
}
|
||||
const agent = this.agentRegistry.create(name, encPub, allowedDomains ?? []);
|
||||
const agent = await this.agentRegistry.create(name, encPub, allowedDomains ?? []);
|
||||
|
||||
// Automatically grant the creating device's access
|
||||
this.agentRegistry.grantAccess(agent.id, _device.deviceId);
|
||||
await this.agentRegistry.grantAccess(agent.id, _device.deviceId);
|
||||
|
||||
// Also grant access to all paired devices
|
||||
const paired = this.deviceRegistry.getPairedDevices(_device.deviceId);
|
||||
const paired = await this.deviceRegistry.getPairedDevices(_device.deviceId);
|
||||
for (const peerId of paired) {
|
||||
this.agentRegistry.grantAccess(agent.id, peerId);
|
||||
await this.agentRegistry.grantAccess(agent.id, peerId);
|
||||
}
|
||||
|
||||
this.json(res, 201, { id: agent.id, token: agent.token, name: agent.name });
|
||||
@@ -384,14 +424,14 @@ export class RelayServer {
|
||||
res: http.ServerResponse,
|
||||
device: { deviceId: string },
|
||||
): void {
|
||||
this.readBody(req, (body) => {
|
||||
this.readBody(req, async (body) => {
|
||||
try {
|
||||
const { agentId } = JSON.parse(body);
|
||||
if (!agentId) {
|
||||
this.json(res, 400, { error: "Missing agentId" });
|
||||
return;
|
||||
}
|
||||
this.agentRegistry.grantAccess(agentId, device.deviceId);
|
||||
await this.agentRegistry.grantAccess(agentId, device.deviceId);
|
||||
this.json(res, 200, { granted: true });
|
||||
} catch {
|
||||
this.json(res, 400, { error: "Invalid JSON" });
|
||||
@@ -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);
|
||||
if (!token) {
|
||||
this.json(res, 401, { error: "Missing Authorization header" });
|
||||
return;
|
||||
}
|
||||
const agent = this.agentRegistry.getByToken(token);
|
||||
const agent = await this.agentRegistry.getByToken(token);
|
||||
if (!agent) {
|
||||
this.json(res, 401, { error: "Invalid agent token" });
|
||||
return;
|
||||
@@ -420,8 +460,8 @@ export class RelayServer {
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceIds = this.agentRegistry.getAccessibleDevices(agent.id);
|
||||
const blobs = this.cookieStore.getByDevices(deviceIds, domain);
|
||||
const deviceIds = await this.agentRegistry.getAccessibleDevices(agent.id);
|
||||
const blobs = await this.cookieStore.getByDevices(deviceIds, domain);
|
||||
|
||||
this.json(res, 200, { cookies: blobs, agentEncPub: agent.encPub });
|
||||
}
|
||||
@@ -495,23 +535,24 @@ export class RelayServer {
|
||||
return;
|
||||
}
|
||||
|
||||
const device = this.deviceRegistry.getByToken(token);
|
||||
if (!device) {
|
||||
ws.close(4003, "Invalid token");
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingAuths.delete(ws);
|
||||
this.authenticatedDevices.set(ws, device.deviceId);
|
||||
this.connections.register(device.deviceId, ws);
|
||||
ws.send(JSON.stringify({ type: "auth_ok", deviceId: device.deviceId }));
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(JSON.stringify({ type: MESSAGE_TYPES.PING }));
|
||||
this.deviceRegistry.getByToken(token).then((device) => {
|
||||
if (!device) {
|
||||
ws.close(4003, "Invalid token");
|
||||
return;
|
||||
}
|
||||
}, PING_INTERVAL_MS);
|
||||
this.pingIntervals.set(ws, interval);
|
||||
|
||||
this.pendingAuths.delete(ws);
|
||||
this.authenticatedDevices.set(ws, device.deviceId);
|
||||
this.connections.register(device.deviceId, ws);
|
||||
ws.send(JSON.stringify({ type: "auth_ok", deviceId: device.deviceId }));
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(JSON.stringify({ type: MESSAGE_TYPES.PING }));
|
||||
}
|
||||
}, PING_INTERVAL_MS);
|
||||
this.pingIntervals.set(ws, interval);
|
||||
});
|
||||
}
|
||||
|
||||
private handleAuthResponse(ws: WebSocket, msg: Record<string, unknown>): void {
|
||||
|
||||
78
src/relay/static.ts
Normal file
78
src/relay/static.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,38 @@ export class CookieBlobStore {
|
||||
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). */
|
||||
getUpdatedSince(deviceIds: string[], since: string): EncryptedCookieBlob[] {
|
||||
const result: EncryptedCookieBlob[] = [];
|
||||
|
||||
@@ -72,6 +72,28 @@ export class DeviceRegistry {
|
||||
const paired = this.getPairedDevices(deviceId);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,5 +4,6 @@ export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
testTimeout: 15_000,
|
||||
exclude: ["node_modules", "dist", "web", "extension"],
|
||||
},
|
||||
});
|
||||
|
||||
11
web/env.d.ts
vendored
Normal file
11
web/env.d.ts
vendored
Normal 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
12
web/index.html
Normal 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
2694
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
web/package.json
Normal file
33
web/package.json
Normal 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
60
web/playwright.config.ts
Normal 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
3
web/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
30
web/src/api/client.ts
Normal file
30
web/src/api/client.ts
Normal 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;
|
||||
55
web/src/components/layout/AppLayout.vue
Normal file
55
web/src/components/layout/AppLayout.vue
Normal 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
10
web/src/main.ts
Normal 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
94
web/src/router/index.ts
Normal 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
22
web/src/stores/auth.ts
Normal 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
60
web/src/stores/cookies.ts
Normal 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
30
web/src/stores/devices.ts
Normal 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 };
|
||||
});
|
||||
43
web/src/stores/settings.ts
Normal file
43
web/src/stores/settings.ts
Normal 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
1
web/src/style.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
62
web/src/types/api.ts
Normal file
62
web/src/types/api.ts
Normal 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;
|
||||
}
|
||||
363
web/src/views/CookiesView.vue
Normal file
363
web/src/views/CookiesView.vue
Normal 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' : ''"
|
||||
>▶</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>
|
||||
238
web/src/views/DashboardView.vue
Normal file
238
web/src/views/DashboardView.vue
Normal 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">
|
||||
🍪
|
||||
</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">
|
||||
📱
|
||||
</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">
|
||||
⚙️
|
||||
</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>
|
||||
220
web/src/views/DevicesView.vue
Normal file
220
web/src/views/DevicesView.vue
Normal 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>
|
||||
75
web/src/views/LoginView.vue
Normal file
75
web/src/views/LoginView.vue
Normal 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>
|
||||
316
web/src/views/SettingsView.vue
Normal file
316
web/src/views/SettingsView.vue
Normal 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
444
web/src/views/SetupView.vue
Normal 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">✓</span>
|
||||
End-to-end encrypted — the server never sees your data
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="text-green-500">✓</span>
|
||||
Multi-browser support (Chrome, Firefox, Edge, Safari)
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="text-green-500">✓</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">✓</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>
|
||||
273
web/tests/api/admin-api.spec.ts
Normal file
273
web/tests/api/admin-api.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
239
web/tests/e2e/01-login.spec.ts
Normal file
239
web/tests/e2e/01-login.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
116
web/tests/e2e/02-dashboard.spec.ts
Normal file
116
web/tests/e2e/02-dashboard.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
186
web/tests/e2e/03-cookies.spec.ts
Normal file
186
web/tests/e2e/03-cookies.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
171
web/tests/e2e/04-devices.spec.ts
Normal file
171
web/tests/e2e/04-devices.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
210
web/tests/e2e/05-settings.spec.ts
Normal file
210
web/tests/e2e/05-settings.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
63
web/tests/e2e/06-responsive.spec.ts
Normal file
63
web/tests/e2e/06-responsive.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
46
web/tests/e2e/helpers/auth.ts
Normal file
46
web/tests/e2e/helpers/auth.ts
Normal 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/);
|
||||
}
|
||||
164
web/tests/e2e/helpers/mock-api.ts
Normal file
164
web/tests/e2e/helpers/mock-api.ts
Normal 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
24
web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
34
web/vite.config.ts
Normal file
34
web/vite.config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8100",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/admin": {
|
||||
target: "http://localhost:8100",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/ws": {
|
||||
target: "ws://localhost:8100",
|
||||
ws: true,
|
||||
},
|
||||
"/health": {
|
||||
target: "http://localhost:8100",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user