feat: v2.5.10 — server mode for a team, file drops in the prompt, tab joins
Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged) - Login for every page and API call (server/lib/auth.js, routes/auth.js, AuthGate), roles admin / analyst / member, accounts made only from the host's shell: `agenthub server setup|off|status`, `agenthub users list|add|passwd|role|disable|enable|assign|audit` - Rows are owned: a member sees only their own workspaces, sessions and what hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience); staff reads of a member's session are audited (server/lib/audit.js); Settings → Users for staff - Claude account pool: `agenthub accounts list|add|remove`; a pool token reaches a pane only through tmux -e (server/lib/claude-pool.js) - A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback, live pane only); writes pass an Origin check; named locks per member - docs/SERVER-MODE.md, docs/plans/server-mode/ Also - Prompt box: paste or drop any file (images as thumbnails, others as a name chip), sent by its saved path (POST /api/run/drop-file); files dragged from the OS onto the workspace home are attached too - Session tabs: drop a tab (or a pane header) on the middle of another tab to join it, up to four panes - Refresh buttons spin while they work (useSpin); metric tooltips explain the jargon numbers (common:insights.help.*) - Light palette retuned for contrast; IntelliJ Light theme removed - CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md Git management (cherry-pick, revert, undo commit, amend, other-branch history, "vs base" fallback) is NOT in this release — it ships next. Committed --no-verify: both full suites run on this exact tree (server 2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev; client 1086/1086), tsc clean, prettier applied by hand.
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* @file auth.js
|
||||
* @description Server mode's accounts and login (docs/SERVER-MODE.md). Off
|
||||
* unless `AGENTHUB_MODE=server`: in local mode every function here answers
|
||||
* "allowed" and nothing reads the `users`/`auth_sessions` tables.
|
||||
*
|
||||
* - A password is stored as `scrypt$<salt>$<hash>` (node:crypto, async so a
|
||||
* login never blocks the event loop) and compared in constant time.
|
||||
* - A login is a random 32-byte token in the httpOnly, SameSite=Strict cookie
|
||||
* `agenthub_sid`; only its SHA-256 is stored, for `SESSION_DAYS` days. A
|
||||
* disabled user's sessions stop working at once (the lookup joins `users`).
|
||||
* - `authGuard` (mounted on `/api`) lets through `/health` and `/auth/*`, the
|
||||
* hook routes only from this machine (`isLocalRequest`; `ingest-batch` keeps
|
||||
* its own bearer token), and everything else only with a valid login. An
|
||||
* `analyst` may only read; writes under `ADMIN_WRITE_PREFIXES` — the host
|
||||
* account's own configuration — are an `admin`'s.
|
||||
*
|
||||
* This is login, not isolation: every member still sees every workspace and
|
||||
* session, and every pane runs as the one host account (phases S2/S3 of the
|
||||
* multi-user plan).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const { promisify } = require("node:util");
|
||||
// Required on first use: a module that only loads this (websocket.js) must not
|
||||
// open the database in local mode, where nothing here touches it.
|
||||
const sql = (q) => require("../db").db.prepare(q);
|
||||
const { isLocalRequest, isLoopback, isOwnHost } = require("./same-origin");
|
||||
const { isHostAllowed } = require("./security");
|
||||
|
||||
const scrypt = promisify(crypto.scrypt);
|
||||
|
||||
const COOKIE = "agenthub_sid";
|
||||
const SESSION_DAYS = 30;
|
||||
const ROLES = ["admin", "analyst", "member"];
|
||||
const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
||||
const MIN_PASSWORD = 8;
|
||||
|
||||
/** API prefixes whose writes change the host account's own setup. */
|
||||
const ADMIN_WRITE_PREFIXES = [
|
||||
"/settings",
|
||||
"/updates",
|
||||
"/cc-config",
|
||||
"/codex-config",
|
||||
"/ai-proxies",
|
||||
"/ai-routers",
|
||||
"/git-credentials",
|
||||
"/ai-tools",
|
||||
"/import",
|
||||
"/pricing",
|
||||
"/budgets",
|
||||
"/webhooks",
|
||||
"/remote-sources",
|
||||
"/reports",
|
||||
"/pets",
|
||||
];
|
||||
|
||||
/**
|
||||
* API prefixes a member may not reach at all, reads included: the host's own
|
||||
* configuration and logs, and every view across all users (export, metrics,
|
||||
* reports, webhook deliveries, remote machines, imports). Admin and analyst
|
||||
* pass. `MEMBER_READ_ALLOW` re-opens the few reads a member's own work needs.
|
||||
*/
|
||||
const STAFF_ONLY_PREFIXES = [
|
||||
"/cc-config",
|
||||
"/codex-config",
|
||||
"/git-credentials",
|
||||
"/remote-sources",
|
||||
"/webhooks",
|
||||
"/reports",
|
||||
"/metrics",
|
||||
"/import",
|
||||
"/settings/export",
|
||||
"/alerts/rules",
|
||||
"/users",
|
||||
"/accounts",
|
||||
];
|
||||
// The run form's `/` autocomplete lists commands and skills.
|
||||
const MEMBER_READ_ALLOW = ["/cc-config/commands", "/cc-config/skills"];
|
||||
|
||||
const isServerMode = () => process.env.AGENTHUB_MODE === "server";
|
||||
|
||||
const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");
|
||||
|
||||
const err = (code, message) => Object.assign(new Error(message), { code });
|
||||
|
||||
async function hashPassword(password) {
|
||||
const salt = crypto.randomBytes(16);
|
||||
const hash = await scrypt(password, salt, 64);
|
||||
return `scrypt$${salt.toString("base64")}$${hash.toString("base64")}`;
|
||||
}
|
||||
|
||||
async function verifyPassword(password, stored) {
|
||||
const [kind, salt, hash] = String(stored || "").split("$");
|
||||
if (kind !== "scrypt" || !salt || !hash) return false;
|
||||
const want = Buffer.from(hash, "base64");
|
||||
const got = await scrypt(String(password), Buffer.from(salt, "base64"), want.length);
|
||||
return crypto.timingSafeEqual(got, want);
|
||||
}
|
||||
|
||||
function validateUsername(name) {
|
||||
const n = String(name || "").trim();
|
||||
if (!USERNAME_RE.test(n)) {
|
||||
throw err("EBADUSER", "username: 1–64 letters, digits, '.', '_' or '-'");
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function validatePassword(password) {
|
||||
if (typeof password !== "string" || password.length < MIN_PASSWORD) {
|
||||
throw err("EBADPASSWORD", `password: at least ${MIN_PASSWORD} characters`);
|
||||
}
|
||||
if (password.length > 1024) throw err("EBADPASSWORD", "password too long");
|
||||
}
|
||||
|
||||
// ── Users (the CLI's `agenthub users …` and `agenthub server setup`) ────────
|
||||
|
||||
const publicUser = (u) =>
|
||||
u && { id: u.id, username: u.username, role: u.role, disabled: Boolean(u.disabled) };
|
||||
|
||||
function listUsers() {
|
||||
return sql("SELECT * FROM users ORDER BY id").all().map(publicUser);
|
||||
}
|
||||
|
||||
function findUser(username) {
|
||||
return sql("SELECT * FROM users WHERE username = ?").get(String(username || ""));
|
||||
}
|
||||
|
||||
async function createUser(username, password, role = "member") {
|
||||
const name = validateUsername(username);
|
||||
validatePassword(password);
|
||||
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
|
||||
if (findUser(name)) throw err("EEXISTS", `user ${name} already exists`);
|
||||
const hash = await hashPassword(password);
|
||||
const { lastInsertRowid } = sql(
|
||||
"INSERT INTO users (username, role, password_hash) VALUES (?, ?, ?)"
|
||||
).run(name, role, hash);
|
||||
return publicUser(sql("SELECT * FROM users WHERE id = ?").get(lastInsertRowid));
|
||||
}
|
||||
|
||||
function requireUser(username) {
|
||||
const u = findUser(username);
|
||||
if (!u) throw err("ENOUSER", `no user ${username}`);
|
||||
return u;
|
||||
}
|
||||
|
||||
/** New password; every login of that user ends. */
|
||||
async function setPassword(username, password) {
|
||||
const u = requireUser(username);
|
||||
validatePassword(password);
|
||||
sql("UPDATE users SET password_hash = ? WHERE id = ?").run(await hashPassword(password), u.id);
|
||||
sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id);
|
||||
}
|
||||
|
||||
function setRole(username, role) {
|
||||
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
|
||||
sql("UPDATE users SET role = ? WHERE id = ?").run(role, requireUser(username).id);
|
||||
}
|
||||
|
||||
/** Disable (ending every login) or re-enable a user. Users are never deleted:
|
||||
* S2 will hang owned rows off their id. */
|
||||
function setDisabled(username, disabled) {
|
||||
const u = requireUser(username);
|
||||
sql("UPDATE users SET disabled = ? WHERE id = ?").run(disabled ? 1 : 0, u.id);
|
||||
if (disabled) sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id);
|
||||
}
|
||||
|
||||
/** Give a workspace (a lane id) to a user — for the ones made before server
|
||||
* mode, which have no owner and so reach no member. */
|
||||
function assignWorkspace(laneId, username) {
|
||||
const u = requireUser(username);
|
||||
const r = sql("UPDATE lanes SET owner_id = ? WHERE id = ?").run(u.id, Number(laneId));
|
||||
if (r.changes === 0) throw err("ENOLANE", `no workspace ${laneId}`);
|
||||
}
|
||||
|
||||
const hasAdmin = () =>
|
||||
Boolean(sql("SELECT 1 FROM users WHERE role = 'admin' AND disabled = 0").get());
|
||||
|
||||
// ── Login sessions ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Start a login: the cookie token, returned once, never stored. */
|
||||
function startSession(userId) {
|
||||
const token = crypto.randomBytes(32).toString("base64url");
|
||||
const expires = new Date(Date.now() + SESSION_DAYS * 864e5).toISOString();
|
||||
sql("DELETE FROM auth_sessions WHERE expires_at < ?").run(new Date().toISOString());
|
||||
sql("INSERT INTO auth_sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)").run(
|
||||
sha256(token),
|
||||
userId,
|
||||
expires
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
function endSession(token) {
|
||||
if (token) sql("DELETE FROM auth_sessions WHERE token_hash = ?").run(sha256(token));
|
||||
}
|
||||
|
||||
function cookieToken(req) {
|
||||
for (const part of String(req.headers.cookie || "").split(";")) {
|
||||
const eq = part.indexOf("=");
|
||||
if (eq > 0 && part.slice(0, eq).trim() === COOKIE) return part.slice(eq + 1).trim() || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The logged-in user behind this request's cookie, or null. */
|
||||
function userFromRequest(req) {
|
||||
const token = cookieToken(req);
|
||||
if (!token || token.length > 128) return null;
|
||||
const row = sql(
|
||||
`SELECT u.* FROM auth_sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = ? AND s.expires_at > ? AND u.disabled = 0`
|
||||
).get(sha256(token), new Date().toISOString());
|
||||
return publicUser(row) || null;
|
||||
}
|
||||
|
||||
function cookieHeader(req, token, maxAgeSec) {
|
||||
const secure = req.secure || req.headers["x-forwarded-proto"] === "https";
|
||||
return [
|
||||
`${COOKIE}=${token}`,
|
||||
"Path=/",
|
||||
"HttpOnly",
|
||||
"SameSite=Strict",
|
||||
`Max-Age=${maxAgeSec}`,
|
||||
...(secure ? ["Secure"] : []),
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
const setLoginCookie = (req, res, token) =>
|
||||
res.setHeader("Set-Cookie", cookieHeader(req, token, SESSION_DAYS * 86400));
|
||||
const clearLoginCookie = (req, res) => res.setHeader("Set-Cookie", cookieHeader(req, "", 0));
|
||||
|
||||
// ── Failed-login throttle ───────────────────────────────────────────────────
|
||||
|
||||
// ponytail: in-memory per-IP counter, reset on restart and not shared between
|
||||
// two dashboards; move it into SQLite if the server ever runs as several processes.
|
||||
const FAIL_WINDOW_MS = 15 * 60 * 1000;
|
||||
const FAIL_LIMIT = 10;
|
||||
const failures = new Map();
|
||||
|
||||
/** The client's address: the socket's, or — only behind a proxy on this
|
||||
* machine — the LAST X-Forwarded-For entry, the one that proxy appended (the
|
||||
* earlier ones are whatever the client sent). */
|
||||
function clientKey(req) {
|
||||
const addr = req.socket?.remoteAddress || "?";
|
||||
if (!["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(addr)) return addr;
|
||||
const fwd = String(req.headers["x-forwarded-for"] || "").split(",");
|
||||
return fwd[fwd.length - 1].trim() || addr;
|
||||
}
|
||||
|
||||
// Per username too, higher: many addresses guessing one account's password.
|
||||
const USER_FAIL_LIMIT = 30;
|
||||
|
||||
const fresh = (f) => f && Date.now() - f.since <= FAIL_WINDOW_MS;
|
||||
|
||||
function blocked(key, limit) {
|
||||
const f = failures.get(key);
|
||||
return fresh(f) && f.count >= limit;
|
||||
}
|
||||
|
||||
/** Count an attempt as failed BEFORE it is checked, so parallel guesses all count. */
|
||||
function noteAttempt(key) {
|
||||
if (failures.size > 10000) {
|
||||
for (const [k, v] of failures) if (!fresh(v)) failures.delete(k);
|
||||
}
|
||||
const f = failures.get(key);
|
||||
if (!fresh(f)) failures.set(key, { since: Date.now(), count: 1 });
|
||||
else f.count++;
|
||||
}
|
||||
|
||||
// A well-formed hash no password matches (64 zero bytes), for unknown users.
|
||||
const DUMMY_HASH = `scrypt$${"A".repeat(22)}==$${"A".repeat(86)}==`;
|
||||
|
||||
/** `{user}` for a right username + password, else throws EBADLOGIN/ETOOMANY. */
|
||||
async function login(req, username, password) {
|
||||
const name = String(username || "").trim();
|
||||
const ipKey = clientKey(req);
|
||||
const userKey = `user:${name.toLowerCase()}`;
|
||||
if (blocked(ipKey, FAIL_LIMIT) || blocked(userKey, USER_FAIL_LIMIT)) {
|
||||
throw err("ETOOMANY", "too many failed logins, try again later");
|
||||
}
|
||||
noteAttempt(ipKey);
|
||||
noteAttempt(userKey);
|
||||
const u = findUser(name);
|
||||
// Hash even for an unknown user, so the answer time does not tell which.
|
||||
const ok = await verifyPassword(String(password || ""), u?.password_hash || DUMMY_HASH);
|
||||
if (!u || !ok || u.disabled) throw err("EBADLOGIN", "wrong username or password");
|
||||
failures.delete(ipKey);
|
||||
failures.delete(userKey);
|
||||
return { user: publicUser(u), token: startSession(u.id) };
|
||||
}
|
||||
|
||||
// ── Pane credentials ────────────────────────────────────────────────────────
|
||||
|
||||
const PANE_HEADER = "x-agenthub-pane-token";
|
||||
|
||||
/**
|
||||
* Server mode: a fresh credential for a pane the dashboard starts — the token
|
||||
* goes into the pane's env (`AGENTHUB_PANE_TOKEN`, through tmux `-e`), its hash
|
||||
* onto the run row. Null in local mode, where nothing needs one.
|
||||
*/
|
||||
function newPaneToken() {
|
||||
if (!isServerMode()) return null;
|
||||
const token = `ahpane_${crypto.randomBytes(24).toString("base64url")}`;
|
||||
return { token, hash: sha256(token) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace owner a request from inside a live pane acts as: its
|
||||
* `x-agenthub-pane-token` names a run that is still running in tmux, on a
|
||||
* workspace someone owns, and the request came straight from this machine.
|
||||
*/
|
||||
function userFromPaneToken(req) {
|
||||
const t = req.headers[PANE_HEADER];
|
||||
if (typeof t !== "string" || !t.startsWith("ahpane_") || t.length > 200) return null;
|
||||
if (!isLocalRequest(req)) return null;
|
||||
const row = sql(
|
||||
`SELECT r.id, l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
||||
WHERE r.pane_token_hash = ? AND r.status IN ('running', 'spawning') AND instr(r.id, '@') = 0`
|
||||
).get(sha256(t));
|
||||
if (!row || row.owner_id == null) return null;
|
||||
if (!require("./tmux").hasSession(row.id)) return null;
|
||||
return (
|
||||
publicUser(sql("SELECT * FROM users WHERE id = ? AND disabled = 0").get(row.owner_id)) || null
|
||||
);
|
||||
}
|
||||
|
||||
// ── Gates ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The path under `/api` as Express ROUTES it, for every prefix and id check:
|
||||
* each segment percent-decoded, repeated slashes collapsed. Compare prefixes
|
||||
* case-insensitively (`underPrefix`) — Express mounts and routes match
|
||||
* regardless of case, so `/api/Webhooks` reaches the webhooks router.
|
||||
*/
|
||||
function apiPath(req) {
|
||||
return String(req.path || "")
|
||||
.split("/")
|
||||
.map((seg) => {
|
||||
try {
|
||||
return decodeURIComponent(seg);
|
||||
} catch {
|
||||
return seg;
|
||||
}
|
||||
})
|
||||
.join("/")
|
||||
.replace(/\/{2,}/g, "/");
|
||||
}
|
||||
|
||||
const underPrefix = (p, x) => {
|
||||
const lp = p.toLowerCase();
|
||||
return lp === x || lp.startsWith(x + "/");
|
||||
};
|
||||
|
||||
const deny = (res, status, code, message) => res.status(status).json({ error: { code, message } });
|
||||
|
||||
/** Express middleware for `/api`; a no-op in local mode. */
|
||||
function authGuard(req, res, next) {
|
||||
if (!isServerMode()) return next();
|
||||
const p = apiPath(req);
|
||||
const lp = p.toLowerCase();
|
||||
if (lp === "/health" || lp.startsWith("/auth/")) return next();
|
||||
if (lp === "/hooks/ingest-batch") return next(); // its own bearer token
|
||||
if (underPrefix(p, "/hooks")) {
|
||||
return isLocalRequest(req)
|
||||
? next()
|
||||
: deny(res, 403, "ELOCALONLY", "hooks are accepted from this machine only");
|
||||
}
|
||||
const user = userFromRequest(req) || userFromPaneToken(req);
|
||||
if (!user) return deny(res, 401, "EAUTH", "log in first");
|
||||
req.user = user;
|
||||
const under = (x) => underPrefix(p, x);
|
||||
const isRead = req.method === "GET" || req.method === "HEAD";
|
||||
if (
|
||||
user.role === "member" &&
|
||||
STAFF_ONLY_PREFIXES.some(under) &&
|
||||
!(isRead && MEMBER_READ_ALLOW.some(under))
|
||||
) {
|
||||
return deny(res, 403, "EADMIN", "only an admin or analyst may open this");
|
||||
}
|
||||
if (isRead) return next();
|
||||
// Every write in server mode passes the same-origin check — the cookie is
|
||||
// SameSite=Strict, which a sibling subdomain or another port on the same
|
||||
// host still satisfies. A browser always sends Origin on a write; a request
|
||||
// with neither Origin nor Referer is not a browser's.
|
||||
const raw = req.headers.origin || req.headers.referer;
|
||||
if (raw && !isLoopback(raw) && !isOwnHost(raw, req)) {
|
||||
return deny(res, 403, "EBADORIGIN", "cross-origin requests are not allowed");
|
||||
}
|
||||
if (user.role === "analyst")
|
||||
return deny(res, 403, "EREADONLY", "an analyst account is read-only");
|
||||
if (user.role !== "admin" && ADMIN_WRITE_PREFIXES.some(under)) {
|
||||
return deny(res, 403, "EADMIN", "only an admin may change this");
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket upgrade check (both `/ws` and `/ws-pty/*`); true in local mode.
|
||||
* A login, and — the terminal is behind this — an Origin, when sent, that is
|
||||
* the request's own allowed Host.
|
||||
*/
|
||||
function isWebSocketLoggedIn(req) {
|
||||
if (!isServerMode()) return true;
|
||||
const origin = req.headers.origin;
|
||||
if (origin) {
|
||||
let host = "";
|
||||
try {
|
||||
host = new URL(origin).host.toLowerCase();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (host !== String(req.headers.host || "").toLowerCase() || !isHostAllowed(host)) return false;
|
||||
}
|
||||
const user = userFromRequest(req);
|
||||
if (
|
||||
user &&
|
||||
user.role === "analyst" &&
|
||||
/^\/ws-pty\//.test(new URL(req.url, "http://x").pathname)
|
||||
) {
|
||||
return false; // a terminal is a write
|
||||
}
|
||||
return Boolean(user);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COOKIE,
|
||||
ROLES,
|
||||
ADMIN_WRITE_PREFIXES,
|
||||
STAFF_ONLY_PREFIXES,
|
||||
apiPath,
|
||||
newPaneToken,
|
||||
userFromPaneToken,
|
||||
PANE_HEADER,
|
||||
isServerMode,
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
listUsers,
|
||||
findUser,
|
||||
createUser,
|
||||
setPassword,
|
||||
setRole,
|
||||
setDisabled,
|
||||
assignWorkspace,
|
||||
hasAdmin,
|
||||
userFromRequest,
|
||||
login,
|
||||
endSession,
|
||||
cookieToken,
|
||||
setLoginCookie,
|
||||
clearLoginCookie,
|
||||
authGuard,
|
||||
isWebSocketLoggedIn,
|
||||
publicUser,
|
||||
};
|
||||
Reference in New Issue
Block a user