Compare commits
2 Commits
1420c4ecfa
...
fb5c841282
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb5c841282 | ||
|
|
1093d64724 |
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:
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,2 +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
|
||||
|
||||
89
README.md
89
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 │
|
||||
│ │
|
||||
├── Ed25519 signing ├── Admin UI (Vue 3 SPA)
|
||||
├── X25519 key exchange ├── SQLite / MySQL / In-memory
|
||||
└── XChaCha20-Poly1305 encryption └── Setup wizard
|
||||
│
|
||||
AI Agent ──Bearer token──────────────────────┘
|
||||
AI Agent ──Bearer token──────────────────┘
|
||||
```
|
||||
|
||||
The relay server is a plain Node.js HTTP + WebSocket server with no framework dependencies. See [docs/architecture.md](docs/architecture.md) for the full design and [docs/security.md](docs/security.md) for the threat model.
|
||||
The relay server is a plain Node.js HTTP + WebSocket server with no framework dependencies. In production, the server embeds the pre-built admin UI and serves it as static files. See [docs/architecture.md](docs/architecture.md) for the full design and [docs/security.md](docs/security.md) for the threat model.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
@@ -104,6 +127,46 @@ The relay server is a plain Node.js HTTP + WebSocket server with no framework de
|
||||
| `GET` | `/health` | Health check |
|
||||
| `WebSocket` | `/ws` | Real-time sync channel |
|
||||
|
||||
### Admin Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `POST` | `/admin/setup/init` | Run setup wizard (set password, choose DB) |
|
||||
| `GET` | `/admin/setup/status` | Check if setup has been completed |
|
||||
| `POST` | `/admin/login` | Login to admin panel |
|
||||
| `GET` | `/admin/devices` | List registered devices |
|
||||
| `GET` | `/admin/connections` | List active WebSocket connections |
|
||||
| `GET` | `/admin/agents` | List registered agents |
|
||||
| `GET` | `/admin/stats` | Server statistics |
|
||||
|
||||
## Deployment
|
||||
|
||||
### Build Pipeline
|
||||
|
||||
The Dockerfile uses a multi-stage build:
|
||||
|
||||
1. **web-builder** — Installs frontend dependencies and runs `vite build`
|
||||
2. **builder** — Compiles the TypeScript backend
|
||||
3. **production** — Copies compiled backend + built frontend into a minimal image
|
||||
|
||||
The frontend is served from the `/public` directory inside the container. No separate web server (nginx, etc.) is needed.
|
||||
|
||||
### Database Persistence
|
||||
|
||||
By default, CookieBridge starts with in-memory storage. On first access, the setup wizard lets you choose:
|
||||
|
||||
- **In-memory** — No persistence, data resets on restart
|
||||
- **SQLite** — File-based, mount a volume for persistence
|
||||
- **MySQL** — Remote database, provide connection details
|
||||
|
||||
For SQLite persistence with Docker:
|
||||
|
||||
```bash
|
||||
docker run -d -p 8080:8080 -v cookiebridge-data:/app/data cookiebridge
|
||||
```
|
||||
|
||||
Database configuration is stored in `data/db-config.json`.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
@@ -111,6 +174,7 @@ npm install
|
||||
npm run dev # Start with file watching
|
||||
npm test # Run test suite
|
||||
npm run typecheck # Type checking only
|
||||
npm run build:all # Build backend + frontend
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
@@ -119,18 +183,21 @@ npm run typecheck # Type checking only
|
||||
src/
|
||||
cli.ts # Server entry point
|
||||
relay/
|
||||
server.ts # HTTP + WebSocket server
|
||||
server.ts # HTTP + WebSocket server + static file serving
|
||||
static.ts # Static file serving with SPA fallback
|
||||
connections.ts # WebSocket connection manager
|
||||
auth.ts # Token & challenge-response auth
|
||||
store.ts # In-memory encrypted cookie storage
|
||||
tokens.ts # Device & agent registries
|
||||
admin/ # Admin panel API routes
|
||||
db/ # Database abstraction (memory, SQLite, MySQL)
|
||||
crypto/ # XChaCha20-Poly1305, Ed25519
|
||||
pairing/ # Device pairing flow
|
||||
sync/ # Sync engine, conflict resolution
|
||||
protocol/
|
||||
spec.ts # Protocol types & constants
|
||||
web/ # Admin panel (Vue 3 + Vite)
|
||||
extension/ # Multi-browser extension source
|
||||
tests/ # Vitest test suite
|
||||
docs/ # Architecture and security docs
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
552
package-lock.json
generated
552
package-lock.json
generated
@@ -9,13 +9,16 @@
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mysql2": "^3.20.0",
|
||||
"sodium-native": "^5.1.0",
|
||||
"typescript": "^5.9.3",
|
||||
"uuid": "^13.0.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/sodium-native": "^2.3.9",
|
||||
@@ -825,6 +828,16 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/better-sqlite3": {
|
||||
"version": "7.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
@@ -872,7 +885,6 @@
|
||||
"version": "25.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
@@ -1028,6 +1040,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-ssl-profiles": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/b4a": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz",
|
||||
@@ -1167,6 +1188,84 @@
|
||||
"bare": ">=1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "12.8.0",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz",
|
||||
"integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
@@ -1183,6 +1282,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -1190,11 +1295,43 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -1209,6 +1346,15 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
||||
@@ -1277,6 +1423,15 @@
|
||||
"bare-events": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
@@ -1311,6 +1466,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1326,6 +1493,15 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/generate-function": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-property": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.6",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
||||
@@ -1339,6 +1515,66 @@
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-property": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
@@ -1685,6 +1921,27 @@
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lru.min": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
|
||||
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"bun": ">=1.0.0",
|
||||
"deno": ">=1.30.0",
|
||||
"node": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wellwelwel"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -1695,12 +1952,73 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.20.0",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.20.0.tgz",
|
||||
"integrity": "sha512-eCLUs7BNbgA6nf/MZXsaBO1SfGs0LtLVrJD3WeWq+jPLDWkSufTD+aGMwykfUVPdZnblaUK1a8G/P63cl9FkKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"aws-ssl-profiles": "^1.1.2",
|
||||
"denque": "^2.1.0",
|
||||
"generate-function": "^2.3.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"long": "^5.3.2",
|
||||
"lru.min": "^1.1.4",
|
||||
"named-placeholders": "^1.1.6",
|
||||
"sql-escaper": "^1.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/named-placeholders": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
|
||||
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lru.min": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@@ -1720,6 +2038,24 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.89.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
|
||||
"integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
@@ -1731,6 +2067,15 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
@@ -1787,6 +2132,72 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
"github-from-package": "0.0.0",
|
||||
"minimist": "^1.2.3",
|
||||
"mkdirp-classic": "^0.5.3",
|
||||
"napi-build-utils": "^2.0.0",
|
||||
"node-abi": "^3.3.0",
|
||||
"pump": "^3.0.0",
|
||||
"rc": "^1.2.7",
|
||||
"simple-get": "^4.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tunnel-agent": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prebuild-install": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/require-addon": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.2.0.tgz",
|
||||
@@ -1863,6 +2274,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
@@ -1882,6 +2299,51 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sodium-native": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-5.1.0.tgz",
|
||||
@@ -1906,6 +2368,21 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sql-escaper": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
|
||||
"integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"bun": ">=1.0.0",
|
||||
"deno": ">=2.0.0",
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
@@ -1931,6 +2408,52 @@
|
||||
"text-decoder": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
"fs-constants": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/teex": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
|
||||
@@ -2021,6 +2544,18 @@
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -2038,7 +2573,12 @@
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
@@ -2238,6 +2778,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build:web": "cd web && npm run build && cd .. && rm -rf public && cp -r web/dist public",
|
||||
"build:all": "npm run build && npm run build:web",
|
||||
"start": "tsx src/cli.ts",
|
||||
"dev": "tsx --watch src/cli.ts",
|
||||
"test": "vitest run",
|
||||
@@ -22,13 +24,16 @@
|
||||
"license": "MIT",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mysql2": "^3.20.0",
|
||||
"sodium-native": "^5.1.0",
|
||||
"typescript": "^5.9.3",
|
||||
"uuid": "^13.0.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/sodium-native": "^2.3.9",
|
||||
|
||||
44
src/cli.ts
44
src/cli.ts
@@ -1,13 +1,45 @@
|
||||
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...");
|
||||
@@ -19,3 +51,9 @@ process.on("SIGTERM", async () => {
|
||||
await server.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Failed to start server:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import http from "node:http";
|
||||
import type { AdminStore } from "./auth.js";
|
||||
import type { ConnectionManager } from "../connections.js";
|
||||
import type { CookieBlobStore } from "../store.js";
|
||||
import type { DeviceRegistry } from "../tokens.js";
|
||||
import type { ICookieStore, IDeviceStore, IAdminStore, DbConfig } from "../db/types.js";
|
||||
import { saveDbConfig, loadDbConfig, createStores } from "../db/index.js";
|
||||
import type { RelayServer } from "../server.js";
|
||||
|
||||
export interface AdminDeps {
|
||||
adminStore: AdminStore;
|
||||
adminStore: IAdminStore;
|
||||
connections: ConnectionManager;
|
||||
cookieStore: CookieBlobStore;
|
||||
deviceRegistry: DeviceRegistry;
|
||||
cookieStore: ICookieStore;
|
||||
deviceRegistry: IDeviceStore;
|
||||
server: RelayServer;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,7 +28,12 @@ export function handleAdminRoute(
|
||||
// --- Public routes (no auth) ---
|
||||
|
||||
if (method === "GET" && url === "/admin/setup/status") {
|
||||
json(res, 200, { isSetUp: deps.adminStore.isSetUp });
|
||||
const dbConfig = loadDbConfig();
|
||||
json(res, 200, {
|
||||
isSetUp: deps.adminStore.isSetUp,
|
||||
dbConfigured: dbConfig !== null,
|
||||
dbType: dbConfig?.type ?? null,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -107,7 +113,7 @@ export function handleAdminRoute(
|
||||
|
||||
function authenticate(
|
||||
req: http.IncomingMessage,
|
||||
store: AdminStore,
|
||||
store: IAdminStore,
|
||||
): { sub: string; role: string } | null {
|
||||
const auth = req.headers.authorization;
|
||||
if (!auth?.startsWith("Bearer ")) return null;
|
||||
@@ -127,11 +133,32 @@ function handleSetupInit(
|
||||
): void {
|
||||
readBody(req, async (body) => {
|
||||
try {
|
||||
const { username, password } = JSON.parse(body);
|
||||
const { username, password, dbConfig } = JSON.parse(body) as {
|
||||
username: string;
|
||||
password: string;
|
||||
dbConfig?: DbConfig;
|
||||
};
|
||||
if (!username || !password) {
|
||||
json(res, 400, { error: "Missing username or password" });
|
||||
return;
|
||||
}
|
||||
|
||||
// If a database config is provided, initialize the database first
|
||||
if (dbConfig) {
|
||||
try {
|
||||
const stores = await createStores(dbConfig);
|
||||
saveDbConfig(dbConfig);
|
||||
deps.server.replaceStores(stores);
|
||||
// Update deps references to point to new stores
|
||||
deps.adminStore = stores.adminStore;
|
||||
deps.cookieStore = stores.cookieStore;
|
||||
deps.deviceRegistry = stores.deviceStore;
|
||||
} catch (err) {
|
||||
json(res, 500, { error: `Database connection failed: ${err instanceof Error ? err.message : String(err)}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (deps.adminStore.isSetUp) {
|
||||
json(res, 409, { error: "Already configured" });
|
||||
return;
|
||||
@@ -165,13 +192,13 @@ function handleLogin(
|
||||
});
|
||||
}
|
||||
|
||||
function handleDashboard(res: http.ServerResponse, deps: AdminDeps): void {
|
||||
const devices = deps.deviceRegistry.listAll();
|
||||
async function handleDashboard(res: http.ServerResponse, deps: AdminDeps): Promise<void> {
|
||||
const devices = await deps.deviceRegistry.listAll();
|
||||
const onlineDeviceIds = devices
|
||||
.filter((d) => deps.connections.isOnline(d.deviceId))
|
||||
.map((d) => d.deviceId);
|
||||
|
||||
const allCookies = deps.cookieStore.getAll();
|
||||
const allCookies = await deps.cookieStore.getAll();
|
||||
|
||||
const domains = new Set(allCookies.map((c) => c.domain));
|
||||
|
||||
@@ -184,17 +211,17 @@ function handleDashboard(res: http.ServerResponse, deps: AdminDeps): void {
|
||||
});
|
||||
}
|
||||
|
||||
function handleCookieList(
|
||||
async function handleCookieList(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
deps: AdminDeps,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const parsed = new URL(req.url ?? "", `http://${req.headers.host}`);
|
||||
|
||||
// Check if this is a single cookie detail request: /admin/cookies/:id
|
||||
const idMatch = parsed.pathname.match(/^\/admin\/cookies\/([a-f0-9]+)$/);
|
||||
if (idMatch) {
|
||||
const cookie = deps.cookieStore.getById(idMatch[1]);
|
||||
const cookie = await deps.cookieStore.getById(idMatch[1]);
|
||||
if (!cookie) {
|
||||
json(res, 404, { error: "Cookie not found" });
|
||||
return;
|
||||
@@ -208,7 +235,7 @@ function handleCookieList(
|
||||
const page = parseInt(parsed.searchParams.get("page") ?? "1", 10);
|
||||
const limit = Math.min(parseInt(parsed.searchParams.get("limit") ?? "50", 10), 200);
|
||||
|
||||
let cookies = deps.cookieStore.getAll();
|
||||
let cookies = await deps.cookieStore.getAll();
|
||||
|
||||
if (domain) {
|
||||
cookies = cookies.filter((c) => c.domain === domain);
|
||||
@@ -227,18 +254,18 @@ function handleCookieList(
|
||||
json(res, 200, { items, total, page, limit });
|
||||
}
|
||||
|
||||
function handleCookieDeleteById(
|
||||
async function handleCookieDeleteById(
|
||||
_req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
deps: AdminDeps,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const parsed = new URL(_req.url ?? "", `http://${_req.headers.host}`);
|
||||
const idMatch = parsed.pathname.match(/^\/admin\/cookies\/([a-f0-9]+)$/);
|
||||
if (!idMatch) {
|
||||
json(res, 400, { error: "Invalid cookie ID" });
|
||||
return;
|
||||
}
|
||||
const deleted = deps.cookieStore.deleteById(idMatch[1]);
|
||||
const deleted = await deps.cookieStore.deleteById(idMatch[1]);
|
||||
json(res, 200, { deleted });
|
||||
}
|
||||
|
||||
@@ -247,7 +274,7 @@ function handleCookieBatchDelete(
|
||||
res: http.ServerResponse,
|
||||
deps: AdminDeps,
|
||||
): void {
|
||||
readBody(req, (body) => {
|
||||
readBody(req, async (body) => {
|
||||
try {
|
||||
const { ids } = JSON.parse(body) as { ids: string[] };
|
||||
if (!ids || !Array.isArray(ids)) {
|
||||
@@ -256,7 +283,7 @@ function handleCookieBatchDelete(
|
||||
}
|
||||
let count = 0;
|
||||
for (const id of ids) {
|
||||
if (deps.cookieStore.deleteById(id)) count++;
|
||||
if (await deps.cookieStore.deleteById(id)) count++;
|
||||
}
|
||||
json(res, 200, { deleted: count });
|
||||
} catch {
|
||||
@@ -265,8 +292,8 @@ function handleCookieBatchDelete(
|
||||
});
|
||||
}
|
||||
|
||||
function handleDeviceList(res: http.ServerResponse, deps: AdminDeps): void {
|
||||
const devices = deps.deviceRegistry.listAll().map((d) => ({
|
||||
async function handleDeviceList(res: http.ServerResponse, deps: AdminDeps): Promise<void> {
|
||||
const devices = (await deps.deviceRegistry.listAll()).map((d) => ({
|
||||
deviceId: d.deviceId,
|
||||
name: d.name,
|
||||
platform: d.platform,
|
||||
@@ -276,11 +303,11 @@ function handleDeviceList(res: http.ServerResponse, deps: AdminDeps): void {
|
||||
json(res, 200, { devices });
|
||||
}
|
||||
|
||||
function handleDeviceRevoke(
|
||||
async function handleDeviceRevoke(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
deps: AdminDeps,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const parsed = new URL(req.url ?? "", `http://${req.headers.host}`);
|
||||
const match = parsed.pathname.match(/^\/admin\/devices\/([^/]+)\/revoke$/);
|
||||
if (!match) {
|
||||
@@ -288,7 +315,7 @@ function handleDeviceRevoke(
|
||||
return;
|
||||
}
|
||||
const deviceId = match[1];
|
||||
const revoked = deps.deviceRegistry.revoke(deviceId);
|
||||
const revoked = await deps.deviceRegistry.revoke(deviceId);
|
||||
if (revoked) {
|
||||
deps.connections.disconnect(deviceId);
|
||||
}
|
||||
|
||||
58
src/relay/db/index.ts
Normal file
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,23 +1,27 @@
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { ConnectionManager } from "./connections.js";
|
||||
import { generateChallenge, verifyAuthResponse } from "./auth.js";
|
||||
import { verify, buildSignablePayload } from "../crypto/signing.js";
|
||||
import { PairingStore } from "../pairing/pairing.js";
|
||||
import { CookieBlobStore } from "./store.js";
|
||||
import { DeviceRegistry, AgentRegistry } from "./tokens.js";
|
||||
import {
|
||||
type Envelope,
|
||||
type EncryptedCookieBlob,
|
||||
MESSAGE_TYPES,
|
||||
PING_INTERVAL_MS,
|
||||
} from "../protocol/spec.js";
|
||||
import { AdminStore } from "./admin/auth.js";
|
||||
import { handleAdminRoute } from "./admin/routes.js";
|
||||
import { serveStatic } from "./static.js";
|
||||
import type { DataStores, ICookieStore, IDeviceStore, IAgentStore, IAdminStore } from "./db/types.js";
|
||||
import { createMemoryStores } from "./db/memory.js";
|
||||
|
||||
export interface RelayServerConfig {
|
||||
port: number;
|
||||
host?: string;
|
||||
stores?: DataStores;
|
||||
/** Directory containing pre-built frontend assets. Serves static files + SPA fallback. */
|
||||
publicDir?: string;
|
||||
}
|
||||
|
||||
interface PendingAuth {
|
||||
@@ -49,10 +53,11 @@ export class RelayServer {
|
||||
private wss: WebSocketServer;
|
||||
readonly connections: ConnectionManager;
|
||||
readonly pairingStore: PairingStore;
|
||||
readonly cookieStore: CookieBlobStore;
|
||||
readonly deviceRegistry: DeviceRegistry;
|
||||
readonly agentRegistry: AgentRegistry;
|
||||
readonly adminStore: AdminStore;
|
||||
cookieStore: ICookieStore;
|
||||
deviceRegistry: IDeviceStore;
|
||||
agentRegistry: IAgentStore;
|
||||
adminStore: IAdminStore;
|
||||
private stores: DataStores;
|
||||
private pendingAuths = new Map<WebSocket, PendingAuth>();
|
||||
private authenticatedDevices = new Map<WebSocket, string>();
|
||||
private pingIntervals = new Map<WebSocket, ReturnType<typeof setInterval>>();
|
||||
@@ -60,16 +65,27 @@ export class RelayServer {
|
||||
constructor(private config: RelayServerConfig) {
|
||||
this.connections = new ConnectionManager();
|
||||
this.pairingStore = new PairingStore();
|
||||
this.cookieStore = new CookieBlobStore();
|
||||
this.deviceRegistry = new DeviceRegistry();
|
||||
this.agentRegistry = new AgentRegistry();
|
||||
this.adminStore = new AdminStore();
|
||||
|
||||
this.stores = config.stores ?? createMemoryStores();
|
||||
this.cookieStore = this.stores.cookieStore;
|
||||
this.deviceRegistry = this.stores.deviceStore;
|
||||
this.agentRegistry = this.stores.agentStore;
|
||||
this.adminStore = this.stores.adminStore;
|
||||
|
||||
this.httpServer = http.createServer(this.handleHttp.bind(this));
|
||||
this.wss = new WebSocketServer({ server: this.httpServer });
|
||||
this.wss.on("connection", this.handleConnection.bind(this));
|
||||
}
|
||||
|
||||
/** Replace the data stores at runtime (used during setup when DB is first configured). */
|
||||
replaceStores(stores: DataStores): void {
|
||||
this.stores = stores;
|
||||
this.cookieStore = stores.cookieStore;
|
||||
this.deviceRegistry = stores.deviceStore;
|
||||
this.agentRegistry = stores.agentStore;
|
||||
this.adminStore = stores.adminStore;
|
||||
}
|
||||
|
||||
start(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
this.httpServer.listen(
|
||||
@@ -80,15 +96,16 @@ export class RelayServer {
|
||||
});
|
||||
}
|
||||
|
||||
stop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
async stop(): Promise<void> {
|
||||
for (const interval of this.pingIntervals.values()) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
this.wss.close(() => {
|
||||
this.httpServer.close(() => resolve());
|
||||
});
|
||||
});
|
||||
await this.stores.close();
|
||||
}
|
||||
|
||||
get port(): number {
|
||||
@@ -110,6 +127,7 @@ export class RelayServer {
|
||||
connections: this.connections,
|
||||
cookieStore: this.cookieStore,
|
||||
deviceRegistry: this.deviceRegistry,
|
||||
server: this,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -168,6 +186,11 @@ export class RelayServer {
|
||||
return;
|
||||
}
|
||||
|
||||
// Serve frontend static files (if publicDir configured)
|
||||
if (this.config.publicDir && method === "GET") {
|
||||
if (serveStatic(req, res, this.config.publicDir)) return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
}
|
||||
@@ -184,12 +207,13 @@ export class RelayServer {
|
||||
this.json(res, 401, { error: "Missing Authorization header" });
|
||||
return;
|
||||
}
|
||||
const device = this.deviceRegistry.getByToken(token);
|
||||
this.deviceRegistry.getByToken(token).then((device) => {
|
||||
if (!device) {
|
||||
this.json(res, 401, { error: "Invalid token" });
|
||||
return;
|
||||
}
|
||||
handler({ deviceId: device.deviceId });
|
||||
});
|
||||
}
|
||||
|
||||
private extractBearerToken(req: http.IncomingMessage): string | null {
|
||||
@@ -201,14 +225,14 @@ export class RelayServer {
|
||||
// --- Device Registration ---
|
||||
|
||||
private handleDeviceRegister(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
this.readBody(req, (body) => {
|
||||
this.readBody(req, async (body) => {
|
||||
try {
|
||||
const { deviceId, name, platform, encPub } = JSON.parse(body);
|
||||
if (!deviceId || !name || !platform || !encPub) {
|
||||
this.json(res, 400, { error: "Missing required fields: deviceId, name, platform, encPub" });
|
||||
return;
|
||||
}
|
||||
const info = this.deviceRegistry.register(deviceId, name, platform, encPub);
|
||||
const info = await this.deviceRegistry.register(deviceId, name, platform, encPub);
|
||||
this.json(res, 201, {
|
||||
deviceId: info.deviceId,
|
||||
token: info.token,
|
||||
@@ -239,7 +263,7 @@ export class RelayServer {
|
||||
}
|
||||
|
||||
private handlePairAccept(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
this.readBody(req, (body) => {
|
||||
this.readBody(req, async (body) => {
|
||||
try {
|
||||
const { deviceId, x25519PubKey, pairingCode } = JSON.parse(body);
|
||||
if (!deviceId || !x25519PubKey || !pairingCode) {
|
||||
@@ -253,7 +277,7 @@ export class RelayServer {
|
||||
}
|
||||
|
||||
// Record the pairing in device registry
|
||||
this.deviceRegistry.addPairing(session.deviceId, deviceId);
|
||||
await this.deviceRegistry.addPairing(session.deviceId, deviceId);
|
||||
|
||||
this.json(res, 200, {
|
||||
initiator: { deviceId: session.deviceId, x25519PubKey: session.x25519PubKey },
|
||||
@@ -272,7 +296,7 @@ export class RelayServer {
|
||||
res: http.ServerResponse,
|
||||
device: { deviceId: string },
|
||||
): void {
|
||||
this.readBody(req, (body) => {
|
||||
this.readBody(req, async (body) => {
|
||||
try {
|
||||
const { cookies } = JSON.parse(body) as {
|
||||
cookies: Array<Omit<EncryptedCookieBlob, "id" | "updatedAt" | "deviceId">>;
|
||||
@@ -282,12 +306,13 @@ export class RelayServer {
|
||||
return;
|
||||
}
|
||||
|
||||
const stored = cookies.map((c) =>
|
||||
this.cookieStore.upsert({ ...c, deviceId: device.deviceId }),
|
||||
);
|
||||
const stored: EncryptedCookieBlob[] = [];
|
||||
for (const c of cookies) {
|
||||
stored.push(await this.cookieStore.upsert({ ...c, deviceId: device.deviceId }));
|
||||
}
|
||||
|
||||
// Notify paired devices via WebSocket if connected
|
||||
const pairedDevices = this.deviceRegistry.getPairedDevices(device.deviceId);
|
||||
const pairedDevices = await this.deviceRegistry.getPairedDevices(device.deviceId);
|
||||
for (const peerId of pairedDevices) {
|
||||
if (this.connections.isOnline(peerId)) {
|
||||
this.connections.send(peerId, {
|
||||
@@ -309,26 +334,26 @@ export class RelayServer {
|
||||
});
|
||||
}
|
||||
|
||||
private handleCookiePull(
|
||||
private async handleCookiePull(
|
||||
_req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
device: { deviceId: string },
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const url = new URL(_req.url ?? "", `http://${_req.headers.host}`);
|
||||
const domain = url.searchParams.get("domain") ?? undefined;
|
||||
|
||||
// Get cookies from all paired devices
|
||||
const group = this.deviceRegistry.getPairingGroup(device.deviceId);
|
||||
const blobs = this.cookieStore.getByDevices(group, domain);
|
||||
const group = await this.deviceRegistry.getPairingGroup(device.deviceId);
|
||||
const blobs = await this.cookieStore.getByDevices(group, domain);
|
||||
|
||||
this.json(res, 200, { cookies: blobs });
|
||||
}
|
||||
|
||||
private handleCookiePoll(
|
||||
private async handleCookiePoll(
|
||||
_req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
device: { deviceId: string },
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const url = new URL(_req.url ?? "", `http://${_req.headers.host}`);
|
||||
const since = url.searchParams.get("since");
|
||||
if (!since) {
|
||||
@@ -336,8 +361,8 @@ export class RelayServer {
|
||||
return;
|
||||
}
|
||||
|
||||
const group = this.deviceRegistry.getPairingGroup(device.deviceId);
|
||||
const blobs = this.cookieStore.getUpdatedSince(group, since);
|
||||
const group = await this.deviceRegistry.getPairingGroup(device.deviceId);
|
||||
const blobs = await this.cookieStore.getUpdatedSince(group, since);
|
||||
|
||||
this.json(res, 200, { cookies: blobs, serverTime: new Date().toISOString() });
|
||||
}
|
||||
@@ -347,14 +372,14 @@ export class RelayServer {
|
||||
res: http.ServerResponse,
|
||||
device: { deviceId: string },
|
||||
): void {
|
||||
this.readBody(req, (body) => {
|
||||
this.readBody(req, async (body) => {
|
||||
try {
|
||||
const { domain, cookieName, path } = JSON.parse(body);
|
||||
if (!domain || !cookieName || !path) {
|
||||
this.json(res, 400, { error: "Missing domain, cookieName, or path" });
|
||||
return;
|
||||
}
|
||||
const deleted = this.cookieStore.delete(device.deviceId, domain, cookieName, path);
|
||||
const deleted = await this.cookieStore.delete(device.deviceId, domain, cookieName, path);
|
||||
this.json(res, 200, { deleted });
|
||||
} catch {
|
||||
this.json(res, 400, { error: "Invalid JSON" });
|
||||
@@ -369,22 +394,22 @@ export class RelayServer {
|
||||
res: http.ServerResponse,
|
||||
_device: { deviceId: string },
|
||||
): void {
|
||||
this.readBody(req, (body) => {
|
||||
this.readBody(req, async (body) => {
|
||||
try {
|
||||
const { name, encPub, allowedDomains } = JSON.parse(body);
|
||||
if (!name || !encPub) {
|
||||
this.json(res, 400, { error: "Missing name or encPub" });
|
||||
return;
|
||||
}
|
||||
const agent = this.agentRegistry.create(name, encPub, allowedDomains ?? []);
|
||||
const agent = await this.agentRegistry.create(name, encPub, allowedDomains ?? []);
|
||||
|
||||
// Automatically grant the creating device's access
|
||||
this.agentRegistry.grantAccess(agent.id, _device.deviceId);
|
||||
await this.agentRegistry.grantAccess(agent.id, _device.deviceId);
|
||||
|
||||
// Also grant access to all paired devices
|
||||
const paired = this.deviceRegistry.getPairedDevices(_device.deviceId);
|
||||
const paired = await this.deviceRegistry.getPairedDevices(_device.deviceId);
|
||||
for (const peerId of paired) {
|
||||
this.agentRegistry.grantAccess(agent.id, peerId);
|
||||
await this.agentRegistry.grantAccess(agent.id, peerId);
|
||||
}
|
||||
|
||||
this.json(res, 201, { id: agent.id, token: agent.token, name: agent.name });
|
||||
@@ -399,14 +424,14 @@ export class RelayServer {
|
||||
res: http.ServerResponse,
|
||||
device: { deviceId: string },
|
||||
): void {
|
||||
this.readBody(req, (body) => {
|
||||
this.readBody(req, async (body) => {
|
||||
try {
|
||||
const { agentId } = JSON.parse(body);
|
||||
if (!agentId) {
|
||||
this.json(res, 400, { error: "Missing agentId" });
|
||||
return;
|
||||
}
|
||||
this.agentRegistry.grantAccess(agentId, device.deviceId);
|
||||
await this.agentRegistry.grantAccess(agentId, device.deviceId);
|
||||
this.json(res, 200, { granted: true });
|
||||
} catch {
|
||||
this.json(res, 400, { error: "Invalid JSON" });
|
||||
@@ -414,13 +439,13 @@ export class RelayServer {
|
||||
});
|
||||
}
|
||||
|
||||
private handleAgentCookies(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
private async handleAgentCookies(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const token = this.extractBearerToken(req);
|
||||
if (!token) {
|
||||
this.json(res, 401, { error: "Missing Authorization header" });
|
||||
return;
|
||||
}
|
||||
const agent = this.agentRegistry.getByToken(token);
|
||||
const agent = await this.agentRegistry.getByToken(token);
|
||||
if (!agent) {
|
||||
this.json(res, 401, { error: "Invalid agent token" });
|
||||
return;
|
||||
@@ -435,8 +460,8 @@ export class RelayServer {
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceIds = this.agentRegistry.getAccessibleDevices(agent.id);
|
||||
const blobs = this.cookieStore.getByDevices(deviceIds, domain);
|
||||
const deviceIds = await this.agentRegistry.getAccessibleDevices(agent.id);
|
||||
const blobs = await this.cookieStore.getByDevices(deviceIds, domain);
|
||||
|
||||
this.json(res, 200, { cookies: blobs, agentEncPub: agent.encPub });
|
||||
}
|
||||
@@ -510,7 +535,7 @@ export class RelayServer {
|
||||
return;
|
||||
}
|
||||
|
||||
const device = this.deviceRegistry.getByToken(token);
|
||||
this.deviceRegistry.getByToken(token).then((device) => {
|
||||
if (!device) {
|
||||
ws.close(4003, "Invalid token");
|
||||
return;
|
||||
@@ -527,6 +552,7 @@ export class RelayServer {
|
||||
}
|
||||
}, 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;
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,6 @@ export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
testTimeout: 15_000,
|
||||
exclude: ["node_modules", "dist", "web", "extension"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,14 +7,24 @@ import { markSetupComplete } from "@/router";
|
||||
const router = useRouter();
|
||||
|
||||
const step = ref(1);
|
||||
const totalSteps = 4;
|
||||
const totalSteps = 5;
|
||||
|
||||
// Step 2: Admin account
|
||||
// Step 2: Database config
|
||||
type DbType = "sqlite" | "mysql";
|
||||
const dbType = ref<DbType>("sqlite");
|
||||
const sqlitePath = ref("./data/cookiebridge.db");
|
||||
const mysqlHost = ref("localhost");
|
||||
const mysqlPort = ref(3306);
|
||||
const mysqlUser = ref("root");
|
||||
const mysqlPassword = ref("");
|
||||
const mysqlDatabase = ref("cookiebridge");
|
||||
|
||||
// Step 3: Admin account
|
||||
const username = ref("");
|
||||
const password = ref("");
|
||||
const confirmPassword = ref("");
|
||||
|
||||
// Step 3: Basic config
|
||||
// Step 4: Basic config
|
||||
const listenPort = ref(8100);
|
||||
const enableHttps = ref(false);
|
||||
|
||||
@@ -25,16 +35,28 @@ const passwordMismatch = computed(
|
||||
() => confirmPassword.value.length > 0 && password.value !== confirmPassword.value,
|
||||
);
|
||||
|
||||
const canProceedStep2 = computed(
|
||||
const canProceedStep3 = computed(
|
||||
() =>
|
||||
username.value.length >= 3 &&
|
||||
password.value.length >= 8 &&
|
||||
password.value === confirmPassword.value,
|
||||
);
|
||||
|
||||
const canProceedStep2 = computed(() => {
|
||||
if (dbType.value === "sqlite") {
|
||||
return sqlitePath.value.length > 0;
|
||||
}
|
||||
return (
|
||||
mysqlHost.value.length > 0 &&
|
||||
mysqlPort.value > 0 &&
|
||||
mysqlUser.value.length > 0 &&
|
||||
mysqlDatabase.value.length > 0
|
||||
);
|
||||
});
|
||||
|
||||
function nextStep() {
|
||||
error.value = "";
|
||||
if (step.value === 2 && passwordMismatch.value) {
|
||||
if (step.value === 3 && passwordMismatch.value) {
|
||||
error.value = "Passwords do not match";
|
||||
return;
|
||||
}
|
||||
@@ -46,6 +68,20 @@ function prevStep() {
|
||||
step.value = Math.max(step.value - 1, 1);
|
||||
}
|
||||
|
||||
function buildDbConfig() {
|
||||
if (dbType.value === "sqlite") {
|
||||
return { type: "sqlite" as const, path: sqlitePath.value };
|
||||
}
|
||||
return {
|
||||
type: "mysql" as const,
|
||||
host: mysqlHost.value,
|
||||
port: mysqlPort.value,
|
||||
user: mysqlUser.value,
|
||||
password: mysqlPassword.value,
|
||||
database: mysqlDatabase.value,
|
||||
};
|
||||
}
|
||||
|
||||
async function completeSetup() {
|
||||
error.value = "";
|
||||
loading.value = true;
|
||||
@@ -53,11 +89,13 @@ async function completeSetup() {
|
||||
await api.post("/setup/init", {
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
dbConfig: buildDbConfig(),
|
||||
});
|
||||
markSetupComplete();
|
||||
step.value = totalSteps;
|
||||
} catch {
|
||||
error.value = "Setup failed. Please try again.";
|
||||
} catch (e: unknown) {
|
||||
const axiosError = e as { response?: { data?: { error?: string } } };
|
||||
error.value = axiosError.response?.data?.error ?? "Setup failed. Please try again.";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -110,8 +148,143 @@ function goToLogin() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Admin account -->
|
||||
<!-- Step 2: Database selection -->
|
||||
<div v-if="step === 2">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Database Configuration</h2>
|
||||
<p class="mt-1 text-sm text-gray-500">Choose how to store your data</p>
|
||||
|
||||
<div class="mt-5 space-y-4">
|
||||
<!-- Database type selection -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border-2 px-4 py-3 text-left transition-colors"
|
||||
:class="
|
||||
dbType === 'sqlite'
|
||||
? 'border-blue-600 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
"
|
||||
@click="dbType = 'sqlite'"
|
||||
>
|
||||
<p class="text-sm font-medium text-gray-900">SQLite</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500">Simple, no setup required</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border-2 px-4 py-3 text-left transition-colors"
|
||||
:class="
|
||||
dbType === 'mysql'
|
||||
? 'border-blue-600 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
"
|
||||
@click="dbType = 'mysql'"
|
||||
>
|
||||
<p class="text-sm font-medium text-gray-900">MySQL</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500">For production deployments</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- SQLite config -->
|
||||
<div v-if="dbType === 'sqlite'">
|
||||
<label class="block text-sm font-medium text-gray-700" for="sqlite-path">
|
||||
Database File Path
|
||||
</label>
|
||||
<input
|
||||
id="sqlite-path"
|
||||
v-model="sqlitePath"
|
||||
type="text"
|
||||
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="./data/cookiebridge.db"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
File will be created automatically. Relative paths are from the server directory.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- MySQL config -->
|
||||
<div v-if="dbType === 'mysql'" class="space-y-3">
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700" for="mysql-host">Host</label>
|
||||
<input
|
||||
id="mysql-host"
|
||||
v-model="mysqlHost"
|
||||
type="text"
|
||||
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="localhost"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700" for="mysql-port">Port</label>
|
||||
<input
|
||||
id="mysql-port"
|
||||
v-model.number="mysqlPort"
|
||||
type="number"
|
||||
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="3306"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700" for="mysql-user">Username</label>
|
||||
<input
|
||||
id="mysql-user"
|
||||
v-model="mysqlUser"
|
||||
type="text"
|
||||
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="root"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700" for="mysql-password">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="mysql-password"
|
||||
v-model="mysqlPassword"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700" for="mysql-database">
|
||||
Database Name
|
||||
</label>
|
||||
<input
|
||||
id="mysql-database"
|
||||
v-model="mysqlDatabase"
|
||||
type="text"
|
||||
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="cookiebridge"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
@click="prevStep"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
:disabled="!canProceedStep2"
|
||||
class="flex-1 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
@click="nextStep"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Admin account -->
|
||||
<div v-if="step === 3">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Create Admin Account</h2>
|
||||
<p class="mt-1 text-sm text-gray-500">Set up your administrator credentials</p>
|
||||
|
||||
@@ -178,7 +351,7 @@ function goToLogin() {
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!canProceedStep2"
|
||||
:disabled="!canProceedStep3"
|
||||
class="flex-1 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
@@ -187,8 +360,8 @@ function goToLogin() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Basic config -->
|
||||
<div v-if="step === 3">
|
||||
<!-- Step 4: Basic config -->
|
||||
<div v-if="step === 4">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Basic Configuration</h2>
|
||||
<p class="mt-1 text-sm text-gray-500">Configure your relay server</p>
|
||||
|
||||
@@ -241,14 +414,14 @@ function goToLogin() {
|
||||
class="flex-1 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
@click="completeSetup"
|
||||
>
|
||||
{{ loading ? "Setting up..." : "Next" }}
|
||||
{{ loading ? "Setting up..." : "Complete Setup" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Done -->
|
||||
<div v-if="step === 4" class="text-center">
|
||||
<!-- Step 5: Done -->
|
||||
<div v-if="step === 5" class="text-center">
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<span class="text-2xl text-green-600">✓</span>
|
||||
</div>
|
||||
@@ -256,6 +429,9 @@ function goToLogin() {
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
Your CookieBridge server is ready. Sign in with your admin credentials.
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Database: {{ dbType === 'sqlite' ? 'SQLite' : 'MySQL' }}
|
||||
</p>
|
||||
<button
|
||||
class="mt-6 w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||
@click="goToLogin"
|
||||
|
||||
Reference in New Issue
Block a user