From 7a14aaab852432d89bff215e7ee913577a1f26e3 Mon Sep 17 00:00:00 2001 From: Caio Figueiroa <334500047+shipsfromrio@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:22:20 -0300 Subject: [PATCH 1/3] fix(claude): show a Team seat's own tier and name a member with no seat --- changelog.d/fixes/claude-seat-tier.md | 1 + open-sse/executors/claudeIdentity.ts | 9 ++ open-sse/services/genericQuotaFetcher.ts | 6 +- open-sse/services/usage/claude.ts | 16 ++ .../usage/components/ProviderLimits/utils.tsx | 3 +- src/lib/oauth/providers/claude.ts | 4 + src/lib/oauth/utils/claudeAuthImport.ts | 34 +++- src/lib/usage/providerLimits.ts | 4 + tests/unit/claude-seat-tier.test.ts | 145 ++++++++++++++++++ 9 files changed, 215 insertions(+), 7 deletions(-) create mode 100644 changelog.d/fixes/claude-seat-tier.md create mode 100644 tests/unit/claude-seat-tier.test.ts diff --git a/changelog.d/fixes/claude-seat-tier.md b/changelog.d/fixes/claude-seat-tier.md new file mode 100644 index 000000000000..aeedd8f94520 --- /dev/null +++ b/changelog.d/fixes/claude-seat-tier.md @@ -0,0 +1 @@ +- **fix(claude):** a Claude Team connection now shows the seat's own tier (`user_rate_limit_tier`, e.g. `default_claude_max_5x`) instead of the organization's base tier (`organization_rate_limit_tier`, e.g. `default_raven`) in the usage plan label, the quota plan and the dashboard, and stores the seat (`seat_tier`) from the bootstrap, the token exchange and the auth-file import. The import reads the nested `oauth_account` bootstrap and keeps one account's connections in two organizations apart. A member with no seat assigned, whom every OAuth call refuses with `oauth_not_allowed_for_organization`, gets a usage message that names it instead of a bare HTTP 403. diff --git a/open-sse/executors/claudeIdentity.ts b/open-sse/executors/claudeIdentity.ts index 61fb974fc379..5b2d2cfbe2a5 100644 --- a/open-sse/executors/claudeIdentity.ts +++ b/open-sse/executors/claudeIdentity.ts @@ -153,6 +153,13 @@ export type ClaudeBootstrap = { organization_name: string | null; organization_type: string | null; organization_rate_limit_tier: string | null; + /** The account's own tier inside its org (a Team seat's tier), e.g. `default_claude_max_5x`. */ + user_rate_limit_tier: string | null; + /** + * Team seat, e.g. `team_standard` or `team_*_premium`. A member with no seat never + * sees it here: the bootstrap itself answers 403 oauth_not_allowed_for_organization. + */ + seat_tier: string | null; }; /** GET /api/claude_cli/bootstrap with the same headers real CLI uses. */ @@ -184,6 +191,8 @@ export async function fetchClaudeBootstrap(accessToken: string): Promise | null | undefined; - const plan = [bootstrap?.organization_rate_limit_tier, usageRecord.plan] + const plan = [ + bootstrap?.user_rate_limit_tier, + bootstrap?.organization_rate_limit_tier, + usageRecord.plan, + ] .filter((value): value is string => typeof value === "string") .map((value) => value.trim()) .find((value) => value.length > 0); diff --git a/open-sse/services/usage/claude.ts b/open-sse/services/usage/claude.ts index 883814dd0271..25e7369e2205 100644 --- a/open-sse/services/usage/claude.ts +++ b/open-sse/services/usage/claude.ts @@ -225,6 +225,8 @@ async function fetchClaudeUsage( typeof data.tier === "string" ? data.tier : null, typeof data.plan === "string" ? data.plan : null, typeof data.subscription_type === "string" ? data.subscription_type : null, + // A Team seat's own tier (e.g. max_5x) before the org's base tier (raven). + bootstrap?.user_rate_limit_tier, bootstrap?.organization_rate_limit_tier ) ?? undefined; @@ -272,6 +274,20 @@ async function fetchClaudeUsage( return { ...legacy, bootstrap: await bootstrapPromise }; } + if (oauthResponse.status === 403) { + const body = toRecord(await oauthResponse.json().catch(() => null)); + const errorCode = toRecord(toRecord(body.error).details).error_code; + // The org refuses OAuth for this account on every call, inference included. + // Measured cause on a Team: the member has no seat assigned. + if (errorCode === "oauth_not_allowed_for_organization") { + return { + message: + "Claude organization does not allow OAuth for this account (oauth_not_allowed_for_organization); on a Team this usually means the member has no seat assigned.", + bootstrap: await bootstrapPromise, + }; + } + } + return { message: `Claude connected. Usage endpoint returned HTTP ${oauthResponse.status}.`, bootstrap: await bootstrapPromise, diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx index d3785bce45e5..4abec29dd3f8 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx @@ -171,8 +171,9 @@ export function resolvePlanValue(plan, providerSpecificData, providerId) { psd.tier, psd.accountTier, // Claude OAuth bootstrap: rate_limit_tier has the Max 5x/20x multiplier. - psd.organizationRateLimitTier, + // The account's own (seat) tier first: a Team org's base tier is not the seat's. psd.rateLimitTier, + psd.organizationRateLimitTier, psd.organizationType, // Codex OAuth bootstrap: chatgpt_plan_type is captured at import time // (src/lib/oauth/services/codexImport.ts) and is the only source of the diff --git a/src/lib/oauth/providers/claude.ts b/src/lib/oauth/providers/claude.ts index dfb9e78deeea..bee08953cb2c 100644 --- a/src/lib/oauth/providers/claude.ts +++ b/src/lib/oauth/providers/claude.ts @@ -33,6 +33,8 @@ async function fetchClaudeBootstrap(accessToken) { organization_name: acct.organization_name || null, organization_type: acct.organization_type || null, organization_rate_limit_tier: acct.organization_rate_limit_tier || null, + user_rate_limit_tier: acct.user_rate_limit_tier || null, + seat_tier: acct.seat_tier || null, }; } catch { return null; @@ -191,6 +193,8 @@ export const claude = { if (bs.organization_type) providerSpecificData.organizationType = bs.organization_type; if (bs.organization_rate_limit_tier) providerSpecificData.organizationRateLimitTier = bs.organization_rate_limit_tier; + if (bs.user_rate_limit_tier) providerSpecificData.rateLimitTier = bs.user_rate_limit_tier; + if (bs.seat_tier) providerSpecificData.seatTier = bs.seat_tier; if (plan) providerSpecificData.plan = plan; const result: any = { diff --git a/src/lib/oauth/utils/claudeAuthImport.ts b/src/lib/oauth/utils/claudeAuthImport.ts index 2a5a44443663..25e096e04678 100644 --- a/src/lib/oauth/utils/claudeAuthImport.ts +++ b/src/lib/oauth/utils/claudeAuthImport.ts @@ -37,6 +37,7 @@ export interface EnrichedClaudeAuth extends ParsedClaudeAuth { organizationName: string | null; organizationType: string | null; rateLimitTier: string | null; + seatTier: string | null; } export interface CreateConnectionOptions { @@ -109,6 +110,7 @@ export async function enrichWithBootstrap( organizationName: null, organizationType: null, rateLimitTier: parsed.rateLimitTier, + seatTier: null, }; const controller = new AbortController(); @@ -131,13 +133,22 @@ export async function enrichWithBootstrap( return base; } - const body = toRecord(await res.json()); + const raw = toRecord(await res.json()); + // The live bootstrap nests the account under `oauth_account` (the shape + // fetchClaudeBootstrap reads); a flat body is still accepted. + const body = raw.oauth_account ? toRecord(raw.oauth_account) : raw; const accountUUID = toNonEmptyString(body.account_uuid); const organizationUUID = toNonEmptyString(body.organization_uuid); const organizationName = toNonEmptyString(body.organization_name); const organizationType = toNonEmptyString(body.organization_type); - const rateLimitTier = toNonEmptyString(body.rate_limit_tier) || parsed.rateLimitTier; + // The account's own (seat) tier first: the org's base tier and the CLI file's + // copy both differ from it once a Team admin moves seats. + const rateLimitTier = + toNonEmptyString(body.user_rate_limit_tier) || + toNonEmptyString(body.rate_limit_tier) || + parsed.rateLimitTier; + const seatTier = toNonEmptyString(body.seat_tier); const bootstrapEmail = toNonEmptyString(body.account_email); return { @@ -147,6 +158,7 @@ export async function enrichWithBootstrap( organizationName, organizationType, rateLimitTier, + seatTier, email: parsed.email || bootstrapEmail, }; } catch { @@ -160,15 +172,22 @@ export async function enrichWithBootstrap( // ──── Lookup ────────────────────────────────────────────────────────────────── export async function findExistingClaudeConnection( - accountUUID: string + accountUUID: string, + organizationUUID: string | null = null ): Promise { const connections = await getProviderConnections({ provider: "claude" }); const lower = accountUUID.toLowerCase(); + const org = organizationUUID?.toLowerCase() ?? null; return ( (connections.find((c) => { const psd = toRecord((c as JsonRecord).providerSpecificData); const stored = toNonEmptyString(psd.accountUUID); - return stored !== null && stored.toLowerCase() === lower; + if (stored === null || stored.toLowerCase() !== lower) return false; + // One accountUUID reaches its personal workspace and every Team org it + // belongs to; the org separates them. Like isMatchingOauthIdentity, the + // org decides only when both sides carry it. + const storedOrg = toNonEmptyString(psd.organizationUUID)?.toLowerCase() ?? null; + return !(org && storedOrg && org !== storedOrg); }) as JsonRecord | undefined) ?? null ); } @@ -181,7 +200,10 @@ export async function createConnectionFromAuthFile( ): Promise<{ connection: JsonRecord; created: boolean }> { // Duplicate detection by accountUUID (skipped when bootstrap failed) if (enriched.accountUUID) { - const existing = await findExistingClaudeConnection(enriched.accountUUID); + const existing = await findExistingClaudeConnection( + enriched.accountUUID, + enriched.organizationUUID + ); if (existing) { if (!options.overwriteExisting) { @@ -212,6 +234,7 @@ export async function createConnectionFromAuthFile( organizationName: enriched.organizationName, organizationType: enriched.organizationType, rateLimitTier: enriched.rateLimitTier, + ...(enriched.seatTier ? { seatTier: enriched.seatTier } : {}), scopes: enriched.scopes, subscriptionType: enriched.subscriptionType, bootstrapEmail: enriched.email, @@ -258,6 +281,7 @@ export async function createConnectionFromAuthFile( organizationName: enriched.organizationName, organizationType: enriched.organizationType, rateLimitTier: enriched.rateLimitTier, + ...(enriched.seatTier ? { seatTier: enriched.seatTier } : {}), scopes: enriched.scopes, subscriptionType: enriched.subscriptionType, bootstrapEmail: enriched.email, diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 7ae520a17d12..00a5dc8826d5 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -866,6 +866,10 @@ async function syncClaudeBootstrapIfNeeded( ["organization_name", "organizationName"], ["organization_type", "organizationType"], ["organization_rate_limit_tier", "organizationRateLimitTier"], + // The account's own tier: `rateLimitTier` is otherwise frozen at import (copied from the + // CLI credentials file) and goes stale when a Team admin moves seats. + ["user_rate_limit_tier", "rateLimitTier"], + ["seat_tier", "seatTier"], ]; const nextPsd: JsonRecord = { ...psd }; diff --git a/tests/unit/claude-seat-tier.test.ts b/tests/unit/claude-seat-tier.test.ts new file mode 100644 index 000000000000..a5a2ff7bec41 --- /dev/null +++ b/tests/unit/claude-seat-tier.test.ts @@ -0,0 +1,145 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// A Team seat's own tier (user_rate_limit_tier, e.g. max_5x) differs from the org's base +// tier (organization_rate_limit_tier, e.g. raven), and a member with no seat (seat_tier +// "unassigned") is refused on every OAuth call with oauth_not_allowed_for_organization. +// Shapes below follow /api/claude_cli/bootstrap and /api/oauth/usage as the API answers them. +const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-claude-seat-tier-")); +process.env.DATA_DIR = testDataDir; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; +process.env.APP_LOG_TO_FILE = "false"; + +const { createConnectionFromAuthFile, enrichWithBootstrap, parseAndValidateClaudeAuth } = + await import("../../src/lib/oauth/utils/claudeAuthImport.ts"); +const { fetchClaudeBootstrap } = await import("../../open-sse/executors/claudeIdentity.ts"); +const { getClaudeUsage, _resetClaudeUsagePollingState } = + await import("../../open-sse/services/usage/claude.ts"); +const { _resetClaudeOauthUsageCooldown } = + await import("../../open-sse/services/claudeUsageCooldown.ts"); +const { convertUsageToQuotaInfo } = await import("../../open-sse/services/genericQuotaFetcher.ts"); +import { cleanupTempDataDir } from "../_setup/tempDataDir.ts"; + +const originalFetch = globalThis.fetch; + +test.afterEach(() => { + globalThis.fetch = originalFetch; + _resetClaudeUsagePollingState(); + _resetClaudeOauthUsageCooldown(); +}); + +test.after(async () => { + await cleanupTempDataDir(testDataDir); +}); + +const seatedBootstrap = { + oauth_account: { + account_uuid: "00000000-0000-4000-8000-00000000000a", + account_email: "seat@example.invalid", + organization_uuid: "00000000-0000-4000-8000-00000000000b", + organization_name: "Unit Team", + organization_type: "claude_team", + organization_rate_limit_tier: "default_raven", + user_rate_limit_tier: "default_claude_max_5x", + seat_tier: "team_premium", + }, +}; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + +test("fetchClaudeBootstrap returns the seat and the account's own tier", async () => { + globalThis.fetch = (async () => json(seatedBootstrap)) as typeof fetch; + const bootstrap = await fetchClaudeBootstrap("unit-token"); + assert.equal(bootstrap?.user_rate_limit_tier, "default_claude_max_5x"); + assert.equal(bootstrap?.organization_rate_limit_tier, "default_raven"); + assert.equal(bootstrap?.seat_tier, "team_premium"); +}); + +test("import reads the nested bootstrap and prefers the seat tier over the CLI file's", async () => { + globalThis.fetch = (async () => json(seatedBootstrap)) as typeof fetch; + const parsed = parseAndValidateClaudeAuth({ + claudeAiOauth: { + accessToken: "unit-access", + refreshToken: "unit-refresh", + rateLimitTier: "default_claude_max_5x_stale", + }, + }); + const enriched = await enrichWithBootstrap(parsed); + assert.equal(enriched.organizationUUID, "00000000-0000-4000-8000-00000000000b"); + assert.equal(enriched.accountUUID, "00000000-0000-4000-8000-00000000000a"); + assert.equal(enriched.rateLimitTier, "default_claude_max_5x"); + assert.equal(enriched.seatTier, "team_premium"); +}); + +test("usage plan label is the seat's tier, not the org's base tier", async () => { + globalThis.fetch = (async (input) => + String(input).includes("bootstrap") + ? json(seatedBootstrap) + : json({ five_hour: { utilization: 10, resets_at: null } })) as typeof fetch; + const usage = (await getClaudeUsage("unit-token-plan")) as { + plan?: string; + }; + assert.equal(usage.plan, "default_claude_max_5x"); +}); + +test("usage names a Team member with no seat instead of a bare HTTP 403", async () => { + const refused = { + type: "error", + error: { + type: "permission_error", + message: "OAuth authentication is currently not allowed for this organization.", + details: { error_code: "oauth_not_allowed_for_organization" }, + }, + }; + globalThis.fetch = (async () => json(refused, 403)) as typeof fetch; + const usage = (await getClaudeUsage("unit-token-noseat")) as { + message?: string; + }; + assert.match(usage.message || "", /oauth_not_allowed_for_organization/); + assert.match(usage.message || "", /seat/); +}); + +test("import keeps one account's connections in two organizations apart", async () => { + const parsed = parseAndValidateClaudeAuth({ + claudeAiOauth: { accessToken: "unit-access-org", refreshToken: "unit-refresh-org" }, + }); + const base = { + ...parsed, + email: "two-orgs@example.invalid", + accountUUID: "00000000-0000-4000-8000-00000000000c", + organizationName: null, + organizationType: null, + seatTier: null, + }; + const orgD = "00000000-0000-4000-8000-00000000000d"; + const orgE = "00000000-0000-4000-8000-00000000000e"; + const first = await createConnectionFromAuthFile({ ...base, organizationUUID: orgD }, {}); + const second = await createConnectionFromAuthFile({ ...base, organizationUUID: orgE }, {}); + assert.equal(second.created, true); + assert.notEqual(second.connection.id, first.connection.id); + + const again = await createConnectionFromAuthFile( + { ...base, organizationUUID: orgE, seatTier: "team_premium" }, + { overwriteExisting: true } + ); + assert.equal(again.created, false); + assert.equal(again.connection.id, second.connection.id); + const psd = again.connection.providerSpecificData as Record; + assert.equal(psd.seatTier, "team_premium"); +}); + +test("quota plan prefers the seat's tier over the org's base tier", () => { + const quota = convertUsageToQuotaInfo( + { + plan: "fallback_plan", + quotas: { "session (5h)": { used: 10, total: 100, resetAt: null } }, + bootstrap: seatedBootstrap.oauth_account, + }, + { provider: "claude" } + ); + assert.equal(quota?.plan, "default_claude_max_5x"); +}); From 44d881c3084f50ff32dbfef39aec261dcd4882f1 Mon Sep 17 00:00:00 2001 From: Caio Figueiroa <334500047+shipsfromrio@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:12:33 -0300 Subject: [PATCH 2/3] refactor(claude): split import bootstrap and identity helpers to keep complexity flat --- src/lib/oauth/utils/claudeAuthImport.ts | 83 ++++++++++++++----------- 1 file changed, 47 insertions(+), 36 deletions(-) diff --git a/src/lib/oauth/utils/claudeAuthImport.ts b/src/lib/oauth/utils/claudeAuthImport.ts index 25e096e04678..74f22343fda9 100644 --- a/src/lib/oauth/utils/claudeAuthImport.ts +++ b/src/lib/oauth/utils/claudeAuthImport.ts @@ -98,6 +98,23 @@ export function parseAndValidateClaudeAuth(raw: unknown): ParsedClaudeAuth { // ──── Bootstrap enrichment ──────────────────────────────────────────────────── +// The live bootstrap nests the account under `oauth_account` (the shape +// fetchClaudeBootstrap reads); a flat body is still accepted. +function bootstrapAccount(raw: unknown): JsonRecord { + const doc = toRecord(raw); + return doc.oauth_account ? toRecord(doc.oauth_account) : doc; +} + +// The account's own (seat) tier first: the org's base tier and the CLI file's +// copy both differ from it once a Team admin moves seats. +function bootstrapRateLimitTier(body: JsonRecord, fileTier: string | null): string | null { + return ( + toNonEmptyString(body.user_rate_limit_tier) || + toNonEmptyString(body.rate_limit_tier) || + fileTier + ); +} + export async function enrichWithBootstrap( parsed: ParsedClaudeAuth, // proxyConfig reserved for future authenticated-proxy support @@ -133,33 +150,17 @@ export async function enrichWithBootstrap( return base; } - const raw = toRecord(await res.json()); - // The live bootstrap nests the account under `oauth_account` (the shape - // fetchClaudeBootstrap reads); a flat body is still accepted. - const body = raw.oauth_account ? toRecord(raw.oauth_account) : raw; - - const accountUUID = toNonEmptyString(body.account_uuid); - const organizationUUID = toNonEmptyString(body.organization_uuid); - const organizationName = toNonEmptyString(body.organization_name); - const organizationType = toNonEmptyString(body.organization_type); - // The account's own (seat) tier first: the org's base tier and the CLI file's - // copy both differ from it once a Team admin moves seats. - const rateLimitTier = - toNonEmptyString(body.user_rate_limit_tier) || - toNonEmptyString(body.rate_limit_tier) || - parsed.rateLimitTier; - const seatTier = toNonEmptyString(body.seat_tier); - const bootstrapEmail = toNonEmptyString(body.account_email); + const body = bootstrapAccount(await res.json()); return { ...base, - accountUUID, - organizationUUID, - organizationName, - organizationType, - rateLimitTier, - seatTier, - email: parsed.email || bootstrapEmail, + accountUUID: toNonEmptyString(body.account_uuid), + organizationUUID: toNonEmptyString(body.organization_uuid), + organizationName: toNonEmptyString(body.organization_name), + organizationType: toNonEmptyString(body.organization_type), + rateLimitTier: bootstrapRateLimitTier(body, parsed.rateLimitTier), + seatTier: toNonEmptyString(body.seat_tier), + email: parsed.email || toNonEmptyString(body.account_email), }; } catch { // Network error, timeout, or parse failure — best-effort; callers handle null fields @@ -176,22 +177,32 @@ export async function findExistingClaudeConnection( organizationUUID: string | null = null ): Promise { const connections = await getProviderConnections({ provider: "claude" }); - const lower = accountUUID.toLowerCase(); - const org = organizationUUID?.toLowerCase() ?? null; return ( - (connections.find((c) => { - const psd = toRecord((c as JsonRecord).providerSpecificData); - const stored = toNonEmptyString(psd.accountUUID); - if (stored === null || stored.toLowerCase() !== lower) return false; - // One accountUUID reaches its personal workspace and every Team org it - // belongs to; the org separates them. Like isMatchingOauthIdentity, the - // org decides only when both sides carry it. - const storedOrg = toNonEmptyString(psd.organizationUUID)?.toLowerCase() ?? null; - return !(org && storedOrg && org !== storedOrg); - }) as JsonRecord | undefined) ?? null + (connections.find((c) => + isSameClaudeIdentity( + toRecord((c as JsonRecord).providerSpecificData), + accountUUID, + organizationUUID + ) + ) as JsonRecord | undefined) ?? null ); } +// One accountUUID reaches its personal workspace and every Team org it belongs +// to; the org separates them. Like isMatchingOauthIdentity, the org decides only +// when both sides carry it. +function isSameClaudeIdentity( + psd: JsonRecord, + accountUUID: string, + organizationUUID: string | null +): boolean { + const stored = toNonEmptyString(psd.accountUUID); + if (stored === null || stored.toLowerCase() !== accountUUID.toLowerCase()) return false; + const storedOrg = toNonEmptyString(psd.organizationUUID)?.toLowerCase(); + const org = organizationUUID?.toLowerCase(); + return !org || !storedOrg || org === storedOrg; +} + // ──── Create / update connection ────────────────────────────────────────────── export async function createConnectionFromAuthFile( From d354aa1f6f73db973b4679fc0b2ca624b06ba5dc Mon Sep 17 00:00:00 2001 From: Caio Figueiroa <334500047+shipsfromrio@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:22:39 -0300 Subject: [PATCH 3/3] refactor(claude): share the import's written fields so the create/update function stays under the size ratchet --- src/lib/oauth/utils/claudeAuthImport.ts | 38 ++++++++++++------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/src/lib/oauth/utils/claudeAuthImport.ts b/src/lib/oauth/utils/claudeAuthImport.ts index 74f22343fda9..91959b16f275 100644 --- a/src/lib/oauth/utils/claudeAuthImport.ts +++ b/src/lib/oauth/utils/claudeAuthImport.ts @@ -205,6 +205,22 @@ function isSameClaudeIdentity( // ──── Create / update connection ────────────────────────────────────────────── +// The identity and plan fields an import writes, on a new connection and over an existing one. +function importedClaudeData(enriched: EnrichedClaudeAuth): JsonRecord { + return { + accountUUID: enriched.accountUUID, + organizationUUID: enriched.organizationUUID, + organizationName: enriched.organizationName, + organizationType: enriched.organizationType, + rateLimitTier: enriched.rateLimitTier, + ...(enriched.seatTier ? { seatTier: enriched.seatTier } : {}), + scopes: enriched.scopes, + subscriptionType: enriched.subscriptionType, + bootstrapEmail: enriched.email, + importedAt: new Date().toISOString(), + }; +} + export async function createConnectionFromAuthFile( enriched: EnrichedClaudeAuth, options: CreateConnectionOptions @@ -240,16 +256,7 @@ export async function createConnectionFromAuthFile( testStatus: "active", providerSpecificData: { ...toRecord(existing.providerSpecificData), - accountUUID: enriched.accountUUID, - organizationUUID: enriched.organizationUUID, - organizationName: enriched.organizationName, - organizationType: enriched.organizationType, - rateLimitTier: enriched.rateLimitTier, - ...(enriched.seatTier ? { seatTier: enriched.seatTier } : {}), - scopes: enriched.scopes, - subscriptionType: enriched.subscriptionType, - bootstrapEmail: enriched.email, - importedAt: new Date().toISOString(), + ...importedClaudeData(enriched), // #10143: preserve an already-persisted device identity across // re-imports so the connection doesn't present as a new device to // Anthropic on every process restart; only mint one if absent. @@ -287,16 +294,7 @@ export async function createConnectionFromAuthFile( isActive: true, testStatus: "active", providerSpecificData: { - accountUUID: enriched.accountUUID, - organizationUUID: enriched.organizationUUID, - organizationName: enriched.organizationName, - organizationType: enriched.organizationType, - rateLimitTier: enriched.rateLimitTier, - ...(enriched.seatTier ? { seatTier: enriched.seatTier } : {}), - scopes: enriched.scopes, - subscriptionType: enriched.subscriptionType, - bootstrapEmail: enriched.email, - importedAt: new Date().toISOString(), + ...importedClaudeData(enriched), // #10143: mint a persistent device identity so this imported // connection doesn't fall back to a lazy-random device id that // regenerates on every process restart (see resolveCliUserID).