@sentropic/llm-mesh 0.33.0 → 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.
@@ -1,10 +1,45 @@
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';
7
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
+ }
8
43
  /** Typed refusal for exportSeatBundle; the code is `seat-export-unavailable:<reason>`. */
9
44
  export class SeatExportUnavailableError extends Error {
10
45
  code;
@@ -56,16 +91,34 @@ export class LocalAccountTransportService {
56
91
  claimedOwnerScopes = new Map();
57
92
  /** enrollmentId → providerId, so poll completion reaches the owning provider. */
58
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();
59
100
  pendingClaudeAccounts = new Set();
60
101
  claudePersistenceTail = Promise.resolve();
61
102
  claudeSessions = new Map();
62
103
  routeAttemptSequence = 0;
63
- 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) {
64
113
  this.keyring = keyring;
65
114
  this.providers = providers;
66
115
  this.configResolver = configResolver;
67
116
  this.legacyAccountOwnerScopeRef = legacyAccountOwnerScopeRef;
68
117
  this.coordinator = new InMemoryAccountTransportCoordinator();
118
+ this.serviceOptions = options ?? {};
119
+ }
120
+ nowMs() {
121
+ return this.serviceOptions.now?.() ?? Date.now();
69
122
  }
70
123
  // ── Enrollment (called via facade by h2a CLI) ──────────────────────────
71
124
  async enroll(providerId, input) {
@@ -766,6 +819,7 @@ export class LocalAccountTransportService {
766
819
  this.coordinator.removeAccount(accountId);
767
820
  this.accountsMap.delete(accountId);
768
821
  this.credentialVersions.delete(accountId);
822
+ this.credentialIssuedAtMs.delete(accountId);
769
823
  this.accountRemovalBarrierRefs.delete(accountId);
770
824
  await this.keyring.deleteSecret(`sentropic-llm-mesh:${accountId}:envelope`);
771
825
  await this.keyring.deleteSecret(`sentropic-llm-mesh:${accountId}:public`);
@@ -777,6 +831,15 @@ export class LocalAccountTransportService {
777
831
  const executableAccount = this.coordinator.addAccount(account);
778
832
  this.accountsMap.set(account.accountId, executableAccount);
779
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());
780
843
  if (removalBarrierRef) {
781
844
  this.accountRemovalBarrierRefs.set(account.accountId, removalBarrierRef);
782
845
  }
@@ -804,7 +867,7 @@ export class LocalAccountTransportService {
804
867
  throw new AccountTransportAcquireError(`Account ${acquiredAccountId} has been removed`, 'no_active_account');
805
868
  }
806
869
  const account = this.accountsMap.get(acquisition.material.accountId ?? '');
807
- const nowMs = typeof input.now === 'number' ? input.now : Date.now();
870
+ const nowMs = typeof input.now === 'number' ? input.now : this.nowMs();
808
871
  // Check if access token is expired
809
872
  // L1 external policy: custodied seats never refresh here and never
810
873
  // serve on unknown expiry. Expired or dateless access becomes
@@ -905,7 +968,7 @@ export class LocalAccountTransportService {
905
968
  const parsedExpiry = typeof rawExpiry === 'string' && rawExpiry
906
969
  ? Date.parse(rawExpiry)
907
970
  : Number.NaN;
908
- const expiredAt = Number.isFinite(parsedExpiry) && parsedExpiry <= Date.now()
971
+ const expiredAt = Number.isFinite(parsedExpiry) && parsedExpiry <= this.nowMs()
909
972
  ? new Date(parsedExpiry).toISOString()
910
973
  : undefined;
911
974
  diagnostics.push({
@@ -924,7 +987,7 @@ export class LocalAccountTransportService {
924
987
  if (!account.expiresAt)
925
988
  return false;
926
989
  const expiresAtMs = Date.parse(account.expiresAt);
927
- return Number.isFinite(expiresAtMs) && expiresAtMs <= Date.now();
990
+ return Number.isFinite(expiresAtMs) && expiresAtMs <= this.nowMs();
928
991
  }
929
992
  /**
930
993
  * True when the account cannot recover on its own: its status already
@@ -1068,7 +1131,7 @@ export class LocalAccountTransportService {
1068
1131
  if (!account || refreshed.accountId !== input.accountId
1069
1132
  || !refreshed.accessToken?.trim() || /[\r\n]/.test(refreshed.accessToken)
1070
1133
  || !Number.isFinite(Date.parse(refreshed.expiresAt))
1071
- || Date.parse(refreshed.expiresAt) <= Date.now()
1134
+ || Date.parse(refreshed.expiresAt) <= this.nowMs()
1072
1135
  || (providerId === 'claude-code' && refreshed.authClientConfigVersion !== input.credentialVersion)
1073
1136
  || (refreshed.refreshToken !== undefined && (!refreshed.refreshToken.trim()
1074
1137
  || /[\r\n]/.test(refreshed.refreshToken))))
@@ -1088,6 +1151,8 @@ export class LocalAccountTransportService {
1088
1151
  throw new Error('Account removed');
1089
1152
  Object.assign(account, updated);
1090
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());
1091
1156
  return { ...refreshed, refreshToken: updated.refreshToken ?? undefined };
1092
1157
  }
1093
1158
  catch (error) {
@@ -1095,12 +1160,20 @@ export class LocalAccountTransportService {
1095
1160
  || await this.isAccountRemoved(input.accountId).catch(() => false)) {
1096
1161
  throw new AccountTransportAcquireError(`Account ${input.accountId} has been removed`, 'no_active_account');
1097
1162
  }
1098
- if ((!requestMayHaveBeenSent || error instanceof ClaudeRefreshPreparationError)
1163
+ const failure = error instanceof ClaudeRefreshPreparationError
1164
+ ? 'retry'
1165
+ : classifyRefreshFailure(error);
1166
+ if ((!requestMayHaveBeenSent || failure === 'retry')
1099
1167
  && account?.status !== 'reauth_required') {
1100
- // 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.
1101
1174
  if (account)
1102
1175
  await this.persistAccountState(account, true).catch(() => { });
1103
- throw new AccountTransportAcquireError('Token refresh preparation failed; retry later', 'no_active_account');
1176
+ throw new RefreshDeferredError();
1104
1177
  }
1105
1178
  await this.markReauthRequired(input.accountId).catch(() => { });
1106
1179
  throw new AccountTransportAcquireError('Token refresh failed; reauthentication required', 'no_active_account');
@@ -1351,6 +1424,7 @@ export class LocalAccountTransportService {
1351
1424
  this.coordinator.removeAccount(accountId);
1352
1425
  this.accountsMap.delete(accountId);
1353
1426
  this.credentialVersions.delete(accountId);
1427
+ this.credentialIssuedAtMs.delete(accountId);
1354
1428
  this.accountRemovalBarrierRefs.delete(accountId);
1355
1429
  }
1356
1430
  const rawIndex = await this.keyring.getSecret(LocalAccountTransportService.accountIndexKey);
@@ -1398,6 +1472,15 @@ export class LocalAccountTransportService {
1398
1472
  refreshToken: envelope.refreshToken,
1399
1473
  expiresAt: envelope.expiresAt,
1400
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
+ }
1401
1484
  if (ownerClaim && ownerClaim.ownerScopeRef === restoredAccount.ownerScopeRef) {
1402
1485
  this.claimedOwnerScopes.set(accountId, ownerClaim.ownerScopeRef);
1403
1486
  }
@@ -1410,11 +1493,300 @@ export class LocalAccountTransportService {
1410
1493
  }
1411
1494
  }
1412
1495
  }
1413
- async markReauthRequired(accountId) {
1496
+ async markReauthRequired(accountId, reason = 'refresh-failed') {
1414
1497
  const account = this.accountsMap.get(accountId);
1415
1498
  if (account) {
1416
1499
  account.status = 'reauth_required';
1417
- 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;
1418
1790
  }
1419
1791
  }
1420
1792
  }