diff --git a/package-lock.json b/package-lock.json index b68e97e..f296393 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,22 @@ { "name": "cookiebridge", - "version": "1.0.0", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cookiebridge", - "version": "1.0.0", - "license": "ISC", + "version": "0.1.0", + "license": "MIT", "dependencies": { + "jsonwebtoken": "^9.0.3", "sodium-native": "^5.1.0", "typescript": "^5.9.3", "uuid": "^13.0.0", "ws": "^8.19.0" }, "devDependencies": { + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.5.0", "@types/sodium-native": "^2.3.9", "@types/uuid": "^10.0.0", @@ -848,6 +850,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", @@ -1147,6 +1167,12 @@ "bare": ">=1.2.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1174,6 +1200,15 @@ "node": ">=8" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", @@ -1304,6 +1339,49 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1565,6 +1643,48 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1575,6 +1695,12 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "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/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1717,6 +1843,38 @@ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", diff --git a/package.json b/package.json index 6d0d403..dd5e138 100644 --- a/package.json +++ b/package.json @@ -12,17 +12,24 @@ "test:watch": "vitest", "typecheck": "tsc --noEmit" }, - "keywords": ["cookies", "sync", "encryption", "browser-extension"], + "keywords": [ + "cookies", + "sync", + "encryption", + "browser-extension" + ], "author": "Rc707Agency", "license": "MIT", "type": "commonjs", "dependencies": { + "jsonwebtoken": "^9.0.3", "sodium-native": "^5.1.0", "typescript": "^5.9.3", "uuid": "^13.0.0", "ws": "^8.19.0" }, "devDependencies": { + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.5.0", "@types/sodium-native": "^2.3.9", "@types/uuid": "^10.0.0", diff --git a/src/relay/admin/auth.ts b/src/relay/admin/auth.ts new file mode 100644 index 0000000..fa13b47 --- /dev/null +++ b/src/relay/admin/auth.ts @@ -0,0 +1,102 @@ +import crypto from "node:crypto"; +import jwt from "jsonwebtoken"; + +const SCRYPT_KEYLEN = 64; +const SCRYPT_COST = 16384; +const SCRYPT_BLOCK_SIZE = 8; +const SCRYPT_PARALLELISM = 1; + +export interface AdminUser { + username: string; + passwordHash: string; // scrypt hash, hex + salt: string; // hex + createdAt: string; +} + +export interface AdminSettings { + syncIntervalMs: number; + maxDevices: number; + autoSync: boolean; + theme: "light" | "dark" | "system"; +} + +const DEFAULT_SETTINGS: AdminSettings = { + syncIntervalMs: 30_000, + maxDevices: 10, + autoSync: true, + theme: "system", +}; + +/** + * In-memory admin state. Stores admin user, JWT secret, and settings. + * In production this would be persisted to disk/database. + */ +export class AdminStore { + private adminUser: AdminUser | null = null; + private jwtSecret: string; + private settings: AdminSettings = { ...DEFAULT_SETTINGS }; + + constructor() { + this.jwtSecret = crypto.randomBytes(32).toString("hex"); + } + + get isSetUp(): boolean { + return this.adminUser !== null; + } + + /** First-time setup: create the admin account. */ + async setup(username: string, password: string): Promise { + if (this.adminUser) throw new Error("Already configured"); + const salt = crypto.randomBytes(16).toString("hex"); + const hash = await this.hashPassword(password, salt); + this.adminUser = { + username, + passwordHash: hash, + salt, + createdAt: new Date().toISOString(), + }; + } + + /** Authenticate and return a JWT. */ + async login(username: string, password: string): Promise { + if (!this.adminUser) throw new Error("Not configured"); + if (this.adminUser.username !== username) throw new Error("Invalid credentials"); + const hash = await this.hashPassword(password, this.adminUser.salt); + if (hash !== this.adminUser.passwordHash) throw new Error("Invalid credentials"); + return jwt.sign({ sub: username, role: "admin" }, this.jwtSecret, { expiresIn: "24h" }); + } + + /** Verify a JWT and return the payload. */ + verifyToken(token: string): { sub: string; role: string } { + return jwt.verify(token, this.jwtSecret) as { sub: string; role: string }; + } + + getUser(): { username: string; createdAt: string } | null { + if (!this.adminUser) return null; + return { username: this.adminUser.username, createdAt: this.adminUser.createdAt }; + } + + getSettings(): AdminSettings { + return { ...this.settings }; + } + + updateSettings(patch: Partial): AdminSettings { + Object.assign(this.settings, patch); + return { ...this.settings }; + } + + private hashPassword(password: string, salt: string): Promise { + 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")); + }, + ); + }); + } +} diff --git a/src/relay/admin/routes.ts b/src/relay/admin/routes.ts new file mode 100644 index 0000000..c48a8d3 --- /dev/null +++ b/src/relay/admin/routes.ts @@ -0,0 +1,328 @@ +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"; + +export interface AdminDeps { + adminStore: AdminStore; + connections: ConnectionManager; + cookieStore: CookieBlobStore; + deviceRegistry: DeviceRegistry; +} + +/** + * Handle /admin/* routes. Returns true if the route was handled. + */ +export function handleAdminRoute( + req: http.IncomingMessage, + res: http.ServerResponse, + deps: AdminDeps, +): boolean { + const url = req.url ?? ""; + const method = req.method ?? ""; + + if (!url.startsWith("/admin/")) return false; + + // --- Public routes (no auth) --- + + if (method === "GET" && url === "/admin/setup/status") { + json(res, 200, { isSetUp: deps.adminStore.isSetUp }); + return true; + } + + if (method === "POST" && url === "/admin/setup/init") { + handleSetupInit(req, res, deps); + return true; + } + + if (method === "POST" && url === "/admin/auth/login") { + handleLogin(req, res, deps); + return true; + } + + // --- Protected routes --- + + const user = authenticate(req, deps.adminStore); + if (!user) { + json(res, 401, { error: "Unauthorized" }); + return true; + } + + if (method === "POST" && url === "/admin/auth/logout") { + json(res, 200, { ok: true }); + return true; + } + + if (method === "GET" && url === "/admin/auth/me") { + const info = deps.adminStore.getUser(); + json(res, 200, info); + return true; + } + + if (method === "GET" && url === "/admin/dashboard") { + handleDashboard(res, deps); + return true; + } + + // Cookie management + if (method === "GET" && url.startsWith("/admin/cookies")) { + handleCookieList(req, res, deps); + return true; + } + if (method === "DELETE" && url.startsWith("/admin/cookies/")) { + handleCookieDeleteById(req, res, deps); + return true; + } + if (method === "DELETE" && url === "/admin/cookies") { + handleCookieBatchDelete(req, res, deps); + return true; + } + + // Device management + if (method === "GET" && url === "/admin/devices") { + handleDeviceList(res, deps); + return true; + } + if (method === "POST" && url.match(/^\/admin\/devices\/[^/]+\/revoke$/)) { + handleDeviceRevoke(req, res, deps); + return true; + } + + // Settings + if (method === "GET" && url === "/admin/settings") { + json(res, 200, deps.adminStore.getSettings()); + return true; + } + if (method === "PATCH" && url === "/admin/settings") { + handleSettingsUpdate(req, res, deps); + return true; + } + + json(res, 404, { error: "Admin route not found" }); + return true; +} + +// --- Auth helpers --- + +function authenticate( + req: http.IncomingMessage, + store: AdminStore, +): { sub: string; role: string } | null { + const auth = req.headers.authorization; + if (!auth?.startsWith("Bearer ")) return null; + try { + return store.verifyToken(auth.slice(7)); + } catch { + return null; + } +} + +// --- Route handlers --- + +function handleSetupInit( + req: http.IncomingMessage, + res: http.ServerResponse, + deps: AdminDeps, +): void { + readBody(req, async (body) => { + try { + const { username, password } = JSON.parse(body); + if (!username || !password) { + json(res, 400, { error: "Missing username or password" }); + return; + } + if (deps.adminStore.isSetUp) { + json(res, 409, { error: "Already configured" }); + return; + } + await deps.adminStore.setup(username, password); + const token = await deps.adminStore.login(username, password); + json(res, 201, { token, username }); + } catch { + json(res, 400, { error: "Invalid request" }); + } + }); +} + +function handleLogin( + req: http.IncomingMessage, + res: http.ServerResponse, + deps: AdminDeps, +): void { + readBody(req, async (body) => { + try { + const { username, password } = JSON.parse(body); + if (!username || !password) { + json(res, 400, { error: "Missing username or password" }); + return; + } + const token = await deps.adminStore.login(username, password); + json(res, 200, { token }); + } catch { + json(res, 401, { error: "Invalid credentials" }); + } + }); +} + +function handleDashboard(res: http.ServerResponse, deps: AdminDeps): void { + const devices = deps.deviceRegistry.listAll(); + const onlineDeviceIds = devices + .filter((d) => deps.connections.isOnline(d.deviceId)) + .map((d) => d.deviceId); + + const allCookies = deps.cookieStore.getAll(); + + const domains = new Set(allCookies.map((c) => c.domain)); + + json(res, 200, { + connections: deps.connections.connectedCount, + totalDevices: devices.length, + onlineDevices: onlineDeviceIds.length, + totalCookies: allCookies.length, + uniqueDomains: domains.size, + }); +} + +function handleCookieList( + req: http.IncomingMessage, + res: http.ServerResponse, + deps: AdminDeps, +): 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]); + if (!cookie) { + json(res, 404, { error: "Cookie not found" }); + return; + } + json(res, 200, cookie); + return; + } + + const domain = parsed.searchParams.get("domain") ?? undefined; + const search = parsed.searchParams.get("q") ?? undefined; + const page = parseInt(parsed.searchParams.get("page") ?? "1", 10); + const limit = Math.min(parseInt(parsed.searchParams.get("limit") ?? "50", 10), 200); + + let cookies = deps.cookieStore.getAll(); + + if (domain) { + cookies = cookies.filter((c) => c.domain === domain); + } + if (search) { + const q = search.toLowerCase(); + cookies = cookies.filter( + (c) => c.domain.toLowerCase().includes(q) || c.cookieName.toLowerCase().includes(q), + ); + } + + const total = cookies.length; + const offset = (page - 1) * limit; + const items = cookies.slice(offset, offset + limit); + + json(res, 200, { items, total, page, limit }); +} + +function handleCookieDeleteById( + _req: http.IncomingMessage, + res: http.ServerResponse, + deps: AdminDeps, +): 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]); + json(res, 200, { deleted }); +} + +function handleCookieBatchDelete( + req: http.IncomingMessage, + res: http.ServerResponse, + deps: AdminDeps, +): void { + readBody(req, (body) => { + try { + const { ids } = JSON.parse(body) as { ids: string[] }; + if (!ids || !Array.isArray(ids)) { + json(res, 400, { error: "Missing ids array" }); + return; + } + let count = 0; + for (const id of ids) { + if (deps.cookieStore.deleteById(id)) count++; + } + json(res, 200, { deleted: count }); + } catch { + json(res, 400, { error: "Invalid JSON" }); + } + }); +} + +function handleDeviceList(res: http.ServerResponse, deps: AdminDeps): void { + const devices = deps.deviceRegistry.listAll().map((d) => ({ + deviceId: d.deviceId, + name: d.name, + platform: d.platform, + createdAt: d.createdAt, + online: deps.connections.isOnline(d.deviceId), + })); + json(res, 200, { devices }); +} + +function handleDeviceRevoke( + req: http.IncomingMessage, + res: http.ServerResponse, + deps: AdminDeps, +): void { + const parsed = new URL(req.url ?? "", `http://${req.headers.host}`); + const match = parsed.pathname.match(/^\/admin\/devices\/([^/]+)\/revoke$/); + if (!match) { + json(res, 400, { error: "Invalid device ID" }); + return; + } + const deviceId = match[1]; + const revoked = deps.deviceRegistry.revoke(deviceId); + if (revoked) { + deps.connections.disconnect(deviceId); + } + json(res, 200, { revoked }); +} + +function handleSettingsUpdate( + req: http.IncomingMessage, + res: http.ServerResponse, + deps: AdminDeps, +): void { + readBody(req, (body) => { + try { + const patch = JSON.parse(body); + const updated = deps.adminStore.updateSettings(patch); + json(res, 200, updated); + } catch { + json(res, 400, { error: "Invalid JSON" }); + } + }); +} + +// --- Helpers --- + +function json(res: http.ServerResponse, status: number, data: unknown): void { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data)); +} + +function readBody(req: http.IncomingMessage, cb: (body: string) => void): void { + let data = ""; + req.on("data", (chunk: Buffer) => { + data += chunk.toString(); + if (data.length > 64 * 1024) req.destroy(); + }); + req.on("end", () => cb(data)); +} diff --git a/src/relay/connections.ts b/src/relay/connections.ts index ba9b06b..7ce6167 100644 --- a/src/relay/connections.ts +++ b/src/relay/connections.ts @@ -61,6 +61,15 @@ export class ConnectionManager { return conn !== undefined && conn.ws.readyState === 1; } + /** Forcibly disconnect a device. */ + disconnect(deviceId: string): void { + const conn = this.connections.get(deviceId); + if (conn) { + conn.ws.close(4004, "Revoked"); + this.connections.delete(deviceId); + } + } + /** Get count of connected devices. */ get connectedCount(): number { return this.connections.size; diff --git a/src/relay/server.ts b/src/relay/server.ts index 853ffe5..405519b 100644 --- a/src/relay/server.ts +++ b/src/relay/server.ts @@ -12,6 +12,8 @@ import { MESSAGE_TYPES, PING_INTERVAL_MS, } from "../protocol/spec.js"; +import { AdminStore } from "./admin/auth.js"; +import { handleAdminRoute } from "./admin/routes.js"; export interface RelayServerConfig { port: number; @@ -50,6 +52,7 @@ export class RelayServer { readonly cookieStore: CookieBlobStore; readonly deviceRegistry: DeviceRegistry; readonly agentRegistry: AgentRegistry; + readonly adminStore: AdminStore; private pendingAuths = new Map(); private authenticatedDevices = new Map(); private pingIntervals = new Map>(); @@ -60,6 +63,7 @@ export class RelayServer { this.cookieStore = new CookieBlobStore(); this.deviceRegistry = new DeviceRegistry(); this.agentRegistry = new AgentRegistry(); + this.adminStore = new AdminStore(); this.httpServer = http.createServer(this.handleHttp.bind(this)); this.wss = new WebSocketServer({ server: this.httpServer }); @@ -99,6 +103,17 @@ export class RelayServer { const url = req.url ?? ""; const method = req.method ?? ""; + // Admin routes + if (url.startsWith("/admin/")) { + handleAdminRoute(req, res, { + adminStore: this.adminStore, + connections: this.connections, + cookieStore: this.cookieStore, + deviceRegistry: this.deviceRegistry, + }); + return; + } + // Health if (method === "GET" && url === "/health") { this.json(res, 200, { status: "ok", connections: this.connections.connectedCount }); diff --git a/src/relay/store.ts b/src/relay/store.ts index 3db6992..fb9ad23 100644 --- a/src/relay/store.ts +++ b/src/relay/store.ts @@ -80,6 +80,38 @@ export class CookieBlobStore { return result; } + /** Get all stored blobs across all devices. */ + getAll(): EncryptedCookieBlob[] { + const result: EncryptedCookieBlob[] = []; + for (const deviceMap of this.store.values()) { + result.push(...deviceMap.values()); + } + return result; + } + + /** Get a single blob by its ID. */ + getById(id: string): EncryptedCookieBlob | null { + for (const deviceMap of this.store.values()) { + for (const blob of deviceMap.values()) { + if (blob.id === id) return blob; + } + } + return null; + } + + /** Delete a blob by its ID. Returns true if found and deleted. */ + deleteById(id: string): boolean { + for (const deviceMap of this.store.values()) { + for (const [key, blob] of deviceMap) { + if (blob.id === id) { + deviceMap.delete(key); + return true; + } + } + } + return false; + } + /** Get all blobs updated after a given timestamp (for polling). */ getUpdatedSince(deviceIds: string[], since: string): EncryptedCookieBlob[] { const result: EncryptedCookieBlob[] = []; diff --git a/src/relay/tokens.ts b/src/relay/tokens.ts index 6e44bbb..56fe243 100644 --- a/src/relay/tokens.ts +++ b/src/relay/tokens.ts @@ -72,6 +72,28 @@ export class DeviceRegistry { const paired = this.getPairedDevices(deviceId); return [deviceId, ...paired]; } + + /** List all registered devices. */ + listAll(): DeviceInfo[] { + return Array.from(this.devices.values()); + } + + /** Revoke a device: remove its token and registration. Returns true if it existed. */ + revoke(deviceId: string): boolean { + const device = this.devices.get(deviceId); + if (!device) return false; + this.tokenToDevice.delete(device.token); + this.devices.delete(deviceId); + // Clean up pairings + const paired = this.pairings.get(deviceId); + if (paired) { + for (const peerId of paired) { + this.pairings.get(peerId)?.delete(deviceId); + } + this.pairings.delete(deviceId); + } + return true; + } } /** diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 5544c8f..0523f44 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,7 +1,7 @@ import axios from "axios"; const api = axios.create({ - baseURL: "/api", + baseURL: "/admin", headers: { "Content-Type": "application/json" }, }); diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index 44d5197..6e498d3 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -8,7 +8,7 @@ export const useAuthStore = defineStore("auth", () => { const isAuthenticated = computed(() => !!token.value); async function login(username: string, password: string): Promise { - const { data } = await api.post("/auth/login", { username, password }); + const { data } = await api.post("/auth/login", { username, password }, { baseURL: "/admin" }); token.value = data.token; localStorage.setItem("cb_admin_token", data.token); } diff --git a/web/src/stores/cookies.ts b/web/src/stores/cookies.ts index aadd358..38fefb8 100644 --- a/web/src/stores/cookies.ts +++ b/web/src/stores/cookies.ts @@ -27,9 +27,10 @@ export const useCookiesStore = defineStore("cookies", () => { loading.value = true; error.value = null; try { - const params = domain ? { domain } : {}; + const params: Record = { limit: "200" }; + if (domain) params.domain = domain; const { data } = await api.get("/cookies", { params }); - cookies.value = data.cookies; + cookies.value = data.items ?? data.cookies ?? []; } catch (e: unknown) { error.value = e instanceof Error ? e.message : "Failed to fetch cookies"; } finally { @@ -42,7 +43,13 @@ export const useCookiesStore = defineStore("cookies", () => { cookieName: string, path: string, ): Promise { - await api.delete("/cookies", { data: { domain, cookieName, path } }); + // Find the cookie ID first, then delete by ID + const cookie = cookies.value.find( + (c) => c.domain === domain && c.cookieName === cookieName && c.path === path, + ); + if (cookie) { + await api.delete(`/cookies/${cookie.id}`); + } cookies.value = cookies.value.filter( (c) => !(c.domain === domain && c.cookieName === cookieName && c.path === path), diff --git a/web/src/stores/devices.ts b/web/src/stores/devices.ts index 7e491fe..76ae153 100644 --- a/web/src/stores/devices.ts +++ b/web/src/stores/devices.ts @@ -22,7 +22,7 @@ export const useDevicesStore = defineStore("devices", () => { } async function revokeDevice(deviceId: string): Promise { - await api.delete(`/devices/${deviceId}`); + await api.post(`/devices/${deviceId}/revoke`); devices.value = devices.value.filter((d) => d.deviceId !== deviceId); } diff --git a/web/src/views/DashboardView.vue b/web/src/views/DashboardView.vue index 456f018..7d552d6 100644 --- a/web/src/views/DashboardView.vue +++ b/web/src/views/DashboardView.vue @@ -1,15 +1,22 @@