@modelprofile.com/authswitch 8.2.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/authority-contract.d.ts +18 -1
  3. package/dist_ts/authority-contract.js +3 -2
  4. package/dist_ts/authority-paths.d.ts +37 -0
  5. package/dist_ts/authority-paths.js +46 -0
  6. package/dist_ts/authority-runtime-contract.d.ts +11 -1
  7. package/dist_ts/classes.authoritybroker.d.ts +9 -0
  8. package/dist_ts/classes.authoritybroker.js +70 -10
  9. package/dist_ts/classes.authorityclient.js +20 -1
  10. package/dist_ts/classes.authoritydaemon.d.ts +14 -3
  11. package/dist_ts/classes.authoritydaemon.js +33 -2
  12. package/dist_ts/classes.authoritydatabase.js +11 -2
  13. package/dist_ts/classes.authorityimport.d.ts +16 -4
  14. package/dist_ts/classes.authorityimport.js +74 -23
  15. package/dist_ts/classes.authoritypreuse.js +8 -3
  16. package/dist_ts/classes.authorityservice.d.ts +10 -11
  17. package/dist_ts/classes.authorityservice.js +14 -23
  18. package/dist_ts/classes.cli.d.ts +10 -2
  19. package/dist_ts/classes.cli.js +12 -4
  20. package/dist_ts/classes.codexmanaged.d.ts +8 -0
  21. package/dist_ts/classes.codexmanaged.js +19 -12
  22. package/dist_ts/classes.legacyfence.d.ts +53 -0
  23. package/dist_ts/classes.legacyfence.js +189 -0
  24. package/dist_ts/classes.operations.d.ts +15 -3
  25. package/dist_ts/classes.operations.js +22 -4
  26. package/dist_ts/classes.service.d.ts +21 -2
  27. package/dist_ts/classes.service.js +35 -8
  28. package/dist_ts/classes.tui.d.ts +2 -1
  29. package/dist_ts/classes.tui.js +3 -2
  30. package/dist_ts/ts_migration/legacysources/authswitchstores.js +5 -2
  31. package/package.json +7 -7
  32. package/readme.md +129 -17
  33. package/ts/00_commitinfo_data.ts +1 -1
  34. package/ts/authority-contract.ts +20 -1
  35. package/ts/authority-paths.ts +69 -0
  36. package/ts/authority-runtime-contract.ts +11 -1
  37. package/ts/classes.authoritybroker.ts +68 -10
  38. package/ts/classes.authorityclient.ts +16 -0
  39. package/ts/classes.authoritydaemon.ts +45 -4
  40. package/ts/classes.authoritydatabase.ts +10 -1
  41. package/ts/classes.authorityimport.ts +101 -24
  42. package/ts/classes.authoritypreuse.ts +7 -1
  43. package/ts/classes.authorityservice.ts +15 -30
  44. package/ts/classes.cli.ts +14 -3
  45. package/ts/classes.codexmanaged.ts +18 -7
  46. package/ts/classes.legacyfence.ts +219 -0
  47. package/ts/classes.operations.ts +22 -3
  48. package/ts/classes.service.ts +45 -8
  49. package/ts/classes.tui.ts +3 -1
  50. package/ts/ts_migration/legacysources/authswitchstores.ts +4 -1
@@ -1,6 +1,6 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import type { IAuthSwitchAccount, IAuthSwitchAccountEvent, IAuthSwitchBinding, IAuthSwitchLogin,
3
- IAuthSwitchOperation, IAuthSwitchSnapshot } from './authority-contract.js';
3
+ IAuthSwitchOperation, IAuthSwitchSnapshot, TAuthSwitchLoginOwnerTool } from './authority-contract.js';
4
4
  import { AuthSwitchRefusal } from './authority-contract.js';
5
5
  import { AuthSwitchAuthorityDatabase } from './classes.authoritydatabase.js';
6
6
  import type { IStoredAuthorityAccount, IStoredAuthorityBinding, IStoredAuthorityGrant,
@@ -51,14 +51,57 @@ const tokenExpiry = (credential: TOpenAiCredential): string | null => {
51
51
  catch { return null; }
52
52
  };
53
53
  const openAiGrantId = (accountId: string): string => idHash('authswitch-grant-v1', accountId, 'openai_managed', 'chatgpt');
54
+
55
+ /**
56
+ * What a caller is told when the answer is decided. Each states the repair and names no id, path or host.
57
+ *
58
+ * They are written once because several sites answer the same question: an operation id this authority does
59
+ * not hold is the same answer whether a read, a cancel or a start met it, and a client that branches on the
60
+ * code must not have to tell those apart.
61
+ */
62
+ const unknownOperation = 'This authority knows no device sign-in by that operation ID. '
63
+ + 'Start a new sign-in and follow that one.';
64
+ const loginNotBindable = 'This login cannot back a runtime binding. The authority must hold the login '
65
+ + 'itself, it must be the account\'s selected OpenAI login, and it must be ready. Reauthenticate the '
66
+ + 'account or choose another one.';
67
+ const bindingUnauthorized = 'This runtime binding is not authorized. Bind the account again to obtain a '
68
+ + 'capability for it.';
69
+ const bindingMoved = 'This runtime binding changed while its credential was being resolved. Bind the '
70
+ + 'account again.';
71
+ const bindingNeedsReauth = 'The account no longer holds the login this binding was authorized for. '
72
+ + 'Reauthenticate the account, then bind it again.';
54
73
  const publicAccount = (account: IStoredAuthorityAccount): IAuthSwitchAccount => ({
55
74
  id: account.id, providerId: account.providerId, label: account.label, email: account.email,
56
75
  plan: account.plan, removed: account.removed, revision: account.revision,
57
76
  statusObservedAt: account.statusObservedAt,
58
77
  });
78
+
79
+ /**
80
+ * Which native tool refreshes a grant, decided here because this is the one place that publishes a login.
81
+ *
82
+ * `owner` and `purpose` are what the authority stores; the tool is their meaning, and naming it here keeps
83
+ * every consumer from re-deriving it -- the derivation that is wrong as soon as two purposes share a tool,
84
+ * or one tool gains a second purpose. `daemon` and `none` have no native refresher at all, which is a value
85
+ * of its own rather than a missing one.
86
+ */
87
+ const loginOwnerTool = (grant: IStoredAuthorityGrant): TAuthSwitchLoginOwnerTool | null => {
88
+ if (grant.owner === 'claude_native') return 'claude_code';
89
+ if (grant.owner !== 'legacy_native') return null;
90
+ switch (grant.purpose) {
91
+ case 'opencode_native': return 'opencode';
92
+ case 'openai_managed': return 'codex';
93
+ case 'claude_host_native': return 'claude_code';
94
+ // A container setup token is held for a container, not refreshed by a tool on this host.
95
+ case 'claude_container_setup': return null;
96
+ // A purpose added without deciding its tool fails the type check here rather than publishing `null`,
97
+ // which would claim the authority refreshes a login some tool actually holds.
98
+ default: { const unhandledPurpose: never = grant.purpose; return unhandledPurpose; }
99
+ }
100
+ };
101
+
59
102
  const publicLogin = (grant: IStoredAuthorityGrant): IAuthSwitchLogin => ({
60
103
  id: grant.id, accountId: grant.accountId, providerId: grant.providerId, purpose: grant.purpose,
61
- owner: grant.owner,
104
+ owner: grant.owner, ownerTool: loginOwnerTool(grant),
62
105
  health: grant.state === 'exchange_may_have_been_sent' ? 'refreshing'
63
106
  : grant.state === 'native' ? 'unverified'
64
107
  : grant.state === 'legacy_native_pending' || grant.state === 'handoff_pending' ? 'pending_handoff' : grant.state,
@@ -341,7 +384,7 @@ export class AuthSwitchAuthorityBroker {
341
384
  private async finishStartingOperation(operationId: string, state: 'failed' | 'interrupted',
342
385
  error: string): Promise<IStoredAuthorityDeviceOperation> {
343
386
  const current = await this.database.readOperation(operationId);
344
- if (!current || current.kind === 'preuse_openai') throw new Error('Account operation was not found.');
387
+ if (!current || current.kind === 'preuse_openai') throw new AuthSwitchRefusal('not_found', unknownOperation);
345
388
  if (deviceOperationTerminal(current)) return current;
346
389
  if (current.state !== 'starting') throw new Error('Account operation already started its provider login.');
347
390
  const updateId = plugins.crypto.randomUUID();
@@ -407,7 +450,7 @@ export class AuthSwitchAuthorityBroker {
407
450
  let current: IStoredAuthorityDeviceOperation;
408
451
  try {
409
452
  const stored = await this.database.readOperation(id);
410
- if (!stored || stored.kind === 'preuse_openai') throw new Error('Account operation was not found.');
453
+ if (!stored || stored.kind === 'preuse_openai') throw new AuthSwitchRefusal('not_found', unknownOperation);
411
454
  current = stored;
412
455
  } catch (error) {
413
456
  await Promise.allSettled([handle.cancel(), handle.close()]);
@@ -514,7 +557,7 @@ export class AuthSwitchAuthorityBroker {
514
557
  public async getOperation(operationId: string): Promise<IAuthSwitchOperation> {
515
558
  if (!isUuid(operationId)) throw new Error('Invalid operation ID.');
516
559
  const operation = await this.database.readOperation(operationId);
517
- if (!operation || operation.kind === 'preuse_openai') throw new Error('Account operation was not found.');
560
+ if (!operation || operation.kind === 'preuse_openai') throw new AuthSwitchRefusal('not_found', unknownOperation);
518
561
  return publicOperation(operation);
519
562
  }
520
563
 
@@ -538,7 +581,7 @@ export class AuthSwitchAuthorityBroker {
538
581
  public async cancelOperation(operationId: string): Promise<IAuthSwitchOperation> {
539
582
  if (!isUuid(operationId)) throw new Error('Invalid operation ID.');
540
583
  const current = await this.database.readOperation(operationId);
541
- if (!current || current.kind === 'preuse_openai') throw new Error('Account operation was not found.');
584
+ if (!current || current.kind === 'preuse_openai') throw new AuthSwitchRefusal('not_found', unknownOperation);
542
585
  if (current.state === 'committing') throw new Error('Login is completing; check its operation status.');
543
586
  if (current.state !== 'starting' && current.state !== 'pending') return publicOperation(current);
544
587
  const updateId = plugins.crypto.randomUUID();
@@ -586,7 +629,7 @@ export class AuthSwitchAuthorityBroker {
586
629
  || grant.owner !== 'daemon'
587
630
  || grant.purpose !== input.purpose || account.providerId !== 'openai'
588
631
  || input.runtime === 'claude') {
589
- throw new Error('Account is not available for this runtime.');
632
+ throw new AuthSwitchRefusal('login_unavailable', loginNotBindable);
590
633
  }
591
634
  return { id, accountId: input.accountId, grantId: grant.id, runtime: input.runtime, scopeId: input.scopeId,
592
635
  incarnationId: input.incarnationId, revision: (existing?.revision ?? 0) + 1,
@@ -624,21 +667,27 @@ export class AuthSwitchAuthorityBroker {
624
667
  const current = await this.database.readBindingContext(bindingId);
625
668
  const binding = current.binding;
626
669
  const expected = Buffer.from(binding?.capabilityHash ?? '0'.repeat(64), 'hex');
670
+ // A missing binding and a wrong capability are compared against the same zero hash on purpose, so
671
+ // nothing here tells a caller which it was. One code preserves exactly that, and it is truthful for
672
+ // both: this capability does not authorize anything, and binding again is the only repair.
627
673
  if (!binding || !plugins.crypto.timingSafeEqual(supplied, expected)) {
628
- throw new Error('Runtime binding is not authorized.');
674
+ throw new AuthSwitchRefusal('binding_unauthorized', bindingUnauthorized);
629
675
  }
630
676
  if (originalBinding && (binding.id !== originalBinding.id
631
677
  || binding.capabilityHash !== originalBinding.capabilityHash
632
678
  || binding.accountId !== originalBinding.accountId || binding.grantId !== originalBinding.grantId
633
679
  || binding.incarnationId !== originalBinding.incarnationId
634
680
  || binding.grantAuthorizationGeneration !== originalBinding.grantAuthorizationGeneration)) {
635
- throw new Error('Account binding changed during credential resolution.');
681
+ // The capability still matched, so what moved is the binding itself: another incarnation took the
682
+ // scope, or the account was reauthorized under it. The caller's repair is the one above -- bind
683
+ // again -- so it is the same code; a second one would name a difference no runtime can act on.
684
+ throw new AuthSwitchRefusal('binding_unauthorized', bindingMoved);
636
685
  }
637
686
  originalBinding ??= binding;
638
687
  if (!current.account || current.account.removed || !current.grant
639
688
  || current.grant.id !== binding.grantId
640
689
  || current.grant.authorizationGeneration !== binding.grantAuthorizationGeneration) {
641
- throw new Error('Account needs reauthentication.');
690
+ throw new AuthSwitchRefusal('login_unavailable', bindingNeedsReauth);
642
691
  }
643
692
  return { account: current.account, grant: current.grant };
644
693
  }, minValidityMs, rejectedGrantGeneration);
@@ -660,6 +709,15 @@ export class AuthSwitchAuthorityBroker {
660
709
  }, minValidityMs, rejectedGrantGeneration);
661
710
  }
662
711
 
712
+ /**
713
+ * The shared managed-access loop, whose refusals are deliberately still unmarked faults.
714
+ *
715
+ * Its two callers repair differently: a runtime holding a binding binds again, while the usage reader has
716
+ * no binding at all and collapses everything into one usage problem. Naming a code here would either say
717
+ * "binding" to a caller that has none, or invent a second meaning for one that does, so the decision waits
718
+ * for the slice that gives the usage answer its own vocabulary. What a bound runtime can act on is decided
719
+ * before this point, in the view `resolveAccess` supplies.
720
+ */
663
721
  private async resolveManagedAccess(readView: () => Promise<{
664
722
  account: IStoredAuthorityAccount; grant: IStoredAuthorityGrant;
665
723
  }>, minValidityMs: number, rejectedGrantGeneration?: number): Promise<IAuthSwitchResolvedAccess> {
@@ -174,8 +174,24 @@ export class AuthSwitchClient {
174
174
  if (!Number.isSafeInteger(heartbeatMs) || heartbeatMs < 50 || heartbeatMs > 30_000) {
175
175
  throw new Error('Authswitch subscription heartbeat must be 50–30000 ms.');
176
176
  }
177
+ /**
178
+ * The pause between reconnection attempts, which never keeps its consumer's process alive.
179
+ *
180
+ * A subscription that cannot reach the daemon re-arms this timer for as long as that lasts, and during
181
+ * each pause it is the only handle this client holds -- the failed request already destroyed its socket.
182
+ * Referenced, that chain of 250 ms waits would hold an otherwise finished process open indefinitely,
183
+ * and it has nothing to finish: the wait itself does no work, and an abort clears it.
184
+ *
185
+ * An abort that already happened is checked before the timer is armed, because a signal fires once: a
186
+ * consumer that stops from inside its own disconnect callback aborts between this loop's last check and
187
+ * this call, and a listener added afterwards would never run. The pause would then be waited out in
188
+ * full, and since it holds nothing, a process whose only remaining handle it is can exit inside it --
189
+ * dropping the closing status this loop owes its consumer.
190
+ */
177
191
  const retry = () => new Promise<void>(resolve => {
192
+ if (signal.aborted) { resolve(); return; }
178
193
  const timer = setTimeout(() => { signal.removeEventListener('abort', aborted); resolve(); }, 250);
194
+ timer.unref();
179
195
  const aborted = () => { clearTimeout(timer); resolve(); };
180
196
  signal.addEventListener('abort', aborted, { once: true });
181
197
  });
@@ -37,8 +37,39 @@ export interface IAuthSwitchAuthorityDaemonOptions extends IAuthSwitchAuthorityD
37
37
  claudeStatus?: ClaudeAccountStatus;
38
38
  /** Activated only after a verified backend source receipt has registered the native home. */
39
39
  claudeNative?: IClaudeNativeAuthorityOptions;
40
- /** Where the one-time import reads the legacy stores. Defaults to this user's own locations. */
41
- legacyImport?: IAuthSwitchAuthorityImportOptions;
40
+ /** Where the one-time import reads the legacy stores, or `'none'`. Never derived from this host here. */
41
+ legacyImport: TAuthSwitchAuthorityLegacyImport;
42
+ }
43
+
44
+ /**
45
+ * Where a daemon's one-time import reads, stated by whoever builds it.
46
+ *
47
+ * The legacy stores are this user's real credentials, so which host they are read from is never something a
48
+ * daemon decides for its caller: it is named, or the daemon is built with no import surface at all.
49
+ * `AuthSwitchAuthorityService`'s entry point is the one place that may name this host's own locations,
50
+ * because that is the daemon that exists to serve this host. The importer requires those two locations of
51
+ * every caller, so naming its options here is already the whole statement.
52
+ */
53
+ export type TAuthSwitchAuthorityLegacyImport = IAuthSwitchAuthorityImportOptions | 'none';
54
+
55
+ /** What a caller that named neither is told. It names the two answers and who may take the third. */
56
+ const legacyImportRequired = 'Authswitch authority requires explicit legacy source locations: pass '
57
+ + 'legacyImport with an env and an absolute homeDirectory, or legacyImport: "none" for a daemon with no '
58
+ + 'account import. Only the authswitch service entry point may take them from this host.';
59
+
60
+ /** What the import routes answer on a daemon that was built with no import surface. */
61
+ const legacyImportAbsent = 'This authority was started without legacy source locations, so it serves no '
62
+ + 'account import.';
63
+
64
+ /** The same check for a caller with types and one without: stated locations, or stated absence. */
65
+ function assertStatedLegacyImport(value: unknown): asserts value is TAuthSwitchAuthorityLegacyImport {
66
+ if (value === 'none') return;
67
+ if (value === null || typeof value !== 'object'
68
+ || typeof (value as { env?: unknown }).env !== 'object' || (value as { env?: unknown }).env === null
69
+ || typeof (value as { homeDirectory?: unknown }).homeDirectory !== 'string'
70
+ || !plugins.path.isAbsolute((value as { homeDirectory: string }).homeDirectory)) {
71
+ throw new Error(legacyImportRequired);
72
+ }
42
73
  }
43
74
 
44
75
  const hasKeys = (value: unknown, keys: readonly string[]): value is Record<string, unknown> =>
@@ -83,7 +114,8 @@ export class AuthSwitchAuthorityDaemon {
83
114
  public readonly broker: AuthSwitchAuthorityBroker;
84
115
  public readonly usage: AuthSwitchAuthorityUsage;
85
116
  public readonly preuse: AuthSwitchAuthorityPreuse;
86
- public readonly legacyImport: AuthSwitchAuthorityImport;
117
+ /** Absent on a daemon built with `legacyImport: 'none'`; its import routes then refuse. */
118
+ public readonly legacyImport?: AuthSwitchAuthorityImport;
87
119
  public readonly claudeNative?: ClaudeNativeAuthority;
88
120
  private readonly managementRouter = new plugins.typedrequest.TypedRouter();
89
121
  private readonly runtimeRouter = new plugins.typedrequest.TypedRouter();
@@ -106,6 +138,10 @@ export class AuthSwitchAuthorityDaemon {
106
138
  private closing = false;
107
139
 
108
140
  constructor(private readonly options: IAuthSwitchAuthorityDaemonOptions) {
141
+ // First, before anything is constructed: a daemon that was going to read the wrong host's credentials
142
+ // must not come into existence at all. The type states it and this states it again, because a caller
143
+ // without types is exactly the caller that would have defaulted here.
144
+ assertStatedLegacyImport(options.legacyImport);
109
145
  this.database = new AuthSwitchAuthorityDatabase(options);
110
146
  const provider = options.broker?.provider ?? new plugins.flexAccounts.OpenAiProviderAdapter();
111
147
  this.broker = new AuthSwitchAuthorityBroker(this.database, { ...options.broker, provider });
@@ -134,7 +170,9 @@ export class AuthSwitchAuthorityDaemon {
134
170
  provider,
135
171
  });
136
172
  this.preuse = new AuthSwitchAuthorityPreuse(this.database, this.broker, options.preuse);
137
- this.legacyImport = new AuthSwitchAuthorityImport(this.database, options.legacyImport);
173
+ if (options.legacyImport !== 'none') {
174
+ this.legacyImport = new AuthSwitchAuthorityImport(this.database, options.legacyImport);
175
+ }
138
176
  if (options.claudeNative) {
139
177
  this.claudeNative = new ClaudeNativeAuthority(this.database, {
140
178
  ...options.claudeNative, onChanged: () => this.broker.notifyChanged(),
@@ -309,6 +347,7 @@ export class AuthSwitchAuthorityDaemon {
309
347
  if (request === null || typeof request !== 'object' || Array.isArray(request)
310
348
  || Object.keys(request).length !== 0) invalid();
311
349
  if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
350
+ if (!this.legacyImport) throw new AuthSwitchRefusal('import_refusal', legacyImportAbsent);
312
351
  return { inventory: await this.legacyImport.inventory() };
313
352
  }));
314
353
  this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchImportStatus>(
@@ -319,6 +358,7 @@ export class AuthSwitchAuthorityDaemon {
319
358
  || (request.limit !== undefined && (!Number.isSafeInteger(request.limit)
320
359
  || request.limit < 1 || request.limit > 128))) invalid();
321
360
  if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
361
+ if (!this.legacyImport) throw new AuthSwitchRefusal('import_refusal', legacyImportAbsent);
322
362
  return { status: await this.legacyImport.status(request) };
323
363
  }));
324
364
  this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchEvents>(
@@ -461,6 +501,7 @@ export class AuthSwitchAuthorityDaemon {
461
501
  || request.callerQuiescent !== true || request.acknowledgeRunOrder !== true
462
502
  || !validImportSubmission(request.submission)) invalid();
463
503
  if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
504
+ if (!this.legacyImport) throw new AuthSwitchRefusal('import_refusal', legacyImportAbsent);
464
505
  // An importer refusal is an `AuthSwitchRefusal` like every other, so the one conversion in
465
506
  // `handler()` marks it. What stays special is its meaning: an UNMARKED failure of this route
466
507
  // leaves the import outcome unknown, and the source must be read with `import.status` rather
@@ -1815,9 +1815,18 @@ export class AuthSwitchAuthorityDatabase {
1815
1815
  throw new Error('Migration completion requires a verified import of the same account and grant.');
1816
1816
  }
1817
1817
  if (current) {
1818
+ // A row may follow its source's current bytes in exactly one state. `pending_native_owner` is a
1819
+ // projection whose own tool still refreshes it: nothing rotating was ever sent for such a row and
1820
+ // the authority holds no copy of its credential, so the file that tool has since rewritten is the
1821
+ // same login and the row must be able to reach it -- otherwise the retry its own refusal asks for
1822
+ // is impossible. It may never change the account or grant it named while doing so, and no other
1823
+ // status may move its digest at all.
1824
+ const followsSource = current.sourceDigest !== draft.sourceDigest;
1818
1825
  if (current.status === 'complete' || current.version !== draft.version
1819
1826
  || current.sourceKind !== draft.sourceKind || current.sourcePathHash !== draft.sourcePathHash
1820
- || current.sourceDigest !== draft.sourceDigest || draft.revision !== current.revision + 1) {
1827
+ || (followsSource && (current.status !== 'pending_native_owner' || current.accountId === null
1828
+ || current.accountId !== draft.accountId || current.grantId !== draft.grantId))
1829
+ || draft.revision !== current.revision + 1) {
1821
1830
  throw new Error('Migration source identity or terminal status cannot change.');
1822
1831
  }
1823
1832
  const result = await AuthSwitchAuthorityMigrationLedgerModel.exact.transition({
@@ -76,9 +76,15 @@ export interface IAuthSwitchImportClaudeTokens {
76
76
  }
77
77
 
78
78
  export interface IAuthSwitchAuthorityImportOptions {
79
- /** Locates the legacy stores, exactly as each owning class locates its own. */
80
- env?: NodeJS.ProcessEnv;
81
- homeDirectory?: string;
79
+ /**
80
+ * Locates the legacy stores, exactly as each owning class locates its own -- and always stated.
81
+ *
82
+ * These are the two inputs that decide whose real credentials an import reads, so the importer never
83
+ * reaches for the host's own environment: a caller that could not name them has no business importing.
84
+ * `AuthSwitchAuthorityService`'s entry point is the one place that takes them from this host.
85
+ */
86
+ env: NodeJS.ProcessEnv;
87
+ homeDirectory: string;
82
88
  now?: () => number;
83
89
  /** The one rotating refresh of an imported ChatGPT login, and the non-rotating read that proves one. */
84
90
  openAi?: IAuthSwitchImportOpenAiOperations;
@@ -189,6 +195,58 @@ const RUN_ORDER_REFUSAL = 'Importing needs the run-order acknowledgement: an imp
189
195
  + 'exercise exactly that copy. Import only once those commands run on the authority and the native '
190
196
  + 'projections exist.';
191
197
 
198
+ /**
199
+ * A row keeps the login it named, permanently: no route forgets a row or re-points it at another account,
200
+ * and one would be a way to import a login the owner never approved under an approval given for a different
201
+ * one. The same is true of a settled row, which answers that it is already imported.
202
+ */
203
+ const FOREIGN_SOURCE_REFUSAL = 'This source now holds a different account\'s login than the one this import '
204
+ + 'registered for it. Restore the login this source was registered with, then import it again.';
205
+ const CHANGED_SOURCE_REFUSAL =
206
+ 'The source changed after its migration ledger row was written; it is a different source now.';
207
+
208
+ /**
209
+ * Whether a row may take the bytes its source holds now.
210
+ *
211
+ * A projection that could not be proven waits for its own tool, and that tool proves itself by refreshing
212
+ * its store -- which rewrites the file. The row has to be able to follow those bytes, or the retry its own
213
+ * refusal asks for could never be made at all. It follows them only this far: the row is unsettled, nothing
214
+ * rotating was ever sent for it, and the authority holds no copy of its credential. That the bytes are still
215
+ * the same login is a separate question, and `ledgerObjection` answers it first.
216
+ */
217
+ const followsRefreshedSource = (ledger: IStoredAuthorityMigrationLedger,
218
+ record: Pick<ILegacySourceRecord, 'treatment'>): boolean =>
219
+ ledger.status === 'pending_native_owner' && record.treatment === 'project' && ledger.grantId !== null;
220
+
221
+ /**
222
+ * What this source's own row objects to, asked once and answered for both the report and the submit.
223
+ *
224
+ * The runbook tells the owner not to submit a source the inventory lists a problem for, which is only true
225
+ * while the two agree, so they are the same four questions in one place. The identity question is the one
226
+ * the provider cannot answer: a row is found by the hash of its location, so the same row answers for
227
+ * whatever login that location holds later -- `codex login` under the same path, a replaced saved record --
228
+ * and the non-rotating proof asks the provider about the identity the newly read bytes themselves claim. It
229
+ * therefore proves those bytes are self-consistent and live, never that they are the login this row was
230
+ * approved for. A source with no derivable identity is left to its reader's own problem.
231
+ */
232
+ type TLedgerObjection = 'imported' | 'quarantined' | 'foreign' | 'changed';
233
+
234
+ const ledgerObjection = (ledger: IStoredAuthorityMigrationLedger,
235
+ record: Pick<ILegacySourceRecord, 'treatment' | 'accountId' | 'sourceDigest'>,
236
+ grantId: string | null): TLedgerObjection | null => {
237
+ // A quarantined row is terminal whatever its location holds now, and its answer -- sign in to that
238
+ // account again -- is the next step either way. Everything after it is about the login that is there.
239
+ if (ledger.status === 'quarantined') return 'quarantined';
240
+ // Before "already imported": a settled row whose location now holds another account would otherwise
241
+ // answer that a login it never touched is imported.
242
+ if (record.accountId !== null && grantId !== null
243
+ && ((ledger.accountId !== null && ledger.accountId !== record.accountId)
244
+ || (ledger.grantId !== null && ledger.grantId !== grantId))) return 'foreign';
245
+ if (ledger.status === 'complete') return 'imported';
246
+ if (ledger.sourceDigest !== record.sourceDigest && !followsRefreshedSource(ledger, record)) return 'changed';
247
+ return null;
248
+ };
249
+
192
250
  /** Strips the source location: a ledger row stores its hash, and no response may carry the path. */
193
251
  const publicSource = (record: ILegacySourceRecord,
194
252
  ledgerStatus: IAuthSwitchImportSource['ledgerStatus']): IAuthSwitchImportSource => ({
@@ -227,9 +285,9 @@ export class AuthSwitchAuthorityImport {
227
285
  private readonly quiescence: IAuthSwitchImportQuiescence;
228
286
 
229
287
  constructor(private readonly database: AuthSwitchAuthorityDatabase,
230
- options: IAuthSwitchAuthorityImportOptions = {}) {
231
- this.env = options.env ?? process.env;
232
- this.homeDirectory = options.homeDirectory ?? plugins.os.homedir();
288
+ options: IAuthSwitchAuthorityImportOptions) {
289
+ this.env = options.env;
290
+ this.homeDirectory = options.homeDirectory;
233
291
  this.now = options.now ?? Date.now;
234
292
  this.openAi = options.openAi ?? new plugins.flexAccounts.OpenAiProviderAdapter();
235
293
  this.claudeStatus = options.claudeStatus ?? new ClaudeAccountStatus();
@@ -248,12 +306,20 @@ export class AuthSwitchAuthorityImport {
248
306
  for (const record of report.sources) {
249
307
  const ledger = await this.database.readMigrationLedger(record.id);
250
308
  const source = publicSource(record, ledger?.status ?? null);
251
- if (ledger !== null && ledger.sourceDigest !== record.sourceDigest) {
252
- // The source changed after its row was written. A later import must not treat the approved bytes and
253
- // the current bytes as the same source; the ledger deliberately refuses to move that row.
309
+ // The same four questions the submit decides on, so a source the report invites is one the submit
310
+ // takes. New bytes of the same login under a row that is still waiting for them are not an objection:
311
+ // they are the retry that row asks for, and no problem line tells the owner to stop.
312
+ const objection = ledger === null ? null : ledgerObjection(ledger, record,
313
+ record.accountId === null ? null : importGrantId(record.accountId, grantPlan(record)));
314
+ if (objection === 'foreign') {
315
+ source.problems.push('this source now holds a different account\'s login than the one it was registered with');
316
+ }
317
+ if (objection === 'changed') {
318
+ // A later import must not treat the approved bytes and the current bytes as the same source; the
319
+ // ledger deliberately refuses to move that row.
254
320
  source.problems.push('the source changed after its migration ledger row was written');
255
321
  }
256
- if (ledger?.status === 'complete') {
322
+ if (objection === 'imported') {
257
323
  source.problems.push('this source is already imported; its legacy copy is retired');
258
324
  }
259
325
  sources.push(source);
@@ -308,17 +374,6 @@ export class AuthSwitchAuthorityImport {
308
374
  const record = this.resolveSource(request.submission);
309
375
  const plan = grantPlan(record);
310
376
  this.assertQuiescent(record);
311
- const ledger = await this.database.readMigrationLedger(record.id);
312
- if (ledger !== null) {
313
- if (ledger.sourceDigest !== record.sourceDigest) {
314
- refuse('The source changed after its migration ledger row was written; it is a different source now.');
315
- }
316
- if (ledger.status === 'complete') refuse('This source is already imported.');
317
- if (ledger.status === 'quarantined') {
318
- refuse('This source is quarantined: its outcome could not be established, so it needs a device '
319
- + 'sign-in for that account rather than another import.');
320
- }
321
- }
322
377
  const accountId = record.accountId;
323
378
  const subject = record.subject;
324
379
  const workspaceId = record.workspaceId;
@@ -326,6 +381,18 @@ export class AuthSwitchAuthorityImport {
326
381
  refuse('The source carries no provider identity the authority can bind an account to.');
327
382
  }
328
383
  const grantId = importGrantId(accountId, plan);
384
+ const ledger = await this.database.readMigrationLedger(record.id);
385
+ // Every one of these is decided from the durable row alone, before a single write: a projection
386
+ // registers its account and grant before the provider is asked anything, so an objection that arrived
387
+ // later would arrive after the record it objects to had been written.
388
+ switch (ledger === null ? null : ledgerObjection(ledger, record, grantId)) {
389
+ case 'imported': refuse('This source is already imported.');
390
+ case 'quarantined':
391
+ refuse('This source is quarantined: its outcome could not be established, so it needs a device '
392
+ + 'sign-in for that account rather than another import.');
393
+ case 'foreign': refuse(FOREIGN_SOURCE_REFUSAL);
394
+ case 'changed': refuse(CHANGED_SOURCE_REFUSAL);
395
+ }
329
396
  if (ledger === null) await this.beginLedger(record);
330
397
  const material = this.material(record, request.submission);
331
398
  return record.treatment === 'project'
@@ -428,12 +495,20 @@ export class AuthSwitchAuthorityImport {
428
495
  }));
429
496
  }
430
497
 
498
+ /**
499
+ * Moves the row, and -- only where a caller states it -- the bytes that move with the status.
500
+ *
501
+ * The digest is part of the same write as the status it was proven under, so no row can record a proof of
502
+ * bytes it does not name. The database holds the rule about which row may carry a new digest at all.
503
+ */
431
504
  private async moveLedger(ledgerId: string, status: IStoredAuthorityMigrationLedger['status'],
432
- accountId: string | null, grantId: string | null): Promise<IStoredAuthorityMigrationLedger> {
505
+ accountId: string | null, grantId: string | null,
506
+ sourceDigest?: string): Promise<IStoredAuthorityMigrationLedger> {
433
507
  const updateId = plugins.crypto.randomUUID();
434
508
  return this.database.changeMigrationLedger(updateId, ledgerId, current => {
435
509
  if (!current) throw new Error('The migration ledger row disappeared during the import.');
436
- return { ...current, status, accountId, grantId, revision: current.revision + 1,
510
+ return { ...current, status, accountId, grantId,
511
+ sourceDigest: sourceDigest ?? current.sourceDigest, revision: current.revision + 1,
437
512
  statusObservedAt: this.stamp(), updateId };
438
513
  });
439
514
  }
@@ -513,7 +588,9 @@ export class AuthSwitchAuthorityImport {
513
588
  await this.moveLedger(record.id, 'pending_native_owner', identity.accountId, identity.grantId);
514
589
  throw error;
515
590
  }
516
- await this.moveLedger(record.id, 'verified', identity.accountId, identity.grantId);
591
+ // The bytes that were just proven are the bytes this row records, written with the proof itself: a row
592
+ // that followed its tool's refresh names the store the provider answered for, never the one before it.
593
+ await this.moveLedger(record.id, 'verified', identity.accountId, identity.grantId, record.sourceDigest);
517
594
  if (record.sourceKind === 'claude_native') await this.adoptClaudeHome(record, identity);
518
595
  await this.moveLedger(record.id, 'complete', identity.accountId, identity.grantId);
519
596
  return this.result(record, 'complete', identity, []);
@@ -1,5 +1,6 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import type { IAuthSwitchPreuseOperation } from './authority-contract.js';
3
+ import { AuthSwitchRefusal } from './authority-contract.js';
3
4
  import { AuthSwitchAuthorityBroker } from './classes.authoritybroker.js';
4
5
  import { AuthSwitchAuthorityDatabase } from './classes.authoritydatabase.js';
5
6
  import type { IStoredAuthorityPreuseOperation, TStoredAuthorityPreuseProblem } from './classes.authoritymodels.js';
@@ -250,7 +251,12 @@ export class AuthSwitchAuthorityPreuse {
250
251
  private async readStored(operationId: string): Promise<IStoredAuthorityPreuseOperation> {
251
252
  if (!uuid(operationId)) throw new Error('Invalid preuse operation ID.');
252
253
  const operation = await this.database.readOperation(operationId);
253
- if (!operation || operation.kind !== 'preuse_openai') throw new Error('Preuse operation was not found.');
254
+ // One site answers both routes that take a preuse operation id, so a read and a cancel of something
255
+ // this authority does not hold say the same thing in the same code.
256
+ if (!operation || operation.kind !== 'preuse_openai') {
257
+ throw new AuthSwitchRefusal('not_found', 'This authority knows no preuse by that operation ID. '
258
+ + 'Start a new preuse and follow that one.');
259
+ }
254
260
  return operation;
255
261
  }
256
262
 
@@ -1,37 +1,12 @@
1
1
  import * as plugins from './plugins.js';
2
+ import { authSwitchAuthorityUnitDirectory, authSwitchAuthorityUnitName,
3
+ resolveAuthSwitchAuthorityPaths } from './authority-paths.js';
2
4
  import { AuthSwitchAuthorityDaemon } from './classes.authoritydaemon.js';
3
5
  import type { IClaudeNativeAuthorityOptions } from './classes.claudeauthority.js';
4
6
  import { ClaudeNativeAdapter, claudeNativeUserSettingsFiles,
5
7
  resolveClaudeNativeHome } from './classes.claudenative.js';
6
8
 
7
- export interface IAuthSwitchAuthorityPaths {
8
- runtimeDirectory: string;
9
- dataDirectory: string;
10
- databaseSocketPath: string;
11
- authoritySocketPath: string;
12
- runtimeSocketPath: string;
13
- runtimeSocketDirectory: string;
14
- }
15
-
16
- /** Canonical per-user paths. An explicit runtime directory is also usable by a trusted container host. */
17
- export const resolveAuthSwitchAuthorityPaths = (runtimeDirectory = process.env.XDG_RUNTIME_DIR): IAuthSwitchAuthorityPaths => {
18
- if (!runtimeDirectory || !plugins.path.isAbsolute(runtimeDirectory)) {
19
- throw new Error('A private XDG_RUNTIME_DIR is required for authswitch authority.');
20
- }
21
- const home = plugins.os.userInfo().homedir;
22
- const dataHome = process.env.XDG_DATA_HOME || plugins.path.join(home, '.local/share');
23
- if (!plugins.path.isAbsolute(dataHome)) throw new Error('XDG_DATA_HOME must be an absolute path.');
24
- const socketDirectory = plugins.path.join(runtimeDirectory, 'authswitch');
25
- const runtimeSocketDirectory = plugins.path.join(socketDirectory, 'runtime');
26
- return {
27
- runtimeDirectory,
28
- dataDirectory: plugins.path.join(dataHome, 'authswitch', 'authority'),
29
- databaseSocketPath: plugins.path.join(socketDirectory, 'internal', 'db.sock'),
30
- authoritySocketPath: plugins.path.join(socketDirectory, 'management', 'authority.sock'),
31
- runtimeSocketPath: plugins.path.join(runtimeSocketDirectory, 'runtime.sock'),
32
- runtimeSocketDirectory,
33
- };
34
- };
9
+ export { resolveAuthSwitchAuthorityPaths, type IAuthSwitchAuthorityPaths } from './authority-paths.js';
35
10
 
36
11
  /** Explicit installer and lifecycle operations for a user-scoped systemd service. */
37
12
  export class AuthSwitchAuthorityService {
@@ -39,9 +14,11 @@ export class AuthSwitchAuthorityService {
39
14
  public readonly unitFile: plugins.smartdaemon.SystemdUnitFile;
40
15
  public readonly definition: plugins.smartdaemon.SystemdServiceDefinition;
41
16
 
42
- constructor(runtimeDirectory: string, unitDirectory?: string) {
17
+ /** The unit directory defaults to the one the legacy fence looks in for this user (`./authority-paths`). */
18
+ constructor(runtimeDirectory: string,
19
+ unitDirectory = authSwitchAuthorityUnitDirectory(process.env, plugins.os.userInfo().homedir)) {
43
20
  const packageRoot = plugins.path.dirname(plugins.path.dirname(plugins.fileURLToPath(import.meta.url)));
44
- const options = { unitName: 'authswitch-authority.service', scope: 'user' as const, runtimeDirectory };
21
+ const options = { unitName: authSwitchAuthorityUnitName, scope: 'user' as const, runtimeDirectory };
45
22
  this.unit = new plugins.smartdaemon.SystemdUnit(options);
46
23
  this.unitFile = new plugins.smartdaemon.SystemdUnitFile({ ...options, unitDirectory });
47
24
  this.definition = new plugins.smartdaemon.SystemdServiceDefinition({
@@ -86,6 +63,13 @@ export const resolveAuthSwitchClaudeNativeAuthority = (env: NodeJS.ProcessEnv =
86
63
  settingsFiles: claudeNativeUserSettingsFiles(configDir) }) };
87
64
  };
88
65
 
66
+ /**
67
+ * The per-user daemon this package installs, and the one place that names this host's own legacy stores.
68
+ *
69
+ * Every other construction of `AuthSwitchAuthorityDaemon` -- a test, a consumer's harness, an embedding
70
+ * backend -- states its own locations or asks for no import surface, because a daemon that defaulted here
71
+ * would read the credentials of whichever user happened to start it.
72
+ */
89
73
  export const runAuthSwitchAuthorityDaemon = async (paths = resolveAuthSwitchAuthorityPaths()): Promise<void> => {
90
74
  const daemon = new AuthSwitchAuthorityDaemon({
91
75
  dataDirectory: paths.dataDirectory,
@@ -93,6 +77,7 @@ export const runAuthSwitchAuthorityDaemon = async (paths = resolveAuthSwitchAuth
93
77
  authoritySocketPath: paths.authoritySocketPath,
94
78
  runtimeSocketPath: paths.runtimeSocketPath,
95
79
  claudeNative: resolveAuthSwitchClaudeNativeAuthority(),
80
+ legacyImport: { env: process.env, homeDirectory: plugins.os.homedir() },
96
81
  });
97
82
  await daemon.start();
98
83
  await new Promise<void>((resolve, reject) => {
package/ts/classes.cli.ts CHANGED
@@ -4,7 +4,7 @@ import { CodexHarness } from './classes.codexharness.js';
4
4
  import { OpenCodeHarness } from './classes.opencodeharness.js';
5
5
  import { ClaudeCodeHarness } from './classes.claudecodeharness.js';
6
6
  import { AuthSwitchTui } from './classes.tui.js';
7
- import { AglAuthSwitchCoordinator, AuthSwitchOperations, authSwitchMutationReplacesLogin, type TAuthSwitchCoordinator, type TAuthSwitchMutation } from './classes.operations.js';
7
+ import { AglAuthSwitchCoordinator, AuthSwitchOperations, authSwitchHostLegacyFence, authSwitchMutationReplacesLogin, type TAuthSwitchCoordinator, type TAuthSwitchLegacyFence, type TAuthSwitchMutation } from './classes.operations.js';
8
8
  import { readAccountList } from './classes.accountlist.js';
9
9
  import { AccountListRenderer } from './classes.listrenderer.js';
10
10
  import { accountLimits, activeAccounts, CondensedRenderer } from './classes.limits.js';
@@ -98,9 +98,20 @@ export class AuthSwitchCli {
98
98
  private readonly operations: AuthSwitchOperations;
99
99
  private get out(): plugins.smartconsole.SmartConsole { return this.console ??= new plugins.smartconsole.SmartConsole(); }
100
100
 
101
- constructor(harnessesArg?: IAuthHarness[] | CodexSwitcher, coordinatorArg?: TAuthSwitchCoordinator) {
101
+ /**
102
+ * `legacyFenceArg` states which authority fences the mutations of injected harnesses.
103
+ *
104
+ * With no harnesses this command line is this host's, exactly as its default harnesses and its AGL
105
+ * coordinator are, and it is fenced by this host's authority. Injected harnesses write whatever stores
106
+ * their caller chose, so -- like the coordinator -- nothing of this host is assumed for them: they are
107
+ * fenced only by what the caller states, and by nothing when it states nothing.
108
+ */
109
+ constructor(harnessesArg?: IAuthHarness[] | CodexSwitcher, coordinatorArg?: TAuthSwitchCoordinator,
110
+ legacyFenceArg?: TAuthSwitchLegacyFence) {
102
111
  const coordinator = new AglAuthSwitchCoordinator();
103
- this.operations = new AuthSwitchOperations(coordinatorArg ?? (harnessesArg === undefined ? request => coordinator.coordinate(request) : undefined));
112
+ this.operations = new AuthSwitchOperations(
113
+ coordinatorArg ?? (harnessesArg === undefined ? request => coordinator.coordinate(request) : undefined),
114
+ legacyFenceArg ?? (harnessesArg === undefined ? authSwitchHostLegacyFence() : 'none'));
104
115
  harnessesArg ??= [new CodexHarness(), new OpenCodeHarness(), new ClaudeCodeHarness()];
105
116
  // Preserve the published single-Codex constructor at the composition boundary.
106
117
  const harnesses = Array.isArray(harnessesArg) ? harnessesArg : [new CodexHarness(harnessesArg)];