@cotal-ai/core 0.48.1 → 0.49.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/agent-file.d.ts +34 -0
- package/dist/agent-file.d.ts.map +1 -1
- package/dist/agent-file.js +94 -2
- package/dist/agent-file.js.map +1 -1
- package/dist/auth-provider.d.ts +25 -1
- package/dist/auth-provider.d.ts.map +1 -1
- package/dist/auth-provider.js.map +1 -1
- package/dist/connector.d.ts +4 -0
- package/dist/connector.d.ts.map +1 -1
- package/dist/connector.js.map +1 -1
- package/dist/endpoint-action.d.ts +1 -0
- package/dist/endpoint-action.d.ts.map +1 -1
- package/dist/endpoint-action.js +1 -1
- package/dist/endpoint-action.js.map +1 -1
- package/dist/endpoint-binding.d.ts +6 -0
- package/dist/endpoint-binding.d.ts.map +1 -1
- package/dist/endpoint-binding.js +17 -0
- package/dist/endpoint-binding.js.map +1 -1
- package/dist/endpoint-grants.d.ts.map +1 -1
- package/dist/endpoint-grants.js +34 -12
- package/dist/endpoint-grants.js.map +1 -1
- package/dist/endpoint-invoke.d.ts.map +1 -1
- package/dist/endpoint-invoke.js +9 -49
- package/dist/endpoint-invoke.js.map +1 -1
- package/dist/endpoint-publish-denial.d.ts +35 -0
- package/dist/endpoint-publish-denial.d.ts.map +1 -0
- package/dist/endpoint-publish-denial.js +56 -0
- package/dist/endpoint-publish-denial.js.map +1 -0
- package/dist/endpoint-serve-kv.d.ts +16 -0
- package/dist/endpoint-serve-kv.d.ts.map +1 -1
- package/dist/endpoint-serve-kv.js +29 -0
- package/dist/endpoint-serve-kv.js.map +1 -1
- package/dist/endpoint-serve.d.ts.map +1 -1
- package/dist/endpoint-serve.js +9 -4
- package/dist/endpoint-serve.js.map +1 -1
- package/dist/endpoint-service.d.ts +21 -0
- package/dist/endpoint-service.d.ts.map +1 -1
- package/dist/endpoint-service.js +150 -15
- package/dist/endpoint-service.js.map +1 -1
- package/dist/endpoint-subjects.d.ts +34 -0
- package/dist/endpoint-subjects.d.ts.map +1 -1
- package/dist/endpoint-subjects.js +87 -18
- package/dist/endpoint-subjects.js.map +1 -1
- package/dist/endpoint-verbs.d.ts +4 -2
- package/dist/endpoint-verbs.d.ts.map +1 -1
- package/dist/endpoint-verbs.js +22 -7
- package/dist/endpoint-verbs.js.map +1 -1
- package/dist/endpoint.d.ts +155 -5
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +528 -48
- package/dist/endpoint.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/issued-authority.d.ts +168 -0
- package/dist/issued-authority.d.ts.map +1 -0
- package/dist/issued-authority.js +497 -0
- package/dist/issued-authority.js.map +1 -0
- package/dist/issuer-session.d.ts +30 -0
- package/dist/issuer-session.d.ts.map +1 -0
- package/dist/issuer-session.js +51 -0
- package/dist/issuer-session.js.map +1 -0
- package/dist/lifecycle-state.d.ts +9 -2
- package/dist/lifecycle-state.d.ts.map +1 -1
- package/dist/lifecycle-state.js +11 -2
- package/dist/lifecycle-state.js.map +1 -1
- package/dist/provision.d.ts +24 -14
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +137 -14
- package/dist/provision.js.map +1 -1
- package/dist/remote-manager-authority.d.ts +126 -1
- package/dist/remote-manager-authority.d.ts.map +1 -1
- package/dist/remote-manager-authority.js +21 -0
- package/dist/remote-manager-authority.js.map +1 -1
- package/dist/run-admission.d.ts +88 -0
- package/dist/run-admission.d.ts.map +1 -0
- package/dist/run-admission.js +193 -0
- package/dist/run-admission.js.map +1 -0
- package/dist/run-driver-grants.d.ts.map +1 -1
- package/dist/run-driver-grants.js +7 -0
- package/dist/run-driver-grants.js.map +1 -1
- package/dist/run-host.d.ts +5 -0
- package/dist/run-host.d.ts.map +1 -1
- package/dist/run-host.js.map +1 -1
- package/dist/runtime.d.ts +31 -3
- package/dist/runtime.d.ts.map +1 -1
- package/dist/secret-fs.d.ts +25 -0
- package/dist/secret-fs.d.ts.map +1 -1
- package/dist/secret-fs.js +136 -3
- package/dist/secret-fs.js.map +1 -1
- package/dist/secret-store.d.ts +38 -0
- package/dist/secret-store.d.ts.map +1 -1
- package/dist/secret-store.js +53 -1
- package/dist/secret-store.js.map +1 -1
- package/dist/streams.d.ts +23 -75
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +65 -85
- package/dist/streams.js.map +1 -1
- package/dist/subjects.d.ts +3 -1
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +3 -1
- package/dist/subjects.js.map +1 -1
- package/package.json +1 -1
package/dist/endpoint.js
CHANGED
|
@@ -5,14 +5,16 @@ import { connect, credsAuthenticator, headers, tokenAuthenticator, nanos, Author
|
|
|
5
5
|
import { wsconnect } from "@nats-io/nats-core";
|
|
6
6
|
import { credsClaims, credsFingerprint, credsRenewalDelayMs, idFromCreds } from "./identity.js";
|
|
7
7
|
import { inspectCredHealth } from "./provision.js";
|
|
8
|
+
import { parseSecretStoreIdentity, } from "./secret-store.js";
|
|
8
9
|
import { resolveService, invokeCommand, submitAndFollowGoal } from "./endpoint-invoke.js";
|
|
9
10
|
import { EpEnvelopeError, respondedButUnbound, replyRefusedBeforeEffect, EP_BIND_REFUSED } from "./endpoint-envelope.js";
|
|
10
11
|
import { isRepeatSafeCommand } from "./endpoint-grants.js";
|
|
11
|
-
import { assertIdToken } from "./endpoint-subjects.js";
|
|
12
|
+
import { assertIdToken, assertGeneration } from "./endpoint-subjects.js";
|
|
13
|
+
import { readAcceptedRow } from "./issued-authority.js";
|
|
12
14
|
import { liveKvEntries } from "./kv-scan.js";
|
|
13
15
|
import { ARTIFACT_PART_KIND, isArtifactPart } from "./artifact.js";
|
|
14
16
|
import { assertValidName } from "./resolve.js";
|
|
15
|
-
import { createSpaceStreams, dmDurableConfig, dlvDurableConfig, taskDurableConfig, fanoutDurableConfig, inboxReaderConfig, MAX_MSGS_PER_SUBJECT, MANAGER_LEASE_TTL_MS, MANAGER_LEASE_ATTEMPT_MS } from "./streams.js";
|
|
17
|
+
import { createSpaceStreams, dmDurableConfig, dlvDurableConfig, taskDurableConfig, fanoutDurableConfig, inboxReaderConfig, MAX_MSGS_PER_SUBJECT, MANAGER_LEASE_TTL_MS, MANAGER_LEASE_ATTEMPT_MS, TTL_RECONCILE_CANARY_KEY } from "./streams.js";
|
|
16
18
|
import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, JetStreamApiCodes, JetStreamApiError, } from "@nats-io/jetstream";
|
|
17
19
|
import {} from "@nats-io/jetstream";
|
|
18
20
|
import { Kvm } from "@nats-io/kv";
|
|
@@ -112,6 +114,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
112
114
|
/** The freshest bearer — what every (re)connect attempt presents. */
|
|
113
115
|
currentBearer;
|
|
114
116
|
bearerTimer;
|
|
117
|
+
/** Arms against the credential authenticated on the current wire. nats-core 3.4.0 discards the
|
|
118
|
+
* promise returned by its async transport-close continuation; if that continuation enters the
|
|
119
|
+
* reconnect dial loop with an expired JWT, its terminal auth error becomes an unhandled rejection.
|
|
120
|
+
* Cotal knows the JWT expiry, so it disables the library reconnect before the broker closes the
|
|
121
|
+
* transport. The endpoint's observed `closed()` supervisor remains responsible for rebuilding. */
|
|
122
|
+
authExpiryReconnectTimer;
|
|
115
123
|
sentinelCreds;
|
|
116
124
|
tls;
|
|
117
125
|
heartbeatMs;
|
|
@@ -202,6 +210,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
202
210
|
firstConnect = true;
|
|
203
211
|
heartbeatTimer;
|
|
204
212
|
sweepTimer;
|
|
213
|
+
/** #1356: when the presence bucket started refusing writes; undefined once one succeeds. */
|
|
214
|
+
presenceWriteFailingSince;
|
|
215
|
+
/** #1356: the broker's last refusal message, kept alongside the start time for diagnosis. */
|
|
216
|
+
lastPresenceWriteError;
|
|
205
217
|
roster = new Map();
|
|
206
218
|
/** Resolves when the current presence watch has consumed its complete initial KV snapshot. */
|
|
207
219
|
presenceSnapshot = Promise.resolve();
|
|
@@ -216,6 +228,18 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
216
228
|
lastPresenceWatchAt = 0;
|
|
217
229
|
/** Last emitted presence-view state. Suppresses duplicate `presence-view` events. */
|
|
218
230
|
presenceViewState = "unpopulated";
|
|
231
|
+
/** A presence-watch rebind in flight (see {@link rebindStalePresenceWatch}); one at a time. */
|
|
232
|
+
presenceRebind;
|
|
233
|
+
/** Bumped by every connection-scoped teardown and by {@link stop}. A presence bind that was
|
|
234
|
+
* awaiting the broker when the epoch moved belongs to a retired epoch: it releases the
|
|
235
|
+
* iterator it got and installs nothing (see {@link startPresenceWatch}). */
|
|
236
|
+
presenceEpoch = 0;
|
|
237
|
+
/** The current watch was bound onto a bucket with NO keys (see {@link markPresenceBucketEmpty}).
|
|
238
|
+
* Such a watch cannot deliver until someone writes, so its silence is not staleness. */
|
|
239
|
+
presenceWatchEmpty = false;
|
|
240
|
+
/** Wall-clock of the last rebind attempt, so a bucket that is silent because it is EMPTY (or a
|
|
241
|
+
* broker that keeps refusing the consumer create) is retried once per TTL, not per sweep tick. */
|
|
242
|
+
presenceRebindAt = 0;
|
|
219
243
|
status = "idle";
|
|
220
244
|
activity;
|
|
221
245
|
/** Mirror of the connector's authoritative attention state, published in presence (advisory). The
|
|
@@ -256,6 +280,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
256
280
|
actorIsEphemeral;
|
|
257
281
|
/** This incarnation's lifecycle UID (opts.lifecycleUid) — see {@link EndpointOptions.lifecycleUid}. */
|
|
258
282
|
ownLifecycleUid;
|
|
283
|
+
acceptedToken;
|
|
284
|
+
/** The issuer-bound generation, learned once per connection from the accepted row. */
|
|
285
|
+
issuedGeneration;
|
|
259
286
|
/** Per-endpoint-name {@link resolveService} cache for {@link invokeService} — dropped on a
|
|
260
287
|
* `failed-precondition` currency refusal (the described incarnation was superseded). */
|
|
261
288
|
resolvedServices = new Map();
|
|
@@ -376,6 +403,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
376
403
|
: this.authed
|
|
377
404
|
? undefined
|
|
378
405
|
: mintLifecycleUid();
|
|
406
|
+
if (opts.acceptedToken !== undefined) {
|
|
407
|
+
if (!opts.creds)
|
|
408
|
+
throw new Error("EndpointOptions.acceptedToken names a static issuance and needs creds beside it (SPEC 13.15)");
|
|
409
|
+
this.acceptedToken = assertGeneration(opts.acceptedToken, "acceptedToken");
|
|
410
|
+
}
|
|
379
411
|
// `card.id` is the principal DOT-FORM `<owner>.<actor>` — the wire identity every `from.id` carries;
|
|
380
412
|
// principalKey validates both tokens.
|
|
381
413
|
const principal = principalKey(this.owner, this.actor);
|
|
@@ -464,6 +496,48 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
464
496
|
/** How soon a FAILED creds refresh retries. Successful refreshes schedule by lifetime fraction
|
|
465
497
|
* (75% of iat→exp), not a fixed margin — standing creds span hours to days, bearers minutes. */
|
|
466
498
|
static CREDS_RETRY_MS = 60_000;
|
|
499
|
+
/** THE ONE PLACE a credential is cleared for presentation to a broker.
|
|
500
|
+
*
|
|
501
|
+
* The property is unconditional — this endpoint never presents a credential it has already
|
|
502
|
+
* decoded as expired — so it is a property of the SUPPLY, not of any one dial site. It used to
|
|
503
|
+
* live inside {@link bindConnection}, which only `start()` and `doRebuild` reach; the
|
|
504
|
+
* authenticator nats.js re-evaluates on ITS OWN reconnects read the cache directly and so
|
|
505
|
+
* presented whatever was last fetched, expired included. Two such reconnects exist and neither
|
|
506
|
+
* passes through bindConnection: the one the broker forces at JWT `exp`, and an ALREADY RUNNING
|
|
507
|
+
* dial loop from an earlier drop that crosses `exp` while it retries (the pre-expiry
|
|
508
|
+
* reconnect fence flips a policy flag nats-core only reads when it observes a NEW drop, so it
|
|
509
|
+
* cannot stop a loop already in flight).
|
|
510
|
+
*
|
|
511
|
+
* Putting the refusal here instead means a future caller cannot miss it: the only way to reach a
|
|
512
|
+
* dial is through {@link credsForWire}, and the one presentation that does not read the cache
|
|
513
|
+
* (the adoption preflight, which presents a fresh CANDIDATE) calls this same function on it.
|
|
514
|
+
*
|
|
515
|
+
* An unbounded credential (no numeric `exp`) is presentable: bounded lifetimes are the renewal
|
|
516
|
+
* seam's concern, and a cred with no expiry has none to be past. */
|
|
517
|
+
static presentableCreds(creds, opts) {
|
|
518
|
+
const { exp } = credsClaims(creds); // throws on a structurally-unusable file (fail-loud)
|
|
519
|
+
if (typeof exp === "number" && exp * 1000 <= Date.now())
|
|
520
|
+
throw new Error(opts.renewable
|
|
521
|
+
? "this endpoint's creds have expired and renewal is failing - not presenting the expired credential to the broker; retrying with backoff"
|
|
522
|
+
: "this endpoint's creds have expired and it holds no creds source to renew them - replace the credential and rebuild the endpoint (pass a creds FUNCTION for standing renewal)");
|
|
523
|
+
return creds;
|
|
524
|
+
}
|
|
525
|
+
/** The cached credential, checked. Handed to nats.js as the authenticator's source on EVERY auth
|
|
526
|
+
* mode (renewed or static), so each (re)connect attempt — ours or the library's — re-reads a
|
|
527
|
+
* CHECKED value. A refusal throws out of the authenticator, which nats-core turns into a closed
|
|
528
|
+
* connection rather than a CONNECT carrying dead material; the endpoint's own supervisor then
|
|
529
|
+
* rebuilds on capped backoff, and {@link bindConnection} re-fetches from the source on each of
|
|
530
|
+
* those attempts, so a renewal that starts working recovers the endpoint without presenting
|
|
531
|
+
* anything expired in the meantime. Deliberately side-effect free: kicking the renewal timer
|
|
532
|
+
* from here would retry the source once per dial attempt, which is the flat load on a dead
|
|
533
|
+
* broker that {@link RETRY_BACKOFF_CAP_MS} exists to prevent. */
|
|
534
|
+
credsForWire() {
|
|
535
|
+
if (!this.currentCreds)
|
|
536
|
+
throw new Error(this.credsSource
|
|
537
|
+
? "this endpoint has no credential to present yet (the creds source has not returned one) - not dialing without auth material"
|
|
538
|
+
: "this endpoint was constructed with an empty creds string - not dialing without auth material (an empty credential is not anonymous access)");
|
|
539
|
+
return CotalEndpoint.presentableCreds(this.currentCreds, { renewable: Boolean(this.credsSource) });
|
|
540
|
+
}
|
|
467
541
|
/** The disposable-preflight connect bound for the EXPLICIT reload proof (D5 class-2 adoption). A
|
|
468
542
|
* rogue or unreachable candidate must resolve well UNDER the manager's delivery-admin request
|
|
469
543
|
* bound, so this stays a few seconds and never blocks the responder. */
|
|
@@ -497,13 +571,62 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
497
571
|
/** Swap the live connection onto the freshest cached cred with a controlled `nc.reconnect()`
|
|
498
572
|
* (nats.js re-evaluates the creds getter per attempt). Swapping now, instead of waiting for the
|
|
499
573
|
* broker to close the connection at `exp`, means the wire never carries a near-dead JWT and the
|
|
500
|
-
* operator never sees a spurious "authentication expired"
|
|
501
|
-
*
|
|
502
|
-
* supervise loop's to own (its rebuild re-fetches); an already-disconnected client is a no-op
|
|
503
|
-
* (its own reconnect loop presents the fresh cred). */
|
|
574
|
+
* operator never sees a spurious "authentication expired". After the reconnect succeeds, re-arm
|
|
575
|
+
* the expiry fence for the credential now authenticated on the wire. */
|
|
504
576
|
async swapConnectionOntoFreshCreds() {
|
|
505
|
-
|
|
506
|
-
|
|
577
|
+
const nc = this.nc;
|
|
578
|
+
if (nc && !this.stopped) {
|
|
579
|
+
await nc.reconnect().catch(() => { });
|
|
580
|
+
if (this.nc === nc && !this.stopped)
|
|
581
|
+
this.armAuthExpiryReconnectFence(nc);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
/** nats-core keeps its reconnect switch on the protocol handler. This pinned internal shape is the
|
|
585
|
+
* same last-resort surface used by {@link closeFailedBind}; there is no public API for changing the
|
|
586
|
+
* reconnect policy of an existing connection. Disabling it does not disable Cotal self-heal: the
|
|
587
|
+
* `nc.closed()` supervisor below rebuilds the endpoint with freshly checked auth material. */
|
|
588
|
+
disableLibraryReconnect(nc) {
|
|
589
|
+
const protocol = nc.protocol;
|
|
590
|
+
if (protocol?.options)
|
|
591
|
+
protocol.options.reconnect = false;
|
|
592
|
+
}
|
|
593
|
+
/** Close a connection whose library reconnect has already been disabled. `drain()` flushes with a
|
|
594
|
+
* PING and waits for the matching PONG; nats-core only rejects that waiter inside reconnect
|
|
595
|
+
* `prepare()`, so a half-open socket with reconnect=false leaves drain pending until the 2-minute
|
|
596
|
+
* ping interval times out. `close()` tears the transport down without that round-trip, which is
|
|
597
|
+
* the same public path {@link closeFailedBind} already uses when there is no graceful delivery
|
|
598
|
+
* contract left. */
|
|
599
|
+
async closeWithoutLibraryReconnect(nc) {
|
|
600
|
+
if (!nc)
|
|
601
|
+
return;
|
|
602
|
+
this.disableLibraryReconnect(nc);
|
|
603
|
+
try {
|
|
604
|
+
await nc.close();
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
/* already closing */
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
/** Disable nats-core reconnect shortly before the JWT authenticated on this wire expires. The small
|
|
611
|
+
* lead makes the policy change precede the broker's expiry close even when both timers wake in the
|
|
612
|
+
* same event-loop turn. A credential adoption does not move this fence until the resident reconnect
|
|
613
|
+
* is requested, so the old wire remains protected during the prove-then-adopt window. */
|
|
614
|
+
armAuthExpiryReconnectFence(nc) {
|
|
615
|
+
clearTimeout(this.authExpiryReconnectTimer);
|
|
616
|
+
this.authExpiryReconnectTimer = undefined;
|
|
617
|
+
const credsExp = this.currentCreds && credsClaims(this.currentCreds).exp;
|
|
618
|
+
const expiryMs = this.userMode && this.currentBearer
|
|
619
|
+
? bearerExpiryMs(this.currentBearer)
|
|
620
|
+
: typeof credsExp === "number"
|
|
621
|
+
? credsExp * 1000
|
|
622
|
+
: undefined;
|
|
623
|
+
if (expiryMs === undefined || this.stopped)
|
|
624
|
+
return;
|
|
625
|
+
this.authExpiryReconnectTimer = setTimeout(() => {
|
|
626
|
+
if (this.nc === nc)
|
|
627
|
+
this.disableLibraryReconnect(nc);
|
|
628
|
+
}, Math.max(0, expiryMs - Date.now() - 500));
|
|
629
|
+
this.authExpiryReconnectTimer.unref?.();
|
|
507
630
|
}
|
|
508
631
|
/** The connectAndBind PRE-CONNECT fetch: pull the freshest source cred and pin it into
|
|
509
632
|
* {@link currentCreds} so the connect() that immediately follows presents it — that connect IS the
|
|
@@ -573,7 +696,13 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
573
696
|
throw new Error("reloadCreds: re-read credential generation did not match the expected re-signed generation (a different store, or a torn/stale read); nothing adopted");
|
|
574
697
|
// PREFLIGHT = the proof. A disposable connection presenting exactly the candidate BEFORE the live
|
|
575
698
|
// cache is touched; a refused cred throws here, leaving the resident connection untouched.
|
|
576
|
-
|
|
699
|
+
// The candidate goes through the SAME checkpoint the resident getter uses: this is the one
|
|
700
|
+
// presentation that does not read the cache, so routing it here is what makes the refusal a
|
|
701
|
+
// property of every path rather than of the cached one. An already-dead re-signed generation is
|
|
702
|
+
// refused locally instead of spending a round trip to be told so, and — because this throws
|
|
703
|
+
// BEFORE the commit below — it can never become the resident connection's next-presented cred.
|
|
704
|
+
const proven = CotalEndpoint.presentableCreds(candidate, { renewable: true });
|
|
705
|
+
const probe = await probeConnect(this.servers, { creds: proven, tls: this.tls, timeoutMs: Math.max(500, Math.min(CotalEndpoint.PREFLIGHT_MS, deadline - Date.now())) });
|
|
577
706
|
if (!probe.ok)
|
|
578
707
|
throw new Error(`reloadCreds: the broker did not accept the re-signed credential (${probe.reason}); nothing adopted`);
|
|
579
708
|
if (Date.now() > deadline)
|
|
@@ -694,11 +823,13 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
694
823
|
throw new Error(this.bearerSource
|
|
695
824
|
? "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"
|
|
696
825
|
: "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)");
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
826
|
+
// The CREDS refusal is NOT repeated here. It lives on the supply itself ({@link credsForWire}),
|
|
827
|
+
// which the authenticator below re-reads per attempt, so it covers this dial AND the reconnects
|
|
828
|
+
// nats.js runs on its own. Raising it early here too would only duplicate it on the one path
|
|
829
|
+
// that was already covered, and a fourth dial site added later would silently miss the copy.
|
|
830
|
+
// The refusal still surfaces on this path: the authenticator throws during the CONNECT, the
|
|
831
|
+
// library closes that attempt, and the reestablish loop's capped backoff re-enters here — where
|
|
832
|
+
// the source re-fetch above is what recovers a renewable endpoint.
|
|
702
833
|
this.nc = await dialerFor(this.servers)({
|
|
703
834
|
servers: this.servers,
|
|
704
835
|
// In USER MODE the connection `name` carries the client-chosen inbox nonce (= connId) the callout
|
|
@@ -714,12 +845,32 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
714
845
|
inboxPrefix: `_INBOX_${this.connId}`,
|
|
715
846
|
// The bearer rides a GETTER: nats.js re-evaluates the token authenticator per (re)connect
|
|
716
847
|
// attempt, so internal reconnects present whatever refreshBearer last fetched.
|
|
717
|
-
// Creds
|
|
718
|
-
// one the broker forces at JWT `exp
|
|
719
|
-
|
|
848
|
+
// Creds ALWAYS ride the CHECKED getter, renewed or static, so every attempt — including the
|
|
849
|
+
// reconnects nats.js performs on its own (the one the broker forces at JWT `exp`, and a dial
|
|
850
|
+
// loop from an earlier drop that crosses `exp` mid-retry) — re-reads a credential that has
|
|
851
|
+
// just been proven unexpired rather than whatever the cache happens to hold.
|
|
852
|
+
// The gate is `!== undefined`, NOT truthiness. An EMPTY creds string is a caller that meant to
|
|
853
|
+
// authenticate and supplied nothing; on a truthiness gate it fell through to `creds: undefined`
|
|
854
|
+
// and dialed ANONYMOUSLY, so the broker answered `Authorization Violation` and the real fault
|
|
855
|
+
// (an empty credential) was never named. Routing it into the checked getter fails it loud
|
|
856
|
+
// instead. Anonymous access stays reachable the only way it should be: by passing no creds.
|
|
857
|
+
...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 }),
|
|
720
858
|
});
|
|
859
|
+
this.armAuthExpiryReconnectFence(this.nc);
|
|
721
860
|
this.watchStatus();
|
|
722
861
|
this.js = jetstream(this.nc);
|
|
862
|
+
// Discovery of the issued generation (SPEC 13.15): the ISSUER wrote the accepted reference
|
|
863
|
+
// under the token this launch was handed; the broker admits the read only under this
|
|
864
|
+
// connection's own per-key grant. What comes back must name this incarnation, and a fresh
|
|
865
|
+
// connection re-reads it: the generation is bound to the credential the transport presented,
|
|
866
|
+
// never to a file that may have been replaced under it.
|
|
867
|
+
if (this.acceptedToken !== undefined) {
|
|
868
|
+
const ref = await readAcceptedRow(this.nc, this.space, this.acceptedToken);
|
|
869
|
+
const uid = this.requireLifecycleUid("an issued endpoint");
|
|
870
|
+
if (ref.owner !== this.owner || ref.actor !== this.actor || ref.uid !== uid)
|
|
871
|
+
throw new Error(`the accepted row names ${ref.owner}.${ref.actor} (uid ${ref.uid}), not this endpoint ${this.owner}.${this.actor} (uid ${uid}); refusing to ride a foreign issuance (SPEC 13.15)`);
|
|
872
|
+
this.issuedGeneration = ref.generation;
|
|
873
|
+
}
|
|
723
874
|
if (this.doWatch || this.doRegister) {
|
|
724
875
|
const kvm = new Kvm(this.nc);
|
|
725
876
|
// The presence bucket is a JetStream stream. Open mode lazily creates it; auth mode
|
|
@@ -783,7 +934,20 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
783
934
|
}
|
|
784
935
|
}
|
|
785
936
|
if (this.doRegister) {
|
|
786
|
-
await
|
|
937
|
+
// #1356: this await is bounded already — a refused presence write returns no response at all,
|
|
938
|
+
// so it surfaces as the JetStream request TIMEOUT (~5s), once, with no retry here. What it did
|
|
939
|
+
// NOT do was say what failed: the bare `timeout` names no bucket, no space and no subsystem,
|
|
940
|
+
// and a launcher that catches it has nothing to act on. The lifecycle-proof failure a few lines
|
|
941
|
+
// above throws a fully explanatory message; this one is held to the same standard. Still fails
|
|
942
|
+
// rather than degrading — SPEC 13.1 fail-before-presence means a registering agent that cannot
|
|
943
|
+
// publish presence must not come up as though it had.
|
|
944
|
+
try {
|
|
945
|
+
await this.publishPresence();
|
|
946
|
+
}
|
|
947
|
+
catch (e) {
|
|
948
|
+
const detail = e?.message ?? String(e);
|
|
949
|
+
throw new Error(`presence registration failed for ${this.card.id}: the space's presence KV bucket "${presenceBucket(this.space)}" did not accept this endpoint's first write (${detail}). The bucket can be OPENED and WATCHED while refusing every write, so a healthy-looking connection does not rule this out; a broker whose store has latched refuses until it is restarted, and no client can clear it (#1356).`);
|
|
950
|
+
}
|
|
787
951
|
this.heartbeatTimer = setInterval(() => {
|
|
788
952
|
this.publishPresence().catch((e) => this.emitRecoverable(e));
|
|
789
953
|
}, this.heartbeatMs);
|
|
@@ -817,6 +981,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
817
981
|
* second heartbeat, double-pump a consumer, or keep stale roster ghosts. Caller-owned
|
|
818
982
|
* subs (tap/serve) are left alone — they aren't rebuilt here. */
|
|
819
983
|
clearConnectionScoped() {
|
|
984
|
+
if (this.authExpiryReconnectTimer) {
|
|
985
|
+
clearTimeout(this.authExpiryReconnectTimer);
|
|
986
|
+
this.authExpiryReconnectTimer = undefined;
|
|
987
|
+
}
|
|
820
988
|
if (this.heartbeatTimer) {
|
|
821
989
|
clearInterval(this.heartbeatTimer);
|
|
822
990
|
this.heartbeatTimer = undefined;
|
|
@@ -834,6 +1002,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
834
1002
|
}
|
|
835
1003
|
}
|
|
836
1004
|
this.streamMsgs.length = 0;
|
|
1005
|
+
this.presenceEpoch++;
|
|
1006
|
+
this.presenceRebind = undefined;
|
|
837
1007
|
try {
|
|
838
1008
|
this.presenceWatchIter?.stop();
|
|
839
1009
|
}
|
|
@@ -860,7 +1030,15 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
860
1030
|
this.chatSubDenied.clear();
|
|
861
1031
|
this.confirmingChatSubs.clear();
|
|
862
1032
|
this.roster.clear();
|
|
1033
|
+
// #1356: the presence-refusal record is connection-scoped like everything else torn down here.
|
|
1034
|
+
// It says "the broker on THIS connection refuses writes to this bucket", so it cannot outlive the
|
|
1035
|
+
// connection that observed it. Left behind, a later failure on a different connection inherits it
|
|
1036
|
+
// and is reported as a bucket refusal — measured: a failed bind against an unreachable server
|
|
1037
|
+
// still printed the refusal sentence while `connectionIssue` already said "connection refused".
|
|
1038
|
+
this.clearPresenceWriteFailure();
|
|
863
1039
|
this.lastPresenceWatchAt = 0;
|
|
1040
|
+
this.presenceRebindAt = 0;
|
|
1041
|
+
this.presenceWatchEmpty = false;
|
|
864
1042
|
this.presenceSnapshotPopulated = false;
|
|
865
1043
|
this.emitPresenceViewIfChanged();
|
|
866
1044
|
this.joinSeq.clear();
|
|
@@ -894,6 +1072,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
894
1072
|
* retry starts with the same empty state as a first attempt. */
|
|
895
1073
|
async closeFailedBind() {
|
|
896
1074
|
const failedNc = this.nc;
|
|
1075
|
+
if (failedNc)
|
|
1076
|
+
this.disableLibraryReconnect(failedNc);
|
|
897
1077
|
this.clearConnectionScoped();
|
|
898
1078
|
this.nc = undefined;
|
|
899
1079
|
this.js = undefined;
|
|
@@ -980,13 +1160,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
980
1160
|
if (!this.stopped)
|
|
981
1161
|
return false;
|
|
982
1162
|
const nc = this.nc;
|
|
1163
|
+
if (nc)
|
|
1164
|
+
this.disableLibraryReconnect(nc);
|
|
983
1165
|
this.clearConnectionScoped();
|
|
984
|
-
|
|
985
|
-
await nc?.drain();
|
|
986
|
-
}
|
|
987
|
-
catch {
|
|
988
|
-
/* already closing */
|
|
989
|
-
}
|
|
1166
|
+
await this.closeWithoutLibraryReconnect(nc);
|
|
990
1167
|
this.nc = undefined;
|
|
991
1168
|
return true;
|
|
992
1169
|
}
|
|
@@ -1028,15 +1205,17 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1028
1205
|
return p;
|
|
1029
1206
|
}
|
|
1030
1207
|
/** The transition: stop the connection-scoped timers FIRST (so nothing live touches
|
|
1031
|
-
* this.nc during the null window), drop the connection refs,
|
|
1208
|
+
* this.nc during the null window), drop the connection refs, close the old nc, then
|
|
1032
1209
|
* rebind + re-arm the supervisor on the fresh connection. clearConnectionScoped is
|
|
1033
1210
|
* idempotent, so connectAndBind's own call here is a noop. */
|
|
1034
1211
|
async doRebuild() {
|
|
1035
1212
|
const oldNc = this.nc;
|
|
1213
|
+
if (oldNc)
|
|
1214
|
+
this.disableLibraryReconnect(oldNc);
|
|
1036
1215
|
this.reconnecting = true;
|
|
1037
1216
|
try {
|
|
1038
1217
|
this.clearConnectionScoped();
|
|
1039
|
-
// Manual reconnect still has a live old epoch: complete broker-consumer cleanup before
|
|
1218
|
+
// Manual reconnect still has a live old epoch: complete broker-consumer cleanup before close.
|
|
1040
1219
|
// Terminal self-heal has an already-closed epoch: disarm retains stream/name for fresh cleanup.
|
|
1041
1220
|
if (oldNc && !oldNc.isClosed())
|
|
1042
1221
|
await Promise.all([...this.membershipFeedWatches].map((watch) => watch.arm));
|
|
@@ -1060,12 +1239,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1060
1239
|
// the authoritative raw-liveness edge for the no-nc window until the new watcher seeds true.
|
|
1061
1240
|
this.emit("transport", { connected: false });
|
|
1062
1241
|
this.emit("connection", { connected: false });
|
|
1063
|
-
|
|
1064
|
-
await oldNc?.drain();
|
|
1065
|
-
}
|
|
1066
|
-
catch {
|
|
1067
|
-
/* already closing */
|
|
1068
|
-
}
|
|
1242
|
+
await this.closeWithoutLibraryReconnect(oldNc);
|
|
1069
1243
|
await this.connectAndBind();
|
|
1070
1244
|
// stop() may have run during the await — don't leave a live connection + heartbeat +
|
|
1071
1245
|
// supervisor on a stopped endpoint. (Reads this.nc in its own scope — a bare `this.nc`
|
|
@@ -1149,10 +1323,17 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1149
1323
|
throw e;
|
|
1150
1324
|
}
|
|
1151
1325
|
}
|
|
1326
|
+
/** The presence epoch moves first: a bind still awaiting the broker must find it moved
|
|
1327
|
+
* before any await below gives it a window to install a watch on a stopped endpoint
|
|
1328
|
+
* (see {@link startPresenceWatch}). */
|
|
1152
1329
|
async stop() {
|
|
1153
1330
|
if (this.stopped)
|
|
1154
1331
|
return;
|
|
1332
|
+
if (this.nc)
|
|
1333
|
+
this.disableLibraryReconnect(this.nc);
|
|
1155
1334
|
this.stopped = true;
|
|
1335
|
+
this.presenceEpoch++;
|
|
1336
|
+
this.presenceRebind = undefined;
|
|
1156
1337
|
// Wake a reestablishLoop sitting in backoff so it sees `stopped` and exits instead of
|
|
1157
1338
|
// sleeping out retryMs; also clears the timer so it can't fire later.
|
|
1158
1339
|
this.kickBackoff();
|
|
@@ -1164,6 +1345,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1164
1345
|
clearTimeout(this.bearerTimer);
|
|
1165
1346
|
if (this.credsTimer)
|
|
1166
1347
|
clearTimeout(this.credsTimer);
|
|
1348
|
+
if (this.authExpiryReconnectTimer)
|
|
1349
|
+
clearTimeout(this.authExpiryReconnectTimer);
|
|
1167
1350
|
for (const watch of this.membershipFeedWatches) {
|
|
1168
1351
|
watch.stopped = true;
|
|
1169
1352
|
watch.arm = watch.arm.catch(() => { }).then(async () => {
|
|
@@ -1215,11 +1398,17 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1215
1398
|
/* best-effort graceful leave */
|
|
1216
1399
|
}
|
|
1217
1400
|
try {
|
|
1218
|
-
await this.nc
|
|
1401
|
+
await this.closeWithoutLibraryReconnect(this.nc);
|
|
1219
1402
|
}
|
|
1220
1403
|
catch {
|
|
1221
1404
|
/* ignore */
|
|
1222
1405
|
}
|
|
1406
|
+
// #1356: stop() does its own teardown and never runs clearConnectionScoped, so clear here too —
|
|
1407
|
+
// AFTER the best-effort offline publish above, which would otherwise re-record a refusal on its
|
|
1408
|
+
// way out. A stopped endpoint has no live basis for "this bucket is refusing writes", and the
|
|
1409
|
+
// duration is computed from `Date.now()`, so a retained record does not merely go stale: it keeps
|
|
1410
|
+
// COUNTING UP for as long as the dead object is held.
|
|
1411
|
+
this.clearPresenceWriteFailure();
|
|
1223
1412
|
}
|
|
1224
1413
|
// ---- messaging -----------------------------------------------------------
|
|
1225
1414
|
/** Multicast: broadcast to everyone on a channel. */
|
|
@@ -1550,7 +1739,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1550
1739
|
* launcher-supplied incarnation the rows are keyed on (ledger-consistent: the §13.1 presence
|
|
1551
1740
|
* lifecycle-proof refuses a divergent uid before any publish). */
|
|
1552
1741
|
serviceCaller() {
|
|
1553
|
-
|
|
1742
|
+
const triple = { owner: this.owner, actor: this.actor, uid: this.requireLifecycleUid("invokeService") };
|
|
1743
|
+
if (this.acceptedToken === undefined)
|
|
1744
|
+
return triple;
|
|
1745
|
+
if (this.issuedGeneration === undefined)
|
|
1746
|
+
throw new Error("invokeService: the issued generation is not yet discovered on this connection");
|
|
1747
|
+
return { ...triple, generation: this.issuedGeneration };
|
|
1554
1748
|
}
|
|
1555
1749
|
/** GENERIC v0.4 service invoke over this endpoint's own connection (P2 item 1, 1c.2b): resolve
|
|
1556
1750
|
* the named endpoint's registered surface — describe, §13.7 store fetch, digest-verified
|
|
@@ -1753,6 +1947,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1753
1947
|
return { state: "current", fresh: true };
|
|
1754
1948
|
if (!this.presenceSnapshotPopulated)
|
|
1755
1949
|
return { state: "unpopulated", fresh: false };
|
|
1950
|
+
if (this.presenceWatchEmpty)
|
|
1951
|
+
return { state: "current", fresh: true };
|
|
1756
1952
|
const staleSince = this.lastPresenceWatchAt + this.ttlMs;
|
|
1757
1953
|
if (Date.now() < staleSince)
|
|
1758
1954
|
return { state: "current", fresh: true };
|
|
@@ -2641,20 +2837,19 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2641
2837
|
delivered++;
|
|
2642
2838
|
if (m.seq >= ceiling) { // reached the page's upper bound
|
|
2643
2839
|
if (m.seq === ceiling) {
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
catch { /* skip undecodable */ }
|
|
2840
|
+
const msg = historyMessageFromDelivery(m);
|
|
2841
|
+
if (msg)
|
|
2842
|
+
out.push({ seq: m.seq, subject: m.subject, msg });
|
|
2648
2843
|
}
|
|
2649
2844
|
complete = true;
|
|
2650
2845
|
break;
|
|
2651
2846
|
}
|
|
2652
|
-
|
|
2653
|
-
|
|
2847
|
+
const msg = historyMessageFromDelivery(m);
|
|
2848
|
+
if (msg) {
|
|
2849
|
+
out.push({ seq: m.seq, subject: m.subject, msg });
|
|
2654
2850
|
if (out.length > limit)
|
|
2655
2851
|
out.shift();
|
|
2656
2852
|
}
|
|
2657
|
-
catch { /* skip undecodable */ }
|
|
2658
2853
|
if (delivered >= pending) {
|
|
2659
2854
|
complete = true;
|
|
2660
2855
|
break;
|
|
@@ -2709,6 +2904,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2709
2904
|
continue;
|
|
2710
2905
|
}
|
|
2711
2906
|
if (s.type === "reconnect") {
|
|
2907
|
+
this.armAuthExpiryReconnectFence(nc);
|
|
2712
2908
|
this.emit("transport", { connected: true, server: s.server });
|
|
2713
2909
|
continue;
|
|
2714
2910
|
}
|
|
@@ -3326,7 +3522,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3326
3522
|
async startPlane3(aclFor, opts = {}) {
|
|
3327
3523
|
if (!this.js)
|
|
3328
3524
|
throw new Error("endpoint not started");
|
|
3329
|
-
this.plane3 = { aclFor, reloadMembershipCreds: opts.reloadMembershipCreds, evictPrincipal: opts.evictPrincipal, planeConnLiveness: opts.planeConnLiveness, principalLiveness: opts.principalLiveness };
|
|
3525
|
+
this.plane3 = { aclFor, reloadMembershipCreds: opts.reloadMembershipCreds, evictPrincipal: opts.evictPrincipal, planeConnLiveness: opts.planeConnLiveness, principalLiveness: opts.principalLiveness, reloadStoreIdentity: opts.reloadStoreIdentity };
|
|
3330
3526
|
await this.armPlane3();
|
|
3331
3527
|
}
|
|
3332
3528
|
/** Serve one runtime durable-membership control request (the server-side delivery daemon). The caller
|
|
@@ -3666,6 +3862,21 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3666
3862
|
return { ok: false, error: e.message };
|
|
3667
3863
|
}
|
|
3668
3864
|
}
|
|
3865
|
+
if (req.op === "reloadStoreIdentity") {
|
|
3866
|
+
// Proof that this daemon reloads standing creds from ONE named store. The manager
|
|
3867
|
+
// compares it to its remint store before every renewal pass. Absent hook
|
|
3868
|
+
// is a daemon that cannot name its store, which is itself a divergent composition.
|
|
3869
|
+
if (!this.plane3?.reloadStoreIdentity)
|
|
3870
|
+
return { ok: false, error: "reloadStoreIdentity: this daemon did not name the SecretStore it reloads from" };
|
|
3871
|
+
try {
|
|
3872
|
+
const identity = this.plane3.reloadStoreIdentity();
|
|
3873
|
+
// Round-trip through the closed parser so a hook cannot smuggle extra fields onto the rail.
|
|
3874
|
+
return { ok: true, data: parseSecretStoreIdentity(identity) };
|
|
3875
|
+
}
|
|
3876
|
+
catch (e) {
|
|
3877
|
+
return { ok: false, error: e.message };
|
|
3878
|
+
}
|
|
3879
|
+
}
|
|
3669
3880
|
return { ok: false, error: `op "${req.op}" not supported on the delivery admin service` };
|
|
3670
3881
|
}
|
|
3671
3882
|
/** Fan-out loop: bind the privileged `fanout` durable on CHAT and route each message (routing only —
|
|
@@ -4605,18 +4816,99 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4605
4816
|
// the publisher — this covers stop(), setStatus("offline"), and any future offline publish site, so
|
|
4606
4817
|
// the raw KV record is compliant, not only the observer-side roster materialization.
|
|
4607
4818
|
const record = this.status === "offline" ? this.toOffline(p) : p;
|
|
4608
|
-
|
|
4819
|
+
// #1356: this put can still be in flight when a teardown runs, because a rebind reaches
|
|
4820
|
+
// publishPresence through onPresenceBucketEmpty and no teardown awaits that flight. Take the same
|
|
4821
|
+
// epoch fence {@link startPresenceWatch} takes, so a put that outlives its epoch still throws to
|
|
4822
|
+
// its caller but no longer writes presence-refusal state belonging to a later connection. Without
|
|
4823
|
+
// it, a heartbeat put (default 2s) whose ~5s JetStream timeout elapses after a rebuild plants a
|
|
4824
|
+
// refusal on the connection that just published successfully.
|
|
4825
|
+
const epoch = this.presenceEpoch;
|
|
4826
|
+
try {
|
|
4827
|
+
await this.kv.put(this.card.id, JSON.stringify(record));
|
|
4828
|
+
}
|
|
4829
|
+
catch (e) {
|
|
4830
|
+
// #1356: a broker can put this bucket into a state where it refuses every write and never
|
|
4831
|
+
// recovers, and the write is the ONLY thing that fails — open and watch both still succeed, so
|
|
4832
|
+
// nothing else here notices. Record WHEN the refusals started, at the one site that knows the
|
|
4833
|
+
// failing write was a presence write; a caller cannot infer that from the generic `warning`
|
|
4834
|
+
// stream, which carries any recoverable error.
|
|
4835
|
+
if (epoch === this.presenceEpoch && !this.stopped) {
|
|
4836
|
+
this.presenceWriteFailingSince ??= Date.now();
|
|
4837
|
+
this.lastPresenceWriteError = e?.message ?? String(e);
|
|
4838
|
+
}
|
|
4839
|
+
throw e;
|
|
4840
|
+
}
|
|
4841
|
+
// A late SUCCESS is the same hazard mirrored, and this fence answers only the cross-epoch half
|
|
4842
|
+
// of it: a success belonging to a retired epoch cannot erase a refusal the current one
|
|
4843
|
+
// established from its own evidence. It does NOT order puts within a single epoch, because it
|
|
4844
|
+
// compares epoch identity rather than which put is the latest evidence, so an earlier put that
|
|
4845
|
+
// succeeds late still clears a later put's refusal. Heartbeats run at 2s against a ~5s put
|
|
4846
|
+
// timeout, so that overlap is routine rather than a corner, and the next failing put re-plants
|
|
4847
|
+
// the record with a fresh `since`. Tracked in #1461, not repaired here.
|
|
4848
|
+
if (epoch !== this.presenceEpoch || this.stopped)
|
|
4849
|
+
return;
|
|
4850
|
+
this.clearPresenceWriteFailure();
|
|
4609
4851
|
}
|
|
4852
|
+
/** #1356: drop the presence-refusal record when the connection that OBSERVED those refusals goes
|
|
4853
|
+
* away. "This bucket is refusing writes" is a claim about a specific broker connection; once that
|
|
4854
|
+
* connection is torn down or rebuilt the claim has no remaining basis, and a later failure on a
|
|
4855
|
+
* fresh connection must establish it again from its own evidence.
|
|
4856
|
+
*
|
|
4857
|
+
* Cleared at the SOURCE rather than guarded at each reader, because a guard protects one consumer
|
|
4858
|
+
* and clearing protects every consumer, including ones not yet written. Measured: without this, a
|
|
4859
|
+
* failed bind against an unreachable server still reported the presence-refusal sentence while the
|
|
4860
|
+
* endpoint's own `connectionIssue` already said "connection refused". */
|
|
4861
|
+
clearPresenceWriteFailure() {
|
|
4862
|
+
this.presenceWriteFailingSince = undefined;
|
|
4863
|
+
this.lastPresenceWriteError = undefined;
|
|
4864
|
+
}
|
|
4865
|
+
/** #1356: the presence bucket has been refusing writes since this time, or `undefined` when the
|
|
4866
|
+
* last publish succeeded. Cleared by the first successful write, so a survived blip reads as
|
|
4867
|
+
* healthy and only a SUSTAINED failure carries a duration.
|
|
4868
|
+
*
|
|
4869
|
+
* Presence writes are the CANARY, not the scope: the broker can disable JetStream account-wide
|
|
4870
|
+
* while the NATS connection stays up, so a caller must not read this as "only presence is
|
|
4871
|
+
* affected". It reports what was observed, not how far the fault extends. */
|
|
4872
|
+
presenceWriteFailure() {
|
|
4873
|
+
if (this.presenceWriteFailingSince === undefined)
|
|
4874
|
+
return undefined;
|
|
4875
|
+
return {
|
|
4876
|
+
since: this.presenceWriteFailingSince,
|
|
4877
|
+
forMs: Date.now() - this.presenceWriteFailingSince,
|
|
4878
|
+
error: this.lastPresenceWriteError,
|
|
4879
|
+
bucket: presenceBucket(this.space),
|
|
4880
|
+
};
|
|
4881
|
+
}
|
|
4882
|
+
/** Bind a presence watch on the current connection. Resolves true when the watch was
|
|
4883
|
+
* installed, false when the endpoint stopped or rebuilt while the bind was in flight: that
|
|
4884
|
+
* bind's iterator is released here and nothing is installed, because the epoch that asked
|
|
4885
|
+
* for it is gone and the epoch that replaced it binds its own watch through
|
|
4886
|
+
* {@link connectAndBind}. Without this fence a bind that completes after {@link stop} would
|
|
4887
|
+
* resurrect a watch on a stopped endpoint, and one that completes after a rebuild would
|
|
4888
|
+
* overwrite the fresh epoch's watch with a dead-connection iterator. */
|
|
4610
4889
|
async startPresenceWatch() {
|
|
4611
4890
|
if (!this.kv)
|
|
4612
|
-
return;
|
|
4891
|
+
return false;
|
|
4892
|
+
const epoch = this.presenceEpoch;
|
|
4613
4893
|
let hydrated;
|
|
4614
4894
|
this.presenceSnapshot = new Promise((resolve) => { hydrated = resolve; });
|
|
4615
4895
|
const iter = await this.kv.watch();
|
|
4896
|
+
if (epoch !== this.presenceEpoch) {
|
|
4897
|
+
try {
|
|
4898
|
+
iter.stop();
|
|
4899
|
+
}
|
|
4900
|
+
catch { /* its connection may already be gone */ }
|
|
4901
|
+
hydrated();
|
|
4902
|
+
return false;
|
|
4903
|
+
}
|
|
4616
4904
|
this.presenceWatchIter = iter;
|
|
4617
4905
|
void (async () => {
|
|
4618
4906
|
let ready = false;
|
|
4619
4907
|
for await (const e of iter) {
|
|
4908
|
+
// A rebind bound a newer watch: this one's remaining entries belong to a retired epoch.
|
|
4909
|
+
// Stop consuming them (the rebind stops the iterator too; this ends the loop first).
|
|
4910
|
+
if (this.presenceWatchIter !== iter)
|
|
4911
|
+
break;
|
|
4620
4912
|
this.handleKvEntry(e);
|
|
4621
4913
|
// @nats-io/kv marks the final initial replay entry isUpdate=true. Later updates stay true.
|
|
4622
4914
|
if (!ready && e.isUpdate) {
|
|
@@ -4628,6 +4920,68 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4628
4920
|
}
|
|
4629
4921
|
hydrated();
|
|
4630
4922
|
})().catch((e) => this.emit("error", e));
|
|
4923
|
+
return true;
|
|
4924
|
+
}
|
|
4925
|
+
/**
|
|
4926
|
+
* Replace a presence watch that has gone silent past TTL while the connection is up. The new
|
|
4927
|
+
* ordered consumer starts from the bucket's current last-per-subject state, so a peer that is
|
|
4928
|
+
* heartbeating is re-observed within one replay and a peer that is gone is aged out by the
|
|
4929
|
+
* next sweep exactly as if the watch had never stalled. Rate-limited to one attempt per TTL
|
|
4930
|
+
* per observer, never overlapping, never on a stopped or rebuilding endpoint (those own their
|
|
4931
|
+
* watch through {@link connectAndBind}). A stop or rebuild that lands while the bind is in
|
|
4932
|
+
* flight retires it: {@link startPresenceWatch} releases the late iterator and reports
|
|
4933
|
+
* nothing, since the epoch that was silent no longer exists. A failed bind is reported and
|
|
4934
|
+
* the view stays stale.
|
|
4935
|
+
*/
|
|
4936
|
+
rebindStalePresenceWatch(now) {
|
|
4937
|
+
if (this.stopped || this.reconnecting || !this.kv || !this.nc || this.nc.isClosed())
|
|
4938
|
+
return;
|
|
4939
|
+
if (this.presenceRebind || now - this.presenceRebindAt < this.ttlMs)
|
|
4940
|
+
return;
|
|
4941
|
+
this.presenceRebindAt = now;
|
|
4942
|
+
const old = this.presenceWatchIter;
|
|
4943
|
+
const epoch = this.presenceEpoch;
|
|
4944
|
+
this.presenceRebind = (async () => {
|
|
4945
|
+
try {
|
|
4946
|
+
const silentMs = now - this.lastPresenceWatchAt;
|
|
4947
|
+
// Bind the replacement FIRST. A rebind the broker refuses (stream gone, create denied) or
|
|
4948
|
+
// one a held link never answers must leave the old watch in place: on a plain stall that
|
|
4949
|
+
// watch is the one that recovers by itself, and its replay is still guarded against
|
|
4950
|
+
// expired PUTs. Only a successfully bound watch retires its predecessor.
|
|
4951
|
+
const installed = await this.startPresenceWatch();
|
|
4952
|
+
// Retired mid-bind (stop or rebuild moved the epoch): the late iterator is already
|
|
4953
|
+
// released and the old watch was torn down by whoever moved the epoch. Nothing to
|
|
4954
|
+
// retire, nothing to report.
|
|
4955
|
+
if (!installed)
|
|
4956
|
+
return;
|
|
4957
|
+
if (old && old !== this.presenceWatchIter) {
|
|
4958
|
+
try {
|
|
4959
|
+
old.stop();
|
|
4960
|
+
}
|
|
4961
|
+
catch { /* already closed with its consumer */ }
|
|
4962
|
+
}
|
|
4963
|
+
// A bucket with no keys replays nothing, so the new watch cannot refresh
|
|
4964
|
+
// `lastPresenceWatchAt` by delivering. It IS current knowledge: nobody is present. Read
|
|
4965
|
+
// the consumer's initial pending count for that one fact; nats.js's KV watch computed it
|
|
4966
|
+
// from the same `info(true)` it used to place the isUpdate marker.
|
|
4967
|
+
const pending = this.presenceWatchIter?._data?._info?.num_pending;
|
|
4968
|
+
if (pending === 0)
|
|
4969
|
+
await this.onPresenceBucketEmpty();
|
|
4970
|
+
this.emit("warning", new Error(`presence watch silent for ${silentMs}ms with the connection up; rebound it from the bucket's current state`));
|
|
4971
|
+
}
|
|
4972
|
+
catch (e) {
|
|
4973
|
+
// A bind the epoch swap itself rejected (connection drained under it) is not a fault of
|
|
4974
|
+
// the epoch that replaced it; only a refusal on a still-current epoch is reported.
|
|
4975
|
+
if (epoch === this.presenceEpoch)
|
|
4976
|
+
this.emit("error", e);
|
|
4977
|
+
}
|
|
4978
|
+
finally {
|
|
4979
|
+
// An epoch swap already disowned this flight (and may own a successor's by now); only
|
|
4980
|
+
// a flight still in its own epoch clears the slot. Within one epoch there is one flight.
|
|
4981
|
+
if (epoch === this.presenceEpoch)
|
|
4982
|
+
this.presenceRebind = undefined;
|
|
4983
|
+
}
|
|
4984
|
+
})();
|
|
4631
4985
|
}
|
|
4632
4986
|
/** Watch the channel registry: replay existing keys, then stream updates, into the local
|
|
4633
4987
|
* cache. Best-effort — a registry the endpoint can't read leaves the cache empty (effective
|
|
@@ -4667,8 +5021,65 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4667
5021
|
/* keep last good */
|
|
4668
5022
|
}
|
|
4669
5023
|
}
|
|
5024
|
+
/** The watch was just bound onto a bucket with no keys.
|
|
5025
|
+
*
|
|
5026
|
+
* A NON-REGISTERING observer (a `cotal status` probe, a lease checker) has real knowledge:
|
|
5027
|
+
* nobody is present. Every peer still in its roster is known gone (its key is not there to
|
|
5028
|
+
* replay), so it is marked offline now rather than aged out against a delivery that cannot
|
|
5029
|
+
* come; the silence gate is disarmed and the view reads current until the first write lands.
|
|
5030
|
+
* Without that the empty-bucket view relapsed to stale one window later and rebound again on
|
|
5031
|
+
* every window, one consumer create and one warning per TTL for as long as the mesh was empty.
|
|
5032
|
+
*
|
|
5033
|
+
* A REGISTERING observer (the manager) is itself one of the keys that should be there. An
|
|
5034
|
+
* empty bucket under it means the bucket was wiped since its last heartbeat (the netcup
|
|
5035
|
+
* recreation), and the same wipe took every peer's record: their absence says the bucket is
|
|
5036
|
+
* new, not that they left. rev-1421-gpt reproduced the previous behaviour at default timing:
|
|
5037
|
+
* the rebind landed ~0.9s after the recreation, the observer marked every peer AND ITSELF
|
|
5038
|
+
* offline, and held the view current for up to one heartbeat, a false verdict `cotal ps`
|
|
5039
|
+
* would print as `mesh offline`. So a registering observer re-publishes its own record NOW,
|
|
5040
|
+
* which the new watch delivers, and lets the ordinary per-peer age-out run from that delivery:
|
|
5041
|
+
* a peer that is still heartbeating rewrites its key within its own heartbeat interval and is
|
|
5042
|
+
* re-observed live; one that is gone ages out exactly as after a plain rebind. The roster is
|
|
5043
|
+
* not touched here and the view is not held; the delivery is what makes it current.
|
|
5044
|
+
*
|
|
5045
|
+
* What neither branch covers: a consumer that dies again while the bucket is still empty is
|
|
5046
|
+
* not detectable by silence, so the first write after that is missed until the observer
|
|
5047
|
+
* restarts. */
|
|
5048
|
+
async onPresenceBucketEmpty() {
|
|
5049
|
+
if (this.doRegister) {
|
|
5050
|
+
// Our own key is missing from a bucket we write to: put it back. publishPresence throws on
|
|
5051
|
+
// a refused write; that surfaces through the rebind flight's error path with the epoch
|
|
5052
|
+
// fence intact, and the view stays stale, which is the honest state for an observer that
|
|
5053
|
+
// cannot even record itself.
|
|
5054
|
+
await this.publishPresence();
|
|
5055
|
+
return;
|
|
5056
|
+
}
|
|
5057
|
+
this.markPresenceBucketEmpty();
|
|
5058
|
+
}
|
|
5059
|
+
/** See {@link onPresenceBucketEmpty}: the non-registering branch. */
|
|
5060
|
+
markPresenceBucketEmpty() {
|
|
5061
|
+
this.presenceWatchEmpty = true;
|
|
5062
|
+
this.lastPresenceWatchAt = 0;
|
|
5063
|
+
let changed = false;
|
|
5064
|
+
for (const [id, p] of this.roster) {
|
|
5065
|
+
if (p.status === "offline")
|
|
5066
|
+
continue;
|
|
5067
|
+
const offline = this.toOffline(p);
|
|
5068
|
+
this.roster.set(id, offline);
|
|
5069
|
+
this.emit("presence", { type: "offline", presence: offline });
|
|
5070
|
+
changed = true;
|
|
5071
|
+
}
|
|
5072
|
+
if (changed)
|
|
5073
|
+
this.emit("roster", this.getRoster());
|
|
5074
|
+
this.emitPresenceViewIfChanged();
|
|
5075
|
+
}
|
|
4670
5076
|
handleKvEntry(e) {
|
|
5077
|
+
// Provisioning uses this one reserved key to prove the bucket's max_age is enforced (#404).
|
|
5078
|
+
// It is maintenance traffic, never a peer identity, an offline transition, or view freshness.
|
|
5079
|
+
if (e.key === TTL_RECONCILE_CANARY_KEY)
|
|
5080
|
+
return;
|
|
4671
5081
|
this.lastPresenceWatchAt = Date.now();
|
|
5082
|
+
this.presenceWatchEmpty = false;
|
|
4672
5083
|
if (e.operation === "DEL" || e.operation === "PURGE") {
|
|
4673
5084
|
this.markOffline(e.key);
|
|
4674
5085
|
return;
|
|
@@ -4774,11 +5185,29 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4774
5185
|
// watch freshness; surface the view as stale instead.
|
|
4775
5186
|
if (this.lastPresenceWatchAt !== 0 && now - this.lastPresenceWatchAt > this.ttlMs) {
|
|
4776
5187
|
this.emitPresenceViewIfChanged();
|
|
5188
|
+
// Staying stale is the right verdict for a held link (#1045), and the wrong END STATE when
|
|
5189
|
+
// the transport is up and the watch's own consumer is what died. Measured on netcup
|
|
5190
|
+
// 2026-09-09: the presence stream was deleted and recreated, its sequence restarted, and
|
|
5191
|
+
// every observer's ORDERED consumer re-created itself at the OLD start sequence (nats.js
|
|
5192
|
+
// 3.4.0 resets from its cursor). The broker kept sending idle heartbeats, so the client
|
|
5193
|
+
// never reset again, the iterator never closed, and the manager's roster stayed frozen at
|
|
5194
|
+
// the pre-recreation snapshot for hours: `cotal ps` read every older seat "mesh offline"
|
|
5195
|
+
// and every newer seat "not in roster" while all of them were heartbeating. The same
|
|
5196
|
+
// end state follows a plain consumer delete (an operator, or the 5-minute inactive
|
|
5197
|
+
// threshold after a long stall). Rebind the watch from the bucket's CURRENT state; a held
|
|
5198
|
+
// link's rebind fails or stays silent and the view simply stays stale, as before.
|
|
5199
|
+
this.rebindStalePresenceWatch(now);
|
|
4777
5200
|
return;
|
|
4778
5201
|
}
|
|
4779
5202
|
let changed = false;
|
|
4780
5203
|
for (const [id, p] of this.roster) {
|
|
4781
|
-
|
|
5204
|
+
// A peer's own `ts` always trails the observer's last delivery, so "older than TTL by the
|
|
5205
|
+
// wall clock" alone ages peers out on the tick just before the whole-bucket gate above
|
|
5206
|
+
// trips (#1311's flap; measured as 3 offline verdicts per silence in the rebind suite).
|
|
5207
|
+
// Require that the watch itself delivered for a full TTL after this peer's last heartbeat:
|
|
5208
|
+
// then other peers were heard and this one was not, which is the only silence that is the
|
|
5209
|
+
// peer's rather than the observer's.
|
|
5210
|
+
if (p.status !== "offline" && now - p.ts > this.ttlMs && this.lastPresenceWatchAt - p.ts > this.ttlMs) {
|
|
4782
5211
|
const offline = this.toOffline(p);
|
|
4783
5212
|
this.roster.set(id, offline);
|
|
4784
5213
|
this.emit("presence", { type: "offline", presence: offline });
|
|
@@ -4817,10 +5246,18 @@ function kindFromParsed(kind) {
|
|
|
4817
5246
|
throw new Error(`cannot derive a message kind from subject kind "${kind}"`);
|
|
4818
5247
|
}
|
|
4819
5248
|
}
|
|
4820
|
-
/** Routing fields in the envelope are advisory.
|
|
4821
|
-
*
|
|
5249
|
+
/** Routing fields in the envelope are advisory. The broker forge-locks sender (and for DMs,
|
|
5250
|
+
* recipient) into the subject. Callers MUST already have rejected a missing `from`, an
|
|
5251
|
+
* unparseable subject, or `from.id !== parsed.sender` (SPEC §5). This derives the remaining
|
|
5252
|
+
* routing tokens from the subject for rows that survived — it does not rewrite a mismatched
|
|
5253
|
+
* `from.id`. Live tails, channel backfill, and channel recall skip the mismatch; history
|
|
5254
|
+
* does the same (#388). */
|
|
4822
5255
|
function authenticatedMessage(msg, parsed) {
|
|
4823
|
-
|
|
5256
|
+
if (parsed.kind === "chat")
|
|
5257
|
+
return authenticatedChannelMessage(msg, parsed.rest);
|
|
5258
|
+
if (parsed.kind === "inst")
|
|
5259
|
+
return authenticatedDmMessage(msg, parsed.rest);
|
|
5260
|
+
return msg;
|
|
4824
5261
|
}
|
|
4825
5262
|
function authenticatedChannelMessage(msg, channel) {
|
|
4826
5263
|
if (msg.channel === channel && msg.to === undefined && msg.toService === undefined)
|
|
@@ -4828,6 +5265,49 @@ function authenticatedChannelMessage(msg, channel) {
|
|
|
4828
5265
|
const { to: _to, toService: _toService, ...base } = msg;
|
|
4829
5266
|
return { ...base, channel };
|
|
4830
5267
|
}
|
|
5268
|
+
function authenticatedDmMessage(msg, to) {
|
|
5269
|
+
if (msg.to === to && msg.channel === undefined && msg.toService === undefined)
|
|
5270
|
+
return msg;
|
|
5271
|
+
const { channel: _channel, toService: _toService, ...base } = msg;
|
|
5272
|
+
return { ...base, to };
|
|
5273
|
+
}
|
|
5274
|
+
/** History drain keeps `m.json()` and used to throw the subject away. SPEC §5: on receive, verify
|
|
5275
|
+
* `from.id` equals the subject sender; on mismatch, a missing `from`, or an unparseable delivery
|
|
5276
|
+
* subject, reject and never surface. Fail closed on shape too: a stored JSON `null` or a truthy
|
|
5277
|
+
* non-object `from` must not throw mid-array. Do not echo-drop `from.id === this.card.id`:
|
|
5278
|
+
* god-view history must include the viewer's own sends. */
|
|
5279
|
+
function historyMessageFromDelivery(m) {
|
|
5280
|
+
let raw;
|
|
5281
|
+
try {
|
|
5282
|
+
raw = m.json();
|
|
5283
|
+
}
|
|
5284
|
+
catch {
|
|
5285
|
+
return undefined;
|
|
5286
|
+
}
|
|
5287
|
+
if (!isHistoryDrainEnvelope(raw))
|
|
5288
|
+
return undefined;
|
|
5289
|
+
const parsed = parseSubject(m.subject);
|
|
5290
|
+
if (!parsed || !isPrincipalOwnerToken(parsed.owner))
|
|
5291
|
+
return undefined;
|
|
5292
|
+
if (raw.from.id !== parsed.sender)
|
|
5293
|
+
return undefined;
|
|
5294
|
+
return authenticatedMessage(raw, parsed);
|
|
5295
|
+
}
|
|
5296
|
+
/**
|
|
5297
|
+
* Narrow enough for the type checker and for fail-closed history: object envelope, usable `id`,
|
|
5298
|
+
* object `from`. That is what lets `from.id !== parsed.sender` run without throwing, and what
|
|
5299
|
+
* lets `authenticatedMessage` take the row without a cast.
|
|
5300
|
+
*
|
|
5301
|
+
* Does NOT verify SPEC §5 message shape. It does not require a string `from.id` (the SPEC §5
|
|
5302
|
+
* comparison still rejects a mismatch), exactly one route key, a finite `ts`, a string
|
|
5303
|
+
* `space`, a full EndpointRef `from` (`name`/`role`), or well-formed `parts`. Those belong
|
|
5304
|
+
* to `isCotalMessage` (Plane-3). History must not use that guard: a public
|
|
5305
|
+
* `unicast(..., { parts: [{ kind: "data", data: undefined }] })` serializes to `{kind:"data"}`
|
|
5306
|
+
* and must still surface.
|
|
5307
|
+
*/
|
|
5308
|
+
function isHistoryDrainEnvelope(value) {
|
|
5309
|
+
return isRecord(value) && isUsableMessageId(value.id) && isRecord(value.from);
|
|
5310
|
+
}
|
|
4831
5311
|
function isPlane3DeliveryFrame(value) {
|
|
4832
5312
|
if (!isRecord(value))
|
|
4833
5313
|
return false;
|