@modelprofile.com/authswitch 9.0.0 → 9.2.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 (38) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/authority-contract.d.ts +71 -1
  3. package/dist_ts/authority-contract.js +13 -2
  4. package/dist_ts/authority-import-contract.d.ts +6 -0
  5. package/dist_ts/authority-runtime-contract.d.ts +29 -0
  6. package/dist_ts/classes.authoritybroker.d.ts +37 -15
  7. package/dist_ts/classes.authoritybroker.js +121 -35
  8. package/dist_ts/classes.authorityclient.d.ts +28 -3
  9. package/dist_ts/classes.authorityclient.js +101 -18
  10. package/dist_ts/classes.authoritydaemon.d.ts +24 -0
  11. package/dist_ts/classes.authoritydaemon.js +87 -40
  12. package/dist_ts/classes.authoritydatabase.d.ts +29 -4
  13. package/dist_ts/classes.authoritydatabase.js +98 -13
  14. package/dist_ts/classes.authorityimport.js +2 -2
  15. package/dist_ts/classes.authoritymodels.js +5 -3
  16. package/dist_ts/classes.codexmanaged.d.ts +0 -9
  17. package/dist_ts/classes.codexmanaged.js +8 -28
  18. package/dist_ts/codexcontract.d.ts +30 -0
  19. package/dist_ts/codexcontract.js +174 -0
  20. package/dist_ts/ts_migration/0004_container_setup_owner.d.ts +12 -0
  21. package/dist_ts/ts_migration/0004_container_setup_owner.js +19 -0
  22. package/dist_ts/ts_migration/index.js +3 -1
  23. package/package.json +8 -8
  24. package/readme.md +102 -25
  25. package/ts/00_commitinfo_data.ts +1 -1
  26. package/ts/authority-contract.ts +75 -4
  27. package/ts/authority-import-contract.ts +6 -0
  28. package/ts/authority-runtime-contract.ts +30 -0
  29. package/ts/classes.authoritybroker.ts +122 -36
  30. package/ts/classes.authorityclient.ts +107 -21
  31. package/ts/classes.authoritydaemon.ts +91 -33
  32. package/ts/classes.authoritydatabase.ts +100 -14
  33. package/ts/classes.authorityimport.ts +1 -1
  34. package/ts/classes.authoritymodels.ts +4 -1
  35. package/ts/classes.codexmanaged.ts +6 -26
  36. package/ts/codexcontract.ts +200 -0
  37. package/ts/ts_migration/0004_container_setup_owner.ts +19 -0
  38. package/ts/ts_migration/index.ts +2 -0
@@ -1,9 +1,10 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import type { IAuthSwitchAccount, IAuthSwitchAccountEvent, IAuthSwitchBinding, IAuthSwitchLogin,
3
- IAuthSwitchOperation, IAuthSwitchSnapshot, TAuthSwitchLoginOwnerTool } 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;
@@ -70,6 +69,10 @@ const bindingMoved = 'This runtime binding changed while its credential was bein
70
69
  + 'account again.';
71
70
  const bindingNeedsReauth = 'The account no longer holds the login this binding was authorized for. '
72
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.';
73
76
  const publicAccount = (account: IStoredAuthorityAccount): IAuthSwitchAccount => ({
74
77
  id: account.id, providerId: account.providerId, label: account.label, email: account.email,
75
78
  plan: account.plan, removed: account.removed, revision: account.revision,
@@ -133,6 +136,12 @@ const publicBinding = (binding: IStoredAuthorityBinding): IAuthSwitchBinding =>
133
136
  scopeId: binding.scopeId, incarnationId: binding.incarnationId, revision: binding.revision,
134
137
  });
135
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
+
136
145
  interface IManagedOperation {
137
146
  id: string;
138
147
  handle: plugins.flexAccounts.ISmartAiProviderLoginHandle;
@@ -154,6 +163,8 @@ export class AuthSwitchAuthorityBroker {
154
163
  private readonly operations = new Map<string, IManagedOperation>();
155
164
  private readonly refreshes = new Map<string, Promise<void>>();
156
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>();
157
168
  private claudeRefresh?: (grantId: string) => Promise<void>;
158
169
  private maintenance?: Promise<void>;
159
170
  private timer?: NodeJS.Timeout;
@@ -267,19 +278,23 @@ export class AuthSwitchAuthorityBroker {
267
278
  for (const listener of this.listeners) listener();
268
279
  }
269
280
 
270
- public async snapshot(options: { accountAfter?: string; loginAfter?: string; bindingAfter?: string; limit?: number } = {}): Promise<IAuthSwitchSnapshot> {
281
+ public async snapshot(options: IReq_AuthSwitchSnapshot['request'] = {}): Promise<IAuthSwitchSnapshot> {
271
282
  if ((options.accountAfter !== undefined && !isId(options.accountAfter))
272
283
  || (options.loginAfter !== undefined && !isId(options.loginAfter))
273
- || (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
+ }
274
288
  const page = await this.database.page(options.accountAfter ?? null, options.loginAfter ?? null,
275
- options.bindingAfter ?? null, options.limit ?? 128);
289
+ options.bindingAfter ?? null, options.nativeAssignmentAfter ?? null, options.limit ?? 128);
276
290
  return { schemaVersion: 2, epoch: page.meta.epoch, revision: page.meta.revision,
277
291
  generatedAt: new Date(this.now()).toISOString(),
278
- accounts: page.accounts.filter(account => !account.removed).map(publicAccount),
279
- 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),
280
294
  bindings: page.bindings.map(publicBinding),
295
+ nativeAssignments: page.claudeHomes.map(publicClaudeAssignment),
281
296
  nextAccountCursor: page.nextAccountCursor, nextLoginCursor: page.nextGrantCursor,
282
- nextBindingCursor: page.nextBindingCursor };
297
+ nextBindingCursor: page.nextBindingCursor, nextNativeAssignmentCursor: page.nextClaudeHomeCursor };
283
298
  }
284
299
 
285
300
  public async events(epoch: string, afterRevision: number, waitMs: number, signal?: AbortSignal): Promise<{
@@ -554,11 +569,46 @@ export class AuthSwitchAuthorityBroker {
554
569
  return publicOperation(pending);
555
570
  }
556
571
 
557
- 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> {
558
580
  if (!isUuid(operationId)) throw new Error('Invalid operation ID.');
559
- const operation = await this.database.readOperation(operationId);
560
- if (!operation || operation.kind === 'preuse_openai') throw new AuthSwitchRefusal('not_found', unknownOperation);
561
- 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
+ });
562
612
  }
563
613
 
564
614
  public async listOperations(after: string | null, limit = 128): Promise<{ operations: IAuthSwitchOperation[]; nextCursor: string | null }> {
@@ -595,7 +645,11 @@ export class AuthSwitchAuthorityBroker {
595
645
  }
596
646
 
597
647
  public async renameAccount(accountId: string, expectedRevision: number, label: string): Promise<IAuthSwitchAccount> {
598
- 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
+ }
599
653
  const updateId = plugins.crypto.randomUUID();
600
654
  const updated = await this.database.changeAccount(updateId, accountId, account => {
601
655
  if (!account || account.removed || account.revision !== expectedRevision) throw new AuthSwitchRefusal('account_changed', 'Account changed; refresh before editing.');
@@ -624,21 +678,28 @@ export class AuthSwitchAuthorityBroker {
624
678
  const capability = plugins.crypto.randomBytes(32).toString('base64url');
625
679
  const capabilityHash = idHash(capability);
626
680
  const updateId = plugins.crypto.randomUUID();
627
- const updated = await this.database.changeBinding(updateId, id, input.accountId, input.loginId, (existing, account, grant) => {
681
+ const updated = await this.database.changeBinding(updateId, id, input.accountId, input.loginId, (_existing, account, grant, revision) => {
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).
628
684
  if (account.removed || grant?.state !== 'ready' || grant.id !== account.primaryGrantId
629
685
  || grant.owner !== 'daemon'
630
- || grant.purpose !== input.purpose || account.providerId !== 'openai'
631
- || input.runtime === 'claude') {
686
+ || grant.purpose !== input.purpose || account.providerId !== 'openai') {
632
687
  throw new AuthSwitchRefusal('login_unavailable', loginNotBindable);
633
688
  }
634
689
  return { id, accountId: input.accountId, grantId: grant.id, runtime: input.runtime, scopeId: input.scopeId,
635
- incarnationId: input.incarnationId, revision: (existing?.revision ?? 0) + 1,
690
+ incarnationId: input.incarnationId, revision,
636
691
  grantAuthorizationGeneration: grant.authorizationGeneration, capabilityHash, updateId };
637
692
  });
638
693
  this.publish();
639
694
  return { binding: publicBinding(updated.binding), capability };
640
695
  }
641
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
+
642
703
  public async revokeBinding(bindingId: string, capability: string): Promise<boolean> {
643
704
  if (!isId(bindingId) || !/^[A-Za-z0-9_-]{43}$/.test(capability)) throw new Error('Invalid runtime binding revocation.');
644
705
  const revoked = await this.database.revokeBinding(plugins.crypto.randomUUID(), bindingId,
@@ -648,13 +709,37 @@ export class AuthSwitchAuthorityBroker {
648
709
  }
649
710
 
650
711
  public async releaseExternalBinding(bindingId: string, capability: string): Promise<'released' | 'inactive'> {
651
- if (!isId(bindingId) || !/^[A-Za-z0-9_-]{43}$/.test(capability)) throw new Error('Invalid runtime binding release.');
712
+ if (!/^[A-Za-z0-9_-]{43}$/.test(capability)) throw new Error('Invalid runtime binding release.');
713
+ return this.releaseExternalCapability(bindingId, authSwitchBindingCapabilityHash(capability));
714
+ }
715
+
716
+ /** Releases a caller-held binding while it still carries exactly this capability, named by its hash. */
717
+ public async releaseExternalCapability(bindingId: string, capabilityHash: string): Promise<'released' | 'inactive'> {
718
+ if (!isId(bindingId) || !isId(capabilityHash)) throw new Error('Invalid runtime binding release.');
652
719
  const released = await this.database.releaseExternalBinding(plugins.crypto.randomUUID(), bindingId,
653
- authSwitchBindingCapabilityHash(capability));
720
+ capabilityHash);
654
721
  if (released) this.publish();
655
722
  return released ? 'released' : 'inactive';
656
723
  }
657
724
 
725
+ /**
726
+ * The caller-held binding a holder names by the identity it bound under, while it is still exactly the
727
+ * incarnation and revision the holder read, or `null`. Its capability hash is what a release then fences
728
+ * and compares, so a successor bound after this read is never released in its place.
729
+ */
730
+ public async findHeldBinding(input: { runtime: Exclude<IAuthSwitchBinding['runtime'], 'codex'>; scopeId: string;
731
+ incarnationId: string; expectedRevision: number }): Promise<{ bindingId: string; capabilityHash: string } | null> {
732
+ if (!['flex', 'opencode', 'claude'].includes(input.runtime) || !safeScope(input.scopeId, 512)
733
+ || !safeScope(input.incarnationId, 256) || !validRevision(input.expectedRevision)) {
734
+ throw new Error('Invalid runtime binding release.');
735
+ }
736
+ const bindingId = idHash('authswitch-binding-v1', input.runtime, input.scopeId);
737
+ const binding = await this.database.readBinding(bindingId);
738
+ if (!binding || binding.runtime !== input.runtime || binding.scopeId !== input.scopeId
739
+ || binding.incarnationId !== input.incarnationId || binding.revision !== input.expectedRevision) return null;
740
+ return { bindingId, capabilityHash: binding.capabilityHash };
741
+ }
742
+
658
743
  public async resolveAccess(bindingId: string, capability: string, minValidityMs: number,
659
744
  rejectedGrantGeneration?: number): Promise<IAuthSwitchResolvedAccess> {
660
745
  if (!isId(bindingId) || !/^[A-Za-z0-9_-]{43}$/.test(capability)
@@ -710,13 +795,12 @@ export class AuthSwitchAuthorityBroker {
710
795
  }
711
796
 
712
797
  /**
713
- * The shared managed-access loop, whose refusals are deliberately still unmarked faults.
714
- *
715
- * Its two callers repair differently: a runtime holding a binding binds again, while the usage reader has
716
- * no binding at all and collapses everything into one usage problem. Naming a code here would either say
717
- * "binding" to a caller that has none, or invent a second meaning for one that does, so the decision waits
718
- * for the slice that gives the usage answer its own vocabulary. What a bound runtime can act on is decided
719
- * before this point, in the view `resolveAccess` supplies.
798
+ * The shared managed-access loop. What it decides about the login itself is a marked refusal both callers
799
+ * read the same way: `login_needs_reauth` when only a new sign-in brings the login back, `access_not_fresh`
800
+ * when the login is intact and the provider could not renew it yet. The usage reader still folds either
801
+ * into its own problem; a bound runtime shows the instruction. What only concerns a binding is decided
802
+ * before this point, in the view `resolveAccess` supplies, and a changed identity or a view that keeps
803
+ * moving stays an unmarked fault, because nobody decided it.
720
804
  */
721
805
  private async resolveManagedAccess(readView: () => Promise<{
722
806
  account: IStoredAuthorityAccount; grant: IStoredAuthorityGrant;
@@ -725,20 +809,20 @@ export class AuthSwitchAuthorityBroker {
725
809
  const { account, grant } = await readView();
726
810
  if (account.removed || grant.accountId !== account.id || grant.id !== account.primaryGrantId
727
811
  || account.providerId !== 'openai' || grant.providerId !== 'openai') {
728
- throw new Error('Account needs reauthentication.');
812
+ throw new AuthSwitchRefusal('login_needs_reauth', loginNeedsReauth);
729
813
  }
730
814
  if (rejectedGrantGeneration !== undefined && rejectedGrantGeneration > grant.grantGeneration) {
731
815
  throw new Error('Provider rejected a future account grant generation.');
732
816
  }
733
817
  if (grant.state === 'exchange_may_have_been_sent') {
734
818
  const inFlight = this.refreshes.get(account.id);
735
- if (!inFlight) throw new Error('Account refresh is unresolved; reauthentication is required.');
819
+ if (!inFlight) throw new AuthSwitchRefusal('login_needs_reauth', loginNeedsReauth);
736
820
  await inFlight;
737
821
  continue;
738
822
  }
739
823
  if (grant.owner !== 'daemon' || grant.purpose !== 'openai_managed'
740
824
  || !['ready', 'retry_wait'].includes(grant.state)) {
741
- throw new Error('Account needs reauthentication.');
825
+ throw new AuthSwitchRefusal('login_needs_reauth', loginNeedsReauth);
742
826
  }
743
827
  const rejectedCurrent = rejectedGrantGeneration === grant.grantGeneration;
744
828
  if (this.isDue(grant, minValidityMs) || rejectedCurrent) {
@@ -747,9 +831,9 @@ export class AuthSwitchAuthorityBroker {
747
831
  rejectedCurrent ? rejectedGrantGeneration : undefined);
748
832
  continue;
749
833
  }
750
- throw new Error('Account access credential is not fresh enough.');
834
+ throw new AuthSwitchRefusal('access_not_fresh', accessNotFresh);
751
835
  }
752
- if (!grant.accessExpiresAt) throw new Error('Account access credential is not fresh enough.');
836
+ if (!grant.accessExpiresAt) throw new AuthSwitchRefusal('access_not_fresh', accessNotFresh);
753
837
  const credential = await this.unsealCredential(account, grant);
754
838
  const info = plugins.flexAuth.parseOpenAiChatGptTokenInfo(credential.accessToken);
755
839
  if (info.chatgptAccountId !== account.workspaceId || info.chatgptUserId !== account.subject) {
@@ -767,7 +851,7 @@ export class AuthSwitchAuthorityBroker {
767
851
  || latest.grant.state === 'exchange_may_have_been_sent') continue;
768
852
  if (!['ready', 'retry_wait'].includes(latest.grant.state) || this.isDue(latest.grant, minValidityMs)
769
853
  || (rejectedGrantGeneration !== undefined && latest.grant.grantGeneration === rejectedGrantGeneration)) {
770
- throw new Error('Account access credential is not fresh enough.');
854
+ throw new AuthSwitchRefusal('access_not_fresh', accessNotFresh);
771
855
  }
772
856
  return { accessToken: credential.accessToken, accountId: account.workspaceId,
773
857
  isFedrampAccount: info.chatgptAccountIsFedramp, expiresAt: grant.accessExpiresAt,
@@ -852,7 +936,8 @@ export class AuthSwitchAuthorityBroker {
852
936
  });
853
937
  this.publish();
854
938
  } catch { /* A persisted attempt marker forces needs_reauth on daemon restart. */ }
855
- throw new Error('Refresh result is uncertain. Reauthenticate this account; its old grant will not be replayed.');
939
+ // The grant is now `needs_reauth`: an uncertain rotation is never replayed, so only a sign-in repairs it.
940
+ throw new AuthSwitchRefusal('login_needs_reauth', loginNeedsReauth);
856
941
  }
857
942
  }
858
943
 
@@ -861,6 +946,7 @@ export class AuthSwitchAuthorityBroker {
861
946
  this.closed = true;
862
947
  if (this.timer) clearTimeout(this.timer);
863
948
  for (const wake of this.listeners) wake();
949
+ for (const wake of this.operationWaiters) wake();
864
950
  this.closing = (async () => {
865
951
  if (this.maintenance) await this.maintenance;
866
952
  await Promise.allSettled([...this.operations.values()].map(item => item.handle.cancel()));
@@ -5,35 +5,43 @@ 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';
12
13
  import type { IReq_AuthSwitchBindAccount, IReq_AuthSwitchReleaseBinding,
13
- IReq_AuthSwitchResolveAccess } from './authority-runtime-contract.js';
14
+ IReq_AuthSwitchResolveAccess, IReq_AuthSwitchUnbind } from './authority-runtime-contract.js';
14
15
  import type { IReq_AuthSwitchImportInventory, IReq_AuthSwitchImportStatus,
15
16
  IReq_AuthSwitchImportSubmit } from './authority-import-contract.js';
16
17
  import { AuthSwitchAuthorityFrameReader, authSwitchAuthorityFrameBytes,
17
18
  maxAuthSwitchAuthorityFrameBytes } from './classes.authorityframing.js';
18
19
 
20
+ /**
21
+ * One request on one connection, bounded by its own deadline alone.
22
+ *
23
+ * `TypedRequest.fire` turns each request's `timeoutMs` into the abort of `signal`, so the transport arms no
24
+ * timer of its own: an idle limit here would end a request that was allowed longer -- an import submit is
25
+ * allowed 120 s -- before its deadline. Every authority request carries one; a request without it is refused.
26
+ */
19
27
  const post = (path: string, payload: ITypedRequest, signal?: AbortSignal): Promise<ITypedRequest> => {
20
28
  return new Promise((resolve, reject) => {
21
- if (signal?.aborted) { reject(signal.reason); return; }
29
+ if (!signal) { reject(new Error('Authswitch daemon requests need a deadline.')); return; }
30
+ if (signal.aborted) { reject(signal.reason); return; }
22
31
  const socket = plugins.net.createConnection(path);
23
32
  let settled = false;
24
33
  const frame = new AuthSwitchAuthorityFrameReader();
25
34
  const finish = (error?: unknown, response?: ITypedRequest): void => {
26
35
  if (settled) return;
27
36
  settled = true;
28
- signal?.removeEventListener('abort', abort);
37
+ signal.removeEventListener('abort', abort);
29
38
  socket.destroy();
30
39
  if (error) reject(error);
31
40
  else if (response) resolve(response);
32
41
  else reject(new Error('Authswitch daemon returned no response.'));
33
42
  };
34
- const abort = () => finish(signal?.reason ?? new Error('Account request was cancelled.'));
35
- signal?.addEventListener('abort', abort, { once: true });
36
- socket.setTimeout(35_000, () => finish(new Error('Authswitch daemon request timed out.')));
43
+ const abort = () => finish(signal.reason ?? new Error('Account request was cancelled.'));
44
+ signal.addEventListener('abort', abort, { once: true });
37
45
  socket.on('connect', () => {
38
46
  const raw = JSON.stringify(payload);
39
47
  if (authSwitchAuthorityFrameBytes(raw) > maxAuthSwitchAuthorityFrameBytes) { finish(new Error('Account request is too large.')); return; }
@@ -53,10 +61,36 @@ const post = (path: string, payload: ITypedRequest, signal?: AbortSignal): Promi
53
61
  });
54
62
  };
55
63
 
64
+ /**
65
+ * The daemon answered with an older contract than this client reads: it runs an authswitch release from before
66
+ * this one and was not restarted after the upgrade. Restarting it onto the installed release is the repair.
67
+ */
68
+ export class AuthSwitchDaemonOutdatedError extends Error {
69
+ constructor() {
70
+ super('The authswitch authority daemon runs an older release than this client. Restart it onto the '
71
+ + 'installed authswitch: authswitch authority service stop, then authswitch authority service start.');
72
+ this.name = 'AuthSwitchDaemonOutdatedError';
73
+ }
74
+ }
75
+
76
+ /**
77
+ * A snapshot page carries every member of this release's contract. `schemaVersion` is not bumped for an
78
+ * additive member, so an older daemon is told apart by the members its answer lacks.
79
+ */
80
+ const assertCurrentSnapshot = (snapshot: IAuthSwitchSnapshot): IAuthSwitchSnapshot => {
81
+ const page: Partial<IAuthSwitchSnapshot> = snapshot;
82
+ if (!Array.isArray(page.nativeAssignments) || page.nextNativeAssignmentCursor === undefined) {
83
+ throw new AuthSwitchDaemonOutdatedError();
84
+ }
85
+ return snapshot;
86
+ };
87
+
56
88
  /** A snapshot is usable only while state is current; lastVerifiedAt is the last successful daemon proof. */
57
89
  export interface IAuthSwitchSubscriptionStatus {
58
90
  state: 'current' | 'unavailable' | 'closed';
59
- reason: 'initial' | 'snapshot' | 'event' | 'heartbeat' | 'disconnect' | 'resync' | 'abort' | 'consumer_error';
91
+ /** `daemon_outdated`: the daemon runs an older release; see `AuthSwitchDaemonOutdatedError`. */
92
+ reason: 'initial' | 'snapshot' | 'event' | 'heartbeat' | 'disconnect' | 'resync' | 'abort' | 'consumer_error'
93
+ | 'daemon_outdated';
60
94
  epoch: string | null;
61
95
  revision: number | null;
62
96
  lastVerifiedAt: string | null;
@@ -66,6 +100,8 @@ export interface IAuthSwitchSubscriptionOptions {
66
100
  onStatus?: (status: IAuthSwitchSubscriptionStatus) => void | Promise<void>;
67
101
  /** Bounded long-poll interval; defaults to 30 seconds. */
68
102
  heartbeatMs?: number;
103
+ /** Deliver snapshots that also carry removed accounts and logins; see `IReq_AuthSwitchSnapshot`. */
104
+ includeRemoved?: boolean;
69
105
  }
70
106
 
71
107
  /**
@@ -96,8 +132,10 @@ export class AuthSwitchClient {
96
132
  .fire(request, { timeoutMs, maxRetries: 0, abortSignal: signal });
97
133
  }
98
134
 
135
+ /** One snapshot page; an older daemon's answer fails with `AuthSwitchDaemonOutdatedError`. */
99
136
  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;
137
+ return assertCurrentSnapshot((await this.request<IReq_AuthSwitchSnapshot>('authswitch.authority.snapshot',
138
+ options, 35_000, false, signal)).snapshot);
101
139
  }
102
140
 
103
141
  /** One independently stamped diagnostic page; callers may continue with its independent cursors. */
@@ -132,30 +170,39 @@ export class AuthSwitchClient {
132
170
  }
133
171
 
134
172
  /** 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> {
173
+ public async snapshotAll(options: { limit?: number; includeRemoved?: boolean; signal?: AbortSignal } = {}): Promise<IAuthSwitchSnapshot> {
136
174
  while (true) {
137
175
  if (options.signal?.aborted) throw options.signal.reason ?? new Error('Account snapshot cancelled.');
138
176
  let accountAfter: string | undefined;
139
177
  let loginAfter: string | undefined;
140
178
  let bindingAfter: string | undefined;
179
+ let nativeAssignmentAfter: string | undefined;
141
180
  let aggregate: IAuthSwitchSnapshot | undefined;
142
181
  let changed = false;
143
182
  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) {
183
+ const page = await this.snapshot({ accountAfter, loginAfter, bindingAfter, nativeAssignmentAfter,
184
+ limit: options.limit, ...(options.includeRemoved ? { includeRemoved: true } : {}) }, options.signal);
185
+ if (!aggregate) {
186
+ aggregate = { ...page, accounts: [...page.accounts], logins: [...page.logins], bindings: [...page.bindings],
187
+ nativeAssignments: [...page.nativeAssignments] };
188
+ } else if (aggregate.epoch !== page.epoch || aggregate.revision !== page.revision) {
147
189
  changed = true;
148
190
  break;
149
191
  } else {
150
192
  aggregate.accounts.push(...page.accounts);
151
193
  aggregate.logins.push(...page.logins);
152
194
  aggregate.bindings.push(...page.bindings);
195
+ aggregate.nativeAssignments.push(...page.nativeAssignments);
153
196
  }
154
197
  accountAfter = page.nextAccountCursor ?? accountAfter ?? page.accounts.at(-1)?.id;
155
198
  loginAfter = page.nextLoginCursor ?? loginAfter ?? page.logins.at(-1)?.id;
156
199
  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 };
200
+ nativeAssignmentAfter = page.nextNativeAssignmentCursor ?? nativeAssignmentAfter
201
+ ?? page.nativeAssignments.at(-1)?.id;
202
+ if (!page.nextAccountCursor && !page.nextLoginCursor && !page.nextBindingCursor
203
+ && !page.nextNativeAssignmentCursor) {
204
+ return { ...aggregate, nextAccountCursor: null, nextLoginCursor: null, nextBindingCursor: null,
205
+ nextNativeAssignmentCursor: null };
159
206
  }
160
207
  }
161
208
  if (!changed) throw new Error('Authority snapshot could not complete.');
@@ -188,9 +235,9 @@ export class AuthSwitchClient {
188
235
  * full, and since it holds nothing, a process whose only remaining handle it is can exit inside it --
189
236
  * dropping the closing status this loop owes its consumer.
190
237
  */
191
- const retry = () => new Promise<void>(resolve => {
238
+ const retry = (delayMs = 250) => new Promise<void>(resolve => {
192
239
  if (signal.aborted) { resolve(); return; }
193
- const timer = setTimeout(() => { signal.removeEventListener('abort', aborted); resolve(); }, 250);
240
+ const timer = setTimeout(() => { signal.removeEventListener('abort', aborted); resolve(); }, delayMs);
194
241
  timer.unref();
195
242
  const aborted = () => { clearTimeout(timer); resolve(); };
196
243
  signal.addEventListener('abort', aborted, { once: true });
@@ -208,19 +255,34 @@ export class AuthSwitchClient {
208
255
  snapshot = undefined;
209
256
  if (available) { available = false; await status('unavailable', reason); }
210
257
  };
258
+ /**
259
+ * An older daemon answers every attempt the same way until it is restarted, so the consumer is told why once
260
+ * per outage, and the loop keeps trying at a slower pace: the restart that repairs it needs nothing else.
261
+ */
262
+ let outdatedReported = false;
263
+ const unreadable = async (error: unknown): Promise<boolean> => {
264
+ if (!(error instanceof AuthSwitchDaemonOutdatedError)) return false;
265
+ snapshot = undefined;
266
+ available = false;
267
+ if (!outdatedReported) { outdatedReported = true; await status('unavailable', 'daemon_outdated'); }
268
+ await retry(5_000);
269
+ return true;
270
+ };
211
271
  try {
212
272
  if (!signal.aborted) await status('unavailable', 'initial');
213
273
  while (!signal.aborted) {
214
274
  if (!snapshot) {
215
275
  let fresh: IAuthSwitchSnapshot;
216
- try { fresh = await this.snapshotAll({ signal }); }
217
- catch {
276
+ try { fresh = await this.snapshotAll({ signal, includeRemoved: options.includeRemoved }); }
277
+ catch (error) {
218
278
  if (signal.aborted) break;
279
+ if (await unreadable(error)) continue;
219
280
  await invalidate('disconnect');
220
281
  await retry();
221
282
  continue;
222
283
  }
223
284
  snapshot = fresh;
285
+ outdatedReported = false;
224
286
  await onSnapshot(fresh);
225
287
  available = true;
226
288
  lastEpoch = fresh.epoch;
@@ -243,9 +305,10 @@ export class AuthSwitchClient {
243
305
  }
244
306
  if (result.events.length) {
245
307
  let fresh: IAuthSwitchSnapshot;
246
- try { fresh = await this.snapshotAll({ signal }); }
247
- catch {
308
+ try { fresh = await this.snapshotAll({ signal, includeRemoved: options.includeRemoved }); }
309
+ catch (error) {
248
310
  if (signal.aborted) break;
311
+ if (await unreadable(error)) continue;
249
312
  await invalidate('disconnect');
250
313
  await retry();
251
314
  continue;
@@ -290,6 +353,16 @@ export class AuthSwitchClient {
290
353
  return (await this.request<IReq_AuthSwitchGetOperation>('authswitch.authority.operation',
291
354
  { operationId }, 35_000, false, signal)).operation;
292
355
  }
356
+ /**
357
+ * Waits up to `waitMs` for the sign-in to change past `afterRevision` and answers with it; a finished
358
+ * sign-in answers at once, and a timed-out wait with the unchanged operation. Follow a sign-in by passing
359
+ * each answer's `revision` back until its state is final.
360
+ */
361
+ public async watchOperation(operationId: string, afterRevision: number, waitMs = 30_000,
362
+ signal?: AbortSignal): Promise<IReq_AuthSwitchGetOperation['response']['operation']> {
363
+ return (await this.request<IReq_AuthSwitchGetOperation>('authswitch.authority.operation',
364
+ { operationId, afterRevision, waitMs }, waitMs + 5_000, false, signal)).operation;
365
+ }
293
366
  public async cancelOperation(operationId: string, signal?: AbortSignal): Promise<IReq_AuthSwitchCancelOperation['response']['operation']> {
294
367
  return (await this.request<IReq_AuthSwitchCancelOperation>('authswitch.authority.cancel',
295
368
  { operationId }, 35_000, false, signal)).operation;
@@ -334,10 +407,23 @@ export class AuthSwitchClient {
334
407
  return this.request<IReq_AuthSwitchClaudeNativeHandoffs>('authswitch.authority.claude.handoffs',
335
408
  { after, limit }, 35_000, false, signal);
336
409
  }
410
+ /** The binding `bind` returned, credential-free, or null once this authority holds none by that id. */
411
+ public async getBinding(bindingId: string, signal?: AbortSignal): Promise<IReq_AuthSwitchGetBinding['response']['binding']> {
412
+ return (await this.request<IReq_AuthSwitchGetBinding>('authswitch.authority.binding',
413
+ { bindingId }, 35_000, false, signal)).binding;
414
+ }
337
415
  public bindAccount(input: IReq_AuthSwitchBindAccount['request'],
338
416
  signal?: AbortSignal): Promise<IReq_AuthSwitchBindAccount['response']> {
339
417
  return this.request<IReq_AuthSwitchBindAccount>('authswitch.authority.bind', input, 35_000, false, signal);
340
418
  }
419
+ /**
420
+ * Releases the holder's own binding by the runtime, scope, incarnation and revision it was bound under,
421
+ * without its capability and without a ready login; `inactive` when no binding matches all four.
422
+ */
423
+ public unbind(input: IReq_AuthSwitchUnbind['request'],
424
+ signal?: AbortSignal): Promise<IReq_AuthSwitchUnbind['response']> {
425
+ return this.request<IReq_AuthSwitchUnbind>('authswitch.authority.unbind', input, 35_000, false, signal);
426
+ }
341
427
  public resolveAccess(input: IReq_AuthSwitchResolveAccess['request'],
342
428
  signal?: AbortSignal): Promise<IReq_AuthSwitchResolveAccess['response']> {
343
429
  return this.request<IReq_AuthSwitchResolveAccess>('authswitch.authority.resolveAccess',