/** * @file pool-groups.js * @description Account pool GROUPS (server mode): an admin splits the pool so * each team's sessions run on that team's own accounts. A group is a name, its * members (one group per user, `pool_group_users`) and the pool accounts it * may use, of every tool (`pool_group_accounts`; one account may serve many * groups). The balancers (claude-pool.js, codex-pool.js, cursor-pool.js) ask * `allowed(ownerId, tool)` which accounts a workspace OWNER's new pane may * take: a member of a group gets only that group's accounts of the tool — * none means the user's own sign-in, as with an empty pool — and a user in no * group gets the SHARED pool, the accounts no group lists. With no group at * all every account is shared, exactly as before groups existed. A pin * (account-pins.js) is an admin's explicit choice and is honoured whatever the * group. Local mode has no users: everything is shared there. * * Accounts are named, not referenced — the pool lives in files — so removing * an account drops its group rows (`dropAccount`); deleting a user or a group * cascades. * @author Nguyễn Ngọc Trí Vĩ */ const err = (code, message) => Object.assign(new Error(message), { code }); const db = () => require("../db").db; const pins = () => require("./account-pins"); const NAME_MAX = 64; function validName(name) { const n = String(name ?? "").trim(); if (!n || n.length > NAME_MAX || /[\u0000-\u001f]/.test(n)) { throw err("EBADNAME", `group name: 1–${NAME_MAX} characters`); } return n; } function groupRow(id) { const g = Number.isInteger(id) ? db().prepare("SELECT id, name FROM pool_groups WHERE id = ?").get(id) : null; if (!g) throw err("ENOGROUP", `no group ${id}`); return g; } /** Every group with its members and accounts per tool. */ function listGroups() { const groups = db() .prepare("SELECT id, name, created_at AS createdAt FROM pool_groups ORDER BY name") .all(); const users = db() .prepare( `SELECT g.group_id AS groupId, u.id AS userId, u.username FROM pool_group_users g JOIN users u ON u.id = g.user_id ORDER BY u.username` ) .all(); const accounts = db() .prepare("SELECT group_id AS groupId, tool, account FROM pool_group_accounts ORDER BY account") .all(); return groups.map((g) => ({ ...g, users: users .filter((u) => u.groupId === g.id) .map(({ userId, username }) => ({ userId, username })), accounts: Object.fromEntries( pins().TOOLS.map((t) => [ t, accounts.filter((a) => a.groupId === g.id && a.tool === t).map((a) => a.account), ]) ), })); } function createGroup(name) { const n = validName(name); try { const id = db().prepare("INSERT INTO pool_groups (name) VALUES (?)").run(n).lastInsertRowid; return { id: Number(id), name: n }; } catch (e) { if (String(e.code).startsWith("SQLITE_CONSTRAINT")) throw err("EEXISTS", `group ${n} exists`); throw e; } } function renameGroup(id, name) { const g = groupRow(id); const n = validName(name); try { db().prepare("UPDATE pool_groups SET name = ? WHERE id = ?").run(n, g.id); } catch (e) { if (String(e.code).startsWith("SQLITE_CONSTRAINT")) throw err("EEXISTS", `group ${n} exists`); throw e; } return { id: g.id, name: n }; } /** Its members go back to the shared pool; its accounts become shared if no other group lists them. */ function deleteGroup(id) { const g = groupRow(id); db().prepare("DELETE FROM pool_groups WHERE id = ?").run(g.id); return g; } /** * Exactly `userIds` are the group's members afterwards — a user in another * group moves here (one group per user). One transaction. */ function setGroupUsers(id, userIds) { const g = groupRow(id); if (!Array.isArray(userIds) || !userIds.every(Number.isInteger)) { throw err("EBADUSER", "userIds: an array of user ids"); } const want = [...new Set(userIds)]; for (const uid of want) { if (!db().prepare("SELECT 1 FROM users WHERE id = ?").get(uid)) throw err("ENOUSER", `no user ${uid}`); } db().transaction(() => { db().prepare("DELETE FROM pool_group_users WHERE group_id = ?").run(g.id); const put = db().prepare( `INSERT INTO pool_group_users (user_id, group_id) VALUES (?, ?) ON CONFLICT(user_id) DO UPDATE SET group_id = excluded.group_id` ); for (const uid of want) put.run(uid, g.id); })(); return g; } /** Exactly `names` are the group's accounts of `tool` afterwards. */ function setGroupAccounts(id, tool, names) { const g = groupRow(id); if (!pins().TOOLS.includes(tool)) throw err("EBADTOOL", `tool: ${pins().TOOLS.join(", ")}`); if (!Array.isArray(names) || !names.every((n) => typeof n === "string")) { throw err("EBADNAME", "accounts: an array of account names"); } const want = [...new Set(names)]; const pool = pins().poolOf(tool); for (const n of want) { if (!pool.hasAccount(n)) throw err("ENOACCOUNT", `no ${tool} account ${n} in the pool`); } db().transaction(() => { db().prepare("DELETE FROM pool_group_accounts WHERE group_id = ? AND tool = ?").run(g.id, tool); const put = db().prepare( "INSERT INTO pool_group_accounts (group_id, tool, account) VALUES (?, ?, ?)" ); for (const n of want) put.run(g.id, tool, n); })(); return g; } /** * Which accounts of `tool` a pane of workspace owner `ownerId` may take, as a * predicate on the account name: their group's, else the shared ones; their * pin always. Everything when groups do not apply (local mode, no group yet, * or no database to ask). */ function allowed(ownerId, tool) { if (!require("./auth").isServerMode()) return () => true; try { if (!db().prepare("SELECT 1 FROM pool_groups LIMIT 1").get()) return () => true; const pin = pins().pinFor(ownerId, tool); const group = Number.isInteger(ownerId) ? db().prepare("SELECT group_id FROM pool_group_users WHERE user_id = ?").get(ownerId) : null; const names = new Set( (group ? db() .prepare("SELECT account FROM pool_group_accounts WHERE group_id = ? AND tool = ?") .all(group.group_id, tool) : [] ).map((r) => r.account) ); const grouped = group ? null : new Set( db() .prepare("SELECT DISTINCT account FROM pool_group_accounts WHERE tool = ?") .all(tool) .map((r) => r.account) ); return (name) => name === pin || (group ? names.has(name) : !grouped.has(name)); } catch { return () => true; // a spawn never fails over a group lookup } } /** A removed account leaves every group. */ function dropAccount(tool, account) { try { db() .prepare("DELETE FROM pool_group_accounts WHERE tool = ? AND account = ?") .run(tool, account); } catch { /* no database (a bare CLI test): nothing grouped */ } } module.exports = { listGroups, createGroup, renameGroup, deleteGroup, setGroupUsers, setGroupAccounts, allowed, dropAccount, };