@modelprofile.com/authswitch 9.0.0 → 9.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/authority-contract.d.ts +71 -1
- package/dist_ts/authority-contract.js +13 -2
- package/dist_ts/authority-import-contract.d.ts +6 -0
- package/dist_ts/authority-runtime-contract.d.ts +29 -0
- package/dist_ts/classes.authoritybroker.d.ts +37 -15
- package/dist_ts/classes.authoritybroker.js +121 -35
- package/dist_ts/classes.authorityclient.d.ts +28 -3
- package/dist_ts/classes.authorityclient.js +101 -18
- package/dist_ts/classes.authoritydaemon.d.ts +24 -0
- package/dist_ts/classes.authoritydaemon.js +87 -40
- package/dist_ts/classes.authoritydatabase.d.ts +29 -4
- package/dist_ts/classes.authoritydatabase.js +98 -13
- package/dist_ts/classes.authorityimport.js +2 -2
- package/dist_ts/classes.authoritymodels.js +5 -3
- package/dist_ts/classes.codexmanaged.d.ts +0 -9
- package/dist_ts/classes.codexmanaged.js +8 -28
- package/dist_ts/codexcontract.d.ts +30 -0
- package/dist_ts/codexcontract.js +174 -0
- package/dist_ts/ts_migration/0004_container_setup_owner.d.ts +12 -0
- package/dist_ts/ts_migration/0004_container_setup_owner.js +19 -0
- package/dist_ts/ts_migration/index.js +3 -1
- package/package.json +8 -8
- package/readme.md +102 -25
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/authority-contract.ts +75 -4
- package/ts/authority-import-contract.ts +6 -0
- package/ts/authority-runtime-contract.ts +30 -0
- package/ts/classes.authoritybroker.ts +122 -36
- package/ts/classes.authorityclient.ts +107 -21
- package/ts/classes.authoritydaemon.ts +91 -33
- package/ts/classes.authoritydatabase.ts +100 -14
- package/ts/classes.authorityimport.ts +1 -1
- package/ts/classes.authoritymodels.ts +4 -1
- package/ts/classes.codexmanaged.ts +6 -26
- package/ts/codexcontract.ts +200 -0
- package/ts/ts_migration/0004_container_setup_owner.ts +19 -0
- package/ts/ts_migration/index.ts +2 -0
|
@@ -4,12 +4,13 @@ import type {
|
|
|
4
4
|
IAuthSwitchDoctorPage, IReq_AuthSwitchDoctor,
|
|
5
5
|
IReq_AuthSwitchBeginAdd, IReq_AuthSwitchBeginReauth, IReq_AuthSwitchCancelOperation,
|
|
6
6
|
IReq_AuthSwitchClaudeNativeHandoff, IReq_AuthSwitchClaudeNativeHandoffs, IReq_AuthSwitchSwitchClaudeNative,
|
|
7
|
-
IReq_AuthSwitchEvents, IReq_AuthSwitchGetOperation, IReq_AuthSwitchListOperations,
|
|
7
|
+
IReq_AuthSwitchEvents, IReq_AuthSwitchGetBinding, IReq_AuthSwitchGetOperation, IReq_AuthSwitchListOperations,
|
|
8
|
+
IReq_AuthSwitchRemoveAccount,
|
|
8
9
|
IReq_AuthSwitchRenameAccount, IReq_AuthSwitchSnapshot,
|
|
9
10
|
IReq_AuthSwitchCancelPreuse, IReq_AuthSwitchGetPreuse, IReq_AuthSwitchStartPreuse, IReq_AuthSwitchUsage,
|
|
10
11
|
} from './authority-contract.js';
|
|
11
12
|
import type { IReq_AuthSwitchBindAccount, IReq_AuthSwitchReleaseBinding,
|
|
12
|
-
IReq_AuthSwitchResolveAccess } from './authority-runtime-contract.js';
|
|
13
|
+
IReq_AuthSwitchResolveAccess, IReq_AuthSwitchUnbind } from './authority-runtime-contract.js';
|
|
13
14
|
import { AuthSwitchRefusal } from './authority-contract.js';
|
|
14
15
|
import type { IReq_AuthSwitchImportInventory, IReq_AuthSwitchImportStatus, IReq_AuthSwitchImportSubmit,
|
|
15
16
|
TAuthSwitchImportSubmission } from './authority-import-contract.js';
|
|
@@ -39,6 +40,11 @@ export interface IAuthSwitchAuthorityDaemonOptions extends IAuthSwitchAuthorityD
|
|
|
39
40
|
claudeNative?: IClaudeNativeAuthorityOptions;
|
|
40
41
|
/** Where the one-time import reads the legacy stores, or `'none'`. Never derived from this host here. */
|
|
41
42
|
legacyImport: TAuthSwitchAuthorityLegacyImport;
|
|
43
|
+
/**
|
|
44
|
+
* How long an accepted connection has to deliver its complete request, counted from its acceptance and
|
|
45
|
+
* never extended by the bytes it sends: 35 s unless stated.
|
|
46
|
+
*/
|
|
47
|
+
requestDeliveryMs?: number;
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
/**
|
|
@@ -136,12 +142,19 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
136
142
|
private readonly runtimeBindingReleaseFences = new Set<string>();
|
|
137
143
|
private readonly runtimeBindingReleases = new Map<string, Promise<IReq_AuthSwitchReleaseBinding['response']>>();
|
|
138
144
|
private closing = false;
|
|
145
|
+
/** Every admitted request until its answer is written; shutdown settles these before it closes the store. */
|
|
146
|
+
private readonly inFlight = new Set<Promise<void>>();
|
|
147
|
+
private readonly requestDeliveryMs: number;
|
|
139
148
|
|
|
140
149
|
constructor(private readonly options: IAuthSwitchAuthorityDaemonOptions) {
|
|
141
150
|
// First, before anything is constructed: a daemon that was going to read the wrong host's credentials
|
|
142
151
|
// must not come into existence at all. The type states it and this states it again, because a caller
|
|
143
152
|
// without types is exactly the caller that would have defaulted here.
|
|
144
153
|
assertStatedLegacyImport(options.legacyImport);
|
|
154
|
+
this.requestDeliveryMs = options.requestDeliveryMs ?? 35_000;
|
|
155
|
+
if (!Number.isSafeInteger(this.requestDeliveryMs) || this.requestDeliveryMs <= 0) {
|
|
156
|
+
throw new Error('Authswitch authority requestDeliveryMs must be a positive whole number of milliseconds.');
|
|
157
|
+
}
|
|
145
158
|
this.database = new AuthSwitchAuthorityDatabase(options);
|
|
146
159
|
const provider = options.broker?.provider ?? new plugins.flexAccounts.OpenAiProviderAdapter();
|
|
147
160
|
this.broker = new AuthSwitchAuthorityBroker(this.database, { ...options.broker, provider });
|
|
@@ -182,8 +195,9 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
182
195
|
this.register();
|
|
183
196
|
}
|
|
184
197
|
|
|
185
|
-
|
|
186
|
-
|
|
198
|
+
/** One exact capability of one binding: what a release fences and what an access resolution holds open. */
|
|
199
|
+
private runtimeBindingKey(bindingId: string, capabilityHash: string): string {
|
|
200
|
+
return `${bindingId}:${capabilityHash}`;
|
|
187
201
|
}
|
|
188
202
|
|
|
189
203
|
private beginRuntimeBindingAccess(key: string): () => void {
|
|
@@ -210,8 +224,8 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
210
224
|
}
|
|
211
225
|
|
|
212
226
|
private releaseRuntimeBinding(bindingId: string,
|
|
213
|
-
|
|
214
|
-
const key = this.runtimeBindingKey(bindingId,
|
|
227
|
+
capabilityHash: string): Promise<IReq_AuthSwitchReleaseBinding['response']> {
|
|
228
|
+
const key = this.runtimeBindingKey(bindingId, capabilityHash);
|
|
215
229
|
const existing = this.runtimeBindingReleases.get(key);
|
|
216
230
|
if (existing) return existing;
|
|
217
231
|
// Capabilities are freshly random for every bind, including a successor that reuses the
|
|
@@ -219,7 +233,7 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
219
233
|
this.runtimeBindingReleaseFences.add(key);
|
|
220
234
|
const task = (async (): Promise<IReq_AuthSwitchReleaseBinding['response']> => {
|
|
221
235
|
await this.waitForRuntimeBindingAccess(key);
|
|
222
|
-
const state = await this.broker.
|
|
236
|
+
const state = await this.broker.releaseExternalCapability(bindingId, capabilityHash);
|
|
223
237
|
this.runtimeBindingReleaseFences.delete(key);
|
|
224
238
|
return { state };
|
|
225
239
|
})();
|
|
@@ -279,8 +293,12 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
279
293
|
private handler<T extends ITypedRequest>(method: T['method'],
|
|
280
294
|
run: plugins.typedrequest.THandlerFunction<T>): plugins.typedrequest.TypedHandler<T> {
|
|
281
295
|
return new plugins.typedrequest.TypedHandler<T>(method, async (request, tools) => {
|
|
282
|
-
try {
|
|
283
|
-
|
|
296
|
+
try {
|
|
297
|
+
// The one admission check: once shutdown begins no route starts work, and the requests already
|
|
298
|
+
// admitted are settled before the store closes.
|
|
299
|
+
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
300
|
+
return await run(request, tools);
|
|
301
|
+
} catch (error) {
|
|
284
302
|
if (error instanceof AuthSwitchRefusal) {
|
|
285
303
|
throw new plugins.typedrequest.TypedResponseError(error.message, error.data);
|
|
286
304
|
}
|
|
@@ -297,7 +315,6 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
297
315
|
|| Object.keys(request).some(key => !['accountId', 'loginId', 'force'].includes(key))
|
|
298
316
|
|| typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
|
|
299
317
|
|| (request.force !== undefined && typeof request.force !== 'boolean')) invalid();
|
|
300
|
-
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
301
318
|
return { usage: await this.usage.get(request.accountId, request.loginId, request.force ?? false) };
|
|
302
319
|
}));
|
|
303
320
|
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchSwitchClaudeNative>(
|
|
@@ -305,7 +322,6 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
305
322
|
if (!hasKeys(request, ['accountId', 'loginId', 'purpose'])
|
|
306
323
|
|| typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
|
|
307
324
|
|| request.purpose !== 'claude_host_native') invalid();
|
|
308
|
-
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
309
325
|
if (!this.claudeNative) throw new AuthSwitchRefusal('claude_home_unregistered', 'Claude native handoff is unavailable.');
|
|
310
326
|
return { handoff: await this.claudeNative.switchTo(request.accountId, request.loginId) };
|
|
311
327
|
}));
|
|
@@ -326,7 +342,9 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
326
342
|
'authswitch.authority.snapshot', async request => {
|
|
327
343
|
if (request === null || typeof request !== 'object' || Array.isArray(request)) invalid();
|
|
328
344
|
const keys = Object.keys(request);
|
|
329
|
-
if (keys.some(key => !['accountAfter', 'loginAfter', 'bindingAfter', 'limit'
|
|
345
|
+
if (keys.some(key => !['accountAfter', 'loginAfter', 'bindingAfter', 'nativeAssignmentAfter', 'limit',
|
|
346
|
+
'includeRemoved'].includes(key))
|
|
347
|
+
|| (request.includeRemoved !== undefined && typeof request.includeRemoved !== 'boolean')) invalid();
|
|
330
348
|
return { snapshot: await this.broker.snapshot(request) };
|
|
331
349
|
}));
|
|
332
350
|
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchDoctor>(
|
|
@@ -346,7 +364,6 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
346
364
|
// believing a source does not exist.
|
|
347
365
|
if (request === null || typeof request !== 'object' || Array.isArray(request)
|
|
348
366
|
|| Object.keys(request).length !== 0) invalid();
|
|
349
|
-
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
350
367
|
if (!this.legacyImport) throw new AuthSwitchRefusal('import_refusal', legacyImportAbsent);
|
|
351
368
|
return { inventory: await this.legacyImport.inventory() };
|
|
352
369
|
}));
|
|
@@ -357,7 +374,6 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
357
374
|
|| (request.after !== undefined && !validHash(request.after))
|
|
358
375
|
|| (request.limit !== undefined && (!Number.isSafeInteger(request.limit)
|
|
359
376
|
|| request.limit < 1 || request.limit > 128))) invalid();
|
|
360
|
-
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
361
377
|
if (!this.legacyImport) throw new AuthSwitchRefusal('import_refusal', legacyImportAbsent);
|
|
362
378
|
return { status: await this.legacyImport.status(request) };
|
|
363
379
|
}));
|
|
@@ -370,7 +386,6 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
370
386
|
'authswitch.authority.add', async request => {
|
|
371
387
|
if (!hasKeys(request, ['operationId', 'providerId', 'flow']) || typeof request.operationId !== 'string'
|
|
372
388
|
|| request.providerId !== 'openai' || request.flow !== 'device') invalid();
|
|
373
|
-
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
374
389
|
return { operation: await this.broker.beginAddOpenAi(request.operationId) };
|
|
375
390
|
}));
|
|
376
391
|
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchBeginReauth>(
|
|
@@ -379,13 +394,16 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
379
394
|
|| typeof request.operationId !== 'string'
|
|
380
395
|
|| typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
|
|
381
396
|
|| request.purpose !== 'openai_managed') invalid();
|
|
382
|
-
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
383
397
|
return { operation: await this.beginReauthOpenAi(request) };
|
|
384
398
|
}));
|
|
385
399
|
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchGetOperation>(
|
|
386
|
-
'authswitch.authority.operation', async request => {
|
|
387
|
-
|
|
388
|
-
|
|
400
|
+
'authswitch.authority.operation', async (request, tools) => {
|
|
401
|
+
const waits = hasKeys(request, ['operationId', 'afterRevision', 'waitMs']);
|
|
402
|
+
if ((!waits && !hasKeys(request, ['operationId'])) || typeof request.operationId !== 'string'
|
|
403
|
+
|| (waits && (!Number.isSafeInteger(request.afterRevision) || request.afterRevision! < 0
|
|
404
|
+
|| !Number.isSafeInteger(request.waitMs) || request.waitMs! < 0 || request.waitMs! > 30_000))) invalid();
|
|
405
|
+
return { operation: await this.broker.getOperation(request.operationId, waits
|
|
406
|
+
? { afterRevision: request.afterRevision!, waitMs: request.waitMs! } : undefined, tools?.abortSignal) };
|
|
389
407
|
}));
|
|
390
408
|
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchListOperations>(
|
|
391
409
|
'authswitch.authority.operations', async request => {
|
|
@@ -407,7 +425,6 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
407
425
|
|| typeof request.loginId !== 'string' || request.purpose !== 'openai_managed'
|
|
408
426
|
|| typeof request.prompt !== 'string'
|
|
409
427
|
|| (request.model !== undefined && typeof request.model !== 'string')) invalid();
|
|
410
|
-
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
411
428
|
const reservation: IPreuseAccountReservation = { operationId: request.operationId };
|
|
412
429
|
let ownsReservation = false;
|
|
413
430
|
const release = (): void => {
|
|
@@ -450,6 +467,11 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
450
467
|
|| typeof request.expectedRevision !== 'number' || typeof request.label !== 'string') invalid();
|
|
451
468
|
return { account: await this.broker.renameAccount(request.accountId, request.expectedRevision, request.label) };
|
|
452
469
|
}));
|
|
470
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchGetBinding>(
|
|
471
|
+
'authswitch.authority.binding', async request => {
|
|
472
|
+
if (!hasKeys(request, ['bindingId']) || !validBindingId(request.bindingId)) invalid();
|
|
473
|
+
return { binding: await this.broker.getBinding(request.bindingId) };
|
|
474
|
+
}));
|
|
453
475
|
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchRemoveAccount>(
|
|
454
476
|
'authswitch.authority.remove', async request => {
|
|
455
477
|
if (!hasKeys(request, ['accountId', 'expectedRevision']) || typeof request.accountId !== 'string'
|
|
@@ -471,6 +493,17 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
471
493
|
|| typeof request.incarnationId !== 'string') invalid();
|
|
472
494
|
return this.broker.bindAccount(request);
|
|
473
495
|
}));
|
|
496
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchUnbind>(
|
|
497
|
+
'authswitch.authority.unbind', async request => {
|
|
498
|
+
if (!hasKeys(request, ['runtime', 'scopeId', 'incarnationId', 'expectedRevision'])
|
|
499
|
+
|| !['flex', 'opencode', 'claude'].includes(request.runtime) || typeof request.scopeId !== 'string'
|
|
500
|
+
|| typeof request.incarnationId !== 'string' || typeof request.expectedRevision !== 'number') invalid();
|
|
501
|
+
// The holder's own binding, found by the identity it bound under whatever its login's state; from
|
|
502
|
+
// here it is released exactly as its capability would release it.
|
|
503
|
+
const held = await this.broker.findHeldBinding(request);
|
|
504
|
+
if (!held) return { state: 'inactive' };
|
|
505
|
+
return this.releaseRuntimeBinding(held.bindingId, held.capabilityHash);
|
|
506
|
+
}));
|
|
474
507
|
this.runtimeRouter.addTypedHandler(this.handler<IReq_AuthSwitchResolveAccess>(
|
|
475
508
|
'authswitch.authority.resolveAccess', async request => {
|
|
476
509
|
if ((typeof request !== 'object' || request === null || Array.isArray(request))
|
|
@@ -480,8 +513,8 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
480
513
|
|| !validBindingId(request.bindingId) || !validBindingCapability(request.capability)
|
|
481
514
|
|| typeof request.minValidityMs !== 'number'
|
|
482
515
|
|| (request.rejectedGrantGeneration !== undefined && typeof request.rejectedGrantGeneration !== 'number')) invalid();
|
|
483
|
-
|
|
484
|
-
|
|
516
|
+
const finish = this.beginRuntimeBindingAccess(this.runtimeBindingKey(request.bindingId,
|
|
517
|
+
authSwitchBindingCapabilityHash(request.capability)));
|
|
485
518
|
try {
|
|
486
519
|
return await this.broker.resolveAccess(request.bindingId, request.capability, request.minValidityMs,
|
|
487
520
|
request.rejectedGrantGeneration);
|
|
@@ -491,8 +524,7 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
491
524
|
'authswitch.authority.release', async request => {
|
|
492
525
|
if (!hasKeys(request, ['bindingId', 'capability'])
|
|
493
526
|
|| !validBindingId(request.bindingId) || !validBindingCapability(request.capability)) invalid();
|
|
494
|
-
|
|
495
|
-
return this.releaseRuntimeBinding(request.bindingId, request.capability);
|
|
527
|
+
return this.releaseRuntimeBinding(request.bindingId, authSwitchBindingCapabilityHash(request.capability));
|
|
496
528
|
}));
|
|
497
529
|
this.runtimeRouter.addTypedHandler(this.handler<IReq_AuthSwitchImportSubmit>(
|
|
498
530
|
'authswitch.authority.import.submit', async request => {
|
|
@@ -500,14 +532,17 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
500
532
|
if (!hasKeys(request, ['submission', 'callerQuiescent', 'acknowledgeRunOrder'])
|
|
501
533
|
|| request.callerQuiescent !== true || request.acknowledgeRunOrder !== true
|
|
502
534
|
|| !validImportSubmission(request.submission)) invalid();
|
|
503
|
-
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
504
535
|
if (!this.legacyImport) throw new AuthSwitchRefusal('import_refusal', legacyImportAbsent);
|
|
505
536
|
// An importer refusal is an `AuthSwitchRefusal` like every other, so the one conversion in
|
|
506
537
|
// `handler()` marks it. What stays special is its meaning: an UNMARKED failure of this route
|
|
507
538
|
// leaves the import outcome unknown, and the source must be read with `import.status` rather
|
|
508
539
|
// than submitted again.
|
|
509
|
-
|
|
510
|
-
|
|
540
|
+
// An import writes accounts, logins and native homes, also on a path that fails after a commit, so
|
|
541
|
+
// every outcome wakes the event readers; they read the committed revision themselves.
|
|
542
|
+
try {
|
|
543
|
+
return { result: await this.legacyImport.submit({ submission: request.submission,
|
|
544
|
+
callerQuiescent: true, acknowledgeRunOrder: true }) };
|
|
545
|
+
} finally { this.broker.notifyChanged(); }
|
|
511
546
|
}));
|
|
512
547
|
}
|
|
513
548
|
|
|
@@ -878,13 +913,24 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
878
913
|
await plugins.fs.promises.unlink(path);
|
|
879
914
|
}
|
|
880
915
|
|
|
916
|
+
/**
|
|
917
|
+
* Serves one request on one connection. A connection's failure is its own: a client that went away before
|
|
918
|
+
* its answer -- EPIPE or a reset on the write, a read error -- only destroys that socket, and the close
|
|
919
|
+
* that follows aborts the request it had admitted. Nothing one client does can end the daemon.
|
|
920
|
+
*
|
|
921
|
+
* A connection has one deadline to deliver its complete request, `requestDeliveryMs` from acceptance, which
|
|
922
|
+
* no byte it sends extends: a client dripping a request slowly holds its connection no longer than one that
|
|
923
|
+
* sends nothing. Once admitted, the request runs until it answers or its client goes away: the client's own
|
|
924
|
+
* deadline governs, which may be longer than the delivery deadline.
|
|
925
|
+
*/
|
|
881
926
|
private accept(socket: plugins.net.Socket, router: plugins.typedrequest.TypedRouter): void {
|
|
882
927
|
this.sockets.add(socket);
|
|
883
928
|
const abort = new AbortController();
|
|
884
|
-
|
|
929
|
+
const delivery = setTimeout(() => socket.destroy(), this.requestDeliveryMs);
|
|
930
|
+
socket.on('error', () => socket.destroy());
|
|
931
|
+
socket.on('close', () => { clearTimeout(delivery); this.sockets.delete(socket); abort.abort(); });
|
|
885
932
|
const frame = new AuthSwitchAuthorityFrameReader();
|
|
886
933
|
let handled = false;
|
|
887
|
-
socket.setTimeout(35_000, () => socket.destroy());
|
|
888
934
|
socket.on('data', chunk => {
|
|
889
935
|
if (handled) { socket.destroy(); return; }
|
|
890
936
|
if (!Buffer.isBuffer(chunk)) { socket.destroy(); return; }
|
|
@@ -893,19 +939,29 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
893
939
|
catch { socket.destroy(); return; }
|
|
894
940
|
if (input === null) return;
|
|
895
941
|
handled = true;
|
|
942
|
+
clearTimeout(delivery);
|
|
896
943
|
let request: ITypedRequest;
|
|
897
944
|
try { request = JSON.parse(input) as ITypedRequest; }
|
|
898
945
|
catch { socket.destroy(); return; }
|
|
899
|
-
void router.routeAndAddResponse(request, { trustedLocalData: { authoritySocket: socket },
|
|
946
|
+
const answered: Promise<void> = router.routeAndAddResponse(request, { trustedLocalData: { authoritySocket: socket },
|
|
900
947
|
trustedAbortSignal: abort.signal })
|
|
901
|
-
.then(response => {
|
|
948
|
+
.then(async response => {
|
|
949
|
+
// A client that is already gone is owed no answer.
|
|
950
|
+
if (!socket.writable) return;
|
|
902
951
|
const raw = JSON.stringify(response);
|
|
903
952
|
if (authSwitchAuthorityFrameBytes(raw) > maxAuthSwitchAuthorityFrameBytes) { socket.destroy(); return; }
|
|
904
|
-
socket.end(raw + '\n');
|
|
905
|
-
}).catch(() => socket.destroy())
|
|
953
|
+
await new Promise<void>(resolve => socket.end(raw + '\n', () => resolve()));
|
|
954
|
+
}).catch(() => { socket.destroy(); })
|
|
955
|
+
.finally(() => { this.inFlight.delete(answered); });
|
|
956
|
+
this.inFlight.add(answered);
|
|
906
957
|
});
|
|
907
958
|
}
|
|
908
959
|
|
|
960
|
+
/**
|
|
961
|
+
* Stops admitting work -- the sockets keep listening so every new request is answered `authority_closing`
|
|
962
|
+
* -- settles everything already admitted, a waiting `events` or operation read being woken by the broker and
|
|
963
|
+
* answering from the still-open store, and only then closes the sockets and the store.
|
|
964
|
+
*/
|
|
909
965
|
public async close(): Promise<void> {
|
|
910
966
|
this.closing = true;
|
|
911
967
|
const errors: unknown[] = [];
|
|
@@ -923,6 +979,8 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
923
979
|
try { await this.usage.close(); } catch (error) { errors.push(error); }
|
|
924
980
|
try { await this.broker.close(); } catch (error) { errors.push(error); }
|
|
925
981
|
if (this.claudeNative) try { await this.claudeNative.close(); } catch (error) { errors.push(error); }
|
|
982
|
+
// A request that arrives meanwhile is refused at admission and joins this set; none of those reads the store.
|
|
983
|
+
while (this.inFlight.size) await Promise.allSettled([...this.inFlight]);
|
|
926
984
|
const management = this.managementServer;
|
|
927
985
|
const runtime = this.runtimeServer;
|
|
928
986
|
this.managementServer = undefined;
|
|
@@ -56,6 +56,11 @@ const sameUsageSource = (left: IAuthSwitchUsageContext, right: IAuthSwitchUsageC
|
|
|
56
56
|
&& left.nativeSource.credentialDigest === right.nativeSource.credentialDigest
|
|
57
57
|
&& left.nativeSource.configDigest === right.nativeSource.configDigest
|
|
58
58
|
&& left.nativeSource.accessTokenDigest === right.nativeSource.accessTokenDigest);
|
|
59
|
+
/**
|
|
60
|
+
* Runtime bindings whose holder releases them over the runtime socket. Managed Codex bindings are the daemon's:
|
|
61
|
+
* the daemon closes that runtime and clears its durable run before it revokes the binding itself.
|
|
62
|
+
*/
|
|
63
|
+
const callerReleasedRuntimes: readonly IStoredAuthorityBinding['runtime'][] = ['flex', 'opencode', 'claude'];
|
|
59
64
|
const storedOperation = (stored: Parameters<typeof AuthSwitchAuthorityOperationModel.exact.toPersisted>[0]): IStoredAuthorityOperation => {
|
|
60
65
|
const operation = AuthSwitchAuthorityOperationModel.exact.toPersisted(stored);
|
|
61
66
|
assertStoredAuthorityOperation(operation);
|
|
@@ -67,6 +72,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
67
72
|
private engine?: plugins.nosqldbEngine.LocalNoSqlDb;
|
|
68
73
|
private database?: plugins.nosqldb.SmartdataDb;
|
|
69
74
|
private started = false;
|
|
75
|
+
private readonly deviceOperationObservers = new Set<(operationId: string) => void>();
|
|
70
76
|
|
|
71
77
|
constructor(private readonly options: IAuthSwitchAuthorityDatabaseOptions) {}
|
|
72
78
|
|
|
@@ -284,6 +290,20 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
284
290
|
return stored ? AuthSwitchAuthorityMigrationLedgerModel.exact.toPersisted(stored) : null;
|
|
285
291
|
}
|
|
286
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Calls `observer` with the id of every device sign-in this database writes, after the write committed or
|
|
295
|
+
* was proven. The writers below are the only ones, so an observer misses no change; it runs synchronously
|
|
296
|
+
* and must only wake a reader. Returns the unsubscribe.
|
|
297
|
+
*/
|
|
298
|
+
public observeDeviceOperations(observer: (operationId: string) => void): () => void {
|
|
299
|
+
this.deviceOperationObservers.add(observer);
|
|
300
|
+
return () => { this.deviceOperationObservers.delete(observer); };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private deviceOperationChanged(operationId: string): void {
|
|
304
|
+
for (const observer of this.deviceOperationObservers) observer(operationId);
|
|
305
|
+
}
|
|
306
|
+
|
|
287
307
|
public async readOperation(id: string): Promise<IStoredAuthorityOperation | null> {
|
|
288
308
|
this.db();
|
|
289
309
|
const stored = await AuthSwitchAuthorityOperationModel.exact.findStoredOne({ id });
|
|
@@ -339,7 +359,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
339
359
|
&& current.accountId === intent.accountId && current.grantId === intent.grantId
|
|
340
360
|
&& current.purpose === intent.purpose;
|
|
341
361
|
try {
|
|
342
|
-
|
|
362
|
+
const admitted = await this.transaction(async session => {
|
|
343
363
|
const existingStored = await AuthSwitchAuthorityOperationModel.exact.findStoredOne(
|
|
344
364
|
{ id: intent.id }, { session });
|
|
345
365
|
if (existingStored) {
|
|
@@ -369,11 +389,15 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
369
389
|
if (result.status !== 'inserted') throw new Error('Account operation ID already exists.');
|
|
370
390
|
return { operation, created: true };
|
|
371
391
|
});
|
|
392
|
+
if (admitted.created) this.deviceOperationChanged(intent.id);
|
|
393
|
+
return admitted;
|
|
372
394
|
} catch (error) {
|
|
373
395
|
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
|
|
374
396
|
const stored = await this.readOperation(intent.id);
|
|
375
397
|
if (stored && sameRequest(stored)) {
|
|
376
|
-
|
|
398
|
+
const created = stored.updateId === intent.updateId;
|
|
399
|
+
if (created) this.deviceOperationChanged(intent.id);
|
|
400
|
+
return { operation: stored, created };
|
|
377
401
|
}
|
|
378
402
|
throw new Error('Account operation creation outcome is unknown; no provider login was started.');
|
|
379
403
|
}
|
|
@@ -435,11 +459,15 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
435
459
|
current: stored, change: model => Object.assign(model, draft),
|
|
436
460
|
});
|
|
437
461
|
if (result.status !== 'transitioned') throw new Error('Account operation changed concurrently.');
|
|
462
|
+
this.deviceOperationChanged(operationId);
|
|
438
463
|
return draft;
|
|
439
464
|
} catch (error) {
|
|
440
465
|
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
|
|
441
466
|
const stored = await this.readOperation(operationId);
|
|
442
|
-
if (stored?.kind !== 'preuse_openai' && stored?.updateId === updateId)
|
|
467
|
+
if (stored?.kind !== 'preuse_openai' && stored?.updateId === updateId) {
|
|
468
|
+
this.deviceOperationChanged(operationId);
|
|
469
|
+
return stored;
|
|
470
|
+
}
|
|
443
471
|
throw new Error('Account operation transition outcome is unknown.');
|
|
444
472
|
}
|
|
445
473
|
}
|
|
@@ -742,10 +770,16 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
742
770
|
}
|
|
743
771
|
}
|
|
744
772
|
|
|
745
|
-
|
|
773
|
+
/**
|
|
774
|
+
* One repeatable-read page of the published authority state. Native homes are part of it: every write that
|
|
775
|
+
* changes which account a home runs on appends an account event, so the metadata revision covers them.
|
|
776
|
+
*/
|
|
777
|
+
public async page(accountAfter: string | null, grantAfter: string | null, bindingAfter: string | null,
|
|
778
|
+
claudeHomeAfter: string | null, limit: number): Promise<{
|
|
746
779
|
meta: IStoredAuthorityMeta; accounts: IStoredAuthorityAccount[]; grants: IStoredAuthorityGrant[];
|
|
747
|
-
bindings: IStoredAuthorityBinding[];
|
|
780
|
+
bindings: IStoredAuthorityBinding[]; claudeHomes: IStoredAuthorityClaudeHome[];
|
|
748
781
|
nextAccountCursor: string | null; nextGrantCursor: string | null; nextBindingCursor: string | null;
|
|
782
|
+
nextClaudeHomeCursor: string | null;
|
|
749
783
|
}> {
|
|
750
784
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 256) throw new Error('Invalid authority page size.');
|
|
751
785
|
return this.transaction(async session => {
|
|
@@ -763,15 +797,21 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
763
797
|
filter: bindingAfter ? { id: { $gt: bindingAfter } } : {},
|
|
764
798
|
sort: { id: 1 }, limit: limit + 1, session,
|
|
765
799
|
});
|
|
800
|
+
const claudeHomes = await AuthSwitchAuthorityClaudeHomeModel.exact.findStored({
|
|
801
|
+
filter: claudeHomeAfter ? { id: { $gt: claudeHomeAfter } } : {},
|
|
802
|
+
sort: { id: 1 }, limit: limit + 1, session,
|
|
803
|
+
});
|
|
766
804
|
const accountPage = accounts.slice(0, limit).map(item => AuthSwitchAuthorityAccountModel.exact.toPersisted(item));
|
|
767
805
|
const grantPage = grants.slice(0, limit).map(item => AuthSwitchAuthorityGrantModel.exact.toPersisted(item));
|
|
768
806
|
const bindingPage = bindings.slice(0, limit).map(item => AuthSwitchAuthorityBindingModel.exact.toPersisted(item));
|
|
807
|
+
const claudeHomePage = claudeHomes.slice(0, limit).map(item => AuthSwitchAuthorityClaudeHomeModel.exact.toPersisted(item));
|
|
769
808
|
return {
|
|
770
809
|
meta: AuthSwitchAuthorityMetaModel.exact.toPersisted(meta),
|
|
771
|
-
accounts: accountPage, grants: grantPage, bindings: bindingPage,
|
|
810
|
+
accounts: accountPage, grants: grantPage, bindings: bindingPage, claudeHomes: claudeHomePage,
|
|
772
811
|
nextAccountCursor: accounts.length > limit ? accountPage.at(-1)!.id : null,
|
|
773
812
|
nextGrantCursor: grants.length > limit ? grantPage.at(-1)!.id : null,
|
|
774
813
|
nextBindingCursor: bindings.length > limit ? bindingPage.at(-1)!.id : null,
|
|
814
|
+
nextClaudeHomeCursor: claudeHomes.length > limit ? claudeHomePage.at(-1)!.id : null,
|
|
775
815
|
};
|
|
776
816
|
});
|
|
777
817
|
}
|
|
@@ -1013,7 +1053,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1013
1053
|
account: IStoredAuthorityAccount; grant: IStoredAuthorityGrant;
|
|
1014
1054
|
}): Promise<{ account: IStoredAuthorityAccount; grant: IStoredAuthorityGrant; operation: IStoredAuthorityDeviceOperation }> {
|
|
1015
1055
|
try {
|
|
1016
|
-
|
|
1056
|
+
const committed = await this.transaction(async session => {
|
|
1017
1057
|
const metaStored = await AuthSwitchAuthorityMetaModel.exact.findStoredOne({ id: 'authswitch-authority' }, { session });
|
|
1018
1058
|
const operationStored = await AuthSwitchAuthorityOperationModel.exact.findStoredOne({ id: operationId }, { session });
|
|
1019
1059
|
if (!metaStored || !operationStored) throw new Error('Device operation is unavailable.');
|
|
@@ -1061,6 +1101,8 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1061
1101
|
updateId, 'account', accountId);
|
|
1062
1102
|
return { ...draft, operation: completed };
|
|
1063
1103
|
});
|
|
1104
|
+
this.deviceOperationChanged(operationId);
|
|
1105
|
+
return committed;
|
|
1064
1106
|
} catch (error) {
|
|
1065
1107
|
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
|
|
1066
1108
|
const [event, operation, pair] = await Promise.all([
|
|
@@ -1070,6 +1112,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1070
1112
|
if (event && operation?.kind !== 'preuse_openai' && operation?.state === 'complete' && operation.updateId === updateId
|
|
1071
1113
|
&& pair.account?.updateId === updateId && pair.grant?.updateId === updateId
|
|
1072
1114
|
&& operation.result?.accountId === accountId && operation.result.grantId === grantId) {
|
|
1115
|
+
this.deviceOperationChanged(operationId);
|
|
1073
1116
|
return { account: pair.account, grant: pair.grant, operation };
|
|
1074
1117
|
}
|
|
1075
1118
|
throw new Error('Device login commit outcome is unknown; inspect the durable operation before retrying.');
|
|
@@ -1163,7 +1206,11 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1163
1206
|
}
|
|
1164
1207
|
}
|
|
1165
1208
|
|
|
1166
|
-
/**
|
|
1209
|
+
/**
|
|
1210
|
+
* Remove all managed grants in one transaction. Native owners must be stopped and handed off first, and a
|
|
1211
|
+
* runtime binding its holder releases must be released first; the daemon's own managed Codex bindings go
|
|
1212
|
+
* with the account.
|
|
1213
|
+
*/
|
|
1167
1214
|
public async removeAccountAndGrants(updateId: string, accountId: string,
|
|
1168
1215
|
expectedRevision: number, statusObservedAt: string): Promise<IStoredAuthorityAccount> {
|
|
1169
1216
|
try {
|
|
@@ -1199,6 +1246,29 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1199
1246
|
throw new AuthSwitchRefusal('claude_handoff_pending', 'Resolve the Claude native handoff before removing this account.');
|
|
1200
1247
|
}
|
|
1201
1248
|
});
|
|
1249
|
+
// A runtime the caller holds is released by the caller, which stops that runtime first; removing the
|
|
1250
|
+
// account under it would strand a runtime whose next access fails. Managed Codex bindings are the
|
|
1251
|
+
// daemon's own, and the daemon has proven their runs gone before it asks for this removal.
|
|
1252
|
+
const heldRuntimes = new Set<string>();
|
|
1253
|
+
let heldAfter: string | null = null;
|
|
1254
|
+
while (true) {
|
|
1255
|
+
const bindings = await AuthSwitchAuthorityBindingModel.exact.findStored({
|
|
1256
|
+
filter: heldAfter ? { accountId, id: { $gt: heldAfter } } : { accountId },
|
|
1257
|
+
sort: { id: 1 }, limit: 128, session,
|
|
1258
|
+
});
|
|
1259
|
+
for (const binding of bindings) {
|
|
1260
|
+
if (callerReleasedRuntimes.includes(binding.runtime)) heldRuntimes.add(binding.runtime);
|
|
1261
|
+
}
|
|
1262
|
+
if (bindings.length < 128) break;
|
|
1263
|
+
heldAfter = bindings.at(-1)!.id;
|
|
1264
|
+
}
|
|
1265
|
+
if (heldRuntimes.size) {
|
|
1266
|
+
const names = [...heldRuntimes].sort();
|
|
1267
|
+
const listed = names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`;
|
|
1268
|
+
throw new AuthSwitchRefusal('account_busy', `This account still backs ${listed} runtime bindings. `
|
|
1269
|
+
+ 'Stop those runtimes and release their bindings before removing the account. A holder that lost '
|
|
1270
|
+
+ 'its capability unbinds its own runtime and scope instead, which needs no sign-in.');
|
|
1271
|
+
}
|
|
1202
1272
|
await scan(async (grant, stored) => {
|
|
1203
1273
|
if (grant.state === 'removed') return;
|
|
1204
1274
|
const draft: IStoredAuthorityGrant = {
|
|
@@ -1278,9 +1348,16 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1278
1348
|
}
|
|
1279
1349
|
}
|
|
1280
1350
|
|
|
1351
|
+
/**
|
|
1352
|
+
* Binds a runtime and scope, or rebinds them. The binding's revision is the authority revision of the event
|
|
1353
|
+
* that writes it, handed to `change` as `revision`: the authority revision never repeats, so no two binds of
|
|
1354
|
+
* a runtime and scope ever share one -- not even across a release, which deletes the record, followed by a
|
|
1355
|
+
* bind that inserts it again -- and a holder that names a binding by the revision it read never names its
|
|
1356
|
+
* successor. Records written before carry smaller revisions, which a later bind therefore still exceeds.
|
|
1357
|
+
*/
|
|
1281
1358
|
public async changeBinding(updateId: string, bindingId: string, accountId: string, grantId: string,
|
|
1282
1359
|
change: (draft: IStoredAuthorityBinding | null, account: IStoredAuthorityAccount,
|
|
1283
|
-
grant: IStoredAuthorityGrant | null) => IStoredAuthorityBinding): Promise<{
|
|
1360
|
+
grant: IStoredAuthorityGrant | null, revision: number) => IStoredAuthorityBinding): Promise<{
|
|
1284
1361
|
meta: IStoredAuthorityMeta; binding: IStoredAuthorityBinding;
|
|
1285
1362
|
}> {
|
|
1286
1363
|
try {
|
|
@@ -1292,8 +1369,11 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1292
1369
|
const grantStored = await AuthSwitchAuthorityGrantModel.exact.findStoredOne({ id: grantId }, { session });
|
|
1293
1370
|
const grant = grantStored ? AuthSwitchAuthorityGrantModel.exact.toPersisted(grantStored) : null;
|
|
1294
1371
|
const current = await AuthSwitchAuthorityBindingModel.exact.findStoredOne({ id: bindingId }, { session });
|
|
1295
|
-
const
|
|
1296
|
-
|
|
1372
|
+
const revision = metaStored.revision + 1;
|
|
1373
|
+
const draft = change(current ? structuredClone(AuthSwitchAuthorityBindingModel.exact.toPersisted(current)) : null,
|
|
1374
|
+
account, grant, revision);
|
|
1375
|
+
if (draft.id !== bindingId || draft.accountId !== accountId || draft.updateId !== updateId
|
|
1376
|
+
|| draft.revision !== revision) throw new Error('Binding change has an invalid identity.');
|
|
1297
1377
|
if (draft.grantId !== grantId || grant?.accountId !== accountId) {
|
|
1298
1378
|
throw new Error('Binding login does not belong to the account.');
|
|
1299
1379
|
}
|
|
@@ -1331,7 +1411,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1331
1411
|
if (!metaStored) throw new Error('Authswitch authority metadata is missing.');
|
|
1332
1412
|
const current = await AuthSwitchAuthorityBindingModel.exact.findStoredOne({ id: bindingId }, { session });
|
|
1333
1413
|
if (!current || current.capabilityHash !== capabilityHash
|
|
1334
|
-
|| (externalOnly && !
|
|
1414
|
+
|| (externalOnly && !callerReleasedRuntimes.includes(current.runtime))) return false;
|
|
1335
1415
|
const result = await AuthSwitchAuthorityBindingModel.exact.delete({ current }, { session });
|
|
1336
1416
|
if (result.status !== 'deleted') throw new Error('Runtime binding changed during revocation.');
|
|
1337
1417
|
await this.appendEvent(session, AuthSwitchAuthorityMetaModel.exact.toPersisted(metaStored),
|
|
@@ -1354,7 +1434,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1354
1434
|
return this.revokeBindingExact(updateId, bindingId, capabilityHash, false);
|
|
1355
1435
|
}
|
|
1356
1436
|
|
|
1357
|
-
/** External callers
|
|
1437
|
+
/** External callers release only Flex, OpenCode and Claude bindings; managed Codex cleanup stays daemon-owned. */
|
|
1358
1438
|
public releaseExternalBinding(updateId: string, bindingId: string, capabilityHash: string): Promise<boolean> {
|
|
1359
1439
|
return this.revokeBindingExact(updateId, bindingId, capabilityHash, true);
|
|
1360
1440
|
}
|
|
@@ -1579,15 +1659,21 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1579
1659
|
activeGrantId: grantId, pendingOperationId: null, status: 'ready', revision: 1,
|
|
1580
1660
|
createdAt: input.now, updateId };
|
|
1581
1661
|
assertStoredAuthorityClaudeHome(home);
|
|
1662
|
+
const metaStored = await AuthSwitchAuthorityMetaModel.exact.findStoredOne({ id: 'authswitch-authority' }, { session });
|
|
1663
|
+
if (!metaStored) throw new Error('Authswitch authority metadata is missing.');
|
|
1582
1664
|
const inserted = await AuthSwitchAuthorityClaudeHomeModel.exact.insert(home, { session });
|
|
1583
1665
|
if (inserted.status !== 'inserted') throw new Error('Claude native home changed concurrently.');
|
|
1666
|
+
// The snapshot publishes which account this home runs on, so registering it is an account change.
|
|
1667
|
+
await this.appendEvent(session, AuthSwitchAuthorityMetaModel.exact.toPersisted(metaStored),
|
|
1668
|
+
updateId, 'account', accountId);
|
|
1584
1669
|
return home;
|
|
1585
1670
|
});
|
|
1586
1671
|
} catch (error) {
|
|
1587
1672
|
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError)
|
|
1588
1673
|
|| !['ambiguous_write', 'unique_conflict'].includes(error.code)) throw error;
|
|
1589
1674
|
const home = await this.readClaudeHome(homeId);
|
|
1590
|
-
if (home?.updateId === updateId && home.activeAccountId === accountId && home.activeGrantId === grantId
|
|
1675
|
+
if (home?.updateId === updateId && home.activeAccountId === accountId && home.activeGrantId === grantId
|
|
1676
|
+
&& await AuthSwitchAuthorityEventModel.exact.findStoredOne({ id: updateId })) return home;
|
|
1591
1677
|
// One grant, one home: the unique index refused a second home for a login another one already holds.
|
|
1592
1678
|
if (error.code === 'unique_conflict') {
|
|
1593
1679
|
throw new AuthSwitchRefusal('import_refusal',
|
|
@@ -347,7 +347,7 @@ export class AuthSwitchAuthorityImport {
|
|
|
347
347
|
const grant = row.grantId === null ? null : await this.database.readGrant(row.grantId);
|
|
348
348
|
const handoff = await this.database.readHandoff(importHandoffId(row.id));
|
|
349
349
|
entries.push({
|
|
350
|
-
sourceId: row.id, sourceKind: row.sourceKind, status: row.status,
|
|
350
|
+
sourceId: row.id, sourceKind: row.sourceKind, sourcePathHash: row.sourcePathHash, status: row.status,
|
|
351
351
|
accountId: row.accountId, loginId: row.grantId, owner: grant?.owner ?? null,
|
|
352
352
|
action: importAction(row.status, handoff), statusObservedAt: row.statusObservedAt,
|
|
353
353
|
});
|
|
@@ -444,7 +444,10 @@ export const assertStoredAuthorityGrant: (value: unknown) => asserts value is IS
|
|
|
444
444
|
throw new Error('Claude handoff grant has incompatible custody.');
|
|
445
445
|
}
|
|
446
446
|
if (value.purpose === 'openai_managed' && value.providerId !== 'openai') throw new Error('OpenAI grant provider mismatch.');
|
|
447
|
-
|
|
447
|
+
// No native tool refreshes a container setup token: the authority holds it, or nobody does.
|
|
448
|
+
if (value.purpose === 'claude_container_setup' && value.owner !== 'daemon' && value.owner !== 'none') {
|
|
449
|
+
throw new Error('Container setup grant has a native owner.');
|
|
450
|
+
}
|
|
448
451
|
};
|
|
449
452
|
|
|
450
453
|
export const assertStoredAuthorityBinding: (value: unknown) => asserts value is IStoredAuthorityBinding = value => {
|