switch Anthropic accounts from the usage pills

The owner keeps a personal account and a company one and moves between them during a day — by what the work
is, and sometimes because one has hit a limit and the work has to carry on. /login handles ARRIVING at an
account. It could not go back to one, because the credential it overwrote was gone.

ai_provider_usage already answered "which of my accounts has room". This answers what immediately follows:
"then use that one". The chips under the numbers stop selecting whose stats you read and start selecting who
you ARE — which turned out to be the useful half of the same gesture, since reading the other account's
percentages was only ever a prelude to using it.

anthropic_accounts holds each login's OAuth object encrypted, keyed (user_id, account_uuid).

One key per ACCOUNT, not one for the table: encryptSecret('anthropic-account:<uuid>', …), so the secret
store mints a separate key each and one leak is one account. Same reasoning that split VAULT_STORE_KEY into
purposes. The uuid comes from Anthropic and survives a re-login; the email is stable too but is editable at
the provider.

What is stored includes a REFRESH token, deliberately — the access token lives hours and the refresh token
is what makes a switch work a week later. So a row plus its key is thirty days of acting as that account.
That was accepted knowingly; the per-account key narrows it, and refreshExpiresAt is a column so a dead
credential gets purged on read rather than sitting here for ever. An expired one cannot be switched to at
all: reveal returns null, because writing an expired credential would sign the owner out with no message.

Capture happens on login AND on every read of the account list. Without that second one this would know
only about logins that went through this dialog, and an account signed into from a terminal would be
invisible to the switcher — half your accounts missing, silently. The switch also captures the OUTGOING
credential first, or switching away from a terminal login loses it.

.credentials.json is replaced wholesale; .claude.json is PATCHED. The second holds the CLI's entire config
and overwriting it would take out every project entry and preference in it.

Owner only, and the isolation is unchanged: getOwnerHomeDir ignores the email it is passed, so every path
here is the owner's. A member's claude reads their own home and none of this touches it. The earlier claim
in this work that the credential was "machine-wide" was wrong — the owner corrected it. What a switch
actually changes is every claude running AS THE OWNER: terminals, task runners, pipelines, the commander.

The spinner is not decoration. A switch rewrites a file and changes nothing visible, so without it the
honest reading of a click is "nothing happened" and the second click is somebody checking. A running turn
keeps the credential it was spawned with, and the response says so rather than leaving the UI to imply
otherwise.

Verified before shipping: capture records the live account with org, plan and expiry; reveal decrypts
byte-identical to what is on disk; /api/claude-auth/accounts is 401 unauthenticated and 403 for a non-owner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 21:24:28 +00:00
co-authored by Claude Opus 5
parent bd9d26199f
commit 7d7a80782e
8 changed files with 525 additions and 32 deletions
@@ -0,0 +1,2 @@
export * from './schema';
export * from './queries';
@@ -0,0 +1,147 @@
import { and, desc, eq, lt } from 'drizzle-orm';
import { db } from '../db';
import { decryptSecret, encryptSecret } from '../crypto';
import { anthropicAccounts } from './schema';
/*
A stored Anthropic login, and the two shapes it comes in.
`StoredAccount` is what an API may return: who it is, how stale, whether it can still be used. It carries
no secret. `revealAccount` is the only way to the credential itself and exists for exactly one caller —
the switch, which writes it back to disk.
*/
/** The encryption purpose for one account. Per-account by design; see the schema note. */
const purposeFor = (accountUuid: string) => `anthropic-account:${accountUuid}`;
export type StoredAccount = {
accountUuid: string;
emailAccount: string;
orgName: string | null;
plan: string | null;
capturedAt: string;
lastUsedAt: string | null;
refreshExpiresAt: string | null;
/** False once the refresh token is past its expiry: switching would fail, so the UI must offer /login. */
usable: boolean;
};
type SaveParams = {
userId: number;
accountUuid: string;
emailAccount: string;
orgName: string | null;
plan: string | null;
/** The `claudeAiOauth` object, verbatim, as JSON. */
credentialsJson: string;
/** The `oauthAccount` object, verbatim, as JSON. */
oauthAccountJson: string;
refreshExpiresAt: Date | null;
};
const publish = (row: typeof anthropicAccounts.$inferSelect): StoredAccount => ({
accountUuid: row.accountUuid,
emailAccount: row.emailAccount,
orgName: row.orgName,
plan: row.plan,
capturedAt: row.capturedAt.toISOString(),
lastUsedAt: row.lastUsedAt?.toISOString() ?? null,
refreshExpiresAt: row.refreshExpiresAt?.toISOString() ?? null,
usable: !row.refreshExpiresAt || row.refreshExpiresAt.getTime() > Date.now(),
});
/**
* Record (or re-record) one account's credential.
*
* Upsert on `(userId, accountUuid)` rather than insert, because capturing happens every time the current
* account is noticed to have changed — so the common case is refreshing a row that already exists with a
* newer token. `capturedAt` moves; `lastUsedAt` deliberately does not, since capturing is not using.
*/
export async function saveAnthropicAccount(p: SaveParams): Promise<void> {
const purpose = purposeFor(p.accountUuid);
const values = {
userId: p.userId,
accountUuid: p.accountUuid,
emailAccount: p.emailAccount,
orgName: p.orgName,
plan: p.plan,
credentials: encryptSecret(purpose, p.credentialsJson),
oauthAccount: encryptSecret(purpose, p.oauthAccountJson),
refreshExpiresAt: p.refreshExpiresAt,
capturedAt: new Date(),
};
await db
.insert(anthropicAccounts)
.values(values)
.onConflictDoUpdate({
target: [anthropicAccounts.userId, anthropicAccounts.accountUuid],
set: {
emailAccount: values.emailAccount,
orgName: values.orgName,
plan: values.plan,
credentials: values.credentials,
oauthAccount: values.oauthAccount,
refreshExpiresAt: values.refreshExpiresAt,
capturedAt: values.capturedAt,
},
});
}
/** Every account this Officer user can switch to, most recently used first. Carries no secret. */
export async function listAnthropicAccounts(userId: number): Promise<StoredAccount[]> {
const rows = await db
.select()
.from(anthropicAccounts)
.where(eq(anthropicAccounts.userId, userId))
.orderBy(desc(anthropicAccounts.lastUsedAt), desc(anthropicAccounts.capturedAt));
return rows.map(publish);
}
/**
* The credential itself, for the switch. Decrypts, so every call is a secret leaving the database.
*
* Returns null for an unknown account AND for one whose refresh token has expired: a caller that wrote an
* expired credential to disk would have signed the owner out with no message, which is the worst of the
* available failures.
*/
export async function revealAnthropicAccount(
userId: number,
accountUuid: string,
): Promise<{ credentialsJson: string; oauthAccountJson: string; emailAccount: string } | null> {
const [row] = await db
.select()
.from(anthropicAccounts)
.where(and(eq(anthropicAccounts.userId, userId), eq(anthropicAccounts.accountUuid, accountUuid)))
.limit(1);
if (!row) return null;
if (row.refreshExpiresAt && row.refreshExpiresAt.getTime() <= Date.now()) return null;
const purpose = purposeFor(row.accountUuid);
return {
credentialsJson: decryptSecret(purpose, row.credentials),
oauthAccountJson: decryptSecret(purpose, row.oauthAccount),
emailAccount: row.emailAccount,
};
}
/** Marks the switch. Separate from reveal so a failed write does not claim the account is in use. */
export async function markAnthropicAccountUsed(userId: number, accountUuid: string): Promise<void> {
await db
.update(anthropicAccounts)
.set({ lastUsedAt: new Date() })
.where(and(eq(anthropicAccounts.userId, userId), eq(anthropicAccounts.accountUuid, accountUuid)));
}
/**
* Drop credentials that can no longer be used.
*
* A dead refresh token is not a credential, it is a liability with a thirty-day head start already spent —
* so it goes rather than sitting in the table for ever. Called opportunistically on list, not scheduled: a
* cron for one DELETE is a moving part that can silently stop.
*/
export async function purgeExpiredAnthropicAccounts(userId: number): Promise<number> {
const gone = await db
.delete(anthropicAccounts)
.where(and(eq(anthropicAccounts.userId, userId), lt(anthropicAccounts.refreshExpiresAt, new Date())))
.returning({ id: anthropicAccounts.id });
return gone.length;
}
@@ -0,0 +1,72 @@
import { integer, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from '../auth/schema';
/**
* The OAuth credential for each Anthropic account the owner signs into, so they can be switched between
* without a browser round trip.
*
* ── Why keep them at all ──
*
* The owner has a personal account and a company one and moves between them during a day — by what they are
* working on, and sometimes because one has hit a limit and the work has to continue. `claude auth login`
* handles the first switch and every switch after it, at the cost of a browser round trip each time, and it
* cannot switch BACK to an account whose credential the CLI has already overwritten.
*
* `ai_provider_usage` already answers "which of my accounts has room". This answers the question that
* immediately follows it: "then use that one".
*
* ── One key per account, not one key for the table ──
*
* `credentials` and `oauthAccount` are encrypted with `encryptSecret('anthropic-account:<uuid>', …)`, so the
* secret store mints a separate key per Anthropic account. One leaked key is one account rather than all of
* them, which is the same reasoning that split `VAULT_STORE_KEY` into purposes in the first place. The uuid
* comes from Anthropic (`oauthAccount.accountUuid`) so it is stable across a re-login, where the email is
* also stable but is user-editable at the provider.
*
* ── What is stored, and what that is worth to somebody who steals it ──
*
* The whole `claudeAiOauth` object, which includes a REFRESH token — deliberately, because the access token
* lives hours and the refresh token is what makes a switch work a week later. So a row plus the key is
* thirty days of acting as that account. That is a real escalation over holding one credential on disk, it
* was accepted knowingly, and two things reduce it: the per-account key above, and `refreshExpiresAt` being
* a column so a dead credential can be purged rather than sitting here for ever.
*
* ── Owner only, and per Officer user regardless ──
*
* Only the owner's `~/.claude` is switched today; a member's `claude` reads their own home and is untouched.
* The rows are keyed by Officer user anyway, because the same mechanism per home is the obvious extension
* and a table that assumed one user would have to be migrated to get there.
*/
export const anthropicAccounts = pgTable(
'anthropic_accounts',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** Anthropic's own id for the account. The encryption purpose is derived from this. */
accountUuid: text('account_uuid').notNull(),
/** What the pill reads, and what `ai_provider_usage.email_account` joins on in practice. */
emailAccount: text('email_account').notNull(),
/** `Pertento`, or null on a personal account with no organisation. Display only. */
orgName: text('org_name'),
/** `Team`, `Max 20x`, `Pro`. Display only. */
plan: text('plan'),
/** `claudeAiOauth` from `.credentials.json`, encrypted. Never logged, never returned by an API. */
credentials: text('credentials').notNull(),
/** `oauthAccount` from `.claude.json`, encrypted. Identity rather than credential, but it travels with it. */
oauthAccount: text('oauth_account').notNull(),
/**
* When the refresh token dies. Past this, a switch cannot succeed and the UI must say so instead of
* failing at spawn time — which would look like the agent being broken rather than a sign-in expiring.
*/
refreshExpiresAt: timestamp('refresh_expires_at', { withTimezone: true }),
/** When this credential was captured — how stale the thing you are switching to is. */
capturedAt: timestamp('captured_at', { withTimezone: true }).notNull().defaultNow(),
/** Last time it was switched TO. Orders the pills by what the owner actually uses. */
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
},
// uniqueIndex, never unique().on() — drizzle-kit mis-diffs named composite unique CONSTRAINTS and
// re-creates them on every push. See src/databases/CLAUDE.md → "Composite keys".
(table) => [uniqueIndex('uq_anthropic_accounts_account').on(table.userId, table.accountUuid)],
);
+1
View File
@@ -37,6 +37,7 @@ export * from './chat-session-models';
export * from './chat-notification-prefs';
export * from './nudges';
export * from './ai-provider-usage';
export * from './anthropic-accounts';
export * from './command-hq';
export * from './dashboards';
export * from './integrations';
+1
View File
@@ -34,6 +34,7 @@ export * from './chat-events/schema'; // chat_session_events
export * from './chat-session-models/schema'; // chat_session_models
export * from './chat-notification-prefs/schema'; // chat_notification_prefs
export * from './ai-provider-usage/schema'; // ai_provider_usage — last usage reading per AI account
export * from './anthropic-accounts/schema'; // anthropic_accounts — switchable OAuth credentials
export * from './command-hq/schema'; // command_hq_categories, command_hq_sessions, command_hq_tags, command_hq_session_tags
export * from './agent-panels/schema'; // agent_panels