@cotal-ai/core 0.17.0 → 0.19.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/acls.d.ts +0 -7
- package/dist/acls.d.ts.map +1 -1
- package/dist/acls.js +10 -7
- package/dist/acls.js.map +1 -1
- package/dist/agui-kind.d.ts +71 -0
- package/dist/agui-kind.d.ts.map +1 -0
- package/dist/agui-kind.js +95 -0
- package/dist/agui-kind.js.map +1 -0
- package/dist/endpoint-action.d.ts +3 -3
- package/dist/endpoint-action.js +4 -4
- package/dist/endpoint-action.js.map +1 -1
- package/dist/endpoint-binding.d.ts +48 -5
- package/dist/endpoint-binding.d.ts.map +1 -1
- package/dist/endpoint-binding.js +82 -6
- package/dist/endpoint-binding.js.map +1 -1
- package/dist/endpoint-cluster.d.ts +20 -0
- package/dist/endpoint-cluster.d.ts.map +1 -1
- package/dist/endpoint-cluster.js +47 -0
- package/dist/endpoint-cluster.js.map +1 -1
- package/dist/endpoint-contract-store.d.ts +6 -0
- package/dist/endpoint-contract-store.d.ts.map +1 -1
- package/dist/endpoint-contract-store.js +21 -2
- package/dist/endpoint-contract-store.js.map +1 -1
- package/dist/endpoint-effects.d.ts +73 -0
- package/dist/endpoint-effects.d.ts.map +1 -0
- package/dist/endpoint-effects.js +46 -0
- package/dist/endpoint-effects.js.map +1 -0
- package/dist/endpoint-envelope.d.ts +32 -1
- package/dist/endpoint-envelope.d.ts.map +1 -1
- package/dist/endpoint-envelope.js +56 -2
- package/dist/endpoint-envelope.js.map +1 -1
- package/dist/endpoint-epname.d.ts +62 -0
- package/dist/endpoint-epname.d.ts.map +1 -0
- package/dist/endpoint-epname.js +273 -0
- package/dist/endpoint-epname.js.map +1 -0
- package/dist/endpoint-error.d.ts +155 -1
- package/dist/endpoint-error.d.ts.map +1 -1
- package/dist/endpoint-error.js +114 -2
- package/dist/endpoint-error.js.map +1 -1
- package/dist/endpoint-goaleff.d.ts +56 -0
- package/dist/endpoint-goaleff.d.ts.map +1 -0
- package/dist/endpoint-goaleff.js +212 -0
- package/dist/endpoint-goaleff.js.map +1 -0
- package/dist/endpoint-grants.d.ts +46 -0
- package/dist/endpoint-grants.d.ts.map +1 -1
- package/dist/endpoint-grants.js +68 -0
- package/dist/endpoint-grants.js.map +1 -1
- package/dist/endpoint-invoke.d.ts +1 -1
- package/dist/endpoint-invoke.d.ts.map +1 -1
- package/dist/endpoint-invoke.js +162 -27
- package/dist/endpoint-invoke.js.map +1 -1
- package/dist/endpoint-journal.d.ts +103 -0
- package/dist/endpoint-journal.d.ts.map +1 -1
- package/dist/endpoint-journal.js +290 -0
- package/dist/endpoint-journal.js.map +1 -1
- package/dist/endpoint-records.d.ts.map +1 -1
- package/dist/endpoint-records.js +37 -0
- package/dist/endpoint-records.js.map +1 -1
- package/dist/endpoint-serve.d.ts.map +1 -1
- package/dist/endpoint-serve.js +45 -1
- package/dist/endpoint-serve.js.map +1 -1
- package/dist/endpoint-subjects.d.ts +10 -0
- package/dist/endpoint-subjects.d.ts.map +1 -1
- package/dist/endpoint-subjects.js +10 -0
- package/dist/endpoint-subjects.js.map +1 -1
- package/dist/endpoint-verbs.d.ts +12 -3
- package/dist/endpoint-verbs.d.ts.map +1 -1
- package/dist/endpoint-verbs.js +113 -19
- package/dist/endpoint-verbs.js.map +1 -1
- package/dist/endpoint.d.ts +170 -6
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +472 -37
- package/dist/endpoint.js.map +1 -1
- package/dist/event-channel.d.ts +107 -0
- package/dist/event-channel.d.ts.map +1 -0
- package/dist/event-channel.js +139 -0
- package/dist/event-channel.js.map +1 -0
- package/dist/evict.d.ts +47 -0
- package/dist/evict.d.ts.map +1 -1
- package/dist/evict.js +191 -59
- package/dist/evict.js.map +1 -1
- package/dist/identity.d.ts +15 -0
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +34 -0
- package/dist/identity.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/membership-feed.d.ts.map +1 -1
- package/dist/membership-feed.js +35 -12
- package/dist/membership-feed.js.map +1 -1
- package/dist/parts.d.ts +29 -5
- package/dist/parts.d.ts.map +1 -1
- package/dist/parts.js +46 -3
- package/dist/parts.js.map +1 -1
- package/dist/provision.d.ts +19 -7
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +75 -14
- package/dist/provision.js.map +1 -1
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +38 -0
- package/dist/resolve.js.map +1 -1
- package/dist/streams.d.ts +142 -3
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +192 -17
- package/dist/streams.js.map +1 -1
- package/dist/subjects.d.ts +49 -15
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +130 -15
- package/dist/subjects.js.map +1 -1
- package/package.json +5 -2
package/dist/endpoint.js
CHANGED
|
@@ -5,11 +5,13 @@ import { connect, credsAuthenticator, headers, tokenAuthenticator, nanos, Author
|
|
|
5
5
|
import { credsClaims, credsFingerprint, credsRenewalDelayMs, idFromCreds } from "./identity.js";
|
|
6
6
|
import { inspectCredHealth } from "./provision.js";
|
|
7
7
|
import { resolveService, invokeCommand, submitAndFollowGoal } from "./endpoint-invoke.js";
|
|
8
|
-
import { EpEnvelopeError } from "./endpoint-envelope.js";
|
|
8
|
+
import { EpEnvelopeError, respondedButUnbound, replyRefusedBeforeEffect, EP_BIND_REFUSED } from "./endpoint-envelope.js";
|
|
9
|
+
import { isRepeatSafeCommand } from "./endpoint-grants.js";
|
|
10
|
+
import { assertIdToken } from "./endpoint-subjects.js";
|
|
9
11
|
import { liveKvEntries } from "./kv-scan.js";
|
|
10
12
|
import { ARTIFACT_PART_KIND, isArtifactPart } from "./artifact.js";
|
|
11
13
|
import { assertValidName } from "./resolve.js";
|
|
12
|
-
import { createSpaceStreams, dmDurableConfig, dlvDurableConfig, taskDurableConfig, fanoutDurableConfig, inboxReaderConfig, MAX_MSGS_PER_SUBJECT, MANAGER_LEASE_TTL_MS } from "./streams.js";
|
|
14
|
+
import { createSpaceStreams, dmDurableConfig, dlvDurableConfig, taskDurableConfig, fanoutDurableConfig, inboxReaderConfig, MAX_MSGS_PER_SUBJECT, MANAGER_LEASE_TTL_MS, MANAGER_LEASE_ATTEMPT_MS } from "./streams.js";
|
|
13
15
|
import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, } from "@nats-io/jetstream";
|
|
14
16
|
import { Kvm } from "@nats-io/kv";
|
|
15
17
|
import { openMembersRegistry, commitMember, tombstoneMember, activateMember, readMember, listMembers, durableEligible, StaleMembershipWrite, } from "./members.js";
|
|
@@ -199,16 +201,34 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
199
201
|
owner;
|
|
200
202
|
/** This endpoint's actor token (principal half 2) — the connection id in the dev default. */
|
|
201
203
|
actor;
|
|
204
|
+
/** True when {@link actor} was SELF-MINTED at construction — a fresh random token, because the
|
|
205
|
+
* card declared no actor and no id and no creds named one. Such a principal differs on every
|
|
206
|
+
* restart, so nothing can be granted to it in advance and nothing durable may be keyed on it.
|
|
207
|
+
* Exposed via {@link actorIsEphemeral} so a caller deriving a per-agent resource name can refuse
|
|
208
|
+
* the mode instead of silently keying on a value that will not survive the process. */
|
|
209
|
+
actorIsEphemeral;
|
|
202
210
|
/** This incarnation's lifecycle UID (opts.lifecycleUid) — see {@link EndpointOptions.lifecycleUid}. */
|
|
203
211
|
ownLifecycleUid;
|
|
204
212
|
/** Per-endpoint-name {@link resolveService} cache for {@link invokeService} — dropped on a
|
|
205
213
|
* `failed-precondition` currency refusal (the described incarnation was superseded). */
|
|
206
214
|
resolvedServices = new Map();
|
|
215
|
+
/** How many calls {@link invokeService} has silently recovered from a bind refusal (§13.2) — the
|
|
216
|
+
* class-queue splits this endpoint hit and survived.
|
|
217
|
+
*
|
|
218
|
+
* Counted because it is recovered: handling the split is what makes it invisible, so this is the
|
|
219
|
+
* only evidence the split rate exists. Always on, never behind a flag — a counter you have to
|
|
220
|
+
* enable is not there when the thing you needed it for happened. */
|
|
221
|
+
splitsRecovered = 0;
|
|
207
222
|
/** This endpoint's wire principal (owner + actor tokens, §13.2) — what its minted grant rows
|
|
208
223
|
* pin. Public so a caller can build owner-mode target blocks for {@link invokeService}. */
|
|
209
224
|
get principal() {
|
|
210
225
|
return { owner: this.owner, actor: this.actor };
|
|
211
226
|
}
|
|
227
|
+
/** Class-queue splits this endpoint has hit and silently survived ({@link splitsRecovered}).
|
|
228
|
+
* Pull it, or listen for `split-recovered` — the event can be missed, the count cannot. */
|
|
229
|
+
get splitRecoveryCount() {
|
|
230
|
+
return this.splitsRecovered;
|
|
231
|
+
}
|
|
212
232
|
/** The endpoint's own lifecycle UID, REQUIRED for every lifecycle-keyed messaging resource; absent
|
|
213
233
|
* ⇒ loud refusal naming the operation (the hard cut of SPEC §13.1 — no alias-keyed fallback). */
|
|
214
234
|
requireLifecycleUid(what) {
|
|
@@ -218,6 +238,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
218
238
|
}
|
|
219
239
|
constructor(opts) {
|
|
220
240
|
super();
|
|
241
|
+
/** Did the dev/static branch fall through to a random connId? Set on every path that assigns
|
|
242
|
+
* `connId` so the ephemeral verdict below can never read an unassigned value. */
|
|
243
|
+
let selfMintedConnId = false;
|
|
221
244
|
this.space = opts.space;
|
|
222
245
|
// A display name is the client-side handle a peer is addressed by; reject the reserved `/`
|
|
223
246
|
// (the future owner/name separator) and surrounding whitespace at the one identity choke
|
|
@@ -258,6 +281,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
258
281
|
}
|
|
259
282
|
this.connId = assertInboxConnId(`ibx${randomUUID().replace(/-/g, "")}`);
|
|
260
283
|
this.sentinelCreds = opts.sentinelCreds;
|
|
284
|
+
// User mode's actor is SERVER-AUTHORED (bearer claims or a declared card checked against
|
|
285
|
+
// them), never self-minted — the ephemeral value here is the inbox nonce, which is the
|
|
286
|
+
// connection id and not the principal.
|
|
287
|
+
this.actorIsEphemeral = false;
|
|
261
288
|
}
|
|
262
289
|
else {
|
|
263
290
|
// DEV / STATIC. Connection identity precedence: an explicit card.id, else the creds' identity, else
|
|
@@ -272,16 +299,22 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
272
299
|
throw new Error("a creds source requires an explicit card.id (no cred to derive the identity from at construction)");
|
|
273
300
|
this.credsSource = opts.creds;
|
|
274
301
|
this.connId = opts.card.id;
|
|
302
|
+
selfMintedConnId = false;
|
|
275
303
|
}
|
|
276
304
|
else {
|
|
277
305
|
const credId = opts.creds ? idFromCreds(opts.creds) : undefined;
|
|
278
306
|
if (opts.card.id && credId && opts.card.id !== credId)
|
|
279
307
|
throw new Error(`card.id ${opts.card.id} != creds identity ${credId} - they must be the same nkey`);
|
|
280
308
|
this.currentCreds = opts.creds;
|
|
309
|
+
selfMintedConnId = opts.card.id === undefined && credId === undefined;
|
|
281
310
|
this.connId = opts.card.id ?? credId ?? randomUUID().replace(/-/g, "");
|
|
282
311
|
}
|
|
283
312
|
this.owner = opts.card.owner ?? DEV_OWNER;
|
|
284
313
|
this.actor = opts.card.actor ?? this.connId;
|
|
314
|
+
// The actor is EPHEMERAL only when it inherited a self-minted connId — a declared `card.actor`
|
|
315
|
+
// is stable even on an otherwise identity-less connection. Recorded here, at the one site the
|
|
316
|
+
// fallback fires, so no caller has to re-derive the precedence rule from the outside.
|
|
317
|
+
this.actorIsEphemeral = opts.card.actor === undefined && selfMintedConnId;
|
|
285
318
|
}
|
|
286
319
|
// The incarnation's lifecycle UID (SPEC §13.1). AUTH mode (JWT creds/bearer) REQUIRES the
|
|
287
320
|
// launcher to supply it — the dm/dlv/chathist durable names must match the exact names the
|
|
@@ -924,6 +957,164 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
924
957
|
await this.publishMsg(chatSubject(this.space, this.owner, this.actor, channel), msg);
|
|
925
958
|
return msg;
|
|
926
959
|
}
|
|
960
|
+
/** The broker's live `max_payload` — the CEILING a frame is measured against, not a budget for a
|
|
961
|
+
* caller's own payload.
|
|
962
|
+
*
|
|
963
|
+
* Exposed because the connection is private and callers outside core (a connector assembling a
|
|
964
|
+
* batched payload) otherwise have no way to learn the ceiling except by failing a publish.
|
|
965
|
+
* Throws rather than guessing a default: a wrong ceiling is worse than none, because it splits
|
|
966
|
+
* either too eagerly or too late and both look like working code.
|
|
967
|
+
*
|
|
968
|
+
* THIS ALONE CANNOT SIZE A MESSAGE. The envelope this endpoint adds after the publish call, and
|
|
969
|
+
* the client's own headers, are charged against the same ceiling and the caller never sees them.
|
|
970
|
+
* Use {@link encodedSize}, which measures what will actually be sent. */
|
|
971
|
+
get maxPayload() {
|
|
972
|
+
const max = this.nc?.info?.max_payload;
|
|
973
|
+
if (typeof max !== "number" || !Number.isFinite(max) || max <= 0)
|
|
974
|
+
throw new Error(`${this.notLiveMsg()} - max_payload is only known while connected`);
|
|
975
|
+
return max;
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Verify the PRECONDITION {@link multicastExpecting} depends on: that the chat stream evaluates
|
|
979
|
+
* the subject expectation BEFORE the `Nats-Msg-Id` dedup cache. **Throws if it cannot be
|
|
980
|
+
* guaranteed.** Call before the first serialized append on a given endpoint.
|
|
981
|
+
*
|
|
982
|
+
* **The ordering follows the stream's REPLICATION FACTOR, not the deployment.** A standalone R1
|
|
983
|
+
* stream and an R1 stream inside a real 3-node cluster both refuse a stale expectation with a CAS
|
|
984
|
+
* error; only an R3 stream evaluates dedup first and answers a retry with `duplicate: true`. A
|
|
985
|
+
* check written against cluster size would pass on exactly the deployment that breaks.
|
|
986
|
+
*
|
|
987
|
+
* Every stream Cotal creates is `num_replicas: 1`, from the same canonical config the restore path
|
|
988
|
+
* uses, so the property holds by construction today. This exists because "by construction" is an
|
|
989
|
+
* observation until something checks it: nothing in the wire contract reserves the replica factor.
|
|
990
|
+
* A caller appending under a stale assumption does not fail loudly; it accepts a retry as success
|
|
991
|
+
* and drops a message.
|
|
992
|
+
*
|
|
993
|
+
* Evidence is `smoke:cas-preflight-cluster`, which records the server version it measured against
|
|
994
|
+
* rather than naming one here — the suites resolve `nats-server` from `PATH`, so a hardcoded
|
|
995
|
+
* provenance ages into a claim about a machine that no longer exists.
|
|
996
|
+
*
|
|
997
|
+
* @throws if the stream is unreadable (no `STREAM.INFO` grant, or absent) or reports more than
|
|
998
|
+
* one replica. Never degrades to a warning: the failure it prevents is silent.
|
|
999
|
+
*/
|
|
1000
|
+
async assertExpectationSemantics() {
|
|
1001
|
+
if (!this.jsm)
|
|
1002
|
+
throw new Error(this.notLiveMsg());
|
|
1003
|
+
const stream = chatStream(this.space);
|
|
1004
|
+
let replicas;
|
|
1005
|
+
try {
|
|
1006
|
+
replicas = (await this.jsm.streams.info(stream)).config.num_replicas;
|
|
1007
|
+
}
|
|
1008
|
+
catch (e) {
|
|
1009
|
+
throw new Error(`cannot verify expectation semantics: stream "${stream}" info unavailable (${e.message}). ` +
|
|
1010
|
+
`Serialized appends are refused rather than run on an unverified stream.`);
|
|
1011
|
+
}
|
|
1012
|
+
// `undefined` is NOT treated as 1. A server that does not report the field is a server whose
|
|
1013
|
+
// ordering we have not established, which is the case this check exists for.
|
|
1014
|
+
if (replicas !== 1)
|
|
1015
|
+
throw new Error(`stream "${stream}" reports num_replicas=${String(replicas)}; serialized appends on THIS ` +
|
|
1016
|
+
`stream require 1 — a property of this one stream, not of the broker, so a clustered ` +
|
|
1017
|
+
`deployment is fine so long as this stream is R1, which a cluster can host. ` +
|
|
1018
|
+
`On a replicated stream the dedup cache is consulted before the subject expectation, so a ` +
|
|
1019
|
+
`retry returns a duplicate ack instead of a conflict and a lost message reads as success.`);
|
|
1020
|
+
}
|
|
1021
|
+
/** The envelope {@link multicastExpecting} publishes, built in ONE place so that a frame and any
|
|
1022
|
+
* measurement of that frame cannot describe different messages. The fields this adds — `ts`,
|
|
1023
|
+
* `space`, `from`, `channel` and the normalized `mentions` — are exactly the ones a caller
|
|
1024
|
+
* holding only its parts cannot account for. */
|
|
1025
|
+
casEnvelope(opts) {
|
|
1026
|
+
return {
|
|
1027
|
+
id: opts.id,
|
|
1028
|
+
ts: Date.now(),
|
|
1029
|
+
space: this.space,
|
|
1030
|
+
from: this.ref(),
|
|
1031
|
+
channel: opts.channel,
|
|
1032
|
+
mentions: normalizeMentions(opts.mentions),
|
|
1033
|
+
parts: opts.parts,
|
|
1034
|
+
replyTo: opts.replyTo,
|
|
1035
|
+
contextId: opts.contextId,
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* The bytes this frame will ACTUALLY put on the wire, to compare against {@link maxPayload}.
|
|
1040
|
+
*
|
|
1041
|
+
* Caller-side arithmetic is wrong in the dangerous direction: a split sized against the caller's
|
|
1042
|
+
* own payload produces a frame the broker REJECTS, and a rejected truncation makes the loss silent
|
|
1043
|
+
* again — the failure splitting exists to prevent.
|
|
1044
|
+
*
|
|
1045
|
+
* It lives on the surface that BUILDS the envelope so measurement and construction cannot drift
|
|
1046
|
+
* apart unnoticed: it shares {@link casEnvelope} with the publish path, sets the same two headers,
|
|
1047
|
+
* and lets the client's own encoder encode them rather than re-implementing the wire format.
|
|
1048
|
+
* `frame-size.smoke.ts` binary-searches a real broker's ceiling and requires this number to land
|
|
1049
|
+
* on it exactly.
|
|
1050
|
+
*
|
|
1051
|
+
* `expectedLastSubjectSeq` is a parameter because it is a header VALUE: sizing at 0 and publishing
|
|
1052
|
+
* at 123456 differ by five bytes.
|
|
1053
|
+
*
|
|
1054
|
+
* Residual: `ts` is re-stamped at publish, so the two differ in value — not in length until
|
|
1055
|
+
* epoch-millis needs a 14th digit.
|
|
1056
|
+
*/
|
|
1057
|
+
encodedSize(opts) {
|
|
1058
|
+
// The same argument validation the publish path applies, so a caller cannot size a frame that
|
|
1059
|
+
// would have been refused before it ever reached the wire.
|
|
1060
|
+
if (!isConcreteChannel(opts.channel))
|
|
1061
|
+
throw new Error(`cannot publish to wildcard channel "${opts.channel}" - pick a concrete sub-channel`);
|
|
1062
|
+
assertIdToken(opts.id, "publish id");
|
|
1063
|
+
if (!Number.isSafeInteger(opts.expectedLastSubjectSeq) || opts.expectedLastSubjectSeq < 0)
|
|
1064
|
+
throw new Error(`expectedLastSubjectSeq must be a non-negative safe integer, got ${JSON.stringify(opts.expectedLastSubjectSeq)}`);
|
|
1065
|
+
if (!Array.isArray(opts.parts) || opts.parts.length === 0)
|
|
1066
|
+
throw new Error("encodedSize requires at least one part");
|
|
1067
|
+
const mh = headers();
|
|
1068
|
+
mh.set("Nats-Msg-Id", opts.id);
|
|
1069
|
+
mh.set("Nats-Expected-Last-Subject-Sequence", `${opts.expectedLastSubjectSeq}`);
|
|
1070
|
+
// `encode()` is on the client's header implementation but not on the published `MsgHdrs` type,
|
|
1071
|
+
// so it is reached through a cast rather than copied. If a client version drops it, this throws
|
|
1072
|
+
// immediately and loudly — the calibration cell would also fail — instead of returning a number
|
|
1073
|
+
// that is quietly wrong near the ceiling.
|
|
1074
|
+
const headerBytes = mh.encode().length;
|
|
1075
|
+
return headerBytes + Buffer.byteLength(JSON.stringify(this.casEnvelope(opts)), "utf8");
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Multicast with an OPTIMISTIC-CONCURRENCY expectation and a caller-chosen dedup id, returning
|
|
1079
|
+
* the `PubAck` fields instead of discarding them. The serialized-append primitive: two writers
|
|
1080
|
+
* racing one subject cannot interleave, because the loser's expectation no longer holds.
|
|
1081
|
+
*
|
|
1082
|
+
* **Why a separate method rather than options on {@link multicast}.** `multicast` mints a fresh
|
|
1083
|
+
* `id` per call and drops the ack; both are right for ordinary chat and both are fatal to a
|
|
1084
|
+
* caller that must retry an append idempotently. Keeping them apart means no existing caller
|
|
1085
|
+
* changes behaviour, and the stricter validation below applies only where a caller opted in.
|
|
1086
|
+
*
|
|
1087
|
+
* - `id` becomes the JetStream `Nats-Msg-Id`, so the SAME id may be republished on retry and the
|
|
1088
|
+
* server dedups it within the stream's duplicate window. It is validated rather than trusted:
|
|
1089
|
+
* it lands in a wire header, and the dedup cache is **stream-wide**, so a caller-supplied id is
|
|
1090
|
+
* both an injection surface and a way to suppress another publisher's message.
|
|
1091
|
+
* - `expectedLastSubjectSeq` is the sequence this publisher believes is the subject's tip; `0`
|
|
1092
|
+
* means "the subject must be empty". A mismatch throws, and the throw stays classifiable by the
|
|
1093
|
+
* already-public {@link isCasLoss} — the error is deliberately **not wrapped**, since wrapping
|
|
1094
|
+
* would hide the `err_code` that classification reads.
|
|
1095
|
+
*
|
|
1096
|
+
* @throws if the endpoint is not live, the channel is not concrete, `id` is malformed, `parts` is
|
|
1097
|
+
* empty, or `expectedLastSubjectSeq` is not a non-negative safe integer.
|
|
1098
|
+
*/
|
|
1099
|
+
async multicastExpecting(opts) {
|
|
1100
|
+
if (!this.js)
|
|
1101
|
+
throw new Error(this.notLiveMsg());
|
|
1102
|
+
if (!isConcreteChannel(opts.channel))
|
|
1103
|
+
throw new Error(`cannot publish to wildcard channel "${opts.channel}" - pick a concrete sub-channel`);
|
|
1104
|
+
// Reuse the existing id grammar rather than mint a second one: [A-Za-z0-9_-]{1,64} admits a
|
|
1105
|
+
// UUID and rejects every character that could break a wire header (CR, LF, space, colon).
|
|
1106
|
+
assertIdToken(opts.id, "publish id");
|
|
1107
|
+
const expected = opts.expectedLastSubjectSeq;
|
|
1108
|
+
if (!Number.isSafeInteger(expected) || expected < 0)
|
|
1109
|
+
throw new Error(`expectedLastSubjectSeq must be a non-negative safe integer, got ${JSON.stringify(expected)}`);
|
|
1110
|
+
if (!Array.isArray(opts.parts) || opts.parts.length === 0)
|
|
1111
|
+
throw new Error("multicastExpecting requires at least one part");
|
|
1112
|
+
const message = this.casEnvelope(opts);
|
|
1113
|
+
// Publish DIRECTLY rather than through publishMsg: this path must set the expectation and read
|
|
1114
|
+
// the ack, and publishMsg deliberately does neither.
|
|
1115
|
+
const ack = await this.js.publish(chatSubject(this.space, this.owner, this.actor, opts.channel), JSON.stringify(message), { msgID: opts.id, expect: { lastSubjectSequence: expected } });
|
|
1116
|
+
return { message, ack: { seq: ack.seq, duplicate: ack.duplicate === true } };
|
|
1117
|
+
}
|
|
927
1118
|
/** Unicast: direct message to one specific instance. */
|
|
928
1119
|
async unicast(instanceId, text, opts) {
|
|
929
1120
|
const msg = {
|
|
@@ -1071,11 +1262,22 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1071
1262
|
/** GENERIC v0.4 service invoke over this endpoint's own connection (P2 item 1, 1c.2b): resolve
|
|
1072
1263
|
* the named endpoint's registered surface — describe, §13.7 store fetch, digest-verified
|
|
1073
1264
|
* recompile ({@link resolveService}; cached per endpoint name) — and invoke one command. The
|
|
1074
|
-
* resolve is describe-bound currency
|
|
1075
|
-
*
|
|
1076
|
-
*
|
|
1077
|
-
*
|
|
1078
|
-
*
|
|
1265
|
+
* resolve is describe-bound currency, and a call that reaches the wrong incarnation is recovered
|
|
1266
|
+
* two ways depending on WHO caught it — the difference between knowing the command did not run
|
|
1267
|
+
* and only knowing someone answered:
|
|
1268
|
+
* - the RESPONDER fenced it on the request's `bind` (§13.2, an `ok:false` reply marked
|
|
1269
|
+
* {@link replyRefusedBeforeEffect}): the command did not run, so the bind is dropped and the
|
|
1270
|
+
* call re-issued ONCE for any command. If that re-issue cannot be resolved, the refusal
|
|
1271
|
+
* surfaces — still saying the command did not run — naming the resolve failure as why the
|
|
1272
|
+
* repair could not be attempted.
|
|
1273
|
+
* - this CLIENT caught it on the reply ({@link respondedButUnbound}: a different instance,
|
|
1274
|
+
* `failed-precondition`; the same instance at any other epoch, `expired`), which is what a
|
|
1275
|
+
* responder too old to know the field produces. A live instance received and answered it, so
|
|
1276
|
+
* the bind is dropped but the call is re-issued only for a command on the
|
|
1277
|
+
* {@link isRepeatSafeCommand} allowlist; anything else surfaces, since a second attempt could
|
|
1278
|
+
* duplicate its effect.
|
|
1279
|
+
* Errors from the responder come back structurally on the attributed reply
|
|
1280
|
+
* (`reply.ok === false`); transport/validation refusals throw {@link EpEnvelopeError}. */
|
|
1079
1281
|
async invokeService(endpoint, command, args, opts = {}) {
|
|
1080
1282
|
if (!this.nc)
|
|
1081
1283
|
throw new Error(this.notLiveMsg());
|
|
@@ -1092,13 +1294,111 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1092
1294
|
const invokeOpts = { ...(opts.target ? { target: opts.target } : {}), ...(opts.deadlineMs !== undefined ? { deadlineMs: opts.deadlineMs } : {}) };
|
|
1093
1295
|
const doInvoke = async () => {
|
|
1094
1296
|
try {
|
|
1095
|
-
|
|
1297
|
+
const r = await invokeCommand(nc, this.space, await resolve(), command, args, invokeOpts);
|
|
1298
|
+
// THE RESPONDER FENCED IT (§13.2 `ai.cotal.ep.bind-refused`): a class member saw the call
|
|
1299
|
+
// was bound to a different incarnation and refused BEFORE running the command.
|
|
1300
|
+
//
|
|
1301
|
+
// Handled here rather than below because it arrives as an ordinary `ok:false` REPLY, not a
|
|
1302
|
+
// throw, so the `respondedButUnbound` recovery never sees it — a long-lived client would
|
|
1303
|
+
// otherwise keep its stale bind and meet the same refusal forever.
|
|
1304
|
+
//
|
|
1305
|
+
// The re-issue is NOT gated on {@link isRepeatSafeCommand}: the responder states the command
|
|
1306
|
+
// did not run, so this is a FIRST attempt, not a second. Exactly once; a second refusal
|
|
1307
|
+
// surfaces.
|
|
1308
|
+
if (r.reply.ok === false && replyRefusedBeforeEffect(r.reply.error)) {
|
|
1309
|
+
// Counted before it is repaired: a recovery that leaves no trace takes the split rate with it.
|
|
1310
|
+
this.splitsRecovered++;
|
|
1311
|
+
// `boundTo` is the other half of `servedBy`: who the handle THOUGHT it was talking to,
|
|
1312
|
+
// against who actually answered. Without it a listener sees that a split was recovered
|
|
1313
|
+
// but not which bind went stale, so it cannot tell one handle's repeated staleness from
|
|
1314
|
+
// splits spread across many — and that is the difference between a handle to drop and a
|
|
1315
|
+
// class that is churning.
|
|
1316
|
+
this.emit("split-recovered", {
|
|
1317
|
+
endpoint, command, servedBy: r.responder, splitsRecovered: this.splitsRecovered,
|
|
1318
|
+
boundTo: (r.reply.error?.details ?? []).find((d) => d.kind === EP_BIND_REFUSED)?.boundTo,
|
|
1319
|
+
});
|
|
1320
|
+
this.resolvedServices.delete(endpoint);
|
|
1321
|
+
// A FAILED RE-ISSUE RETHROWS THE ORIGINAL REFUSAL, not the resolve error: if the endpoint
|
|
1322
|
+
// has since retired, the resolve times out and the caller would otherwise get `no describe
|
|
1323
|
+
// reply within 10000ms` — about a describe it never asked for — losing the one fact that
|
|
1324
|
+
// says its handle is stale AND that nothing ran. The resolve failure is named as the
|
|
1325
|
+
// reason the repair could not be attempted.
|
|
1326
|
+
//
|
|
1327
|
+
// The code must be the refusal's own (`failed-precondition` for a different instance,
|
|
1328
|
+
// `expired` for the same instance at ANY other epoch — the fence does not compare
|
|
1329
|
+
// direction, so a superseded incarnation still answering produces it too); the marker on
|
|
1330
|
+
// any other code is incoherent, so it is rejected rather than trusted (§13.3).
|
|
1331
|
+
const raw = r.reply.error?.code;
|
|
1332
|
+
const refusalCode = raw === "expired" ? "expired"
|
|
1333
|
+
: raw === "failed-precondition" ? "failed-precondition" : undefined;
|
|
1334
|
+
if (refusalCode === undefined)
|
|
1335
|
+
throw new EpEnvelopeError("internal", `${endpoint}.${command} came back marked as refused before it ran, but with code ${String(raw)}; the fence produces only failed-precondition or expired (SPEC 13.2)`, r.reply.error?.details);
|
|
1336
|
+
// ONLY THE RESOLVE IS WRAPPED — do not widen this `try` to cover the re-issue. A re-issue
|
|
1337
|
+
// that PUBLISHED AND RAN can still throw (an unfenced responder answers and the post-reply
|
|
1338
|
+
// currency check raises `respondedButUnbound`); wrapping that as the first hop's refusal
|
|
1339
|
+
// would hand the caller `WAS NOT RUN` for a command that executed, and its next attempt
|
|
1340
|
+
// would be a second one believing it was the first. A re-issue that fails on its own terms
|
|
1341
|
+
// must propagate its OWN error, because `respondedButUnbound` asserts the opposite of what
|
|
1342
|
+
// the fence's marker does: a responder answered and the effect may have landed.
|
|
1343
|
+
let reissueTarget;
|
|
1344
|
+
try {
|
|
1345
|
+
reissueTarget = await resolve();
|
|
1346
|
+
}
|
|
1347
|
+
catch (reissue) {
|
|
1348
|
+
throw new EpEnvelopeError(refusalCode, `${endpoint}.${command} WAS NOT RUN - the incarnation that received it refused it before any effect, and the re-issue could not be resolved: ${reissue instanceof Error ? reissue.message : String(reissue)}. Re-resolve and re-issue when the endpoint is reachable (SPEC 13.2)`, r.reply.error?.details,
|
|
1349
|
+
// §13.3: the message asserts the command did not run, so the FIELD a caller keys on
|
|
1350
|
+
// must assert it too. Omitted, it MUST be read as `unknown`, which is the opposite of
|
|
1351
|
+
// what this path knows: the responder fenced the call before any effect and the
|
|
1352
|
+
// re-issue never went out. Prose is for the reader; this is for the machine.
|
|
1353
|
+
"not-executed");
|
|
1354
|
+
}
|
|
1355
|
+
return await invokeCommand(nc, this.space, reissueTarget, command, args, invokeOpts);
|
|
1356
|
+
}
|
|
1357
|
+
return r;
|
|
1096
1358
|
}
|
|
1097
1359
|
catch (e) {
|
|
1098
|
-
if (!(e instanceof EpEnvelopeError)
|
|
1360
|
+
if (!(e instanceof EpEnvelopeError))
|
|
1361
|
+
throw e;
|
|
1362
|
+
// DO NOT auto-retry a command a responder already ANSWERED. This path covers the responders
|
|
1363
|
+
// WITHOUT the fence above: they ignore `bind`, run the command, and the error is raised
|
|
1364
|
+
// afterwards, so core cannot tell a repair from a duplicate and the allowlist is the only
|
|
1365
|
+
// guard left. (`failed-precondition` here is not only supersession — it also fires when a
|
|
1366
|
+
// DIFFERENT live instance wins the class queue and replies, an ordinary split.)
|
|
1367
|
+
//
|
|
1368
|
+
// Dropping the retry outright is not an option: in a two-manager space roughly half of all
|
|
1369
|
+
// class-queue calls split, so every other `ps` would surface an error this absorbs, and
|
|
1370
|
+
// re-running a read costs nothing.
|
|
1371
|
+
//
|
|
1372
|
+
// So it is gated on an ALLOWLIST (`REPEAT_SAFE_COMMANDS`), keyed by ENDPOINT because this
|
|
1373
|
+
// method is endpoint-agnostic. Polarity matters more than membership: an allowlist fails
|
|
1374
|
+
// CLOSED. Not a `GOAL_BEARING_COMMANDS`-only gate — `purge` is neither goal-bearing nor
|
|
1375
|
+
// convergent, and its second run deletes messages published after the first completed, so
|
|
1376
|
+
// the boundary is "changes anything", which core cannot see: commands carry no idempotency
|
|
1377
|
+
// declaration and every manager command shares `class: "ephemeral"`.
|
|
1378
|
+
//
|
|
1379
|
+
// Keyed on the MARKER, not the error code, because the same fact has two producers: a
|
|
1380
|
+
// DIFFERENT instance answering (`failed-precondition`) and the SAME instance at ANY OTHER
|
|
1381
|
+
// EPOCH (`expired`) — epoch inequality, not "a successor", so a superseded incarnation
|
|
1382
|
+
// still answering produces it too. Same rule for both, or a long-lived client stays bound
|
|
1383
|
+
// to a dead epoch while every call reaches whoever is actually there.
|
|
1384
|
+
if (respondedButUnbound(e)) {
|
|
1385
|
+
// Drop the stale bind FIRST, whichever way this goes: it names an incarnation that is not
|
|
1386
|
+
// the one answering, so every later call would reuse it and meet the same refusal. This is
|
|
1387
|
+
// not a retry — nothing is re-issued by it, and for a command that is not repeat-safe the
|
|
1388
|
+
// next call is the caller's, made after it has verified.
|
|
1389
|
+
this.resolvedServices.delete(endpoint);
|
|
1390
|
+
if (!isRepeatSafeCommand(endpoint, command))
|
|
1391
|
+
throw e;
|
|
1392
|
+
return await invokeCommand(nc, this.space, await resolve(), command, args, invokeOpts);
|
|
1393
|
+
}
|
|
1394
|
+
// An UNMARKED `failed-precondition` is the resolve's own refusal, raised before any command
|
|
1395
|
+
// was published, so re-resolving once is a repair. The `replyRefusedBeforeEffect` half keeps
|
|
1396
|
+
// out the refusal THIS method raises after a failed re-issue: it carries that same code, and
|
|
1397
|
+
// would otherwise fall into the re-resolve below as a THIRD attempt at a command whose
|
|
1398
|
+
// second could not even be resolved. A marker means the disposition is already decided,
|
|
1399
|
+
// whichever code carries it.
|
|
1400
|
+
if (e.code !== "failed-precondition" || replyRefusedBeforeEffect(e.toEpError()))
|
|
1099
1401
|
throw e;
|
|
1100
|
-
// The describe-bound incarnation is gone (restart/supersede) — re-resolve once, then invoke
|
|
1101
|
-
// against the CURRENT one; a second failure is the real state and throws.
|
|
1102
1402
|
this.resolvedServices.delete(endpoint);
|
|
1103
1403
|
return await invokeCommand(nc, this.space, await resolve(), command, args, invokeOpts);
|
|
1104
1404
|
}
|
|
@@ -1735,7 +2035,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1735
2035
|
await this.ensureDmDurable(owner, actor, lifecycleUid, {});
|
|
1736
2036
|
}
|
|
1737
2037
|
/** Idempotent-PER-LIFECYCLE create of a `dm_<o>-<a>-<uid>` durable with its ACTIVATION FRONTIER
|
|
1738
|
-
* (SPEC
|
|
2038
|
+
* (SPEC §8). Info-first: an existing durable (a manager-restart re-provision of the SAME uid, or
|
|
1739
2039
|
* the same lifecycle's own restart) is kept as-is, preserving the ORIGINAL frontier — the
|
|
1740
2040
|
* activation moment never moves. A fresh lifecycle captures the DM stream's current `last_seq` and
|
|
1741
2041
|
* starts delivery at frontier+1, so a same-alias successor inherits none of the predecessor's
|
|
@@ -1921,7 +2221,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1921
2221
|
throw new Error("endpoint not started");
|
|
1922
2222
|
if (this.managerLeaseKv)
|
|
1923
2223
|
return this.managerLeaseKv;
|
|
1924
|
-
|
|
2224
|
+
// A JetStream client of its own, so every operation on this bucket carries the lease budget's
|
|
2225
|
+
// attempt deadline rather than the library default. The default is TTL/2, which would let one
|
|
2226
|
+
// attempt spend the whole renew window (see MANAGER_LEASE_ATTEMPT_MS). Scoped to this bucket:
|
|
2227
|
+
// every op on it is a single small keyed request, and nothing else shares this client.
|
|
2228
|
+
const kvm = new Kvm(jetstream(this.nc, { timeout: MANAGER_LEASE_ATTEMPT_MS }));
|
|
1925
2229
|
if (this.authed) {
|
|
1926
2230
|
this.managerLeaseKv = await kvm.open(managerBucket(this.space));
|
|
1927
2231
|
}
|
|
@@ -1952,6 +2256,18 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1952
2256
|
async renewManagerLease(info, revision) {
|
|
1953
2257
|
return (await this.managerLeaseRegistry()).update(managerLeaseKey(info.instanceId), this.encodeManagerLease({ ...info, since: Date.now() }), revision);
|
|
1954
2258
|
}
|
|
2259
|
+
/** Read THIS instance's OWN lease key, keyed (not the `lease.*` sweep {@link readManagerLease} does).
|
|
2260
|
+
*
|
|
2261
|
+
* `undefined` means the key IS NOT THERE — a definite absence, established by a completed read.
|
|
2262
|
+
* A read that could not be completed THROWS instead, so a caller can tell "it is gone" from "I could
|
|
2263
|
+
* not find out". That distinction is the whole point of the method: a renew that got no answer has
|
|
2264
|
+
* proved nothing, and only a definite answer here may be acted on. */
|
|
2265
|
+
async readOwnManagerLease(instanceId) {
|
|
2266
|
+
const e = await (await this.managerLeaseRegistry()).get(managerLeaseKey(instanceId));
|
|
2267
|
+
if (!e || e.operation !== "PUT")
|
|
2268
|
+
return undefined;
|
|
2269
|
+
return { info: JSON.parse(new TextDecoder().decode(e.value)), revision: e.revision };
|
|
2270
|
+
}
|
|
1955
2271
|
/** Release THIS instance's key on clean shutdown so a same-id restart re-acquires immediately. CAS-guarded
|
|
1956
2272
|
* by `revision`: if we already LOST it (renew gap) the stored revision has moved, the conditional delete
|
|
1957
2273
|
* no-ops. Keyed per instance, so a release NEVER touches a sibling manager's key (security pin 6). */
|
|
@@ -2240,7 +2556,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2240
2556
|
async startPlane3(aclFor, opts = {}) {
|
|
2241
2557
|
if (!this.js)
|
|
2242
2558
|
throw new Error("endpoint not started");
|
|
2243
|
-
this.plane3 = { aclFor, reloadMembershipCreds: opts.reloadMembershipCreds, evictPrincipal: opts.evictPrincipal, planeConnLiveness: opts.planeConnLiveness };
|
|
2559
|
+
this.plane3 = { aclFor, reloadMembershipCreds: opts.reloadMembershipCreds, evictPrincipal: opts.evictPrincipal, planeConnLiveness: opts.planeConnLiveness, principalLiveness: opts.principalLiveness };
|
|
2244
2560
|
await this.armPlane3();
|
|
2245
2561
|
}
|
|
2246
2562
|
/** Serve one runtime durable-membership control request (the server-side delivery daemon). The caller
|
|
@@ -2562,6 +2878,24 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2562
2878
|
return { ok: false, error: e.message };
|
|
2563
2879
|
}
|
|
2564
2880
|
}
|
|
2881
|
+
if (req.op === "principalLiveness") {
|
|
2882
|
+
// The freeze-holder liveness probe (#391): the READ-ONLY half of `evictPrincipal`. A repair
|
|
2883
|
+
// that must REFUSE while the holder is alive cannot use eviction as its own precheck — that
|
|
2884
|
+
// kills the holder before anything can refuse on its behalf. Same executor-hook shape as the
|
|
2885
|
+
// verbs above (the $SYS observer cred lives outside this trust boundary); absent hook =
|
|
2886
|
+
// refused loudly, and the caller maps a refusal to UNKNOWN and never repairs over it.
|
|
2887
|
+
if (!this.plane3?.principalLiveness)
|
|
2888
|
+
return { ok: false, error: "principalLiveness: no liveness oracle wired on this daemon" };
|
|
2889
|
+
const principal = typeof req.args?.principal === "string" ? req.args.principal.trim() : "";
|
|
2890
|
+
if (!principal)
|
|
2891
|
+
return { ok: false, error: "principalLiveness: a principal (owner.actor dot-form) is required" };
|
|
2892
|
+
try {
|
|
2893
|
+
return { ok: true, data: await this.plane3.principalLiveness(principal) };
|
|
2894
|
+
}
|
|
2895
|
+
catch (e) {
|
|
2896
|
+
return { ok: false, error: e.message };
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2565
2899
|
return { ok: false, error: `op "${req.op}" not supported on the delivery admin service` };
|
|
2566
2900
|
}
|
|
2567
2901
|
/** Fan-out loop: bind the privileged `fanout` durable on CHAT and route each message (routing only —
|
|
@@ -3465,7 +3799,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3465
3799
|
return; // observers watch but never publish their own record
|
|
3466
3800
|
const p = {
|
|
3467
3801
|
card: this.card,
|
|
3468
|
-
// SPEC §6
|
|
3802
|
+
// SPEC §6: presence carries the incarnation's lifecycle UID (MUST in auth mode from v0.4);
|
|
3469
3803
|
// omitted only where the endpoint has none (a pure operator/daemon connection never registers).
|
|
3470
3804
|
...(this.ownLifecycleUid !== undefined ? { lifecycleUid: this.ownLifecycleUid } : {}),
|
|
3471
3805
|
status: this.status,
|
|
@@ -3806,6 +4140,33 @@ export function isPermissionDenied(e) {
|
|
|
3806
4140
|
return true;
|
|
3807
4141
|
return /permissions?\s+violation/i.test(String(e?.message ?? ""));
|
|
3808
4142
|
}
|
|
4143
|
+
/** True ONLY for a denial on a **publish** — the single case that proves the message was never
|
|
4144
|
+
* ACCEPTED or stored. (Not "never reached the server": the server necessarily received enough of
|
|
4145
|
+
* it to reject it. The distinction matters precisely here, because this helper exists to separate
|
|
4146
|
+
* provably-not-stored from possibly-stored, and the looser phrasing overstates the very thing
|
|
4147
|
+
* being measured.) {@link isPermissionDenied} deliberately does not look at the operation: it exists to
|
|
4148
|
+
* separate "denied" from "service down", and that answer is the same either way. The operation
|
|
4149
|
+
* matters enormously to a caller that reports *delivery*, because a JetStream publish is
|
|
4150
|
+
* request/PubAck and the subscription half is the reply inbox — a denial THERE rejects
|
|
4151
|
+
* `js.publish()` while the stream may already hold the message. Verified against a live broker: a
|
|
4152
|
+
* user allowed to publish but denied its `_INBOX` subscription got
|
|
4153
|
+
* `Permissions Violation for Subscription to "_INBOX.….*"` back from `js.publish()`, and an
|
|
4154
|
+
* unrestricted observer then read `messages: 1` off the stream.
|
|
4155
|
+
*
|
|
4156
|
+
* Note what is deliberately NOT accepted: the untyped text fallback above. A permission-shaped
|
|
4157
|
+
* message string carries no operation, so it cannot prove non-delivery, and guessing "publish"
|
|
4158
|
+
* from wording would reintroduce exactly the false certainty this exists to prevent. Anything not
|
|
4159
|
+
* provably a publish denial is unknown, and a caller reporting delivery must fail toward
|
|
4160
|
+
* "I could not confirm" rather than toward "it did not happen" — the costly mistake is telling
|
|
4161
|
+
* someone to re-send a message that was in fact stored. */
|
|
4162
|
+
export function isPublishPermissionDenied(e) {
|
|
4163
|
+
const typed = e instanceof PermissionViolationError
|
|
4164
|
+
? e
|
|
4165
|
+
: e?.cause instanceof PermissionViolationError
|
|
4166
|
+
? (e.cause)
|
|
4167
|
+
: undefined;
|
|
4168
|
+
return typed?.operation === "publish";
|
|
4169
|
+
}
|
|
3809
4170
|
/** Parse a NATS server URL (`nats://host:port`, `host:port`, a bare host, or a comma list — the
|
|
3810
4171
|
* first entry wins) into a host+port for {@link tcpInfoProbe}. Defaults the port to 4222. */
|
|
3811
4172
|
function hostPort(server) {
|
|
@@ -3873,6 +4234,51 @@ function tcpInfoProbe(server, timeoutMs) {
|
|
|
3873
4234
|
socket.on("close", () => finish(false)); // closed before a full INFO line arrived
|
|
3874
4235
|
});
|
|
3875
4236
|
}
|
|
4237
|
+
/** Bounded TCP reachability on a socket we OWN: does a handshake to `server` complete within
|
|
4238
|
+
* `timeoutMs`? Deliberately narrower than {@link tcpInfoProbe} — it asks only whether the
|
|
4239
|
+
* transport can be reached, never whether NATS is speaking there, so the TLS-first listener that
|
|
4240
|
+
* `tcpInfoProbe` reports false for still passes this gate and goes on to a real connect.
|
|
4241
|
+
*
|
|
4242
|
+
* It exists because `connect()` cannot be trusted to release a connection it never established.
|
|
4243
|
+
* `@nats-io/transport-node`'s `NodeTransport.dial()` keeps its socket in a local until the
|
|
4244
|
+
* handshake resolves (`this.socket = await this.dial(hp)`), so `this.socket` is STILL UNDEFINED
|
|
4245
|
+
* when the client's own connect timeout wins the race in `protocol.ts`'s `dial` and the catch
|
|
4246
|
+
* calls `transport.close()` — whose teardown is `this.socket?.destroy()`, i.e. a destroy of
|
|
4247
|
+
* nothing. Against an address that BLACKHOLES (SYN unanswered) rather than REFUSES (RST), the
|
|
4248
|
+
* socket is orphaned in libuv until the OS SYN timeout, and the process cannot exit for minutes
|
|
4249
|
+
* after the probe already returned its answer. That is issue #389, and it is upstream: nothing a
|
|
4250
|
+
* caller passes (`reconnect: false`, `timeout`) reaches the orphan. Our socket, our `destroy()`,
|
|
4251
|
+
* on every exit path — never an `unref`/force-exit, which would hide the symptom and a future
|
|
4252
|
+
* real hang with it. */
|
|
4253
|
+
function tcpDialable(server, timeoutMs) {
|
|
4254
|
+
return new Promise((resolve) => {
|
|
4255
|
+
let socket;
|
|
4256
|
+
let done = false;
|
|
4257
|
+
const finish = (dialable) => {
|
|
4258
|
+
if (done)
|
|
4259
|
+
return;
|
|
4260
|
+
done = true;
|
|
4261
|
+
try {
|
|
4262
|
+
socket?.destroy();
|
|
4263
|
+
}
|
|
4264
|
+
catch { /* already gone */ }
|
|
4265
|
+
resolve(dialable);
|
|
4266
|
+
};
|
|
4267
|
+
let host, port;
|
|
4268
|
+
try {
|
|
4269
|
+
({ host, port } = hostPort(server));
|
|
4270
|
+
}
|
|
4271
|
+
catch {
|
|
4272
|
+
return resolve(false);
|
|
4273
|
+
}
|
|
4274
|
+
socket = createConnection({ host, port });
|
|
4275
|
+
socket.setTimeout(timeoutMs);
|
|
4276
|
+
socket.on("connect", () => finish(true));
|
|
4277
|
+
socket.on("timeout", () => finish(false)); // blackhole: SYN unanswered inside our deadline
|
|
4278
|
+
socket.on("error", () => finish(false)); // refused / reset / DNS failure
|
|
4279
|
+
socket.on("close", () => finish(false));
|
|
4280
|
+
});
|
|
4281
|
+
}
|
|
3876
4282
|
/** Whether a NATS server is *running* at `servers`. With NO creds this is a SILENT plaintext
|
|
3877
4283
|
* liveness check ({@link tcpInfoProbe}): it reads the server's pre-auth `INFO` greeting and closes
|
|
3878
4284
|
* WITHOUT authenticating, so a live broker (open OR auth — INFO precedes auth) returns true while
|
|
@@ -3882,12 +4288,22 @@ function tcpInfoProbe(server, timeoutMs) {
|
|
|
3882
4288
|
* refuses these creds is still up — so the caller surfaces the real auth failure, and `up` won't
|
|
3883
4289
|
* start a duplicate on the bound port). Only a genuine connection failure (refused/timeout) is false. */
|
|
3884
4290
|
export async function isReachable(servers = DEFAULT_SERVER, opts = {}) {
|
|
4291
|
+
const timeoutMs = opts.timeoutMs ?? 1000;
|
|
3885
4292
|
if (!opts.creds && !opts.token && !opts.user && !opts.pass && !opts.tls)
|
|
3886
|
-
return tcpInfoProbe(servers,
|
|
4293
|
+
return tcpInfoProbe(servers, timeoutMs);
|
|
4294
|
+
// The credless branch above already owns its socket. This one reaches `connect()`, so it carries
|
|
4295
|
+
// the same orphaned-socket defect probeConnect did (#389) and takes the same gate: reach the
|
|
4296
|
+
// address on a socket we own first, and give `connect()` the remainder of the budget its own
|
|
4297
|
+
// timeout always covered. A gate failure is a genuine connection failure, which is exactly the
|
|
4298
|
+
// `false` the catch below already returns for one — an auth rejection cannot reach us from an
|
|
4299
|
+
// address that never completed a handshake.
|
|
4300
|
+
const started = Date.now();
|
|
4301
|
+
if (!(await tcpDialable(servers, timeoutMs)))
|
|
4302
|
+
return false;
|
|
3887
4303
|
try {
|
|
3888
4304
|
const nc = await connect({
|
|
3889
4305
|
servers,
|
|
3890
|
-
timeout:
|
|
4306
|
+
timeout: Math.max(1, timeoutMs - (Date.now() - started)),
|
|
3891
4307
|
reconnect: false,
|
|
3892
4308
|
maxReconnectAttempts: 0,
|
|
3893
4309
|
...authOpts(opts),
|
|
@@ -3905,10 +4321,22 @@ export async function isReachable(servers = DEFAULT_SERVER, opts = {}) {
|
|
|
3905
4321
|
* specific identity is accepted (`ok`); omit them to probe mere liveness (an auth broker answers
|
|
3906
4322
|
* `auth-required`, which still proves it's up). */
|
|
3907
4323
|
export async function probeConnect(server = DEFAULT_SERVER, opts = {}) {
|
|
4324
|
+
const timeoutMs = opts.timeoutMs ?? 1000;
|
|
4325
|
+
const started = Date.now();
|
|
4326
|
+
// Reach the address on a socket we own BEFORE handing it to `connect()`, which orphans the
|
|
4327
|
+
// connection it never established (see {@link tcpDialable} for the upstream mechanism, #389).
|
|
4328
|
+
// This cannot change any verdict: every address that gets past here had to complete a TCP
|
|
4329
|
+
// handshake for `connect()` to have gotten anywhere either, and a gate failure is routed through
|
|
4330
|
+
// the SAME classification the catch uses — so a locally-dead cred is still `stale-auth` and not
|
|
4331
|
+
// silently downgraded to `unreachable` by the address being dark. The cost is one extra
|
|
4332
|
+
// handshake on the reachable path; the deadline below is the REMAINDER of the budget, because
|
|
4333
|
+
// `connect()`'s own `timeout` always covered its handshake too.
|
|
4334
|
+
if (!(await tcpDialable(server, timeoutMs)))
|
|
4335
|
+
return classifyProbeFailure(undefined, opts);
|
|
3908
4336
|
try {
|
|
3909
4337
|
const nc = await connect({
|
|
3910
4338
|
servers: server,
|
|
3911
|
-
timeout:
|
|
4339
|
+
timeout: Math.max(1, timeoutMs - (Date.now() - started)),
|
|
3912
4340
|
reconnect: false,
|
|
3913
4341
|
maxReconnectAttempts: 0,
|
|
3914
4342
|
...authOpts(opts),
|
|
@@ -3917,26 +4345,33 @@ export async function probeConnect(server = DEFAULT_SERVER, opts = {}) {
|
|
|
3917
4345
|
return { ok: true };
|
|
3918
4346
|
}
|
|
3919
4347
|
catch (e) {
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
4348
|
+
return classifyProbeFailure(e, opts);
|
|
4349
|
+
}
|
|
4350
|
+
}
|
|
4351
|
+
/** Why a {@link probeConnect} attempt did not end in `ok`. Shared by the pre-connect reachability
|
|
4352
|
+
* gate and the connect catch so both classify identically — the gate must never turn a diagnosable
|
|
4353
|
+
* credential death into a bare `unreachable` just because the address went dark.
|
|
4354
|
+
*
|
|
4355
|
+
* A presented cred that is PROVABLY expired by its own JWT is stale-auth (credential death) — and
|
|
4356
|
+
* that is knowable LOCALLY, without the network, so it is decided FIRST, before the error type. On
|
|
4357
|
+
* the wire the broker's rejection and the socket close race: a slow CI/Windows handshake can
|
|
4358
|
+
* surface a bare transport failure (→ "unreachable") instead of a clean AuthorizationError, which
|
|
4359
|
+
* used to misclassify a dead cred. Reading the cred removes that timing dependency entirely, so the
|
|
4360
|
+
* classification is deterministic. Unreadable content falls through to the wire truth (no false
|
|
4361
|
+
* stale diagnosis from garbage). `e` is undefined when the gate refused before any connect. */
|
|
4362
|
+
function classifyProbeFailure(e, opts) {
|
|
4363
|
+
if (typeof opts.creds === "string") {
|
|
4364
|
+
try {
|
|
4365
|
+
if (inspectCredHealth(opts.creds).state === "expired")
|
|
4366
|
+
return { ok: false, reason: "stale-auth" };
|
|
3933
4367
|
}
|
|
3934
|
-
|
|
3935
|
-
return { ok: false, reason: "stale-auth" };
|
|
3936
|
-
// The broker answered but rejected these creds (so it IS up) — auth-required, not stale-auth.
|
|
3937
|
-
if (e instanceof AuthorizationError)
|
|
3938
|
-
return { ok: false, reason: "auth-required" };
|
|
3939
|
-
return { ok: false, reason: "unreachable" };
|
|
4368
|
+
catch { /* not introspectable — keep the wire truth */ }
|
|
3940
4369
|
}
|
|
4370
|
+
if (e instanceof UserAuthenticationExpiredError)
|
|
4371
|
+
return { ok: false, reason: "stale-auth" };
|
|
4372
|
+
// The broker answered but rejected these creds (so it IS up) — auth-required, not stale-auth.
|
|
4373
|
+
if (e instanceof AuthorizationError)
|
|
4374
|
+
return { ok: false, reason: "auth-required" };
|
|
4375
|
+
return { ok: false, reason: "unreachable" };
|
|
3941
4376
|
}
|
|
3942
4377
|
//# sourceMappingURL=endpoint.js.map
|