/** * @file me.js * @description `/api/me` — the signed-in account's own UI preferences * (`user_prefs`) and its first-run setup (docs/plans/onboarding), so they * follow it to any browser, differ between the accounts of one dashboard, and * the setup wizard shows once per account. Local mode is one account, * `local`; server mode keys a row `u` by the login cookie — the request * never names an account. Only the keys in `PREF_KEYS` are kept, each a * string of at most 8 KB, and a PUT merges into the row (`null` removes a * key). Writes are same-origin and touch only the caller's own row. * `/instructions` is the account's own agent instructions (issue #67, at most * 16 000 characters, blank clears), put before the workspace's in every pane * of a workspace the account owns (`launchExtras` in lib/lane-sources.js); * `tools` names the AI tools whose panes receive them. * `agenthub.chatUi` ("1") opens Claude/Codex sessions as a chat (`PaneChat`); * the appearance keys are `useTheme`'s own (`theme`, `agenthub.theme.dark|light`). * * `/account` (server mode) is the signed-in account itself for Settings → * Account: GET its profile, workspace/session counts and logins; PATCH its * display name and email; `POST /password` changes its own password (the old * one required, throttled like a step-up; every OTHER login ends); * `POST /sign-out-others` ends every login but this one. These writes take the * account from the login COOKIE only — a pane token (an agent acting as its * owner) may not change the owner's login. Each change is audited. * `/avatar` (server mode) is the account's own picture (server/lib/avatars.js): * GET serves it (`nosniff`, `sandbox` CSP), PUT takes the raw image bytes * (PNG/JPEG/GIF/WebP, at most 1 MB) and DELETE removes it — both writes are * account writes like the profile's. The sidebar shows it beside the name. * * S3 (one Linux user per account): an account's dark/light choice also becomes * the theme in their own Claude Code settings, written as them. * @author Nguyễn Ngọc Trí Vĩ */ const express = require("express"); const { Router } = express; const { db } = require("../db"); const auth = require("../lib/auth"); const { sameOriginGuard } = require("../lib/same-origin"); const router = Router(); /** The localStorage keys an account's preferences are made of. */ const PREF_KEYS = [ "theme", "agenthub.theme.dark", "agenthub.theme.light", "i18nextLng", "agenthub.terminal.font", "agenthub.terminal.fontSize", "agenthub.ui.fontSize", "agenthub.pet", "agenthub.pet.size", "agenthub.pet.names", "agent-dashboard-sagi-enabled", "agent-dashboard-sagi-muted", "agent-monitor-notifications", "agenthub.defaultTool", "agenthub.permissionMode", "agenthub.tour", "agenthub.chatUi", "agenthub.workspace.allTabs", ]; const MAX_VALUE = 8 * 1024; /** `u` for a server-mode account, `local` in local mode, null when nobody is signed in. */ function userKey(req) { if (!auth.isServerMode()) return "local"; const user = req.user || auth.userFromRequest(req); return user ? `u${user.id}` : null; } const getRow = db.prepare("SELECT prefs, onboarded_at FROM user_prefs WHERE user_key = ?"); const upsert = db.prepare( `INSERT INTO user_prefs (user_key, prefs) VALUES (?, ?) ON CONFLICT(user_key) DO UPDATE SET prefs = excluded.prefs, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')` ); const markOnboarded = db.prepare( `INSERT INTO user_prefs (user_key, onboarded_at) VALUES (?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ON CONFLICT(user_key) DO UPDATE SET onboarded_at = COALESCE(onboarded_at, excluded.onboarded_at)` ); function read(key) { const row = getRow.get(key); let prefs = {}; try { prefs = row ? JSON.parse(row.prefs) : {}; } catch { /* a damaged row reads as empty */ } return { prefs, onboardedAt: row?.onboarded_at || null }; } const unauth = (res) => res.status(401).json({ error: { code: "EAUTH", message: "sign in first" } }); router.get("/prefs", (req, res) => { const key = userKey(req); if (!key) return unauth(res); res.json(read(key)); }); router.put("/prefs", sameOriginGuard, (req, res) => { const key = userKey(req); if (!key) return unauth(res); const patch = req.body?.prefs; if (!patch || typeof patch !== "object" || Array.isArray(patch)) { return res.status(400).json({ error: { code: "EBADPREFS", message: "prefs: an object" } }); } const { prefs } = read(key); for (const [k, v] of Object.entries(patch)) { if (!PREF_KEYS.includes(k)) { return res.status(400).json({ error: { code: "EBADPREFS", message: `unknown key ${k}` } }); } if (v === null) delete prefs[k]; else if (typeof v === "string" && v.length <= MAX_VALUE) prefs[k] = v; else return res .status(400) .json({ error: { code: "EBADPREFS", message: `${k}: a short string` } }); } upsert.run(key, JSON.stringify(prefs)); if ("theme" in patch) followClaudeTheme(req, patch.theme); res.json(read(key)); }); const sources = require("../lib/lane-sources"); const instructionsView = (key) => ({ instructions: sources.getUserInstructions(key) || "", max: sources.MAX_INSTRUCTIONS, // The tools a pane of which actually receives them (see launchExtras). tools: [...sources.SUPPORTED_TOOLS], }); router.get("/instructions", (req, res) => { const key = userKey(req); if (!key) return unauth(res); res.json(instructionsView(key)); }); router.put("/instructions", sameOriginGuard, (req, res) => { const key = userKey(req); if (!key) return unauth(res); try { sources.setUserInstructions(key, req.body?.instructions ?? null); } catch (err) { const status = err.code === "ETOOBIG" ? 413 : 400; // as PUT /api/lanes/:id/instructions return res.status(status).json({ error: { code: err.code, message: err.message } }); } res.json(instructionsView(key)); }); router.post("/onboarded", sameOriginGuard, (req, res) => { const key = userKey(req); if (!key) return unauth(res); markOnboarded.run(key); res.json(read(key)); }); // ── The account itself (server mode) ───────────────────────────────────────── const { sessionWhere } = require("../lib/scope"); const { recordAccountChange } = require("../lib/audit"); const ACCOUNT_STATUS = { EBADFIELD: 400, EBADEMAIL: 400, EBADPASSWORD: 400, EBADIMAGE: 400, ETOOBIG: 413, EREAUTH: 403, ETOOMANY: 429, }; function accountView(userId) { const row = db.prepare("SELECT * FROM users WHERE id = ?").get(userId); const w = sessionWhere({ user: { id: userId, role: "developer" } }); return { mode: "server", user: auth.publicUser(row), workspaces: db.prepare("SELECT COUNT(*) AS n FROM lanes WHERE owner_id = ?").get(userId).n, sessions: db.prepare(`SELECT COUNT(*) AS n FROM sessions WHERE ${w.where}`).get(...w.params).n, ...auth.loginStats(userId), }; } /** A write to the account: the cookie's own user, never a pane token's. */ function accountWrite(handler) { return async (req, res) => { if (!auth.isServerMode()) { return res .status(403) .json({ error: { code: "ELOCALMODE", message: "no account in local mode" } }); } const user = auth.userFromRequest(req); if (!user) return unauth(res); try { const body = req.body && typeof req.body === "object" ? req.body : {}; const { out, action } = await handler(req, user, body); recordAccountChange(user.id, user.id, req.method, `${user.username}: ${action}`); res.json(out); } catch (err) { const status = ACCOUNT_STATUS[err.code]; if (!status) return res.status(500).json({ error: { message: err.message } }); res.status(status).json({ error: { code: err.code, message: err.message } }); } }; } router.get("/account", (req, res) => { if (!auth.isServerMode()) return res.json({ mode: "local" }); const user = req.user || auth.userFromRequest(req); if (!user) return unauth(res); res.json(accountView(user.id)); }); router.patch( "/account", sameOriginGuard, accountWrite(async (_req, user, body) => { const fields = auth.validateProfile({ displayName: body.displayName, email: body.email }); auth.setProfile(user.id, fields); return { out: accountView(user.id), action: "own profile" }; }) ); router.post( "/password", sameOriginGuard, accountWrite(async (req, user, body) => { await auth.confirmPassword(user, body.currentPassword); await auth.setPassword(user.username, body.newPassword, { keepToken: auth.cookieToken(req) }); return { out: { ok: true }, action: "own password changed" }; }) ); router.post( "/sign-out-others", sameOriginGuard, accountWrite(async (req, user) => { const ended = auth.endSessions(user.id, auth.cookieToken(req)); return { out: { ended }, action: `signed out ${ended} other login(s)` }; }) ); const avatars = require("../lib/avatars"); router.get("/avatar", (req, res) => { if (!auth.isServerMode()) return res.status(404).json({ error: { code: "ENOAVATAR" } }); const user = req.user || auth.userFromRequest(req); if (!user) return unauth(res); const file = avatars.avatarFile(user.id); if (!file) return res.status(404).json({ error: { code: "ENOAVATAR", message: "no avatar" } }); res.set("Content-Type", file.type); res.set("X-Content-Type-Options", "nosniff"); res.set("Content-Security-Policy", "sandbox; default-src 'none'"); // The client versions the URL with `avatarAt`, so a new picture is a new URL. res.set("Cache-Control", "private, max-age=86400"); res.sendFile(file.path); }); router.put( "/avatar", sameOriginGuard, express.raw({ type: () => true, limit: avatars.MAX_AVATAR_BYTES }), accountWrite(async (req, user) => { avatars.setAvatar(user.id, req.body); return { out: accountView(user.id), action: "own avatar set" }; }) ); router.delete( "/avatar", sameOriginGuard, accountWrite(async (_req, user) => { avatars.removeAvatar(user.id); return { out: accountView(user.id), action: "own avatar removed" }; }) ); // An avatar past its limit reaches here as express.raw's 413. router.use((e, _req, res, next) => { if (e && e.type === "entity.too.large") return res.status(413).json({ error: { code: "ETOOBIG", message: "the image is too large" } }); return next(e); }); /** S3: an account's dark/light mode becomes their Claude Code theme (best effort). */ function followClaudeTheme(req, mode) { if (mode !== "dark" && mode !== "light") return; try { const runAs = require("../lib/run-as"); const acct = runAs.accountForUser(req.user?.username); if (acct) require("../lib/member-claude").setTheme(acct, mode); } catch (err) { console.warn("[me] Claude theme not written:", err.message); } } module.exports = router; module.exports.PREF_KEYS = PREF_KEYS;