@cotal-ai/core 0.15.0 → 0.17.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 +75 -12
- 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.d.ts.map +1 -1
- package/dist/channels.js +18 -9
- 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 +24 -1
- package/dist/endpoint-contract-store.d.ts.map +1 -1
- package/dist/endpoint-contract-store.js +79 -27
- 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 +48 -7
- package/dist/endpoint-grants.d.ts.map +1 -1
- package/dist/endpoint-grants.js +97 -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 +340 -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 +10 -1
- package/dist/endpoint-subjects.d.ts.map +1 -1
- package/dist/endpoint-subjects.js +10 -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 +169 -15
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +491 -43
- package/dist/endpoint.js.map +1 -1
- package/dist/identity.d.ts +19 -0
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +12 -0
- package/dist/identity.js.map +1 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/kv-scan.d.ts +105 -0
- package/dist/kv-scan.d.ts.map +1 -0
- package/dist/kv-scan.js +192 -0
- package/dist/kv-scan.js.map +1 -0
- 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/members.d.ts +8 -4
- package/dist/members.d.ts.map +1 -1
- package/dist/members.js +19 -9
- package/dist/members.js.map +1 -1
- 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 +106 -9
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +506 -110
- 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 +53 -1
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +135 -8
- package/dist/streams.js.map +1 -1
- package/dist/subjects.d.ts +32 -26
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +38 -25
- 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,15 +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";
|
|
9
|
+
import { liveKvEntries } from "./kv-scan.js";
|
|
10
|
+
import { ARTIFACT_PART_KIND, isArtifactPart } from "./artifact.js";
|
|
7
11
|
import { assertValidName } from "./resolve.js";
|
|
8
12
|
import { createSpaceStreams, dmDurableConfig, dlvDurableConfig, taskDurableConfig, fanoutDurableConfig, inboxReaderConfig, MAX_MSGS_PER_SUBJECT, MANAGER_LEASE_TTL_MS } from "./streams.js";
|
|
9
13
|
import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, } from "@nats-io/jetstream";
|
|
10
14
|
import { Kvm } from "@nats-io/kv";
|
|
11
15
|
import { openMembersRegistry, commitMember, tombstoneMember, activateMember, readMember, listMembers, durableEligible, StaleMembershipWrite, } from "./members.js";
|
|
12
|
-
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";
|
|
13
17
|
import { openDeliveryRegistry } from "./lease.js";
|
|
14
18
|
import { openChannelRegistry, effectiveReplay, effectiveReplayWindowMs, effectiveDeliveryClass, readChannelConfig, readChannelDefaults, } from "./channels.js";
|
|
15
|
-
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";
|
|
16
20
|
export const DEFAULT_SERVER = "nats://127.0.0.1:4222";
|
|
17
21
|
const PLANE3_FRAME_HEADER = "Cotal-Delivery-Frame";
|
|
18
22
|
/** Space joined when none is given on the CLI (the `cotal-<space>` cmux tab, etc.). */
|
|
@@ -33,6 +37,33 @@ export const DEFAULT_SPACE = "main";
|
|
|
33
37
|
* (unknown owner) or DELIVER transfer is `term()`d + surfaced after this many redeliveries, so one
|
|
34
38
|
* stuck/poison entry can't head-of-line the single shared reader forever. */
|
|
35
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
|
+
}
|
|
36
67
|
export class CotalEndpoint extends EventEmitter {
|
|
37
68
|
card;
|
|
38
69
|
space;
|
|
@@ -170,6 +201,14 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
170
201
|
actor;
|
|
171
202
|
/** This incarnation's lifecycle UID (opts.lifecycleUid) — see {@link EndpointOptions.lifecycleUid}. */
|
|
172
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
|
+
}
|
|
173
212
|
/** The endpoint's own lifecycle UID, REQUIRED for every lifecycle-keyed messaging resource; absent
|
|
174
213
|
* ⇒ loud refusal naming the operation (the hard cut of SPEC §13.1 — no alias-keyed fallback). */
|
|
175
214
|
requireLifecycleUid(what) {
|
|
@@ -1021,6 +1060,55 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1021
1060
|
const m = await this.nc.request(reqSubject, JSON.stringify(body), { timeout: timeoutMs, noMux: true, reply });
|
|
1022
1061
|
return m.json();
|
|
1023
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
|
+
}
|
|
1024
1112
|
/** Send a durable-membership request to the SERVER-SIDE delivery daemon (`ctl.delivery`) and await its
|
|
1025
1113
|
* reply. Unlike {@link requestControl}, the reply rides a subject UNDER `ctl.delivery.<id>.>` (not the
|
|
1026
1114
|
* per-id `_INBOX`), so the scoped delivery cred can answer without broad inbox-publish — see
|
|
@@ -1329,11 +1417,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1329
1417
|
const kv = await this.membershipFeedRegistry();
|
|
1330
1418
|
const members = [];
|
|
1331
1419
|
let asOf;
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1420
|
+
// ONE pass. This was `kv.keys()` followed by a sequential `kv.get()` per key — O(N) round trips,
|
|
1421
|
+
// measured at 30-34s for 89 entries against a mesh at 534ms RTT. `liveKvEntries` is ~3 round
|
|
1422
|
+
// trips regardless of N, and (unlike the old loop) refuses to return a truncated view rather
|
|
1423
|
+
// than reporting a partial roster as the whole one.
|
|
1424
|
+
for (const e of await liveKvEntries(kv)) {
|
|
1425
|
+
if (e.key === MEMBERSHIP_FEED_KEY) {
|
|
1337
1426
|
try {
|
|
1338
1427
|
asOf = e.json().observedAt;
|
|
1339
1428
|
}
|
|
@@ -1342,7 +1431,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1342
1431
|
}
|
|
1343
1432
|
try {
|
|
1344
1433
|
const rec = e.json();
|
|
1345
|
-
members.push({ id: key, live: rec.live ?? [], durable: rec.durable ?? [], observedAt: rec.observedAt });
|
|
1434
|
+
members.push({ id: e.key, live: rec.live ?? [], durable: rec.durable ?? [], observedAt: rec.observedAt });
|
|
1346
1435
|
}
|
|
1347
1436
|
catch { /* skip undecodable */ }
|
|
1348
1437
|
}
|
|
@@ -1366,6 +1455,45 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1366
1455
|
// history from any sender
|
|
1367
1456
|
return this.streamHistory(chatStream(this.space), chatSubject(this.space, "*", "*", channel), opts?.limit ?? 100);
|
|
1368
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
|
+
}
|
|
1369
1497
|
/** Fetch recent DMs (any sender→any recipient) from the space's DM backlog. God-view only:
|
|
1370
1498
|
* a normal agent/observer's ACL denies CONSUMER.CREATE on DM_<space>, so this throws-and-
|
|
1371
1499
|
* skips for them — only an `admin`-profile cred can read it. */
|
|
@@ -1373,34 +1501,175 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1373
1501
|
// every inst.<recipOwner>.<recipActor>.<sndOwner>.<sndActor> DM — the whole DM subtree (god-view)
|
|
1374
1502
|
return this.streamHistory(dmStream(this.space), `${spacePrefix(this.space)}.inst.>`, opts?.limit ?? 100);
|
|
1375
1503
|
}
|
|
1376
|
-
/**
|
|
1377
|
-
*
|
|
1378
|
-
*
|
|
1379
|
-
*
|
|
1380
|
-
|
|
1504
|
+
/**
|
|
1505
|
+
* The `limit` MOST RECENT messages matching `subject`, oldest-first within the page.
|
|
1506
|
+
*
|
|
1507
|
+
* **This used to return the OLDEST N.** `js.consumers.get(stream, {...})` builds an ORDERED
|
|
1508
|
+
* consumer, which defaults to `DeliverPolicy.StartSequence` with `opt_start_seq: 1` — the very
|
|
1509
|
+
* beginning of the stream. Capping the fetch at `limit` therefore took the first N messages ever
|
|
1510
|
+
* sent, while this method is documented as "recent" and every caller (the dashboard feed, the
|
|
1511
|
+
* agent-facing history tools) presents the result as the latest. Confirmed live against a
|
|
1512
|
+
* 123-message channel: `limit=10` returned the ten oldest, not the ten newest.
|
|
1513
|
+
*
|
|
1514
|
+
* Fixing it by draining from the start and keeping the tail would be correct and ruinous: it
|
|
1515
|
+
* transfers the entire backlog to show one screen. Instead, find the newest matching sequence and
|
|
1516
|
+
* consume a WINDOW ending there, widening geometrically until the window holds `limit` matches.
|
|
1517
|
+
* A filtered subject's sequences are non-contiguous (other channels interleave in the same
|
|
1518
|
+
* stream), so the window cannot be computed arithmetically. A FAILED attempt holds fewer than a
|
|
1519
|
+
* page by definition, so wasted transfer stays page-sized and geometric growth keeps the number of
|
|
1520
|
+
* attempts logarithmic. The one unbounded case is named in the body: a channel whose matches are
|
|
1521
|
+
* all old and sparse walks back to the start of the stream and reads its whole retained set.
|
|
1522
|
+
*
|
|
1523
|
+
* `before` pages toward the past: pass the `seq` of the oldest message you already have.
|
|
1524
|
+
*/
|
|
1525
|
+
async streamHistory(stream, subject, limit, before) {
|
|
1381
1526
|
if (!this.nc)
|
|
1382
1527
|
throw new Error("endpoint not started");
|
|
1528
|
+
if (limit <= 0)
|
|
1529
|
+
return [];
|
|
1383
1530
|
const js = jetstream(this.nc);
|
|
1384
|
-
const msgs = [];
|
|
1385
1531
|
try {
|
|
1386
|
-
|
|
1387
|
-
|
|
1532
|
+
// THE EXACT CEILING, on the already-granted surface. A one-shot consumer with
|
|
1533
|
+
// `DeliverPolicy.Last` plus this subject's filter reports the newest MATCHING sequence, and its
|
|
1534
|
+
// `num_pending` of zero means the channel is genuinely empty. Using the STREAM's last sequence
|
|
1535
|
+
// instead (which is all `STREAM.INFO` offers) was a loose upper bound, and on a quiet channel
|
|
1536
|
+
// in a busy stream the gap between the two is the whole problem: every window near the stream
|
|
1537
|
+
// head is empty, so the search widened over and over before finding anything.
|
|
1538
|
+
//
|
|
1539
|
+
// Deliberately NOT `getMessage({ last_by_subj })`, which would be the obvious way to ask: it
|
|
1540
|
+
// needs `$JS.API.STREAM.MSG.GET`, which read credentials do not hold. That grant hole already
|
|
1541
|
+
// shipped once from this function and turned every non-admin history read into an empty list.
|
|
1542
|
+
const ceiling = before !== undefined ? before - 1 : await this.lastMatchingSeq(js, stream, subject);
|
|
1543
|
+
if (ceiling < 1)
|
|
1544
|
+
return [];
|
|
1545
|
+
// Widen from the exact ceiling until a window holds a full page, or until the window IS the
|
|
1546
|
+
// whole subject. Draining each attempt is bounded, and that is the point: a window only fails
|
|
1547
|
+
// when it holds FEWER than `limit` matches, so every wasted drain moves less than one page.
|
|
1548
|
+
// Geometric growth keeps the number of attempts logarithmic, so total wasted transfer is a
|
|
1549
|
+
// small multiple of a page.
|
|
1550
|
+
//
|
|
1551
|
+
// NAMED POLICY for the remaining case: when a channel's matches are all old and sparse, the
|
|
1552
|
+
// search walks back to sequence 1 and the final drain transfers that subject's whole retained
|
|
1553
|
+
// set. That is chosen deliberately — a FULL page of genuinely recent messages, at the cost of
|
|
1554
|
+
// an unbounded read on a channel that has not been used in a long time — over returning a
|
|
1555
|
+
// short page while older messages exist. The exact ceiling above means this is now reached
|
|
1556
|
+
// only by real sparsity WITHIN a channel, never by the channel simply being quiet lately.
|
|
1557
|
+
let span = Math.max(limit * 4, 64);
|
|
1558
|
+
for (;;) {
|
|
1559
|
+
const start = Math.max(1, ceiling - span + 1);
|
|
1560
|
+
const page = await this.drainWindow(js, stream, subject, start, ceiling);
|
|
1561
|
+
if (page.length >= limit || start === 1)
|
|
1562
|
+
return page.slice(-limit);
|
|
1563
|
+
span *= 4;
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
catch (e) {
|
|
1567
|
+
// NARROW. This catch is how the MSG.GET grant bug shipped: it turned a Permissions Violation
|
|
1568
|
+
// into an empty history, so every non-admin read looked exactly like a quiet channel, and the
|
|
1569
|
+
// smokes stayed green because they run as admin against a local broker. Emitting on the error
|
|
1570
|
+
// event is not enough either — callers consume the RETURN VALUE, and the dashboard renders
|
|
1571
|
+
// empty regardless of what an operator log says.
|
|
1572
|
+
//
|
|
1573
|
+
// So only two things may still produce an empty result:
|
|
1574
|
+
// 1. The stream does not exist (a space with no history yet).
|
|
1575
|
+
// 2. A permission denial on the DM backlog specifically. `dmHistory` is god-view by
|
|
1576
|
+
// contract: a normal agent's ACL denies it, and returning empty there is documented
|
|
1577
|
+
// behaviour, not a bug being hidden.
|
|
1578
|
+
// Anything else — a denial on CHAT, a timeout, a 503, a protocol or consumer-create failure —
|
|
1579
|
+
// is raised, because "no history" and "I could not read the history" are different answers and
|
|
1580
|
+
// only one of them is safe to render as an empty conversation.
|
|
1581
|
+
const msg = String(e?.message ?? "");
|
|
1582
|
+
if (/stream not found/i.test(msg) || e?.code === 404)
|
|
1583
|
+
return [];
|
|
1584
|
+
if (isPermissionDenied(e) && stream === dmStream(this.space))
|
|
1585
|
+
return [];
|
|
1586
|
+
throw e;
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
/** The newest stream sequence matching `subject`, or 0 when the subject has no messages.
|
|
1590
|
+
*
|
|
1591
|
+
* One ordered consumer at `DeliverPolicy.Last` with this subject's filter: its `num_pending`
|
|
1592
|
+
* (available from the create, before anything is delivered) is 0 for an empty subject, and
|
|
1593
|
+
* otherwise one message carries the sequence. Same CREATE/INFO/NEXT/DELETE surface `drainWindow`
|
|
1594
|
+
* already uses, so no broker authority is added. */
|
|
1595
|
+
async lastMatchingSeq(js, stream, subject) {
|
|
1596
|
+
const consumer = await js.consumers.get(stream, {
|
|
1597
|
+
filter_subjects: [subject],
|
|
1598
|
+
deliver_policy: DeliverPolicy.Last,
|
|
1599
|
+
});
|
|
1600
|
+
try {
|
|
1601
|
+
// Bind-time zero is the ONLY thing that means "this subject has no messages".
|
|
1602
|
+
if ((await consumer.info(true)).num_pending === 0)
|
|
1603
|
+
return 0;
|
|
1604
|
+
const iter = await consumer.fetch({ max_messages: 1 });
|
|
1605
|
+
for await (const m of iter)
|
|
1606
|
+
return m.seq;
|
|
1607
|
+
// Bind said a message was pending and none arrived. The pinned client's pull iterator ends
|
|
1608
|
+
// CLEANLY when the connection closes ("we don't propagate the error here"), so this is what a
|
|
1609
|
+
// dropped link looks like from here. Returning 0 would make the caller report an empty
|
|
1610
|
+
// channel, which is the same "could not read means no history" lie the narrowed catch above
|
|
1611
|
+
// exists to stop.
|
|
1612
|
+
throw new Error(`history: the broker reported messages on ${subject} but delivered none - the read was cut short, not empty`);
|
|
1613
|
+
}
|
|
1614
|
+
finally {
|
|
1615
|
+
await consumer.delete().catch(() => { });
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
/** Drain every message matching `subject` with sequence in `[start, ceiling]`, oldest-first.
|
|
1619
|
+
* One ephemeral ordered consumer, one batched pull — `AckPolicy.None`, so no per-message ack
|
|
1620
|
+
* round trip. Fetches exactly the pending count so it returns as soon as the window is
|
|
1621
|
+
* delivered rather than blocking for the pull's full expiry. */
|
|
1622
|
+
async drainWindow(js, stream, subject, start, ceiling) {
|
|
1623
|
+
const out = [];
|
|
1624
|
+
const consumer = await js.consumers.get(stream, { filter_subjects: [subject], opt_start_seq: start });
|
|
1625
|
+
try {
|
|
1626
|
+
// A freshly created consumer already carries its ConsumerInfo, so read the CACHED copy: the
|
|
1627
|
+
// explicit uncached `info()` this used to call was a round trip for data we already had.
|
|
1628
|
+
const pending = (await consumer.info(true)).num_pending;
|
|
1388
1629
|
if (pending === 0)
|
|
1389
|
-
return
|
|
1630
|
+
return out;
|
|
1390
1631
|
const iter = await consumer.fetch({ max_messages: pending });
|
|
1632
|
+
// PROVE THE WINDOW COMPLETED. The pull iterator ends cleanly on a dropped connection, so a
|
|
1633
|
+
// close after three of ten deliveries would otherwise return a convincing three-message page.
|
|
1634
|
+
// The window is done when we have reached its upper bound or consumed everything bind said
|
|
1635
|
+
// was pending; anything else is a cut-short read and must say so.
|
|
1636
|
+
let delivered = 0;
|
|
1637
|
+
let complete = false;
|
|
1391
1638
|
for await (const m of iter) {
|
|
1639
|
+
delivered++;
|
|
1640
|
+
if (m.seq >= ceiling) { // reached the page's upper bound
|
|
1641
|
+
if (m.seq === ceiling) {
|
|
1642
|
+
try {
|
|
1643
|
+
out.push(m.json());
|
|
1644
|
+
}
|
|
1645
|
+
catch { /* skip undecodable */ }
|
|
1646
|
+
}
|
|
1647
|
+
complete = true;
|
|
1648
|
+
break;
|
|
1649
|
+
}
|
|
1392
1650
|
try {
|
|
1393
|
-
|
|
1651
|
+
out.push(m.json());
|
|
1394
1652
|
}
|
|
1395
1653
|
catch {
|
|
1396
1654
|
/* skip undecodable */
|
|
1397
1655
|
}
|
|
1656
|
+
if (delivered >= pending) {
|
|
1657
|
+
complete = true;
|
|
1658
|
+
break;
|
|
1659
|
+
}
|
|
1398
1660
|
}
|
|
1661
|
+
if (!complete)
|
|
1662
|
+
throw new Error(`history: read ${delivered} of ${pending} messages on ${subject} before the stream ended early - the window was cut short, not empty`);
|
|
1663
|
+
return out;
|
|
1399
1664
|
}
|
|
1400
|
-
|
|
1401
|
-
|
|
1665
|
+
finally {
|
|
1666
|
+
// DELETE THE EPHEMERAL CONSUMER. The pinned client gives an ordered consumer a 5-minute
|
|
1667
|
+
// inactive threshold, so leaving them behind is not free: the widening search below can make
|
|
1668
|
+
// up to eight per call, the dashboard makes one call per channel, and a reload repeats it.
|
|
1669
|
+
// Left alone that accumulates consumers on the broker until the thresholds expire, and the
|
|
1670
|
+
// resulting resource exhaustion would land in streamHistory's catch and read as empty history.
|
|
1671
|
+
await consumer.delete().catch(() => { });
|
|
1402
1672
|
}
|
|
1403
|
-
return msgs;
|
|
1404
1673
|
}
|
|
1405
1674
|
// ---- internals -----------------------------------------------------------
|
|
1406
1675
|
/**
|
|
@@ -1557,6 +1826,14 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1557
1826
|
async commitAcl(targetId, lifecycleUid, allowSubscribe) {
|
|
1558
1827
|
await writeAclRecord(await this.aclRegistry(), targetId, lifecycleUid, allowSubscribe);
|
|
1559
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
|
+
}
|
|
1560
1837
|
/** The server-side delivery daemon's fresh-per-entry ACL read: one LIFECYCLE's current read ACL
|
|
1561
1838
|
* (`allowSubscribe`) from the durable registry (exact key `<owner>.<actor>.<uid>`), or `undefined`
|
|
1562
1839
|
* if no record (an unknown lifecycle — the reader DEFERS, never drops). A present `[]` (known
|
|
@@ -1570,7 +1847,13 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1570
1847
|
* authorize the successor (SPEC 13.1: at most one live lifecycle per alias). */
|
|
1571
1848
|
async aclForAlias(principal) {
|
|
1572
1849
|
const row = await readAclForAlias(await this.aclRegistry(), principal);
|
|
1573
|
-
|
|
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 };
|
|
1574
1857
|
}
|
|
1575
1858
|
/** Lazily open the delivery lease/readiness KV (pre-created at `cotal up`; bind, never create). */
|
|
1576
1859
|
async deliveryRegistry() {
|
|
@@ -1655,44 +1938,97 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1655
1938
|
encodeManagerLease(info) {
|
|
1656
1939
|
return new TextEncoder().encode(JSON.stringify(info));
|
|
1657
1940
|
}
|
|
1658
|
-
/** Acquire
|
|
1659
|
-
*
|
|
1660
|
-
*
|
|
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). */
|
|
1661
1947
|
async acquireManagerLease(info) {
|
|
1662
|
-
return (await this.managerLeaseRegistry()).create(
|
|
1948
|
+
return (await this.managerLeaseRegistry()).create(managerLeaseKey(info.instanceId), this.encodeManagerLease({ ...info, since: Date.now() }));
|
|
1663
1949
|
}
|
|
1664
|
-
/** Renew
|
|
1665
|
-
* 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. */
|
|
1666
1952
|
async renewManagerLease(info, revision) {
|
|
1667
|
-
return (await this.managerLeaseRegistry()).update(
|
|
1953
|
+
return (await this.managerLeaseRegistry()).update(managerLeaseKey(info.instanceId), this.encodeManagerLease({ ...info, since: Date.now() }), revision);
|
|
1668
1954
|
}
|
|
1669
|
-
/** Release
|
|
1670
|
-
* by `revision`: if we already LOST
|
|
1671
|
-
*
|
|
1672
|
-
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) {
|
|
1673
1959
|
try {
|
|
1674
1960
|
const kv = await this.managerLeaseRegistry();
|
|
1675
1961
|
if (revision === undefined)
|
|
1676
|
-
await kv.delete(
|
|
1962
|
+
await kv.delete(managerLeaseKey(instanceId));
|
|
1677
1963
|
else
|
|
1678
|
-
await kv.delete(
|
|
1964
|
+
await kv.delete(managerLeaseKey(instanceId), { previousSeq: revision });
|
|
1679
1965
|
}
|
|
1680
1966
|
catch { /* not ours / already gone */ }
|
|
1681
1967
|
}
|
|
1682
|
-
/** Read
|
|
1683
|
-
*
|
|
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. */
|
|
1684
1983
|
async readManagerLease() {
|
|
1685
1984
|
if (!this.nc)
|
|
1686
1985
|
return undefined;
|
|
1687
1986
|
try {
|
|
1688
|
-
const
|
|
1689
|
-
const
|
|
1690
|
-
|
|
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)
|
|
1691
2012
|
return undefined;
|
|
1692
|
-
return
|
|
2013
|
+
return JSON.parse(new TextDecoder().decode(newest.data));
|
|
1693
2014
|
}
|
|
1694
|
-
catch {
|
|
1695
|
-
|
|
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;
|
|
1696
2032
|
}
|
|
1697
2033
|
}
|
|
1698
2034
|
/** Privileged: one owner's NON-TOMBSTONED durable memberships as `{channel, generation, activated}` —
|
|
@@ -1918,6 +2254,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1918
2254
|
return this.deliveryJoin(caller, args);
|
|
1919
2255
|
if (req.op === "durableLeave")
|
|
1920
2256
|
return this.deliveryLeave(caller, args);
|
|
2257
|
+
if (req.op === "readHistory")
|
|
2258
|
+
return this.deliveryReadHistory(caller, args);
|
|
1921
2259
|
if (req.op === "listMemberships") {
|
|
1922
2260
|
if (typeof args.lifecycleUid !== "string")
|
|
1923
2261
|
return { ok: false, error: "listMemberships: the caller's lifecycleUid is required (membership rows are lifecycle-keyed, SPEC 13.1)" };
|
|
@@ -2014,6 +2352,107 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2014
2352
|
}
|
|
2015
2353
|
return { ok: true, data: { channel } };
|
|
2016
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
|
+
}
|
|
2017
2456
|
/** (Re)bind the Plane-3 fan-out writer + trusted reader. Idempotent — the durables resume from their
|
|
2018
2457
|
* cursor. Called by {@link startPlane3} once AND by {@link connectAndBind} on every (re)connect, so
|
|
2019
2458
|
* the delivery daemon's reconnect RE-ARMS the backstop + the ctl.delivery responder. Without this, a broker blip would silently kill
|
|
@@ -3247,6 +3686,13 @@ function isEndpointRef(value) {
|
|
|
3247
3686
|
return isRecord(value) && typeof value.id === "string" && typeof value.name === "string" &&
|
|
3248
3687
|
(value.role === undefined || typeof value.role === "string");
|
|
3249
3688
|
}
|
|
3689
|
+
/** A conformant message part: the three CORE kinds, or a reverse-DNS extension kind (SPEC §5).
|
|
3690
|
+
*
|
|
3691
|
+
* Reached from {@link isCotalMessage}, which gates the Plane-3 delivery frame — so a core kind
|
|
3692
|
+
* missing an arm here is not a schema nicety: the durable backstop DROPS every message carrying
|
|
3693
|
+
* it, silently, and the drop surfaces nowhere near the feature that added the part. `artifact`
|
|
3694
|
+
* was exactly that case before it was added (a bare kind has no dot, so it fell through to the
|
|
3695
|
+
* extension regex and failed). */
|
|
3250
3696
|
function isMessagePart(value) {
|
|
3251
3697
|
if (!isRecord(value) || typeof value.kind !== "string")
|
|
3252
3698
|
return false;
|
|
@@ -3254,6 +3700,8 @@ function isMessagePart(value) {
|
|
|
3254
3700
|
return typeof value.text === "string";
|
|
3255
3701
|
if (value.kind === "data")
|
|
3256
3702
|
return Object.prototype.hasOwnProperty.call(value, "data");
|
|
3703
|
+
if (value.kind === ARTIFACT_PART_KIND)
|
|
3704
|
+
return isArtifactPart(value);
|
|
3257
3705
|
return /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(value.kind);
|
|
3258
3706
|
}
|
|
3259
3707
|
function isRecord(value) {
|