0c2f49876d
- Users page: one Edit user form (display name, email, role, active, a new or generated password, workspaces given or taken back, sign out everywhere), validated as a whole before anything is written (PATCH /api/users/:id, GET /api/users/:id); users.display_name/email added migration-safe. - Settings → Account for every signed-in user: profile, own password (the current one required, this login kept), sign out other devices (/api/me/account, /api/me/password, /api/me/sign-out-others; login cookie only, never a pane token). - Display names wherever a person is named (sidebar, workspace owner groups, Users page and audit log); owner_display_name / reader_display additive. - Users page: admins add and remove Claude/Codex pool accounts. - Folder browser: an admin with their own Linux account opens in their home; the "Server folder" tick opens the host's (never remembered). - AI Proxy: "Config 9router" beside Add profile when 9router is installed, opened on the dashboard's own host (GET /api/ai-proxies/9router). - Terminal: a tmux selection reaches the clipboard over plain HTTP too (writeClipboard's textarea fallback, focus handed back). - Chat: a file the session touched outside the workspace previews (GET /api/sessions/:id/file). - install-system: `!requiretty` only when visudo accepts it (sudo-rs on Ubuntu 25.10+ rejects the setting). - openapi.yaml regenerated; docs (API, SERVER-MODE, LANES) updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
259 lines
10 KiB
JavaScript
259 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 fresh = await exchangeRefresh(creds);
|
|
profiles.writeProfile(name, { ...stored, credentials: fresh });
|
|
return fresh.accessToken;
|
|
}
|
|
|
|
/**
|
|
* One refresh-token exchange: `creds` (a `claudeAiOauth` block) with its new
|
|
* access token, refresh token and expiry. The CALLER must persist the result —
|
|
* Anthropic retires the refresh token it was handed (see `refreshProfile`).
|
|
*/
|
|
async function exchangeRefresh(creds) {
|
|
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");
|
|
return {
|
|
...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,
|
|
};
|
|
}
|
|
|
|
/** 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,
|
|
exchangeRefresh,
|
|
CLIENT_ID,
|
|
USAGE_URL,
|
|
TOKEN_URL,
|
|
};
|