@sentropic/llm-mesh 0.32.4 → 0.34.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 (73) hide show
  1. package/dist/enrollment/index.d.ts +4 -0
  2. package/dist/enrollment/index.d.ts.map +1 -1
  3. package/dist/enrollment/index.js +2 -0
  4. package/dist/enrollment/index.js.map +1 -1
  5. package/dist/enrollment/muse-code.d.ts +9 -0
  6. package/dist/enrollment/muse-code.d.ts.map +1 -1
  7. package/dist/enrollment/muse-code.js +22 -1
  8. package/dist/enrollment/muse-code.js.map +1 -1
  9. package/dist/enrollment/seat-onboarding-projections.d.ts +44 -0
  10. package/dist/enrollment/seat-onboarding-projections.d.ts.map +1 -0
  11. package/dist/enrollment/seat-onboarding-projections.js +113 -0
  12. package/dist/enrollment/seat-onboarding-projections.js.map +1 -0
  13. package/dist/enrollment/seat-onboarding.d.ts +161 -0
  14. package/dist/enrollment/seat-onboarding.d.ts.map +1 -0
  15. package/dist/enrollment/seat-onboarding.js +698 -0
  16. package/dist/enrollment/seat-onboarding.js.map +1 -0
  17. package/dist/node/index.d.ts +8 -0
  18. package/dist/node/index.d.ts.map +1 -1
  19. package/dist/node/index.js +4 -0
  20. package/dist/node/index.js.map +1 -1
  21. package/dist/node/seat-onboarding-capture.d.ts +47 -0
  22. package/dist/node/seat-onboarding-capture.d.ts.map +1 -0
  23. package/dist/node/seat-onboarding-capture.js +164 -0
  24. package/dist/node/seat-onboarding-capture.js.map +1 -0
  25. package/dist/node/seat-onboarding-cell-compose.d.ts +25 -0
  26. package/dist/node/seat-onboarding-cell-compose.d.ts.map +1 -0
  27. package/dist/node/seat-onboarding-cell-compose.js +117 -0
  28. package/dist/node/seat-onboarding-cell-compose.js.map +1 -0
  29. package/dist/node/seat-onboarding-erase.d.ts +8 -0
  30. package/dist/node/seat-onboarding-erase.d.ts.map +1 -0
  31. package/dist/node/seat-onboarding-erase.js +31 -0
  32. package/dist/node/seat-onboarding-erase.js.map +1 -0
  33. package/dist/node/seat-onboarding-materialize.d.ts +6 -0
  34. package/dist/node/seat-onboarding-materialize.d.ts.map +1 -0
  35. package/dist/node/seat-onboarding-materialize.js +11 -0
  36. package/dist/node/seat-onboarding-materialize.js.map +1 -0
  37. package/dist/node/seat-onboarding-record.d.ts +11 -0
  38. package/dist/node/seat-onboarding-record.d.ts.map +1 -0
  39. package/dist/node/seat-onboarding-record.js +86 -0
  40. package/dist/node/seat-onboarding-record.js.map +1 -0
  41. package/dist/node/seat-onboarding-stage1.d.ts +11 -0
  42. package/dist/node/seat-onboarding-stage1.d.ts.map +1 -0
  43. package/dist/node/seat-onboarding-stage1.js +121 -0
  44. package/dist/node/seat-onboarding-stage1.js.map +1 -0
  45. package/dist/node/seat-onboarding-stage2.d.ts +10 -0
  46. package/dist/node/seat-onboarding-stage2.d.ts.map +1 -0
  47. package/dist/node/seat-onboarding-stage2.js +110 -0
  48. package/dist/node/seat-onboarding-stage2.js.map +1 -0
  49. package/dist/node/seat-onboarding-types.d.ts +106 -0
  50. package/dist/node/seat-onboarding-types.d.ts.map +1 -0
  51. package/dist/node/seat-onboarding-types.js +6 -0
  52. package/dist/node/seat-onboarding-types.js.map +1 -0
  53. package/dist/node/seat-stage-write.d.ts +25 -0
  54. package/dist/node/seat-stage-write.d.ts.map +1 -0
  55. package/dist/node/seat-stage-write.js +93 -0
  56. package/dist/node/seat-stage-write.js.map +1 -0
  57. package/dist/service/custodian-refresh.d.ts +52 -0
  58. package/dist/service/custodian-refresh.d.ts.map +1 -0
  59. package/dist/service/custodian-refresh.js +51 -0
  60. package/dist/service/custodian-refresh.js.map +1 -0
  61. package/dist/service/facade.d.ts +28 -0
  62. package/dist/service/facade.d.ts.map +1 -1
  63. package/dist/service/facade.js +18 -1
  64. package/dist/service/facade.js.map +1 -1
  65. package/dist/service/local-account-transport-service.d.ts +127 -2
  66. package/dist/service/local-account-transport-service.d.ts.map +1 -1
  67. package/dist/service/local-account-transport-service.js +540 -10
  68. package/dist/service/local-account-transport-service.js.map +1 -1
  69. package/dist/service/reauth-alert.d.ts +29 -0
  70. package/dist/service/reauth-alert.d.ts.map +1 -0
  71. package/dist/service/reauth-alert.js +2 -0
  72. package/dist/service/reauth-alert.js.map +1 -0
  73. package/package.json +1 -1
@@ -1,9 +1,54 @@
1
1
  import { AccountTransportAcquireError, InMemoryAccountTransportCoordinator, } from '../account-transports.js';
2
2
  import { MUSE_DIRECT_BILLING_TYPE } from '../enrollment/muse.js';
3
3
  import { ClaudeRefreshPreparationError } from '../enrollment/claude-code.js';
4
+ import { MusePostRotationError } from '../enrollment/muse-code.js';
4
5
  import { listModelProfilesByProvider } from '../catalog.js';
5
6
  import { isDeviceFlowSeatAccount, isExternalSeatAccount, isExternalSeatUnavailable, markExternalSeatAuthority, readExternalSeatAuthority, throwExternalSeatUnavailable, } from './external-seat-authority.js';
6
7
  import { classifyTransition, validateSeatBundle, } from '../enrollment/seat-bundle.js';
8
+ import { validateSeatOnboarding, } from '../enrollment/seat-onboarding.js';
9
+ import { sanitizeCustodianRefreshPolicy, shouldRefreshProactively, } from './custodian-refresh.js';
10
+ /**
11
+ * A refresh failure that must NOT flip the account: the grant was provably
12
+ * not consumed (an explicit 429/5xx token-endpoint response, or a typed local
13
+ * preparation failure before any provider request), so a retry is safe.
14
+ * Extends {@link AccountTransportAcquireError} so `acquire()` callers keep
15
+ * their handling unchanged.
16
+ */
17
+ export class RefreshDeferredError extends AccountTransportAcquireError {
18
+ constructor() {
19
+ super('Token refresh deferred; retry later', 'no_active_account');
20
+ }
21
+ }
22
+ // An explicit revocation marker always wins: the grant is dead, retrying is
23
+ // pointless and rotating again could harm the grant family.
24
+ const DEFINITIVE_REFRESH_FAILURE = /invalid_grant|invalidated|revoked|deleted|disabled|unauthorized_client|invalid or missing refresh token/i;
25
+ // Only an explicit token-endpoint error response proves the grant was NOT
26
+ // consumed: HTTP 429/5xx with a response, in either provider shape (the
27
+ // `(status)` form or Claude's `HTTP <status>` form). Transport signals with
28
+ // no response (network, timeout, reset, abort, `fetch failed`) prove nothing:
29
+ // the server may have rotated the grant before the socket died. They stay
30
+ // fail-closed toward reauth, as do post-rotation failures (typed
31
+ // `MusePostRotationError`) and any other 4xx or unknown shape.
32
+ const EXPLICIT_RETRYABLE_STATUS = /\(\s*(429|5\d\d)\s*\)|\bHTTP\s+(429|5\d\d)\b|\b429\b/i;
33
+ function classifyRefreshFailure(error) {
34
+ if (error instanceof MusePostRotationError)
35
+ return 'reauth';
36
+ const message = error instanceof Error ? error.message : String(error);
37
+ if (DEFINITIVE_REFRESH_FAILURE.test(message))
38
+ return 'reauth';
39
+ if (EXPLICIT_RETRYABLE_STATUS.test(message))
40
+ return 'retry';
41
+ return 'reauth';
42
+ }
43
+ /** Typed refusal for exportSeatBundle; the code is `seat-export-unavailable:<reason>`. */
44
+ export class SeatExportUnavailableError extends Error {
45
+ code;
46
+ constructor(reason, detail) {
47
+ super(`seat-export-unavailable:${reason}; ${detail}`);
48
+ this.name = 'SeatExportUnavailableError';
49
+ this.code = `seat-export-unavailable:${reason}`;
50
+ }
51
+ }
7
52
  // Cloud Code does not currently return a per-account model inventory during
8
53
  // enrollment. Advertise only the model proven executable by the live transport
9
54
  // contract; explicit account.modelIds may override this after provider evidence.
@@ -46,16 +91,34 @@ export class LocalAccountTransportService {
46
91
  claimedOwnerScopes = new Map();
47
92
  /** enrollmentId → providerId, so poll completion reaches the owning provider. */
48
93
  enrollmentProviders = new Map();
94
+ /**
95
+ * Last credential issuance per account (enrollment, restore, or refresh).
96
+ * The proactive window is derived from this issuance age, never from the
97
+ * enrollment age: a refreshed credential restarts its own TTL.
98
+ */
99
+ credentialIssuedAtMs = new Map();
49
100
  pendingClaudeAccounts = new Set();
50
101
  claudePersistenceTail = Promise.resolve();
51
102
  claudeSessions = new Map();
52
103
  routeAttemptSequence = 0;
53
- constructor(keyring, providers, configResolver, legacyAccountOwnerScopeRef) {
104
+ /** Account ids already alerted for their current reauth_required epoch. */
105
+ alertedReauth = new Set();
106
+ /** Account ids already warned about an upcoming no-grant expiry (once per enrollment). */
107
+ warnedExpiringNoGrant = new Set();
108
+ custodianTimer = null;
109
+ custodianTickInFlight = false;
110
+ custodianLoopGeneration = 0;
111
+ serviceOptions;
112
+ constructor(keyring, providers, configResolver, legacyAccountOwnerScopeRef, options) {
54
113
  this.keyring = keyring;
55
114
  this.providers = providers;
56
115
  this.configResolver = configResolver;
57
116
  this.legacyAccountOwnerScopeRef = legacyAccountOwnerScopeRef;
58
117
  this.coordinator = new InMemoryAccountTransportCoordinator();
118
+ this.serviceOptions = options ?? {};
119
+ }
120
+ nowMs() {
121
+ return this.serviceOptions.now?.() ?? Date.now();
59
122
  }
60
123
  // ── Enrollment (called via facade by h2a CLI) ──────────────────────────
61
124
  async enroll(providerId, input) {
@@ -394,6 +457,21 @@ export class LocalAccountTransportService {
394
457
  if (!bundle.lifecycle.providerExpiry) {
395
458
  throw new Error('Seat bundle requires providerExpiry; unknown expiry is not infinite validity');
396
459
  }
460
+ // L5: an optional onboarding block is validated and bound against the
461
+ // bundle before any registration; a refusal rejects the whole import
462
+ // with no account persisted.
463
+ let onboarding = null;
464
+ if (input.onboarding !== undefined) {
465
+ const blockResult = validateSeatOnboarding(input.onboarding);
466
+ if (!blockResult.ok) {
467
+ throw new Error(`Seat onboarding refused: ${blockResult.code}; ${blockResult.detail}`);
468
+ }
469
+ const bindingError = this.onboardingBindingError(blockResult.block, bundle);
470
+ if (bindingError) {
471
+ throw new Error(`Seat onboarding refused: onboarding-lineage-mismatch; ${bindingError}`);
472
+ }
473
+ onboarding = blockResult.block;
474
+ }
397
475
  const pair = {
398
476
  agy: ['gemini', 'cloud-code'],
399
477
  codex: ['openai', 'codex'],
@@ -422,6 +500,9 @@ export class LocalAccountTransportService {
422
500
  }
423
501
  const transition = classifyTransition({ epoch: heldAuthority.epoch, generation: heldAuthority.generation, operationId: heldAuthority.operationId }, incoming);
424
502
  if (transition === 'idempotent') {
503
+ // L5: an idempotent reimport of the same tuple backfills a missing
504
+ // seat record (pre-L5 imports gain export by re-presenting the bundle).
505
+ await this.backfillSeatRecordIfMissing(accountId, bundle, onboarding);
425
506
  return { accountId, seatRef, generation: incoming.generation, idempotent: true };
426
507
  }
427
508
  // A forward transition falls through to registration below, which
@@ -466,8 +547,135 @@ export class LocalAccountTransportService {
466
547
  expiresAt: bundle.lifecycle.providerExpiry ?? '',
467
548
  authClientConfigVersion: bundle.identity.credentialFormatVersion,
468
549
  }, account);
550
+ // L5: persist the versioned seat record with the validated values
551
+ // verbatim; a forward transition overwrites the N values with N+1.
552
+ await this.writeSeatRecord(accountId, bundle, onboarding);
469
553
  return { accountId, seatRef, generation: incoming.generation, idempotent: false };
470
554
  }
555
+ /**
556
+ * L5 facade export beside importSeatBundle. Trusted in-process use only.
557
+ * Refuses with a typed `seat-export-unavailable:<reason>` unless the
558
+ * account carries the external seat marker and a seat record whose
559
+ * lineage equals the current marker tuple. On success the stored bundle
560
+ * and block are re-validated (consumer authority, refresh-material scan,
561
+ * block binding) and returned verbatim from the record — no synthesized
562
+ * field, no refresh material, no native files, ever.
563
+ */
564
+ async exportSeatBundle(input) {
565
+ const owner = this.requireOwnerScope(input.ownerScope);
566
+ await this.restorePersistedAccounts();
567
+ const held = this.accountsMap.get(input.accountId);
568
+ const marker = held
569
+ ? readExternalSeatAuthority(held.metadata)
570
+ : null;
571
+ if (!held || !marker || marker.seatRef === 'unknown') {
572
+ throw new SeatExportUnavailableError('not-a-seat', `account '${input.accountId}' carries no external seat marker`);
573
+ }
574
+ if (marker.ownerScope !== owner) {
575
+ throw new SeatExportUnavailableError('owner-mismatch', 'the held seat is owned by another owner scope');
576
+ }
577
+ const record = await this.readSeatRecord(input.accountId);
578
+ if (!record) {
579
+ throw new SeatExportUnavailableError('record-missing', 'no stored seat record; re-present the same bundle to backfill');
580
+ }
581
+ const revalidated = validateSeatBundle(record.bundle, { ownerScope: owner, trustedAuthority: 'consumer' });
582
+ if (!revalidated.ok) {
583
+ throw new SeatExportUnavailableError('record-invalid', `stored bundle refused: ${revalidated.code}; ${revalidated.detail}`);
584
+ }
585
+ const stored = revalidated.bundle;
586
+ if (stored.credentialClass !== 'access-only' && stored.credentialClass !== 'static-key') {
587
+ throw new SeatExportUnavailableError('record-invalid', 'stored bundle must be access-only or static-key');
588
+ }
589
+ if (stored.lifecycle.refreshAuthority !== 'none') {
590
+ throw new SeatExportUnavailableError('record-invalid', 'stored bundle must not carry refresh authority');
591
+ }
592
+ if ((stored.meshUse.kind !== 'access' && stored.meshUse.kind !== 'static-key') || !stored.meshUse.accessRef) {
593
+ throw new SeatExportUnavailableError('record-invalid', 'stored bundle carries no usable access reference');
594
+ }
595
+ if (!stored.lifecycle.providerExpiry) {
596
+ throw new SeatExportUnavailableError('record-invalid', 'stored bundle requires providerExpiry');
597
+ }
598
+ const lineageMatches = stored.identity.seatRef === marker.seatRef
599
+ && stored.lifecycle.epoch === marker.epoch
600
+ && stored.lifecycle.generation === marker.generation
601
+ && stored.lifecycle.operationId === marker.operationId;
602
+ if (!lineageMatches) {
603
+ throw new SeatExportUnavailableError('lineage-mismatch', 'stored record lineage does not equal the held marker tuple');
604
+ }
605
+ if (record.onboarding !== null) {
606
+ const blockResult = validateSeatOnboarding(record.onboarding);
607
+ if (!blockResult.ok) {
608
+ throw new SeatExportUnavailableError('record-invalid', `stored block refused: ${blockResult.code}; ${blockResult.detail}`);
609
+ }
610
+ const bindingError = this.onboardingBindingError(blockResult.block, stored);
611
+ if (bindingError) {
612
+ throw new SeatExportUnavailableError('record-invalid', `stored block misbound: ${bindingError}`);
613
+ }
614
+ }
615
+ return { bundle: record.bundle, onboarding: record.onboarding };
616
+ }
617
+ /** §3.4 binding of an onboarding block against its bundle; null when bound. */
618
+ onboardingBindingError(block, bundle) {
619
+ if (block.cli !== bundle.identity.cli)
620
+ return 'block cli does not match the bundle';
621
+ if (block.seatRef !== bundle.identity.seatRef)
622
+ return 'block seatRef does not match the bundle';
623
+ if (block.accountBinding !== bundle.provenance.accountBinding) {
624
+ return 'block accountBinding does not match the bundle';
625
+ }
626
+ if (block.consent.ownerScope !== bundle.identity.ownerScope) {
627
+ return 'block consent ownerScope does not match the bundle';
628
+ }
629
+ const lifecycle = bundle.lifecycle;
630
+ if (block.lineage.epoch !== lifecycle.epoch
631
+ || block.lineage.generation !== lifecycle.generation
632
+ || block.lineage.operationId !== lifecycle.operationId) {
633
+ return 'block lineage does not match the bundle lifecycle';
634
+ }
635
+ if (block.nativeCliVersion !== bundle.identity.nativeCliVersion) {
636
+ return 'bundle nativeCliVersion must equal the block version';
637
+ }
638
+ return null;
639
+ }
640
+ seatRecordKey(accountId) {
641
+ return `sentropic-llm-mesh:${accountId}:seat-record`;
642
+ }
643
+ async writeSeatRecord(accountId, bundle, onboarding) {
644
+ const record = {
645
+ version: 1,
646
+ bundle,
647
+ onboarding,
648
+ storedAt: new Date().toISOString(),
649
+ };
650
+ await this.keyring.setSecret(this.seatRecordKey(accountId), JSON.stringify(record));
651
+ }
652
+ async readSeatRecord(accountId) {
653
+ const raw = await this.keyring.getSecret(this.seatRecordKey(accountId));
654
+ if (!raw)
655
+ return null;
656
+ try {
657
+ const record = JSON.parse(raw);
658
+ if (record.version !== 1 || !record.bundle || typeof record.storedAt !== 'string')
659
+ return null;
660
+ if (record.onboarding !== null && record.onboarding !== undefined && typeof record.onboarding !== 'object') {
661
+ return null;
662
+ }
663
+ return {
664
+ version: 1,
665
+ bundle: record.bundle,
666
+ onboarding: record.onboarding ?? null,
667
+ storedAt: record.storedAt,
668
+ };
669
+ }
670
+ catch {
671
+ return null;
672
+ }
673
+ }
674
+ async backfillSeatRecordIfMissing(accountId, bundle, onboarding) {
675
+ if (await this.readSeatRecord(accountId))
676
+ return;
677
+ await this.writeSeatRecord(accountId, bundle, onboarding);
678
+ }
471
679
  async hasClaudeGrant(credential, ownerScopeRef) {
472
680
  const index = await this.keyring.getSecret(LocalAccountTransportService.accountIndexKey);
473
681
  for (const accountId of this.parseAccountIndex(index)) {
@@ -535,6 +743,7 @@ export class LocalAccountTransportService {
535
743
  accountId: id, ownerScopeRef, removedAt: this.nextRemovalTimestamp() })).catch(() => { });
536
744
  await this.keyring.deleteSecret(`sentropic-llm-mesh:${id}:public`).catch(() => { });
537
745
  await this.keyring.deleteSecret(`sentropic-llm-mesh:${id}:envelope`).catch(() => { });
746
+ await this.keyring.deleteSecret(this.seatRecordKey(id)).catch(() => { });
538
747
  this.coordinator.removeAccount(id);
539
748
  this.accountsMap.delete(id);
540
749
  this.credentialVersions.delete(id);
@@ -610,9 +819,11 @@ export class LocalAccountTransportService {
610
819
  this.coordinator.removeAccount(accountId);
611
820
  this.accountsMap.delete(accountId);
612
821
  this.credentialVersions.delete(accountId);
822
+ this.credentialIssuedAtMs.delete(accountId);
613
823
  this.accountRemovalBarrierRefs.delete(accountId);
614
824
  await this.keyring.deleteSecret(`sentropic-llm-mesh:${accountId}:envelope`);
615
825
  await this.keyring.deleteSecret(`sentropic-llm-mesh:${accountId}:public`);
826
+ await this.keyring.deleteSecret(this.seatRecordKey(accountId));
616
827
  return { accountId, removed: true };
617
828
  }
618
829
  // ── Account Registration for Coordinator ──────────────────────────────
@@ -620,6 +831,15 @@ export class LocalAccountTransportService {
620
831
  const executableAccount = this.coordinator.addAccount(account);
621
832
  this.accountsMap.set(account.accountId, executableAccount);
622
833
  this.credentialVersions.set(account.accountId, configVersion);
834
+ // A (re-)enrollment opens a fresh alert epoch: the owner has just
835
+ // re-authenticated, so the next outage must alert again. The
836
+ // pre-expiry warning window reopens as well.
837
+ this.alertedReauth.delete(account.accountId);
838
+ this.warnedExpiringNoGrant.delete(account.accountId);
839
+ const enrolledAtMs = account.enrollmentCompletedAt
840
+ ? Date.parse(account.enrollmentCompletedAt)
841
+ : Number.NaN;
842
+ this.credentialIssuedAtMs.set(account.accountId, Number.isFinite(enrolledAtMs) ? enrolledAtMs : this.nowMs());
623
843
  if (removalBarrierRef) {
624
844
  this.accountRemovalBarrierRefs.set(account.accountId, removalBarrierRef);
625
845
  }
@@ -647,7 +867,7 @@ export class LocalAccountTransportService {
647
867
  throw new AccountTransportAcquireError(`Account ${acquiredAccountId} has been removed`, 'no_active_account');
648
868
  }
649
869
  const account = this.accountsMap.get(acquisition.material.accountId ?? '');
650
- const nowMs = typeof input.now === 'number' ? input.now : Date.now();
870
+ const nowMs = typeof input.now === 'number' ? input.now : this.nowMs();
651
871
  // Check if access token is expired
652
872
  // L1 external policy: custodied seats never refresh here and never
653
873
  // serve on unknown expiry. Expired or dateless access becomes
@@ -748,7 +968,7 @@ export class LocalAccountTransportService {
748
968
  const parsedExpiry = typeof rawExpiry === 'string' && rawExpiry
749
969
  ? Date.parse(rawExpiry)
750
970
  : Number.NaN;
751
- const expiredAt = Number.isFinite(parsedExpiry) && parsedExpiry <= Date.now()
971
+ const expiredAt = Number.isFinite(parsedExpiry) && parsedExpiry <= this.nowMs()
752
972
  ? new Date(parsedExpiry).toISOString()
753
973
  : undefined;
754
974
  diagnostics.push({
@@ -767,7 +987,7 @@ export class LocalAccountTransportService {
767
987
  if (!account.expiresAt)
768
988
  return false;
769
989
  const expiresAtMs = Date.parse(account.expiresAt);
770
- return Number.isFinite(expiresAtMs) && expiresAtMs <= Date.now();
990
+ return Number.isFinite(expiresAtMs) && expiresAtMs <= this.nowMs();
771
991
  }
772
992
  /**
773
993
  * True when the account cannot recover on its own: its status already
@@ -911,7 +1131,7 @@ export class LocalAccountTransportService {
911
1131
  if (!account || refreshed.accountId !== input.accountId
912
1132
  || !refreshed.accessToken?.trim() || /[\r\n]/.test(refreshed.accessToken)
913
1133
  || !Number.isFinite(Date.parse(refreshed.expiresAt))
914
- || Date.parse(refreshed.expiresAt) <= Date.now()
1134
+ || Date.parse(refreshed.expiresAt) <= this.nowMs()
915
1135
  || (providerId === 'claude-code' && refreshed.authClientConfigVersion !== input.credentialVersion)
916
1136
  || (refreshed.refreshToken !== undefined && (!refreshed.refreshToken.trim()
917
1137
  || /[\r\n]/.test(refreshed.refreshToken))))
@@ -931,6 +1151,8 @@ export class LocalAccountTransportService {
931
1151
  throw new Error('Account removed');
932
1152
  Object.assign(account, updated);
933
1153
  this.credentialVersions.set(input.accountId, refreshed.authClientConfigVersion);
1154
+ // The refreshed credential restarts its own TTL from this issuance.
1155
+ this.credentialIssuedAtMs.set(input.accountId, this.nowMs());
934
1156
  return { ...refreshed, refreshToken: updated.refreshToken ?? undefined };
935
1157
  }
936
1158
  catch (error) {
@@ -938,12 +1160,20 @@ export class LocalAccountTransportService {
938
1160
  || await this.isAccountRemoved(input.accountId).catch(() => false)) {
939
1161
  throw new AccountTransportAcquireError(`Account ${input.accountId} has been removed`, 'no_active_account');
940
1162
  }
941
- if ((!requestMayHaveBeenSent || error instanceof ClaudeRefreshPreparationError)
1163
+ const failure = error instanceof ClaudeRefreshPreparationError
1164
+ ? 'retry'
1165
+ : classifyRefreshFailure(error);
1166
+ if ((!requestMayHaveBeenSent || failure === 'retry')
942
1167
  && account?.status !== 'reauth_required') {
943
- // A fence write can commit and then throw, but no provider request occurred.
1168
+ // The grant was provably not consumed: either no provider request
1169
+ // went out yet (a fence write can commit and then throw), or the
1170
+ // token endpoint answered with an explicit 429/5xx. Restore the
1171
+ // pre-request fence and defer without flipping. Every other
1172
+ // failure (timeout, reset, mint/persistence after a possible
1173
+ // rotation) falls through to the fail-closed flip below.
944
1174
  if (account)
945
1175
  await this.persistAccountState(account, true).catch(() => { });
946
- throw new AccountTransportAcquireError('Token refresh preparation failed; retry later', 'no_active_account');
1176
+ throw new RefreshDeferredError();
947
1177
  }
948
1178
  await this.markReauthRequired(input.accountId).catch(() => { });
949
1179
  throw new AccountTransportAcquireError('Token refresh failed; reauthentication required', 'no_active_account');
@@ -1161,6 +1391,7 @@ export class LocalAccountTransportService {
1161
1391
  if (!current || current.removalBarrierRef === attemptedBarrierRef) {
1162
1392
  await this.keyring.deleteSecret(`sentropic-llm-mesh:${accountId}:envelope`);
1163
1393
  await this.keyring.deleteSecret(`sentropic-llm-mesh:${accountId}:public`);
1394
+ await this.keyring.deleteSecret(this.seatRecordKey(accountId));
1164
1395
  }
1165
1396
  }
1166
1397
  async readPublicRecord(accountId) {
@@ -1193,6 +1424,7 @@ export class LocalAccountTransportService {
1193
1424
  this.coordinator.removeAccount(accountId);
1194
1425
  this.accountsMap.delete(accountId);
1195
1426
  this.credentialVersions.delete(accountId);
1427
+ this.credentialIssuedAtMs.delete(accountId);
1196
1428
  this.accountRemovalBarrierRefs.delete(accountId);
1197
1429
  }
1198
1430
  const rawIndex = await this.keyring.getSecret(LocalAccountTransportService.accountIndexKey);
@@ -1240,6 +1472,15 @@ export class LocalAccountTransportService {
1240
1472
  refreshToken: envelope.refreshToken,
1241
1473
  expiresAt: envelope.expiresAt,
1242
1474
  }, envelope.authClientConfigVersion, publicRecord.removalBarrierRef);
1475
+ // A restore must not reuse the enrollment age as the TTL origin: the
1476
+ // persisted `updatedAt` is the newest issuance-safe bound the keyring
1477
+ // holds (it only ever moves the window later, never earlier).
1478
+ const restoredIssuedAtMs = typeof publicRecord.updatedAt === 'string'
1479
+ ? Date.parse(publicRecord.updatedAt)
1480
+ : Number.NaN;
1481
+ if (Number.isFinite(restoredIssuedAtMs)) {
1482
+ this.credentialIssuedAtMs.set(accountId, restoredIssuedAtMs);
1483
+ }
1243
1484
  if (ownerClaim && ownerClaim.ownerScopeRef === restoredAccount.ownerScopeRef) {
1244
1485
  this.claimedOwnerScopes.set(accountId, ownerClaim.ownerScopeRef);
1245
1486
  }
@@ -1252,11 +1493,300 @@ export class LocalAccountTransportService {
1252
1493
  }
1253
1494
  }
1254
1495
  }
1255
- async markReauthRequired(accountId) {
1496
+ async markReauthRequired(accountId, reason = 'refresh-failed') {
1256
1497
  const account = this.accountsMap.get(accountId);
1257
1498
  if (account) {
1258
1499
  account.status = 'reauth_required';
1259
- await this.persistAccountState(account);
1500
+ // The in-memory flip is the source of truth; a keyring outage must
1501
+ // not swallow the owner alert.
1502
+ try {
1503
+ await this.persistAccountState(account);
1504
+ }
1505
+ catch {
1506
+ // The persist retries on the next tick; the alert goes out now.
1507
+ }
1508
+ await this.emitReauthAlert(account, reason);
1509
+ }
1510
+ }
1511
+ /** Secret-free custody event for an account (diagnostic ref only). */
1512
+ buildCustodyAlertEvent(account, reason) {
1513
+ const rawExpiry = account.expiresAt;
1514
+ const parsedExpiry = typeof rawExpiry === 'string' && rawExpiry
1515
+ ? Date.parse(rawExpiry)
1516
+ : Number.NaN;
1517
+ return {
1518
+ at: new Date(this.nowMs()).toISOString(),
1519
+ ownerScopeRef: account.ownerScopeRef ?? '',
1520
+ transportProviderId: String(account.transportProviderId),
1521
+ accountRef: this.diagnosticAccountRef(account.accountId),
1522
+ ...(Number.isFinite(parsedExpiry) && parsedExpiry <= this.nowMs()
1523
+ ? { expiredAt: new Date(parsedExpiry).toISOString() }
1524
+ : {}),
1525
+ ...(reason === 'expiring-no-grant' && Number.isFinite(parsedExpiry) && parsedExpiry > this.nowMs()
1526
+ ? { expiresAt: new Date(parsedExpiry).toISOString() }
1527
+ : {}),
1528
+ reason,
1529
+ };
1530
+ }
1531
+ /**
1532
+ * Emit the D4 reauth alert once per reauth_required epoch. The event is
1533
+ * secret-free (diagnostic account ref only); a throwing sink never breaks
1534
+ * the custodian. A fresh `active` status clears the epoch.
1535
+ */
1536
+ async emitReauthAlert(account, reason) {
1537
+ if (this.alertedReauth.has(account.accountId))
1538
+ return;
1539
+ this.alertedReauth.add(account.accountId);
1540
+ const sink = this.serviceOptions.reauthAlert;
1541
+ if (!sink)
1542
+ return;
1543
+ try {
1544
+ await sink.notifyReauthRequired(this.buildCustodyAlertEvent(account, reason));
1545
+ }
1546
+ catch {
1547
+ // Alert delivery never breaks custody; the status persists and the
1548
+ // next cycle retries the alert after clearing a stale epoch entry.
1549
+ this.alertedReauth.delete(account.accountId);
1550
+ }
1551
+ }
1552
+ /**
1553
+ * Warn once per enrollment that a grant-less credential is about to
1554
+ * expire. Re-enrollment is the only rotation path, so the warning opens
1555
+ * the plannable window the expiry flip alone cannot give. It uses its own
1556
+ * epoch: the later `expired-no-grant` flip must still alert.
1557
+ */
1558
+ async emitExpiringNoGrantWarning(account) {
1559
+ if (this.warnedExpiringNoGrant.has(account.accountId))
1560
+ return;
1561
+ this.warnedExpiringNoGrant.add(account.accountId);
1562
+ const sink = this.serviceOptions.reauthAlert;
1563
+ if (!sink)
1564
+ return;
1565
+ try {
1566
+ await sink.notifyReauthRequired(this.buildCustodyAlertEvent(account, 'expiring-no-grant'));
1567
+ }
1568
+ catch {
1569
+ // A throwing sink retries on the next cycle, like the reauth alert.
1570
+ this.warnedExpiringNoGrant.delete(account.accountId);
1571
+ }
1572
+ }
1573
+ /**
1574
+ * Proactive custodian cycle (Lot 1): refresh every mesh-managed account
1575
+ * inside its skew window before expiry, flip `reauth_required` early on
1576
+ * definitive refresh failure and alert (D4). Only provably unconsumed
1577
+ * failures defer without flipping: explicit 429/5xx token-endpoint
1578
+ * responses and typed local preparation failures. Timeouts, resets,
1579
+ * mint/persistence failures after a possible rotation, and unknown shapes
1580
+ * flip fail-closed: the grant may already be consumed.
1581
+ *
1582
+ * Externally custodied seats never refresh here and are skipped; an
1583
+ * unknown or elapsed expiry reads reauth-required (it awaits the next
1584
+ * import) without flipping a status the custodian never owns. A
1585
+ * CLI-imported Muse credential carries no refresh grant yet IS renewable
1586
+ * (the mesh re-reads the CLI store), so it follows the grant path. Static
1587
+ * keys with no refresh grant (Vibe long-lived keys, Muse direct keys)
1588
+ * are skipped: their rotation path is re-enrollment, not refresh. They
1589
+ * warn once inside the refresh window (`expiring-no-grant`, with the
1590
+ * upcoming expiry in the report) and flip on expiry.
1591
+ */
1592
+ async runCustodianRefreshCycle() {
1593
+ await this.restorePersistedAccounts();
1594
+ const now = this.nowMs();
1595
+ const policy = sanitizeCustodianRefreshPolicy(this.serviceOptions.refreshPolicy);
1596
+ const accounts = [];
1597
+ let refreshed = 0;
1598
+ for (const account of [...this.accountsMap.values()]) {
1599
+ const base = {
1600
+ accountRef: this.diagnosticAccountRef(account.accountId),
1601
+ transportProviderId: String(account.transportProviderId),
1602
+ ownerScopeRef: account.ownerScopeRef ?? '',
1603
+ };
1604
+ const expiredAt = typeof account.expiresAt === 'string' && account.expiresAt
1605
+ ? Date.parse(account.expiresAt)
1606
+ : Number.NaN;
1607
+ const expired = Number.isFinite(expiredAt) && expiredAt <= now;
1608
+ if (isExternalSeatAccount(account)) {
1609
+ // Mirrors the route directory: an expired external projection reads
1610
+ // reauth-required (it awaits the next import), without flipping a
1611
+ // status the custodian never owns or alerting the owner. An unknown
1612
+ // expiry is not infinite validity: it reads reauth-required too.
1613
+ const unknownExpiry = !Number.isFinite(expiredAt);
1614
+ accounts.push({ ...base, action: 'skipped-external',
1615
+ readiness: expired || unknownExpiry ? 'reauth-required' : 'ready',
1616
+ ...(expired ? { expiredAt: new Date(expiredAt).toISOString() } : {}) });
1617
+ continue;
1618
+ }
1619
+ if (account.status === 'reauth_required') {
1620
+ const reason = !account.refreshToken && expired
1621
+ ? 'expired-no-grant'
1622
+ : 'refresh-failed';
1623
+ await this.emitReauthAlert(account, reason);
1624
+ accounts.push({ ...base, action: 'already-reauth', readiness: 'reauth-required',
1625
+ ...(expired ? { expiredAt: new Date(expiredAt).toISOString() } : {}) });
1626
+ continue;
1627
+ }
1628
+ // A CLI-imported Muse credential carries no refresh grant, yet it IS
1629
+ // renewable: mesh refresh re-reads the CLI store and re-mints (same
1630
+ // wire as the acquire path). Only direct-billed Muse keys
1631
+ // (`auth_type: 'api_key'`, no CLI store) are truly grant-less.
1632
+ const metadata = account.metadata;
1633
+ const isMuseDirectKey = metadata?.['auth_type'] === 'api_key';
1634
+ const museCliRefreshable = account.transportProviderId === 'muse'
1635
+ && !isMuseDirectKey && !isDeviceFlowSeatAccount(account);
1636
+ if (!account.refreshToken?.trim() && !museCliRefreshable) {
1637
+ // No renewable grant: an expired credential cannot recover on its
1638
+ // own, so it flips to reauth_required and alerts (planned
1639
+ // re-enrollment). An unexpired one stays ready and warns once inside
1640
+ // the refresh window, with the upcoming expiry exposed for planning.
1641
+ if (expired) {
1642
+ await this.markReauthRequired(account.accountId, 'expired-no-grant');
1643
+ accounts.push({ ...base, action: 'expired-no-grant', readiness: 'reauth-required',
1644
+ expiredAt: new Date(expiredAt).toISOString() });
1645
+ continue;
1646
+ }
1647
+ if (!Number.isFinite(expiredAt)) {
1648
+ // Grant-less and dateless: nothing can schedule or heal this
1649
+ // credential; it reads not-ready until re-enrollment records one.
1650
+ accounts.push({ ...base, action: 'skipped-no-expiry', readiness: 'reauth-required' });
1651
+ continue;
1652
+ }
1653
+ const upcomingExpiry = new Date(expiredAt).toISOString();
1654
+ accounts.push({ ...base, action: 'skipped-no-grant', readiness: 'ready',
1655
+ expiresAt: upcomingExpiry });
1656
+ const issuedAtMs = this.credentialIssuedAtMs.get(account.accountId);
1657
+ if (shouldRefreshProactively({ expiresAtMs: expiredAt,
1658
+ ...(issuedAtMs !== undefined && Number.isFinite(issuedAtMs) ? { issuedAtMs } : {}),
1659
+ nowMs: now }, policy)) {
1660
+ await this.emitExpiringNoGrantWarning(account);
1661
+ }
1662
+ continue;
1663
+ }
1664
+ if (!Number.isFinite(expiredAt)) {
1665
+ // The custodian cannot schedule a renewal without an expiry; the
1666
+ // account reads not-ready until a successful acquire-time refresh
1667
+ // records one. No flip: routing still serves the live credential.
1668
+ accounts.push({ ...base, action: 'skipped-no-expiry', readiness: 'reauth-required' });
1669
+ continue;
1670
+ }
1671
+ const issuedAtMs = this.credentialIssuedAtMs.get(account.accountId);
1672
+ if (!shouldRefreshProactively({ expiresAtMs: expiredAt,
1673
+ ...(issuedAtMs !== undefined && Number.isFinite(issuedAtMs) ? { issuedAtMs } : {}), nowMs: now }, policy)) {
1674
+ accounts.push({ ...base, action: 'skipped-healthy', readiness: 'ready' });
1675
+ continue;
1676
+ }
1677
+ if (this.refreshInFlight.has(account.accountId)) {
1678
+ // A refresh is already running for this account; report its pending
1679
+ // state instead of stacking another one or claiming it is healthy.
1680
+ accounts.push({ ...base, action: 'refresh-in-flight', readiness: 'ready' });
1681
+ continue;
1682
+ }
1683
+ try {
1684
+ const version = this.credentialVersions.get(account.accountId) ?? 'v1.0.0';
1685
+ await this.refreshToken({
1686
+ accountId: account.accountId,
1687
+ refreshToken: account.refreshToken ?? undefined,
1688
+ credentialVersion: version,
1689
+ });
1690
+ this.alertedReauth.delete(account.accountId);
1691
+ this.warnedExpiringNoGrant.delete(account.accountId);
1692
+ refreshed += 1;
1693
+ accounts.push({ ...base, action: 'refreshed', readiness: 'ready' });
1694
+ }
1695
+ catch (error) {
1696
+ // A deferred failure never flipped the status and never alerted: the
1697
+ // grant is still live, the next tick retries.
1698
+ if (error instanceof RefreshDeferredError) {
1699
+ accounts.push({ ...base, action: 'refresh-deferred-retry', readiness: 'ready',
1700
+ ...(expired ? { expiredAt: new Date(expiredAt).toISOString() } : {}) });
1701
+ continue;
1702
+ }
1703
+ // refreshToken() already flipped the status and alerted; ensure the
1704
+ // alert epoch is recorded even when the persist raced. The widened
1705
+ // read defeats the early-continue narrowing above, by design.
1706
+ const statusAfterRefresh = account.status;
1707
+ if (statusAfterRefresh === 'reauth_required' && !this.alertedReauth.has(account.accountId)) {
1708
+ await this.emitReauthAlert(account, 'refresh-failed');
1709
+ }
1710
+ accounts.push({ ...base, action: 'refresh-failed-reauth', readiness: 'reauth-required',
1711
+ ...(expired ? { expiredAt: new Date(expiredAt).toISOString() } : {}) });
1712
+ }
1713
+ }
1714
+ return {
1715
+ at: new Date(now).toISOString(),
1716
+ checked: accounts.length,
1717
+ refreshed,
1718
+ reauthRequired: accounts.filter((entry) => entry.readiness === 'reauth-required').length,
1719
+ accounts,
1720
+ };
1721
+ }
1722
+ /**
1723
+ * Start the custodian loop: an immediate cycle, then one every `intervalMs`
1724
+ * with a small random stagger (two replicas started together must not fire
1725
+ * in lockstep on single-use refresh grants). Ticks never overlap; the timer
1726
+ * is unref'd so it cannot hold the process open.
1727
+ *
1728
+ * The host MUST run exactly one loop per owner scope: this library holds no
1729
+ * distributed lock, so two live loops would race on rotating grants
1730
+ * (multi-replica custody belongs to the Lot 2 host). Cycle and
1731
+ * report-listener failures surface through `onError`; without it a dead
1732
+ * keyring would tick silently.
1733
+ *
1734
+ * Returns a stop function; `stopCustodianLoop()` stops it as well.
1735
+ */
1736
+ startCustodianLoop(intervalMs, onReport, onError) {
1737
+ this.stopCustodianLoop();
1738
+ const everyMs = Number.isFinite(intervalMs) && intervalMs > 0
1739
+ ? Math.max(1_000, Math.floor(intervalMs))
1740
+ : 60_000;
1741
+ const generation = (this.custodianLoopGeneration += 1);
1742
+ const live = () => generation === this.custodianLoopGeneration;
1743
+ const tick = async () => {
1744
+ if (this.custodianTickInFlight)
1745
+ return;
1746
+ this.custodianTickInFlight = true;
1747
+ try {
1748
+ const report = await this.runCustodianRefreshCycle();
1749
+ try {
1750
+ onReport?.(report);
1751
+ }
1752
+ catch (error) {
1753
+ onError?.(error);
1754
+ }
1755
+ }
1756
+ catch (error) {
1757
+ // A failed cycle never kills the loop; the next tick retries.
1758
+ onError?.(error);
1759
+ }
1760
+ finally {
1761
+ this.custodianTickInFlight = false;
1762
+ }
1763
+ };
1764
+ const staggerMs = () => everyMs + Math.floor(Math.random() * Math.min(everyMs * 0.1, 30_000));
1765
+ const schedule = (delayMs) => {
1766
+ const timer = setTimeout(() => {
1767
+ if (!live())
1768
+ return;
1769
+ void tick().finally(() => {
1770
+ if (live())
1771
+ schedule(staggerMs());
1772
+ });
1773
+ }, delayMs);
1774
+ timer.unref?.();
1775
+ this.custodianTimer = timer;
1776
+ };
1777
+ // Immediate first cycle: readiness is reported at boot, not after one
1778
+ // idle interval.
1779
+ void tick().finally(() => {
1780
+ if (live())
1781
+ schedule(staggerMs());
1782
+ });
1783
+ return () => this.stopCustodianLoop();
1784
+ }
1785
+ stopCustodianLoop() {
1786
+ this.custodianLoopGeneration += 1;
1787
+ if (this.custodianTimer) {
1788
+ clearTimeout(this.custodianTimer);
1789
+ this.custodianTimer = null;
1260
1790
  }
1261
1791
  }
1262
1792
  }