@modelprofile.com/authswitch 6.4.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/authority-contract.d.ts +302 -6
  3. package/dist_ts/authority-contract.js +1 -1
  4. package/dist_ts/authority-runtime-contract.d.ts +15 -0
  5. package/dist_ts/authorityrehearsal.child.d.ts +1 -0
  6. package/dist_ts/authorityrehearsal.child.js +395 -0
  7. package/dist_ts/classes.accountlist.js +4 -3
  8. package/dist_ts/classes.authoritybackup.d.ts +134 -0
  9. package/dist_ts/classes.authoritybackup.js +769 -0
  10. package/dist_ts/classes.authoritybroker.d.ts +29 -6
  11. package/dist_ts/classes.authoritybroker.js +413 -122
  12. package/dist_ts/classes.authoritycli.d.ts +24 -0
  13. package/dist_ts/classes.authoritycli.js +675 -0
  14. package/dist_ts/classes.authorityclient.d.ts +34 -9
  15. package/dist_ts/classes.authorityclient.js +136 -38
  16. package/dist_ts/classes.authoritydaemon.d.ts +47 -0
  17. package/dist_ts/classes.authoritydaemon.js +705 -16
  18. package/dist_ts/classes.authoritydatabase.d.ts +182 -8
  19. package/dist_ts/classes.authoritydatabase.js +1188 -41
  20. package/dist_ts/classes.authoritymodels.d.ts +247 -1
  21. package/dist_ts/classes.authoritymodels.js +870 -6
  22. package/dist_ts/classes.authoritypreuse.d.ts +47 -0
  23. package/dist_ts/classes.authoritypreuse.js +278 -0
  24. package/dist_ts/classes.authorityregistry.d.ts +24 -0
  25. package/dist_ts/classes.authorityregistry.js +88 -0
  26. package/dist_ts/classes.authorityrehearsal.d.ts +127 -0
  27. package/dist_ts/classes.authorityrehearsal.js +632 -0
  28. package/dist_ts/classes.authorityusage.d.ts +160 -0
  29. package/dist_ts/classes.authorityusage.js +429 -0
  30. package/dist_ts/classes.claudeauthority.d.ts +66 -0
  31. package/dist_ts/classes.claudeauthority.js +532 -0
  32. package/dist_ts/classes.claudecodeharness.js +3 -2
  33. package/dist_ts/classes.claudecodelocks.d.ts +13 -0
  34. package/dist_ts/classes.claudecodelocks.js +75 -3
  35. package/dist_ts/classes.claudenative.d.ts +137 -0
  36. package/dist_ts/classes.claudenative.js +731 -0
  37. package/dist_ts/classes.claudestatus.d.ts +9 -0
  38. package/dist_ts/classes.claudestatus.js +26 -9
  39. package/dist_ts/classes.claudetokenrefresh.d.ts +15 -1
  40. package/dist_ts/classes.claudetokenrefresh.js +74 -21
  41. package/dist_ts/classes.codexharness.js +4 -2
  42. package/dist_ts/classes.codexmanaged.d.ts +91 -0
  43. package/dist_ts/classes.codexmanaged.js +404 -0
  44. package/dist_ts/classes.codexpreuse.d.ts +8 -0
  45. package/dist_ts/classes.codexpreuse.js +24 -6
  46. package/dist_ts/classes.fileharness.d.ts +2 -0
  47. package/dist_ts/classes.fileharness.js +6 -4
  48. package/dist_ts/classes.limits.js +12 -7
  49. package/dist_ts/classes.opencodeharness.js +2 -2
  50. package/dist_ts/claudehttp.d.ts +10 -0
  51. package/dist_ts/claudehttp.js +29 -3
  52. package/dist_ts/index.d.ts +2 -0
  53. package/dist_ts/index.js +8 -1
  54. package/dist_ts/interfaces.harness.d.ts +6 -0
  55. package/dist_ts/interfaces.list.d.ts +9 -3
  56. package/dist_ts/plugins.d.ts +7 -3
  57. package/dist_ts/plugins.js +9 -4
  58. package/dist_ts/ts_migration/0001_authority_meta.d.ts +3 -0
  59. package/dist_ts/ts_migration/0001_authority_meta.js +32 -0
  60. package/dist_ts/ts_migration/index.d.ts +3 -0
  61. package/dist_ts/ts_migration/index.js +6 -0
  62. package/package.json +12 -2
  63. package/readme.md +205 -13
  64. package/ts/00_commitinfo_data.ts +1 -1
  65. package/ts/authority-contract.ts +264 -9
  66. package/ts/authority-runtime-contract.ts +21 -1
  67. package/ts/authorityrehearsal.child.ts +330 -0
  68. package/ts/classes.accountlist.ts +3 -2
  69. package/ts/classes.authoritybackup.ts +780 -0
  70. package/ts/classes.authoritybroker.ts +410 -120
  71. package/ts/classes.authoritycli.ts +719 -0
  72. package/ts/classes.authorityclient.ts +164 -38
  73. package/ts/classes.authoritydaemon.ts +614 -17
  74. package/ts/classes.authoritydatabase.ts +1229 -44
  75. package/ts/classes.authoritymodels.ts +579 -4
  76. package/ts/classes.authoritypreuse.ts +297 -0
  77. package/ts/classes.authorityregistry.ts +118 -0
  78. package/ts/classes.authorityrehearsal.ts +625 -0
  79. package/ts/classes.authorityusage.ts +527 -0
  80. package/ts/classes.claudeauthority.ts +541 -0
  81. package/ts/classes.claudecodeharness.ts +2 -1
  82. package/ts/classes.claudecodelocks.ts +70 -1
  83. package/ts/classes.claudenative.ts +816 -0
  84. package/ts/classes.claudestatus.ts +38 -9
  85. package/ts/classes.claudetokenrefresh.ts +82 -19
  86. package/ts/classes.codexharness.ts +3 -1
  87. package/ts/classes.codexmanaged.ts +402 -0
  88. package/ts/classes.codexpreuse.ts +33 -5
  89. package/ts/classes.fileharness.ts +7 -3
  90. package/ts/classes.limits.ts +11 -6
  91. package/ts/classes.opencodeharness.ts +1 -1
  92. package/ts/claudehttp.ts +31 -2
  93. package/ts/index.ts +7 -0
  94. package/ts/interfaces.harness.ts +6 -0
  95. package/ts/interfaces.list.ts +9 -3
  96. package/ts/plugins.ts +9 -3
  97. package/ts/ts_migration/0001_authority_meta.ts +33 -0
  98. package/ts/ts_migration/index.ts +7 -0
@@ -108,7 +108,7 @@ const claudeUsageWindows = (usage: Record<string, unknown>): IClaudeUsageWindows
108
108
  type TClaudeProfile =
109
109
  | { kind: 'found'; plan: string | undefined; facts: IHarnessStatusFact[] }
110
110
  | { kind: 'mismatch' }
111
- | { kind: 'unavailable'; problem: string };
111
+ | { kind: 'unavailable'; problem: string; credentialRejected: boolean };
112
112
 
113
113
  /**
114
114
  * One Claude OAuth login as a status lookup needs it: the token to ask with, and what the store that keeps it knows
@@ -125,6 +125,14 @@ export interface IClaudeLogin {
125
125
  plan?: string;
126
126
  }
127
127
 
128
+ /** Structured proof from the same profile and usage requests used for harness status. */
129
+ export interface IClaudeStatusRead {
130
+ status: IHarnessAccountStatus;
131
+ profile: 'verified' | 'unverified' | 'mismatch' | 'unavailable' | 'unsupported_scope';
132
+ usage: 'available' | 'unavailable' | 'unsupported_scope';
133
+ credentialRejected: boolean;
134
+ }
135
+
128
136
  /**
129
137
  * How long a session reuses an account's profile. The watch polls every few minutes, and the profile (plan,
130
138
  * organization, billing type) changes far more rarely, so it is read at most hourly there.
@@ -167,7 +175,8 @@ export class ClaudeAccountStatus {
167
175
  let body: Record<string, unknown>;
168
176
  try { body = await this.get('profile', loginArg.accessToken, signal); }
169
177
  catch (error) {
170
- if (error instanceof ClaudeLoginRejectedError) return { kind: 'unavailable', problem: `Profile: ${error.message}` };
178
+ if (error instanceof ClaudeLoginRejectedError) return { kind: 'unavailable',
179
+ problem: `Profile: ${error.message}`, credentialRejected: true };
171
180
  throw error;
172
181
  }
173
182
  try {
@@ -181,7 +190,8 @@ export class ClaudeAccountStatus {
181
190
  if (credentialText(value)) facts.push({ section: 'Account', label: String(label), value: String(value) });
182
191
  }
183
192
  return { kind: 'found', plan: credentialText(organization.organization_type)?.replace(/^claude_/, ''), facts };
184
- } catch { return { kind: 'unavailable', problem: 'Claude profile response is unsupported; no profile values were inferred.' }; }
193
+ } catch { return { kind: 'unavailable',
194
+ problem: 'Claude profile response is unsupported; no profile values were inferred.', credentialRejected: false }; }
185
195
  }
186
196
 
187
197
  /** The plan the login was saved with, the only one known while the service cannot be asked. */
@@ -195,25 +205,43 @@ export class ClaudeAccountStatus {
195
205
  }
196
206
 
197
207
  public async read(loginArg: IClaudeLogin, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
208
+ return (await this.readDetailed(loginArg, optionsArg)).status;
209
+ }
210
+
211
+ /** One request pair; callers requiring immutable identity use the proof rather than display text. */
212
+ public async readDetailed(loginArg: IClaudeLogin,
213
+ optionsArg: IHarnessStatusOptions = {}): Promise<IClaudeStatusRead> {
198
214
  const result: IHarnessAccountStatus = { facts: [], problems: [], ...this.storedSummary(loginArg) };
199
215
  // Only a store that keeps the login's scopes can tell beforehand that the service will not answer about the account.
200
216
  if (loginArg.scopes.length && !loginArg.scopes.includes('user:profile')) {
201
217
  result.facts.push({ section: 'Availability', label: 'Live account status', value: 'This login lacks the user:profile scope. Log in through Claude Code to obtain subscriber status.' });
202
- return result;
218
+ return { status: result, profile: 'unsupported_scope', usage: 'unsupported_scope', credentialRejected: false };
203
219
  }
204
220
  const token = credentialText(loginArg.accessToken);
205
- if (!token) return { facts: [], problems: ['No usable Claude Code access token is available.'] };
221
+ if (!token) return { status: { facts: [], problems: ['No usable Claude Code access token is available.'] },
222
+ profile: 'unavailable', usage: 'unavailable', credentialRejected: false };
206
223
  const [profile, usage] = await Promise.allSettled([this.profile(loginArg, optionsArg), this.get('usage', token, optionsArg.signal)]);
207
224
  const failure = (reason: unknown): string => reason instanceof ClaudeRequestError ? reason.message : 'Lookup failed.';
225
+ let profileOutcome: IClaudeStatusRead['profile'] = 'unavailable';
226
+ let usageOutcome: IClaudeStatusRead['usage'] = 'unavailable';
227
+ let credentialRejected = usage.status === 'rejected' && usage.reason instanceof ClaudeLoginRejectedError;
208
228
  if (profile.status === 'fulfilled') {
209
229
  const found = profile.value;
210
- if (found.kind === 'mismatch') return { facts: [], problems: [MISMATCH] };
211
- if (found.kind === 'unavailable') result.problems.push(found.problem);
230
+ if (found.kind === 'mismatch') return { status: { facts: [], problems: [MISMATCH] },
231
+ profile: 'mismatch', usage: 'unavailable', credentialRejected: false };
232
+ if (found.kind === 'unavailable') {
233
+ result.problems.push(found.problem);
234
+ credentialRejected ||= found.credentialRejected;
235
+ }
212
236
  else {
237
+ profileOutcome = loginArg.identity ? 'verified' : 'unverified';
213
238
  if (found.plan) result.summary = { ...result.summary, subscription: { plan: found.plan, source: 'live' } };
214
239
  result.facts.push(...found.facts.map(fact => ({ ...fact })));
215
240
  }
216
- } else result.problems.push(`Profile: ${failure(profile.reason)}`);
241
+ } else {
242
+ result.problems.push(`Profile: ${failure(profile.reason)}`);
243
+ credentialRejected ||= profile.reason instanceof ClaudeLoginRejectedError;
244
+ }
217
245
  if (usage.status === 'fulfilled') {
218
246
  try {
219
247
  const { windows, unsupported } = claudeUsageWindows(usage.value);
@@ -232,12 +260,13 @@ export class ClaudeAccountStatus {
232
260
  if (limit !== undefined && limit > 0 && used !== undefined) result.facts.push({ section: 'Extra usage', label: 'Monthly budget used', value: `${Math.round(used / limit * 10000) / 100}%` });
233
261
  }
234
262
  if (unsupported.length) result.facts.push({ section: 'Availability', label: 'Unsupported limits', value: `The service returned limits without a supported reset window, which are not shown: ${unsupported.join(', ')}.` });
263
+ usageOutcome = 'available';
235
264
  } catch { result.problems.push('Claude usage response is unsupported; missing values were not inferred.'); }
236
265
  } else result.problems.push(`Usage: ${failure(usage.reason)}`);
237
266
  const limited = [profile, usage].flatMap(item => item.status === 'rejected' && item.reason instanceof ClaudeRateLimitError ? [item.reason] : []);
238
267
  if (limited.length) result.rateLimit = { retryAt: latestRetryAt(limited.map(error => error.retryAt)) };
239
268
  // The missing renewal dates are the harness's `renewalUnavailableReason`, stated once by the views that show them.
240
269
  result.facts.push({ section: 'Availability', label: 'Earned resets', value: 'authswitch does not read earned reset credits for Claude Code logins.' });
241
- return result;
270
+ return { status: result, profile: profileOutcome, usage: usageOutcome, credentialRejected };
242
271
  }
243
272
  }
@@ -1,5 +1,8 @@
1
- import { credentialText } from './classes.credentialstore.js';
2
- import { claudeRequest, ClaudeAccountOnHoldError, ClaudeLoginRejectedError, ClaudeRequestError, oauthErrorCode } from './claudehttp.js';
1
+ import { claudeRequest, claudeRotatingRequest, ClaudeAccountOnHoldError, ClaudeLoginRejectedError,
2
+ ClaudeRequestError, ClaudeRotatingRequestError, oauthErrorCode } from './claudehttp.js';
3
+
4
+ const credentialText = (value: unknown): string | undefined =>
5
+ typeof value === 'string' && value.length > 0 ? value : undefined;
3
6
 
4
7
  /** Claude Code 2.1.273's token endpoint (`TOKEN_URL`, bundle offset @188533776). */
5
8
  const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
@@ -36,6 +39,13 @@ export interface IClaudeTokens {
36
39
  scopes?: string[];
37
40
  }
38
41
 
42
+ /** Authority callers persist a pre-send marker, then classify every failed rotating request. */
43
+ export type TClaudeAuthorityRefreshResult =
44
+ | { success: true; tokens: IClaudeTokens }
45
+ | { success: false; requestOutcome: 'notSent' | 'outcomeUnknown';
46
+ reason: 'not_refreshable' | 'provider_rejected' | 'account_on_hold' | 'provider_unavailable' | 'unsupported_response';
47
+ responseStatus?: number };
48
+
39
49
  /** How Claude Code 2.1.273 builds one refresh request for a stored login. */
40
50
  export interface IClaudeGrant {
41
51
  /** The login's own OAuth client, or Claude Code's public one. */
@@ -104,10 +114,10 @@ export class ClaudeTokenRefresh {
104
114
  * Whether Claude Code would refresh this login before using it: its access token expires within five minutes. A
105
115
  * login without a numeric expiry or without a refresh token is used as it is, exactly as Claude Code does.
106
116
  */
107
- public due(oauthArg: Record<string, unknown>): boolean {
117
+ public due(oauthArg: Record<string, unknown>, minValidityMs = CLAUDE_REFRESH_MARGIN_MS): boolean {
108
118
  const expiresAt = oauthArg.expiresAt;
109
119
  return typeof expiresAt === 'number' && Number.isFinite(expiresAt) && credentialText(oauthArg.refreshToken) !== undefined
110
- && this.now() + CLAUDE_REFRESH_MARGIN_MS >= expiresAt;
120
+ && this.now() + Math.max(CLAUDE_REFRESH_MARGIN_MS, minValidityMs) >= expiresAt;
111
121
  }
112
122
 
113
123
  /** One refresh request, as Claude Code sends it: a JSON body, the login's client, and the scopes of this attempt. */
@@ -121,26 +131,56 @@ export class ClaudeTokenRefresh {
121
131
  });
122
132
  }
123
133
 
124
- /**
125
- * New tokens for the login. A response without a refresh token keeps the one sent, and one without a scope keeps the
126
- * stored scopes, as Claude Code merges them. Throws `ClaudeRequestError` with a fixed diagnostic; a grant the service
127
- * rejects is `ClaudeLoginRejectedError`, an account the service put on hold is `ClaudeAccountOnHoldError`, and a rate
128
- * limit is `ClaudeRateLimitError`.
129
- */
130
- public async refresh(oauthArg: Record<string, unknown>): Promise<IClaudeTokens> {
134
+ private attemptForAuthority(refreshTokenArg: string, grantArg: IClaudeGrant, scopesArg: readonly string[]) {
135
+ return claudeRotatingRequest(this.fetcher, {
136
+ url: TOKEN_URL, method: 'POST', headers: { 'Content-Type': 'application/json' }, subject: 'Claude sign-in',
137
+ body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: refreshTokenArg,
138
+ client_id: grantArg.clientId, scope: grantScope(scopesArg) }),
139
+ timeoutMs: REFRESH_TIMEOUT_MS, readErrorBody: true, now: this.now,
140
+ });
141
+ }
142
+
143
+ /** The authority must already have durably marked this grant as possibly sent. */
144
+ public async refreshForAuthority(oauthArg: Record<string, unknown>): Promise<TClaudeAuthorityRefreshResult> {
131
145
  const refreshToken = credentialText(oauthArg.refreshToken);
132
146
  const grant = claudeGrant(oauthArg);
133
147
  if (!refreshToken || !grant) {
134
- throw new ClaudeRequestError('This saved login has no refresh token, and no subscriber scope or plan to refresh with. Log in again with Claude Code and save it.');
148
+ return { success: false, requestOutcome: 'notSent', reason: 'not_refreshable' };
135
149
  }
136
- let { status, body } = await this.attempt(refreshToken, grant, grant.scopes);
137
- // The service refusing Claude Code's current scope set is the one failure it sends a second request for.
138
- if (status === 400 && oauthErrorCode(body) === 'invalid_scope' && grant.fallbackScopes !== null) {
139
- ({ status, body } = await this.attempt(refreshToken, grant, grant.fallbackScopes));
150
+ let response: Awaited<ReturnType<typeof this.attemptForAuthority>>;
151
+ try {
152
+ response = await this.attemptForAuthority(refreshToken, grant, grant.scopes);
153
+ // A definitive invalid_scope response is the one safe condition in which Claude retries the same grant.
154
+ if (response.status === 400 && oauthErrorCode(response.body) === 'invalid_scope'
155
+ && grant.fallbackScopes !== null) {
156
+ response = await this.attemptForAuthority(refreshToken, grant, grant.fallbackScopes);
157
+ }
158
+ } catch (error) {
159
+ return { success: false,
160
+ requestOutcome: error instanceof ClaudeRotatingRequestError ? error.requestOutcome : 'outcomeUnknown',
161
+ reason: 'provider_unavailable' };
140
162
  }
141
- if (accountOnHold(status, body)) throw new ClaudeAccountOnHoldError();
142
- if ((status === 400 || status === 401) && oauthErrorCode(body) === 'invalid_grant') throw new ClaudeLoginRejectedError();
143
- if (status < 200 || status > 299) throw new ClaudeRequestError(`The Claude sign-in service returned HTTP ${status}.`);
163
+ if (accountOnHold(response.status, response.body)) {
164
+ return { success: false, requestOutcome: 'outcomeUnknown', reason: 'account_on_hold',
165
+ responseStatus: response.status };
166
+ }
167
+ if ((response.status === 400 || response.status === 401)
168
+ && oauthErrorCode(response.body) === 'invalid_grant') {
169
+ return { success: false, requestOutcome: 'outcomeUnknown', reason: 'provider_rejected',
170
+ responseStatus: response.status };
171
+ }
172
+ if (response.status < 200 || response.status > 299) {
173
+ return { success: false, requestOutcome: 'outcomeUnknown', reason: 'provider_unavailable',
174
+ responseStatus: response.status };
175
+ }
176
+ try { return { success: true, tokens: this.tokensFromResponse(response.body, refreshToken) }; }
177
+ catch {
178
+ return { success: false, requestOutcome: 'outcomeUnknown', reason: 'unsupported_response',
179
+ responseStatus: response.status };
180
+ }
181
+ }
182
+
183
+ private tokensFromResponse(body: Record<string, unknown> | null, refreshToken: string): IClaudeTokens {
144
184
  const receivedAt = this.now();
145
185
  const accessToken = credentialText(body?.access_token);
146
186
  const tokenType = body?.token_type;
@@ -158,4 +198,27 @@ export class ClaudeTokenRefresh {
158
198
  ...(scope === null || !scope.length ? {} : { scopes: scope }),
159
199
  };
160
200
  }
201
+
202
+ /**
203
+ * New tokens for the login. A response without a refresh token keeps the one sent, and one without a scope keeps the
204
+ * stored scopes, as Claude Code merges them. Throws `ClaudeRequestError` with a fixed diagnostic; a grant the service
205
+ * rejects is `ClaudeLoginRejectedError`, an account the service put on hold is `ClaudeAccountOnHoldError`, and a rate
206
+ * limit is `ClaudeRateLimitError`.
207
+ */
208
+ public async refresh(oauthArg: Record<string, unknown>): Promise<IClaudeTokens> {
209
+ const refreshToken = credentialText(oauthArg.refreshToken);
210
+ const grant = claudeGrant(oauthArg);
211
+ if (!refreshToken || !grant) {
212
+ throw new ClaudeRequestError('This saved login has no refresh token, and no subscriber scope or plan to refresh with. Log in again with Claude Code and save it.');
213
+ }
214
+ let { status, body } = await this.attempt(refreshToken, grant, grant.scopes);
215
+ // The service refusing Claude Code's current scope set is the one failure it sends a second request for.
216
+ if (status === 400 && oauthErrorCode(body) === 'invalid_scope' && grant.fallbackScopes !== null) {
217
+ ({ status, body } = await this.attempt(refreshToken, grant, grant.fallbackScopes));
218
+ }
219
+ if (accountOnHold(status, body)) throw new ClaudeAccountOnHoldError();
220
+ if ((status === 400 || status === 401) && oauthErrorCode(body) === 'invalid_grant') throw new ClaudeLoginRejectedError();
221
+ if (status < 200 || status > 299) throw new ClaudeRequestError(`The Claude sign-in service returned HTTP ${status}.`);
222
+ return this.tokensFromResponse(body, refreshToken);
223
+ }
161
224
  }
@@ -69,6 +69,7 @@ export class CodexHarness implements IAuthHarness {
69
69
  const isActive = this.recordOf(entryArg, active.identity);
70
70
  return {
71
71
  id: entryArg.email,
72
+ providerAccountId: entryArg.accountId,
72
73
  label: entryArg.email,
73
74
  isActive,
74
75
  isStashed,
@@ -77,7 +78,8 @@ export class CodexHarness implements IAuthHarness {
77
78
  };
78
79
  });
79
80
  if (active.identity && !accounts.some((accountArg) => accountArg.isActive)) {
80
- accounts.unshift({ id: this.activeAccountId(active.identity.email, active.identity.accountId), label: active.identity.email, isActive: true,
81
+ accounts.unshift({ id: this.activeAccountId(active.identity.email, active.identity.accountId),
82
+ providerAccountId: active.identity.accountId, label: active.identity.email, isActive: true,
81
83
  isStashed: false, savedAt: null, details: [] });
82
84
  }
83
85
  return {