/** * @file cc-target.js * @description Whose Claude Code configuration a `/api/cc-config` request reads * and writes — the ONE resolver every handler of `routes/cc-config.js` uses, * and the allowlist of the endpoints a non-admin may reach there at all. * * - Local mode, or an admin: the host's Claude home (`getClaudeHome()`), with * every handler behaving exactly as it did before members had a config page. * - Server mode, a non-admin with an enabled, provisioned Linux account (S3): * their OWN `/.claude` and `/.claude.json`. Their writes run as * them (cc-mutate's `ioFor`, cc-plugin-cli's sudo), never as the host. * - An analyst with no such account, reading: the host's, read-only — what an * analyst read before. `?view=host` gives an analyst WITH an account that * same read-only view. * - Anyone else (a developer with no account, any non-admin write without * one): 403 ENOACCOUNT — never "the host's then". * * Nothing here takes a home or a path from the request. * @author Nguyễn Ngọc Trí Vĩ */ const path = require("node:path"); const { getClaudeHome } = require("./claude-home"); /** * What a non-admin may call under `/api/cc-config` ("METHOD /path", lower * case, HEAD counted as GET): reads of their own config and the writes that * run as them. Everything else — `/profiles*` (the host's login switching), * `/plugins/update-all` (the host's Update all, AgentHub's own plugin * included), and any endpoint added later — stays an admin's until it is * listed here. */ const MEMBER_ROUTES = new Set([ "GET /target", "GET /overview", "GET /skills", "GET /agents", "GET /commands", "GET /output-styles", "GET /plugins", "GET /plugins/available", "GET /mcp", "GET /hooks", "GET /settings", "GET /memory", "GET /marketplaces", "GET /keybindings", "GET /statusline", "GET /hook-scripts", "GET /file", "GET /backups", "PUT /keybindings", "PUT /file", "DELETE /file", "PATCH /settings", "POST /project-memory", "POST /plugins/install", "POST /plugins/action", "POST /marketplaces/add", "POST /marketplaces/action", "POST /mcp/add", "POST /mcp/remove", "POST /reload", ]); /** The host-wide reads an analyst had before (staff read everything): still read-only. */ const ANALYST_READS = new Set(["GET /profiles", "GET /plugins/update-all"]); /** `"METHOD /path"` as the allowlists spell it (decoded, lower case, no trailing slash). */ function routeKey(req) { const p = require("./auth").apiPath(req).toLowerCase().replace(/\/+$/, "") || "/"; const method = req.method === "HEAD" ? "GET" : String(req.method || "").toUpperCase(); return `${method} ${p}`; } /** Whether `req.user` may reach this cc-config endpoint at all (local mode and admins: yes). */ function mayReach(req) { const u = req.user; if (!u || u.role === "admin") return true; const key = routeKey(req); return MEMBER_ROUTES.has(key) || (u.role === "analyst" && ANALYST_READS.has(key)); } /** Express middleware: the router's own gate (403 EADMIN), the single rule for `/cc-config`. */ function ccGate(req, res, next) { if (mayReach(req)) return next(); res.status(403).json({ error: { code: "EADMIN", message: "only an admin may open this" } }); } const isRead = (req) => req.method === "GET" || req.method === "HEAD"; function host(readOnly = false) { return { acct: null, own: false, readOnly, home: getClaudeHome(), claudeJson: require("./cc-discovery").getClaudeJsonPath(), }; } /** * The target of `req`: `{acct, own, readOnly, home, claudeJson}` — `acct` and * `own` set for a member's own config. * @throws ENOACCOUNT (status 403) for a non-admin with no account to act on */ function resolveCcTarget(req) { const u = req.user; if (!u || u.role === "admin") return host(); if (u.role === "analyst" && isRead(req) && req.query?.view === "host") return host(true); let acct = null; try { acct = require("./run-as").accountForUser(u.username); } catch { acct = null; // a disabled / unprovisioned account is no account: refuse below } if (acct) { return { acct, own: true, readOnly: false, home: path.join(acct.home, ".claude"), claudeJson: path.join(acct.home, ".claude.json"), }; } if (u.role === "analyst" && isRead(req)) return host(true); throw Object.assign( new Error( "your Claude Code config lives in your own account, and this server has none for you yet" ), { code: "ENOACCOUNT", status: 403 } ); } module.exports = { resolveCcTarget, ccGate, mayReach, routeKey, MEMBER_ROUTES, ANALYST_READS };