/** * @file scope.js * @description Server mode's row ownership (phase S2, docs/SERVER-MODE.md) — * the ONE place that decides which rows a request may see. * * A workspace (`lanes.owner_id`) belongs to the user who created it, and may be * SHARED with other users (`lane_shares`): they see it and work in it like its * owner — visibility here — while owner-only actions check `isLaneOwner`, and * personal figures (Today, budgets) stay on owned rows only. A session * belongs to its workspace: `sessions.lane_id`, or a `dashboard_runs` row of * that workspace holding it (a session not stamped yet). A session no workspace owns * belongs to the user of the Linux account it ran as (`sessions.account`, * server/lib/session-account.js) — a script's `claude -p` is its runner's. A * session a remote client uploaded is its device user's (`sessions.owner_id`, * server/hub/remote-ingest.js). Agents, events and * token rows belong to their session. A developer sees and changes only what * their own workspaces own; an analyst SEES everything but changes only their * own (every non-GET request is scoped like a developer's); an admin sees and * changes everything, and so does every request in local mode (`req.user` is * never set there). * * Every helper takes `req`, so no caller can pick whose rows it reads. * @author Nguyễn Ngọc Trí Vĩ */ const sql = (q) => require("../db").db.prepare(q); const { ACCOUNT_OWNED } = require("./session-account"); /** The user whose rows this request is limited to, or null for "all". */ function memberId(req) { const u = req?.user; if (!u) return null; if (u.role === "developer") return u.id; // An analyst reads every user's rows but writes only their own. if (u.role === "analyst" && req.method !== "GET" && req.method !== "HEAD") return u.id; return null; } /** The owner a workspace created by this request gets (null outside server mode). */ const ownerFor = (req) => (req?.user ? req.user.id : null); /** * SQL predicate over a sessions alias: `{where, params}`, `where` = "1" when * unrestricted. `col` is the session id expression, `laneCol` its lane id. */ function sessionWhere(req, col = "id", laneCol = "lane_id") { return ownedSessionWhere(memberId(req), col, laneCol, true); } /** * `sessionWhere` without shared workspaces: what this request's user OWNS — * for their own money (budgets), never another user's spend in a workspace * shared with them. "1" when unrestricted, like `sessionWhere`. */ function ownedOnlySessionWhere(req, col = "id", laneCol = "lane_id") { return ownedSessionWhere(memberId(req), col, laneCol, false); } /** * `sessionWhere` for the requesting user's OWN sessions, whatever their role — * a personal figure (the status bar's Today) that must not be every user's sum * for an admin or analyst. "1" in local mode, where there is no user. */ function ownSessionWhere(req, col = "id", laneCol = "lane_id") { return ownedSessionWhere(req?.user ? req.user.id : null, col, laneCol); } // The workspaces a user owns or that are shared with them (2 params: uid, uid). const MY_LANES = "SELECT id FROM lanes WHERE owner_id = ? UNION SELECT lane_id FROM lane_shares WHERE user_id = ?"; // The sessions shared with a user one by one (1 param: uid), and the // workspaces holding them (sharing phase 3: seen, but only that session). const MY_SESSIONS = "SELECT session_id FROM session_shares WHERE user_id = ?"; const SESSION_LANES = `SELECT lane_id FROM sessions WHERE lane_id IS NOT NULL AND id IN (${MY_SESSIONS})`; // Every workspace a user may open (3 params: uid ×3). const SEEN_LANES = `${MY_LANES} UNION ${SESSION_LANES}`; function ownedSessionWhere(uid, col, laneCol, shared = false) { if (uid == null) return { where: "1", params: [] }; const lanes = shared ? MY_LANES : "SELECT id FROM lanes WHERE owner_id = ?"; const lp = shared ? [uid, uid] : [uid]; return { where: `(${laneCol} IN (${lanes}) OR ${col} IN (SELECT r.session_id FROM dashboard_runs r WHERE r.lane_id IN (${lanes}) AND r.session_id IS NOT NULL) OR ${col} IN (SELECT id FROM sessions WHERE owner_id = ?) OR ${col} IN (SELECT session_id FROM (${ACCOUNT_OWNED}) WHERE uid = ?)${ shared ? ` OR ${col} IN (${MY_SESSIONS})` : "" })`, params: [...lp, ...lp, uid, uid, ...(shared ? [uid] : [])], }; } /** SQL predicate for any table carrying a session id column (events, agents, tokens). */ function bySessionWhere(req, col = "session_id") { const uid = memberId(req); if (uid == null) return { where: "1", params: [] }; const s = sessionWhere(req, "s.id", "s.lane_id"); return { where: `${col} IN (SELECT s.id FROM sessions s WHERE ${s.where})`, params: s.params }; } /** SQL predicate over a lanes id column. */ function laneWhere(req, col = "id") { const uid = memberId(req); if (uid == null) return { where: "1", params: [] }; return { where: `${col} IN (${SEEN_LANES})`, params: [uid, uid, uid] }; } /** Whether session `sessionId` is shared with `userId` on its own (phase 3). */ function sessionSharedWith(sessionId, userId) { if (!sessionId || userId == null) return false; return Boolean( sql("SELECT 1 FROM session_shares WHERE session_id = ? AND user_id = ?").get( String(sessionId), userId ) ); } /** * How much of workspace `laneId` this request may use: `all` (local mode, an * admin or analyst read, its owner, a user it is shared with), `session` * (only sessions shared with them one by one), or null. */ function laneAccess(req, laneId) { const uid = memberId(req); if (uid == null) return "all"; const v = typeof laneId === "number" ? laneId : String(laneId); const row = sql("SELECT id, owner_id FROM lanes WHERE id = ?").get(v); if (!row) return null; if (row.owner_id === uid || sharedWith(row.id, uid)) return "all"; const one = sql( `SELECT 1 FROM sessions WHERE lane_id = ? AND id IN (${MY_SESSIONS}) LIMIT 1` ).get(row.id, uid); return one ? "session" : null; } /** Whether `userId` is one of the users workspace `laneId` is shared with. */ function sharedWith(laneId, userId) { if (laneId == null || userId == null) return false; return Boolean( sql("SELECT 1 FROM lane_shares WHERE lane_id = ? AND user_id = ?").get(Number(laneId), userId) ); } /** * Owner-only actions on a workspace (rename, reset, remove, sharing): its * owner or an admin; always true in local mode. A user it is shared with may * do everything else, which `canSeeLane` decides. */ function isLaneOwner(req, laneId) { const u = req?.user; if (!u || u.role === "admin") return true; const row = sql("SELECT owner_id FROM lanes WHERE id = ?").get(Number(laneId)); return Boolean(row) && row.owner_id === u.id; } function canSeeLane(req, laneId) { const uid = memberId(req); if (uid == null) return true; // Bound as given, like getLane binds a route param: SQLite's integer // affinity reads '9002.0' or ' 9002' as 9002 for both, so the row checked // here is the row the route would load. return laneAccess(req, laneId) != null; } function canSeeSession(req, sessionId) { if (memberId(req) == null) return true; const s = sessionWhere(req); return Boolean( sql(`SELECT 1 FROM sessions WHERE id = ? AND ${s.where}`).get(String(sessionId), ...s.params) ); } function canSeeAgent(req, agentId) { if (memberId(req) == null) return true; const row = sql("SELECT session_id FROM agents WHERE id = ?").get(String(agentId)); return Boolean(row) && canSeeSession(req, row.session_id); } /** A run id `agenthub-lane-[-]` (or an archived `…@