@modelprofile.com/authswitch 6.5.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/authority-contract.d.ts +300 -6
  3. package/dist_ts/authority-contract.js +1 -1
  4. package/dist_ts/authority-runtime-contract.d.ts +13 -0
  5. package/dist_ts/classes.accountlist.js +4 -3
  6. package/dist_ts/classes.authoritybroker.d.ts +25 -5
  7. package/dist_ts/classes.authoritybroker.js +388 -123
  8. package/dist_ts/classes.authoritycli.d.ts +24 -0
  9. package/dist_ts/classes.authoritycli.js +675 -0
  10. package/dist_ts/classes.authorityclient.d.ts +19 -7
  11. package/dist_ts/classes.authorityclient.js +51 -14
  12. package/dist_ts/classes.authoritydaemon.d.ts +28 -1
  13. package/dist_ts/classes.authoritydaemon.js +477 -36
  14. package/dist_ts/classes.authoritydatabase.d.ts +155 -11
  15. package/dist_ts/classes.authoritydatabase.js +1037 -69
  16. package/dist_ts/classes.authoritymodels.d.ts +229 -23
  17. package/dist_ts/classes.authoritymodels.js +867 -128
  18. package/dist_ts/classes.authoritypreuse.d.ts +47 -0
  19. package/dist_ts/classes.authoritypreuse.js +277 -0
  20. package/dist_ts/classes.authorityregistry.d.ts +9 -0
  21. package/dist_ts/classes.authorityregistry.js +32 -0
  22. package/dist_ts/classes.authoritysecrets.d.ts +1 -1
  23. package/dist_ts/classes.authoritysecrets.js +2 -2
  24. package/dist_ts/classes.authorityusage.d.ts +160 -0
  25. package/dist_ts/classes.authorityusage.js +429 -0
  26. package/dist_ts/classes.claudeauthority.d.ts +66 -0
  27. package/dist_ts/classes.claudeauthority.js +528 -0
  28. package/dist_ts/classes.claudecodeharness.js +3 -2
  29. package/dist_ts/classes.claudecodelocks.d.ts +13 -0
  30. package/dist_ts/classes.claudecodelocks.js +75 -3
  31. package/dist_ts/classes.claudenative.d.ts +130 -0
  32. package/dist_ts/classes.claudenative.js +708 -0
  33. package/dist_ts/classes.claudestatus.d.ts +9 -0
  34. package/dist_ts/classes.claudestatus.js +26 -9
  35. package/dist_ts/classes.claudetokenrefresh.d.ts +15 -1
  36. package/dist_ts/classes.claudetokenrefresh.js +74 -21
  37. package/dist_ts/classes.codexharness.js +4 -2
  38. package/dist_ts/classes.codexmanaged.d.ts +12 -1
  39. package/dist_ts/classes.codexmanaged.js +75 -14
  40. package/dist_ts/classes.codexpreuse.d.ts +8 -0
  41. package/dist_ts/classes.codexpreuse.js +24 -6
  42. package/dist_ts/classes.fileharness.d.ts +2 -0
  43. package/dist_ts/classes.fileharness.js +6 -4
  44. package/dist_ts/classes.limits.js +12 -7
  45. package/dist_ts/classes.opencodeharness.js +2 -2
  46. package/dist_ts/claudehttp.d.ts +10 -0
  47. package/dist_ts/claudehttp.js +29 -3
  48. package/dist_ts/index.d.ts +1 -0
  49. package/dist_ts/index.js +7 -1
  50. package/dist_ts/interfaces.harness.d.ts +6 -0
  51. package/dist_ts/interfaces.list.d.ts +9 -3
  52. package/dist_ts/plugins.d.ts +6 -3
  53. package/dist_ts/plugins.js +8 -4
  54. package/dist_ts/ts_migration/0001_authority_meta.d.ts +13 -0
  55. package/dist_ts/ts_migration/0001_authority_meta.js +44 -0
  56. package/dist_ts/ts_migration/0002_remove_backup_records.d.ts +17 -0
  57. package/dist_ts/ts_migration/0002_remove_backup_records.js +48 -0
  58. package/dist_ts/ts_migration/index.d.ts +6 -0
  59. package/dist_ts/ts_migration/index.js +12 -0
  60. package/package.json +3 -3
  61. package/readme.md +177 -14
  62. package/ts/00_commitinfo_data.ts +1 -1
  63. package/ts/authority-contract.ts +262 -9
  64. package/ts/authority-runtime-contract.ts +14 -0
  65. package/ts/classes.accountlist.ts +3 -2
  66. package/ts/classes.authoritybroker.ts +385 -121
  67. package/ts/classes.authoritycli.ts +719 -0
  68. package/ts/classes.authorityclient.ts +75 -18
  69. package/ts/classes.authoritydaemon.ts +444 -36
  70. package/ts/classes.authoritydatabase.ts +1076 -75
  71. package/ts/classes.authoritymodels.ts +642 -144
  72. package/ts/classes.authoritypreuse.ts +296 -0
  73. package/ts/classes.authorityregistry.ts +47 -0
  74. package/ts/classes.authoritysecrets.ts +1 -1
  75. package/ts/classes.authorityusage.ts +527 -0
  76. package/ts/classes.claudeauthority.ts +537 -0
  77. package/ts/classes.claudecodeharness.ts +2 -1
  78. package/ts/classes.claudecodelocks.ts +70 -1
  79. package/ts/classes.claudenative.ts +785 -0
  80. package/ts/classes.claudestatus.ts +38 -9
  81. package/ts/classes.claudetokenrefresh.ts +82 -19
  82. package/ts/classes.codexharness.ts +3 -1
  83. package/ts/classes.codexmanaged.ts +76 -13
  84. package/ts/classes.codexpreuse.ts +33 -5
  85. package/ts/classes.fileharness.ts +7 -3
  86. package/ts/classes.limits.ts +11 -6
  87. package/ts/classes.opencodeharness.ts +1 -1
  88. package/ts/claudehttp.ts +31 -2
  89. package/ts/index.ts +6 -0
  90. package/ts/interfaces.harness.ts +6 -0
  91. package/ts/interfaces.list.ts +9 -3
  92. package/ts/plugins.ts +8 -3
  93. package/ts/ts_migration/0001_authority_meta.ts +45 -0
  94. package/ts/ts_migration/0002_remove_backup_records.ts +49 -0
  95. package/ts/ts_migration/index.ts +12 -0
@@ -1,11 +1,15 @@
1
1
  import * as plugins from './plugins.js';
2
- import type { IAuthSwitchAccount, IAuthSwitchAccountEvent, IAuthSwitchBinding, IAuthSwitchOperation, IAuthSwitchSnapshot } from './authority-contract.js';
2
+ import type { IAuthSwitchAccount, IAuthSwitchAccountEvent, IAuthSwitchBinding, IAuthSwitchLogin,
3
+ IAuthSwitchOperation, IAuthSwitchSnapshot } from './authority-contract.js';
3
4
  import { AuthSwitchAuthorityDatabase } from './classes.authoritydatabase.js';
4
- import type { IStoredAuthorityAccount, IStoredAuthorityBinding, IStoredAuthorityGrant } from './classes.authoritymodels.js';
5
+ import type { IStoredAuthorityAccount, IStoredAuthorityBinding, IStoredAuthorityGrant,
6
+ IStoredAuthorityDeviceOperation } from './classes.authoritymodels.js';
7
+ import type { IAuthSwitchUsageContext } from './classes.authorityusage.js';
5
8
  import { AuthSwitchTpmSecretCodec, type IAuthSwitchSecretCodec } from './classes.authoritysecrets.js';
6
9
 
7
10
  type TOpenAiCredential = plugins.flexAccounts.IOpenAiChatGptOAuthCredential;
8
- type TOpenAiProvider = Pick<plugins.flexAccounts.ISmartAiProviderAdapter, 'beginLogin' | 'refreshCredential' | 'inspectCredential' | 'dispose'>;
11
+ type TOpenAiProvider = Pick<plugins.flexAccounts.ISmartAiProviderAdapter,
12
+ 'beginLogin' | 'refreshCredential' | 'inspectCredential' | 'getAccountRateLimits' | 'dispose'>;
9
13
 
10
14
  export interface IAuthSwitchAuthorityBrokerOptions {
11
15
  provider?: TOpenAiProvider;
@@ -23,8 +27,19 @@ export interface IAuthSwitchResolvedAccess {
23
27
  }
24
28
 
25
29
  const idHash = (...values: string[]): string => plugins.crypto.createHash('sha256').update(JSON.stringify(values)).digest('hex');
30
+ export const authSwitchBindingCapabilityHash = (capability: string): string => idHash(capability);
26
31
  const isId = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
32
+ const sameUsageAuthority = (left: IAuthSwitchUsageContext, right: IAuthSwitchUsageContext): boolean =>
33
+ left.epoch === right.epoch && left.accountId === right.accountId && left.loginId === right.loginId
34
+ && left.subjectId === right.subjectId && left.workspaceId === right.workspaceId
35
+ && left.providerId === right.providerId
36
+ && left.providerId === 'openai' && left.purpose === 'openai_managed'
37
+ && left.owner === 'daemon' && left.purpose === right.purpose && left.owner === right.owner
38
+ && !left.removed && !right.removed
39
+ && left.authorizationGeneration === right.authorizationGeneration;
27
40
  const isUuid = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9-]{36}$/.test(value);
41
+ const deviceOperationTerminal = (operation: IStoredAuthorityDeviceOperation): boolean =>
42
+ !['starting', 'pending', 'committing'].includes(operation.state);
28
43
  const safeLabel = (value: unknown): value is string => typeof value === 'string' && value.trim() === value
29
44
  && value.length > 0 && value.length <= 128 && !/[\u0000-\u001f\u007f]/.test(value);
30
45
  const safeScope = (value: unknown, maximum: number): value is string => typeof value === 'string'
@@ -35,26 +50,54 @@ const tokenExpiry = (credential: TOpenAiCredential): string | null => {
35
50
  catch { return null; }
36
51
  };
37
52
  const openAiGrantId = (accountId: string): string => idHash('authswitch-grant-v1', accountId, 'openai_managed', 'chatgpt');
38
- const publicAccount = (account: IStoredAuthorityAccount, grant: IStoredAuthorityGrant | null): IAuthSwitchAccount => ({
53
+ const publicAccount = (account: IStoredAuthorityAccount): IAuthSwitchAccount => ({
39
54
  id: account.id, providerId: account.providerId, label: account.label, email: account.email,
40
- plan: account.plan, health: account.removed ? 'removed'
41
- : grant?.state === 'exchange_may_have_been_sent' ? 'refreshing' : grant?.state ?? 'needs_reauth',
42
- owner: grant?.owner ?? 'none', grantGeneration: grant?.grantGeneration ?? 0, revision: account.revision,
43
- accessExpiresAt: grant?.accessExpiresAt ?? null, retryAt: grant?.retryAt ?? null, problem: grant?.problem ?? 'none',
55
+ plan: account.plan, removed: account.removed, revision: account.revision,
44
56
  statusObservedAt: account.statusObservedAt,
45
57
  });
58
+ const publicLogin = (grant: IStoredAuthorityGrant): IAuthSwitchLogin => ({
59
+ id: grant.id, accountId: grant.accountId, providerId: grant.providerId, purpose: grant.purpose,
60
+ owner: grant.owner,
61
+ health: grant.state === 'exchange_may_have_been_sent' ? 'refreshing'
62
+ : grant.state === 'native' ? 'unverified'
63
+ : grant.state === 'legacy_native_pending' || grant.state === 'handoff_pending' ? 'pending_handoff' : grant.state,
64
+ problem: grant.problem, grantGeneration: grant.grantGeneration, revision: grant.revision,
65
+ accessExpiresAt: grant.accessExpiresAt, retryAt: grant.retryAt,
66
+ statusObservedAt: grant.statusObservedAt,
67
+ capabilities: {
68
+ canReauthenticate: grant.purpose === 'openai_managed' && grant.owner === 'daemon'
69
+ && grant.id === openAiGrantId(grant.accountId)
70
+ && !['removed', 'exchange_may_have_been_sent'].includes(grant.state),
71
+ canBind: grant.purpose === 'openai_managed' && grant.owner === 'daemon'
72
+ && grant.id === openAiGrantId(grant.accountId) && grant.state === 'ready',
73
+ canPreuse: grant.providerId === 'openai' && grant.purpose === 'openai_managed' && grant.owner === 'daemon'
74
+ && grant.id === openAiGrantId(grant.accountId) && ['ready', 'retry_wait'].includes(grant.state),
75
+ },
76
+ });
77
+ const publicOperation = (stored: IStoredAuthorityDeviceOperation): IAuthSwitchOperation => ({
78
+ id: stored.id, kind: stored.kind, purpose: stored.purpose,
79
+ targetAccountId: stored.accountId, targetLoginId: stored.grantId, state: stored.state,
80
+ prompt: stored.prompt, result: stored.result ? {
81
+ accountId: stored.result.accountId, loginId: stored.result.grantId,
82
+ accountRevision: stored.result.accountRevision, loginGeneration: stored.result.grantGeneration,
83
+ } : null,
84
+ error: stored.error, createdAt: stored.createdAt, updatedAt: stored.updatedAt,
85
+ finishedAt: stored.finishedAt, revision: stored.revision,
86
+ });
46
87
  const publicBinding = (binding: IStoredAuthorityBinding): IAuthSwitchBinding => ({
47
88
  id: binding.id, accountId: binding.accountId, runtime: binding.runtime,
48
89
  scopeId: binding.scopeId, incarnationId: binding.incarnationId, revision: binding.revision,
49
90
  });
50
91
 
51
92
  interface IManagedOperation {
52
- public: IAuthSwitchOperation;
93
+ id: string;
53
94
  handle: plugins.flexAccounts.ISmartAiProviderLoginHandle;
54
95
  task: Promise<void>;
55
- cancelled: boolean;
56
- committing: boolean;
57
- finishedAt: number | null;
96
+ }
97
+
98
+ interface IStartingOperation {
99
+ requestKey: string;
100
+ promise: Promise<IAuthSwitchOperation>;
58
101
  }
59
102
 
60
103
  /** One daemon-owned authority. No management DTO contains a refresh grant. */
@@ -63,11 +106,15 @@ export class AuthSwitchAuthorityBroker {
63
106
  private readonly secrets: IAuthSwitchSecretCodec;
64
107
  private readonly now: () => number;
65
108
  private readonly proactive: boolean;
109
+ private readonly starts = new Map<string, IStartingOperation>();
66
110
  private readonly operations = new Map<string, IManagedOperation>();
67
111
  private readonly refreshes = new Map<string, Promise<void>>();
68
112
  private readonly listeners = new Set<() => void>();
113
+ private claudeRefresh?: (grantId: string) => Promise<void>;
114
+ private maintenance?: Promise<void>;
69
115
  private timer?: NodeJS.Timeout;
70
116
  private closed = false;
117
+ private closing?: Promise<void>;
71
118
 
72
119
  constructor(private readonly database: AuthSwitchAuthorityDatabase, options: IAuthSwitchAuthorityBrokerOptions = {}) {
73
120
  this.provider = options.provider ?? new plugins.flexAccounts.OpenAiProviderAdapter();
@@ -76,6 +123,13 @@ export class AuthSwitchAuthorityBroker {
76
123
  this.proactive = options.proactive ?? true;
77
124
  }
78
125
 
126
+ public setClaudeRefresher(refresh: (grantId: string) => Promise<void>): void {
127
+ if (this.claudeRefresh) throw new Error('Claude refresh owner is already configured.');
128
+ this.claudeRefresh = refresh;
129
+ }
130
+
131
+ public notifyChanged(): void { this.publish(); }
132
+
79
133
  public async start(): Promise<void> {
80
134
  const probe = plugins.crypto.randomBytes(32);
81
135
  const probeId = idHash('authswitch-custody-probe-v1');
@@ -88,6 +142,28 @@ export class AuthSwitchAuthorityBroker {
88
142
  }
89
143
  } finally { opened.fill(0); }
90
144
  } finally { probe.fill(0); }
145
+ for (const state of ['starting', 'pending', 'committing'] as const) {
146
+ let after: string | null = null;
147
+ while (true) {
148
+ const page = await this.database.operationsByState(state, after);
149
+ if (!page.length) break;
150
+ for (const operation of page) {
151
+ const now = new Date(this.now()).toISOString();
152
+ const updateId = plugins.crypto.randomUUID();
153
+ await this.database.changeOperation(updateId, operation.id, current => {
154
+ if (current.state !== state) throw new Error('Device operation recovery changed concurrently.');
155
+ return { ...current, state: state === 'committing' ? 'outcome_unknown' : 'interrupted',
156
+ prompt: null, result: null,
157
+ error: state === 'committing'
158
+ ? 'Sign-in completed but its account result could not be confirmed. Check the login before retrying.'
159
+ : 'Sign-in was interrupted by authority restart; no account change was committed.',
160
+ updatedAt: now, finishedAt: now, revision: current.revision + 1,
161
+ updateId };
162
+ });
163
+ }
164
+ after = page.at(-1)!.id;
165
+ }
166
+ }
91
167
  let after: string | null = null;
92
168
  while (true) {
93
169
  const page = await this.database.grantsByState('exchange_may_have_been_sent', after);
@@ -109,9 +185,13 @@ export class AuthSwitchAuthorityBroker {
109
185
  private schedule(): void {
110
186
  if (this.closed) return;
111
187
  this.timer = setTimeout(() => {
112
- void this.refreshDueAccounts().catch(() => {
188
+ const task = this.refreshDueAccounts().catch(() => {
113
189
  process.stderr.write('Authswitch proactive refresh scan failed; account status may be stale.\n');
114
- }).finally(() => this.schedule());
190
+ }).finally(() => {
191
+ if (this.maintenance === task) this.maintenance = undefined;
192
+ this.schedule();
193
+ });
194
+ this.maintenance = task;
115
195
  }, 30_000);
116
196
  this.timer.unref();
117
197
  }
@@ -121,12 +201,14 @@ export class AuthSwitchAuthorityBroker {
121
201
  for (const state of ['ready', 'retry_wait'] as const) {
122
202
  let after: string | null = null;
123
203
  while (true) {
124
- const page = await this.database.grantsByState(state, after);
204
+ const page = await this.database.grantsByState(state, after);
125
205
  if (!page.length) break;
126
- await Promise.allSettled(page.filter(grant => grant.purpose === 'openai_managed'
127
- && grant.owner === 'daemon' && this.isDue(grant, 0)
206
+ await Promise.allSettled(page.filter(grant => grant.owner === 'daemon' && this.isDue(grant, 0)
128
207
  && (grant.state !== 'retry_wait' || (grant.retryAt !== null && Date.parse(grant.retryAt) <= this.now())))
129
- .map(grant => this.refreshAccount(grant.accountId)));
208
+ .flatMap(grant => grant.purpose === 'openai_managed'
209
+ ? [this.refreshAccount(grant.accountId, 0)]
210
+ : grant.purpose === 'claude_host_native' && this.claudeRefresh
211
+ ? [this.claudeRefresh(grant.id)] : []));
130
212
  after = page.at(-1)!.id;
131
213
  }
132
214
  }
@@ -141,16 +223,19 @@ export class AuthSwitchAuthorityBroker {
141
223
  for (const listener of this.listeners) listener();
142
224
  }
143
225
 
144
- public async snapshot(options: { accountAfter?: string; bindingAfter?: string; limit?: number } = {}): Promise<IAuthSwitchSnapshot> {
226
+ public async snapshot(options: { accountAfter?: string; loginAfter?: string; bindingAfter?: string; limit?: number } = {}): Promise<IAuthSwitchSnapshot> {
145
227
  if ((options.accountAfter !== undefined && !isId(options.accountAfter))
228
+ || (options.loginAfter !== undefined && !isId(options.loginAfter))
146
229
  || (options.bindingAfter !== undefined && !isId(options.bindingAfter))) throw new Error('Invalid account snapshot cursor.');
147
- const page = await this.database.page(options.accountAfter ?? null, options.bindingAfter ?? null, options.limit ?? 128);
148
- return { schemaVersion: 1, epoch: page.meta.epoch, revision: page.meta.revision,
230
+ const page = await this.database.page(options.accountAfter ?? null, options.loginAfter ?? null,
231
+ options.bindingAfter ?? null, options.limit ?? 128);
232
+ return { schemaVersion: 2, epoch: page.meta.epoch, revision: page.meta.revision,
149
233
  generatedAt: new Date(this.now()).toISOString(),
150
- accounts: page.accounts.filter(item => !item.account.removed)
151
- .map(item => publicAccount(item.account, item.grant)),
234
+ accounts: page.accounts.filter(account => !account.removed).map(publicAccount),
235
+ logins: page.grants.filter(grant => grant.state !== 'removed').map(publicLogin),
152
236
  bindings: page.bindings.map(publicBinding),
153
- nextAccountCursor: page.nextAccountCursor, nextBindingCursor: page.nextBindingCursor };
237
+ nextAccountCursor: page.nextAccountCursor, nextLoginCursor: page.nextGrantCursor,
238
+ nextBindingCursor: page.nextBindingCursor };
154
239
  }
155
240
 
156
241
  public async events(epoch: string, afterRevision: number, waitMs: number, signal?: AbortSignal): Promise<{
@@ -233,113 +318,236 @@ export class AuthSwitchAuthorityBroker {
233
318
  finally { bytes.fill(0); }
234
319
  }
235
320
 
236
- public async beginAddOpenAi(): Promise<IAuthSwitchOperation> { return this.beginLogin(null); }
237
- public async beginReauthOpenAi(accountId: string): Promise<IAuthSwitchOperation> {
238
- if (!isId(accountId)) throw new Error('Invalid account ID.');
239
- const { account, grant } = await this.database.readAccountAndPrimaryGrant(accountId);
240
- if (!account || account.removed || account.providerId !== 'openai') throw new Error('OpenAI account was not found.');
241
- if (grant?.owner === 'legacy_native') throw new Error('Stop the legacy native owner before reauthenticating this account.');
242
- return this.beginLogin(accountId);
321
+ private startDeviceOperation(operationId: string, requestKey: string,
322
+ start: () => Promise<IAuthSwitchOperation>): Promise<IAuthSwitchOperation> {
323
+ if (!isUuid(operationId)) return Promise.reject(new Error('Invalid operation ID.'));
324
+ if (this.closed) return Promise.reject(new Error('Authswitch authority is closing.'));
325
+ const existing = this.starts.get(operationId);
326
+ if (existing) {
327
+ if (existing.requestKey !== requestKey) {
328
+ return Promise.reject(new Error('Account operation ID belongs to a different request.'));
329
+ }
330
+ return existing.promise;
331
+ }
332
+ const promise = start();
333
+ this.starts.set(operationId, { requestKey, promise });
334
+ void promise.finally(() => {
335
+ if (this.starts.get(operationId)?.promise === promise) this.starts.delete(operationId);
336
+ }).catch(() => {});
337
+ return promise;
338
+ }
339
+
340
+ private async finishStartingOperation(operationId: string, state: 'failed' | 'interrupted',
341
+ error: string): Promise<IStoredAuthorityDeviceOperation> {
342
+ const current = await this.database.readOperation(operationId);
343
+ if (!current || current.kind === 'preuse_openai') throw new Error('Account operation was not found.');
344
+ if (deviceOperationTerminal(current)) return current;
345
+ if (current.state !== 'starting') throw new Error('Account operation already started its provider login.');
346
+ const updateId = plugins.crypto.randomUUID();
347
+ const now = new Date(this.now()).toISOString();
348
+ try {
349
+ return await this.database.changeOperation(updateId, operationId, operation => ({ ...operation,
350
+ state, error, updatedAt: now, finishedAt: now, revision: operation.revision + 1, updateId }));
351
+ } catch (changeError) {
352
+ const changed = await this.database.readOperation(operationId);
353
+ if (changed && changed.kind !== 'preuse_openai' && deviceOperationTerminal(changed)) return changed;
354
+ throw changeError;
355
+ }
356
+ }
357
+
358
+ public beginAddOpenAi(operationId: string): Promise<IAuthSwitchOperation> {
359
+ return this.startDeviceOperation(operationId, 'add_openai', () => this.beginLogin(operationId, null));
243
360
  }
244
361
 
245
- private async beginLogin(targetId: string | null): Promise<IAuthSwitchOperation> {
362
+ public beginReauthOpenAi(operationId: string, accountId: string, loginId: string,
363
+ purpose: 'openai_managed', beforeNew?: () => Promise<void>): Promise<IAuthSwitchOperation> {
364
+ if (!isId(accountId) || !isId(loginId) || purpose !== 'openai_managed') {
365
+ return Promise.reject(new Error('Invalid login target.'));
366
+ }
367
+ const requestKey = JSON.stringify(['reauth_openai', accountId, loginId, purpose]);
368
+ return this.startDeviceOperation(operationId, requestKey,
369
+ () => this.beginLogin(operationId, { accountId, grantId: loginId }, beforeNew));
370
+ }
371
+
372
+ private async beginLogin(operationId: string, target: { accountId: string; grantId: string } | null,
373
+ beforeNew?: () => Promise<void>): Promise<IAuthSwitchOperation> {
246
374
  if (this.closed) throw new Error('Authswitch authority is closing.');
247
- this.pruneOperations();
248
- if ([...this.operations.values()].filter(item => item.public.state === 'pending').length >= 32) {
249
- throw new Error('Too many pending device logins.');
375
+ const now = new Date(this.now()).toISOString();
376
+ const admitted = await this.database.createOperation({ id: operationId, kind: target ? 'reauth_openai' : 'add_openai',
377
+ purpose: 'openai_managed', accountId: target?.accountId ?? null, grantId: target?.grantId ?? null,
378
+ state: 'starting', prompt: null, result: null, error: null, commitId: null,
379
+ createdAt: now, updatedAt: now, finishedAt: null,
380
+ revision: 1, updateId: plugins.crypto.randomUUID() });
381
+ if (!admitted.created) return publicOperation(admitted.operation);
382
+ const id = operationId;
383
+ try {
384
+ if (this.closed) throw new Error('Authswitch authority is closing.');
385
+ if (this.operations.size >= 32) throw new Error('Too many pending device logins.');
386
+ await beforeNew?.();
387
+ if (this.closed) throw new Error('Authswitch authority is closing.');
388
+ await this.database.confirmOperationMayStart(id);
389
+ if (this.closed) throw new Error('Authswitch authority is closing.');
390
+ } catch (error) {
391
+ const terminal = await this.finishStartingOperation(id, this.closed ? 'interrupted' : 'failed',
392
+ this.closed ? 'Sign-in was interrupted; no account change was committed.'
393
+ : 'Device sign-in preparation failed; no provider login was started.');
394
+ if (terminal.state === 'cancelled') return publicOperation(terminal);
395
+ throw error;
396
+ }
397
+ let handle: plugins.flexAccounts.ISmartAiProviderLoginHandle;
398
+ try {
399
+ handle = await this.provider.beginLogin({ flow: 'device' });
400
+ } catch {
401
+ return publicOperation(await this.finishStartingOperation(id, this.closed ? 'interrupted' : 'failed',
402
+ this.closed ? 'Sign-in was interrupted; no account change was committed.'
403
+ : 'Device sign-in could not start.'));
404
+ }
405
+ void handle.completion.catch(() => {});
406
+ let current: IStoredAuthorityDeviceOperation;
407
+ try {
408
+ const stored = await this.database.readOperation(id);
409
+ if (!stored || stored.kind === 'preuse_openai') throw new Error('Account operation was not found.');
410
+ current = stored;
411
+ } catch (error) {
412
+ await Promise.allSettled([handle.cancel(), handle.close()]);
413
+ throw error;
250
414
  }
251
- const handle = await this.provider.beginLogin({ flow: 'device' });
252
- if (handle.prompt.flow !== 'device') {
253
- await handle.close();
254
- throw new Error('Provider did not start a device login.');
415
+ if (this.closed || current.state !== 'starting') {
416
+ await Promise.allSettled([handle.cancel(), handle.close()]);
417
+ const terminal = this.closed && current.state === 'starting'
418
+ ? await this.finishStartingOperation(id, 'interrupted',
419
+ 'Sign-in was interrupted; no account change was committed.') : current;
420
+ return publicOperation(terminal);
421
+ }
422
+ const prompt = handle.prompt;
423
+ if (prompt.flow !== 'device') {
424
+ await Promise.allSettled([handle.cancel(), handle.close()]);
425
+ return publicOperation(await this.finishStartingOperation(id, 'failed', 'Device sign-in could not start.'));
426
+ }
427
+ const operation: IManagedOperation = { id, handle, task: Promise.resolve() };
428
+ this.operations.set(id, operation);
429
+ let pending: IStoredAuthorityDeviceOperation;
430
+ try {
431
+ const updateId = plugins.crypto.randomUUID();
432
+ pending = await this.database.changeOperation(updateId, id, current => ({ ...current,
433
+ state: 'pending', prompt: { flow: 'device', verificationUrl: prompt.verificationUrl,
434
+ userCode: prompt.userCode }, updatedAt: new Date(this.now()).toISOString(),
435
+ revision: current.revision + 1, updateId }));
436
+ } catch (error) {
437
+ await Promise.allSettled([handle.cancel(), handle.close()]);
438
+ if (this.operations.get(id) === operation) this.operations.delete(id);
439
+ const current = await this.database.readOperation(id);
440
+ if (current && current.kind !== 'preuse_openai' && deviceOperationTerminal(current)) return publicOperation(current);
441
+ throw error;
255
442
  }
256
- const publicOperation: IAuthSwitchOperation = { id: plugins.crypto.randomUUID(), state: 'pending',
257
- prompt: { flow: 'device', verificationUrl: handle.prompt.verificationUrl, userCode: handle.prompt.userCode },
258
- account: null, error: null };
259
- const operation: IManagedOperation = { public: publicOperation, handle, task: Promise.resolve(),
260
- cancelled: false, committing: false, finishedAt: null };
261
- this.operations.set(publicOperation.id, operation);
262
443
  operation.task = handle.completion.then(async result => {
263
- if (operation.cancelled || this.closed) return;
264
- operation.committing = true;
444
+ if (this.closed) throw new Error('Authority closed during sign-in.');
265
445
  const identity = this.identity(result.credential);
266
- if (targetId !== null && identity.id !== targetId) throw new Error('The device login selected a different OpenAI account.');
446
+ if (target && identity.id !== target.accountId) throw new Error('The device login selected a different OpenAI account.');
267
447
  const grantId = openAiGrantId(identity.id);
448
+ if (target && grantId !== target.grantId) throw new Error('The device login selected a different OpenAI login.');
449
+ const commitId = plugins.crypto.randomUUID();
450
+ try {
451
+ const markerUpdateId = plugins.crypto.randomUUID();
452
+ await this.database.changeOperation(markerUpdateId, id, current => {
453
+ if (current.state !== 'pending') throw new Error('Device operation is no longer pending.');
454
+ return { ...current, state: 'committing', prompt: null, commitId,
455
+ updatedAt: new Date(this.now()).toISOString(), revision: current.revision + 1,
456
+ updateId: markerUpdateId };
457
+ });
458
+ } catch (error) {
459
+ const current = await this.database.readOperation(id);
460
+ if (current?.state === 'cancelled' || current?.state === 'interrupted') return;
461
+ throw error;
462
+ }
268
463
  const ciphertext = await this.sealCredential(grantId, result.credential);
269
- if (operation.cancelled || this.closed) return;
270
- const updateId = plugins.crypto.randomUUID();
271
- const updated = await this.database.changeAccountAndGrant(updateId, identity.id, grantId, (existing, oldGrant) => {
272
- if (targetId === null && existing && !existing.removed) throw new Error('Account already exists; reauthenticate that account instead.');
273
- if (targetId !== null && (!existing || existing.removed)) throw new Error('Target account was removed during login.');
464
+ if (this.closed) throw new Error('Authority closed before device sign-in commit.');
465
+ await this.database.commitLoginOperation(commitId, id, identity.id, grantId, (existing, oldGrant) => {
466
+ if (!target && existing && !existing.removed) throw new Error('Account already exists; reauthenticate that account instead.');
467
+ if (target && (!existing || existing.removed)) throw new Error('Target account was removed during login.');
274
468
  if (oldGrant?.owner === 'legacy_native') throw new Error('The legacy native owner still holds this grant.');
275
469
  const now = new Date(this.now()).toISOString();
276
470
  const account: IStoredAuthorityAccount = existing
277
471
  ? { ...existing, label: existing.label || identity.email || `OpenAI ${identity.workspaceId}`,
278
472
  email: identity.email, plan: identity.plan, primaryGrantId: grantId, removed: false,
279
- revision: existing.revision + 1, statusObservedAt: now, updateId }
473
+ revision: existing.revision + 1, statusObservedAt: now, updateId: commitId }
280
474
  : { id: identity.id, providerId: 'openai', issuer: 'openai', subject: identity.subject,
281
475
  workspaceId: identity.workspaceId, label: identity.email ?? `OpenAI ${identity.workspaceId}`,
282
476
  email: identity.email, plan: identity.plan, primaryGrantId: grantId, removed: false,
283
- revision: 1, statusObservedAt: now, updateId };
477
+ revision: 1, statusObservedAt: now, updateId: commitId };
284
478
  const grant: IStoredAuthorityGrant = oldGrant
285
479
  ? { ...oldGrant, state: 'ready', owner: 'daemon', ciphertext,
286
480
  grantGeneration: oldGrant.grantGeneration + 1,
287
481
  authorizationGeneration: oldGrant.authorizationGeneration + 1,
288
482
  revision: oldGrant.revision + 1, accessExpiresAt: tokenExpiry(result.credential),
289
- attemptId: null, retryAt: null, retryCount: 0, problem: 'none', statusObservedAt: now, updateId }
483
+ attemptId: null, retryAt: null, retryCount: 0, problem: 'none', statusObservedAt: now, updateId: commitId }
290
484
  : { id: grantId, accountId: identity.id, providerId: 'openai', purpose: 'openai_managed',
291
485
  audience: 'chatgpt', state: 'ready', owner: 'daemon', grantGeneration: 1,
292
486
  authorizationGeneration: 1, revision: 1, ciphertext,
293
487
  accessExpiresAt: tokenExpiry(result.credential), attemptId: null,
294
- retryAt: null, retryCount: 0, problem: 'none', statusObservedAt: now, updateId };
488
+ retryAt: null, retryCount: 0, problem: 'none', statusObservedAt: now, updateId: commitId };
295
489
  return { account, grant };
296
490
  });
297
491
  this.publish();
298
- operation.public.account = publicAccount(updated.account, updated.grant);
299
- operation.public.state = 'complete';
300
- }).catch(() => {
301
- operation.public.state = operation.cancelled ? 'cancelled' : 'failed';
302
- operation.public.error = operation.cancelled ? null : targetId === null
303
- ? 'OpenAI login failed or the account could not be saved. No active account was changed.'
304
- : 'OpenAI reauthentication failed or selected a different account. The existing account was preserved.';
492
+ }).catch(async () => {
493
+ const current = await this.database.readOperation(id);
494
+ if (!current || ['complete', 'failed', 'cancelled', 'interrupted', 'outcome_unknown'].includes(current.state)) return;
495
+ const state = current.state === 'committing' ? 'outcome_unknown' : this.closed ? 'interrupted' : 'failed';
496
+ const updateId = plugins.crypto.randomUUID();
497
+ await this.database.changeOperation(updateId, id, prior => ({ ...prior, state,
498
+ prompt: null, result: null, error: state === 'outcome_unknown'
499
+ ? 'Sign-in completed but its account result could not be confirmed. Check the login before retrying.'
500
+ : state === 'interrupted' ? 'Sign-in was interrupted; no account change was committed.'
501
+ : target ? 'Reauthentication failed or selected a different identity. The existing login was preserved.'
502
+ : 'Device sign-in failed. No account was added.',
503
+ updatedAt: new Date(this.now()).toISOString(), finishedAt: new Date(this.now()).toISOString(),
504
+ revision: prior.revision + 1, updateId }));
305
505
  }).finally(async () => {
306
- operation.finishedAt = this.now();
307
506
  try { await handle.close(); } catch { /* A completed operation remains inspectable. */ }
507
+ if (this.operations.get(id) === operation) this.operations.delete(id);
308
508
  });
309
- return structuredClone(publicOperation);
509
+ void operation.task.catch(() => {});
510
+ return publicOperation(pending);
310
511
  }
311
512
 
312
- public getOperation(operationId: string): IAuthSwitchOperation {
513
+ public async getOperation(operationId: string): Promise<IAuthSwitchOperation> {
313
514
  if (!isUuid(operationId)) throw new Error('Invalid operation ID.');
314
- this.pruneOperations();
315
- const operation = this.operations.get(operationId);
316
- if (!operation) throw new Error('Account operation expired or was not found.');
317
- return structuredClone(operation.public);
515
+ const operation = await this.database.readOperation(operationId);
516
+ if (!operation || operation.kind === 'preuse_openai') throw new Error('Account operation was not found.');
517
+ return publicOperation(operation);
518
+ }
519
+
520
+ public async listOperations(after: string | null, limit = 128): Promise<{ operations: IAuthSwitchOperation[]; nextCursor: string | null }> {
521
+ if (after !== null && !isUuid(after)) throw new Error('Invalid operation cursor.');
522
+ const page = await this.database.operationsPage(after, limit);
523
+ return { operations: page.operations.map(publicOperation), nextCursor: page.nextCursor };
318
524
  }
319
525
 
320
526
  /** Trusted daemon lifecycle hook: hold new managed runtimes out until targeted reauth settles. */
321
527
  public async waitOperation(operationId: string): Promise<void> {
322
528
  const operation = this.operations.get(operationId);
323
- if (!isUuid(operationId) || !operation) throw new Error('Account operation expired or was not found.');
324
- await operation.task;
325
- }
326
-
327
- private pruneOperations(): void {
328
- for (const [id, operation] of this.operations) {
329
- if (operation.finishedAt !== null && this.now() - operation.finishedAt > 600_000) this.operations.delete(id);
529
+ if (!isUuid(operationId)) throw new Error('Invalid operation ID.');
530
+ if (operation) await operation.task;
531
+ else if (['starting', 'pending', 'committing'].includes(
532
+ (await this.database.readOperation(operationId))?.state ?? '')) {
533
+ throw new Error('Unfinished device operation has no active provider owner.');
330
534
  }
331
535
  }
332
536
 
333
537
  public async cancelOperation(operationId: string): Promise<IAuthSwitchOperation> {
538
+ if (!isUuid(operationId)) throw new Error('Invalid operation ID.');
539
+ const current = await this.database.readOperation(operationId);
540
+ if (!current || current.kind === 'preuse_openai') throw new Error('Account operation was not found.');
541
+ if (current.state === 'committing') throw new Error('Login is completing; check its operation status.');
542
+ if (current.state !== 'starting' && current.state !== 'pending') return publicOperation(current);
543
+ const updateId = plugins.crypto.randomUUID();
544
+ const cancelled = await this.database.changeOperation(updateId, operationId, prior => ({ ...prior,
545
+ state: 'cancelled', prompt: null, result: null, error: 'Device sign-in was cancelled.',
546
+ updatedAt: new Date(this.now()).toISOString(), finishedAt: new Date(this.now()).toISOString(),
547
+ revision: prior.revision + 1, updateId }));
334
548
  const operation = this.operations.get(operationId);
335
- if (!isUuid(operationId) || !operation) throw new Error('Account operation expired or was not found.');
336
- if (operation.public.state !== 'pending') return structuredClone(operation.public);
337
- if (operation.committing) throw new Error('Login is completing; check its operation status.');
338
- operation.cancelled = true;
339
- await operation.handle.cancel();
340
- operation.public.state = 'cancelled';
341
- operation.public.error = null;
342
- return structuredClone(operation.public);
549
+ if (operation) await operation.handle.cancel();
550
+ return publicOperation(cancelled);
343
551
  }
344
552
 
345
553
  public async renameAccount(accountId: string, expectedRevision: number, label: string): Promise<IAuthSwitchAccount> {
@@ -351,8 +559,7 @@ export class AuthSwitchAuthorityBroker {
351
559
  statusObservedAt: new Date(this.now()).toISOString() };
352
560
  });
353
561
  this.publish();
354
- const grant = updated.account.primaryGrantId ? await this.database.readGrant(updated.account.primaryGrantId) : null;
355
- return publicAccount(updated.account, grant);
562
+ return publicAccount(updated.account);
356
563
  }
357
564
 
358
565
  public async removeAccount(accountId: string, expectedRevision: number): Promise<IAuthSwitchAccount> {
@@ -361,20 +568,22 @@ export class AuthSwitchAuthorityBroker {
361
568
  const account = await this.database.removeAccountAndGrants(updateId, accountId,
362
569
  expectedRevision, new Date(this.now()).toISOString());
363
570
  this.publish();
364
- const grant = account.primaryGrantId ? await this.database.readGrant(account.primaryGrantId) : null;
365
- return publicAccount(account, grant);
571
+ return publicAccount(account);
366
572
  }
367
573
 
368
- public async bindAccount(input: { accountId: string; runtime: IAuthSwitchBinding['runtime']; scopeId: string; incarnationId: string }): Promise<{ binding: IAuthSwitchBinding; capability: string }> {
369
- if (!isId(input.accountId) || !['flex', 'codex', 'opencode', 'claude'].includes(input.runtime)
574
+ public async bindAccount(input: { accountId: string; loginId: string; purpose: 'openai_managed';
575
+ runtime: IAuthSwitchBinding['runtime']; scopeId: string; incarnationId: string }): Promise<{ binding: IAuthSwitchBinding; capability: string }> {
576
+ if (!isId(input.accountId) || !isId(input.loginId) || input.purpose !== 'openai_managed'
577
+ || !['flex', 'codex', 'opencode', 'claude'].includes(input.runtime)
370
578
  || !safeScope(input.scopeId, 512) || !safeScope(input.incarnationId, 256)) throw new Error('Invalid account binding.');
371
579
  const id = idHash('authswitch-binding-v1', input.runtime, input.scopeId);
372
580
  const capability = plugins.crypto.randomBytes(32).toString('base64url');
373
581
  const capabilityHash = idHash(capability);
374
582
  const updateId = plugins.crypto.randomUUID();
375
- const updated = await this.database.changeBinding(updateId, id, input.accountId, (existing, account, grant) => {
376
- if (account.removed || grant?.state !== 'ready' || grant.owner !== 'daemon'
377
- || grant.purpose !== 'openai_managed' || account.providerId !== 'openai'
583
+ const updated = await this.database.changeBinding(updateId, id, input.accountId, input.loginId, (existing, account, grant) => {
584
+ if (account.removed || grant?.state !== 'ready' || grant.id !== account.primaryGrantId
585
+ || grant.owner !== 'daemon'
586
+ || grant.purpose !== input.purpose || account.providerId !== 'openai'
378
587
  || input.runtime === 'claude') {
379
588
  throw new Error('Account is not available for this runtime.');
380
589
  }
@@ -388,11 +597,20 @@ export class AuthSwitchAuthorityBroker {
388
597
 
389
598
  public async revokeBinding(bindingId: string, capability: string): Promise<boolean> {
390
599
  if (!isId(bindingId) || !/^[A-Za-z0-9_-]{43}$/.test(capability)) throw new Error('Invalid runtime binding revocation.');
391
- const revoked = await this.database.revokeBinding(plugins.crypto.randomUUID(), bindingId, idHash(capability));
600
+ const revoked = await this.database.revokeBinding(plugins.crypto.randomUUID(), bindingId,
601
+ authSwitchBindingCapabilityHash(capability));
392
602
  if (revoked) this.publish();
393
603
  return revoked;
394
604
  }
395
605
 
606
+ public async releaseExternalBinding(bindingId: string, capability: string): Promise<'released' | 'inactive'> {
607
+ if (!isId(bindingId) || !/^[A-Za-z0-9_-]{43}$/.test(capability)) throw new Error('Invalid runtime binding release.');
608
+ const released = await this.database.releaseExternalBinding(plugins.crypto.randomUUID(), bindingId,
609
+ authSwitchBindingCapabilityHash(capability));
610
+ if (released) this.publish();
611
+ return released ? 'released' : 'inactive';
612
+ }
613
+
396
614
  public async resolveAccess(bindingId: string, capability: string, minValidityMs: number,
397
615
  rejectedGrantGeneration?: number): Promise<IAuthSwitchResolvedAccess> {
398
616
  if (!isId(bindingId) || !/^[A-Za-z0-9_-]{43}$/.test(capability)
@@ -400,16 +618,54 @@ export class AuthSwitchAuthorityBroker {
400
618
  || (rejectedGrantGeneration !== undefined && (!Number.isSafeInteger(rejectedGrantGeneration)
401
619
  || rejectedGrantGeneration < 1))) throw new Error('Invalid runtime credential request.');
402
620
  const supplied = Buffer.from(idHash(capability), 'hex');
403
- for (let attempt = 0; attempt < 4; attempt++) {
404
- const initial = await this.database.readBindingContext(bindingId);
405
- const binding = initial.binding;
621
+ let originalBinding: IStoredAuthorityBinding | null = null;
622
+ return this.resolveManagedAccess(async () => {
623
+ const current = await this.database.readBindingContext(bindingId);
624
+ const binding = current.binding;
406
625
  const expected = Buffer.from(binding?.capabilityHash ?? '0'.repeat(64), 'hex');
407
626
  if (!binding || !plugins.crypto.timingSafeEqual(supplied, expected)) {
408
627
  throw new Error('Runtime binding is not authorized.');
409
628
  }
410
- const { account, grant } = initial;
411
- if (!account || account.removed || !grant || grant.id !== binding.grantId
412
- || binding.grantAuthorizationGeneration !== grant.authorizationGeneration) {
629
+ if (originalBinding && (binding.id !== originalBinding.id
630
+ || binding.capabilityHash !== originalBinding.capabilityHash
631
+ || binding.accountId !== originalBinding.accountId || binding.grantId !== originalBinding.grantId
632
+ || binding.incarnationId !== originalBinding.incarnationId
633
+ || binding.grantAuthorizationGeneration !== originalBinding.grantAuthorizationGeneration)) {
634
+ throw new Error('Account binding changed during credential resolution.');
635
+ }
636
+ originalBinding ??= binding;
637
+ if (!current.account || current.account.removed || !current.grant
638
+ || current.grant.id !== binding.grantId
639
+ || current.grant.authorizationGeneration !== binding.grantAuthorizationGeneration) {
640
+ throw new Error('Account needs reauthentication.');
641
+ }
642
+ return { account: current.account, grant: current.grant };
643
+ }, minValidityMs, rejectedGrantGeneration);
644
+ }
645
+
646
+ /** Backend-only access for usage; it never creates or bypasses a runtime binding. */
647
+ public async resolveUsageAccess(context: IAuthSwitchUsageContext,
648
+ rejectedGrantGeneration: number | undefined, minValidityMs: number): Promise<IAuthSwitchResolvedAccess> {
649
+ if (!isId(context.accountId) || !isId(context.loginId)
650
+ || !Number.isSafeInteger(minValidityMs) || minValidityMs < 0 || minValidityMs > 3_600_000
651
+ || (rejectedGrantGeneration !== undefined && (!Number.isSafeInteger(rejectedGrantGeneration)
652
+ || rejectedGrantGeneration < 1))) throw new Error('Invalid authority usage access request.');
653
+ return this.resolveManagedAccess(async () => {
654
+ const current = await this.database.readUsageAccessView(context.accountId, context.loginId);
655
+ if (!current || !sameUsageAuthority(context, current.context)) {
656
+ throw new Error('Account usage authority changed.');
657
+ }
658
+ return { account: current.account, grant: current.grant };
659
+ }, minValidityMs, rejectedGrantGeneration);
660
+ }
661
+
662
+ private async resolveManagedAccess(readView: () => Promise<{
663
+ account: IStoredAuthorityAccount; grant: IStoredAuthorityGrant;
664
+ }>, minValidityMs: number, rejectedGrantGeneration?: number): Promise<IAuthSwitchResolvedAccess> {
665
+ for (let attempt = 0; attempt < 4; attempt++) {
666
+ const { account, grant } = await readView();
667
+ if (account.removed || grant.accountId !== account.id || grant.id !== account.primaryGrantId
668
+ || account.providerId !== 'openai' || grant.providerId !== 'openai') {
413
669
  throw new Error('Account needs reauthentication.');
414
670
  }
415
671
  if (rejectedGrantGeneration !== undefined && rejectedGrantGeneration > grant.grantGeneration) {
@@ -428,7 +684,8 @@ export class AuthSwitchAuthorityBroker {
428
684
  const rejectedCurrent = rejectedGrantGeneration === grant.grantGeneration;
429
685
  if (this.isDue(grant, minValidityMs) || rejectedCurrent) {
430
686
  if (grant.state === 'ready' || (grant.retryAt !== null && Date.parse(grant.retryAt) <= this.now())) {
431
- await this.refreshAccount(account.id, rejectedCurrent ? rejectedGrantGeneration : undefined);
687
+ await this.refreshAccount(account.id, minValidityMs,
688
+ rejectedCurrent ? rejectedGrantGeneration : undefined);
432
689
  continue;
433
690
  }
434
691
  throw new Error('Account access credential is not fresh enough.');
@@ -439,18 +696,15 @@ export class AuthSwitchAuthorityBroker {
439
696
  if (info.chatgptAccountId !== account.workspaceId || info.chatgptUserId !== account.subject) {
440
697
  throw new Error('Account access identity changed.');
441
698
  }
442
- const latest = await this.database.readBindingContext(bindingId);
443
- if (!latest.binding || latest.binding.capabilityHash !== binding.capabilityHash
444
- || latest.binding.accountId !== binding.accountId
445
- || latest.binding.grantId !== binding.grantId
446
- || latest.binding.incarnationId !== binding.incarnationId
447
- || latest.binding.grantAuthorizationGeneration !== binding.grantAuthorizationGeneration
448
- || latest.account?.removed || !latest.grant
449
- || latest.grant.authorizationGeneration !== grant.authorizationGeneration
699
+ const latest = await readView();
700
+ if (latest.account.removed || latest.account.id !== account.id
701
+ || latest.account.primaryGrantId !== latest.grant.id
702
+ || latest.account.workspaceId !== account.workspaceId || latest.account.subject !== account.subject
703
+ || latest.grant.id !== grant.id || latest.grant.authorizationGeneration !== grant.authorizationGeneration
450
704
  || latest.grant.owner !== 'daemon') {
451
705
  throw new Error('Account binding changed during credential resolution.');
452
706
  }
453
- if (latest.grant.grantGeneration !== grant.grantGeneration
707
+ if (latest.grant.grantGeneration !== grant.grantGeneration || latest.grant.revision !== grant.revision
454
708
  || latest.grant.state === 'exchange_may_have_been_sent') continue;
455
709
  if (!['ready', 'retry_wait'].includes(latest.grant.state) || this.isDue(latest.grant, minValidityMs)
456
710
  || (rejectedGrantGeneration !== undefined && latest.grant.grantGeneration === rejectedGrantGeneration)) {
@@ -463,20 +717,23 @@ export class AuthSwitchAuthorityBroker {
463
717
  throw new Error('Account credential rotated repeatedly during resolution; retry the request.');
464
718
  }
465
719
 
466
- private async refreshAccount(accountId: string, rejectedGrantGeneration?: number): Promise<void> {
720
+ private async refreshAccount(accountId: string, minValidityMs: number,
721
+ rejectedGrantGeneration?: number): Promise<void> {
467
722
  const prior = this.refreshes.get(accountId);
468
723
  if (prior) return prior;
469
- const task = this.performRefresh(accountId, rejectedGrantGeneration).finally(() => { if (this.refreshes.get(accountId) === task) this.refreshes.delete(accountId); });
724
+ const task = this.performRefresh(accountId, minValidityMs, rejectedGrantGeneration)
725
+ .finally(() => { if (this.refreshes.get(accountId) === task) this.refreshes.delete(accountId); });
470
726
  this.refreshes.set(accountId, task);
471
727
  return task;
472
728
  }
473
729
 
474
- private async performRefresh(accountId: string, rejectedGrantGeneration?: number): Promise<void> {
730
+ private async performRefresh(accountId: string, minValidityMs: number,
731
+ rejectedGrantGeneration?: number): Promise<void> {
475
732
  const initial = await this.database.readAccountAndPrimaryGrant(accountId);
476
733
  const initialGrant = initial.grant;
477
734
  if (!initial.account || initial.account.removed || !initialGrant || initialGrant.purpose !== 'openai_managed'
478
735
  || !['ready', 'retry_wait'].includes(initialGrant.state) || initialGrant.owner !== 'daemon'
479
- || (!this.isDue(initialGrant, 0) && initialGrant.grantGeneration !== rejectedGrantGeneration)
736
+ || (!this.isDue(initialGrant, minValidityMs) && initialGrant.grantGeneration !== rejectedGrantGeneration)
480
737
  || (initialGrant.state === 'retry_wait'
481
738
  && (initialGrant.retryAt === null || Date.parse(initialGrant.retryAt) > this.now()))) return;
482
739
  const credential = await this.unsealCredential(initial.account, initialGrant);
@@ -540,13 +797,20 @@ export class AuthSwitchAuthorityBroker {
540
797
  }
541
798
  }
542
799
 
543
- public async close(): Promise<void> {
800
+ public close(): Promise<void> {
801
+ if (this.closing) return this.closing;
544
802
  this.closed = true;
545
803
  if (this.timer) clearTimeout(this.timer);
546
804
  for (const wake of this.listeners) wake();
547
- await Promise.allSettled([...this.operations.values()].map(item => item.handle.cancel()));
548
- await Promise.allSettled([...this.operations.values()].map(item => item.task));
549
- await Promise.allSettled([...this.refreshes.values()]);
550
- await this.provider.dispose();
805
+ this.closing = (async () => {
806
+ if (this.maintenance) await this.maintenance;
807
+ await Promise.allSettled([...this.operations.values()].map(item => item.handle.cancel()));
808
+ await Promise.allSettled([...this.starts.values()].map(item => item.promise));
809
+ await Promise.allSettled([...this.operations.values()].map(item => item.handle.cancel()));
810
+ await Promise.allSettled([...this.operations.values()].map(item => item.task));
811
+ await Promise.allSettled([...this.refreshes.values()]);
812
+ await this.provider.dispose();
813
+ })();
814
+ return this.closing;
551
815
  }
552
816
  }