/** * @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. A session * belongs to its workspace: `sessions.lane_id`, or a `dashboard_runs` row of * that workspace holding it (a session not stamped yet). 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); /** 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") { const uid = memberId(req); if (uid == null) return { where: "1", params: [] }; return { where: `(${laneCol} IN (SELECT id FROM lanes WHERE owner_id = ?) OR ${col} IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE l.owner_id = ? AND r.session_id IS NOT NULL))`, params: [uid, 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 (SELECT id FROM lanes WHERE owner_id = ?)`, params: [uid] }; } 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. const v = typeof laneId === "number" ? laneId : String(laneId); const row = sql("SELECT owner_id FROM lanes WHERE id = ?").get(v); return Boolean(row) && row.owner_id === uid; } 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 `…@