9a54978eef
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.
252 lines
10 KiB
JavaScript
252 lines
10 KiB
JavaScript
/**
|
|
* @file cc-usage.js
|
|
* @description How much of an Anthropic account's quota is already spent —
|
|
* the number that decides which saved auth profile (`cc-profiles.js`) is worth
|
|
* switching to.
|
|
*
|
|
* There is no local source for it. Nothing under CLAUDE_HOME records a
|
|
* utilization or a reset time; the credentials file carries only
|
|
* `subscriptionType` and `rateLimitTier`. The figure comes from the same
|
|
* endpoint the CLI's own `/usage` reads, `GET /api/oauth/usage`, answered for
|
|
* whichever account's OAuth token is presented.
|
|
*
|
|
* Two rules this module exists to hold:
|
|
*
|
|
* - **A profile's token is sent to exactly one place: Anthropic.** It is read
|
|
* from the profile file, put in an `Authorization` header, and never
|
|
* returned, logged or echoed. What leaves this module is two percentages
|
|
* and two reset timestamps.
|
|
*
|
|
* - **The active account is read from the CLI, never from its saved copy.**
|
|
* A refresh token is single use, so every refresh Claude Code performs on
|
|
* the account it is logged into retires the copy stored in that profile:
|
|
* the saved credentials of the LIVE account are the one set certain to be
|
|
* rejected. `cc-profiles.liveCredentialsFor` hands back what the CLI is
|
|
* actually holding, and that token is never refreshed here — rotating it
|
|
* would log the user out of their own terminal.
|
|
*
|
|
* - **Nothing here runs on a poll, and no refresh happens that a token's own
|
|
* expiry did not force.** Opening the Profiles modal asks for every saved
|
|
* account at once — comparing them is the point — so the guard against
|
|
* rewriting a user's credentials over and over is not "one row at a time",
|
|
* it is here: the live account never refreshes at all, and any other profile
|
|
* refreshes only once its token has actually expired, which then leaves it
|
|
* valid for hours of further opens. What a refresh does return is written
|
|
* straight back (`cc-profiles.writeProfile`), because Anthropic retires the
|
|
* refresh token it was handed and dropping the replacement would leave an
|
|
* account the user has to log into again.
|
|
*
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const profiles = require("./cc-profiles");
|
|
|
|
/** Claude Code's own public OAuth client. A refresh is only accepted for the
|
|
* client the token was issued to, so this is not a configurable value. */
|
|
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
|
|
const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
const PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
|
|
const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
|
|
|
|
/** Treat a token expiring within this window as already expired: a refresh is
|
|
* cheaper than a 401 that costs the whole call. */
|
|
const EXPIRY_MARGIN_MS = 60_000;
|
|
|
|
const TIMEOUT_MS = 15_000;
|
|
|
|
function err(code, message) {
|
|
const e = new Error(message);
|
|
e.code = code;
|
|
return e;
|
|
}
|
|
|
|
async function postJson(url, body, headers) {
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", ...headers },
|
|
body: JSON.stringify(body),
|
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
});
|
|
const text = await res.text();
|
|
if (!res.ok) {
|
|
// The one upstream failure a user can act on: this profile's refresh token
|
|
// has already been spent, which happens whenever a NEWER login or refresh
|
|
// for the same account rotated it. Saying so beats echoing a raw 400.
|
|
if (text.includes("invalid_grant")) {
|
|
throw err(
|
|
"ESTALE",
|
|
"this profile's saved login has been superseded - activate the account once and save the profile again"
|
|
);
|
|
}
|
|
throw err("EUPSTREAM", `${url} answered ${res.status}: ${text.slice(0, 200)}`);
|
|
}
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
throw err("EUPSTREAM", `${url} did not answer JSON`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Exchange a profile's refresh token for a fresh access token AND persist what
|
|
* comes back.
|
|
*
|
|
* The persist is not an optimisation. Anthropic returns a new refresh token and
|
|
* retires the one just used, so a refresh that is not written back leaves the
|
|
* profile file holding a dead credential — the account would have to be logged
|
|
* into again before it could ever be activated.
|
|
*
|
|
* @param {string} name a saved profile's identifier
|
|
* @returns {Promise<string>} the fresh access token (never returned to a route)
|
|
*/
|
|
async function refreshProfile(name) {
|
|
const stored = profiles.readProfile(name);
|
|
if (!stored) throw err("ENOTFOUND", `no saved profile named ${name}`);
|
|
const creds = stored.credentials || {};
|
|
if (!creds.refreshToken) {
|
|
throw err("ENOREFRESH", `profile ${name} has no refresh token — re-save it from a login`);
|
|
}
|
|
|
|
const data = await postJson(TOKEN_URL, {
|
|
grant_type: "refresh_token",
|
|
refresh_token: creds.refreshToken,
|
|
client_id: CLIENT_ID,
|
|
});
|
|
if (!data.access_token) throw err("EUPSTREAM", "the token endpoint returned no access_token");
|
|
|
|
profiles.writeProfile(name, {
|
|
...stored,
|
|
credentials: {
|
|
...creds,
|
|
accessToken: data.access_token,
|
|
// Both are rotated by the exchange; keeping the old refresh token here
|
|
// would pin the profile to a credential that no longer works.
|
|
refreshToken: data.refresh_token || creds.refreshToken,
|
|
expiresAt: Date.now() + (Number(data.expires_in) || 0) * 1000,
|
|
},
|
|
});
|
|
return data.access_token;
|
|
}
|
|
|
|
/** The two windows the UI reports, flattened out of a response that also
|
|
* carries a dozen experiment-named buckets this has no use for. */
|
|
function windowOf(value) {
|
|
if (!value || typeof value !== "object") return null;
|
|
const utilization = Number(value.utilization);
|
|
return {
|
|
utilization: Number.isFinite(utilization) ? utilization : null,
|
|
resetsAt: typeof value.resets_at === "string" ? value.resets_at : null,
|
|
};
|
|
}
|
|
|
|
/** Ask Anthropic what this token's account has spent. */
|
|
async function fetchUsage(accessToken) {
|
|
const res = await fetch(USAGE_URL, {
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
"anthropic-beta": "oauth-2025-04-20",
|
|
},
|
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
});
|
|
if (res.status === 401) throw err("EUNAUTHORIZED", "the account rejected this token");
|
|
if (!res.ok) throw err("EUPSTREAM", `the usage endpoint answered ${res.status}`);
|
|
const data = await res.json();
|
|
return { fiveHour: windowOf(data.five_hour), sevenDay: windowOf(data.seven_day) };
|
|
}
|
|
|
|
/** Token → owner. An access token never changes hands, so a hit never goes stale. */
|
|
const owners = new Map();
|
|
|
|
/**
|
|
* The account a token belongs to — `{uuid, email}` — or null when the profile
|
|
* endpoint does not say. A saved profile's label and its token come from two
|
|
* different files (`oauthAccount` and the credentials store), and a running
|
|
* Claude Code session can leave them naming different accounts, so the token's
|
|
* own answer is the only one that says who a request will be billed to.
|
|
*/
|
|
async function fetchOwner(accessToken) {
|
|
if (owners.has(accessToken)) return owners.get(accessToken);
|
|
try {
|
|
const res = await fetch(PROFILE_URL, {
|
|
headers: { Authorization: `Bearer ${accessToken}`, "anthropic-beta": "oauth-2025-04-20" },
|
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
});
|
|
if (!res.ok) return null;
|
|
const account = (await res.json())?.account;
|
|
if (!account?.uuid) return null;
|
|
const owner = { uuid: account.uuid, email: account.email || null };
|
|
if (owners.size > 50) owners.clear(); // ponytail: crude cap; a few accounts per machine
|
|
owners.set(accessToken, owner);
|
|
return owner;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Who the CLI's live token belongs to; null when offline, expired or not OAuth. */
|
|
async function liveOwner() {
|
|
const { oauth } = profiles.readActiveAuth();
|
|
if (!oauth?.accessToken || !(Number(oauth.expiresAt) > Date.now() + EXPIRY_MARGIN_MS)) {
|
|
return null;
|
|
}
|
|
return fetchOwner(oauth.accessToken);
|
|
}
|
|
|
|
/**
|
|
* Usage for one saved profile, refreshing its token first when that token has
|
|
* expired (which, for any profile that is not the active one, it usually has).
|
|
*
|
|
* @param {string} name a saved profile's identifier
|
|
* @returns {Promise<{name:string, refreshed:boolean, fiveHour:object|null,
|
|
* sevenDay:object|null, checkedAt:string}>} percentages and reset times only
|
|
*/
|
|
async function profileUsage(name) {
|
|
const stored = profiles.readProfile(name);
|
|
if (!stored) throw err("ENOTFOUND", `no saved profile named ${name}`);
|
|
|
|
// For the account that is logged in RIGHT NOW the saved copy is the one set
|
|
// of credentials guaranteed to fail — the CLI has rotated its refresh token
|
|
// since, retiring the profile's. Use what the CLI holds, and never refresh
|
|
// it: that would retire the token the user's own terminal is using, and this
|
|
// module does not write `.credentials.json` back.
|
|
const live = profiles.liveCredentialsFor(stored, { owner: await liveOwner() });
|
|
const creds = live || stored.credentials || {};
|
|
const fresh = Number(creds.expiresAt) > Date.now() + EXPIRY_MARGIN_MS;
|
|
if (live && !fresh) {
|
|
throw err(
|
|
"ELIVEEXPIRED",
|
|
"the active account's token has expired - use Claude Code once so it refreshes, then check again"
|
|
);
|
|
}
|
|
|
|
let token = fresh ? creds.accessToken : await refreshProfile(name);
|
|
let refreshed = !fresh;
|
|
if (!token) throw err("ENOAUTH", `profile ${name} holds no access token`);
|
|
|
|
try {
|
|
const usage = await fetchUsage(token);
|
|
return { name, refreshed, ...usage, checkedAt: new Date().toISOString() };
|
|
} catch (e) {
|
|
// A token that looked fresh but was revoked server-side is worth one
|
|
// retry through the refresh path; a second 401 is a real answer. Not for
|
|
// the live account, for the same reason its token is never refreshed here.
|
|
if (e.code !== "EUNAUTHORIZED" || refreshed || live) throw e;
|
|
token = await refreshProfile(name);
|
|
refreshed = true;
|
|
const usage = await fetchUsage(token);
|
|
return { name, refreshed, ...usage, checkedAt: new Date().toISOString() };
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
profileUsage,
|
|
fetchUsage,
|
|
fetchOwner,
|
|
liveOwner,
|
|
refreshProfile,
|
|
CLIENT_ID,
|
|
USAGE_URL,
|
|
TOKEN_URL,
|
|
};
|