@modelprofile.com/authswitch 6.2.0 → 6.4.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 (50) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/authority-contract.d.ts +142 -0
  3. package/dist_ts/authority-contract.js +3 -0
  4. package/dist_ts/authority-runtime-contract.d.ts +32 -0
  5. package/dist_ts/authority-runtime-contract.js +2 -0
  6. package/dist_ts/classes.authoritybroker.d.ts +73 -0
  7. package/dist_ts/classes.authoritybroker.js +548 -0
  8. package/dist_ts/classes.authorityclient.d.ts +34 -0
  9. package/dist_ts/classes.authorityclient.js +200 -0
  10. package/dist_ts/classes.authoritydaemon.d.ts +26 -0
  11. package/dist_ts/classes.authoritydaemon.js +249 -0
  12. package/dist_ts/classes.authoritydatabase.d.ts +94 -0
  13. package/dist_ts/classes.authoritydatabase.js +736 -0
  14. package/dist_ts/classes.authorityframing.d.ts +8 -0
  15. package/dist_ts/classes.authorityframing.js +22 -0
  16. package/dist_ts/classes.authoritymodels.d.ts +215 -0
  17. package/dist_ts/classes.authoritymodels.js +818 -0
  18. package/dist_ts/classes.authoritysecrets.d.ts +9 -0
  19. package/dist_ts/classes.authoritysecrets.js +30 -0
  20. package/dist_ts/classes.authorityservice.d.ts +28 -0
  21. package/dist_ts/classes.authorityservice.js +71 -0
  22. package/dist_ts/classes.cli.d.ts +56 -0
  23. package/dist_ts/classes.cli.js +238 -63
  24. package/dist_ts/classes.codexpreuse.js +2 -2
  25. package/dist_ts/classes.tui.js +51 -2
  26. package/dist_ts/index.d.ts +2 -0
  27. package/dist_ts/index.js +26 -1
  28. package/dist_ts/plugins.d.ts +9 -2
  29. package/dist_ts/plugins.js +10 -3
  30. package/dist_ts/preuse.d.ts +89 -2
  31. package/dist_ts/preuse.js +107 -2
  32. package/package.json +17 -3
  33. package/readme.md +118 -16
  34. package/ts/00_commitinfo_data.ts +1 -1
  35. package/ts/authority-contract.ts +109 -0
  36. package/ts/authority-runtime-contract.ts +27 -0
  37. package/ts/classes.authoritybroker.ts +530 -0
  38. package/ts/classes.authorityclient.ts +183 -0
  39. package/ts/classes.authoritydaemon.ts +219 -0
  40. package/ts/classes.authoritydatabase.ts +725 -0
  41. package/ts/classes.authorityframing.ts +21 -0
  42. package/ts/classes.authoritymodels.ts +367 -0
  43. package/ts/classes.authoritysecrets.ts +27 -0
  44. package/ts/classes.authorityservice.ts +83 -0
  45. package/ts/classes.cli.ts +236 -54
  46. package/ts/classes.codexpreuse.ts +1 -1
  47. package/ts/classes.tui.ts +39 -2
  48. package/ts/index.ts +25 -0
  49. package/ts/plugins.ts +9 -2
  50. package/ts/preuse.ts +141 -3
@@ -0,0 +1,530 @@
1
+ import * as plugins from './plugins.js';
2
+ import type { IAuthSwitchAccount, IAuthSwitchAccountEvent, IAuthSwitchBinding, IAuthSwitchOperation, IAuthSwitchSnapshot } from './authority-contract.js';
3
+ import { AuthSwitchAuthorityDatabase } from './classes.authoritydatabase.js';
4
+ import type { IStoredAuthorityAccount, IStoredAuthorityBinding, IStoredAuthorityGrant } from './classes.authoritymodels.js';
5
+ import { AuthSwitchTpmSecretCodec, type IAuthSwitchSecretCodec } from './classes.authoritysecrets.js';
6
+
7
+ type TOpenAiCredential = plugins.flexAccounts.IOpenAiChatGptOAuthCredential;
8
+ type TOpenAiProvider = Pick<plugins.flexAccounts.ISmartAiProviderAdapter, 'beginLogin' | 'refreshCredential' | 'inspectCredential' | 'dispose'>;
9
+
10
+ export interface IAuthSwitchAuthorityBrokerOptions {
11
+ provider?: TOpenAiProvider;
12
+ secrets?: IAuthSwitchSecretCodec;
13
+ now?: () => number;
14
+ proactive?: boolean;
15
+ }
16
+
17
+ export interface IAuthSwitchResolvedAccess {
18
+ accessToken: string;
19
+ accountId: string;
20
+ isFedrampAccount: boolean;
21
+ expiresAt: string;
22
+ grantGeneration: number;
23
+ }
24
+
25
+ const idHash = (...values: string[]): string => plugins.crypto.createHash('sha256').update(JSON.stringify(values)).digest('hex');
26
+ const isId = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
27
+ const isUuid = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9-]{36}$/.test(value);
28
+ const safeLabel = (value: unknown): value is string => typeof value === 'string' && value.trim() === value
29
+ && value.length > 0 && value.length <= 128 && !/[\u0000-\u001f\u007f]/.test(value);
30
+ const safeScope = (value: unknown, maximum: number): value is string => typeof value === 'string'
31
+ && value.length > 0 && value.length <= maximum && !/[\u0000-\u001f\u007f]/.test(value);
32
+ const validRevision = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0;
33
+ const tokenExpiry = (credential: TOpenAiCredential): string | null => {
34
+ try { return plugins.flexAuth.parseOpenAiChatGptTokenInfo(credential.accessToken).expiresAt ?? null; }
35
+ catch { return null; }
36
+ };
37
+ const openAiGrantId = (accountId: string): string => idHash('authswitch-grant-v1', accountId, 'openai_managed', 'chatgpt');
38
+ const publicAccount = (account: IStoredAuthorityAccount, grant: IStoredAuthorityGrant | null): IAuthSwitchAccount => ({
39
+ 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',
44
+ statusObservedAt: account.statusObservedAt,
45
+ });
46
+ const publicBinding = (binding: IStoredAuthorityBinding): IAuthSwitchBinding => ({
47
+ id: binding.id, accountId: binding.accountId, runtime: binding.runtime,
48
+ scopeId: binding.scopeId, incarnationId: binding.incarnationId, revision: binding.revision,
49
+ });
50
+
51
+ interface IManagedOperation {
52
+ public: IAuthSwitchOperation;
53
+ handle: plugins.flexAccounts.ISmartAiProviderLoginHandle;
54
+ task: Promise<void>;
55
+ cancelled: boolean;
56
+ committing: boolean;
57
+ finishedAt: number | null;
58
+ }
59
+
60
+ /** One daemon-owned authority. No management DTO contains a refresh grant. */
61
+ export class AuthSwitchAuthorityBroker {
62
+ private readonly provider: TOpenAiProvider;
63
+ private readonly secrets: IAuthSwitchSecretCodec;
64
+ private readonly now: () => number;
65
+ private readonly proactive: boolean;
66
+ private readonly operations = new Map<string, IManagedOperation>();
67
+ private readonly refreshes = new Map<string, Promise<void>>();
68
+ private readonly listeners = new Set<() => void>();
69
+ private timer?: NodeJS.Timeout;
70
+ private closed = false;
71
+
72
+ constructor(private readonly database: AuthSwitchAuthorityDatabase, options: IAuthSwitchAuthorityBrokerOptions = {}) {
73
+ this.provider = options.provider ?? new plugins.flexAccounts.OpenAiProviderAdapter();
74
+ this.secrets = options.secrets ?? new AuthSwitchTpmSecretCodec();
75
+ this.now = options.now ?? Date.now;
76
+ this.proactive = options.proactive ?? true;
77
+ }
78
+
79
+ public async start(): Promise<void> {
80
+ const probe = plugins.crypto.randomBytes(32);
81
+ const probeId = idHash('authswitch-custody-probe-v1');
82
+ try {
83
+ const sealed = await this.secrets.seal(probeId, probe);
84
+ const opened = await this.secrets.unseal(probeId, sealed);
85
+ try {
86
+ if (opened.length !== probe.length || !plugins.crypto.timingSafeEqual(opened, probe)) {
87
+ throw new Error('Authswitch secret custody self-check failed.');
88
+ }
89
+ } finally { opened.fill(0); }
90
+ } finally { probe.fill(0); }
91
+ let after: string | null = null;
92
+ while (true) {
93
+ const page = await this.database.grantsByState('exchange_may_have_been_sent', after);
94
+ if (!page.length) break;
95
+ for (const grant of page) {
96
+ const updateId = plugins.crypto.randomUUID();
97
+ await this.database.changeGrant(updateId, grant.id, current => {
98
+ if (!current || current.state !== 'exchange_may_have_been_sent') throw new Error('Refresh recovery changed concurrently.');
99
+ return { ...current, state: 'needs_reauth', attemptId: null, problem: 'exchange_uncertain',
100
+ revision: current.revision + 1, statusObservedAt: new Date(this.now()).toISOString(), updateId };
101
+ });
102
+ this.publish();
103
+ }
104
+ after = page.at(-1)!.id;
105
+ }
106
+ if (this.proactive) this.schedule();
107
+ }
108
+
109
+ private schedule(): void {
110
+ if (this.closed) return;
111
+ this.timer = setTimeout(() => {
112
+ void this.refreshDueAccounts().catch(() => {
113
+ process.stderr.write('Authswitch proactive refresh scan failed; account status may be stale.\n');
114
+ }).finally(() => this.schedule());
115
+ }, 30_000);
116
+ this.timer.unref();
117
+ }
118
+
119
+ public async refreshDueAccounts(): Promise<void> {
120
+ if (this.closed) return;
121
+ for (const state of ['ready', 'retry_wait'] as const) {
122
+ let after: string | null = null;
123
+ while (true) {
124
+ const page = await this.database.grantsByState(state, after);
125
+ if (!page.length) break;
126
+ await Promise.allSettled(page.filter(grant => grant.purpose === 'openai_managed'
127
+ && grant.owner === 'daemon' && this.isDue(grant, 0)
128
+ && (grant.state !== 'retry_wait' || (grant.retryAt !== null && Date.parse(grant.retryAt) <= this.now())))
129
+ .map(grant => this.refreshAccount(grant.accountId)));
130
+ after = page.at(-1)!.id;
131
+ }
132
+ }
133
+ }
134
+
135
+ private isDue(grant: IStoredAuthorityGrant, minValidityMs: number): boolean {
136
+ const expires = grant.accessExpiresAt === null ? NaN : Date.parse(grant.accessExpiresAt);
137
+ return !Number.isFinite(expires) || expires <= this.now() + Math.max(minValidityMs, 0) + 300_000;
138
+ }
139
+
140
+ private publish(): void {
141
+ for (const listener of this.listeners) listener();
142
+ }
143
+
144
+ public async snapshot(options: { accountAfter?: string; bindingAfter?: string; limit?: number } = {}): Promise<IAuthSwitchSnapshot> {
145
+ if ((options.accountAfter !== undefined && !isId(options.accountAfter))
146
+ || (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,
149
+ generatedAt: new Date(this.now()).toISOString(),
150
+ accounts: page.accounts.filter(item => !item.account.removed)
151
+ .map(item => publicAccount(item.account, item.grant)),
152
+ bindings: page.bindings.map(publicBinding),
153
+ nextAccountCursor: page.nextAccountCursor, nextBindingCursor: page.nextBindingCursor };
154
+ }
155
+
156
+ public async events(epoch: string, afterRevision: number, waitMs: number, signal?: AbortSignal): Promise<{
157
+ epoch: string; revision: number; resyncRequired: boolean; events: IAuthSwitchAccountEvent[];
158
+ }> {
159
+ if (!isUuid(epoch) || !validRevision(afterRevision) || !Number.isSafeInteger(waitMs) || waitMs < 0 || waitMs > 30_000) {
160
+ throw new Error('Invalid account event cursor.');
161
+ }
162
+ const inspect = async () => {
163
+ const meta = await this.database.readMeta();
164
+ const resyncRequired = epoch !== meta.epoch || afterRevision > meta.revision;
165
+ const persisted = resyncRequired ? [] : await this.database.eventsAfter(afterRevision);
166
+ if (!resyncRequired && afterRevision < meta.revision
167
+ && (persisted.length === 0 || persisted.some((item, index) => item.revision !== afterRevision + index + 1))) {
168
+ return { epoch: meta.epoch, revision: meta.revision, resyncRequired: true, events: [] };
169
+ }
170
+ const events = persisted.map(item => ({ epoch: item.epoch, revision: item.revision, kind: item.kind, accountId: item.accountId }));
171
+ const revision = events.at(-1)?.revision ?? meta.revision;
172
+ return { epoch: meta.epoch, revision, resyncRequired, events };
173
+ };
174
+ const immediate = await inspect();
175
+ if (immediate.resyncRequired || immediate.events.length || waitMs === 0 || this.closed || signal?.aborted) return immediate;
176
+ return new Promise((resolve, reject) => {
177
+ let finished = false;
178
+ const fail = (error: unknown) => {
179
+ if (finished) return;
180
+ finished = true;
181
+ clearTimeout(timer);
182
+ this.listeners.delete(wake);
183
+ signal?.removeEventListener('abort', wake);
184
+ reject(error);
185
+ };
186
+ const wake = () => {
187
+ if (finished) return;
188
+ finished = true;
189
+ clearTimeout(timer);
190
+ this.listeners.delete(wake);
191
+ signal?.removeEventListener('abort', wake);
192
+ void inspect().then(resolve, reject);
193
+ };
194
+ const timer = setTimeout(wake, waitMs);
195
+ this.listeners.add(wake);
196
+ signal?.addEventListener('abort', wake, { once: true });
197
+ // Close the read/register race without holding a database session for the wait.
198
+ void inspect().then(result => { if (result.resyncRequired || result.events.length) wake(); }, fail);
199
+ });
200
+ }
201
+
202
+ private identity(credential: TOpenAiCredential): { id: string; subject: string; workspaceId: string; email: string | null; plan: string | null } {
203
+ const token = plugins.flexAuth.parseOpenAiChatGptTokenInfo(credential.idToken);
204
+ const subject = token.chatgptUserId;
205
+ const workspaceId = token.chatgptAccountId;
206
+ if (!subject || !workspaceId) throw new Error('OpenAI login omitted a stable user or account identity.');
207
+ return { id: idHash('authswitch-account-v1', 'openai', subject, workspaceId), subject, workspaceId,
208
+ email: token.email?.toLowerCase() ?? null, plan: token.chatgptPlanType ?? null };
209
+ }
210
+
211
+ private async sealCredential(grantId: string, credential: TOpenAiCredential): Promise<string> {
212
+ const bytes = Buffer.from(JSON.stringify(credential), 'utf8');
213
+ try { return await this.secrets.seal(grantId, bytes); }
214
+ finally { bytes.fill(0); }
215
+ }
216
+
217
+ private async unsealCredential(account: IStoredAuthorityAccount, grant: IStoredAuthorityGrant): Promise<TOpenAiCredential> {
218
+ if (!grant.ciphertext || grant.owner !== 'daemon' || grant.purpose !== 'openai_managed'
219
+ || grant.accountId !== account.id) throw new Error('Account has no daemon-owned credential.');
220
+ const bytes = await this.secrets.unseal(grant.id, grant.ciphertext);
221
+ try {
222
+ const value: unknown = JSON.parse(Buffer.from(bytes).toString('utf8'));
223
+ if (!value || typeof value !== 'object') throw new Error();
224
+ const record = value as Record<string, unknown>;
225
+ if (record.kind !== 'chatgptOAuth' || record.providerId !== 'openai'
226
+ || typeof record.accessToken !== 'string' || typeof record.refreshToken !== 'string'
227
+ || typeof record.idToken !== 'string') throw new Error();
228
+ const credential = value as TOpenAiCredential;
229
+ if (this.identity(credential).id !== account.id) throw new Error();
230
+ this.provider.inspectCredential(credential);
231
+ return credential;
232
+ } catch { throw new Error('Stored account credential is invalid or belongs to another account.'); }
233
+ finally { bytes.fill(0); }
234
+ }
235
+
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);
243
+ }
244
+
245
+ private async beginLogin(targetId: string | null): Promise<IAuthSwitchOperation> {
246
+ 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.');
250
+ }
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.');
255
+ }
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
+ operation.task = handle.completion.then(async result => {
263
+ if (operation.cancelled || this.closed) return;
264
+ operation.committing = true;
265
+ const identity = this.identity(result.credential);
266
+ if (targetId !== null && identity.id !== targetId) throw new Error('The device login selected a different OpenAI account.');
267
+ const grantId = openAiGrantId(identity.id);
268
+ 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.');
274
+ if (oldGrant?.owner === 'legacy_native') throw new Error('The legacy native owner still holds this grant.');
275
+ const now = new Date(this.now()).toISOString();
276
+ const account: IStoredAuthorityAccount = existing
277
+ ? { ...existing, label: existing.label || identity.email || `OpenAI ${identity.workspaceId}`,
278
+ email: identity.email, plan: identity.plan, primaryGrantId: grantId, removed: false,
279
+ revision: existing.revision + 1, statusObservedAt: now, updateId }
280
+ : { id: identity.id, providerId: 'openai', issuer: 'openai', subject: identity.subject,
281
+ workspaceId: identity.workspaceId, label: identity.email ?? `OpenAI ${identity.workspaceId}`,
282
+ email: identity.email, plan: identity.plan, primaryGrantId: grantId, removed: false,
283
+ revision: 1, statusObservedAt: now, updateId };
284
+ const grant: IStoredAuthorityGrant = oldGrant
285
+ ? { ...oldGrant, state: 'ready', owner: 'daemon', ciphertext,
286
+ grantGeneration: oldGrant.grantGeneration + 1,
287
+ authorizationGeneration: oldGrant.authorizationGeneration + 1,
288
+ revision: oldGrant.revision + 1, accessExpiresAt: tokenExpiry(result.credential),
289
+ attemptId: null, retryAt: null, retryCount: 0, problem: 'none', statusObservedAt: now, updateId }
290
+ : { id: grantId, accountId: identity.id, providerId: 'openai', purpose: 'openai_managed',
291
+ audience: 'chatgpt', state: 'ready', owner: 'daemon', grantGeneration: 1,
292
+ authorizationGeneration: 1, revision: 1, ciphertext,
293
+ accessExpiresAt: tokenExpiry(result.credential), attemptId: null,
294
+ retryAt: null, retryCount: 0, problem: 'none', statusObservedAt: now, updateId };
295
+ return { account, grant };
296
+ });
297
+ 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.';
305
+ }).finally(async () => {
306
+ operation.finishedAt = this.now();
307
+ try { await handle.close(); } catch { /* A completed operation remains inspectable. */ }
308
+ });
309
+ return structuredClone(publicOperation);
310
+ }
311
+
312
+ public getOperation(operationId: string): IAuthSwitchOperation {
313
+ 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);
318
+ }
319
+
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);
323
+ }
324
+ }
325
+
326
+ public async cancelOperation(operationId: string): Promise<IAuthSwitchOperation> {
327
+ 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);
336
+ }
337
+
338
+ public async renameAccount(accountId: string, expectedRevision: number, label: string): Promise<IAuthSwitchAccount> {
339
+ if (!isId(accountId) || !validRevision(expectedRevision) || !safeLabel(label)) throw new Error('Invalid account change.');
340
+ const updateId = plugins.crypto.randomUUID();
341
+ const updated = await this.database.changeAccount(updateId, accountId, account => {
342
+ if (!account || account.removed || account.revision !== expectedRevision) throw new Error('Account changed; refresh before editing.');
343
+ return { ...account, label, revision: account.revision + 1, updateId,
344
+ statusObservedAt: new Date(this.now()).toISOString() };
345
+ });
346
+ this.publish();
347
+ const grant = updated.account.primaryGrantId ? await this.database.readGrant(updated.account.primaryGrantId) : null;
348
+ return publicAccount(updated.account, grant);
349
+ }
350
+
351
+ public async removeAccount(accountId: string, expectedRevision: number): Promise<IAuthSwitchAccount> {
352
+ if (!isId(accountId) || !validRevision(expectedRevision)) throw new Error('Invalid account change.');
353
+ const updateId = plugins.crypto.randomUUID();
354
+ const account = await this.database.removeAccountAndGrants(updateId, accountId,
355
+ expectedRevision, new Date(this.now()).toISOString());
356
+ this.publish();
357
+ const grant = account.primaryGrantId ? await this.database.readGrant(account.primaryGrantId) : null;
358
+ return publicAccount(account, grant);
359
+ }
360
+
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)
363
+ || !safeScope(input.scopeId, 512) || !safeScope(input.incarnationId, 256)) throw new Error('Invalid account binding.');
364
+ const id = idHash('authswitch-binding-v1', input.runtime, input.scopeId);
365
+ const capability = plugins.crypto.randomBytes(32).toString('base64url');
366
+ const capabilityHash = idHash(capability);
367
+ 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'
371
+ || input.runtime === 'claude') {
372
+ throw new Error('Account is not available for this runtime.');
373
+ }
374
+ return { id, accountId: input.accountId, grantId: grant.id, runtime: input.runtime, scopeId: input.scopeId,
375
+ incarnationId: input.incarnationId, revision: (existing?.revision ?? 0) + 1,
376
+ grantAuthorizationGeneration: grant.authorizationGeneration, capabilityHash, updateId };
377
+ });
378
+ this.publish();
379
+ return { binding: publicBinding(updated.binding), capability };
380
+ }
381
+
382
+ public async resolveAccess(bindingId: string, capability: string, minValidityMs: number): Promise<IAuthSwitchResolvedAccess> {
383
+ 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.');
385
+ 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;
389
+ const expected = Buffer.from(binding?.capabilityHash ?? '0'.repeat(64), 'hex');
390
+ if (!binding || !plugins.crypto.timingSafeEqual(supplied, expected)) {
391
+ throw new Error('Runtime binding is not authorized.');
392
+ }
393
+ const { account, grant } = initial;
394
+ if (!account || account.removed || !grant || grant.id !== binding.grantId
395
+ || binding.grantAuthorizationGeneration !== grant.authorizationGeneration) {
396
+ throw new Error('Account needs reauthentication.');
397
+ }
398
+ if (grant.state === 'exchange_may_have_been_sent') {
399
+ const inFlight = this.refreshes.get(account.id);
400
+ if (!inFlight) throw new Error('Account refresh is unresolved; reauthentication is required.');
401
+ await inFlight;
402
+ continue;
403
+ }
404
+ if (grant.owner !== 'daemon' || grant.purpose !== 'openai_managed'
405
+ || !['ready', 'retry_wait'].includes(grant.state)) {
406
+ throw new Error('Account needs reauthentication.');
407
+ }
408
+ if (this.isDue(grant, minValidityMs)) {
409
+ if (grant.state === 'ready' || (grant.retryAt !== null && Date.parse(grant.retryAt) <= this.now())) {
410
+ await this.refreshAccount(account.id);
411
+ continue;
412
+ }
413
+ throw new Error('Account access credential is not fresh enough.');
414
+ }
415
+ if (!grant.accessExpiresAt) throw new Error('Account access credential is not fresh enough.');
416
+ const credential = await this.unsealCredential(account, grant);
417
+ const info = plugins.flexAuth.parseOpenAiChatGptTokenInfo(credential.accessToken);
418
+ if (info.chatgptAccountId !== account.workspaceId || info.chatgptUserId !== account.subject) {
419
+ throw new Error('Account access identity changed.');
420
+ }
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
429
+ || latest.grant.owner !== 'daemon') {
430
+ throw new Error('Account binding changed during credential resolution.');
431
+ }
432
+ if (latest.grant.grantGeneration !== grant.grantGeneration
433
+ || latest.grant.state === 'exchange_may_have_been_sent') continue;
434
+ if (!['ready', 'retry_wait'].includes(latest.grant.state) || this.isDue(latest.grant, minValidityMs)) {
435
+ throw new Error('Account access credential is not fresh enough.');
436
+ }
437
+ return { accessToken: credential.accessToken, accountId: account.workspaceId,
438
+ isFedrampAccount: info.chatgptAccountIsFedramp, expiresAt: grant.accessExpiresAt,
439
+ grantGeneration: grant.grantGeneration };
440
+ }
441
+ throw new Error('Account credential rotated repeatedly during resolution; retry the request.');
442
+ }
443
+
444
+ private async refreshAccount(accountId: string): Promise<void> {
445
+ const prior = this.refreshes.get(accountId);
446
+ if (prior) return prior;
447
+ const task = this.performRefresh(accountId).finally(() => { if (this.refreshes.get(accountId) === task) this.refreshes.delete(accountId); });
448
+ this.refreshes.set(accountId, task);
449
+ return task;
450
+ }
451
+
452
+ private async performRefresh(accountId: string): Promise<void> {
453
+ const initial = await this.database.readAccountAndPrimaryGrant(accountId);
454
+ const initialGrant = initial.grant;
455
+ if (!initial.account || initial.account.removed || !initialGrant || initialGrant.purpose !== 'openai_managed'
456
+ || !['ready', 'retry_wait'].includes(initialGrant.state) || initialGrant.owner !== 'daemon'
457
+ || !this.isDue(initialGrant, 0)
458
+ || (initialGrant.state === 'retry_wait'
459
+ && (initialGrant.retryAt === null || Date.parse(initialGrant.retryAt) > this.now()))) return;
460
+ const credential = await this.unsealCredential(initial.account, initialGrant);
461
+ const attemptId = plugins.crypto.randomUUID();
462
+ await this.database.changeGrant(attemptId, initialGrant.id, (grant, account) => {
463
+ if (account.removed || grant.state !== initialGrant.state || grant.revision !== initialGrant.revision
464
+ || grant.grantGeneration !== initialGrant.grantGeneration) throw new Error('Account changed before refresh began.');
465
+ return { ...grant, state: 'exchange_may_have_been_sent', attemptId, retryAt: null,
466
+ revision: grant.revision + 1, statusObservedAt: new Date(this.now()).toISOString(), updateId: attemptId };
467
+ });
468
+ this.publish();
469
+ try {
470
+ const refreshed = await this.provider.refreshCredential(credential);
471
+ if (!refreshed.success) {
472
+ if (refreshed.requestOutcome === 'notSent') {
473
+ const retryId = plugins.crypto.randomUUID();
474
+ await this.database.changeGrant(retryId, initialGrant.id, grant => {
475
+ if (grant.state !== 'exchange_may_have_been_sent' || grant.attemptId !== attemptId) {
476
+ throw new Error('Account ownership changed during refresh.');
477
+ }
478
+ const backoffMs = Math.min(30_000 * 2 ** Math.min(grant.retryCount, 10), 1_800_000);
479
+ const retryMs = Math.max(backoffMs, Math.min(refreshed.retryAfterMs ?? 0, 3_600_000));
480
+ return { ...grant, state: 'retry_wait', attemptId: null,
481
+ retryAt: new Date(this.now() + retryMs).toISOString(), retryCount: grant.retryCount + 1,
482
+ problem: 'provider_unavailable',
483
+ revision: grant.revision + 1, statusObservedAt: new Date(this.now()).toISOString(), updateId: retryId };
484
+ });
485
+ this.publish();
486
+ return;
487
+ }
488
+ throw new Error('Provider refresh outcome is unknown.');
489
+ }
490
+ const identity = this.identity(refreshed.credential);
491
+ if (identity.id !== accountId) throw new Error('Provider returned a different account.');
492
+ const ciphertext = await this.sealCredential(initialGrant.id, refreshed.credential);
493
+ const commitId = plugins.crypto.randomUUID();
494
+ await this.database.changeGrant(commitId, initialGrant.id, grant => {
495
+ if (grant.state !== 'exchange_may_have_been_sent' || grant.attemptId !== attemptId) {
496
+ throw new Error('Account ownership changed during refresh.');
497
+ }
498
+ return { ...grant, state: 'ready', attemptId: null, retryAt: null, retryCount: 0, problem: 'none',
499
+ ciphertext, accessExpiresAt: tokenExpiry(refreshed.credential),
500
+ grantGeneration: grant.grantGeneration + 1, revision: grant.revision + 1,
501
+ statusObservedAt: new Date(this.now()).toISOString(), updateId: commitId };
502
+ });
503
+ this.publish();
504
+ } catch {
505
+ const uncertainId = plugins.crypto.randomUUID();
506
+ try {
507
+ await this.database.changeGrant(uncertainId, initialGrant.id, grant => {
508
+ if (grant.state !== 'exchange_may_have_been_sent' || grant.attemptId !== attemptId) {
509
+ throw new Error('Account was changed after its refresh attempt.');
510
+ }
511
+ return { ...grant, state: 'needs_reauth', attemptId: null, retryAt: null,
512
+ problem: 'exchange_uncertain', revision: grant.revision + 1,
513
+ statusObservedAt: new Date(this.now()).toISOString(), updateId: uncertainId };
514
+ });
515
+ this.publish();
516
+ } catch { /* A persisted attempt marker forces needs_reauth on daemon restart. */ }
517
+ throw new Error('Refresh result is uncertain. Reauthenticate this account; its old grant will not be replayed.');
518
+ }
519
+ }
520
+
521
+ public async close(): Promise<void> {
522
+ this.closed = true;
523
+ if (this.timer) clearTimeout(this.timer);
524
+ 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();
529
+ }
530
+ }