@modelprofile.com/authswitch 8.2.0 → 9.1.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 (64) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/authority-contract.d.ts +84 -1
  3. package/dist_ts/authority-contract.js +14 -2
  4. package/dist_ts/authority-import-contract.d.ts +6 -0
  5. package/dist_ts/authority-paths.d.ts +37 -0
  6. package/dist_ts/authority-paths.js +46 -0
  7. package/dist_ts/authority-runtime-contract.d.ts +11 -1
  8. package/dist_ts/classes.authoritybroker.d.ts +23 -8
  9. package/dist_ts/classes.authoritybroker.js +155 -32
  10. package/dist_ts/classes.authorityclient.d.ts +22 -2
  11. package/dist_ts/classes.authorityclient.js +98 -13
  12. package/dist_ts/classes.authoritydaemon.d.ts +21 -3
  13. package/dist_ts/classes.authoritydaemon.js +77 -32
  14. package/dist_ts/classes.authoritydatabase.d.ts +21 -3
  15. package/dist_ts/classes.authoritydatabase.js +98 -12
  16. package/dist_ts/classes.authorityimport.d.ts +16 -4
  17. package/dist_ts/classes.authorityimport.js +75 -24
  18. package/dist_ts/classes.authoritymodels.js +5 -3
  19. package/dist_ts/classes.authoritypreuse.js +8 -3
  20. package/dist_ts/classes.authorityservice.d.ts +10 -11
  21. package/dist_ts/classes.authorityservice.js +14 -23
  22. package/dist_ts/classes.cli.d.ts +10 -2
  23. package/dist_ts/classes.cli.js +12 -4
  24. package/dist_ts/classes.codexmanaged.d.ts +0 -1
  25. package/dist_ts/classes.codexmanaged.js +13 -26
  26. package/dist_ts/classes.legacyfence.d.ts +53 -0
  27. package/dist_ts/classes.legacyfence.js +189 -0
  28. package/dist_ts/classes.operations.d.ts +15 -3
  29. package/dist_ts/classes.operations.js +22 -4
  30. package/dist_ts/classes.service.d.ts +21 -2
  31. package/dist_ts/classes.service.js +35 -8
  32. package/dist_ts/classes.tui.d.ts +2 -1
  33. package/dist_ts/classes.tui.js +3 -2
  34. package/dist_ts/codexcontract.d.ts +30 -0
  35. package/dist_ts/codexcontract.js +174 -0
  36. package/dist_ts/ts_migration/0004_container_setup_owner.d.ts +12 -0
  37. package/dist_ts/ts_migration/0004_container_setup_owner.js +19 -0
  38. package/dist_ts/ts_migration/index.js +3 -1
  39. package/dist_ts/ts_migration/legacysources/authswitchstores.js +5 -2
  40. package/package.json +11 -11
  41. package/readme.md +194 -24
  42. package/ts/00_commitinfo_data.ts +1 -1
  43. package/ts/authority-contract.ts +90 -4
  44. package/ts/authority-import-contract.ts +6 -0
  45. package/ts/authority-paths.ts +69 -0
  46. package/ts/authority-runtime-contract.ts +11 -1
  47. package/ts/classes.authoritybroker.ts +153 -33
  48. package/ts/classes.authorityclient.ts +102 -15
  49. package/ts/classes.authoritydaemon.ts +89 -27
  50. package/ts/classes.authoritydatabase.ts +98 -12
  51. package/ts/classes.authorityimport.ts +102 -25
  52. package/ts/classes.authoritymodels.ts +4 -1
  53. package/ts/classes.authoritypreuse.ts +7 -1
  54. package/ts/classes.authorityservice.ts +15 -30
  55. package/ts/classes.cli.ts +14 -3
  56. package/ts/classes.codexmanaged.ts +10 -19
  57. package/ts/classes.legacyfence.ts +219 -0
  58. package/ts/classes.operations.ts +22 -3
  59. package/ts/classes.service.ts +45 -8
  60. package/ts/classes.tui.ts +3 -1
  61. package/ts/codexcontract.ts +200 -0
  62. package/ts/ts_migration/0004_container_setup_owner.ts +19 -0
  63. package/ts/ts_migration/index.ts +2 -0
  64. package/ts/ts_migration/legacysources/authswitchstores.ts +4 -1
@@ -1,9 +1,10 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import type { IAuthSwitchAccount, IAuthSwitchAccountEvent, IAuthSwitchBinding, IAuthSwitchLogin,
3
- IAuthSwitchOperation, IAuthSwitchSnapshot } from './authority-contract.js';
4
- import { AuthSwitchRefusal } from './authority-contract.js';
3
+ IAuthSwitchNativeAssignment, IAuthSwitchOperation, IAuthSwitchSnapshot, IReq_AuthSwitchSnapshot,
4
+ TAuthSwitchLoginOwnerTool } from './authority-contract.js';
5
+ import { AuthSwitchRefusal, isAuthSwitchAccountLabel } from './authority-contract.js';
5
6
  import { AuthSwitchAuthorityDatabase } from './classes.authoritydatabase.js';
6
- import type { IStoredAuthorityAccount, IStoredAuthorityBinding, IStoredAuthorityGrant,
7
+ import type { IStoredAuthorityAccount, IStoredAuthorityBinding, IStoredAuthorityClaudeHome, IStoredAuthorityGrant,
7
8
  IStoredAuthorityDeviceOperation } from './classes.authoritymodels.js';
8
9
  import type { IAuthSwitchUsageContext } from './classes.authorityusage.js';
9
10
  import { AuthSwitchTpmSecretCodec, type IAuthSwitchSecretCodec } from './classes.authoritysecrets.js';
@@ -41,8 +42,6 @@ const sameUsageAuthority = (left: IAuthSwitchUsageContext, right: IAuthSwitchUsa
41
42
  const isUuid = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9-]{36}$/.test(value);
42
43
  const deviceOperationTerminal = (operation: IStoredAuthorityDeviceOperation): boolean =>
43
44
  !['starting', 'pending', 'committing'].includes(operation.state);
44
- const safeLabel = (value: unknown): value is string => typeof value === 'string' && value.trim() === value
45
- && value.length > 0 && value.length <= 128 && !/[\u0000-\u001f\u007f]/.test(value);
46
45
  const safeScope = (value: unknown, maximum: number): value is string => typeof value === 'string'
47
46
  && value.length > 0 && value.length <= maximum && !/[\u0000-\u001f\u007f]/.test(value);
48
47
  const validRevision = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0;
@@ -51,14 +50,61 @@ const tokenExpiry = (credential: TOpenAiCredential): string | null => {
51
50
  catch { return null; }
52
51
  };
53
52
  const openAiGrantId = (accountId: string): string => idHash('authswitch-grant-v1', accountId, 'openai_managed', 'chatgpt');
53
+
54
+ /**
55
+ * What a caller is told when the answer is decided. Each states the repair and names no id, path or host.
56
+ *
57
+ * They are written once because several sites answer the same question: an operation id this authority does
58
+ * not hold is the same answer whether a read, a cancel or a start met it, and a client that branches on the
59
+ * code must not have to tell those apart.
60
+ */
61
+ const unknownOperation = 'This authority knows no device sign-in by that operation ID. '
62
+ + 'Start a new sign-in and follow that one.';
63
+ const loginNotBindable = 'This login cannot back a runtime binding. The authority must hold the login '
64
+ + 'itself, it must be the account\'s selected OpenAI login, and it must be ready. Reauthenticate the '
65
+ + 'account or choose another one.';
66
+ const bindingUnauthorized = 'This runtime binding is not authorized. Bind the account again to obtain a '
67
+ + 'capability for it.';
68
+ const bindingMoved = 'This runtime binding changed while its credential was being resolved. Bind the '
69
+ + 'account again.';
70
+ const bindingNeedsReauth = 'The account no longer holds the login this binding was authorized for. '
71
+ + 'Reauthenticate the account, then bind it again.';
72
+ const loginNeedsReauth = 'This account\'s sign-in has ended. Reauthenticate the account with a new device '
73
+ + 'sign-in; a runtime bound to it binds again afterwards.';
74
+ const accessNotFresh = 'The provider could not renew this account\'s access yet. Try the request again '
75
+ + 'shortly; the authority keeps retrying the renewal.';
54
76
  const publicAccount = (account: IStoredAuthorityAccount): IAuthSwitchAccount => ({
55
77
  id: account.id, providerId: account.providerId, label: account.label, email: account.email,
56
78
  plan: account.plan, removed: account.removed, revision: account.revision,
57
79
  statusObservedAt: account.statusObservedAt,
58
80
  });
81
+
82
+ /**
83
+ * Which native tool refreshes a grant, decided here because this is the one place that publishes a login.
84
+ *
85
+ * `owner` and `purpose` are what the authority stores; the tool is their meaning, and naming it here keeps
86
+ * every consumer from re-deriving it -- the derivation that is wrong as soon as two purposes share a tool,
87
+ * or one tool gains a second purpose. `daemon` and `none` have no native refresher at all, which is a value
88
+ * of its own rather than a missing one.
89
+ */
90
+ const loginOwnerTool = (grant: IStoredAuthorityGrant): TAuthSwitchLoginOwnerTool | null => {
91
+ if (grant.owner === 'claude_native') return 'claude_code';
92
+ if (grant.owner !== 'legacy_native') return null;
93
+ switch (grant.purpose) {
94
+ case 'opencode_native': return 'opencode';
95
+ case 'openai_managed': return 'codex';
96
+ case 'claude_host_native': return 'claude_code';
97
+ // A container setup token is held for a container, not refreshed by a tool on this host.
98
+ case 'claude_container_setup': return null;
99
+ // A purpose added without deciding its tool fails the type check here rather than publishing `null`,
100
+ // which would claim the authority refreshes a login some tool actually holds.
101
+ default: { const unhandledPurpose: never = grant.purpose; return unhandledPurpose; }
102
+ }
103
+ };
104
+
59
105
  const publicLogin = (grant: IStoredAuthorityGrant): IAuthSwitchLogin => ({
60
106
  id: grant.id, accountId: grant.accountId, providerId: grant.providerId, purpose: grant.purpose,
61
- owner: grant.owner,
107
+ owner: grant.owner, ownerTool: loginOwnerTool(grant),
62
108
  health: grant.state === 'exchange_may_have_been_sent' ? 'refreshing'
63
109
  : grant.state === 'native' ? 'unverified'
64
110
  : grant.state === 'legacy_native_pending' || grant.state === 'handoff_pending' ? 'pending_handoff' : grant.state,
@@ -90,6 +136,12 @@ const publicBinding = (binding: IStoredAuthorityBinding): IAuthSwitchBinding =>
90
136
  scopeId: binding.scopeId, incarnationId: binding.incarnationId, revision: binding.revision,
91
137
  });
92
138
 
139
+ const publicClaudeAssignment = (home: IStoredAuthorityClaudeHome): IAuthSwitchNativeAssignment => ({
140
+ id: home.id, tool: 'claude_code', accountId: home.activeAccountId, loginId: home.activeGrantId,
141
+ state: home.status === 'quarantined' ? 'quarantined' : home.pendingOperationId !== null ? 'switching' : 'ready',
142
+ revision: home.revision,
143
+ });
144
+
93
145
  interface IManagedOperation {
94
146
  id: string;
95
147
  handle: plugins.flexAccounts.ISmartAiProviderLoginHandle;
@@ -111,6 +163,8 @@ export class AuthSwitchAuthorityBroker {
111
163
  private readonly operations = new Map<string, IManagedOperation>();
112
164
  private readonly refreshes = new Map<string, Promise<void>>();
113
165
  private readonly listeners = new Set<() => void>();
166
+ /** Operation long polls; woken by their operation's own changes, and all of them on close. */
167
+ private readonly operationWaiters = new Set<() => void>();
114
168
  private claudeRefresh?: (grantId: string) => Promise<void>;
115
169
  private maintenance?: Promise<void>;
116
170
  private timer?: NodeJS.Timeout;
@@ -224,19 +278,23 @@ export class AuthSwitchAuthorityBroker {
224
278
  for (const listener of this.listeners) listener();
225
279
  }
226
280
 
227
- public async snapshot(options: { accountAfter?: string; loginAfter?: string; bindingAfter?: string; limit?: number } = {}): Promise<IAuthSwitchSnapshot> {
281
+ public async snapshot(options: IReq_AuthSwitchSnapshot['request'] = {}): Promise<IAuthSwitchSnapshot> {
228
282
  if ((options.accountAfter !== undefined && !isId(options.accountAfter))
229
283
  || (options.loginAfter !== undefined && !isId(options.loginAfter))
230
- || (options.bindingAfter !== undefined && !isId(options.bindingAfter))) throw new Error('Invalid account snapshot cursor.');
284
+ || (options.bindingAfter !== undefined && !isId(options.bindingAfter))
285
+ || (options.nativeAssignmentAfter !== undefined && !isId(options.nativeAssignmentAfter))) {
286
+ throw new Error('Invalid account snapshot cursor.');
287
+ }
231
288
  const page = await this.database.page(options.accountAfter ?? null, options.loginAfter ?? null,
232
- options.bindingAfter ?? null, options.limit ?? 128);
289
+ options.bindingAfter ?? null, options.nativeAssignmentAfter ?? null, options.limit ?? 128);
233
290
  return { schemaVersion: 2, epoch: page.meta.epoch, revision: page.meta.revision,
234
291
  generatedAt: new Date(this.now()).toISOString(),
235
- accounts: page.accounts.filter(account => !account.removed).map(publicAccount),
236
- logins: page.grants.filter(grant => grant.state !== 'removed').map(publicLogin),
292
+ accounts: page.accounts.filter(account => options.includeRemoved || !account.removed).map(publicAccount),
293
+ logins: page.grants.filter(grant => options.includeRemoved || grant.state !== 'removed').map(publicLogin),
237
294
  bindings: page.bindings.map(publicBinding),
295
+ nativeAssignments: page.claudeHomes.map(publicClaudeAssignment),
238
296
  nextAccountCursor: page.nextAccountCursor, nextLoginCursor: page.nextGrantCursor,
239
- nextBindingCursor: page.nextBindingCursor };
297
+ nextBindingCursor: page.nextBindingCursor, nextNativeAssignmentCursor: page.nextClaudeHomeCursor };
240
298
  }
241
299
 
242
300
  public async events(epoch: string, afterRevision: number, waitMs: number, signal?: AbortSignal): Promise<{
@@ -341,7 +399,7 @@ export class AuthSwitchAuthorityBroker {
341
399
  private async finishStartingOperation(operationId: string, state: 'failed' | 'interrupted',
342
400
  error: string): Promise<IStoredAuthorityDeviceOperation> {
343
401
  const current = await this.database.readOperation(operationId);
344
- if (!current || current.kind === 'preuse_openai') throw new Error('Account operation was not found.');
402
+ if (!current || current.kind === 'preuse_openai') throw new AuthSwitchRefusal('not_found', unknownOperation);
345
403
  if (deviceOperationTerminal(current)) return current;
346
404
  if (current.state !== 'starting') throw new Error('Account operation already started its provider login.');
347
405
  const updateId = plugins.crypto.randomUUID();
@@ -407,7 +465,7 @@ export class AuthSwitchAuthorityBroker {
407
465
  let current: IStoredAuthorityDeviceOperation;
408
466
  try {
409
467
  const stored = await this.database.readOperation(id);
410
- if (!stored || stored.kind === 'preuse_openai') throw new Error('Account operation was not found.');
468
+ if (!stored || stored.kind === 'preuse_openai') throw new AuthSwitchRefusal('not_found', unknownOperation);
411
469
  current = stored;
412
470
  } catch (error) {
413
471
  await Promise.allSettled([handle.cancel(), handle.close()]);
@@ -511,11 +569,46 @@ export class AuthSwitchAuthorityBroker {
511
569
  return publicOperation(pending);
512
570
  }
513
571
 
514
- public async getOperation(operationId: string): Promise<IAuthSwitchOperation> {
572
+ /**
573
+ * One device sign-in. With `wait`, the read is a long poll, the way `events` waits: it answers at once when
574
+ * the operation's revision is past `afterRevision` or the sign-in has finished, and otherwise when the
575
+ * operation next changes or `waitMs` runs out, whichever is first -- so a caller follows the prompt and
576
+ * the outcome without polling. A timed-out wait answers with the unchanged operation.
577
+ */
578
+ public async getOperation(operationId: string, wait?: { afterRevision: number; waitMs: number },
579
+ signal?: AbortSignal): Promise<IAuthSwitchOperation> {
515
580
  if (!isUuid(operationId)) throw new Error('Invalid operation ID.');
516
- const operation = await this.database.readOperation(operationId);
517
- if (!operation || operation.kind === 'preuse_openai') throw new Error('Account operation was not found.');
518
- return publicOperation(operation);
581
+ if (wait !== undefined && (!validRevision(wait.afterRevision) || !Number.isSafeInteger(wait.waitMs)
582
+ || wait.waitMs < 0 || wait.waitMs > 30_000)) throw new Error('Invalid operation wait.');
583
+ const read = async (): Promise<IStoredAuthorityDeviceOperation> => {
584
+ const operation = await this.database.readOperation(operationId);
585
+ if (!operation || operation.kind === 'preuse_openai') throw new AuthSwitchRefusal('not_found', unknownOperation);
586
+ return operation;
587
+ };
588
+ const answers = (operation: IStoredAuthorityDeviceOperation): boolean => wait === undefined
589
+ || operation.revision > wait.afterRevision || deviceOperationTerminal(operation);
590
+ const immediate = await read();
591
+ if (answers(immediate) || wait!.waitMs === 0 || this.closed || signal?.aborted) return publicOperation(immediate);
592
+ return new Promise((resolve, reject) => {
593
+ let finished = false;
594
+ const settle = (outcome: () => void) => {
595
+ if (finished) return;
596
+ finished = true;
597
+ clearTimeout(timer);
598
+ unobserve();
599
+ this.operationWaiters.delete(wake);
600
+ signal?.removeEventListener('abort', wake);
601
+ outcome();
602
+ };
603
+ const wake = () => settle(() => { void read().then(operation => resolve(publicOperation(operation)), reject); });
604
+ const timer = setTimeout(wake, wait!.waitMs);
605
+ const unobserve = this.database.observeDeviceOperations(changed => { if (changed === operationId) wake(); });
606
+ this.operationWaiters.add(wake);
607
+ signal?.addEventListener('abort', wake, { once: true });
608
+ // Close the read/register race without holding a database session for the wait.
609
+ void read().then(operation => { if (answers(operation)) wake(); },
610
+ error => settle(() => reject(error)));
611
+ });
519
612
  }
520
613
 
521
614
  public async listOperations(after: string | null, limit = 128): Promise<{ operations: IAuthSwitchOperation[]; nextCursor: string | null }> {
@@ -538,7 +631,7 @@ export class AuthSwitchAuthorityBroker {
538
631
  public async cancelOperation(operationId: string): Promise<IAuthSwitchOperation> {
539
632
  if (!isUuid(operationId)) throw new Error('Invalid operation ID.');
540
633
  const current = await this.database.readOperation(operationId);
541
- if (!current || current.kind === 'preuse_openai') throw new Error('Account operation was not found.');
634
+ if (!current || current.kind === 'preuse_openai') throw new AuthSwitchRefusal('not_found', unknownOperation);
542
635
  if (current.state === 'committing') throw new Error('Login is completing; check its operation status.');
543
636
  if (current.state !== 'starting' && current.state !== 'pending') return publicOperation(current);
544
637
  const updateId = plugins.crypto.randomUUID();
@@ -552,7 +645,11 @@ export class AuthSwitchAuthorityBroker {
552
645
  }
553
646
 
554
647
  public async renameAccount(accountId: string, expectedRevision: number, label: string): Promise<IAuthSwitchAccount> {
555
- if (!isId(accountId) || !validRevision(expectedRevision) || !safeLabel(label)) throw new Error('Invalid account change.');
648
+ if (!isId(accountId) || !validRevision(expectedRevision)) throw new Error('Invalid account change.');
649
+ if (!isAuthSwitchAccountLabel(label)) {
650
+ throw new AuthSwitchRefusal('invalid_input', 'An account label has between one and one hundred and twenty-eight '
651
+ + 'characters, with no space at either end and no control character. Choose another label.');
652
+ }
556
653
  const updateId = plugins.crypto.randomUUID();
557
654
  const updated = await this.database.changeAccount(updateId, accountId, account => {
558
655
  if (!account || account.removed || account.revision !== expectedRevision) throw new AuthSwitchRefusal('account_changed', 'Account changed; refresh before editing.');
@@ -582,11 +679,12 @@ export class AuthSwitchAuthorityBroker {
582
679
  const capabilityHash = idHash(capability);
583
680
  const updateId = plugins.crypto.randomUUID();
584
681
  const updated = await this.database.changeBinding(updateId, id, input.accountId, input.loginId, (existing, account, grant) => {
682
+ // A ChatGPT login backs every runtime, Claude Code included; a Claude account reaches Claude Code
683
+ // through its native switch instead, so it never binds (its provider is not OpenAI).
585
684
  if (account.removed || grant?.state !== 'ready' || grant.id !== account.primaryGrantId
586
685
  || grant.owner !== 'daemon'
587
- || grant.purpose !== input.purpose || account.providerId !== 'openai'
588
- || input.runtime === 'claude') {
589
- throw new Error('Account is not available for this runtime.');
686
+ || grant.purpose !== input.purpose || account.providerId !== 'openai') {
687
+ throw new AuthSwitchRefusal('login_unavailable', loginNotBindable);
590
688
  }
591
689
  return { id, accountId: input.accountId, grantId: grant.id, runtime: input.runtime, scopeId: input.scopeId,
592
690
  incarnationId: input.incarnationId, revision: (existing?.revision ?? 0) + 1,
@@ -596,6 +694,12 @@ export class AuthSwitchAuthorityBroker {
596
694
  return { binding: publicBinding(updated.binding), capability };
597
695
  }
598
696
 
697
+ public async getBinding(bindingId: string): Promise<IAuthSwitchBinding | null> {
698
+ if (!isId(bindingId)) throw new Error('Invalid runtime binding.');
699
+ const binding = await this.database.readBinding(bindingId);
700
+ return binding ? publicBinding(binding) : null;
701
+ }
702
+
599
703
  public async revokeBinding(bindingId: string, capability: string): Promise<boolean> {
600
704
  if (!isId(bindingId) || !/^[A-Za-z0-9_-]{43}$/.test(capability)) throw new Error('Invalid runtime binding revocation.');
601
705
  const revoked = await this.database.revokeBinding(plugins.crypto.randomUUID(), bindingId,
@@ -624,21 +728,27 @@ export class AuthSwitchAuthorityBroker {
624
728
  const current = await this.database.readBindingContext(bindingId);
625
729
  const binding = current.binding;
626
730
  const expected = Buffer.from(binding?.capabilityHash ?? '0'.repeat(64), 'hex');
731
+ // A missing binding and a wrong capability are compared against the same zero hash on purpose, so
732
+ // nothing here tells a caller which it was. One code preserves exactly that, and it is truthful for
733
+ // both: this capability does not authorize anything, and binding again is the only repair.
627
734
  if (!binding || !plugins.crypto.timingSafeEqual(supplied, expected)) {
628
- throw new Error('Runtime binding is not authorized.');
735
+ throw new AuthSwitchRefusal('binding_unauthorized', bindingUnauthorized);
629
736
  }
630
737
  if (originalBinding && (binding.id !== originalBinding.id
631
738
  || binding.capabilityHash !== originalBinding.capabilityHash
632
739
  || binding.accountId !== originalBinding.accountId || binding.grantId !== originalBinding.grantId
633
740
  || binding.incarnationId !== originalBinding.incarnationId
634
741
  || binding.grantAuthorizationGeneration !== originalBinding.grantAuthorizationGeneration)) {
635
- throw new Error('Account binding changed during credential resolution.');
742
+ // The capability still matched, so what moved is the binding itself: another incarnation took the
743
+ // scope, or the account was reauthorized under it. The caller's repair is the one above -- bind
744
+ // again -- so it is the same code; a second one would name a difference no runtime can act on.
745
+ throw new AuthSwitchRefusal('binding_unauthorized', bindingMoved);
636
746
  }
637
747
  originalBinding ??= binding;
638
748
  if (!current.account || current.account.removed || !current.grant
639
749
  || current.grant.id !== binding.grantId
640
750
  || current.grant.authorizationGeneration !== binding.grantAuthorizationGeneration) {
641
- throw new Error('Account needs reauthentication.');
751
+ throw new AuthSwitchRefusal('login_unavailable', bindingNeedsReauth);
642
752
  }
643
753
  return { account: current.account, grant: current.grant };
644
754
  }, minValidityMs, rejectedGrantGeneration);
@@ -660,6 +770,14 @@ export class AuthSwitchAuthorityBroker {
660
770
  }, minValidityMs, rejectedGrantGeneration);
661
771
  }
662
772
 
773
+ /**
774
+ * The shared managed-access loop. What it decides about the login itself is a marked refusal both callers
775
+ * read the same way: `login_needs_reauth` when only a new sign-in brings the login back, `access_not_fresh`
776
+ * when the login is intact and the provider could not renew it yet. The usage reader still folds either
777
+ * into its own problem; a bound runtime shows the instruction. What only concerns a binding is decided
778
+ * before this point, in the view `resolveAccess` supplies, and a changed identity or a view that keeps
779
+ * moving stays an unmarked fault, because nobody decided it.
780
+ */
663
781
  private async resolveManagedAccess(readView: () => Promise<{
664
782
  account: IStoredAuthorityAccount; grant: IStoredAuthorityGrant;
665
783
  }>, minValidityMs: number, rejectedGrantGeneration?: number): Promise<IAuthSwitchResolvedAccess> {
@@ -667,20 +785,20 @@ export class AuthSwitchAuthorityBroker {
667
785
  const { account, grant } = await readView();
668
786
  if (account.removed || grant.accountId !== account.id || grant.id !== account.primaryGrantId
669
787
  || account.providerId !== 'openai' || grant.providerId !== 'openai') {
670
- throw new Error('Account needs reauthentication.');
788
+ throw new AuthSwitchRefusal('login_needs_reauth', loginNeedsReauth);
671
789
  }
672
790
  if (rejectedGrantGeneration !== undefined && rejectedGrantGeneration > grant.grantGeneration) {
673
791
  throw new Error('Provider rejected a future account grant generation.');
674
792
  }
675
793
  if (grant.state === 'exchange_may_have_been_sent') {
676
794
  const inFlight = this.refreshes.get(account.id);
677
- if (!inFlight) throw new Error('Account refresh is unresolved; reauthentication is required.');
795
+ if (!inFlight) throw new AuthSwitchRefusal('login_needs_reauth', loginNeedsReauth);
678
796
  await inFlight;
679
797
  continue;
680
798
  }
681
799
  if (grant.owner !== 'daemon' || grant.purpose !== 'openai_managed'
682
800
  || !['ready', 'retry_wait'].includes(grant.state)) {
683
- throw new Error('Account needs reauthentication.');
801
+ throw new AuthSwitchRefusal('login_needs_reauth', loginNeedsReauth);
684
802
  }
685
803
  const rejectedCurrent = rejectedGrantGeneration === grant.grantGeneration;
686
804
  if (this.isDue(grant, minValidityMs) || rejectedCurrent) {
@@ -689,9 +807,9 @@ export class AuthSwitchAuthorityBroker {
689
807
  rejectedCurrent ? rejectedGrantGeneration : undefined);
690
808
  continue;
691
809
  }
692
- throw new Error('Account access credential is not fresh enough.');
810
+ throw new AuthSwitchRefusal('access_not_fresh', accessNotFresh);
693
811
  }
694
- if (!grant.accessExpiresAt) throw new Error('Account access credential is not fresh enough.');
812
+ if (!grant.accessExpiresAt) throw new AuthSwitchRefusal('access_not_fresh', accessNotFresh);
695
813
  const credential = await this.unsealCredential(account, grant);
696
814
  const info = plugins.flexAuth.parseOpenAiChatGptTokenInfo(credential.accessToken);
697
815
  if (info.chatgptAccountId !== account.workspaceId || info.chatgptUserId !== account.subject) {
@@ -709,7 +827,7 @@ export class AuthSwitchAuthorityBroker {
709
827
  || latest.grant.state === 'exchange_may_have_been_sent') continue;
710
828
  if (!['ready', 'retry_wait'].includes(latest.grant.state) || this.isDue(latest.grant, minValidityMs)
711
829
  || (rejectedGrantGeneration !== undefined && latest.grant.grantGeneration === rejectedGrantGeneration)) {
712
- throw new Error('Account access credential is not fresh enough.');
830
+ throw new AuthSwitchRefusal('access_not_fresh', accessNotFresh);
713
831
  }
714
832
  return { accessToken: credential.accessToken, accountId: account.workspaceId,
715
833
  isFedrampAccount: info.chatgptAccountIsFedramp, expiresAt: grant.accessExpiresAt,
@@ -794,7 +912,8 @@ export class AuthSwitchAuthorityBroker {
794
912
  });
795
913
  this.publish();
796
914
  } catch { /* A persisted attempt marker forces needs_reauth on daemon restart. */ }
797
- throw new Error('Refresh result is uncertain. Reauthenticate this account; its old grant will not be replayed.');
915
+ // The grant is now `needs_reauth`: an uncertain rotation is never replayed, so only a sign-in repairs it.
916
+ throw new AuthSwitchRefusal('login_needs_reauth', loginNeedsReauth);
798
917
  }
799
918
  }
800
919
 
@@ -803,6 +922,7 @@ export class AuthSwitchAuthorityBroker {
803
922
  this.closed = true;
804
923
  if (this.timer) clearTimeout(this.timer);
805
924
  for (const wake of this.listeners) wake();
925
+ for (const wake of this.operationWaiters) wake();
806
926
  this.closing = (async () => {
807
927
  if (this.maintenance) await this.maintenance;
808
928
  await Promise.allSettled([...this.operations.values()].map(item => item.handle.cancel()));
@@ -5,7 +5,8 @@ import type {
5
5
  IReq_AuthSwitchBeginReauth, IReq_AuthSwitchCancelOperation,
6
6
  IReq_AuthSwitchClaudeNativeHandoff, IReq_AuthSwitchClaudeNativeHandoffs,
7
7
  IReq_AuthSwitchSwitchClaudeNative, IReq_AuthSwitchEvents,
8
- IReq_AuthSwitchGetOperation, IReq_AuthSwitchListOperations, IReq_AuthSwitchRemoveAccount, IReq_AuthSwitchRenameAccount,
8
+ IReq_AuthSwitchGetBinding, IReq_AuthSwitchGetOperation, IReq_AuthSwitchListOperations,
9
+ IReq_AuthSwitchRemoveAccount, IReq_AuthSwitchRenameAccount,
9
10
  IReq_AuthSwitchCancelPreuse, IReq_AuthSwitchGetPreuse, IReq_AuthSwitchSnapshot,
10
11
  IReq_AuthSwitchStartPreuse, IReq_AuthSwitchUsage,
11
12
  } from './authority-contract.js';
@@ -53,10 +54,36 @@ const post = (path: string, payload: ITypedRequest, signal?: AbortSignal): Promi
53
54
  });
54
55
  };
55
56
 
57
+ /**
58
+ * The daemon answered with an older contract than this client reads: it runs an authswitch release from before
59
+ * this one and was not restarted after the upgrade. Restarting it onto the installed release is the repair.
60
+ */
61
+ export class AuthSwitchDaemonOutdatedError extends Error {
62
+ constructor() {
63
+ super('The authswitch authority daemon runs an older release than this client. Restart it onto the '
64
+ + 'installed authswitch: authswitch authority service stop, then authswitch authority service start.');
65
+ this.name = 'AuthSwitchDaemonOutdatedError';
66
+ }
67
+ }
68
+
69
+ /**
70
+ * A snapshot page carries every member of this release's contract. `schemaVersion` is not bumped for an
71
+ * additive member, so an older daemon is told apart by the members its answer lacks.
72
+ */
73
+ const assertCurrentSnapshot = (snapshot: IAuthSwitchSnapshot): IAuthSwitchSnapshot => {
74
+ const page: Partial<IAuthSwitchSnapshot> = snapshot;
75
+ if (!Array.isArray(page.nativeAssignments) || page.nextNativeAssignmentCursor === undefined) {
76
+ throw new AuthSwitchDaemonOutdatedError();
77
+ }
78
+ return snapshot;
79
+ };
80
+
56
81
  /** A snapshot is usable only while state is current; lastVerifiedAt is the last successful daemon proof. */
57
82
  export interface IAuthSwitchSubscriptionStatus {
58
83
  state: 'current' | 'unavailable' | 'closed';
59
- reason: 'initial' | 'snapshot' | 'event' | 'heartbeat' | 'disconnect' | 'resync' | 'abort' | 'consumer_error';
84
+ /** `daemon_outdated`: the daemon runs an older release; see `AuthSwitchDaemonOutdatedError`. */
85
+ reason: 'initial' | 'snapshot' | 'event' | 'heartbeat' | 'disconnect' | 'resync' | 'abort' | 'consumer_error'
86
+ | 'daemon_outdated';
60
87
  epoch: string | null;
61
88
  revision: number | null;
62
89
  lastVerifiedAt: string | null;
@@ -66,6 +93,8 @@ export interface IAuthSwitchSubscriptionOptions {
66
93
  onStatus?: (status: IAuthSwitchSubscriptionStatus) => void | Promise<void>;
67
94
  /** Bounded long-poll interval; defaults to 30 seconds. */
68
95
  heartbeatMs?: number;
96
+ /** Deliver snapshots that also carry removed accounts and logins; see `IReq_AuthSwitchSnapshot`. */
97
+ includeRemoved?: boolean;
69
98
  }
70
99
 
71
100
  /**
@@ -96,8 +125,10 @@ export class AuthSwitchClient {
96
125
  .fire(request, { timeoutMs, maxRetries: 0, abortSignal: signal });
97
126
  }
98
127
 
128
+ /** One snapshot page; an older daemon's answer fails with `AuthSwitchDaemonOutdatedError`. */
99
129
  public async snapshot(options: IReq_AuthSwitchSnapshot['request'] = {}, signal?: AbortSignal): Promise<IAuthSwitchSnapshot> {
100
- return (await this.request<IReq_AuthSwitchSnapshot>('authswitch.authority.snapshot', options, 35_000, false, signal)).snapshot;
130
+ return assertCurrentSnapshot((await this.request<IReq_AuthSwitchSnapshot>('authswitch.authority.snapshot',
131
+ options, 35_000, false, signal)).snapshot);
101
132
  }
102
133
 
103
134
  /** One independently stamped diagnostic page; callers may continue with its independent cursors. */
@@ -132,30 +163,39 @@ export class AuthSwitchClient {
132
163
  }
133
164
 
134
165
  /** Assemble a consistent view from bounded database pages; restart if a writer changes the revision. */
135
- public async snapshotAll(options: { limit?: number; signal?: AbortSignal } = {}): Promise<IAuthSwitchSnapshot> {
166
+ public async snapshotAll(options: { limit?: number; includeRemoved?: boolean; signal?: AbortSignal } = {}): Promise<IAuthSwitchSnapshot> {
136
167
  while (true) {
137
168
  if (options.signal?.aborted) throw options.signal.reason ?? new Error('Account snapshot cancelled.');
138
169
  let accountAfter: string | undefined;
139
170
  let loginAfter: string | undefined;
140
171
  let bindingAfter: string | undefined;
172
+ let nativeAssignmentAfter: string | undefined;
141
173
  let aggregate: IAuthSwitchSnapshot | undefined;
142
174
  let changed = false;
143
175
  while (true) {
144
- const page = await this.snapshot({ accountAfter, loginAfter, bindingAfter, limit: options.limit }, options.signal);
145
- if (!aggregate) aggregate = { ...page, accounts: [...page.accounts], logins: [...page.logins], bindings: [...page.bindings] };
146
- else if (aggregate.epoch !== page.epoch || aggregate.revision !== page.revision) {
176
+ const page = await this.snapshot({ accountAfter, loginAfter, bindingAfter, nativeAssignmentAfter,
177
+ limit: options.limit, ...(options.includeRemoved ? { includeRemoved: true } : {}) }, options.signal);
178
+ if (!aggregate) {
179
+ aggregate = { ...page, accounts: [...page.accounts], logins: [...page.logins], bindings: [...page.bindings],
180
+ nativeAssignments: [...page.nativeAssignments] };
181
+ } else if (aggregate.epoch !== page.epoch || aggregate.revision !== page.revision) {
147
182
  changed = true;
148
183
  break;
149
184
  } else {
150
185
  aggregate.accounts.push(...page.accounts);
151
186
  aggregate.logins.push(...page.logins);
152
187
  aggregate.bindings.push(...page.bindings);
188
+ aggregate.nativeAssignments.push(...page.nativeAssignments);
153
189
  }
154
190
  accountAfter = page.nextAccountCursor ?? accountAfter ?? page.accounts.at(-1)?.id;
155
191
  loginAfter = page.nextLoginCursor ?? loginAfter ?? page.logins.at(-1)?.id;
156
192
  bindingAfter = page.nextBindingCursor ?? bindingAfter ?? page.bindings.at(-1)?.id;
157
- if (!page.nextAccountCursor && !page.nextLoginCursor && !page.nextBindingCursor) {
158
- return { ...aggregate, nextAccountCursor: null, nextLoginCursor: null, nextBindingCursor: null };
193
+ nativeAssignmentAfter = page.nextNativeAssignmentCursor ?? nativeAssignmentAfter
194
+ ?? page.nativeAssignments.at(-1)?.id;
195
+ if (!page.nextAccountCursor && !page.nextLoginCursor && !page.nextBindingCursor
196
+ && !page.nextNativeAssignmentCursor) {
197
+ return { ...aggregate, nextAccountCursor: null, nextLoginCursor: null, nextBindingCursor: null,
198
+ nextNativeAssignmentCursor: null };
159
199
  }
160
200
  }
161
201
  if (!changed) throw new Error('Authority snapshot could not complete.');
@@ -174,8 +214,24 @@ export class AuthSwitchClient {
174
214
  if (!Number.isSafeInteger(heartbeatMs) || heartbeatMs < 50 || heartbeatMs > 30_000) {
175
215
  throw new Error('Authswitch subscription heartbeat must be 50–30000 ms.');
176
216
  }
177
- const retry = () => new Promise<void>(resolve => {
178
- const timer = setTimeout(() => { signal.removeEventListener('abort', aborted); resolve(); }, 250);
217
+ /**
218
+ * The pause between reconnection attempts, which never keeps its consumer's process alive.
219
+ *
220
+ * A subscription that cannot reach the daemon re-arms this timer for as long as that lasts, and during
221
+ * each pause it is the only handle this client holds -- the failed request already destroyed its socket.
222
+ * Referenced, that chain of 250 ms waits would hold an otherwise finished process open indefinitely,
223
+ * and it has nothing to finish: the wait itself does no work, and an abort clears it.
224
+ *
225
+ * An abort that already happened is checked before the timer is armed, because a signal fires once: a
226
+ * consumer that stops from inside its own disconnect callback aborts between this loop's last check and
227
+ * this call, and a listener added afterwards would never run. The pause would then be waited out in
228
+ * full, and since it holds nothing, a process whose only remaining handle it is can exit inside it --
229
+ * dropping the closing status this loop owes its consumer.
230
+ */
231
+ const retry = (delayMs = 250) => new Promise<void>(resolve => {
232
+ if (signal.aborted) { resolve(); return; }
233
+ const timer = setTimeout(() => { signal.removeEventListener('abort', aborted); resolve(); }, delayMs);
234
+ timer.unref();
179
235
  const aborted = () => { clearTimeout(timer); resolve(); };
180
236
  signal.addEventListener('abort', aborted, { once: true });
181
237
  });
@@ -192,19 +248,34 @@ export class AuthSwitchClient {
192
248
  snapshot = undefined;
193
249
  if (available) { available = false; await status('unavailable', reason); }
194
250
  };
251
+ /**
252
+ * An older daemon answers every attempt the same way until it is restarted, so the consumer is told why once
253
+ * per outage, and the loop keeps trying at a slower pace: the restart that repairs it needs nothing else.
254
+ */
255
+ let outdatedReported = false;
256
+ const unreadable = async (error: unknown): Promise<boolean> => {
257
+ if (!(error instanceof AuthSwitchDaemonOutdatedError)) return false;
258
+ snapshot = undefined;
259
+ available = false;
260
+ if (!outdatedReported) { outdatedReported = true; await status('unavailable', 'daemon_outdated'); }
261
+ await retry(5_000);
262
+ return true;
263
+ };
195
264
  try {
196
265
  if (!signal.aborted) await status('unavailable', 'initial');
197
266
  while (!signal.aborted) {
198
267
  if (!snapshot) {
199
268
  let fresh: IAuthSwitchSnapshot;
200
- try { fresh = await this.snapshotAll({ signal }); }
201
- catch {
269
+ try { fresh = await this.snapshotAll({ signal, includeRemoved: options.includeRemoved }); }
270
+ catch (error) {
202
271
  if (signal.aborted) break;
272
+ if (await unreadable(error)) continue;
203
273
  await invalidate('disconnect');
204
274
  await retry();
205
275
  continue;
206
276
  }
207
277
  snapshot = fresh;
278
+ outdatedReported = false;
208
279
  await onSnapshot(fresh);
209
280
  available = true;
210
281
  lastEpoch = fresh.epoch;
@@ -227,9 +298,10 @@ export class AuthSwitchClient {
227
298
  }
228
299
  if (result.events.length) {
229
300
  let fresh: IAuthSwitchSnapshot;
230
- try { fresh = await this.snapshotAll({ signal }); }
231
- catch {
301
+ try { fresh = await this.snapshotAll({ signal, includeRemoved: options.includeRemoved }); }
302
+ catch (error) {
232
303
  if (signal.aborted) break;
304
+ if (await unreadable(error)) continue;
233
305
  await invalidate('disconnect');
234
306
  await retry();
235
307
  continue;
@@ -274,6 +346,16 @@ export class AuthSwitchClient {
274
346
  return (await this.request<IReq_AuthSwitchGetOperation>('authswitch.authority.operation',
275
347
  { operationId }, 35_000, false, signal)).operation;
276
348
  }
349
+ /**
350
+ * Waits up to `waitMs` for the sign-in to change past `afterRevision` and answers with it; a finished
351
+ * sign-in answers at once, and a timed-out wait with the unchanged operation. Follow a sign-in by passing
352
+ * each answer's `revision` back until its state is final.
353
+ */
354
+ public async watchOperation(operationId: string, afterRevision: number, waitMs = 30_000,
355
+ signal?: AbortSignal): Promise<IReq_AuthSwitchGetOperation['response']['operation']> {
356
+ return (await this.request<IReq_AuthSwitchGetOperation>('authswitch.authority.operation',
357
+ { operationId, afterRevision, waitMs }, waitMs + 5_000, false, signal)).operation;
358
+ }
277
359
  public async cancelOperation(operationId: string, signal?: AbortSignal): Promise<IReq_AuthSwitchCancelOperation['response']['operation']> {
278
360
  return (await this.request<IReq_AuthSwitchCancelOperation>('authswitch.authority.cancel',
279
361
  { operationId }, 35_000, false, signal)).operation;
@@ -318,6 +400,11 @@ export class AuthSwitchClient {
318
400
  return this.request<IReq_AuthSwitchClaudeNativeHandoffs>('authswitch.authority.claude.handoffs',
319
401
  { after, limit }, 35_000, false, signal);
320
402
  }
403
+ /** The binding `bind` returned, credential-free, or null once this authority holds none by that id. */
404
+ public async getBinding(bindingId: string, signal?: AbortSignal): Promise<IReq_AuthSwitchGetBinding['response']['binding']> {
405
+ return (await this.request<IReq_AuthSwitchGetBinding>('authswitch.authority.binding',
406
+ { bindingId }, 35_000, false, signal)).binding;
407
+ }
321
408
  public bindAccount(input: IReq_AuthSwitchBindAccount['request'],
322
409
  signal?: AbortSignal): Promise<IReq_AuthSwitchBindAccount['response']> {
323
410
  return this.request<IReq_AuthSwitchBindAccount>('authswitch.authority.bind', input, 35_000, false, signal);