/** * @file server/hub/index.js * @description The hub half of the remote-clients architecture * (docs/plans/remote-clients/README.md), loaded ONLY in server mode: * `server/index.js` requires this folder inside its `isServerMode()` branch, * and nothing outside `server/hub/` may require it at load time (the boundary * test in server/__tests__/hub-boundary.test.js). A local install never loads * a line of it. * * - `mountEarly(app)` — before the body parsers and the cookie gate: the * gateway (`/api/remote/gw`, a raw stream) and the device-token routes * (`/api/remote/device/*`, their own JSON parser with room for an upload). * - `mountRoutes(app)` — with the other routers, behind authGuard/scopeGuard: * the signed-in views (devices, remote sessions, "who opened my sessions"). * - `startTimers()` — the retention sweep. * @author Nguyễn Ngọc Trí Vĩ */ const express = require("express"); const devices = require("./devices"); const sessions = require("./remote-sessions"); const ingest = require("./remote-ingest"); const { remoteGateway } = require("./remote-gateway"); /** An upload chunk is at most 4 MB of text; JSON escaping can grow it. */ const UPLOAD_LIMIT = "12mb"; const send = (res, e) => { const status = e.status || (e.code === "EBADLOGIN" ? 401 : e.code === "ETOOMANY" ? 429 : 500); if (status === 500) console.error("[remote]", e); res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message, ...(e.expected ? { expected: e.expected } : {}), }, }); }; /** Device-token middleware: sets `req.device` / `req.deviceUser`, else 401. */ function requireDevice(req, res, next) { const who = devices.deviceFromRequest(req); if (!who) { return res .status(401) .json({ error: { code: "EDEVICE", message: "device token missing, expired or revoked" } }); } req.device = { id: who.device.id, userId: who.user.id, name: who.device.name }; req.deviceInfo = who; next(); } function deviceRouter() { const r = express.Router(); r.use(express.json({ limit: UPLOAD_LIMIT })); // Enroll this machine: the user's own hub password, throttled like a login. r.post("/login", async (req, res) => { try { const { username, password, deviceName } = req.body || {}; const u = await require("../lib/auth").checkLogin(req, username, password); if (u.must_change_password) { return res.status(403).json({ error: { code: "EPWCHANGE", message: "choose your own password on the hub first, then enroll this machine", }, }); } const { token, device } = devices.createDevice(u.id, deviceName); require("../lib/audit").recordAccountChange( u.id, u.id, "POST", `remote device enrolled: #${device.id} ${device.name}` ); res.json({ token, device, user: { id: u.id, username: u.username, role: u.role }, retainDays: ingest.RETAIN_DAYS, }); } catch (e) { send(res, e); } }); r.use(requireDevice); r.get("/me", (req, res) => { res.json({ ...req.deviceInfo, retainDays: ingest.RETAIN_DAYS }); }); // This machine logs itself out: its token stops working at once. r.post("/logout", (req, res) => { devices.revokeDevice(req.device.id); res.json({ ok: true }); }); r.post("/sessions", (req, res) => { try { const row = sessions.registerSession(req.device, req.body || {}); res.json({ ok: true, sessionId: row.session_id, account: row.account }); } catch (e) { send(res, e); } }); r.post("/sessions/:id/end", (req, res) => { res.json({ ok: sessions.markEnded(req.params.id, req.device.id) }); }); r.get("/sessions/:id/files", (req, res) => { try { res.json({ files: ingest.fileState(req.device, req.params.id) }); } catch (e) { send(res, e); } }); r.post("/upload", async (req, res) => { try { res.json(await ingest.takeUpload(req.device, req.body || {})); } catch (e) { send(res, e); } }); return r; } const isStaff = (u) => u && (u.role === "admin" || u.role === "analyst"); /** Whose rows a signed-in request asks for: their own, or (staff) `?user=`, or all. */ function targetUser(req) { if (!isStaff(req.user)) return req.user.id; if (req.query.user == null || req.query.user === "") return null; const n = Number(req.query.user); return Number.isInteger(n) && n > 0 ? n : -1; } function signedInRouter() { const r = express.Router(); r.use((req, res, next) => req.user ? next() : res.status(401).json({ error: { code: "EAUTH", message: "log in first" } }) ); r.get("/devices", (req, res) => { res.json({ devices: devices.listDevices(targetUser(req)) }); }); // Revoke a device: its owner or an admin, with their own password again. r.post("/devices/:id/revoke", async (req, res) => { try { const d = devices.getDevice(req.params.id); const mine = d && d.userId === req.user.id; if (!d || (!mine && req.user.role !== "admin")) { return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such device" } }); } await require("../lib/auth").confirmPassword(req.user, req.body?.password); devices.revokeDevice(d.id); require("../lib/audit").recordAccountChange( req.user.id, d.userId, "POST", `remote device revoked: #${d.id} ${d.name}` ); res.json({ ok: true, device: devices.getDevice(d.id) }); } catch (e) { send(res, e.code === "EREAUTH" ? Object.assign(e, { status: 403 }) : e); } }); r.get("/sessions", (req, res) => { res.json({ sessions: sessions.listSessions(targetUser(req)) }); }); // Who opened MY sessions (admin/analyst reads, audit.js) — for every user. r.get("/audit", (req, res) => { const rows = require("../db") .db.prepare( `SELECT a.id, a.at, a.method, a.path, a.session_id AS sessionId, r.username AS reader, r.display_name AS readerDisplay FROM audit_log a LEFT JOIN users r ON r.id = a.user_id WHERE a.owner_id = ? AND a.user_id != ? AND a.session_id IS NOT NULL ORDER BY a.id DESC LIMIT 100` ) .all(req.user.id, req.user.id); res.json({ entries: rows }); }); return r; } /** Before the body parsers and the cookie gate. */ function mountEarly(app) { app.use("/api/remote/gw", remoteGateway); app.use("/api/remote/device", deviceRouter()); } /** With the other routers, behind authGuard/scopeGuard/auditGuard. */ function mountRoutes(app) { app.use("/api/remote", signedInRouter()); } let timer = null; function startTimers() { if (timer) return; const every = Number(process.env.AGENTHUB_REMOTE_PURGE_MS) || 60 * 60 * 1000; const run = () => { try { const n = ingest.purgeOld(); if (n) console.log(`[remote] removed ${n} conversation(s) past ${ingest.RETAIN_DAYS} days`); } catch (e) { console.warn(`[remote] purge failed: ${e.message}`); } }; setTimeout(run, 30_000).unref(); timer = setInterval(run, every); timer.unref(); } module.exports = { mountEarly, mountRoutes, startTimers };