@modelprofile.com/authswitch 6.5.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 +13 -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 +25 -5
  11. package/dist_ts/classes.authoritybroker.js +392 -123
  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 +19 -7
  15. package/dist_ts/classes.authorityclient.js +51 -14
  16. package/dist_ts/classes.authoritydaemon.d.ts +28 -1
  17. package/dist_ts/classes.authoritydaemon.js +482 -36
  18. package/dist_ts/classes.authoritydatabase.d.ts +175 -10
  19. package/dist_ts/classes.authoritydatabase.js +1100 -52
  20. package/dist_ts/classes.authoritymodels.d.ts +223 -1
  21. package/dist_ts/classes.authoritymodels.js +798 -9
  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 +11 -0
  43. package/dist_ts/classes.codexmanaged.js +75 -14
  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 +1 -0
  53. package/dist_ts/index.js +7 -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 +5 -2
  57. package/dist_ts/plugins.js +7 -3
  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 +4 -1
  63. package/readme.md +181 -14
  64. package/ts/00_commitinfo_data.ts +1 -1
  65. package/ts/authority-contract.ts +264 -9
  66. package/ts/authority-runtime-contract.ts +14 -0
  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 +389 -121
  71. package/ts/classes.authoritycli.ts +719 -0
  72. package/ts/classes.authorityclient.ts +75 -18
  73. package/ts/classes.authoritydaemon.ts +449 -36
  74. package/ts/classes.authoritydatabase.ts +1147 -55
  75. package/ts/classes.authoritymodels.ts +541 -6
  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 +75 -12
  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 +6 -0
  94. package/ts/interfaces.harness.ts +6 -0
  95. package/ts/interfaces.list.ts +9 -3
  96. package/ts/plugins.ts +7 -2
  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,113 +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
528
  /** Trusted daemon lifecycle hook: hold new managed runtimes out until targeted reauth settles. */
321
529
  public async waitOperation(operationId: string): Promise<void> {
322
530
  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);
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.');
330
536
  }
331
537
  }
332
538
 
333
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 }));
334
550
  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);
551
+ if (operation) await operation.handle.cancel();
552
+ return publicOperation(cancelled);
343
553
  }
344
554
 
345
555
  public async renameAccount(accountId: string, expectedRevision: number, label: string): Promise<IAuthSwitchAccount> {
@@ -351,8 +561,7 @@ export class AuthSwitchAuthorityBroker {
351
561
  statusObservedAt: new Date(this.now()).toISOString() };
352
562
  });
353
563
  this.publish();
354
- const grant = updated.account.primaryGrantId ? await this.database.readGrant(updated.account.primaryGrantId) : null;
355
- return publicAccount(updated.account, grant);
564
+ return publicAccount(updated.account);
356
565
  }
357
566
 
358
567
  public async removeAccount(accountId: string, expectedRevision: number): Promise<IAuthSwitchAccount> {
@@ -361,20 +570,22 @@ export class AuthSwitchAuthorityBroker {
361
570
  const account = await this.database.removeAccountAndGrants(updateId, accountId,
362
571
  expectedRevision, new Date(this.now()).toISOString());
363
572
  this.publish();
364
- const grant = account.primaryGrantId ? await this.database.readGrant(account.primaryGrantId) : null;
365
- return publicAccount(account, grant);
573
+ return publicAccount(account);
366
574
  }
367
575
 
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)
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)
370
580
  || !safeScope(input.scopeId, 512) || !safeScope(input.incarnationId, 256)) throw new Error('Invalid account binding.');
371
581
  const id = idHash('authswitch-binding-v1', input.runtime, input.scopeId);
372
582
  const capability = plugins.crypto.randomBytes(32).toString('base64url');
373
583
  const capabilityHash = idHash(capability);
374
584
  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'
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'
378
589
  || input.runtime === 'claude') {
379
590
  throw new Error('Account is not available for this runtime.');
380
591
  }
@@ -388,11 +599,20 @@ export class AuthSwitchAuthorityBroker {
388
599
 
389
600
  public async revokeBinding(bindingId: string, capability: string): Promise<boolean> {
390
601
  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));
602
+ const revoked = await this.database.revokeBinding(plugins.crypto.randomUUID(), bindingId,
603
+ authSwitchBindingCapabilityHash(capability));
392
604
  if (revoked) this.publish();
393
605
  return revoked;
394
606
  }
395
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
+
396
616
  public async resolveAccess(bindingId: string, capability: string, minValidityMs: number,
397
617
  rejectedGrantGeneration?: number): Promise<IAuthSwitchResolvedAccess> {
398
618
  if (!isId(bindingId) || !/^[A-Za-z0-9_-]{43}$/.test(capability)
@@ -400,16 +620,55 @@ export class AuthSwitchAuthorityBroker {
400
620
  || (rejectedGrantGeneration !== undefined && (!Number.isSafeInteger(rejectedGrantGeneration)
401
621
  || rejectedGrantGeneration < 1))) throw new Error('Invalid runtime credential request.');
402
622
  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;
623
+ let originalBinding: IStoredAuthorityBinding | null = null;
624
+ return this.resolveManagedAccess(async () => {
625
+ const current = await this.database.readBindingContext(bindingId);
626
+ const binding = current.binding;
406
627
  const expected = Buffer.from(binding?.capabilityHash ?? '0'.repeat(64), 'hex');
407
628
  if (!binding || !plugins.crypto.timingSafeEqual(supplied, expected)) {
408
629
  throw new Error('Runtime binding is not authorized.');
409
630
  }
410
- const { account, grant } = initial;
411
- if (!account || account.removed || !grant || grant.id !== binding.grantId
412
- || 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') {
413
672
  throw new Error('Account needs reauthentication.');
414
673
  }
415
674
  if (rejectedGrantGeneration !== undefined && rejectedGrantGeneration > grant.grantGeneration) {
@@ -428,7 +687,8 @@ export class AuthSwitchAuthorityBroker {
428
687
  const rejectedCurrent = rejectedGrantGeneration === grant.grantGeneration;
429
688
  if (this.isDue(grant, minValidityMs) || rejectedCurrent) {
430
689
  if (grant.state === 'ready' || (grant.retryAt !== null && Date.parse(grant.retryAt) <= this.now())) {
431
- await this.refreshAccount(account.id, rejectedCurrent ? rejectedGrantGeneration : undefined);
690
+ await this.refreshAccount(account.id, minValidityMs,
691
+ rejectedCurrent ? rejectedGrantGeneration : undefined);
432
692
  continue;
433
693
  }
434
694
  throw new Error('Account access credential is not fresh enough.');
@@ -439,18 +699,15 @@ export class AuthSwitchAuthorityBroker {
439
699
  if (info.chatgptAccountId !== account.workspaceId || info.chatgptUserId !== account.subject) {
440
700
  throw new Error('Account access identity changed.');
441
701
  }
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
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
450
707
  || latest.grant.owner !== 'daemon') {
451
708
  throw new Error('Account binding changed during credential resolution.');
452
709
  }
453
- if (latest.grant.grantGeneration !== grant.grantGeneration
710
+ if (latest.grant.grantGeneration !== grant.grantGeneration || latest.grant.revision !== grant.revision
454
711
  || latest.grant.state === 'exchange_may_have_been_sent') continue;
455
712
  if (!['ready', 'retry_wait'].includes(latest.grant.state) || this.isDue(latest.grant, minValidityMs)
456
713
  || (rejectedGrantGeneration !== undefined && latest.grant.grantGeneration === rejectedGrantGeneration)) {
@@ -463,20 +720,24 @@ export class AuthSwitchAuthorityBroker {
463
720
  throw new Error('Account credential rotated repeatedly during resolution; retry the request.');
464
721
  }
465
722
 
466
- private async refreshAccount(accountId: string, rejectedGrantGeneration?: number): Promise<void> {
723
+ private async refreshAccount(accountId: string, minValidityMs: number,
724
+ rejectedGrantGeneration?: number): Promise<void> {
467
725
  const prior = this.refreshes.get(accountId);
468
726
  if (prior) return prior;
469
- const task = this.performRefresh(accountId, rejectedGrantGeneration).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); });
470
729
  this.refreshes.set(accountId, task);
471
730
  return task;
472
731
  }
473
732
 
474
- private async performRefresh(accountId: string, rejectedGrantGeneration?: number): Promise<void> {
733
+ private async performRefresh(accountId: string, minValidityMs: number,
734
+ rejectedGrantGeneration?: number): Promise<void> {
735
+ await this.database.assertOpenAdmission();
475
736
  const initial = await this.database.readAccountAndPrimaryGrant(accountId);
476
737
  const initialGrant = initial.grant;
477
738
  if (!initial.account || initial.account.removed || !initialGrant || initialGrant.purpose !== 'openai_managed'
478
739
  || !['ready', 'retry_wait'].includes(initialGrant.state) || initialGrant.owner !== 'daemon'
479
- || (!this.isDue(initialGrant, 0) && initialGrant.grantGeneration !== rejectedGrantGeneration)
740
+ || (!this.isDue(initialGrant, minValidityMs) && initialGrant.grantGeneration !== rejectedGrantGeneration)
480
741
  || (initialGrant.state === 'retry_wait'
481
742
  && (initialGrant.retryAt === null || Date.parse(initialGrant.retryAt) > this.now()))) return;
482
743
  const credential = await this.unsealCredential(initial.account, initialGrant);
@@ -540,13 +801,20 @@ export class AuthSwitchAuthorityBroker {
540
801
  }
541
802
  }
542
803
 
543
- public async close(): Promise<void> {
804
+ public close(): Promise<void> {
805
+ if (this.closing) return this.closing;
544
806
  this.closed = true;
545
807
  if (this.timer) clearTimeout(this.timer);
546
808
  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();
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;
551
819
  }
552
820
  }