@modelprofile.com/authswitch 8.2.0 → 9.1.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 (64) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/authority-contract.d.ts +84 -1
  3. package/dist_ts/authority-contract.js +14 -2
  4. package/dist_ts/authority-import-contract.d.ts +6 -0
  5. package/dist_ts/authority-paths.d.ts +37 -0
  6. package/dist_ts/authority-paths.js +46 -0
  7. package/dist_ts/authority-runtime-contract.d.ts +11 -1
  8. package/dist_ts/classes.authoritybroker.d.ts +23 -8
  9. package/dist_ts/classes.authoritybroker.js +155 -32
  10. package/dist_ts/classes.authorityclient.d.ts +22 -2
  11. package/dist_ts/classes.authorityclient.js +98 -13
  12. package/dist_ts/classes.authoritydaemon.d.ts +21 -3
  13. package/dist_ts/classes.authoritydaemon.js +77 -32
  14. package/dist_ts/classes.authoritydatabase.d.ts +21 -3
  15. package/dist_ts/classes.authoritydatabase.js +98 -12
  16. package/dist_ts/classes.authorityimport.d.ts +16 -4
  17. package/dist_ts/classes.authorityimport.js +75 -24
  18. package/dist_ts/classes.authoritymodels.js +5 -3
  19. package/dist_ts/classes.authoritypreuse.js +8 -3
  20. package/dist_ts/classes.authorityservice.d.ts +10 -11
  21. package/dist_ts/classes.authorityservice.js +14 -23
  22. package/dist_ts/classes.cli.d.ts +10 -2
  23. package/dist_ts/classes.cli.js +12 -4
  24. package/dist_ts/classes.codexmanaged.d.ts +0 -1
  25. package/dist_ts/classes.codexmanaged.js +13 -26
  26. package/dist_ts/classes.legacyfence.d.ts +53 -0
  27. package/dist_ts/classes.legacyfence.js +189 -0
  28. package/dist_ts/classes.operations.d.ts +15 -3
  29. package/dist_ts/classes.operations.js +22 -4
  30. package/dist_ts/classes.service.d.ts +21 -2
  31. package/dist_ts/classes.service.js +35 -8
  32. package/dist_ts/classes.tui.d.ts +2 -1
  33. package/dist_ts/classes.tui.js +3 -2
  34. package/dist_ts/codexcontract.d.ts +30 -0
  35. package/dist_ts/codexcontract.js +174 -0
  36. package/dist_ts/ts_migration/0004_container_setup_owner.d.ts +12 -0
  37. package/dist_ts/ts_migration/0004_container_setup_owner.js +19 -0
  38. package/dist_ts/ts_migration/index.js +3 -1
  39. package/dist_ts/ts_migration/legacysources/authswitchstores.js +5 -2
  40. package/package.json +11 -11
  41. package/readme.md +194 -24
  42. package/ts/00_commitinfo_data.ts +1 -1
  43. package/ts/authority-contract.ts +90 -4
  44. package/ts/authority-import-contract.ts +6 -0
  45. package/ts/authority-paths.ts +69 -0
  46. package/ts/authority-runtime-contract.ts +11 -1
  47. package/ts/classes.authoritybroker.ts +153 -33
  48. package/ts/classes.authorityclient.ts +102 -15
  49. package/ts/classes.authoritydaemon.ts +89 -27
  50. package/ts/classes.authoritydatabase.ts +98 -12
  51. package/ts/classes.authorityimport.ts +102 -25
  52. package/ts/classes.authoritymodels.ts +4 -1
  53. package/ts/classes.authoritypreuse.ts +7 -1
  54. package/ts/classes.authorityservice.ts +15 -30
  55. package/ts/classes.cli.ts +14 -3
  56. package/ts/classes.codexmanaged.ts +10 -19
  57. package/ts/classes.legacyfence.ts +219 -0
  58. package/ts/classes.operations.ts +22 -3
  59. package/ts/classes.service.ts +45 -8
  60. package/ts/classes.tui.ts +3 -1
  61. package/ts/codexcontract.ts +200 -0
  62. package/ts/ts_migration/0004_container_setup_owner.ts +19 -0
  63. package/ts/ts_migration/index.ts +2 -0
  64. package/ts/ts_migration/legacysources/authswitchstores.ts +4 -1
@@ -4,7 +4,8 @@ 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, IReq_AuthSwitchRemoveAccount,
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';
@@ -37,8 +38,39 @@ export interface IAuthSwitchAuthorityDaemonOptions extends IAuthSwitchAuthorityD
37
38
  claudeStatus?: ClaudeAccountStatus;
38
39
  /** Activated only after a verified backend source receipt has registered the native home. */
39
40
  claudeNative?: IClaudeNativeAuthorityOptions;
40
- /** Where the one-time import reads the legacy stores. Defaults to this user's own locations. */
41
- legacyImport?: IAuthSwitchAuthorityImportOptions;
41
+ /** Where the one-time import reads the legacy stores, or `'none'`. Never derived from this host here. */
42
+ legacyImport: TAuthSwitchAuthorityLegacyImport;
43
+ }
44
+
45
+ /**
46
+ * Where a daemon's one-time import reads, stated by whoever builds it.
47
+ *
48
+ * The legacy stores are this user's real credentials, so which host they are read from is never something a
49
+ * daemon decides for its caller: it is named, or the daemon is built with no import surface at all.
50
+ * `AuthSwitchAuthorityService`'s entry point is the one place that may name this host's own locations,
51
+ * because that is the daemon that exists to serve this host. The importer requires those two locations of
52
+ * every caller, so naming its options here is already the whole statement.
53
+ */
54
+ export type TAuthSwitchAuthorityLegacyImport = IAuthSwitchAuthorityImportOptions | 'none';
55
+
56
+ /** What a caller that named neither is told. It names the two answers and who may take the third. */
57
+ const legacyImportRequired = 'Authswitch authority requires explicit legacy source locations: pass '
58
+ + 'legacyImport with an env and an absolute homeDirectory, or legacyImport: "none" for a daemon with no '
59
+ + 'account import. Only the authswitch service entry point may take them from this host.';
60
+
61
+ /** What the import routes answer on a daemon that was built with no import surface. */
62
+ const legacyImportAbsent = 'This authority was started without legacy source locations, so it serves no '
63
+ + 'account import.';
64
+
65
+ /** The same check for a caller with types and one without: stated locations, or stated absence. */
66
+ function assertStatedLegacyImport(value: unknown): asserts value is TAuthSwitchAuthorityLegacyImport {
67
+ if (value === 'none') return;
68
+ if (value === null || typeof value !== 'object'
69
+ || typeof (value as { env?: unknown }).env !== 'object' || (value as { env?: unknown }).env === null
70
+ || typeof (value as { homeDirectory?: unknown }).homeDirectory !== 'string'
71
+ || !plugins.path.isAbsolute((value as { homeDirectory: string }).homeDirectory)) {
72
+ throw new Error(legacyImportRequired);
73
+ }
42
74
  }
43
75
 
44
76
  const hasKeys = (value: unknown, keys: readonly string[]): value is Record<string, unknown> =>
@@ -83,7 +115,8 @@ export class AuthSwitchAuthorityDaemon {
83
115
  public readonly broker: AuthSwitchAuthorityBroker;
84
116
  public readonly usage: AuthSwitchAuthorityUsage;
85
117
  public readonly preuse: AuthSwitchAuthorityPreuse;
86
- public readonly legacyImport: AuthSwitchAuthorityImport;
118
+ /** Absent on a daemon built with `legacyImport: 'none'`; its import routes then refuse. */
119
+ public readonly legacyImport?: AuthSwitchAuthorityImport;
87
120
  public readonly claudeNative?: ClaudeNativeAuthority;
88
121
  private readonly managementRouter = new plugins.typedrequest.TypedRouter();
89
122
  private readonly runtimeRouter = new plugins.typedrequest.TypedRouter();
@@ -104,8 +137,14 @@ export class AuthSwitchAuthorityDaemon {
104
137
  private readonly runtimeBindingReleaseFences = new Set<string>();
105
138
  private readonly runtimeBindingReleases = new Map<string, Promise<IReq_AuthSwitchReleaseBinding['response']>>();
106
139
  private closing = false;
140
+ /** Every admitted request until its answer is written; shutdown settles these before it closes the store. */
141
+ private readonly inFlight = new Set<Promise<void>>();
107
142
 
108
143
  constructor(private readonly options: IAuthSwitchAuthorityDaemonOptions) {
144
+ // First, before anything is constructed: a daemon that was going to read the wrong host's credentials
145
+ // must not come into existence at all. The type states it and this states it again, because a caller
146
+ // without types is exactly the caller that would have defaulted here.
147
+ assertStatedLegacyImport(options.legacyImport);
109
148
  this.database = new AuthSwitchAuthorityDatabase(options);
110
149
  const provider = options.broker?.provider ?? new plugins.flexAccounts.OpenAiProviderAdapter();
111
150
  this.broker = new AuthSwitchAuthorityBroker(this.database, { ...options.broker, provider });
@@ -134,7 +173,9 @@ export class AuthSwitchAuthorityDaemon {
134
173
  provider,
135
174
  });
136
175
  this.preuse = new AuthSwitchAuthorityPreuse(this.database, this.broker, options.preuse);
137
- this.legacyImport = new AuthSwitchAuthorityImport(this.database, options.legacyImport);
176
+ if (options.legacyImport !== 'none') {
177
+ this.legacyImport = new AuthSwitchAuthorityImport(this.database, options.legacyImport);
178
+ }
138
179
  if (options.claudeNative) {
139
180
  this.claudeNative = new ClaudeNativeAuthority(this.database, {
140
181
  ...options.claudeNative, onChanged: () => this.broker.notifyChanged(),
@@ -241,8 +282,12 @@ export class AuthSwitchAuthorityDaemon {
241
282
  private handler<T extends ITypedRequest>(method: T['method'],
242
283
  run: plugins.typedrequest.THandlerFunction<T>): plugins.typedrequest.TypedHandler<T> {
243
284
  return new plugins.typedrequest.TypedHandler<T>(method, async (request, tools) => {
244
- try { return await run(request, tools); }
245
- catch (error) {
285
+ try {
286
+ // The one admission check: once shutdown begins no route starts work, and the requests already
287
+ // admitted are settled before the store closes.
288
+ if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
289
+ return await run(request, tools);
290
+ } catch (error) {
246
291
  if (error instanceof AuthSwitchRefusal) {
247
292
  throw new plugins.typedrequest.TypedResponseError(error.message, error.data);
248
293
  }
@@ -259,7 +304,6 @@ export class AuthSwitchAuthorityDaemon {
259
304
  || Object.keys(request).some(key => !['accountId', 'loginId', 'force'].includes(key))
260
305
  || typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
261
306
  || (request.force !== undefined && typeof request.force !== 'boolean')) invalid();
262
- if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
263
307
  return { usage: await this.usage.get(request.accountId, request.loginId, request.force ?? false) };
264
308
  }));
265
309
  this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchSwitchClaudeNative>(
@@ -267,7 +311,6 @@ export class AuthSwitchAuthorityDaemon {
267
311
  if (!hasKeys(request, ['accountId', 'loginId', 'purpose'])
268
312
  || typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
269
313
  || request.purpose !== 'claude_host_native') invalid();
270
- if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
271
314
  if (!this.claudeNative) throw new AuthSwitchRefusal('claude_home_unregistered', 'Claude native handoff is unavailable.');
272
315
  return { handoff: await this.claudeNative.switchTo(request.accountId, request.loginId) };
273
316
  }));
@@ -288,7 +331,9 @@ export class AuthSwitchAuthorityDaemon {
288
331
  'authswitch.authority.snapshot', async request => {
289
332
  if (request === null || typeof request !== 'object' || Array.isArray(request)) invalid();
290
333
  const keys = Object.keys(request);
291
- if (keys.some(key => !['accountAfter', 'loginAfter', 'bindingAfter', 'limit'].includes(key))) invalid();
334
+ if (keys.some(key => !['accountAfter', 'loginAfter', 'bindingAfter', 'nativeAssignmentAfter', 'limit',
335
+ 'includeRemoved'].includes(key))
336
+ || (request.includeRemoved !== undefined && typeof request.includeRemoved !== 'boolean')) invalid();
292
337
  return { snapshot: await this.broker.snapshot(request) };
293
338
  }));
294
339
  this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchDoctor>(
@@ -308,7 +353,7 @@ export class AuthSwitchAuthorityDaemon {
308
353
  // believing a source does not exist.
309
354
  if (request === null || typeof request !== 'object' || Array.isArray(request)
310
355
  || Object.keys(request).length !== 0) invalid();
311
- if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
356
+ if (!this.legacyImport) throw new AuthSwitchRefusal('import_refusal', legacyImportAbsent);
312
357
  return { inventory: await this.legacyImport.inventory() };
313
358
  }));
314
359
  this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchImportStatus>(
@@ -318,7 +363,7 @@ export class AuthSwitchAuthorityDaemon {
318
363
  || (request.after !== undefined && !validHash(request.after))
319
364
  || (request.limit !== undefined && (!Number.isSafeInteger(request.limit)
320
365
  || request.limit < 1 || request.limit > 128))) invalid();
321
- if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
366
+ if (!this.legacyImport) throw new AuthSwitchRefusal('import_refusal', legacyImportAbsent);
322
367
  return { status: await this.legacyImport.status(request) };
323
368
  }));
324
369
  this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchEvents>(
@@ -330,7 +375,6 @@ export class AuthSwitchAuthorityDaemon {
330
375
  'authswitch.authority.add', async request => {
331
376
  if (!hasKeys(request, ['operationId', 'providerId', 'flow']) || typeof request.operationId !== 'string'
332
377
  || request.providerId !== 'openai' || request.flow !== 'device') invalid();
333
- if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
334
378
  return { operation: await this.broker.beginAddOpenAi(request.operationId) };
335
379
  }));
336
380
  this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchBeginReauth>(
@@ -339,13 +383,16 @@ export class AuthSwitchAuthorityDaemon {
339
383
  || typeof request.operationId !== 'string'
340
384
  || typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
341
385
  || request.purpose !== 'openai_managed') invalid();
342
- if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
343
386
  return { operation: await this.beginReauthOpenAi(request) };
344
387
  }));
345
388
  this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchGetOperation>(
346
- 'authswitch.authority.operation', async request => {
347
- if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
348
- return { operation: await this.broker.getOperation(request.operationId) };
389
+ 'authswitch.authority.operation', async (request, tools) => {
390
+ const waits = hasKeys(request, ['operationId', 'afterRevision', 'waitMs']);
391
+ if ((!waits && !hasKeys(request, ['operationId'])) || typeof request.operationId !== 'string'
392
+ || (waits && (!Number.isSafeInteger(request.afterRevision) || request.afterRevision! < 0
393
+ || !Number.isSafeInteger(request.waitMs) || request.waitMs! < 0 || request.waitMs! > 30_000))) invalid();
394
+ return { operation: await this.broker.getOperation(request.operationId, waits
395
+ ? { afterRevision: request.afterRevision!, waitMs: request.waitMs! } : undefined, tools?.abortSignal) };
349
396
  }));
350
397
  this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchListOperations>(
351
398
  'authswitch.authority.operations', async request => {
@@ -367,7 +414,6 @@ export class AuthSwitchAuthorityDaemon {
367
414
  || typeof request.loginId !== 'string' || request.purpose !== 'openai_managed'
368
415
  || typeof request.prompt !== 'string'
369
416
  || (request.model !== undefined && typeof request.model !== 'string')) invalid();
370
- if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
371
417
  const reservation: IPreuseAccountReservation = { operationId: request.operationId };
372
418
  let ownsReservation = false;
373
419
  const release = (): void => {
@@ -410,6 +456,11 @@ export class AuthSwitchAuthorityDaemon {
410
456
  || typeof request.expectedRevision !== 'number' || typeof request.label !== 'string') invalid();
411
457
  return { account: await this.broker.renameAccount(request.accountId, request.expectedRevision, request.label) };
412
458
  }));
459
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchGetBinding>(
460
+ 'authswitch.authority.binding', async request => {
461
+ if (!hasKeys(request, ['bindingId']) || !validBindingId(request.bindingId)) invalid();
462
+ return { binding: await this.broker.getBinding(request.bindingId) };
463
+ }));
413
464
  this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchRemoveAccount>(
414
465
  'authswitch.authority.remove', async request => {
415
466
  if (!hasKeys(request, ['accountId', 'expectedRevision']) || typeof request.accountId !== 'string'
@@ -440,7 +491,6 @@ export class AuthSwitchAuthorityDaemon {
440
491
  || !validBindingId(request.bindingId) || !validBindingCapability(request.capability)
441
492
  || typeof request.minValidityMs !== 'number'
442
493
  || (request.rejectedGrantGeneration !== undefined && typeof request.rejectedGrantGeneration !== 'number')) invalid();
443
- if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
444
494
  const finish = this.beginRuntimeBindingAccess(this.runtimeBindingKey(request.bindingId, request.capability));
445
495
  try {
446
496
  return await this.broker.resolveAccess(request.bindingId, request.capability, request.minValidityMs,
@@ -451,7 +501,6 @@ export class AuthSwitchAuthorityDaemon {
451
501
  'authswitch.authority.release', async request => {
452
502
  if (!hasKeys(request, ['bindingId', 'capability'])
453
503
  || !validBindingId(request.bindingId) || !validBindingCapability(request.capability)) invalid();
454
- if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
455
504
  return this.releaseRuntimeBinding(request.bindingId, request.capability);
456
505
  }));
457
506
  this.runtimeRouter.addTypedHandler(this.handler<IReq_AuthSwitchImportSubmit>(
@@ -460,13 +509,17 @@ export class AuthSwitchAuthorityDaemon {
460
509
  if (!hasKeys(request, ['submission', 'callerQuiescent', 'acknowledgeRunOrder'])
461
510
  || request.callerQuiescent !== true || request.acknowledgeRunOrder !== true
462
511
  || !validImportSubmission(request.submission)) invalid();
463
- if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
512
+ if (!this.legacyImport) throw new AuthSwitchRefusal('import_refusal', legacyImportAbsent);
464
513
  // An importer refusal is an `AuthSwitchRefusal` like every other, so the one conversion in
465
514
  // `handler()` marks it. What stays special is its meaning: an UNMARKED failure of this route
466
515
  // leaves the import outcome unknown, and the source must be read with `import.status` rather
467
516
  // than submitted again.
468
- return { result: await this.legacyImport.submit({ submission: request.submission,
469
- callerQuiescent: true, acknowledgeRunOrder: true }) };
517
+ // An import writes accounts, logins and native homes, also on a path that fails after a commit, so
518
+ // every outcome wakes the event readers; they read the committed revision themselves.
519
+ try {
520
+ return { result: await this.legacyImport.submit({ submission: request.submission,
521
+ callerQuiescent: true, acknowledgeRunOrder: true }) };
522
+ } finally { this.broker.notifyChanged(); }
470
523
  }));
471
524
  }
472
525
 
@@ -855,16 +908,23 @@ export class AuthSwitchAuthorityDaemon {
855
908
  let request: ITypedRequest;
856
909
  try { request = JSON.parse(input) as ITypedRequest; }
857
910
  catch { socket.destroy(); return; }
858
- void router.routeAndAddResponse(request, { trustedLocalData: { authoritySocket: socket },
911
+ const answered: Promise<void> = router.routeAndAddResponse(request, { trustedLocalData: { authoritySocket: socket },
859
912
  trustedAbortSignal: abort.signal })
860
- .then(response => {
913
+ .then(async response => {
861
914
  const raw = JSON.stringify(response);
862
915
  if (authSwitchAuthorityFrameBytes(raw) > maxAuthSwitchAuthorityFrameBytes) { socket.destroy(); return; }
863
- socket.end(raw + '\n');
864
- }).catch(() => socket.destroy());
916
+ await new Promise<void>(resolve => socket.end(raw + '\n', () => resolve()));
917
+ }).catch(() => { socket.destroy(); })
918
+ .finally(() => { this.inFlight.delete(answered); });
919
+ this.inFlight.add(answered);
865
920
  });
866
921
  }
867
922
 
923
+ /**
924
+ * Stops admitting work -- the sockets keep listening so every new request is answered `authority_closing`
925
+ * -- settles everything already admitted, a waiting `events` or operation read being woken by the broker and
926
+ * answering from the still-open store, and only then closes the sockets and the store.
927
+ */
868
928
  public async close(): Promise<void> {
869
929
  this.closing = true;
870
930
  const errors: unknown[] = [];
@@ -882,6 +942,8 @@ export class AuthSwitchAuthorityDaemon {
882
942
  try { await this.usage.close(); } catch (error) { errors.push(error); }
883
943
  try { await this.broker.close(); } catch (error) { errors.push(error); }
884
944
  if (this.claudeNative) try { await this.claudeNative.close(); } catch (error) { errors.push(error); }
945
+ // A request that arrives meanwhile is refused at admission and joins this set; none of those reads the store.
946
+ while (this.inFlight.size) await Promise.allSettled([...this.inFlight]);
885
947
  const management = this.managementServer;
886
948
  const runtime = this.runtimeServer;
887
949
  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
- return await this.transaction(async session => {
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
- return { operation: stored, created: stored.updateId === intent.updateId };
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) return stored;
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
- public async page(accountAfter: string | null, grantAfter: string | null, bindingAfter: string | null, limit: number): Promise<{
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
- return await this.transaction(async session => {
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
- /** Remove all managed grants in one transaction; native owners must be stopped and handed off first. */
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,30 @@ 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 binds the same runtime and scope again and releases the capability that returns; '
1271
+ + 'if binding again is refused, reauthenticate the account first.');
1272
+ }
1202
1273
  await scan(async (grant, stored) => {
1203
1274
  if (grant.state === 'removed') return;
1204
1275
  const draft: IStoredAuthorityGrant = {
@@ -1331,7 +1402,7 @@ export class AuthSwitchAuthorityDatabase {
1331
1402
  if (!metaStored) throw new Error('Authswitch authority metadata is missing.');
1332
1403
  const current = await AuthSwitchAuthorityBindingModel.exact.findStoredOne({ id: bindingId }, { session });
1333
1404
  if (!current || current.capabilityHash !== capabilityHash
1334
- || (externalOnly && !['flex', 'opencode'].includes(current.runtime))) return false;
1405
+ || (externalOnly && !callerReleasedRuntimes.includes(current.runtime))) return false;
1335
1406
  const result = await AuthSwitchAuthorityBindingModel.exact.delete({ current }, { session });
1336
1407
  if (result.status !== 'deleted') throw new Error('Runtime binding changed during revocation.');
1337
1408
  await this.appendEvent(session, AuthSwitchAuthorityMetaModel.exact.toPersisted(metaStored),
@@ -1354,7 +1425,7 @@ export class AuthSwitchAuthorityDatabase {
1354
1425
  return this.revokeBindingExact(updateId, bindingId, capabilityHash, false);
1355
1426
  }
1356
1427
 
1357
- /** External callers can release only Flex/OpenCode bindings; managed Codex cleanup stays daemon-owned. */
1428
+ /** External callers release only Flex, OpenCode and Claude bindings; managed Codex cleanup stays daemon-owned. */
1358
1429
  public releaseExternalBinding(updateId: string, bindingId: string, capabilityHash: string): Promise<boolean> {
1359
1430
  return this.revokeBindingExact(updateId, bindingId, capabilityHash, true);
1360
1431
  }
@@ -1579,15 +1650,21 @@ export class AuthSwitchAuthorityDatabase {
1579
1650
  activeGrantId: grantId, pendingOperationId: null, status: 'ready', revision: 1,
1580
1651
  createdAt: input.now, updateId };
1581
1652
  assertStoredAuthorityClaudeHome(home);
1653
+ const metaStored = await AuthSwitchAuthorityMetaModel.exact.findStoredOne({ id: 'authswitch-authority' }, { session });
1654
+ if (!metaStored) throw new Error('Authswitch authority metadata is missing.');
1582
1655
  const inserted = await AuthSwitchAuthorityClaudeHomeModel.exact.insert(home, { session });
1583
1656
  if (inserted.status !== 'inserted') throw new Error('Claude native home changed concurrently.');
1657
+ // The snapshot publishes which account this home runs on, so registering it is an account change.
1658
+ await this.appendEvent(session, AuthSwitchAuthorityMetaModel.exact.toPersisted(metaStored),
1659
+ updateId, 'account', accountId);
1584
1660
  return home;
1585
1661
  });
1586
1662
  } catch (error) {
1587
1663
  if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError)
1588
1664
  || !['ambiguous_write', 'unique_conflict'].includes(error.code)) throw error;
1589
1665
  const home = await this.readClaudeHome(homeId);
1590
- if (home?.updateId === updateId && home.activeAccountId === accountId && home.activeGrantId === grantId) return home;
1666
+ if (home?.updateId === updateId && home.activeAccountId === accountId && home.activeGrantId === grantId
1667
+ && await AuthSwitchAuthorityEventModel.exact.findStoredOne({ id: updateId })) return home;
1591
1668
  // One grant, one home: the unique index refused a second home for a login another one already holds.
1592
1669
  if (error.code === 'unique_conflict') {
1593
1670
  throw new AuthSwitchRefusal('import_refusal',
@@ -1815,9 +1892,18 @@ export class AuthSwitchAuthorityDatabase {
1815
1892
  throw new Error('Migration completion requires a verified import of the same account and grant.');
1816
1893
  }
1817
1894
  if (current) {
1895
+ // A row may follow its source's current bytes in exactly one state. `pending_native_owner` is a
1896
+ // projection whose own tool still refreshes it: nothing rotating was ever sent for such a row and
1897
+ // the authority holds no copy of its credential, so the file that tool has since rewritten is the
1898
+ // same login and the row must be able to reach it -- otherwise the retry its own refusal asks for
1899
+ // is impossible. It may never change the account or grant it named while doing so, and no other
1900
+ // status may move its digest at all.
1901
+ const followsSource = current.sourceDigest !== draft.sourceDigest;
1818
1902
  if (current.status === 'complete' || current.version !== draft.version
1819
1903
  || current.sourceKind !== draft.sourceKind || current.sourcePathHash !== draft.sourcePathHash
1820
- || current.sourceDigest !== draft.sourceDigest || draft.revision !== current.revision + 1) {
1904
+ || (followsSource && (current.status !== 'pending_native_owner' || current.accountId === null
1905
+ || current.accountId !== draft.accountId || current.grantId !== draft.grantId))
1906
+ || draft.revision !== current.revision + 1) {
1821
1907
  throw new Error('Migration source identity or terminal status cannot change.');
1822
1908
  }
1823
1909
  const result = await AuthSwitchAuthorityMigrationLedgerModel.exact.transition({