fix(plan): a Claude plan is the one Anthropic reports now, not the sign-in's

An account signed in on Pro and upgraded to Max 5x kept showing "Pro": the
plan was read only from .credentials.json / .claude.json, whose
subscriptionType and rate-limit tiers are written at sign-in. fetchOwner
already asks GET /api/oauth/profile for a token's owner; it now also reads
organization_type / rate_limit_tier / seat_tier into a plan
(planFromProfile), remembered per account uuid (rememberLivePlan). The live
row, saved profiles (identityOf) and pool accounts (poolStatus, whose quota
refresh now asks a login's owner too) show that plan when one was seen
(shownPlan), else planLabel of the files as before.
This commit is contained in:
2026-10-28 15:09:30 +07:00
parent 33d30f5e1f
commit 0841d3e5c8
12 changed files with 171 additions and 18 deletions
+36 -2
View File
@@ -448,8 +448,14 @@ describe("cc-profiles — identity and token disagree", () => {
it("planLabel reads the plan from the rate-limit tier and seat, not subscriptionType alone", () => {
const { planLabel } = profiles;
// An upgrade keeps `subscriptionType: "pro"` from sign-in; the tier is the truth.
assert.strictEqual(planLabel({ subscriptionType: "pro", rateLimitTier: "default_claude_max_5x" }, null), "Max 5x");
assert.strictEqual(planLabel({ subscriptionType: "max", rateLimitTier: "default_claude_max_20x" }, null), "Max 20x");
assert.strictEqual(
planLabel({ subscriptionType: "pro", rateLimitTier: "default_claude_max_5x" }, null),
"Max 5x"
);
assert.strictEqual(
planLabel({ subscriptionType: "max", rateLimitTier: "default_claude_max_20x" }, null),
"Max 20x"
);
assert.strictEqual(planLabel({ subscriptionType: "pro" }, null), "Pro");
const team = { subscriptionType: "team", rateLimitTier: "default_raven" };
assert.strictEqual(planLabel(team, { seatTier: "team_standard" }), "Team Standard");
@@ -462,3 +468,31 @@ it("planLabel reads the plan from the rate-limit tier and seat, not subscription
assert.strictEqual(planLabel(team, null), "Team");
assert.strictEqual(planLabel(null, null), null);
});
it("the profile endpoint's live plan wins over files written at sign-in", () => {
const { planFromProfile, rememberLivePlan, shownPlan } = profiles;
assert.strictEqual(
planFromProfile({ organization_type: "claude_max", rate_limit_tier: "default_claude_max_5x" }),
"Max 5x"
);
assert.strictEqual(
planFromProfile({
organization_type: "claude_team",
rate_limit_tier: "default_raven",
seat_tier: "team_standard",
}),
"Team Standard"
);
assert.strictEqual(planFromProfile({ organization_type: "claude_pro" }), "Pro");
assert.strictEqual(planFromProfile(null), null);
// Signed in on Pro, upgraded to Max 5x since: the files still say Pro.
const oauth = { subscriptionType: "pro", rateLimitTier: "default_claude_pro" };
const account = { accountUuid: "acct-upgraded" };
assert.strictEqual(shownPlan(oauth, account), "Pro");
rememberLivePlan("acct-upgraded", "Max 5x");
assert.strictEqual(shownPlan(oauth, account), "Max 5x");
rememberLivePlan("acct-upgraded", null); // an unknown answer keeps the last one
assert.strictEqual(shownPlan(oauth, account), "Max 5x");
assert.strictEqual(shownPlan(oauth, { accountUuid: "other" }), "Pro");
});
+3
View File
@@ -26,6 +26,7 @@ const pty = require("../lib/pty-run");
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
const realFetchUsage = ccUsage.fetchUsage;
const realFetchOwner = ccUsage.fetchOwner;
function run(id, account, { status = "completed", session = null, started, ended = null } = {}) {
db.prepare(
@@ -61,10 +62,12 @@ before(() => {
ccUsage.fetchUsage = async () => {
throw Object.assign(new Error("offline"), { code: "EUNAUTHORIZED" });
};
ccUsage.fetchOwner = async () => null;
});
after(() => {
ccUsage.fetchUsage = realFetchUsage;
ccUsage.fetchOwner = realFetchOwner;
tmux.__setExecImpl(null);
fs.rmSync(TMP, { recursive: true, force: true });
});
@@ -30,6 +30,7 @@ const claudePool = require("../lib/claude-pool");
const pins = require("../lib/account-pins");
require("../lib/cc-usage").fetchUsage = async () => ({});
require("../lib/cc-usage").fetchOwner = async () => null;
const KEY = (c) => `key_${c.repeat(40)}`;
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
+1
View File
@@ -30,6 +30,7 @@ const claudePool = require("../lib/claude-pool");
const pins = require("../lib/account-pins");
require("../lib/cc-usage").fetchUsage = async () => ({});
require("../lib/cc-usage").fetchOwner = async () => null;
const BOSS_PASS = "admin-pass-1";
const KEY = (c) => `key_${c.repeat(40)}`;
+59
View File
@@ -170,6 +170,65 @@ test("a profile holding another account's token shows EWRONGACCOUNT, not that ac
}
});
test("the live plan is the profile endpoint's, not the one written at sign-in", async () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { quotas, __resetCache } = require("../lib/quota");
const root = fs.mkdtempSync(path.join(os.tmpdir(), "quota-plan-"));
const home = path.join(root, ".claude");
fs.mkdirSync(home, { recursive: true });
// Signed in on Pro; upgraded to Max 5x since — the files never learned.
fs.writeFileSync(
path.join(home, ".credentials.json"),
JSON.stringify({
claudeAiOauth: {
accessToken: "live-upgraded",
expiresAt: Date.now() + 3600_000,
subscriptionType: "pro",
rateLimitTier: "default_claude_pro",
},
})
);
fs.writeFileSync(
`${home}.json`,
JSON.stringify({ oauthAccount: { accountUuid: "uuid-up", emailAddress: "up@x.y" } })
);
const prev = { home: process.env.CLAUDE_HOME, fetch: global.fetch };
process.env.CLAUDE_HOME = home;
global.fetch = async (url) => ({
status: 200,
ok: true,
json: async () =>
url.endsWith("/profile")
? {
account: { uuid: "uuid-up", email: "up@x.y" },
organization: {
organization_type: "claude_max",
rate_limit_tier: "default_claude_max_5x",
},
}
: { five_hour: { utilization: 10, resets_at: null } },
});
try {
__resetCache();
const live = (await quotas()).tools.find((t) => t.id === "claude" && t.active);
assert.strictEqual(live.plan, "Max 5x");
// Remembered: a later read from the files alone says the same.
const { shownPlan } = require("../lib/cc-profiles");
assert.strictEqual(
shownPlan({ subscriptionType: "pro" }, { accountUuid: "uuid-up" }),
"Max 5x"
);
} finally {
if (prev.home === undefined) delete process.env.CLAUDE_HOME;
else process.env.CLAUDE_HOME = prev.home;
global.fetch = prev.fetch;
__resetCache();
fs.rmSync(root, { recursive: true, force: true });
}
});
test("the live row is the account its token belongs to, not the one .claude.json names", async () => {
const fs = require("node:fs");
const os = require("node:os");
@@ -29,6 +29,7 @@ const cursorPool = require("../lib/cursor-pool");
// The quota read would go to the network; the pool's list must not.
require("../lib/cc-usage").fetchUsage = async () => ({});
require("../lib/cc-usage").fetchOwner = async () => null;
let server;
let BASE;
@@ -26,6 +26,7 @@ const auth = require("../lib/auth");
// The quota read would go to the network.
require("../lib/cc-usage").fetchUsage = async () => ({});
require("../lib/cc-usage").fetchOwner = async () => null;
let server;
let BASE;