@cotal-ai/core 0.16.0 → 0.18.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 +21 -0
- package/dist/acls.d.ts.map +1 -1
- package/dist/acls.js +44 -1
- package/dist/acls.js.map +1 -1
- package/dist/artifact.d.ts +45 -0
- package/dist/artifact.d.ts.map +1 -0
- package/dist/artifact.js +82 -0
- package/dist/artifact.js.map +1 -0
- package/dist/backup-config.d.ts +9 -2
- package/dist/backup-config.d.ts.map +1 -1
- package/dist/backup-config.js +6 -2
- package/dist/backup-config.js.map +1 -1
- package/dist/broker-tls.d.ts +70 -0
- package/dist/broker-tls.d.ts.map +1 -0
- package/dist/broker-tls.js +195 -0
- package/dist/broker-tls.js.map +1 -0
- package/dist/canonical.d.ts +14 -1
- package/dist/canonical.d.ts.map +1 -1
- package/dist/canonical.js +21 -1
- package/dist/canonical.js.map +1 -1
- package/dist/channels.js +4 -4
- package/dist/channels.js.map +1 -1
- package/dist/endpoint-action.d.ts +108 -3
- package/dist/endpoint-action.d.ts.map +1 -1
- package/dist/endpoint-action.js +209 -20
- package/dist/endpoint-action.js.map +1 -1
- package/dist/endpoint-binding.d.ts +138 -8
- package/dist/endpoint-binding.d.ts.map +1 -1
- package/dist/endpoint-binding.js +233 -26
- package/dist/endpoint-binding.js.map +1 -1
- package/dist/endpoint-contract-store.d.ts +30 -1
- package/dist/endpoint-contract-store.d.ts.map +1 -1
- package/dist/endpoint-contract-store.js +100 -29
- package/dist/endpoint-contract-store.js.map +1 -1
- package/dist/endpoint-envelope.d.ts +15 -37
- package/dist/endpoint-envelope.d.ts.map +1 -1
- package/dist/endpoint-envelope.js +93 -44
- package/dist/endpoint-envelope.js.map +1 -1
- package/dist/endpoint-error.d.ts +33 -0
- package/dist/endpoint-error.d.ts.map +1 -0
- package/dist/endpoint-error.js +34 -0
- package/dist/endpoint-error.js.map +1 -0
- package/dist/endpoint-grants.d.ts +63 -7
- package/dist/endpoint-grants.d.ts.map +1 -1
- package/dist/endpoint-grants.js +118 -9
- package/dist/endpoint-grants.js.map +1 -1
- package/dist/endpoint-invoke.d.ts +138 -0
- package/dist/endpoint-invoke.d.ts.map +1 -0
- package/dist/endpoint-invoke.js +393 -0
- package/dist/endpoint-invoke.js.map +1 -0
- package/dist/endpoint-records.d.ts.map +1 -1
- package/dist/endpoint-records.js +17 -0
- package/dist/endpoint-records.js.map +1 -1
- package/dist/endpoint-serve-kv.d.ts +97 -0
- package/dist/endpoint-serve-kv.d.ts.map +1 -0
- package/dist/endpoint-serve-kv.js +336 -0
- package/dist/endpoint-serve-kv.js.map +1 -0
- package/dist/endpoint-serve.d.ts.map +1 -1
- package/dist/endpoint-serve.js +21 -1
- package/dist/endpoint-serve.js.map +1 -1
- package/dist/endpoint-service.d.ts +1 -1
- package/dist/endpoint-service.d.ts.map +1 -1
- package/dist/endpoint-service.js +34 -10
- package/dist/endpoint-service.js.map +1 -1
- package/dist/endpoint-session-rail.d.ts +127 -0
- package/dist/endpoint-session-rail.d.ts.map +1 -0
- package/dist/endpoint-session-rail.js +325 -0
- package/dist/endpoint-session-rail.js.map +1 -0
- package/dist/endpoint-session.d.ts +1 -115
- package/dist/endpoint-session.d.ts.map +1 -1
- package/dist/endpoint-session.js +4 -316
- package/dist/endpoint-session.js.map +1 -1
- package/dist/endpoint-subjects.d.ts +20 -1
- package/dist/endpoint-subjects.d.ts.map +1 -1
- package/dist/endpoint-subjects.js +20 -1
- package/dist/endpoint-subjects.js.map +1 -1
- package/dist/endpoint-verbs.d.ts +1 -2
- package/dist/endpoint-verbs.d.ts.map +1 -1
- package/dist/endpoint-verbs.js +6 -6
- package/dist/endpoint-verbs.js.map +1 -1
- package/dist/endpoint.d.ts +157 -11
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +471 -47
- package/dist/endpoint.js.map +1 -1
- 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 +34 -0
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +46 -0
- package/dist/identity.js.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/kv-scan.d.ts +8 -0
- package/dist/kv-scan.d.ts.map +1 -1
- package/dist/kv-scan.js +8 -0
- package/dist/kv-scan.js.map +1 -1
- package/dist/lease.d.ts +12 -5
- package/dist/lease.d.ts.map +1 -1
- package/dist/lease.js.map +1 -1
- package/dist/lifecycle-saga.d.ts +174 -0
- package/dist/lifecycle-saga.d.ts.map +1 -0
- package/dist/lifecycle-saga.js +418 -0
- package/dist/lifecycle-saga.js.map +1 -0
- package/dist/lifecycle-state.d.ts +202 -0
- package/dist/lifecycle-state.d.ts.map +1 -0
- package/dist/lifecycle-state.js +347 -0
- package/dist/lifecycle-state.js.map +1 -0
- package/dist/parts.d.ts +13 -0
- package/dist/parts.d.ts.map +1 -0
- package/dist/parts.js +17 -0
- package/dist/parts.js.map +1 -0
- package/dist/provision.d.ts +124 -15
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +578 -121
- package/dist/provision.js.map +1 -1
- package/dist/schema-profile.d.ts +3 -2
- package/dist/schema-profile.d.ts.map +1 -1
- package/dist/schema-profile.js +242 -18
- package/dist/schema-profile.js.map +1 -1
- package/dist/session-browser.d.ts +15 -0
- package/dist/session-browser.d.ts.map +1 -0
- package/dist/session-browser.js +14 -0
- package/dist/session-browser.js.map +1 -0
- package/dist/session-terminal-frames.d.ts +36 -0
- package/dist/session-terminal-frames.d.ts.map +1 -0
- package/dist/session-terminal-frames.js +149 -0
- package/dist/session-terminal-frames.js.map +1 -0
- package/dist/spaces.d.ts.map +1 -1
- package/dist/spaces.js +6 -1
- package/dist/spaces.js.map +1 -1
- package/dist/streams.d.ts +168 -1
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +299 -21
- package/dist/streams.js.map +1 -1
- package/dist/subjects.d.ts +34 -36
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +46 -35
- package/dist/subjects.js.map +1 -1
- package/dist/types.d.ts +27 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -1
package/dist/endpoint.js
CHANGED
|
@@ -4,16 +4,19 @@ import { createConnection } from "node:net";
|
|
|
4
4
|
import { connect, credsAuthenticator, headers, tokenAuthenticator, nanos, AuthorizationError, PermissionViolationError, UserAuthenticationExpiredError, NoRespondersError, RequestError, } from "@nats-io/transport-node";
|
|
5
5
|
import { credsClaims, credsFingerprint, credsRenewalDelayMs, idFromCreds } from "./identity.js";
|
|
6
6
|
import { inspectCredHealth } from "./provision.js";
|
|
7
|
+
import { resolveService, invokeCommand, submitAndFollowGoal } from "./endpoint-invoke.js";
|
|
8
|
+
import { EpEnvelopeError } from "./endpoint-envelope.js";
|
|
7
9
|
import { liveKvEntries } from "./kv-scan.js";
|
|
10
|
+
import { ARTIFACT_PART_KIND, isArtifactPart } from "./artifact.js";
|
|
8
11
|
import { assertValidName } from "./resolve.js";
|
|
9
12
|
import { createSpaceStreams, dmDurableConfig, dlvDurableConfig, taskDurableConfig, fanoutDurableConfig, inboxReaderConfig, MAX_MSGS_PER_SUBJECT, MANAGER_LEASE_TTL_MS } from "./streams.js";
|
|
10
13
|
import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, } from "@nats-io/jetstream";
|
|
11
14
|
import { Kvm } from "@nats-io/kv";
|
|
12
15
|
import { openMembersRegistry, commitMember, tombstoneMember, activateMember, readMember, listMembers, durableEligible, StaleMembershipWrite, } from "./members.js";
|
|
13
|
-
import { openAclRegistry, readAcl, readAclForAlias, AmbiguousAclAlias, commitAcl as writeAclRecord } from "./acls.js";
|
|
16
|
+
import { openAclRegistry, readAcl, readAclForAlias, AmbiguousAclAlias, commitAcl as writeAclRecord, reissueAcl as writeAclReissue } from "./acls.js";
|
|
14
17
|
import { openDeliveryRegistry } from "./lease.js";
|
|
15
18
|
import { openChannelRegistry, effectiveReplay, effectiveReplayWindowMs, effectiveDeliveryClass, readChannelConfig, readChannelDefaults, } from "./channels.js";
|
|
16
|
-
import { anycastSubject, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, controlServiceSubject,
|
|
19
|
+
import { anycastSubject, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, controlServiceSubject, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, dmStream, dmDurable, dlvStream, dlvDurable, dlvSubject, dinboxSubject, inboxStream, parseDinboxPrincipal, FANOUT_DURABLE, INBOX_READER_DURABLE, leaseKey, managerBucket, MANAGER_LEASE_KEY, managerLeaseKey, chatWildcard, assertValidChannel, channelInAllow, isConcreteChannel, normalizeMentions, parseSubject, isPrincipalOwnerToken, assertInboxConnId, presenceBucket, membershipBucket, MEMBERSHIP_FEED_KEY, principalKey, parsePrincipalKey, assertLifecycleToken, mintLifecycleUid, lifecycleNameKey, DEV_OWNER, spacePrefix, spaceWildcard, subjectMatches, taskStream, taskDurable, token, unicastSubject, unicastRecvFilter, } from "./subjects.js";
|
|
17
20
|
export const DEFAULT_SERVER = "nats://127.0.0.1:4222";
|
|
18
21
|
const PLANE3_FRAME_HEADER = "Cotal-Delivery-Frame";
|
|
19
22
|
/** Space joined when none is given on the CLI (the `cotal-<space>` cmux tab, etc.). */
|
|
@@ -34,6 +37,33 @@ export const DEFAULT_SPACE = "main";
|
|
|
34
37
|
* (unknown owner) or DELIVER transfer is `term()`d + surfaced after this many redeliveries, so one
|
|
35
38
|
* stuck/poison entry can't head-of-line the single shared reader forever. */
|
|
36
39
|
const READER_MAX_REDELIVERIES = 10;
|
|
40
|
+
/** Page size for the mediated history read when the caller names none — matches `channelHistory`'s
|
|
41
|
+
* own default, since the mediated read exists to be a drop-in for it. */
|
|
42
|
+
const READ_HISTORY_DEFAULT_LIMIT = 100;
|
|
43
|
+
/** Hard server-side ceiling on one mediated page. The caller PROPOSES a limit and the mediator
|
|
44
|
+
* decides: the reader is pooled and privileged, so an unbounded caller-chosen limit would let one
|
|
45
|
+
* request pull a channel's whole retained set through it. Clamped, not refused — a UI asking for
|
|
46
|
+
* more than a page should get a page, and `complete: false` already tells it more remains. */
|
|
47
|
+
const READ_HISTORY_MAX_LIMIT = 200;
|
|
48
|
+
/** The NEWEST prefix-from-the-end of `items` whose serialized size fits `budget` bytes, order
|
|
49
|
+
* preserved. Returns `[]` when not even the newest single message fits — the caller must refuse
|
|
50
|
+
* loudly there rather than serve an empty page, which would read as "no history".
|
|
51
|
+
*
|
|
52
|
+
* Measured in ENCODED bytes, not `string.length`: a page of multi-byte text would otherwise be
|
|
53
|
+
* undercounted and still overflow the broker. Same discipline as `assertFactFits`. */
|
|
54
|
+
export function fitHistoryPage(items, budget) {
|
|
55
|
+
const enc = new TextEncoder();
|
|
56
|
+
let used = 2; // the enclosing `[]`
|
|
57
|
+
let first = items.length; // index of the oldest kept item
|
|
58
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
59
|
+
const size = enc.encode(JSON.stringify(items[i])).length + 1; // + the `,` separator
|
|
60
|
+
if (used + size > budget)
|
|
61
|
+
break;
|
|
62
|
+
used += size;
|
|
63
|
+
first = i;
|
|
64
|
+
}
|
|
65
|
+
return first === items.length ? [] : items.slice(first);
|
|
66
|
+
}
|
|
37
67
|
export class CotalEndpoint extends EventEmitter {
|
|
38
68
|
card;
|
|
39
69
|
space;
|
|
@@ -171,6 +201,14 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
171
201
|
actor;
|
|
172
202
|
/** This incarnation's lifecycle UID (opts.lifecycleUid) — see {@link EndpointOptions.lifecycleUid}. */
|
|
173
203
|
ownLifecycleUid;
|
|
204
|
+
/** Per-endpoint-name {@link resolveService} cache for {@link invokeService} — dropped on a
|
|
205
|
+
* `failed-precondition` currency refusal (the described incarnation was superseded). */
|
|
206
|
+
resolvedServices = new Map();
|
|
207
|
+
/** This endpoint's wire principal (owner + actor tokens, §13.2) — what its minted grant rows
|
|
208
|
+
* pin. Public so a caller can build owner-mode target blocks for {@link invokeService}. */
|
|
209
|
+
get principal() {
|
|
210
|
+
return { owner: this.owner, actor: this.actor };
|
|
211
|
+
}
|
|
174
212
|
/** The endpoint's own lifecycle UID, REQUIRED for every lifecycle-keyed messaging resource; absent
|
|
175
213
|
* ⇒ loud refusal naming the operation (the hard cut of SPEC §13.1 — no alias-keyed fallback). */
|
|
176
214
|
requireLifecycleUid(what) {
|
|
@@ -1022,6 +1060,55 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1022
1060
|
const m = await this.nc.request(reqSubject, JSON.stringify(body), { timeout: timeoutMs, noMux: true, reply });
|
|
1023
1061
|
return m.json();
|
|
1024
1062
|
}
|
|
1063
|
+
/** This endpoint's v0.4 caller triple (§13.2) — the identity its minted ep-rail rows pin. The
|
|
1064
|
+
* owner/actor principal is mode-correct by construction (static: DEV_OWNER + the connection
|
|
1065
|
+
* identity; user mode: the bearer's callout-derived pair — 1c.2c), and the lifecycle UID is the
|
|
1066
|
+
* launcher-supplied incarnation the rows are keyed on (ledger-consistent: the §13.1 presence
|
|
1067
|
+
* lifecycle-proof refuses a divergent uid before any publish). */
|
|
1068
|
+
serviceCaller() {
|
|
1069
|
+
return { owner: this.owner, actor: this.actor, uid: this.requireLifecycleUid("invokeService") };
|
|
1070
|
+
}
|
|
1071
|
+
/** GENERIC v0.4 service invoke over this endpoint's own connection (P2 item 1, 1c.2b): resolve
|
|
1072
|
+
* the named endpoint's registered surface — describe, §13.7 store fetch, digest-verified
|
|
1073
|
+
* recompile ({@link resolveService}; cached per endpoint name) — and invoke one command. The
|
|
1074
|
+
* resolve is describe-bound currency: when a DIFFERENT instance answers a later invoke
|
|
1075
|
+
* (`failed-precondition`, a restart/supersede), the cache is dropped and resolved ONCE more
|
|
1076
|
+
* against the current incarnation. Errors from the responder come back structurally on the
|
|
1077
|
+
* attributed reply (`reply.ok === false`); transport/validation refusals throw
|
|
1078
|
+
* {@link EpEnvelopeError}. */
|
|
1079
|
+
async invokeService(endpoint, command, args, opts = {}) {
|
|
1080
|
+
if (!this.nc)
|
|
1081
|
+
throw new Error(this.notLiveMsg());
|
|
1082
|
+
const nc = this.nc;
|
|
1083
|
+
const caller = this.serviceCaller();
|
|
1084
|
+
const resolve = async () => {
|
|
1085
|
+
const cached = this.resolvedServices.get(endpoint);
|
|
1086
|
+
if (cached)
|
|
1087
|
+
return cached;
|
|
1088
|
+
const svc = await resolveService(nc, this.space, endpoint, caller, { deadlineMs: opts.deadlineMs ?? 10_000 });
|
|
1089
|
+
this.resolvedServices.set(endpoint, svc);
|
|
1090
|
+
return svc;
|
|
1091
|
+
};
|
|
1092
|
+
const invokeOpts = { ...(opts.target ? { target: opts.target } : {}), ...(opts.deadlineMs !== undefined ? { deadlineMs: opts.deadlineMs } : {}) };
|
|
1093
|
+
const doInvoke = async () => {
|
|
1094
|
+
try {
|
|
1095
|
+
return await invokeCommand(nc, this.space, await resolve(), command, args, invokeOpts);
|
|
1096
|
+
}
|
|
1097
|
+
catch (e) {
|
|
1098
|
+
if (!(e instanceof EpEnvelopeError) || e.code !== "failed-precondition")
|
|
1099
|
+
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
|
+
this.resolvedServices.delete(endpoint);
|
|
1103
|
+
return await invokeCommand(nc, this.space, await resolve(), command, args, invokeOpts);
|
|
1104
|
+
}
|
|
1105
|
+
};
|
|
1106
|
+
// P2 item 2 (2b): a goal-bearing command (spawn/launch) follows its acceptance to the terminal so
|
|
1107
|
+
// the caller still returns on the real outcome (UX unchanged); every other command replies directly.
|
|
1108
|
+
if (!opts.follow)
|
|
1109
|
+
return doInvoke();
|
|
1110
|
+
return submitAndFollowGoal(nc, this.space, endpoint, caller, opts.deadlineMs ?? 10_000, doInvoke);
|
|
1111
|
+
}
|
|
1025
1112
|
/** Send a durable-membership request to the SERVER-SIDE delivery daemon (`ctl.delivery`) and await its
|
|
1026
1113
|
* reply. Unlike {@link requestControl}, the reply rides a subject UNDER `ctl.delivery.<id>.>` (not the
|
|
1027
1114
|
* per-id `_INBOX`), so the scoped delivery cred can answer without broad inbox-publish — see
|
|
@@ -1368,6 +1455,45 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1368
1455
|
// history from any sender
|
|
1369
1456
|
return this.streamHistory(chatStream(this.space), chatSubject(this.space, "*", "*", channel), opts?.limit ?? 100);
|
|
1370
1457
|
}
|
|
1458
|
+
/** Read a channel's recent history THROUGH THE DELIVERY DAEMON instead of through a consumer this
|
|
1459
|
+
* connection creates itself — the mediated read of SPEC's "Mediated reads (normative)" rule (no raw
|
|
1460
|
+
* consumer / `DIRECT.GET` / `STREAM.MSG.GET` for an untrusted holder; the trusted reader serves it
|
|
1461
|
+
* onto the caller's own confined rail). `items` is shape-identical to what {@link channelHistory}
|
|
1462
|
+
* returns — the same `CotalMessage[]`, the same newest-N selection, the same oldest-first order
|
|
1463
|
+
* within the page — so a caller migrates by reading `.items` and nothing else changes. The return
|
|
1464
|
+
* is WRAPPED rather than bare precisely because of `complete`: a bare array cannot say whether
|
|
1465
|
+
* older history remains behind it.
|
|
1466
|
+
*
|
|
1467
|
+
* **Why this exists when `channelHistory` already works:** authorization. A consumer pins its
|
|
1468
|
+
* authorization at CREATE time, so a caller whose read ACL is revoked mid-scroll keeps being served
|
|
1469
|
+
* by the consumer it already holds. The mediator re-reads authorization on EVERY call (live
|
|
1470
|
+
* registry row ∩ mint-time ceiling — SPEC §9.6), so a revocation stops the very next read and a
|
|
1471
|
+
* registry-only widen cannot exceed the effective credential. That is the whole point of the verb;
|
|
1472
|
+
* a mediator that cached the ACL would be a rename of the path it replaces.
|
|
1473
|
+
*
|
|
1474
|
+
* **The caller never names itself.** The daemon takes the principal from the broker-authenticated
|
|
1475
|
+
* request subject ({@link serveControl} fail-closes when the payload `from` disagrees), so there is
|
|
1476
|
+
* no caller-supplied identity to forge.
|
|
1477
|
+
*
|
|
1478
|
+
* A channel outside the caller's read ACL THROWS. It must never come back as an empty page: "you
|
|
1479
|
+
* may not read this" and "there is nothing here" are different answers and only one is safe to
|
|
1480
|
+
* render as an empty conversation.
|
|
1481
|
+
*
|
|
1482
|
+
* Chat channels only — DM history is deliberately not served here (it is god-view-only today, a
|
|
1483
|
+
* different authorization model, and one handler with two authz paths is the wrong shape on the
|
|
1484
|
+
* surface where a mistake exposes private messages). */
|
|
1485
|
+
async readHistory(channel, opts) {
|
|
1486
|
+
const reply = await this.requestDelivery("readHistory", { channel, limit: opts?.limit });
|
|
1487
|
+
if (!reply.ok)
|
|
1488
|
+
throw new Error(reply.error ?? "readHistory failed");
|
|
1489
|
+
const data = reply.data;
|
|
1490
|
+
// Validate rather than coerce. A malformed reply must not be massaged into a plausible page:
|
|
1491
|
+
// defaulting `complete` would invent the very signal a caller uses to decide whether it is
|
|
1492
|
+
// looking at the start of a conversation.
|
|
1493
|
+
if (!Array.isArray(data?.items) || typeof data?.complete !== "boolean")
|
|
1494
|
+
throw new Error("readHistory: the delivery daemon returned a malformed page (expected { items, complete })");
|
|
1495
|
+
return { items: data.items, complete: data.complete };
|
|
1496
|
+
}
|
|
1371
1497
|
/** Fetch recent DMs (any sender→any recipient) from the space's DM backlog. God-view only:
|
|
1372
1498
|
* a normal agent/observer's ACL denies CONSUMER.CREATE on DM_<space>, so this throws-and-
|
|
1373
1499
|
* skips for them — only an `admin`-profile cred can read it. */
|
|
@@ -1700,6 +1826,14 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1700
1826
|
async commitAcl(targetId, lifecycleUid, allowSubscribe) {
|
|
1701
1827
|
await writeAclRecord(await this.aclRegistry(), targetId, lifecycleUid, allowSubscribe);
|
|
1702
1828
|
}
|
|
1829
|
+
/**
|
|
1830
|
+
* Raise the mint-time ACL ceiling. Provision/remint only — see {@link reissueAcl}.
|
|
1831
|
+
* Process discipline: call only in the same act that bakes `allowSubscribe` into the JWT; the
|
|
1832
|
+
* write is not crypto-bound to credential bytes.
|
|
1833
|
+
*/
|
|
1834
|
+
async reissueAcl(targetId, lifecycleUid, allowSubscribe) {
|
|
1835
|
+
await writeAclReissue(await this.aclRegistry(), targetId, lifecycleUid, allowSubscribe);
|
|
1836
|
+
}
|
|
1703
1837
|
/** The server-side delivery daemon's fresh-per-entry ACL read: one LIFECYCLE's current read ACL
|
|
1704
1838
|
* (`allowSubscribe`) from the durable registry (exact key `<owner>.<actor>.<uid>`), or `undefined`
|
|
1705
1839
|
* if no record (an unknown lifecycle — the reader DEFERS, never drops). A present `[]` (known
|
|
@@ -1713,7 +1847,13 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1713
1847
|
* authorize the successor (SPEC 13.1: at most one live lifecycle per alias). */
|
|
1714
1848
|
async aclForAlias(principal) {
|
|
1715
1849
|
const row = await readAclForAlias(await this.aclRegistry(), principal);
|
|
1716
|
-
|
|
1850
|
+
if (row === undefined)
|
|
1851
|
+
return undefined;
|
|
1852
|
+
const allow = row.record.allowSubscribe;
|
|
1853
|
+
// Legacy rows predate the ceiling field: treat missing as equal to allowSubscribe so behaviour
|
|
1854
|
+
// matches what that row already exposed. New rows always carry an explicit ceiling.
|
|
1855
|
+
const issued = row.record.issuedAllowSubscribe ?? allow;
|
|
1856
|
+
return { allowSubscribe: allow, issuedAllowSubscribe: issued, lifecycleUid: row.lifecycleUid };
|
|
1717
1857
|
}
|
|
1718
1858
|
/** Lazily open the delivery lease/readiness KV (pre-created at `cotal up`; bind, never create). */
|
|
1719
1859
|
async deliveryRegistry() {
|
|
@@ -1798,44 +1938,97 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1798
1938
|
encodeManagerLease(info) {
|
|
1799
1939
|
return new TextEncoder().encode(JSON.stringify(info));
|
|
1800
1940
|
}
|
|
1801
|
-
/** Acquire
|
|
1802
|
-
*
|
|
1803
|
-
*
|
|
1941
|
+
/** Acquire THIS logical instance's liveness lease via ATOMIC CAS create on its own per-instance key
|
|
1942
|
+
* ({@link managerLeaseKey}). THROWS only if that SAME instance id already holds a live key (a same-root
|
|
1943
|
+
* concurrent double-start, or a restart racing the crashed predecessor's not-yet-expired key) — a loud
|
|
1944
|
+
* refusal, never a retry. A DIFFERENT instance (second workspace root ⇒ different id) creates its OWN
|
|
1945
|
+
* key and coexists (P2 item 3 demotion). A crashed holder's key auto-expires (bucket TTL). Returns the
|
|
1946
|
+
* lease revision (for renew). */
|
|
1804
1947
|
async acquireManagerLease(info) {
|
|
1805
|
-
return (await this.managerLeaseRegistry()).create(
|
|
1948
|
+
return (await this.managerLeaseRegistry()).create(managerLeaseKey(info.instanceId), this.encodeManagerLease({ ...info, since: Date.now() }));
|
|
1806
1949
|
}
|
|
1807
|
-
/** Renew
|
|
1808
|
-
* revision moved (lost the lease). Returns the new revision. */
|
|
1950
|
+
/** Renew THIS instance's held key (CAS update against `revision`) before the bucket TTL expires it.
|
|
1951
|
+
* Throws if the revision moved (lost the lease). Returns the new revision. */
|
|
1809
1952
|
async renewManagerLease(info, revision) {
|
|
1810
|
-
return (await this.managerLeaseRegistry()).update(
|
|
1953
|
+
return (await this.managerLeaseRegistry()).update(managerLeaseKey(info.instanceId), this.encodeManagerLease({ ...info, since: Date.now() }), revision);
|
|
1811
1954
|
}
|
|
1812
|
-
/** Release
|
|
1813
|
-
* by `revision`: if we already LOST
|
|
1814
|
-
*
|
|
1815
|
-
async releaseManagerLease(revision) {
|
|
1955
|
+
/** Release THIS instance's key on clean shutdown so a same-id restart re-acquires immediately. CAS-guarded
|
|
1956
|
+
* by `revision`: if we already LOST it (renew gap) the stored revision has moved, the conditional delete
|
|
1957
|
+
* no-ops. Keyed per instance, so a release NEVER touches a sibling manager's key (security pin 6). */
|
|
1958
|
+
async releaseManagerLease(instanceId, revision) {
|
|
1816
1959
|
try {
|
|
1817
1960
|
const kv = await this.managerLeaseRegistry();
|
|
1818
1961
|
if (revision === undefined)
|
|
1819
|
-
await kv.delete(
|
|
1962
|
+
await kv.delete(managerLeaseKey(instanceId));
|
|
1820
1963
|
else
|
|
1821
|
-
await kv.delete(
|
|
1964
|
+
await kv.delete(managerLeaseKey(instanceId), { previousSeq: revision });
|
|
1822
1965
|
}
|
|
1823
1966
|
catch { /* not ours / already gone */ }
|
|
1824
1967
|
}
|
|
1825
|
-
/** Read
|
|
1826
|
-
*
|
|
1968
|
+
/** Read a live manager liveness lease, or undefined if NONE (no manager instance holds the space). A
|
|
1969
|
+
* presence/existence check for the CLI's `spawn -f` reuse and `waitLeaseGone`, which only need "is any
|
|
1970
|
+
* manager here". Open-only — never creates the bucket, so a probe that finds no manager leaves none
|
|
1971
|
+
* behind. (Instance-precise enumeration for the class scatter comes from the registration records KV
|
|
1972
|
+
* in 3b-4, not this liveness bucket.)
|
|
1973
|
+
*
|
|
1974
|
+
* MULTI-INSTANCE EXACT, and it has to be read that way rather than as a point get. Several managers
|
|
1975
|
+
* may hold one space, each renewing its own `lease.<instanceId>`. A single `last_by_subj` over
|
|
1976
|
+
* `lease.*` returns the newest message under the wildcard REGARDLESS OF KEY — so a stopping peer's
|
|
1977
|
+
* DEL tombstone, being newest, answered "no manager here" while a sibling was alive and renewing.
|
|
1978
|
+
* Only an explicit `kv.delete` writes that tombstone: a manager whose lease TTL-expires is removed by
|
|
1979
|
+
* limits and leaves nothing behind, so the poisoning case was the ORDINARY one (stop a manager
|
|
1980
|
+
* cleanly, then `spawn -f`), not the crash. Enumerating live entries collapses to the greatest
|
|
1981
|
+
* revision PER KEY and drops keys whose final state is a marker, so a peer's DEL can only retire that
|
|
1982
|
+
* peer's own key and can never mask a live one. */
|
|
1827
1983
|
async readManagerLease() {
|
|
1828
1984
|
if (!this.nc)
|
|
1829
1985
|
return undefined;
|
|
1830
1986
|
try {
|
|
1831
|
-
const
|
|
1832
|
-
const
|
|
1833
|
-
|
|
1987
|
+
const jsm = this.jsm ?? (this.jsm = await jetstreamManager(this.nc));
|
|
1988
|
+
const stream = `KV_${managerBucket(this.space)}`;
|
|
1989
|
+
const prefix = `$KV.${managerBucket(this.space)}.${MANAGER_LEASE_KEY}.`;
|
|
1990
|
+
// STREAM.INFO to learn WHICH instance keys exist, then one point-get per key. NOT a bucket
|
|
1991
|
+
// scan: `liveKvEntries` binds a push consumer, and this principal's grant on this bucket is
|
|
1992
|
+
// STREAM.INFO + STREAM.MSG.GET + the `lease.*` publish only (`provision.ts`, supervisor). A
|
|
1993
|
+
// consumer here is a permissions violation for the very callers this probe serves, and no
|
|
1994
|
+
// open-mesh test can see that, because an open mesh has no permissions to violate.
|
|
1995
|
+
const info = await jsm.streams.info(stream, { subjects_filter: `${prefix}*` });
|
|
1996
|
+
let newest;
|
|
1997
|
+
for (const subject of Object.keys(info.state.subjects ?? {})) {
|
|
1998
|
+
// `last_by_subj` is CORRECT PER KEY and wrong across keys. Scoped to one instance's subject
|
|
1999
|
+
// it returns that instance's latest state, so a DEL here retires only its own key. The
|
|
2000
|
+
// defect was asking one wildcard for the newest message in the whole subtree, where a
|
|
2001
|
+
// stopping peer's tombstone outranks a live sibling's older PUT.
|
|
2002
|
+
const m = await jsm.streams.getMessage(stream, { last_by_subj: subject }).catch(() => null);
|
|
2003
|
+
if (m === null)
|
|
2004
|
+
continue; // key vanished between INFO and GET: it is not a live holder
|
|
2005
|
+
const op = m.header?.get("KV-Operation");
|
|
2006
|
+
if (op === "DEL" || op === "PURGE" || m.data.length === 0)
|
|
2007
|
+
continue;
|
|
2008
|
+
if (newest === undefined || m.seq > newest.seq)
|
|
2009
|
+
newest = { data: m.data, seq: m.seq };
|
|
2010
|
+
}
|
|
2011
|
+
if (newest === undefined)
|
|
1834
2012
|
return undefined;
|
|
1835
|
-
return
|
|
2013
|
+
return JSON.parse(new TextDecoder().decode(newest.data));
|
|
1836
2014
|
}
|
|
1837
|
-
catch {
|
|
1838
|
-
|
|
2015
|
+
catch (e) {
|
|
2016
|
+
// ABSENCE MUST BE PROVEN, NOT INFERRED FROM A FAILED READ. Exactly two outcomes mean "genuinely
|
|
2017
|
+
// no manager": 10037, no message on the subtree, and a missing bucket — this probe is open-only
|
|
2018
|
+
// and on an authed mesh nothing creates it until a manager first takes a lease, so a space that
|
|
2019
|
+
// has never run one has no stream to read. Every OTHER failure (a JetStream hiccup, a request
|
|
2020
|
+
// timeout, a permissions refusal, or `liveKvEntries` refusing a pass that died mid-delivery) used
|
|
2021
|
+
// to return undefined here as well, and every caller reads undefined as "the space is empty": the
|
|
2022
|
+
// CLI's `spawn -f` reuse stands a SECOND manager up against a live one and `waitLeaseGone` reports
|
|
2023
|
+
// the space free. A read that failed is not evidence of absence, so refuse loudly rather than
|
|
2024
|
+
// degrade (AGENTS.md: no fallbacks). This is the wider of the two doors the point-get fix closes:
|
|
2025
|
+
// it needs only a transient error, where the tombstone path needed a peer to stop.
|
|
2026
|
+
const code = e.code;
|
|
2027
|
+
if (code === 10037)
|
|
2028
|
+
return undefined;
|
|
2029
|
+
if (code === 404 || /stream not found/i.test(e?.message ?? ""))
|
|
2030
|
+
return undefined;
|
|
2031
|
+
throw e;
|
|
1839
2032
|
}
|
|
1840
2033
|
}
|
|
1841
2034
|
/** Privileged: one owner's NON-TOMBSTONED durable memberships as `{channel, generation, activated}` —
|
|
@@ -2047,7 +2240,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2047
2240
|
async startPlane3(aclFor, opts = {}) {
|
|
2048
2241
|
if (!this.js)
|
|
2049
2242
|
throw new Error("endpoint not started");
|
|
2050
|
-
this.plane3 = { aclFor, reloadMembershipCreds: opts.reloadMembershipCreds, evictPrincipal: opts.evictPrincipal, planeConnLiveness: opts.planeConnLiveness };
|
|
2243
|
+
this.plane3 = { aclFor, reloadMembershipCreds: opts.reloadMembershipCreds, evictPrincipal: opts.evictPrincipal, planeConnLiveness: opts.planeConnLiveness, principalLiveness: opts.principalLiveness };
|
|
2051
2244
|
await this.armPlane3();
|
|
2052
2245
|
}
|
|
2053
2246
|
/** Serve one runtime durable-membership control request (the server-side delivery daemon). The caller
|
|
@@ -2061,6 +2254,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2061
2254
|
return this.deliveryJoin(caller, args);
|
|
2062
2255
|
if (req.op === "durableLeave")
|
|
2063
2256
|
return this.deliveryLeave(caller, args);
|
|
2257
|
+
if (req.op === "readHistory")
|
|
2258
|
+
return this.deliveryReadHistory(caller, args);
|
|
2064
2259
|
if (req.op === "listMemberships") {
|
|
2065
2260
|
if (typeof args.lifecycleUid !== "string")
|
|
2066
2261
|
return { ok: false, error: "listMemberships: the caller's lifecycleUid is required (membership rows are lifecycle-keyed, SPEC 13.1)" };
|
|
@@ -2157,6 +2352,107 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2157
2352
|
}
|
|
2158
2353
|
return { ok: true, data: { channel } };
|
|
2159
2354
|
}
|
|
2355
|
+
/** Serve one MEDIATED HISTORY READ (`readHistory`, the client side is {@link readHistory}). The daemon
|
|
2356
|
+
* holds the consumer so the caller does not have to: this is a privilege reduction, not a new
|
|
2357
|
+
* capability, and it is the shape SPEC's "Mediated reads (normative)" rule asks for.
|
|
2358
|
+
*
|
|
2359
|
+
* THE ONE INVARIANT THIS METHOD EXISTS FOR: authorization is read FRESH on every call and never
|
|
2360
|
+
* cached across calls. A consumer pins its authorization when it is created, so a revoked caller
|
|
2361
|
+
* keeps being served by a consumer it already holds; re-reading here is what makes a revocation
|
|
2362
|
+
* stop the very NEXT read. Cache this and the verb loses its only advantage over the raw consumer
|
|
2363
|
+
* path.
|
|
2364
|
+
*
|
|
2365
|
+
* AUTHORITY (SPEC §9.6): "current read ACL" is the effective broker-accepted credential. The
|
|
2366
|
+
* durable registry is a live mirror of that credential, not a second grant source. History
|
|
2367
|
+
* authorizes against `allowSubscribe ∩ issuedAllowSubscribe` — the live row (so revocation via
|
|
2368
|
+
* plain commitAcl still stops the next read) intersected with the mint-time ceiling (so a
|
|
2369
|
+
* registry widen without a remint cannot grant what the JWT does not). Raising the ceiling is
|
|
2370
|
+
* {@link reissueAcl}, which is what provision does when it bakes the list into the JWT.
|
|
2371
|
+
*
|
|
2372
|
+
* The caller arrives as an alias (the control subject carries owner+actor, never a uid) and is the
|
|
2373
|
+
* AUTHENTICATED subject sender — `serveControl` has already fail-closed on any payload that names a
|
|
2374
|
+
* different principal, so `caller` cannot be self-asserted. */
|
|
2375
|
+
async deliveryReadHistory(caller, args) {
|
|
2376
|
+
const channel = this.checkDurableChannelArg(args, "readHistory");
|
|
2377
|
+
if (typeof channel !== "string")
|
|
2378
|
+
return channel; // a ControlReply error
|
|
2379
|
+
// The caller PROPOSES a limit; the mediator decides. Reject a nonsense limit loudly rather than
|
|
2380
|
+
// silently substituting a default — a caller asking for -1 has a bug and should hear about it.
|
|
2381
|
+
const asked = args.limit === undefined ? READ_HISTORY_DEFAULT_LIMIT : args.limit;
|
|
2382
|
+
if (typeof asked !== "number" || !Number.isInteger(asked) || asked < 1)
|
|
2383
|
+
return { ok: false, error: `readHistory: limit must be a positive integer (got ${JSON.stringify(args.limit)})` };
|
|
2384
|
+
const limit = Math.min(asked, READ_HISTORY_MAX_LIMIT);
|
|
2385
|
+
// FRESH per call — see the method doc. Same registry, same alias resolution, and the same loud
|
|
2386
|
+
// refusal on two live rows that `durableJoin` uses: a stale lifecycle must never authorize its
|
|
2387
|
+
// successor's read.
|
|
2388
|
+
//
|
|
2389
|
+
// SPEC §9.6: "current read ACL" is the effective broker-accepted credential. The registry row is
|
|
2390
|
+
// a live mirror and can be rewritten by plain commitAcl without a remint. Authorizing history
|
|
2391
|
+
// from the row ALONE let a widen grant reads channelHistory still broker-denies — a new
|
|
2392
|
+
// capability wearing a privilege-reduction label (panel BLOCKING at 914fd7b0). History therefore
|
|
2393
|
+
// requires the channel in BOTH the live row AND the mint-time ceiling (`issuedAllowSubscribe`).
|
|
2394
|
+
// Revocation still works: narrowing allowSubscribe stops the next read. Raising the ceiling
|
|
2395
|
+
// requires reissueAcl (provision/remint), which is the same act that bakes the list into the JWT.
|
|
2396
|
+
let acl;
|
|
2397
|
+
try {
|
|
2398
|
+
acl = await this.aclForAlias(caller);
|
|
2399
|
+
}
|
|
2400
|
+
catch (e) {
|
|
2401
|
+
return { ok: false, error: e.message };
|
|
2402
|
+
}
|
|
2403
|
+
if (acl === undefined)
|
|
2404
|
+
return { ok: false, error: `readHistory: no read ACL on record for ${caller} - not permitted` };
|
|
2405
|
+
const inLive = channelInAllow(acl.allowSubscribe, channel);
|
|
2406
|
+
const inIssued = channelInAllow(acl.issuedAllowSubscribe, channel);
|
|
2407
|
+
if (!inLive || !inIssued) {
|
|
2408
|
+
// Name the live list in the error — that is what operators edit and what revocation clears.
|
|
2409
|
+
// A ceiling miss (registry widened past the credential) is the same refusal shape as a live
|
|
2410
|
+
// miss so a caller cannot probe which half failed.
|
|
2411
|
+
return {
|
|
2412
|
+
ok: false,
|
|
2413
|
+
error: `readHistory: channel "${channel}" is not within your read ACL [${acl.allowSubscribe.join(", ")}] - refused`,
|
|
2414
|
+
};
|
|
2415
|
+
}
|
|
2416
|
+
// One message MORE than the page is the completeness probe: if the backlog hands back limit+1,
|
|
2417
|
+
// something older exists behind the page and `complete` is false. Cheaper and more honest than a
|
|
2418
|
+
// separate count, which could race the page it describes.
|
|
2419
|
+
let page;
|
|
2420
|
+
try {
|
|
2421
|
+
page = await this.channelHistory(channel, { limit: limit + 1 });
|
|
2422
|
+
}
|
|
2423
|
+
catch (e) {
|
|
2424
|
+
// Surface the read failure. `streamHistory` deliberately raises rather than returning empty on a
|
|
2425
|
+
// denial or a cut-short window, and that distinction must survive the rail: an error here would
|
|
2426
|
+
// otherwise reach the caller as an empty page and render as "no history".
|
|
2427
|
+
return { ok: false, error: `readHistory: ${e.message}` };
|
|
2428
|
+
}
|
|
2429
|
+
const reachedStart = page.length <= limit;
|
|
2430
|
+
const wanted = reachedStart ? page : page.slice(-limit);
|
|
2431
|
+
// A page that cannot be SENT is not a page. The reply rides one NATS message, so `limit` alone is
|
|
2432
|
+
// the wrong bound: 200 large messages serialize past `max_payload`, `m.respond` throws inside
|
|
2433
|
+
// `serveControl`'s swallow, and the caller sees a bare request timeout it cannot tell apart from a
|
|
2434
|
+
// dead daemon. Measured, not predicted: 200 x ~6 KB timed out at 5s with the message "timeout".
|
|
2435
|
+
//
|
|
2436
|
+
// So bound by BYTES too, keeping the NEWEST that fit — which needs no new vocabulary, because
|
|
2437
|
+
// `complete: false` already means "older history remains behind this page". Trimming here is the
|
|
2438
|
+
// documented truncation signal doing its job, not a silent degradation.
|
|
2439
|
+
const fitted = fitHistoryPage(wanted, this.payloadBudget());
|
|
2440
|
+
// `wanted.length > 0` is load-bearing, not defensive: an EMPTY channel also fits nothing, and
|
|
2441
|
+
// without this guard a channel nobody has posted to was refused with "the newest message exceeds
|
|
2442
|
+
// the payload budget" — a confident, entirely wrong explanation for a legitimately empty result.
|
|
2443
|
+
// Genuine emptiness is `{ items: [], complete: true }`; only a message too large to ever send is
|
|
2444
|
+
// the error.
|
|
2445
|
+
if (wanted.length > 0 && fitted.length === 0)
|
|
2446
|
+
return { ok: false, error: `readHistory: the newest message on "${channel}" alone exceeds the broker payload budget (${this.payloadBudget()} bytes) - refused loudly rather than served as an empty page` };
|
|
2447
|
+
return { ok: true, data: { items: fitted, complete: reachedStart && fitted.length === wanted.length } };
|
|
2448
|
+
}
|
|
2449
|
+
/** Bytes a control reply may occupy: the broker's `max_payload` less headroom for the `ControlReply`
|
|
2450
|
+
* envelope wrapped around the items. Read from the live server info rather than assumed, since an
|
|
2451
|
+
* operator can raise or lower it. */
|
|
2452
|
+
payloadBudget() {
|
|
2453
|
+
const max = this.nc?.info?.max_payload ?? 1_048_576;
|
|
2454
|
+
return Math.max(1, Math.floor(max * 0.9));
|
|
2455
|
+
}
|
|
2160
2456
|
/** (Re)bind the Plane-3 fan-out writer + trusted reader. Idempotent — the durables resume from their
|
|
2161
2457
|
* cursor. Called by {@link startPlane3} once AND by {@link connectAndBind} on every (re)connect, so
|
|
2162
2458
|
* the delivery daemon's reconnect RE-ARMS the backstop + the ctl.delivery responder. Without this, a broker blip would silently kill
|
|
@@ -2266,6 +2562,24 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2266
2562
|
return { ok: false, error: e.message };
|
|
2267
2563
|
}
|
|
2268
2564
|
}
|
|
2565
|
+
if (req.op === "principalLiveness") {
|
|
2566
|
+
// The freeze-holder liveness probe (#391): the READ-ONLY half of `evictPrincipal`. A repair
|
|
2567
|
+
// that must REFUSE while the holder is alive cannot use eviction as its own precheck — that
|
|
2568
|
+
// kills the holder before anything can refuse on its behalf. Same executor-hook shape as the
|
|
2569
|
+
// verbs above (the $SYS observer cred lives outside this trust boundary); absent hook =
|
|
2570
|
+
// refused loudly, and the caller maps a refusal to UNKNOWN and never repairs over it.
|
|
2571
|
+
if (!this.plane3?.principalLiveness)
|
|
2572
|
+
return { ok: false, error: "principalLiveness: no liveness oracle wired on this daemon" };
|
|
2573
|
+
const principal = typeof req.args?.principal === "string" ? req.args.principal.trim() : "";
|
|
2574
|
+
if (!principal)
|
|
2575
|
+
return { ok: false, error: "principalLiveness: a principal (owner.actor dot-form) is required" };
|
|
2576
|
+
try {
|
|
2577
|
+
return { ok: true, data: await this.plane3.principalLiveness(principal) };
|
|
2578
|
+
}
|
|
2579
|
+
catch (e) {
|
|
2580
|
+
return { ok: false, error: e.message };
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2269
2583
|
return { ok: false, error: `op "${req.op}" not supported on the delivery admin service` };
|
|
2270
2584
|
}
|
|
2271
2585
|
/** Fan-out loop: bind the privileged `fanout` durable on CHAT and route each message (routing only —
|
|
@@ -3390,6 +3704,13 @@ function isEndpointRef(value) {
|
|
|
3390
3704
|
return isRecord(value) && typeof value.id === "string" && typeof value.name === "string" &&
|
|
3391
3705
|
(value.role === undefined || typeof value.role === "string");
|
|
3392
3706
|
}
|
|
3707
|
+
/** A conformant message part: the three CORE kinds, or a reverse-DNS extension kind (SPEC §5).
|
|
3708
|
+
*
|
|
3709
|
+
* Reached from {@link isCotalMessage}, which gates the Plane-3 delivery frame — so a core kind
|
|
3710
|
+
* missing an arm here is not a schema nicety: the durable backstop DROPS every message carrying
|
|
3711
|
+
* it, silently, and the drop surfaces nowhere near the feature that added the part. `artifact`
|
|
3712
|
+
* was exactly that case before it was added (a bare kind has no dot, so it fell through to the
|
|
3713
|
+
* extension regex and failed). */
|
|
3393
3714
|
function isMessagePart(value) {
|
|
3394
3715
|
if (!isRecord(value) || typeof value.kind !== "string")
|
|
3395
3716
|
return false;
|
|
@@ -3397,6 +3718,8 @@ function isMessagePart(value) {
|
|
|
3397
3718
|
return typeof value.text === "string";
|
|
3398
3719
|
if (value.kind === "data")
|
|
3399
3720
|
return Object.prototype.hasOwnProperty.call(value, "data");
|
|
3721
|
+
if (value.kind === ARTIFACT_PART_KIND)
|
|
3722
|
+
return isArtifactPart(value);
|
|
3400
3723
|
return /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(value.kind);
|
|
3401
3724
|
}
|
|
3402
3725
|
function isRecord(value) {
|
|
@@ -3501,6 +3824,33 @@ export function isPermissionDenied(e) {
|
|
|
3501
3824
|
return true;
|
|
3502
3825
|
return /permissions?\s+violation/i.test(String(e?.message ?? ""));
|
|
3503
3826
|
}
|
|
3827
|
+
/** True ONLY for a denial on a **publish** — the single case that proves the message was never
|
|
3828
|
+
* ACCEPTED or stored. (Not "never reached the server": the server necessarily received enough of
|
|
3829
|
+
* it to reject it. The distinction matters precisely here, because this helper exists to separate
|
|
3830
|
+
* provably-not-stored from possibly-stored, and the looser phrasing overstates the very thing
|
|
3831
|
+
* being measured.) {@link isPermissionDenied} deliberately does not look at the operation: it exists to
|
|
3832
|
+
* separate "denied" from "service down", and that answer is the same either way. The operation
|
|
3833
|
+
* matters enormously to a caller that reports *delivery*, because a JetStream publish is
|
|
3834
|
+
* request/PubAck and the subscription half is the reply inbox — a denial THERE rejects
|
|
3835
|
+
* `js.publish()` while the stream may already hold the message. Verified against a live broker: a
|
|
3836
|
+
* user allowed to publish but denied its `_INBOX` subscription got
|
|
3837
|
+
* `Permissions Violation for Subscription to "_INBOX.….*"` back from `js.publish()`, and an
|
|
3838
|
+
* unrestricted observer then read `messages: 1` off the stream.
|
|
3839
|
+
*
|
|
3840
|
+
* Note what is deliberately NOT accepted: the untyped text fallback above. A permission-shaped
|
|
3841
|
+
* message string carries no operation, so it cannot prove non-delivery, and guessing "publish"
|
|
3842
|
+
* from wording would reintroduce exactly the false certainty this exists to prevent. Anything not
|
|
3843
|
+
* provably a publish denial is unknown, and a caller reporting delivery must fail toward
|
|
3844
|
+
* "I could not confirm" rather than toward "it did not happen" — the costly mistake is telling
|
|
3845
|
+
* someone to re-send a message that was in fact stored. */
|
|
3846
|
+
export function isPublishPermissionDenied(e) {
|
|
3847
|
+
const typed = e instanceof PermissionViolationError
|
|
3848
|
+
? e
|
|
3849
|
+
: e?.cause instanceof PermissionViolationError
|
|
3850
|
+
? (e.cause)
|
|
3851
|
+
: undefined;
|
|
3852
|
+
return typed?.operation === "publish";
|
|
3853
|
+
}
|
|
3504
3854
|
/** Parse a NATS server URL (`nats://host:port`, `host:port`, a bare host, or a comma list — the
|
|
3505
3855
|
* first entry wins) into a host+port for {@link tcpInfoProbe}. Defaults the port to 4222. */
|
|
3506
3856
|
function hostPort(server) {
|
|
@@ -3568,6 +3918,51 @@ function tcpInfoProbe(server, timeoutMs) {
|
|
|
3568
3918
|
socket.on("close", () => finish(false)); // closed before a full INFO line arrived
|
|
3569
3919
|
});
|
|
3570
3920
|
}
|
|
3921
|
+
/** Bounded TCP reachability on a socket we OWN: does a handshake to `server` complete within
|
|
3922
|
+
* `timeoutMs`? Deliberately narrower than {@link tcpInfoProbe} — it asks only whether the
|
|
3923
|
+
* transport can be reached, never whether NATS is speaking there, so the TLS-first listener that
|
|
3924
|
+
* `tcpInfoProbe` reports false for still passes this gate and goes on to a real connect.
|
|
3925
|
+
*
|
|
3926
|
+
* It exists because `connect()` cannot be trusted to release a connection it never established.
|
|
3927
|
+
* `@nats-io/transport-node`'s `NodeTransport.dial()` keeps its socket in a local until the
|
|
3928
|
+
* handshake resolves (`this.socket = await this.dial(hp)`), so `this.socket` is STILL UNDEFINED
|
|
3929
|
+
* when the client's own connect timeout wins the race in `protocol.ts`'s `dial` and the catch
|
|
3930
|
+
* calls `transport.close()` — whose teardown is `this.socket?.destroy()`, i.e. a destroy of
|
|
3931
|
+
* nothing. Against an address that BLACKHOLES (SYN unanswered) rather than REFUSES (RST), the
|
|
3932
|
+
* socket is orphaned in libuv until the OS SYN timeout, and the process cannot exit for minutes
|
|
3933
|
+
* after the probe already returned its answer. That is issue #389, and it is upstream: nothing a
|
|
3934
|
+
* caller passes (`reconnect: false`, `timeout`) reaches the orphan. Our socket, our `destroy()`,
|
|
3935
|
+
* on every exit path — never an `unref`/force-exit, which would hide the symptom and a future
|
|
3936
|
+
* real hang with it. */
|
|
3937
|
+
function tcpDialable(server, timeoutMs) {
|
|
3938
|
+
return new Promise((resolve) => {
|
|
3939
|
+
let socket;
|
|
3940
|
+
let done = false;
|
|
3941
|
+
const finish = (dialable) => {
|
|
3942
|
+
if (done)
|
|
3943
|
+
return;
|
|
3944
|
+
done = true;
|
|
3945
|
+
try {
|
|
3946
|
+
socket?.destroy();
|
|
3947
|
+
}
|
|
3948
|
+
catch { /* already gone */ }
|
|
3949
|
+
resolve(dialable);
|
|
3950
|
+
};
|
|
3951
|
+
let host, port;
|
|
3952
|
+
try {
|
|
3953
|
+
({ host, port } = hostPort(server));
|
|
3954
|
+
}
|
|
3955
|
+
catch {
|
|
3956
|
+
return resolve(false);
|
|
3957
|
+
}
|
|
3958
|
+
socket = createConnection({ host, port });
|
|
3959
|
+
socket.setTimeout(timeoutMs);
|
|
3960
|
+
socket.on("connect", () => finish(true));
|
|
3961
|
+
socket.on("timeout", () => finish(false)); // blackhole: SYN unanswered inside our deadline
|
|
3962
|
+
socket.on("error", () => finish(false)); // refused / reset / DNS failure
|
|
3963
|
+
socket.on("close", () => finish(false));
|
|
3964
|
+
});
|
|
3965
|
+
}
|
|
3571
3966
|
/** Whether a NATS server is *running* at `servers`. With NO creds this is a SILENT plaintext
|
|
3572
3967
|
* liveness check ({@link tcpInfoProbe}): it reads the server's pre-auth `INFO` greeting and closes
|
|
3573
3968
|
* WITHOUT authenticating, so a live broker (open OR auth — INFO precedes auth) returns true while
|
|
@@ -3577,12 +3972,22 @@ function tcpInfoProbe(server, timeoutMs) {
|
|
|
3577
3972
|
* refuses these creds is still up — so the caller surfaces the real auth failure, and `up` won't
|
|
3578
3973
|
* start a duplicate on the bound port). Only a genuine connection failure (refused/timeout) is false. */
|
|
3579
3974
|
export async function isReachable(servers = DEFAULT_SERVER, opts = {}) {
|
|
3975
|
+
const timeoutMs = opts.timeoutMs ?? 1000;
|
|
3580
3976
|
if (!opts.creds && !opts.token && !opts.user && !opts.pass && !opts.tls)
|
|
3581
|
-
return tcpInfoProbe(servers,
|
|
3977
|
+
return tcpInfoProbe(servers, timeoutMs);
|
|
3978
|
+
// The credless branch above already owns its socket. This one reaches `connect()`, so it carries
|
|
3979
|
+
// the same orphaned-socket defect probeConnect did (#389) and takes the same gate: reach the
|
|
3980
|
+
// address on a socket we own first, and give `connect()` the remainder of the budget its own
|
|
3981
|
+
// timeout always covered. A gate failure is a genuine connection failure, which is exactly the
|
|
3982
|
+
// `false` the catch below already returns for one — an auth rejection cannot reach us from an
|
|
3983
|
+
// address that never completed a handshake.
|
|
3984
|
+
const started = Date.now();
|
|
3985
|
+
if (!(await tcpDialable(servers, timeoutMs)))
|
|
3986
|
+
return false;
|
|
3582
3987
|
try {
|
|
3583
3988
|
const nc = await connect({
|
|
3584
3989
|
servers,
|
|
3585
|
-
timeout:
|
|
3990
|
+
timeout: Math.max(1, timeoutMs - (Date.now() - started)),
|
|
3586
3991
|
reconnect: false,
|
|
3587
3992
|
maxReconnectAttempts: 0,
|
|
3588
3993
|
...authOpts(opts),
|
|
@@ -3600,10 +4005,22 @@ export async function isReachable(servers = DEFAULT_SERVER, opts = {}) {
|
|
|
3600
4005
|
* specific identity is accepted (`ok`); omit them to probe mere liveness (an auth broker answers
|
|
3601
4006
|
* `auth-required`, which still proves it's up). */
|
|
3602
4007
|
export async function probeConnect(server = DEFAULT_SERVER, opts = {}) {
|
|
4008
|
+
const timeoutMs = opts.timeoutMs ?? 1000;
|
|
4009
|
+
const started = Date.now();
|
|
4010
|
+
// Reach the address on a socket we own BEFORE handing it to `connect()`, which orphans the
|
|
4011
|
+
// connection it never established (see {@link tcpDialable} for the upstream mechanism, #389).
|
|
4012
|
+
// This cannot change any verdict: every address that gets past here had to complete a TCP
|
|
4013
|
+
// handshake for `connect()` to have gotten anywhere either, and a gate failure is routed through
|
|
4014
|
+
// the SAME classification the catch uses — so a locally-dead cred is still `stale-auth` and not
|
|
4015
|
+
// silently downgraded to `unreachable` by the address being dark. The cost is one extra
|
|
4016
|
+
// handshake on the reachable path; the deadline below is the REMAINDER of the budget, because
|
|
4017
|
+
// `connect()`'s own `timeout` always covered its handshake too.
|
|
4018
|
+
if (!(await tcpDialable(server, timeoutMs)))
|
|
4019
|
+
return classifyProbeFailure(undefined, opts);
|
|
3603
4020
|
try {
|
|
3604
4021
|
const nc = await connect({
|
|
3605
4022
|
servers: server,
|
|
3606
|
-
timeout:
|
|
4023
|
+
timeout: Math.max(1, timeoutMs - (Date.now() - started)),
|
|
3607
4024
|
reconnect: false,
|
|
3608
4025
|
maxReconnectAttempts: 0,
|
|
3609
4026
|
...authOpts(opts),
|
|
@@ -3612,26 +4029,33 @@ export async function probeConnect(server = DEFAULT_SERVER, opts = {}) {
|
|
|
3612
4029
|
return { ok: true };
|
|
3613
4030
|
}
|
|
3614
4031
|
catch (e) {
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
4032
|
+
return classifyProbeFailure(e, opts);
|
|
4033
|
+
}
|
|
4034
|
+
}
|
|
4035
|
+
/** Why a {@link probeConnect} attempt did not end in `ok`. Shared by the pre-connect reachability
|
|
4036
|
+
* gate and the connect catch so both classify identically — the gate must never turn a diagnosable
|
|
4037
|
+
* credential death into a bare `unreachable` just because the address went dark.
|
|
4038
|
+
*
|
|
4039
|
+
* A presented cred that is PROVABLY expired by its own JWT is stale-auth (credential death) — and
|
|
4040
|
+
* that is knowable LOCALLY, without the network, so it is decided FIRST, before the error type. On
|
|
4041
|
+
* the wire the broker's rejection and the socket close race: a slow CI/Windows handshake can
|
|
4042
|
+
* surface a bare transport failure (→ "unreachable") instead of a clean AuthorizationError, which
|
|
4043
|
+
* used to misclassify a dead cred. Reading the cred removes that timing dependency entirely, so the
|
|
4044
|
+
* classification is deterministic. Unreadable content falls through to the wire truth (no false
|
|
4045
|
+
* stale diagnosis from garbage). `e` is undefined when the gate refused before any connect. */
|
|
4046
|
+
function classifyProbeFailure(e, opts) {
|
|
4047
|
+
if (typeof opts.creds === "string") {
|
|
4048
|
+
try {
|
|
4049
|
+
if (inspectCredHealth(opts.creds).state === "expired")
|
|
4050
|
+
return { ok: false, reason: "stale-auth" };
|
|
3628
4051
|
}
|
|
3629
|
-
|
|
3630
|
-
return { ok: false, reason: "stale-auth" };
|
|
3631
|
-
// The broker answered but rejected these creds (so it IS up) — auth-required, not stale-auth.
|
|
3632
|
-
if (e instanceof AuthorizationError)
|
|
3633
|
-
return { ok: false, reason: "auth-required" };
|
|
3634
|
-
return { ok: false, reason: "unreachable" };
|
|
4052
|
+
catch { /* not introspectable — keep the wire truth */ }
|
|
3635
4053
|
}
|
|
4054
|
+
if (e instanceof UserAuthenticationExpiredError)
|
|
4055
|
+
return { ok: false, reason: "stale-auth" };
|
|
4056
|
+
// The broker answered but rejected these creds (so it IS up) — auth-required, not stale-auth.
|
|
4057
|
+
if (e instanceof AuthorizationError)
|
|
4058
|
+
return { ok: false, reason: "auth-required" };
|
|
4059
|
+
return { ok: false, reason: "unreachable" };
|
|
3636
4060
|
}
|
|
3637
4061
|
//# sourceMappingURL=endpoint.js.map
|