@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.
- package/dist/enrollment/index.d.ts +4 -0
- package/dist/enrollment/index.d.ts.map +1 -1
- package/dist/enrollment/index.js +2 -0
- package/dist/enrollment/index.js.map +1 -1
- package/dist/enrollment/muse-code.d.ts +9 -0
- package/dist/enrollment/muse-code.d.ts.map +1 -1
- package/dist/enrollment/muse-code.js +22 -1
- package/dist/enrollment/muse-code.js.map +1 -1
- package/dist/enrollment/seat-onboarding-projections.d.ts +44 -0
- package/dist/enrollment/seat-onboarding-projections.d.ts.map +1 -0
- package/dist/enrollment/seat-onboarding-projections.js +113 -0
- package/dist/enrollment/seat-onboarding-projections.js.map +1 -0
- package/dist/enrollment/seat-onboarding.d.ts +161 -0
- package/dist/enrollment/seat-onboarding.d.ts.map +1 -0
- package/dist/enrollment/seat-onboarding.js +698 -0
- package/dist/enrollment/seat-onboarding.js.map +1 -0
- package/dist/node/index.d.ts +8 -0
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +4 -0
- package/dist/node/index.js.map +1 -1
- package/dist/node/seat-onboarding-capture.d.ts +47 -0
- package/dist/node/seat-onboarding-capture.d.ts.map +1 -0
- package/dist/node/seat-onboarding-capture.js +164 -0
- package/dist/node/seat-onboarding-capture.js.map +1 -0
- package/dist/node/seat-onboarding-cell-compose.d.ts +25 -0
- package/dist/node/seat-onboarding-cell-compose.d.ts.map +1 -0
- package/dist/node/seat-onboarding-cell-compose.js +117 -0
- package/dist/node/seat-onboarding-cell-compose.js.map +1 -0
- package/dist/node/seat-onboarding-erase.d.ts +8 -0
- package/dist/node/seat-onboarding-erase.d.ts.map +1 -0
- package/dist/node/seat-onboarding-erase.js +31 -0
- package/dist/node/seat-onboarding-erase.js.map +1 -0
- package/dist/node/seat-onboarding-materialize.d.ts +6 -0
- package/dist/node/seat-onboarding-materialize.d.ts.map +1 -0
- package/dist/node/seat-onboarding-materialize.js +11 -0
- package/dist/node/seat-onboarding-materialize.js.map +1 -0
- package/dist/node/seat-onboarding-record.d.ts +11 -0
- package/dist/node/seat-onboarding-record.d.ts.map +1 -0
- package/dist/node/seat-onboarding-record.js +86 -0
- package/dist/node/seat-onboarding-record.js.map +1 -0
- package/dist/node/seat-onboarding-stage1.d.ts +11 -0
- package/dist/node/seat-onboarding-stage1.d.ts.map +1 -0
- package/dist/node/seat-onboarding-stage1.js +121 -0
- package/dist/node/seat-onboarding-stage1.js.map +1 -0
- package/dist/node/seat-onboarding-stage2.d.ts +10 -0
- package/dist/node/seat-onboarding-stage2.d.ts.map +1 -0
- package/dist/node/seat-onboarding-stage2.js +110 -0
- package/dist/node/seat-onboarding-stage2.js.map +1 -0
- package/dist/node/seat-onboarding-types.d.ts +106 -0
- package/dist/node/seat-onboarding-types.d.ts.map +1 -0
- package/dist/node/seat-onboarding-types.js +6 -0
- package/dist/node/seat-onboarding-types.js.map +1 -0
- package/dist/node/seat-stage-write.d.ts +25 -0
- package/dist/node/seat-stage-write.d.ts.map +1 -0
- package/dist/node/seat-stage-write.js +93 -0
- package/dist/node/seat-stage-write.js.map +1 -0
- package/dist/service/custodian-refresh.d.ts +52 -0
- package/dist/service/custodian-refresh.d.ts.map +1 -0
- package/dist/service/custodian-refresh.js +51 -0
- package/dist/service/custodian-refresh.js.map +1 -0
- package/dist/service/facade.d.ts +28 -0
- package/dist/service/facade.d.ts.map +1 -1
- package/dist/service/facade.js +18 -1
- package/dist/service/facade.js.map +1 -1
- package/dist/service/local-account-transport-service.d.ts +127 -2
- package/dist/service/local-account-transport-service.d.ts.map +1 -1
- package/dist/service/local-account-transport-service.js +540 -10
- package/dist/service/local-account-transport-service.js.map +1 -1
- package/dist/service/reauth-alert.d.ts +29 -0
- package/dist/service/reauth-alert.d.ts.map +1 -0
- package/dist/service/reauth-alert.js +2 -0
- package/dist/service/reauth-alert.js.map +1 -0
- 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
|
-
|
|
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 :
|
|
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 <=
|
|
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 <=
|
|
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) <=
|
|
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
|
-
|
|
1163
|
+
const failure = error instanceof ClaudeRefreshPreparationError
|
|
1164
|
+
? 'retry'
|
|
1165
|
+
: classifyRefreshFailure(error);
|
|
1166
|
+
if ((!requestMayHaveBeenSent || failure === 'retry')
|
|
942
1167
|
&& account?.status !== 'reauth_required') {
|
|
943
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
}
|