From 67b556d35410f917b879dddf7e0ab2116ede1d8d Mon Sep 17 00:00:00 2001 From: "rubrot[bot]" <315953720+rubrot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:47:22 +0000 Subject: [PATCH 1/2] a personal and a team claude plan on one email are two accounts, fixes #26 --- CHANGELOG.md | 1 + package.json | 2 +- src/claude.test.ts | 63 ++++++++++++++++++- src/claude.ts | 67 +++++++++++++++------ src/cli.ts | 23 ++++--- src/codex.ts | 4 +- src/domain.ts | 54 ++++++++++++++++- src/storage.test.ts | 143 ++++++++++++++++++++++++++++++++++++++++++++ src/storage.ts | 21 +++---- 9 files changed, 330 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d18521f..4ce5b76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- [2026-10-02] a personal and a team claude plan on one email are two accounts, fixes #26 - [2026-10-01] mac app polish - [2026-09-30] proxy refuses web pages - [2026-09-30] a native macOS app for tokenmaxx, with a menu bar item diff --git a/package.json b/package.json index be8322c..3df348a 100644 --- a/package.json +++ b/package.json @@ -63,5 +63,5 @@ "post-commit": "bun x @rubriclab/package post-commit" }, "type": "module", - "version": "0.0.78" + "version": "0.0.79" } diff --git a/src/claude.test.ts b/src/claude.test.ts index cd63b36..f3e356a 100644 --- a/src/claude.test.ts +++ b/src/claude.test.ts @@ -67,7 +67,8 @@ describe('registerClaudeAccount', () => { vault }) expect(account.identity).toBe('lennard@example.com') - expect(account.externalAccountId).toBe('account-uuid') + expect(account.externalAccountId).toBe('org-uuid') + expect(account.externalUserId).toBe('account-uuid') expect(vault.items.get(account.secretReference ?? '')).toBe(JSON.stringify(stored)) }) }) @@ -257,3 +258,63 @@ describe('claude banked resets on the wire', () => { expect(outcome).toEqual({ code: 'reset', windowsReset: 3 }) }) }) + +describe('one claude login with a personal and a team subscription', () => { + // a fresh access token per probe, so the verified-identity cache never answers for another test + const fresh = { ...stored, expiresAt: Date.now() + 3_600_000 } + const usage = { five_hour: { utilization: 10 }, seven_day: { utilization: 20 } } + const teamAccount: Extract = { + auth: 'oauth', + createdAt: '2026-09-01T00:00:00.000Z', + enabled: true, + externalAccountId: 'team-org', + externalUserId: 'user-uuid', + health: 'ready', + id: '00000000-0000-4000-8000-000000000401', + identity: 'dev@example.com', + label: 'dev@example.com (team)', + onThreshold: 'switch', + plan: 'team', + profilePath: null, + provider: 'anthropic', + secretReference: reference, + updatedAt: '2026-09-01T00:00:00.000Z' + } + const probe = (account: Extract, organization: string) => + probeClaude({ + account, + fetchImplementation: async request => + Response.json( + String(request).includes('/profile') + ? { + account: { email: 'dev@example.com', uuid: 'user-uuid' }, + organization: { uuid: organization } + } + : usage + ), + now: () => new Date(), + vault: memoryVault({ + [reference]: JSON.stringify({ ...fresh, accessToken: crypto.randomUUID() }) + }) + }) + + test('a row saved before organizations were recorded gets its organization on the next probe', async () => { + const legacy = { ...teamAccount, externalAccountId: 'user-uuid', externalUserId: null } + const result = await probe({ ...legacy, label: 'dev@example.com' }, 'personal-org') + expect(result.account.externalAccountId).toBe('personal-org') + expect(result.account.externalUserId).toBe('user-uuid') + expect(result.account.label).toBe('dev@example.com') + }) + + test('a probe keeps the qualified label of the second subscription', async () => { + const result = await probe(teamAccount, 'team-org') + expect(result.account.label).toBe('dev@example.com (team)') + expect(result.account.identity).toBe('dev@example.com') + }) + + test('a credential that now answers for another organization is flagged', async () => { + expect(probe(teamAccount, 'personal-org')).rejects.toMatchObject({ + code: 'IDENTITY_CHANGED' + }) + }) +}) diff --git a/src/claude.ts b/src/claude.ts index 70f2298..0edfea2 100644 --- a/src/claude.ts +++ b/src/claude.ts @@ -11,6 +11,7 @@ import { type ResetCreditCounts, type ResetCreditsView, type ResetOutcome, + relabel, type UsageSnapshot, type UsageWindow } from './domain.ts' @@ -315,10 +316,16 @@ export async function refreshClaudeCredential(input: { }) } +interface ClaudeProfile { + email: string | null + organizationId: string | null + userId: string +} + async function fetchClaudeProfile( accessToken: string, fetchImplementation: FetchImplementation = fetch -): Promise<{ accountId: string; email: string | null; organizationId: string | null }> { +): Promise { const response = await fetchImplementation(profileEndpoint, { headers: { Authorization: `Bearer ${accessToken}`, @@ -336,19 +343,33 @@ async function fetchClaudeProfile( ) } const profile = ProfileSchema.parse(await response.json()) - const accountId = profile.account?.uuid ?? profile.uuid - if (accountId === undefined) { + const userId = profile.account?.uuid ?? profile.uuid + if (userId === undefined) { throw new ApplicationError('ACCOUNT_ID_MISSING', 'Claude profile response has no account id') } return { - accountId, email: profile.account?.email_address ?? profile.account?.email ?? profile.email_address ?? profile.email ?? null, - organizationId: profile.organization?.uuid ?? null + organizationId: profile.organization?.uuid ?? null, + userId + } +} + +// One Claude login can hold several subscriptions (a personal plan and a team plan), each its +// own organization, so an account is the (organization, user) pair, stored the way codex +// stores (workspace, user). Rows saved before this hold only the user id in externalAccountId. +function claudeExternalIds( + profile: ClaudeProfile, + account?: Extract +): { externalAccountId: string | null; externalUserId: string } { + const storedOrganizationId = account?.externalUserId == null ? null : account.externalAccountId + return { + externalAccountId: profile.organizationId ?? storedOrganizationId, + externalUserId: profile.userId } } @@ -386,8 +407,7 @@ export async function registerClaudeAccount(input: { auth: 'oauth', createdAt: now, enabled: true, - externalAccountId: profile.accountId, - externalUserId: null, + ...claudeExternalIds(profile), health: 'ready', id, identity: email.data, @@ -817,20 +837,29 @@ function refreshTokenHealth( function assertIdentity( account: Extract, - accountId: string + profile: ClaudeProfile ): void { - if (account.externalAccountId !== null && account.externalAccountId !== accountId) { + const userId = account.externalUserId ?? account.externalAccountId + if (userId !== null && userId !== profile.userId) { throw new ApplicationError( 'IDENTITY_CHANGED', 'Stored Claude credential belongs to a different account' ) } + if ( + account.externalUserId !== null && + account.externalAccountId !== null && + profile.organizationId !== null && + account.externalAccountId !== profile.organizationId + ) { + throw new ApplicationError( + 'IDENTITY_CHANGED', + 'Stored Claude credential belongs to a different organization' + ) + } } -const verifiedIdentities = new Map< - string, - { accessToken: string; accountId: string; email: string | null } ->() +const verifiedIdentities = new Map() export async function probeClaude(input: { account: Extract @@ -877,10 +906,10 @@ export async function probeClaude(input: { 'Claude credential does not include the user:profile scope required for usage' ) } - const verifyIdentity = async (): Promise<{ accountId: string; email: string | null }> => { + const verifyIdentity = async (): Promise => { const cached = verifiedIdentities.get(reference) if (cached !== undefined && cached.accessToken === credential.accessToken) { - return { accountId: cached.accountId, email: cached.email } + return { email: cached.email, organizationId: cached.organizationId, userId: cached.userId } } const fetched = await fetchClaudeProfile(credential.accessToken, fetchImplementation) verifiedIdentities.set(reference, { accessToken: credential.accessToken, ...fetched }) @@ -893,7 +922,7 @@ export async function probeClaude(input: { credential = await refresh(credential.accessToken) return verifyIdentity() }) - assertIdentity(account, profile.accountId) + assertIdentity(account, profile) const usage = await fetchClaudeUsage({ accessToken: credential.accessToken, accountId: account.id, @@ -910,14 +939,14 @@ export async function probeClaude(input: { fetchImplementation }) }) - assertIdentity(account, profile.accountId) + assertIdentity(account, profile) const email = AccountEmailSchema.safeParse(profile.email) return { account: { ...account, + ...claudeExternalIds(profile, account), + ...relabel(account, email.success ? email.data : account.identity), health: refreshTokenHealth(credential.refreshTokenExpiresAt, input.now()), - identity: email.success ? email.data : account.identity, - label: email.success ? email.data : account.label, plan: claudePlanTier(credential) ?? account.plan ?? null, updatedAt: input.now().toISOString() }, diff --git a/src/cli.ts b/src/cli.ts index d5b8ca6..e8f7fd9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,7 +16,15 @@ import { uninstallPiConfig, uninstallProviderConfig } from './config-install.ts' -import { type Account, PROVIDERS, type ProviderId, ProviderIdSchema } from './domain.ts' +import { + type Account, + distinctLabel, + PROVIDERS, + type ProviderId, + ProviderIdSchema, + relabel, + sameExternalIdentity +} from './domain.ts' import { ApplicationError, errorMessage } from './errors.ts' import { registerGrokAccount } from './grok.ts' import { @@ -641,22 +649,19 @@ async function promptApiKey( async function signInOauth(context: ApplicationContext, provider: ProviderId): Promise { const authenticated = await registerIsolatedAccount(provider) - const existing = context.store - .listAccounts(provider) - .find( - candidate => - candidate.externalAccountId !== null && - candidate.externalAccountId === authenticated.externalAccountId - ) - const account = + const stored = context.store.listAccounts(provider) + const existing = stored.find(candidate => sameExternalIdentity(candidate, authenticated)) + const signedIn = existing === undefined ? authenticated : { ...authenticated, + ...relabel(existing, authenticated.identity), createdAt: existing.createdAt, enabled: existing.enabled, id: existing.id } + const account = { ...signedIn, label: distinctLabel(signedIn, stored) } try { await requestAccountSave(context.paths.managerSocket, account, { profilePath: existing?.profilePath ?? null, diff --git a/src/codex.ts b/src/codex.ts index c916399..7d06f74 100644 --- a/src/codex.ts +++ b/src/codex.ts @@ -7,6 +7,7 @@ import { type ProviderProbeResult, type ResetCreditsView, type ResetOutcome, + relabel, type UsageSnapshot, type UsageWindow } from './domain.ts' @@ -666,8 +667,7 @@ export async function probeCodex(input: { externalAccountId: identity.accountId, externalUserId: identity.userId, health: 'ready', - identity: email.success ? email.data : account.identity, - label: email.success ? email.data : account.label, + ...relabel(account, email.success ? email.data : account.identity), plan: identity.plan ?? account.plan ?? null, updatedAt: input.now().toISOString() }, diff --git a/src/domain.ts b/src/domain.ts index 4a62a45..fc8422a 100644 --- a/src/domain.ts +++ b/src/domain.ts @@ -54,7 +54,7 @@ export const AccountSchema = z secretReference: z.string().trim().min(1) }).strict(), AccountFieldsSchema.extend({ - externalUserId: z.null().default(null), + externalUserId: z.string().trim().min(1).nullable().default(null), profilePath: z.string().trim().min(1).nullable(), provider: z.literal('anthropic'), secretReference: z.string().trim().min(1).nullable() @@ -66,8 +66,8 @@ export const AccountSchema = z secretReference: z.string().trim().min(1) }).strict() ]) - .refine(account => account.label === account.identity, { - message: 'Account label must equal its identity', + .refine(account => isLabelFor(account.identity, account.label), { + message: 'Account label must equal its identity, optionally with a qualifier', path: ['label'] }) .refine( @@ -79,6 +79,54 @@ export const AccountSchema = z ) export type Account = z.infer +function qualifiedLabel(identity: string, qualifier: string): string { + return `${identity} (${qualifier})` +} + +function isLabelFor(identity: string, label: string): boolean { + return label === identity || (label.startsWith(`${identity} (`) && label.endsWith(')')) +} + +// Rows saved before user ids were recorded know a single id: the ChatGPT workspace for +// openai, the Claude user for anthropic. Those rows match on that id alone. +function singleExternalId(account: Account): string | null { + return account.provider === 'anthropic' + ? (account.externalUserId ?? account.externalAccountId) + : account.externalAccountId +} + +export function sameExternalIdentity(left: Account, right: Account): boolean { + if (left.provider !== right.provider) { + return false + } + if (left.externalUserId !== null && right.externalUserId !== null) { + return ( + left.externalAccountId === right.externalAccountId && + left.externalUserId === right.externalUserId + ) + } + const id = singleExternalId(left) + return id !== null && id === singleExternalId(right) +} + +export function distinctLabel(account: Account, stored: readonly Account[]): string { + const taken = new Set( + stored + .filter(other => other.provider === account.provider && other.id !== account.id) + .map(other => other.label) + ) + const qualifiers = [ + account.plan?.replace(/^default_(claude_)?/, ''), + account.externalAccountId?.slice(0, 8), + account.id.slice(0, 8) + ].flatMap(qualifier => (qualifier ? [qualifiedLabel(account.identity, qualifier)] : [])) + return [account.label, ...qualifiers].find(label => !taken.has(label)) ?? account.label +} + +export function relabel(account: Account, identity: string): { identity: string; label: string } { + return { identity, label: identity === account.identity ? account.label : identity } +} + const UsageWindowSchema = z .object({ id: z.string().trim().min(1), diff --git a/src/storage.test.ts b/src/storage.test.ts index 3e7ca50..d5826db 100644 --- a/src/storage.test.ts +++ b/src/storage.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from 'bun:test' import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { type Account, distinctLabel, sameExternalIdentity } from './domain.ts' import { createStateStore } from './storage.ts' describe('a database with rows this build cannot read', () => { @@ -47,3 +48,145 @@ describe('a database with rows this build cannot read', () => { store.close() }) }) + +describe('claude accounts are one per organization and user', () => { + const claude = (n: number, fields: Partial>) => + ({ + auth: 'oauth', + createdAt: '2026-07-01T00:00:00.000Z', + enabled: true, + externalAccountId: null, + externalUserId: null, + health: 'ready', + id: `00000000-0000-4000-8000-00000000050${n}`, + identity: 'dev@example.com', + label: 'dev@example.com', + onThreshold: 'switch', + plan: 'max', + profilePath: null, + provider: 'anthropic', + secretReference: `claude:${n}`, + updatedAt: '2026-07-01T00:00:00.000Z', + ...fields + }) satisfies Account + + test('a database from before keeps its row and takes a second subscription of the same user', () => { + const path = join(mkdtempSync(join(tmpdir(), 'tokenmaxx-store-')), 'state.sqlite') + const legacy = claude(1, { externalAccountId: 'user-uuid' }) + const seed = createStateStore(path) + seed.saveAccount(legacy) + seed.close() + const database = new Database(path) + database.exec('DROP INDEX accounts_anthropic_external_user') + database.exec( + "CREATE UNIQUE INDEX accounts_anthropic_external ON accounts(external_account_id) WHERE provider = 'anthropic' AND external_account_id IS NOT NULL" + ) + database.close() + + const store = createStateStore(path) + expect(store.listAccounts('anthropic')).toEqual([legacy]) + const backfilled = { ...legacy, externalAccountId: 'personal-org', externalUserId: 'user-uuid' } + store.saveAccount(backfilled) + const team = claude(2, { + externalAccountId: 'team-org', + externalUserId: 'user-uuid', + label: 'dev@example.com (team)', + plan: 'team' + }) + store.saveAccount(team) + expect(store.listAccounts('anthropic')).toEqual([backfilled, team]) + expect(() => + store.saveAccount({ + ...team, + id: '00000000-0000-4000-8000-000000000503', + identity: 'x@y.com', + label: 'x@y.com' + }) + ).toThrow('Account conflicts') + store.close() + }) + + test('a row without its organization still collides with a new row of the same user', () => { + const store = createStateStore( + join(mkdtempSync(join(tmpdir(), 'tokenmaxx-store-')), 'state.sqlite') + ) + store.saveAccount(claude(1, { externalAccountId: 'user-uuid' })) + expect(() => + store.saveAccount( + claude(2, { + externalAccountId: 'team-org', + externalUserId: 'user-uuid', + label: 'dev@example.com (team)' + }) + ) + ).toThrow('Account conflicts') + store.close() + }) +}) + +describe('sign-in identity and labels', () => { + const base = { + auth: 'oauth', + createdAt: '2026-07-01T00:00:00.000Z', + enabled: true, + health: 'ready', + identity: 'dev@example.com', + label: 'dev@example.com', + onThreshold: 'switch', + profilePath: null, + updatedAt: '2026-07-01T00:00:00.000Z' + } as const + const personal: Account = { + ...base, + externalAccountId: 'personal-org', + externalUserId: 'user-uuid', + id: '00000000-0000-4000-8000-000000000601', + plan: 'max', + provider: 'anthropic', + secretReference: 'claude:1' + } + const team: Account = { + ...personal, + externalAccountId: 'team-org', + id: '00000000-0000-4000-8000-000000000602', + plan: 'default_claude_team', + secretReference: 'claude:2' + } + + test('the same user in another organization is a new account, not a re-login', () => { + expect(sameExternalIdentity(personal, team)).toBe(false) + expect(sameExternalIdentity(personal, { ...personal, id: team.id })).toBe(true) + }) + + test('a row saved before organizations were recorded matches its user once', () => { + const legacy = { ...personal, externalAccountId: 'user-uuid', externalUserId: null } + expect(sameExternalIdentity(legacy, team)).toBe(true) + }) + + test('codex users sharing one workspace stay separate accounts', () => { + const codex: Account = { + ...base, + externalAccountId: 'workspace', + externalUserId: 'user-a', + id: '00000000-0000-4000-8000-000000000603', + plan: 'team', + provider: 'openai', + secretReference: 'codex:1' + } + expect(sameExternalIdentity(codex, { ...codex, externalUserId: 'user-b' })).toBe(false) + expect(sameExternalIdentity(codex, { ...codex, externalUserId: null })).toBe(true) + }) + + test('the label only gains a qualifier when the email is already taken', () => { + expect(distinctLabel(personal, [])).toBe('dev@example.com') + expect(distinctLabel(personal, [personal])).toBe('dev@example.com') + expect(distinctLabel(team, [personal])).toBe('dev@example.com (team)') + const sameplan = { ...team, plan: 'max' } + expect( + distinctLabel(sameplan, [ + personal, + { ...sameplan, id: personal.id, label: 'dev@example.com (max)' } + ]) + ).toBe('dev@example.com (team-org)') + }) +}) diff --git a/src/storage.ts b/src/storage.ts index 8bbc2aa..e25a451 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -13,6 +13,7 @@ import { ProviderStateSchema, type SwitchRecord, SwitchRecordSchema, + sameExternalIdentity, type TokenEvent, TokenEventSchema, type UsageSnapshot, @@ -254,6 +255,7 @@ function migrate(database: Database): void { try { database.exec(` DROP INDEX IF EXISTS accounts_provider_external; + DROP INDEX IF EXISTS accounts_anthropic_external; CREATE UNIQUE INDEX IF NOT EXISTS accounts_provider_label ON accounts(provider, label); CREATE UNIQUE INDEX IF NOT EXISTS accounts_openai_external_user @@ -261,9 +263,11 @@ function migrate(database: Database): void { WHERE provider = 'openai' AND external_account_id IS NOT NULL AND external_user_id IS NOT NULL; - CREATE UNIQUE INDEX IF NOT EXISTS accounts_anthropic_external - ON accounts(external_account_id) - WHERE provider = 'anthropic' AND external_account_id IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS accounts_anthropic_external_user + ON accounts(external_account_id, external_user_id) + WHERE provider = 'anthropic' + AND external_account_id IS NOT NULL + AND external_user_id IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS accounts_xai_external ON accounts(external_account_id) WHERE provider = 'xai' AND external_account_id IS NOT NULL; @@ -334,16 +338,7 @@ export function createStateStore(databasePath: string): StateStore { const duplicate = listAccounts(parsed.provider).find( candidate => candidate.id !== parsed.id && - (candidate.label === parsed.label || - (parsed.provider === 'openai' && - candidate.provider === 'openai' && - parsed.externalAccountId !== null && - parsed.externalUserId !== null && - candidate.externalAccountId === parsed.externalAccountId && - candidate.externalUserId === parsed.externalUserId) || - (parsed.provider !== 'openai' && - parsed.externalAccountId !== null && - candidate.externalAccountId === parsed.externalAccountId)) + (candidate.label === parsed.label || sameExternalIdentity(candidate, parsed)) ) if (duplicate !== undefined) { throw new ApplicationError( From 4b2073d1e1511c590c1067b0c0628c8adbf605a9 Mon Sep 17 00:00:00 2001 From: "rubrot[bot]" <315953720+rubrot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:49:14 +0000 Subject: [PATCH 2/2] await the rejected org probe in the claude test --- src/claude.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/claude.test.ts b/src/claude.test.ts index f3e356a..e27ab28 100644 --- a/src/claude.test.ts +++ b/src/claude.test.ts @@ -313,7 +313,7 @@ describe('one claude login with a personal and a team subscription', () => { }) test('a credential that now answers for another organization is flagged', async () => { - expect(probe(teamAccount, 'personal-org')).rejects.toMatchObject({ + await expect(probe(teamAccount, 'personal-org')).rejects.toMatchObject({ code: 'IDENTITY_CHANGED' }) })