@modelprofile.com/authswitch 9.2.0 → 10.0.1

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 (81) hide show
  1. package/dist_ts/00_commitinfo_data.js +2 -2
  2. package/dist_ts/authority-contract.d.ts +52 -6
  3. package/dist_ts/authority-contract.js +1 -1
  4. package/dist_ts/classes.authoritybroker.d.ts +20 -4
  5. package/dist_ts/classes.authoritybroker.js +44 -8
  6. package/dist_ts/classes.authoritycli.d.ts +4 -1
  7. package/dist_ts/classes.authoritycli.js +42 -7
  8. package/dist_ts/classes.authorityclient.d.ts +9 -1
  9. package/dist_ts/classes.authorityclient.js +9 -3
  10. package/dist_ts/classes.authoritydaemon.js +8 -5
  11. package/dist_ts/classes.authoritydatabase.d.ts +6 -2
  12. package/dist_ts/classes.authoritydatabase.js +13 -6
  13. package/dist_ts/classes.authorityimport.d.ts +4 -4
  14. package/dist_ts/classes.authorityimport.js +8 -7
  15. package/dist_ts/classes.authoritymodels.d.ts +11 -3
  16. package/dist_ts/classes.authoritymodels.js +43 -13
  17. package/dist_ts/classes.authoritypreuse.d.ts +8 -2
  18. package/dist_ts/classes.authoritypreuse.js +27 -17
  19. package/dist_ts/classes.authorityusage.d.ts +4 -3
  20. package/dist_ts/classes.authorityusage.js +3 -5
  21. package/dist_ts/classes.claudeauthority.d.ts +5 -0
  22. package/dist_ts/classes.claudeauthority.js +47 -4
  23. package/dist_ts/classes.claudecodelocks.d.ts +6 -0
  24. package/dist_ts/classes.claudecodelocks.js +30 -2
  25. package/dist_ts/classes.claudenative.d.ts +37 -2
  26. package/dist_ts/classes.claudenative.js +142 -13
  27. package/dist_ts/classes.cli.js +3 -5
  28. package/dist_ts/classes.codexpreuse.d.ts +14 -3
  29. package/dist_ts/classes.codexpreuse.js +127 -44
  30. package/dist_ts/classes.codexstatus.js +44 -95
  31. package/dist_ts/classes.login.d.ts +20 -10
  32. package/dist_ts/classes.login.js +14 -13
  33. package/dist_ts/classes.openaiprovider.d.ts +53 -0
  34. package/dist_ts/classes.openaiprovider.js +340 -0
  35. package/dist_ts/classes.opencodeharness.js +3 -2
  36. package/dist_ts/classes.service.js +3 -5
  37. package/dist_ts/index.d.ts +2 -0
  38. package/dist_ts/index.js +3 -1
  39. package/dist_ts/interfaces.openai.d.ts +118 -0
  40. package/dist_ts/interfaces.openai.js +3 -0
  41. package/dist_ts/openaiauth.d.ts +76 -0
  42. package/dist_ts/openaiauth.js +371 -0
  43. package/dist_ts/openaiusage.d.ts +59 -0
  44. package/dist_ts/openaiusage.js +222 -0
  45. package/dist_ts/plugins.d.ts +1 -3
  46. package/dist_ts/plugins.js +2 -4
  47. package/dist_ts/preuse.d.ts +50 -0
  48. package/dist_ts/preuse.js +63 -4
  49. package/dist_ts/ts_migration/legacysources/shared.js +4 -3
  50. package/openai-codex-license.txt +201 -0
  51. package/package.json +6 -4
  52. package/readme.md +102 -15
  53. package/third-party-notices.md +29 -0
  54. package/ts/00_commitinfo_data.ts +1 -1
  55. package/ts/authority-contract.ts +56 -8
  56. package/ts/classes.authoritybroker.ts +52 -13
  57. package/ts/classes.authoritycli.ts +48 -6
  58. package/ts/classes.authorityclient.ts +11 -3
  59. package/ts/classes.authoritydaemon.ts +7 -4
  60. package/ts/classes.authoritydatabase.ts +14 -6
  61. package/ts/classes.authorityimport.ts +12 -12
  62. package/ts/classes.authoritymodels.ts +42 -14
  63. package/ts/classes.authoritypreuse.ts +30 -17
  64. package/ts/classes.authorityusage.ts +8 -9
  65. package/ts/classes.claudeauthority.ts +37 -3
  66. package/ts/classes.claudecodelocks.ts +31 -1
  67. package/ts/classes.claudenative.ts +161 -14
  68. package/ts/classes.cli.ts +3 -3
  69. package/ts/classes.codexpreuse.ts +130 -40
  70. package/ts/classes.codexstatus.ts +40 -73
  71. package/ts/classes.login.ts +34 -21
  72. package/ts/classes.openaiprovider.ts +365 -0
  73. package/ts/classes.opencodeharness.ts +3 -2
  74. package/ts/classes.service.ts +3 -4
  75. package/ts/index.ts +2 -0
  76. package/ts/interfaces.openai.ts +142 -0
  77. package/ts/openaiauth.ts +422 -0
  78. package/ts/openaiusage.ts +247 -0
  79. package/ts/plugins.ts +1 -3
  80. package/ts/preuse.ts +88 -3
  81. package/ts/ts_migration/legacysources/shared.ts +4 -3
@@ -1,7 +1,10 @@
1
1
  import * as plugins from './plugins.js';
2
+ import { AuthSwitchOpenAiProvider } from './classes.openaiprovider.js';
3
+ import { parseOpenAiTokenClaims } from './openaiauth.js';
4
+ import type { IOpenAiLoginHandle, IOpenAiOAuthCredential } from './interfaces.openai.js';
2
5
  import type { IAuthSwitchAccount, IAuthSwitchAccountEvent, IAuthSwitchBinding, IAuthSwitchLogin,
3
6
  IAuthSwitchNativeAssignment, IAuthSwitchOperation, IAuthSwitchSnapshot, IReq_AuthSwitchSnapshot,
4
- TAuthSwitchLoginOwnerTool } from './authority-contract.js';
7
+ TAuthSwitchLoginOwnerTool, TAuthSwitchRevocation } from './authority-contract.js';
5
8
  import { AuthSwitchRefusal, isAuthSwitchAccountLabel } from './authority-contract.js';
6
9
  import { AuthSwitchAuthorityDatabase } from './classes.authoritydatabase.js';
7
10
  import type { IStoredAuthorityAccount, IStoredAuthorityBinding, IStoredAuthorityClaudeHome, IStoredAuthorityGrant,
@@ -9,9 +12,9 @@ import type { IStoredAuthorityAccount, IStoredAuthorityBinding, IStoredAuthority
9
12
  import type { IAuthSwitchUsageContext } from './classes.authorityusage.js';
10
13
  import { AuthSwitchTpmSecretCodec, type IAuthSwitchSecretCodec } from './classes.authoritysecrets.js';
11
14
 
12
- type TOpenAiCredential = plugins.flexAccounts.IOpenAiChatGptOAuthCredential;
13
- type TOpenAiProvider = Pick<plugins.flexAccounts.ISmartAiProviderAdapter,
14
- 'beginLogin' | 'refreshCredential' | 'inspectCredential' | 'getAccountRateLimits' | 'dispose'>;
15
+ type TOpenAiCredential = IOpenAiOAuthCredential;
16
+ type TOpenAiProvider = Pick<AuthSwitchOpenAiProvider,
17
+ 'beginLogin' | 'refreshCredential' | 'revokeCredential' | 'inspectCredential' | 'getAccountRateLimits' | 'dispose'>;
15
18
 
16
19
  export interface IAuthSwitchAuthorityBrokerOptions {
17
20
  provider?: TOpenAiProvider;
@@ -46,9 +49,15 @@ const safeScope = (value: unknown, maximum: number): value is string => typeof v
46
49
  && value.length > 0 && value.length <= maximum && !/[\u0000-\u001f\u007f]/.test(value);
47
50
  const validRevision = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0;
48
51
  const tokenExpiry = (credential: TOpenAiCredential): string | null => {
49
- try { return plugins.flexAuth.parseOpenAiChatGptTokenInfo(credential.accessToken).expiresAt ?? null; }
52
+ try { return parseOpenAiTokenClaims(credential.accessToken).expiresAt ?? null; }
50
53
  catch { return null; }
51
54
  };
55
+ /**
56
+ * How long a removal's revocations may take in all. The daemon answers a removal once they settle, so the budget
57
+ * keeps a revoking removal inside the 35 seconds `AuthSwitchClient` allows any request, leaving the rest to the
58
+ * drain and the removal itself; a sign-in OpenAI has not confirmed revoking by then reports `failed`.
59
+ */
60
+ const revocationBudgetMs = 10_000;
52
61
  const openAiGrantId = (accountId: string): string => idHash('authswitch-grant-v1', accountId, 'openai_managed', 'chatgpt');
53
62
 
54
63
  /**
@@ -144,7 +153,7 @@ const publicClaudeAssignment = (home: IStoredAuthorityClaudeHome): IAuthSwitchNa
144
153
 
145
154
  interface IManagedOperation {
146
155
  id: string;
147
- handle: plugins.flexAccounts.ISmartAiProviderLoginHandle;
156
+ handle: IOpenAiLoginHandle;
148
157
  task: Promise<void>;
149
158
  }
150
159
 
@@ -172,7 +181,7 @@ export class AuthSwitchAuthorityBroker {
172
181
  private closing?: Promise<void>;
173
182
 
174
183
  constructor(private readonly database: AuthSwitchAuthorityDatabase, options: IAuthSwitchAuthorityBrokerOptions = {}) {
175
- this.provider = options.provider ?? new plugins.flexAccounts.OpenAiProviderAdapter();
184
+ this.provider = options.provider ?? new AuthSwitchOpenAiProvider();
176
185
  this.secrets = options.secrets ?? new AuthSwitchTpmSecretCodec();
177
186
  this.now = options.now ?? Date.now;
178
187
  this.proactive = options.proactive ?? true;
@@ -344,7 +353,7 @@ export class AuthSwitchAuthorityBroker {
344
353
  }
345
354
 
346
355
  private identity(credential: TOpenAiCredential): { id: string; subject: string; workspaceId: string; email: string | null; plan: string | null } {
347
- const token = plugins.flexAuth.parseOpenAiChatGptTokenInfo(credential.idToken);
356
+ const token = parseOpenAiTokenClaims(credential.idToken);
348
357
  const subject = token.chatgptUserId;
349
358
  const workspaceId = token.chatgptAccountId;
350
359
  if (!subject || !workspaceId) throw new Error('OpenAI login omitted a stable user or account identity.');
@@ -453,7 +462,7 @@ export class AuthSwitchAuthorityBroker {
453
462
  if (terminal.state === 'cancelled') return publicOperation(terminal);
454
463
  throw error;
455
464
  }
456
- let handle: plugins.flexAccounts.ISmartAiProviderLoginHandle;
465
+ let handle: IOpenAiLoginHandle;
457
466
  try {
458
467
  handle = await this.provider.beginLogin({ flow: 'device' });
459
468
  } catch {
@@ -660,13 +669,43 @@ export class AuthSwitchAuthorityBroker {
660
669
  return publicAccount(updated.account);
661
670
  }
662
671
 
663
- public async removeAccount(accountId: string, expectedRevision: number): Promise<IAuthSwitchAccount> {
672
+ /**
673
+ * Removes an account. Only `revoke: true` reaches the network: it revokes the ChatGPT sign-in the removal
674
+ * discarded, after the removal committed.
675
+ */
676
+ public async removeAccount(accountId: string, expectedRevision: number,
677
+ options: { revoke: boolean }): Promise<{ account: IAuthSwitchAccount; revoked: TAuthSwitchRevocation }> {
664
678
  if (!isId(accountId) || !validRevision(expectedRevision)) throw new Error('Invalid account change.');
665
679
  const updateId = plugins.crypto.randomUUID();
666
- const account = await this.database.removeAccountAndGrants(updateId, accountId,
680
+ const { account, retired } = await this.database.removeAccountAndGrants(updateId, accountId,
667
681
  expectedRevision, new Date(this.now()).toISOString());
668
682
  this.publish();
669
- return publicAccount(account);
683
+ const revoked = options.revoke ? await this.revokeRetired(account, retired) : 'not_requested';
684
+ return { account: publicAccount(account), revoked };
685
+ }
686
+
687
+ /**
688
+ * Revokes the ChatGPT sign-ins a removal discarded, as Codex revokes its own when it logs out, so that sign-in
689
+ * stops working at the service too. Like Codex's, it is best effort: the account is already removed, and the
690
+ * log says when the service did not confirm the revocation. All of them share `revocationBudgetMs`: each is
691
+ * given what remains of it, and one the budget no longer reaches is not sent and counts as not confirmed.
692
+ */
693
+ private async revokeRetired(account: IStoredAuthorityAccount,
694
+ retired: IStoredAuthorityGrant[]): Promise<Exclude<TAuthSwitchRevocation, 'not_requested'>> {
695
+ if (retired.length === 0) return 'not_applicable';
696
+ const deadline = this.now() + revocationBudgetMs;
697
+ let revoked: 'yes' | 'failed' = 'yes';
698
+ for (const grant of retired) {
699
+ const remainingMs = Math.floor(deadline - this.now());
700
+ const confirmed = remainingMs > 0 && await this.unsealCredential(account, grant)
701
+ .then(credential => this.provider.revokeCredential(credential, { timeoutMs: remainingMs }))
702
+ .then(() => true, () => false);
703
+ if (!confirmed) {
704
+ revoked = 'failed';
705
+ process.stderr.write('Authswitch removed an account, but OpenAI did not confirm revoking its sign-in.\n');
706
+ }
707
+ }
708
+ return revoked;
670
709
  }
671
710
 
672
711
  public async bindAccount(input: { accountId: string; loginId: string; purpose: 'openai_managed';
@@ -835,7 +874,7 @@ export class AuthSwitchAuthorityBroker {
835
874
  }
836
875
  if (!grant.accessExpiresAt) throw new AuthSwitchRefusal('access_not_fresh', accessNotFresh);
837
876
  const credential = await this.unsealCredential(account, grant);
838
- const info = plugins.flexAuth.parseOpenAiChatGptTokenInfo(credential.accessToken);
877
+ const info = parseOpenAiTokenClaims(credential.accessToken);
839
878
  if (info.chatgptAccountId !== account.workspaceId || info.chatgptUserId !== account.subject) {
840
879
  throw new Error('Account access identity changed.');
841
880
  }
@@ -1,11 +1,12 @@
1
1
  import type { IAuthSwitchDoctorPage, IAuthSwitchOperation, IAuthSwitchPreuseOperation,
2
- IAuthSwitchSnapshot, IReq_AuthSwitchDoctor, IReq_AuthSwitchStartPreuse } from './authority-contract.js';
2
+ IAuthSwitchSnapshot, IReq_AuthSwitchDoctor, IReq_AuthSwitchRemoveAccount,
3
+ IReq_AuthSwitchStartPreuse } from './authority-contract.js';
3
4
  import { isAuthSwitchImportRefusal } from './authority-import-contract.js';
4
5
  import type { IAuthSwitchImportInventory, IAuthSwitchImportResult, IAuthSwitchImportStatus,
5
6
  IReq_AuthSwitchImportStatus, TAuthSwitchImportAction } from './authority-import-contract.js';
6
7
  import { AuthSwitchClient } from './classes.authorityclient.js';
7
8
  import { resolveAuthSwitchAuthorityPaths } from './classes.authorityservice.js';
8
- import { defaultPreusePrompt, validatePreuseOptions } from './preuse.js';
9
+ import { defaultPreusePrompt, describePreuseFailure, describePreuseLimit, validatePreuseOptions } from './preuse.js';
9
10
  import { plainText } from './formatting.js';
10
11
  import * as plugins from './plugins.js';
11
12
 
@@ -23,6 +24,8 @@ export interface IAuthSwitchAuthorityCliClient {
23
24
  startPreuse(input: IReq_AuthSwitchStartPreuse['request']): Promise<IAuthSwitchPreuseOperation>;
24
25
  getPreuse(operationId: string): Promise<IAuthSwitchPreuseOperation>;
25
26
  cancelPreuse(operationId: string): Promise<IAuthSwitchPreuseOperation>;
27
+ removeAccount(accountId: string, expectedRevision: number,
28
+ options: { revoke: boolean }): Promise<IReq_AuthSwitchRemoveAccount['response']>;
26
29
  close(reason?: Error): void;
27
30
  }
28
31
 
@@ -44,6 +47,7 @@ type TAuthorityCliCommand =
44
47
  | { kind: 'importSubmit'; sourceId: string; sourceDigest: string; json: boolean }
45
48
  | { kind: 'add'; json: boolean }
46
49
  | { kind: 'reauth'; accountId: string; loginId: string; json: boolean }
50
+ | { kind: 'remove'; accountId: string; revoke: boolean; json: boolean }
47
51
  | { kind: 'get'; operationId: string; json: boolean }
48
52
  | { kind: 'cancel'; operationId: string; json: boolean }
49
53
  | { kind: 'resume'; operationId: string; json: boolean }
@@ -60,7 +64,7 @@ class AuthorityCliFailure extends Error {
60
64
  }
61
65
  }
62
66
 
63
- const usage = 'Usage: authswitch account list [--json] | authswitch account add openai [--json] | authswitch account reauth <account-id> <login-id> [--json] | authswitch account operation get|cancel|resume <operation-id> [--json] | authswitch account preuse <account-id> <login-id>|--all [--prompt <text>] [--model <id>] [--json] | authswitch account preuse operation get|cancel|resume <operation-id> [--json] | authswitch authority doctor [--json] | authswitch authority import inventory [--json] | authswitch authority import status [--json] | authswitch authority import submit <source-id> --digest <source-digest> [--json]';
67
+ const usage = 'Usage: authswitch account list [--json] | authswitch account add openai [--json] | authswitch account reauth <account-id> <login-id> [--json] | authswitch account remove <account-id> [--revoke] [--json] | authswitch account operation get|cancel|resume <operation-id> [--json] | authswitch account preuse <account-id> <login-id>|--all [--prompt <text>] [--model <id>] [--json] | authswitch account preuse operation get|cancel|resume <operation-id> [--json] | authswitch authority doctor [--json] | authswitch authority import inventory [--json] | authswitch authority import status [--json] | authswitch authority import submit <source-id> --digest <source-digest> [--json]';
64
68
  const safe = (value: string | number | null): string => plainText(value === null ? 'none' : String(value));
65
69
  const isId = (value: string | undefined): value is string => Boolean(value && !value.startsWith('-'));
66
70
  const isHash = (value: string | undefined): value is string =>
@@ -143,6 +147,14 @@ const parse = (argv: readonly string[]): TAuthorityCliCommand | null => {
143
147
  && (argv.length === 4 || (argv.length === 5 && argv[4] === '--json'))) {
144
148
  return { kind: 'reauth', accountId: argv[2], loginId: argv[3], json: argv[4] === '--json' };
145
149
  }
150
+ if (argv[0] === 'account' && argv[1] === 'remove' && isId(argv[2])) {
151
+ const options = argv.slice(3);
152
+ const revoke = options.includes('--revoke');
153
+ const json = options.includes('--json');
154
+ if (options.length !== Number(revoke) + Number(json)
155
+ || options.some(option => option !== '--revoke' && option !== '--json')) return null;
156
+ return { kind: 'remove', accountId: argv[2], revoke, json };
157
+ }
146
158
  if (argv[0] === 'account' && argv[1] === 'operation'
147
159
  && (argv[2] === 'get' || argv[2] === 'cancel' || argv[2] === 'resume') && isId(argv[3])
148
160
  && (argv.length === 4 || (argv.length === 5 && argv[4] === '--json'))) {
@@ -210,11 +222,27 @@ class ScopedAuthorityCliClient implements IAuthSwitchAuthorityCliClient {
210
222
  return this.client.cancelPreuse(operationId, this.controller.signal);
211
223
  }
212
224
 
225
+ public removeAccount(accountId: string, expectedRevision: number,
226
+ options: { revoke: boolean }): Promise<IReq_AuthSwitchRemoveAccount['response']> {
227
+ return this.client.removeAccount(accountId, expectedRevision, { ...options, signal: this.controller.signal });
228
+ }
229
+
213
230
  public close(reason = new Error('Authswitch authority CLI closed.')): void {
214
231
  if (!this.controller.signal.aborted) this.controller.abort(reason);
215
232
  }
216
233
  }
217
234
 
235
+ const revocationLines: Record<IReq_AuthSwitchRemoveAccount['response']['revoked'], string> = {
236
+ not_requested: 'Its ChatGPT sign-in was not revoked at OpenAI (no --revoke).',
237
+ yes: 'OpenAI confirmed revoking its ChatGPT sign-in.',
238
+ failed: 'OpenAI did not confirm revoking its ChatGPT sign-in; it may still be valid there.',
239
+ not_applicable: 'The authority held no ChatGPT sign-in of its own for it, so nothing was revoked.',
240
+ };
241
+
242
+ const renderRemoval = (removal: IReq_AuthSwitchRemoveAccount['response']): string =>
243
+ `Removed account ${safe(removal.account.email ?? removal.account.label)} (${safe(removal.account.id)}).\n`
244
+ + `${revocationLines[removal.revoked]}\n`;
245
+
218
246
  const renderList = (snapshot: IAuthSwitchSnapshot): string => {
219
247
  const lines = [`Authority accounts — generated ${safe(snapshot.generatedAt)}, revision ${snapshot.revision}`];
220
248
  if (snapshot.accounts.length === 0) lines.push('No authority accounts are registered.');
@@ -332,7 +360,8 @@ const resumeCommand = (operationId: string, json: boolean): string =>
332
360
  `authswitch account operation resume ${safe(operationId)}${json ? ' --json' : ''}`;
333
361
 
334
362
  const terminalPreuseOperation = (operation: IAuthSwitchPreuseOperation): boolean =>
335
- operation.state === 'complete' || operation.state === 'not_sent' || operation.state === 'outcome_unknown';
363
+ operation.state === 'complete' || operation.state === 'not_sent' || operation.state === 'refused'
364
+ || operation.state === 'outcome_unknown';
336
365
 
337
366
  const preuseResumeCommand = (operationId: string, json: boolean): string =>
338
367
  `authswitch account preuse operation resume ${safe(operationId)}${json ? ' --json' : ''}`;
@@ -346,9 +375,9 @@ const renderOperation = (operation: IAuthSwitchOperation, json: boolean): string
346
375
  if (operation.targetAccountId || operation.targetLoginId) {
347
376
  lines.push(` target account=${safe(operation.targetAccountId)}; login=${safe(operation.targetLoginId)}`);
348
377
  }
349
- if (operation.prompt?.flow === 'device') {
378
+ if (operation.prompt) {
350
379
  lines.push(` Open ${safe(operation.prompt.verificationUrl)}`, ` Code: ${safe(operation.prompt.userCode)}`);
351
- } else if (operation.prompt?.flow === 'browser') lines.push(` Open ${safe(operation.prompt.authUrl)}`);
380
+ }
352
381
  if (operation.result) {
353
382
  lines.push(` completed account=${safe(operation.result.accountId)}; login=${safe(operation.result.loginId)}; account revision=${operation.result.accountRevision}; login generation=${operation.result.loginGeneration}`);
354
383
  }
@@ -368,6 +397,8 @@ const renderPreuseOperation = (operation: IAuthSwitchPreuseOperation, json: bool
368
397
  if (operation.result) {
369
398
  lines.push(` tokens input=${safe(operation.result.inputTokens)}; output=${safe(operation.result.outputTokens)}; total=${safe(operation.result.totalTokens)}`);
370
399
  }
400
+ if (operation.limit) lines.push(` ${safe(describePreuseLimit(operation.limit, Date.now()))}`);
401
+ if (operation.failure) lines.push(` Cause: ${safe(describePreuseFailure(operation.failure))}`);
371
402
  if (!terminalPreuseOperation(operation)) {
372
403
  lines.push(` Resume: ${preuseResumeCommand(operation.id, false)}`);
373
404
  }
@@ -721,6 +752,17 @@ export const runAuthSwitchAuthorityCli = async (argv: readonly string[],
721
752
  await writeOutput(command.json ? JSON.stringify(result) + '\n' : renderImportResult(result));
722
753
  return result.status === 'complete' ? 0 : 1;
723
754
  }
755
+ if (command.kind === 'remove') {
756
+ const account = (await activeClient.snapshotAll()).accounts.find(item => item.id === command.accountId);
757
+ if (!account) {
758
+ await writeDiagnostic('No authority account has this id. Run `authswitch account list` for the ids.\n');
759
+ return 1;
760
+ }
761
+ const removed = await activeClient.removeAccount(account.id, account.revision, { revoke: command.revoke });
762
+ if (removed.account.id !== account.id || !removed.account.removed) throw new AuthorityCliFailure('response');
763
+ await writeOutput(command.json ? JSON.stringify(removed) + '\n' : renderRemoval(removed));
764
+ return removed.revoked === 'failed' ? 1 : 0;
765
+ }
724
766
  if (command.kind === 'preuseGet' || command.kind === 'preuseCancel') {
725
767
  const operation = validatePreuseOperation(command.kind === 'preuseGet'
726
768
  ? await activeClient.getPreuse(command.operationId)
@@ -387,10 +387,18 @@ export class AuthSwitchClient {
387
387
  return (await this.request<IReq_AuthSwitchRenameAccount>('authswitch.authority.rename',
388
388
  { accountId, expectedRevision, label }, 35_000, false, signal)).account;
389
389
  }
390
+ /**
391
+ * Removes an account; `revoke: true` also revokes its ChatGPT sign-in at OpenAI (see
392
+ * `IReq_AuthSwitchRemoveAccount`). An older daemon refuses `revoke` as an invalid request before it changes
393
+ * anything, and answers a plain removal without `revoked`, which then is `not_requested`: it never revokes.
394
+ */
390
395
  public async removeAccount(accountId: string, expectedRevision: number,
391
- signal?: AbortSignal): Promise<IReq_AuthSwitchRemoveAccount['response']['account']> {
392
- return (await this.request<IReq_AuthSwitchRemoveAccount>('authswitch.authority.remove',
393
- { accountId, expectedRevision }, 35_000, false, signal)).account;
396
+ options: { revoke?: boolean; signal?: AbortSignal } = {}): Promise<IReq_AuthSwitchRemoveAccount['response']> {
397
+ const response: Partial<IReq_AuthSwitchRemoveAccount['response']> =
398
+ await this.request<IReq_AuthSwitchRemoveAccount>('authswitch.authority.remove',
399
+ { accountId, expectedRevision, ...(options.revoke ? { revoke: true as const } : {}) }, 35_000, false,
400
+ options.signal);
401
+ return { account: response.account!, revoked: response.revoked ?? 'not_requested' };
394
402
  }
395
403
  public async switchClaudeNative(accountId: string, loginId: string,
396
404
  signal?: AbortSignal): Promise<IReq_AuthSwitchSwitchClaudeNative['response']['handoff']> {
@@ -1,5 +1,6 @@
1
1
  import type { ITypedRequest } from '@api.global/typedrequest-interfaces';
2
2
  import * as plugins from './plugins.js';
3
+ import { AuthSwitchOpenAiProvider } from './classes.openaiprovider.js';
3
4
  import type {
4
5
  IAuthSwitchDoctorPage, IReq_AuthSwitchDoctor,
5
6
  IReq_AuthSwitchBeginAdd, IReq_AuthSwitchBeginReauth, IReq_AuthSwitchCancelOperation,
@@ -156,7 +157,7 @@ export class AuthSwitchAuthorityDaemon {
156
157
  throw new Error('Authswitch authority requestDeliveryMs must be a positive whole number of milliseconds.');
157
158
  }
158
159
  this.database = new AuthSwitchAuthorityDatabase(options);
159
- const provider = options.broker?.provider ?? new plugins.flexAccounts.OpenAiProviderAdapter();
160
+ const provider = options.broker?.provider ?? new AuthSwitchOpenAiProvider();
160
161
  this.broker = new AuthSwitchAuthorityBroker(this.database, { ...options.broker, provider });
161
162
  this.usage = new AuthSwitchAuthorityUsage({
162
163
  ...options.usage,
@@ -474,15 +475,17 @@ export class AuthSwitchAuthorityDaemon {
474
475
  }));
475
476
  this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchRemoveAccount>(
476
477
  'authswitch.authority.remove', async request => {
477
- if (!hasKeys(request, ['accountId', 'expectedRevision']) || typeof request.accountId !== 'string'
478
- || typeof request.expectedRevision !== 'number') invalid();
478
+ const revoke = Object.hasOwn(request, 'revoke');
479
+ if (!hasKeys(request, revoke ? ['accountId', 'expectedRevision', 'revoke'] : ['accountId', 'expectedRevision'])
480
+ || typeof request.accountId !== 'string' || typeof request.expectedRevision !== 'number'
481
+ || (revoke && request.revoke !== true)) invalid();
479
482
  if (this.drainingAccounts.has(request.accountId) || this.preuseAccounts.has(request.accountId)) {
480
483
  throw new AuthSwitchRefusal('account_busy', 'Account runtime drain is already in progress.');
481
484
  }
482
485
  this.drainingAccounts.add(request.accountId);
483
486
  try {
484
487
  await this.drainManagedCodexAccount(request.accountId);
485
- return { account: await this.broker.removeAccount(request.accountId, request.expectedRevision) };
488
+ return await this.broker.removeAccount(request.accountId, request.expectedRevision, { revoke });
486
489
  } finally { this.drainingAccounts.delete(request.accountId); }
487
490
  }));
488
491
  this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchBindAccount>(
@@ -567,8 +567,8 @@ export class AuthSwitchAuthorityDatabase {
567
567
  const draft = change(structuredClone(current));
568
568
  const allowed: Record<IStoredAuthorityPreuseOperation['state'], IStoredAuthorityPreuseOperation['state'][]> = {
569
569
  preparing: ['request_may_have_started', 'not_sent'],
570
- request_may_have_started: ['complete', 'outcome_unknown'],
571
- complete: [], not_sent: [], outcome_unknown: [],
570
+ request_may_have_started: ['complete', 'refused', 'outcome_unknown'],
571
+ complete: [], not_sent: [], refused: [], outcome_unknown: [],
572
572
  };
573
573
  if (draft.id !== current.id || draft.kind !== current.kind || draft.purpose !== current.purpose
574
574
  || draft.accountId !== current.accountId || draft.grantId !== current.grantId
@@ -1209,12 +1209,17 @@ export class AuthSwitchAuthorityDatabase {
1209
1209
  /**
1210
1210
  * Remove all managed grants in one transaction. Native owners must be stopped and handed off first, and a
1211
1211
  * runtime binding its holder releases must be released first; the daemon's own managed Codex bindings go
1212
- * with the account.
1212
+ * with the account. `retired` holds the sealed ChatGPT sign-ins the removal discarded, exactly as the attempt
1213
+ * that committed read them, so the caller can revoke them at the provider.
1213
1214
  */
1214
1215
  public async removeAccountAndGrants(updateId: string, accountId: string,
1215
- expectedRevision: number, statusObservedAt: string): Promise<IStoredAuthorityAccount> {
1216
+ expectedRevision: number, statusObservedAt: string): Promise<{ account: IStoredAuthorityAccount;
1217
+ retired: IStoredAuthorityGrant[] }> {
1218
+ // Collected afresh by every attempt of the transaction, so after it the list is the committed attempt's.
1219
+ let retired: IStoredAuthorityGrant[] = [];
1216
1220
  try {
1217
1221
  return await this.transaction(async session => {
1222
+ retired = [];
1218
1223
  const metaStored = await AuthSwitchAuthorityMetaModel.exact.findStoredOne({ id: 'authswitch-authority' }, { session });
1219
1224
  const accountStored = await AuthSwitchAuthorityAccountModel.exact.findStoredOne({ id: accountId }, { session });
1220
1225
  if (!metaStored || !accountStored || accountStored.removed || accountStored.revision !== expectedRevision) {
@@ -1271,6 +1276,9 @@ export class AuthSwitchAuthorityDatabase {
1271
1276
  }
1272
1277
  await scan(async (grant, stored) => {
1273
1278
  if (grant.state === 'removed') return;
1279
+ if (grant.purpose === 'openai_managed' && grant.owner === 'daemon' && grant.ciphertext !== null) {
1280
+ retired.push(grant);
1281
+ }
1274
1282
  const draft: IStoredAuthorityGrant = {
1275
1283
  ...grant, state: 'removed', owner: 'none', ciphertext: null, accessExpiresAt: null,
1276
1284
  attemptId: null, retryAt: null, retryCount: 0, problem: 'none',
@@ -1311,13 +1319,13 @@ export class AuthSwitchAuthorityDatabase {
1311
1319
  await this.persistAccount(session, accountStored, removed);
1312
1320
  await this.appendEvent(session, AuthSwitchAuthorityMetaModel.exact.toPersisted(metaStored),
1313
1321
  updateId, 'account', accountId);
1314
- return removed;
1322
+ return { account: removed, retired };
1315
1323
  });
1316
1324
  } catch (error) {
1317
1325
  if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
1318
1326
  const event = await AuthSwitchAuthorityEventModel.exact.findStoredOne({ id: updateId });
1319
1327
  const account = await this.readAccount(accountId);
1320
- if (event && account?.removed && account.updateId === updateId) return account;
1328
+ if (event && account?.removed && account.updateId === updateId) return { account, retired };
1321
1329
  throw new Error('Authswitch account removal outcome is unknown; refresh before retrying.');
1322
1330
  }
1323
1331
  }
@@ -1,4 +1,7 @@
1
1
  import * as plugins from './plugins.js';
2
+ import { AuthSwitchOpenAiProvider } from './classes.openaiprovider.js';
3
+ import { parseOpenAiTokenClaims, type IOpenAiTokenClaims } from './openaiauth.js';
4
+ import type { IOpenAiAccess, IOpenAiOAuthCredential, TOpenAiAccountRateLimitsResult, TOpenAiRefreshResult } from './interfaces.openai.js';
2
5
  import type { AuthSwitchAuthorityDatabase } from './classes.authoritydatabase.js';
3
6
  import { authSwitchEnvironmentId } from './classes.operations.js';
4
7
  import { AuthSwitchRefusal } from './authority-contract.js';
@@ -55,14 +58,12 @@ export interface IAuthSwitchImportQuiescence {
55
58
  /**
56
59
  * Exactly the two provider operations an import needs, so nothing here can reach for a third.
57
60
  *
58
- * `OpenAiProviderAdapter` satisfies this; naming only what is used keeps the rotating call countable and
61
+ * `AuthSwitchOpenAiProvider` satisfies this; naming only what is used keeps the rotating call countable and
59
62
  * makes it visible that a projection never touches `refreshCredential`.
60
63
  */
61
64
  export interface IAuthSwitchImportOpenAiOperations {
62
- refreshCredential(credential: plugins.flexAccounts.IOpenAiChatGptOAuthCredential):
63
- Promise<plugins.flexAccounts.TSmartAiProviderRefreshResult>;
64
- getAccountRateLimits(credential: plugins.flexAccounts.TOpenAiChatGptReadCredential):
65
- Promise<plugins.flexAccounts.TSmartAiProviderAccountRateLimitsResult>;
65
+ refreshCredential(credential: IOpenAiOAuthCredential): Promise<TOpenAiRefreshResult>;
66
+ getAccountRateLimits(access: IOpenAiAccess): Promise<TOpenAiAccountRateLimitsResult>;
66
67
  }
67
68
 
68
69
  /** The non-rotating Claude proof: one profile read that must name the expected immutable identity. */
@@ -289,7 +290,7 @@ export class AuthSwitchAuthorityImport {
289
290
  this.env = options.env;
290
291
  this.homeDirectory = options.homeDirectory;
291
292
  this.now = options.now ?? Date.now;
292
- this.openAi = options.openAi ?? new plugins.flexAccounts.OpenAiProviderAdapter();
293
+ this.openAi = options.openAi ?? new AuthSwitchOpenAiProvider();
293
294
  this.claudeStatus = options.claudeStatus ?? new ClaudeAccountStatus();
294
295
  this.claudeTokens = options.claudeTokens ?? new ClaudeTokenRefresh();
295
296
  this.secrets = options.secrets ?? new AuthSwitchTpmSecretCodec();
@@ -426,8 +427,8 @@ export class AuthSwitchAuthorityImport {
426
427
 
427
428
  private externalIdentity(idToken: string): { accountId: string; subject: string; workspaceId: string;
428
429
  email: string | null; plan: string | null } {
429
- let info: ReturnType<typeof plugins.flexAuth.parseOpenAiChatGptTokenInfo>;
430
- try { info = plugins.flexAuth.parseOpenAiChatGptTokenInfo(idToken); }
430
+ let info: IOpenAiTokenClaims;
431
+ try { info = parseOpenAiTokenClaims(idToken); }
431
432
  catch { return refuse('The submitted login carries no readable ChatGPT identity.'); }
432
433
  if (!info.chatgptUserId || !info.chatgptAccountId) {
433
434
  refuse('The submitted login omits a stable ChatGPT user or account identity.');
@@ -618,15 +619,14 @@ export class AuthSwitchAuthorityImport {
618
619
  identity: { accountId: string; subject: string; workspaceId: string },
619
620
  material: TLegacySourceMaterial): Promise<void> {
620
621
  if (material.providerId === 'openai') {
621
- const result = await this.openAi.getAccountRateLimits({ kind: 'chatgptAccess',
622
- providerId: 'openai', accessToken: material.credential.accessToken,
622
+ const result = await this.openAi.getAccountRateLimits({ accessToken: material.credential.accessToken,
623
623
  accountId: identity.workspaceId, isFedrampAccount: false });
624
624
  if (!result.success) {
625
625
  refuse(`The stored access token could not be proven live (${result.errorCode}); let the native tool `
626
626
  + 'refresh its own store, then import again.');
627
627
  }
628
628
  // The claims of the very token that answered must still name this account.
629
- const claims = plugins.flexAuth.parseOpenAiChatGptTokenInfo(material.credential.accessToken);
629
+ const claims = parseOpenAiTokenClaims(material.credential.accessToken);
630
630
  if (claims.chatgptAccountId !== identity.workspaceId) {
631
631
  refuse('The stored access token belongs to another ChatGPT account.');
632
632
  }
@@ -773,7 +773,7 @@ export class AuthSwitchAuthorityImport {
773
773
  const result = await this.openAi.refreshCredential(material.credential);
774
774
  if (!result.success) return null;
775
775
  let accessExpiresAt: string | null = null;
776
- try { accessExpiresAt = plugins.flexAuth.parseOpenAiChatGptTokenInfo(result.credential.accessToken).expiresAt ?? null; }
776
+ try { accessExpiresAt = parseOpenAiTokenClaims(result.credential.accessToken).expiresAt ?? null; }
777
777
  catch { accessExpiresAt = null; }
778
778
  return { material: { providerId: 'openai', credential: { kind: 'chatgptOAuth', providerId: 'openai',
779
779
  accessToken: result.credential.accessToken, refreshToken: result.credential.refreshToken,
@@ -1,5 +1,6 @@
1
1
  import * as plugins from './plugins.js';
2
- import type { TAuthSwitchClaudeProofFailure, TAuthSwitchLoginPrompt } from './authority-contract.js';
2
+ import type { IAuthSwitchPreuseFailure, IAuthSwitchPreuseLimit, TAuthSwitchClaudeProofFailure,
3
+ TAuthSwitchLoginPrompt } from './authority-contract.js';
3
4
  import type { IAuthSwitchStoredUsage } from './classes.authorityusage.js';
4
5
 
5
6
  export interface IStoredAuthorityMeta {
@@ -225,7 +226,7 @@ export interface IStoredAuthorityDeviceOperation {
225
226
  export type TStoredAuthorityPreuseProblem =
226
227
  | 'none' | 'target_unavailable' | 'access_unavailable' | 'catalog_unavailable'
227
228
  | 'account_busy' | 'cancelled' | 'authority_closing' | 'interrupted'
228
- | 'request_uncertain';
229
+ | 'request_uncertain' | 'usage_limit' | 'rate_limit';
229
230
 
230
231
  /** One quota-consuming request. The prompt and generated prose never enter persistence. */
231
232
  export interface IStoredAuthorityPreuseOperation {
@@ -240,9 +241,13 @@ export interface IStoredAuthorityPreuseOperation {
240
241
  model: string | null;
241
242
  authorizationGeneration: number;
242
243
  grantGeneration: number | null;
243
- state: 'preparing' | 'request_may_have_started' | 'complete' | 'not_sent' | 'outcome_unknown';
244
+ state: 'preparing' | 'request_may_have_started' | 'complete' | 'not_sent' | 'refused' | 'outcome_unknown';
244
245
  result: { inputTokens: number | null; outputTokens: number | null; totalTokens: number | null } | null;
245
246
  problem: TStoredAuthorityPreuseProblem;
247
+ /** Present exactly when `problem` is `usage_limit` or `rate_limit`. */
248
+ limit?: IAuthSwitchPreuseLimit;
249
+ /** Present only when `problem` is `request_uncertain` and the request failed with a stated cause. */
250
+ failure?: IAuthSwitchPreuseFailure;
246
251
  createdAt: string;
247
252
  updatedAt: string;
248
253
  finishedAt: string | null;
@@ -271,6 +276,8 @@ export interface IStoredAuthorityOperationDocument {
271
276
  authorizationGeneration?: number;
272
277
  grantGeneration?: number | null;
273
278
  problem?: TStoredAuthorityPreuseProblem;
279
+ limit?: IAuthSwitchPreuseLimit;
280
+ failure?: IAuthSwitchPreuseFailure;
274
281
  createdAt: string;
275
282
  updatedAt: string;
276
283
  finishedAt: string | null;
@@ -603,13 +610,11 @@ const assertStoredAuthorityDeviceOperation: (value: unknown) => asserts value is
603
610
  : value.accountId === null || value.grantId === null || value.expectedGrantGeneration === null) {
604
611
  throw new Error('Invalid authswitch operation target.');
605
612
  }
606
- if (value.prompt !== null && (!object(value.prompt)
607
- || (value.prompt.flow === 'device'
608
- ? !exactKeys(value.prompt, ['flow', 'verificationUrl', 'userCode'])
609
- || !bounded(value.prompt.verificationUrl, 2048) || !bounded(value.prompt.userCode, 128)
610
- : value.prompt.flow === 'browser'
611
- ? !exactKeys(value.prompt, ['flow', 'authUrl']) || !bounded(value.prompt.authUrl, 2048)
612
- : true))) throw new Error('Invalid authswitch operation prompt.');
613
+ if (value.prompt !== null && (!object(value.prompt) || value.prompt.flow !== 'device'
614
+ || !exactKeys(value.prompt, ['flow', 'verificationUrl', 'userCode'])
615
+ || !bounded(value.prompt.verificationUrl, 2048) || !bounded(value.prompt.userCode, 128))) {
616
+ throw new Error('Invalid authswitch operation prompt.');
617
+ }
613
618
  if (value.result !== null && (!object(value.result)
614
619
  || !exactKeys(value.result, ['accountId', 'grantId', 'accountRevision', 'grantGeneration'])
615
620
  || !hash(value.result.accountId) || !hash(value.result.grantId)
@@ -635,19 +640,33 @@ const modelId = (value: unknown): value is string => typeof value === 'string'
635
640
  const tokenCount = (value: unknown): value is number | null => value === null
636
641
  || (Number.isSafeInteger(value) && Number(value) >= 0);
637
642
 
643
+ const preuseLimitProblems: readonly string[] = ['usage_limit', 'rate_limit'];
644
+ const preuseLimit = (value: unknown): boolean => object(value) && exactKeys(value, ['kind', 'window', 'resetsAt'])
645
+ && ['usage_limit', 'rate_limit'].includes(String(value.kind))
646
+ && (value.window === null || bounded(value.window, 64)) && (value.resetsAt === null || iso(value.resetsAt));
647
+ const preuseFailure = (value: unknown): boolean => object(value) && exactKeys(value, ['kind', 'status', 'providerCode'])
648
+ && ['http', 'stream', 'response', 'network', 'aborted', 'unknown'].includes(String(value.kind))
649
+ && (value.status === null || (Number.isInteger(value.status) && Number(value.status) >= 100 && Number(value.status) <= 599))
650
+ && (value.providerCode === null || (typeof value.providerCode === 'string' && /^[A-Za-z0-9_.]{1,64}$/.test(value.providerCode)));
651
+
638
652
  const assertStoredAuthorityPreuseOperation: (value: unknown) => asserts value is IStoredAuthorityPreuseOperation = value => {
653
+ const limited = object(value) && preuseLimitProblems.includes(String(value.problem));
654
+ const failed = object(value) && Object.hasOwn(value, 'failure');
639
655
  if (!object(value) || !exactKeys(value, ['id', 'kind', 'purpose', 'accountId', 'grantId', 'requestHash',
640
656
  'requestedModel', 'model', 'authorizationGeneration', 'grantGeneration', 'state', 'result', 'problem',
657
+ ...(limited ? ['limit'] : []), ...(failed ? ['failure'] : []),
641
658
  'createdAt', 'updatedAt', 'finishedAt', 'revision', 'updateId'])
659
+ || (limited && !preuseLimit(value.limit))
660
+ || (failed && (value.problem !== 'request_uncertain' || !preuseFailure(value.failure)))
642
661
  || !uuid(value.id) || value.kind !== 'preuse_openai' || value.purpose !== 'openai_managed'
643
662
  || !hash(value.accountId) || !hash(value.grantId) || !hash(value.requestHash)
644
663
  || (value.requestedModel !== null && !modelId(value.requestedModel))
645
664
  || (value.model !== null && !modelId(value.model))
646
665
  || !revision(value.authorizationGeneration) || value.authorizationGeneration === 0
647
666
  || (value.grantGeneration !== null && (!revision(value.grantGeneration) || value.grantGeneration === 0))
648
- || !['preparing', 'request_may_have_started', 'complete', 'not_sent', 'outcome_unknown'].includes(String(value.state))
667
+ || !['preparing', 'request_may_have_started', 'complete', 'not_sent', 'refused', 'outcome_unknown'].includes(String(value.state))
649
668
  || !['none', 'target_unavailable', 'access_unavailable', 'catalog_unavailable', 'account_busy', 'cancelled',
650
- 'authority_closing', 'interrupted', 'request_uncertain'].includes(String(value.problem))
669
+ 'authority_closing', 'interrupted', 'request_uncertain', 'usage_limit', 'rate_limit'].includes(String(value.problem))
651
670
  || !iso(value.createdAt) || !iso(value.updatedAt)
652
671
  || (value.finishedAt !== null && !iso(value.finishedAt))
653
672
  || !revision(value.revision) || value.revision === 0 || !uuid(value.updateId)) {
@@ -675,13 +694,20 @@ const assertStoredAuthorityPreuseOperation: (value: unknown) => asserts value is
675
694
  throw new Error('Invalid completed authswitch preuse operation.');
676
695
  }
677
696
  } else if (value.state === 'not_sent') {
697
+ // Only the account's own usage reading stops a prompt with a limit before anything is sent.
678
698
  if (value.model !== null || value.grantGeneration !== null || value.result !== null
679
- || value.problem === 'none' || value.problem === 'request_uncertain' || value.finishedAt === null) {
699
+ || value.problem === 'none' || value.problem === 'request_uncertain' || value.problem === 'rate_limit'
700
+ || value.finishedAt === null) {
680
701
  throw new Error('Invalid unsent authswitch preuse operation.');
681
702
  }
703
+ } else if (value.state === 'refused') {
704
+ if (value.model === null || value.grantGeneration === null || value.result !== null
705
+ || !limited || value.finishedAt === null) {
706
+ throw new Error('Invalid refused authswitch preuse operation.');
707
+ }
682
708
  } else if (value.result !== null || value.finishedAt === null || value.model === null
683
709
  || value.grantGeneration === null
684
- || !['cancelled', 'authority_closing', 'request_uncertain'].includes(String(value.problem))) {
710
+ || !['cancelled', 'authority_closing', 'request_uncertain', 'usage_limit', 'rate_limit'].includes(String(value.problem))) {
685
711
  throw new Error('Invalid uncertain authswitch preuse operation.');
686
712
  }
687
713
  };
@@ -930,6 +956,8 @@ export class AuthSwitchAuthorityOperationModel extends plugins.nosqldb.SmartData
930
956
  @plugins.nosqldb.svDb() public authorizationGeneration?: number;
931
957
  @plugins.nosqldb.svDb() public grantGeneration?: number | null;
932
958
  @plugins.nosqldb.svDb() public problem?: TStoredAuthorityPreuseProblem;
959
+ @plugins.nosqldb.svDb() public limit?: IAuthSwitchPreuseLimit;
960
+ @plugins.nosqldb.svDb() public failure?: IAuthSwitchPreuseFailure;
933
961
  @plugins.nosqldb.svDb() public createdAt!: string;
934
962
  @plugins.nosqldb.svDb() public updatedAt!: string;
935
963
  @plugins.nosqldb.svDb() public finishedAt!: string | null;