/** * @file devices.js * @description The hub's enrolled remote-client machines (server mode only; * docs/plans/remote-clients/README.md). A user enrolls a machine running * AgentHub LOCAL with their hub password (`POST /api/remote/device/login`, * throttled like a login — `auth.checkLogin`) and gets ONE device token, * `ahdev_<32 random bytes>`, shown once; the hub keeps only its SHA-256. * * A token is good for `TOKEN_DAYS` (90) and until it is revoked — by its own * machine (logout), by its user, or by an admin (both behind the step-up * password, written to `audit_log`) — or its user is disabled. It is the only * thing a client holds: it opens the device routes and the gateway, never a * cookie route, and never carries a pool credential. * @author Nguyễn Ngọc Trí Vĩ */ const crypto = require("node:crypto"); const sql = (q) => require("../db").db.prepare(q); const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex"); const TOKEN_DAYS = Number(process.env.AGENTHUB_DEVICE_TOKEN_DAYS) || 90; const TOKEN_RE = /^ahdev_[A-Za-z0-9_-]{43}$/; const MAX_NAME = 80; /** last_seen_at is rewritten at most this often per device (every gateway call reads it). */ const SEEN_EVERY_MS = 60_000; /** A device name as shown: printable, trimmed, capped; "" → "device". */ function cleanName(name) { const s = String(name || "") .replace(/[\u0000-\u001f\u007f]/g, "") .trim() .slice(0, MAX_NAME); return s || "device"; } /** Mint a device for `userId`: `{token, device}` — the token exists only in this answer. */ function createDevice(userId, name) { const token = `ahdev_${crypto.randomBytes(32).toString("base64url")}`; const expires = new Date(Date.now() + TOKEN_DAYS * 864e5).toISOString(); const r = sql( "INSERT INTO devices (user_id, name, token_hash, expires_at) VALUES (?, ?, ?, ?)" ).run(userId, cleanName(name), sha256(token), expires); return { token, device: getDevice(Number(r.lastInsertRowid)) }; } /** A device row as the API shows it — never the hash. */ function shape(row) { if (!row) return null; const now = Date.now(); return { id: row.id, userId: row.user_id, name: row.name, createdAt: row.created_at, expiresAt: row.expires_at, lastSeenAt: row.last_seen_at, lastIp: row.last_ip, revokedAt: row.revoked_at, active: !row.revoked_at && Date.parse(row.expires_at) > now, }; } function getDevice(id) { return shape(sql("SELECT * FROM devices WHERE id = ?").get(Number(id))); } /** Every device of `userId` (null = every user's), newest first. */ function listDevices(userId = null) { const rows = userId == null ? sql("SELECT * FROM devices ORDER BY id DESC").all() : sql("SELECT * FROM devices WHERE user_id = ? ORDER BY id DESC").all(Number(userId)); return rows.map(shape); } /** Revoke one device (idempotent); false when there is no such device. */ function revokeDevice(id) { const r = sql( "UPDATE devices SET revoked_at = COALESCE(revoked_at, strftime('%Y-%m-%dT%H:%M:%fZ','now')) WHERE id = ?" ).run(Number(id)); return r.changes > 0; } /** The bearer token of a request (Authorization: Bearer, else x-api-key). */ function bearerOf(req) { const h = String(req.headers.authorization || ""); const m = /^Bearer\s+(\S+)$/i.exec(h); if (m) return m[1]; const k = req.headers["x-api-key"]; return typeof k === "string" ? k : ""; } /** * `{device, user}` for a request carrying a live device token of an enabled * user, else null. A malformed token is not even hashed against the table. */ function deviceFromRequest(req) { const token = bearerOf(req); if (!TOKEN_RE.test(token)) return null; const row = sql( `SELECT d.*, u.username, u.role, u.disabled FROM devices d JOIN users u ON u.id = d.user_id WHERE d.token_hash = ?` ).get(sha256(token)); if (!row || row.revoked_at || row.disabled) return null; if (!(Date.parse(row.expires_at) > Date.now())) return null; const seen = row.last_seen_at ? Date.parse(row.last_seen_at) : 0; const ip = req.socket?.remoteAddress || null; if (Date.now() - seen > SEEN_EVERY_MS || ip !== row.last_ip) { sql("UPDATE devices SET last_seen_at = ?, last_ip = ? WHERE id = ?").run( new Date().toISOString(), ip, row.id ); } return { device: shape(row), user: { id: row.user_id, username: row.username, role: row.role }, }; } module.exports = { TOKEN_DAYS, TOKEN_RE, cleanName, createDevice, getDevice, listDevices, revokeDevice, deviceFromRequest, bearerOf, __sha256: sha256, };