@cotal-ai/core 0.101.6 → 0.102.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/endpoint.js CHANGED
@@ -7,6 +7,7 @@ import { wsconnect } from "@nats-io/nats-core";
7
7
  import { parseLivenessAnswer, responderFromLease, responderFromProbe, isLivenessPlane, LIVENESS_PLANES, } from "./liveness.js";
8
8
  import { processRuntime } from "./process-runtime.js";
9
9
  import { credsClaims, credsFingerprint, credsRenewalDelayMs, idFromCreds } from "./identity.js";
10
+ import { armRenewal, renewalDeadlineMs, jitteredBackoffMs } from "./renewal.js";
10
11
  import { requireBrokerFloor } from "./broker-floor.js";
11
12
  import { withDeadline } from "./deadline.js";
12
13
  import { inspectCredHealth } from "./provision.js";
@@ -180,7 +181,15 @@ export class CotalEndpoint extends EventEmitter {
180
181
  bearerSource;
181
182
  /** The freshest bearer — what every (re)connect attempt presents. */
182
183
  currentBearer;
183
- bearerTimer;
184
+ /** Cancels the armed bearer renewal or retry. */
185
+ cancelBearerRenewal;
186
+ /** Consecutive failed bearer renewals: the retry's step, and whether this run of failures has
187
+ * already been surfaced. */
188
+ bearerRenewalFailures = 0;
189
+ /** Whether the expired-bearer refusal has been surfaced for this outage. */
190
+ bearerRefusalSurfaced = false;
191
+ /** When the held bearer was adopted: its lifetime's start when it carries no `iat`. */
192
+ bearerAdoptedAtMs = 0;
184
193
  /** Arms against the credential authenticated on the current wire. nats-core 3.4.0 discards the
185
194
  * promise returned by its async transport-close continuation; if that continuation enters the
186
195
  * reconnect dial loop with an expired JWT, its terminal auth error becomes an unhandled rejection.
@@ -659,11 +668,12 @@ export class CotalEndpoint extends EventEmitter {
659
668
  return;
660
669
  this.superviseConnection();
661
670
  }
662
- /** How far ahead of the current bearer's `exp` a refresh fires, and how soon a FAILED refresh
663
- * retries. The margin must clear a reconnect window (nats.js retries use the sync token getter,
664
- * so whatever `currentBearer` holds is what every attempt presents). */
665
- static BEARER_REFRESH_MARGIN_MS = 60_000;
666
- static BEARER_RETRY_MS = 15_000;
671
+ /** The floor on a bearer renewal's delay. A token whose whole lifetime sits under it is renewed at
672
+ * this cadence (`expiry-renewal` mints 5s bearers). */
673
+ static BEARER_MIN_RENEWAL_MS = 5_000;
674
+ /** A failed bearer renewal retries after about this long, doubling per failure up to the cap. */
675
+ static BEARER_RETRY_MS = 5_000;
676
+ static BEARER_RETRY_CAP_MS = 60_000;
667
677
  /**
668
678
  * A condition the endpoint is already surviving. Node rethrows `error` when no listener is
669
679
  * attached, so emitting retry notices on `error` killed hosts that the endpoint intended to
@@ -672,10 +682,11 @@ export class CotalEndpoint extends EventEmitter {
672
682
  emitRecoverable(err) {
673
683
  this.emit("warning", err);
674
684
  }
675
- /** Fetch a fresh bearer from the source, pin its principal to ours, arm the next refresh. On a
685
+ /** Fetch a fresh bearer from the source, pin its principal to ours, arm the next renewal. On a
676
686
  * fetch/principal failure: THROWS when `initial` (start() must fail loud before first connect);
677
- * otherwise emits "warning" and retries — the live connection keeps working until its current JWT
678
- * expiry, so a dead auth service is loud without instantly dropping the mesh. */
687
+ * otherwise retries on a jittered, capped backoff, and warns once for the whole run of failures —
688
+ * the live connection keeps working until its current JWT expiry, so a dead auth service is
689
+ * loud without instantly dropping the mesh, and without a line per attempt. */
679
690
  async refreshBearer(initial = false) {
680
691
  try {
681
692
  const bearer = await this.bearerSource();
@@ -698,11 +709,10 @@ export class CotalEndpoint extends EventEmitter {
698
709
  // {@link presentableCreds} (#1572).
699
710
  if (expiryMs <= Date.now())
700
711
  throw new Error("the bearer source returned a token that has already expired - nothing adopted");
701
- // THE SAME BYTES BACK AGAIN from a source whose token cannot carry the next cycle: the
702
- // delay it arms is non-positive, `armBearerRefresh` floors that to 5s, and the next read
703
- // returns that same token - a 5s loop against the auth service for the rest of its life,
704
- // with BEARER_RETRY_MS bypassed because the fetch did not FAIL. It succeeded and returned
705
- // nothing new (#1561).
712
+ // THE SAME BYTES BACK AGAIN from a source whose token is already past its renewal window:
713
+ // the renewal it arms is floored to 5s, and the next read returns that same token - a 5s
714
+ // loop against the auth service for the rest of its life, with the retry backoff bypassed
715
+ // because the fetch did not FAIL. It succeeded and returned nothing new (#1561).
706
716
  //
707
717
  // The test is byte identity, not expiry, and not advancement. `exp` carries one second of
708
718
  // resolution, so a key rotation re-signing the same claims under a new key - and a healthy
@@ -710,30 +720,63 @@ export class CotalEndpoint extends EventEmitter {
710
720
  // `exp` has not moved. That is genuinely re-issued material, which an advancement test
711
721
  // refuses and this one adopts. It is the question the creds path asks with
712
722
  // `credsFingerprint`: did the source re-issue ANYTHING (#1572).
713
- if (bearer === held && expiryMs - Date.now() - CotalEndpoint.BEARER_REFRESH_MARGIN_MS <= 0)
723
+ if (bearer === held && Date.now() >= bearerRenewalDeadlineMs(held, this.bearerAdoptedAtMs))
714
724
  throw new Error("the bearer source re-served the token already held and it cannot carry another cycle (the auth service has not issued a fresh one) - nothing adopted");
715
725
  }
726
+ if (bearer !== held)
727
+ this.bearerAdoptedAtMs = Date.now();
716
728
  this.currentBearer = bearer;
717
- // A non-positive delay is NOT clamped to BEARER_RETRY_MS. A deployment whose whole token TTL
718
- // sits inside the margin is legitimate - `expiry-renewal` mints 5s bearers against the 60s
719
- // margin - and for it the 5s floor IS the renewal cadence. Backing that source off to 15s
720
- // leaves a 5s token dead for two thirds of every cycle. What made #1561 a pointless loop was
721
- // the source returning nothing new, which is refused above, not the cadence itself.
722
- this.armBearerRefresh(expiryMs - Date.now() - CotalEndpoint.BEARER_REFRESH_MARGIN_MS);
729
+ this.bearerRenewalFailures = 0;
730
+ // The renewal point is drawn from the middle of the token's lifetime, spaced from this
731
+ // process's other renewals in the space: a fixed margin before `exp` kept every endpoint
732
+ // issued in one second renewing, and rebinding through the callout, in one second for life.
733
+ const { issuedAtMs, expiresAtMs } = bearerLifetimeMs(bearer, this.bearerAdoptedAtMs);
734
+ this.armBearerTimer(armRenewal(this.space, issuedAtMs, expiresAtMs, () => void this.renewBearerOnTimer(), CotalEndpoint.BEARER_MIN_RENEWAL_MS));
723
735
  }
724
736
  catch (e) {
725
737
  if (initial)
726
738
  throw e;
727
- this.emitRecoverable(new Error(`bearer refresh failed (${e instanceof Error ? e.message : String(e)}) - retrying; this connection dies at its current token's expiry if the auth service stays down`));
728
- this.armBearerRefresh(CotalEndpoint.BEARER_RETRY_MS);
739
+ if (this.bearerRenewalFailures === 0)
740
+ this.emitRecoverable(new Error(`bearer refresh failed (${e instanceof Error ? e.message : String(e)}) - retrying with backoff; this connection dies at its current token's expiry if the auth service stays down`));
741
+ // A wait the source named is kept as named: never retried inside it, nor stretched by the backoff.
742
+ const named = retryAfterMs(e);
743
+ const delay = named > 0 ? named : jitteredBackoffMs(this.bearerRenewalFailures, CotalEndpoint.BEARER_RETRY_MS, CotalEndpoint.BEARER_RETRY_CAP_MS);
744
+ this.bearerRenewalFailures++;
745
+ const timer = setTimeout(() => void this.renewBearerOnTimer(), delay);
746
+ timer.unref?.();
747
+ this.armBearerTimer(() => clearTimeout(timer));
729
748
  }
730
749
  }
731
- armBearerRefresh(delayMs) {
750
+ armBearerTimer(cancel) {
751
+ this.cancelBearerRenewal?.();
752
+ this.cancelBearerRenewal = cancel;
732
753
  if (this.stopped)
754
+ cancel();
755
+ }
756
+ /** The timed renewal. A new token moves the live connection onto itself at once, through the
757
+ * same controlled reconnect the creds path uses, so the broker never has to expire a connection
758
+ * this endpoint could renew. With no live connection, it ends the rebuild loop's wait instead:
759
+ * that loop refuses to dial an expired token and leaves the retries to this timer. */
760
+ async renewBearerOnTimer() {
761
+ const held = this.currentBearer;
762
+ await this.refreshBearer();
763
+ if (this.stopped || this.currentBearer === held)
733
764
  return;
734
- clearTimeout(this.bearerTimer);
735
- this.bearerTimer = setTimeout(() => void this.refreshBearer(), Math.max(5_000, delayMs));
736
- this.bearerTimer.unref?.();
765
+ if (this.nc && !this.nc.isClosed())
766
+ await this.swapConnectionOntoFreshCreds();
767
+ else
768
+ this.kickBackoff();
769
+ }
770
+ /** The cached bearer, checked: every (re)connect attempt reads it, the reconnects nats.js runs
771
+ * on its own included, so none presents an expired token, which the callout could only deny
772
+ * after a full round trip. The bearer counterpart of {@link credsForWire}. */
773
+ bearerForWire() {
774
+ const bearer = this.currentBearer;
775
+ if (bearer !== undefined && bearerExpiryMs(bearer) > Date.now())
776
+ return bearer;
777
+ throw new Error(this.bearerSource
778
+ ? "this endpoint's user bearer has expired and renewal through the auth exchange is failing - not presenting the expired token to the broker; retrying with backoff"
779
+ : "this endpoint's user bearer has expired and it holds no bearer source to renew it - re-authenticate and rebuild the endpoint (construct it with a bearer FUNCTION for standing renewal)");
737
780
  }
738
781
  /** How soon a FAILED creds refresh retries. Successful refreshes schedule by lifetime fraction
739
782
  * (75% of iat→exp), not a fixed margin — standing creds span hours to days, bearers minutes. */
@@ -780,6 +823,12 @@ export class CotalEndpoint extends EventEmitter {
780
823
  : "this endpoint was constructed with an empty creds string - not dialing without auth material (an empty credential is not anonymous access)");
781
824
  return CotalEndpoint.presentableCreds(this.currentCreds, { renewable: Boolean(this.credsSource) });
782
825
  }
826
+ /** The credential this endpoint presents on its next connect, checked as {@link credsForWire}
827
+ * checks it. A second connection under the same identity reads it on every attempt, so it
828
+ * presents each credential this endpoint adopts and never an expired one. */
829
+ wireCreds() {
830
+ return this.credsForWire();
831
+ }
783
832
  /** Refuses a fetched generation there is nothing to renew FROM, so the caller's failure posture
784
833
  * ({@link CREDS_RETRY_MS}) applies instead of the renewal schedule (issue #1523).
785
834
  *
@@ -850,11 +899,11 @@ export class CotalEndpoint extends EventEmitter {
850
899
  const { iat, exp } = credsClaims(creds);
851
900
  return { iat, exp };
852
901
  }
853
- /** Swap the live connection onto the freshest cached cred with a controlled `nc.reconnect()`
854
- * (nats.js re-evaluates the creds getter per attempt). Swapping now, instead of waiting for the
855
- * broker to close the connection at `exp`, means the wire never carries a near-dead JWT and the
856
- * operator never sees a spurious "authentication expired". After the reconnect succeeds, re-arm
857
- * the expiry fence for the credential now authenticated on the wire. */
902
+ /** Swap the live connection onto the freshest cached cred or bearer with a controlled
903
+ * `nc.reconnect()` (nats.js re-evaluates the authenticator per attempt). Swapping now, instead of
904
+ * waiting for the broker to close the connection at `exp`, means the wire never carries a
905
+ * near-dead JWT and the operator never sees a spurious "authentication expired". After the
906
+ * reconnect succeeds, re-arm the expiry fence for the credential now authenticated on the wire. */
858
907
  async swapConnectionOntoFreshCreds() {
859
908
  const nc = this.nc;
860
909
  if (nc && !this.stopped) {
@@ -1088,14 +1137,15 @@ export class CotalEndpoint extends EventEmitter {
1088
1137
  }
1089
1138
  async bindConnection() {
1090
1139
  this.clearConnectionScoped();
1091
- // Bearer-source endpoints fetch before the FIRST connect, and re-fetch on a rebuild whose
1092
- // cached token is already inside the refresh margin (a rebuild after a long outage would
1093
- // otherwise present a dead bearer for its first attempts).
1140
+ // Bearer-source endpoints fetch before the FIRST connect, and renew first on a rebuild whose
1141
+ // cached token is past its renewal window (a rebuild after a long outage would otherwise
1142
+ // present a dead bearer). While renewals are failing, their retry timer owns the source: a
1143
+ // fetch here too would add one call to the exchange per rebuild attempt.
1094
1144
  if (this.bearerSource) {
1095
- const stale = !this.currentBearer ||
1096
- bearerExpiryMs(this.currentBearer) - Date.now() < CotalEndpoint.BEARER_REFRESH_MARGIN_MS;
1097
- if (stale)
1098
- await this.refreshBearer(!this.currentBearer);
1145
+ if (!this.currentBearer)
1146
+ await this.refreshBearer(true);
1147
+ else if (this.bearerRenewalFailures === 0 && Date.now() >= bearerRenewalDeadlineMs(this.currentBearer, this.bearerAdoptedAtMs))
1148
+ await this.refreshBearer();
1099
1149
  }
1100
1150
  // Creds-source endpoints likewise fetch before the FIRST connect, and re-fetch on a rebuild
1101
1151
  // whose cached cred is expired or inside its renewal window.
@@ -1109,13 +1159,22 @@ export class CotalEndpoint extends EventEmitter {
1109
1159
  // instantly drop a still-live mesh), which used to leave the connect below presenting an
1110
1160
  // EXPIRED bearer: a guaranteed denial that still costs a full auth-callout round trip, repeated
1111
1161
  // by the reestablish loop at a flat rate for as long as the process lives. Refuse here instead.
1112
- // The loop's capped backoff paces the retries and re-enters this method, so a renewal that
1113
- // starts working reconnects on its own; an endpoint with no bearer source cannot renew at all,
1114
- // and says so.
1115
- if (this.userMode && this.currentBearer && bearerExpiryMs(this.currentBearer) <= Date.now())
1116
- throw new Error(this.bearerSource
1117
- ? "this endpoint's user bearer has expired and renewal through the auth exchange is failing - not presenting the expired token to the broker; retrying with backoff"
1118
- : "this endpoint's user bearer has expired and it holds no bearer source to renew it - re-authenticate and rebuild the endpoint (construct it with a bearer FUNCTION for standing renewal)");
1162
+ // The loop's capped backoff paces the retries and re-enters this method, and a renewal that
1163
+ // starts working ends its wait; an endpoint with no bearer source cannot renew at all, and says
1164
+ // so. The refusal is surfaced once per outage: the loop retries a repeat without a notice.
1165
+ if (this.userMode) {
1166
+ try {
1167
+ this.bearerForWire();
1168
+ this.bearerRefusalSurfaced = false;
1169
+ }
1170
+ catch (e) {
1171
+ if (!this.bearerRefusalSurfaced) {
1172
+ this.bearerRefusalSurfaced = true;
1173
+ throw e;
1174
+ }
1175
+ throw new RepeatedRefusal(e.message);
1176
+ }
1177
+ }
1119
1178
  // The CREDS refusal is NOT repeated here. It lives on the supply itself ({@link credsForWire}),
1120
1179
  // which the authenticator below re-reads per attempt, so it covers this dial AND the reconnects
1121
1180
  // nats.js runs on its own. Raising it early here too would only duplicate it on the one path
@@ -1139,8 +1198,9 @@ export class CotalEndpoint extends EventEmitter {
1139
1198
  ...(this.transportPingIntervalMs === undefined ? {} : { pingInterval: this.transportPingIntervalMs }),
1140
1199
  ...(this.transportMaxPingOut === undefined ? {} : { maxPingOut: this.transportMaxPingOut }),
1141
1200
  ...(this.transportConnectTimeoutMs === undefined ? {} : { timeout: this.transportConnectTimeoutMs }),
1142
- // The bearer rides a GETTER: nats.js re-evaluates the token authenticator per (re)connect
1143
- // attempt, so internal reconnects present whatever refreshBearer last fetched.
1201
+ // The bearer rides the CHECKED getter: nats.js re-evaluates the token authenticator per
1202
+ // (re)connect attempt, so internal reconnects present whatever refreshBearer last fetched,
1203
+ // and refuse a token that has expired since, as the creds getter does.
1144
1204
  // Creds ALWAYS ride the CHECKED getter, renewed or static, so every attempt — including the
1145
1205
  // reconnects nats.js performs on its own (the one the broker forces at JWT `exp`, and a dial
1146
1206
  // loop from an earlier drop that crosses `exp` mid-retry) — re-reads a credential that has
@@ -1150,7 +1210,7 @@ export class CotalEndpoint extends EventEmitter {
1150
1210
  // and dialed ANONYMOUSLY, so the broker answered `Authorization Violation` and the real fault
1151
1211
  // (an empty credential) was never named. Routing it into the checked getter fails it loud
1152
1212
  // instead. Anonymous access stays reachable the only way it should be: by passing no creds.
1153
- ...authOpts({ token: this.token, user: this.user, pass: this.pass, creds: this.currentCreds !== undefined || this.credsSource ? () => this.credsForWire() : undefined, bearer: this.userMode ? () => this.currentBearer : undefined, sentinelCreds: this.sentinelCreds, tls: this.tls }),
1213
+ ...authOpts({ token: this.token, user: this.user, pass: this.pass, creds: this.currentCreds !== undefined || this.credsSource ? () => this.credsForWire() : undefined, bearer: this.userMode ? () => this.bearerForWire() : undefined, sentinelCreds: this.sentinelCreds, tls: this.tls }),
1154
1214
  });
1155
1215
  // SPEC §13.12: the control surface requires nats-server >= 2.12; this runs on every
1156
1216
  // fresh connection, including the reconnects the library performs on its own here.
@@ -1609,12 +1669,13 @@ export class CotalEndpoint extends EventEmitter {
1609
1669
  * auth callout. */
1610
1670
  static RETRY_BACKOFF_CAP_MS = 60_000;
1611
1671
  /** The wait before the next rebuild attempt: {@link retryMs}, doubling per consecutive failure up
1612
- * to {@link RETRY_BACKOFF_CAP_MS}. The FIRST retry still waits exactly retryMs, so a transient
1613
- * drop recovers as fast as it always did; only a failure that repeats gets paced. */
1672
+ * to {@link RETRY_BACKOFF_CAP_MS}, then a random half of it taken off. A transient drop still
1673
+ * recovers within retryMs, and endpoints refused together, as a broker restart or an auth limit
1674
+ * refuses a host's agents, spread their retries instead of arriving at the callout at once. */
1614
1675
  nextRetryDelayMs() {
1615
1676
  const delay = Math.min(this.retryMs * 2 ** this.retryAttempt, CotalEndpoint.RETRY_BACKOFF_CAP_MS);
1616
1677
  this.retryAttempt++;
1617
- return delay;
1678
+ return Math.round(delay / 2 + Math.random() * (delay / 2));
1618
1679
  }
1619
1680
  /** Rebuild with backoff until it sticks or we're stopped. Interruptible: a manual
1620
1681
  * {@link reconnect} kicks the backoff so the next attempt runs immediately instead of
@@ -1632,13 +1693,17 @@ export class CotalEndpoint extends EventEmitter {
1632
1693
  return; // success — re-armed; the supervisor re-triggers on the next terminal close
1633
1694
  }
1634
1695
  catch (e) {
1635
- if (!this.stopped)
1636
- this.emitRecoverable(e);
1696
+ const delay = this.nextRetryDelayMs();
1697
+ // The broker never says why the auth callout refused a bearer, so a user-mode refusal is
1698
+ // named as one, with where the reason is, and paced like any other failure.
1699
+ if (!this.stopped && !(e instanceof RepeatedRefusal))
1700
+ this.emitRecoverable(this.userMode && isAuthRejection(e)
1701
+ ? new Error(`the broker refused this endpoint's bearer for ${this.owner}.${this.actor} - retrying in ${Math.ceil(delay / 1000)}s; the space's auth service logs why`, { cause: e })
1702
+ : e);
1637
1703
  // A warning listener may stop the endpoint. A backoff armed after that holds the process
1638
1704
  // open for the whole delay, because the stop has already kicked the backoff it saw.
1639
1705
  if (this.stopped)
1640
1706
  return;
1641
- const delay = this.nextRetryDelayMs();
1642
1707
  await new Promise((resolve) => {
1643
1708
  this.backoffResolve = resolve;
1644
1709
  this.backoffTimer = setTimeout(resolve, delay);
@@ -1700,8 +1765,7 @@ export class CotalEndpoint extends EventEmitter {
1700
1765
  clearInterval(this.heartbeatTimer);
1701
1766
  if (this.sweepTimer)
1702
1767
  clearInterval(this.sweepTimer);
1703
- if (this.bearerTimer)
1704
- clearTimeout(this.bearerTimer);
1768
+ this.cancelBearerRenewal?.();
1705
1769
  if (this.credsTimer)
1706
1770
  clearTimeout(this.credsTimer);
1707
1771
  if (this.authExpiryReconnectTimer)
@@ -2138,6 +2202,12 @@ export class CotalEndpoint extends EventEmitter {
2138
2202
  const m = await this.nc.request(reqSubject, JSON.stringify(body), { timeout: timeoutMs, noMux: true, reply });
2139
2203
  return m.json();
2140
2204
  }
2205
+ /** The caller this connection's endpoint requests ride as: the legacy triple, or the issued
2206
+ * caller with its discovered generation (SPEC 13.15). It names the reply rail this connection's
2207
+ * credential reads, which is where a manager's turn notice for it lands (SPEC 14.3). */
2208
+ serviceRailCaller() {
2209
+ return this.serviceCaller();
2210
+ }
2141
2211
  /** This endpoint's v0.4 caller triple (§13.2) — the identity its minted ep-rail rows pin. The
2142
2212
  * owner/actor principal is mode-correct by construction (static: DEV_OWNER + the connection
2143
2213
  * identity; user mode: the bearer's callout-derived pair), and the lifecycle UID is the
@@ -7469,6 +7539,11 @@ function authOpts(a) {
7469
7539
  /** The bearer's `exp` as epoch ms — what the refresh schedule keys on. A bearer without a numeric
7470
7540
  * `exp` is structurally unusable for a refreshing endpoint (fail-loud, like the principal decode). */
7471
7541
  function bearerExpiryMs(bearer) {
7542
+ return bearerLifetimeMs(bearer, 0).expiresAtMs;
7543
+ }
7544
+ /** The bearer's lifetime as epoch ms: `iat` to `exp`, starting at `adoptedAtMs` when it carries no
7545
+ * `iat`. */
7546
+ function bearerLifetimeMs(bearer, adoptedAtMs) {
7472
7547
  const payload = bearer.split(".")[1];
7473
7548
  if (!payload)
7474
7549
  throw new Error("user-mode bearer is not a JWT (no payload segment)");
@@ -7481,7 +7556,17 @@ function bearerExpiryMs(bearer) {
7481
7556
  }
7482
7557
  if (typeof claims.exp !== "number")
7483
7558
  throw new Error("user-mode bearer is missing a numeric exp claim");
7484
- return claims.exp * 1000;
7559
+ const expiresAtMs = claims.exp * 1000;
7560
+ return { issuedAtMs: Math.min(typeof claims.iat === "number" ? claims.iat * 1000 : adoptedAtMs, expiresAtMs), expiresAtMs };
7561
+ }
7562
+ /** Past this point a held bearer is overdue: its renewal should already have replaced it. */
7563
+ function bearerRenewalDeadlineMs(bearer, adoptedAtMs) {
7564
+ const { issuedAtMs, expiresAtMs } = bearerLifetimeMs(bearer, adoptedAtMs);
7565
+ return renewalDeadlineMs(issuedAtMs, expiresAtMs);
7566
+ }
7567
+ /** A refusal already surfaced once for this outage. The rebuild loop retries it without a notice,
7568
+ * so an endpoint that cannot renew shows one status line rather than one per attempt. */
7569
+ class RepeatedRefusal extends Error {
7485
7570
  }
7486
7571
  /** Decode the owner+actor PRINCIPAL from a user bearer WITHOUT verifying it — the client trusts its own
7487
7572
  * bearer only to build its subjects; the broker's minted grant (from the callout, which DOES verify the
@@ -7741,6 +7826,12 @@ function tcpDialable(server, timeoutMs) {
7741
7826
  export function isAuthRejection(e) {
7742
7827
  return e instanceof AuthorizationError || e instanceof UserAuthenticationExpiredError;
7743
7828
  }
7829
+ /** The wait a credential source asked for with a numeric `retryAfterSeconds` on its error, in
7830
+ * milliseconds, or 0. */
7831
+ function retryAfterMs(e) {
7832
+ const seconds = e?.retryAfterSeconds;
7833
+ return typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 0;
7834
+ }
7744
7835
  /** Whether a NATS server is *running* at `servers`. With NO creds this is a SILENT plaintext
7745
7836
  * liveness check ({@link tcpInfoProbe}): it reads the server's pre-auth `INFO` greeting and closes
7746
7837
  * WITHOUT authenticating, so a live broker (open OR auth — INFO precedes auth) returns true while