@modelprofile.com/authswitch 6.4.0 → 7.0.0

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