@cotal-ai/core 0.90.1 → 0.92.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-file.d.ts +2 -1
- package/dist/agent-file.d.ts.map +1 -1
- package/dist/agent-file.js +5 -3
- package/dist/agent-file.js.map +1 -1
- package/dist/auth-provider.d.ts +155 -1
- package/dist/auth-provider.d.ts.map +1 -1
- package/dist/auth-provider.js +24 -0
- package/dist/auth-provider.js.map +1 -1
- package/dist/authority-store-config.d.ts +31 -0
- package/dist/authority-store-config.d.ts.map +1 -0
- package/dist/authority-store-config.js +45 -0
- package/dist/authority-store-config.js.map +1 -0
- package/dist/backup-config.d.ts +32 -1
- package/dist/backup-config.d.ts.map +1 -1
- package/dist/backup-config.js +11 -0
- package/dist/backup-config.js.map +1 -1
- package/dist/backup.d.ts +4 -0
- package/dist/backup.d.ts.map +1 -1
- package/dist/backup.js +14 -0
- package/dist/backup.js.map +1 -1
- package/dist/broker-identity.d.ts +24 -0
- package/dist/broker-identity.d.ts.map +1 -0
- package/dist/broker-identity.js +36 -0
- package/dist/broker-identity.js.map +1 -0
- package/dist/browser-control.d.ts +1 -0
- package/dist/browser-control.d.ts.map +1 -1
- package/dist/browser-control.js +1 -0
- package/dist/browser-control.js.map +1 -1
- package/dist/browser-validators-build.d.ts +1 -0
- package/dist/browser-validators-build.d.ts.map +1 -1
- package/dist/channels.d.ts +14 -0
- package/dist/channels.d.ts.map +1 -1
- package/dist/channels.js +19 -5
- package/dist/channels.js.map +1 -1
- package/dist/command.d.ts +57 -0
- package/dist/command.d.ts.map +1 -1
- package/dist/command.js +49 -2
- package/dist/command.js.map +1 -1
- package/dist/connector-setup.d.ts +6 -0
- package/dist/connector-setup.d.ts.map +1 -1
- package/dist/connector.d.ts +3 -0
- package/dist/connector.d.ts.map +1 -1
- package/dist/connector.js.map +1 -1
- package/dist/endpoint-binding.d.ts +66 -1
- package/dist/endpoint-binding.d.ts.map +1 -1
- package/dist/endpoint-binding.js +167 -118
- package/dist/endpoint-binding.js.map +1 -1
- package/dist/endpoint-call.js +1 -1
- package/dist/endpoint-call.js.map +1 -1
- package/dist/endpoint-caller.d.ts +2 -0
- package/dist/endpoint-caller.d.ts.map +1 -1
- package/dist/endpoint-caller.js.map +1 -1
- package/dist/endpoint-grants.d.ts +15 -5
- package/dist/endpoint-grants.d.ts.map +1 -1
- package/dist/endpoint-grants.js +17 -5
- package/dist/endpoint-grants.js.map +1 -1
- package/dist/endpoint-invoke.d.ts +9 -0
- package/dist/endpoint-invoke.d.ts.map +1 -1
- package/dist/endpoint-invoke.js +7 -0
- package/dist/endpoint-invoke.js.map +1 -1
- package/dist/endpoint-schema-diff.d.ts +20 -0
- package/dist/endpoint-schema-diff.d.ts.map +1 -0
- package/dist/endpoint-schema-diff.js +15 -0
- package/dist/endpoint-schema-diff.js.map +1 -0
- package/dist/endpoint-serve.d.ts +4 -0
- package/dist/endpoint-serve.d.ts.map +1 -1
- package/dist/endpoint-serve.js +1 -1
- package/dist/endpoint-serve.js.map +1 -1
- package/dist/endpoint.d.ts +98 -5
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +350 -36
- package/dist/endpoint.js.map +1 -1
- package/dist/identity.d.ts +18 -0
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +28 -1
- package/dist/identity.js.map +1 -1
- package/dist/index.d.ts +15 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -1
- package/dist/index.js.map +1 -1
- package/dist/issued-authority.d.ts +25 -1
- package/dist/issued-authority.d.ts.map +1 -1
- package/dist/issued-authority.js +24 -7
- package/dist/issued-authority.js.map +1 -1
- package/dist/membership-feed.d.ts +16 -12
- package/dist/membership-feed.d.ts.map +1 -1
- package/dist/membership-feed.js +143 -12
- package/dist/membership-feed.js.map +1 -1
- package/dist/one-line.d.ts +5 -4
- package/dist/one-line.d.ts.map +1 -1
- package/dist/one-line.js +6 -5
- package/dist/one-line.js.map +1 -1
- package/dist/provision.d.ts +8 -1
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +39 -17
- package/dist/provision.js.map +1 -1
- package/dist/registry.d.ts +8 -4
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +58 -20
- package/dist/registry.js.map +1 -1
- package/dist/remote-manager-authority.d.ts +34 -2
- package/dist/remote-manager-authority.d.ts.map +1 -1
- package/dist/remote-manager-authority.js.map +1 -1
- package/dist/remote-service-authority.d.ts +10 -6
- package/dist/remote-service-authority.d.ts.map +1 -1
- package/dist/remote-service-authority.js +4 -1
- package/dist/remote-service-authority.js.map +1 -1
- package/dist/run-admission.d.ts +24 -1
- package/dist/run-admission.d.ts.map +1 -1
- package/dist/run-admission.js +60 -3
- package/dist/run-admission.js.map +1 -1
- package/dist/run-cancel.d.ts +43 -0
- package/dist/run-cancel.d.ts.map +1 -0
- package/dist/run-cancel.js +72 -0
- package/dist/run-cancel.js.map +1 -0
- package/dist/run-host.d.ts +12 -0
- package/dist/run-host.d.ts.map +1 -1
- package/dist/run-submission-client.d.ts +35 -0
- package/dist/run-submission-client.d.ts.map +1 -0
- package/dist/run-submission-client.js +61 -0
- package/dist/run-submission-client.js.map +1 -0
- package/dist/run-submission.d.ts +78 -0
- package/dist/run-submission.d.ts.map +1 -0
- package/dist/run-submission.js +158 -0
- package/dist/run-submission.js.map +1 -0
- package/dist/sealed-reply.d.ts +21 -0
- package/dist/sealed-reply.d.ts.map +1 -0
- package/dist/sealed-reply.js +75 -0
- package/dist/sealed-reply.js.map +1 -0
- package/dist/secret-fs.d.ts +29 -6
- package/dist/secret-fs.d.ts.map +1 -1
- package/dist/secret-fs.js +107 -18
- package/dist/secret-fs.js.map +1 -1
- package/dist/space-deletion-targets.d.ts +4 -0
- package/dist/space-deletion-targets.d.ts.map +1 -0
- package/dist/space-deletion-targets.js +15 -0
- package/dist/space-deletion-targets.js.map +1 -0
- package/dist/space-initialization-diff.d.ts +47 -0
- package/dist/space-initialization-diff.d.ts.map +1 -0
- package/dist/space-initialization-diff.js +100 -0
- package/dist/space-initialization-diff.js.map +1 -0
- package/dist/space-initialization-observation.d.ts +52 -0
- package/dist/space-initialization-observation.d.ts.map +1 -0
- package/dist/space-initialization-observation.js +133 -0
- package/dist/space-initialization-observation.js.map +1 -0
- package/dist/space-resource-definitions.d.ts +86 -0
- package/dist/space-resource-definitions.d.ts.map +1 -0
- package/dist/space-resource-definitions.js +133 -0
- package/dist/space-resource-definitions.js.map +1 -0
- package/dist/space-schema-diff.d.ts +45 -0
- package/dist/space-schema-diff.d.ts.map +1 -0
- package/dist/space-schema-diff.js +204 -0
- package/dist/space-schema-diff.js.map +1 -0
- package/dist/space-schema-observation.d.ts +45 -0
- package/dist/space-schema-observation.d.ts.map +1 -0
- package/dist/space-schema-observation.js +78 -0
- package/dist/space-schema-observation.js.map +1 -0
- package/dist/spaces.d.ts.map +1 -1
- package/dist/spaces.js +5 -25
- package/dist/spaces.js.map +1 -1
- package/dist/stream-schema-diff.d.ts +31 -0
- package/dist/stream-schema-diff.d.ts.map +1 -0
- package/dist/stream-schema-diff.js +45 -0
- package/dist/stream-schema-diff.js.map +1 -0
- package/dist/streams.d.ts +30 -1
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +41 -18
- package/dist/streams.js.map +1 -1
- package/package.json +2 -2
package/dist/endpoint.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
4
|
import { createConnection } from "node:net";
|
|
4
5
|
import { connect, credsAuthenticator, headers, tokenAuthenticator, nanos, millis, AuthorizationError, PermissionViolationError, UserAuthenticationExpiredError, NoRespondersError, RequestError, TimeoutError, ClosedConnectionError, } from "@nats-io/transport-node";
|
|
5
6
|
import { wsconnect } from "@nats-io/nats-core";
|
|
@@ -17,11 +18,12 @@ import { assertIdToken, assertGeneration } from "./endpoint-subjects.js";
|
|
|
17
18
|
import { readAcceptedRow } from "./issued-authority.js";
|
|
18
19
|
import { lastKvEntries, liveKvEntries } from "./kv-scan.js";
|
|
19
20
|
import { isConsumerAlreadyExists, isConsumerNotFound, isJetStreamMissing } from "./jetstream-missing.js";
|
|
21
|
+
import { isRestoredConsumer } from "./backup.js";
|
|
20
22
|
import { ARTIFACT_PART_KIND, isArtifactPart } from "./artifact.js";
|
|
21
23
|
import { assertValidName } from "./resolve.js";
|
|
22
24
|
import { EVICT_PRINCIPALS_MAX } from "./evict.js";
|
|
23
25
|
import { createSpaceStreams, dmDurableConfig, dlvDurableConfig, taskDurableConfig, fanoutDurableConfig, inboxReaderConfig, MAX_MSGS_PER_SUBJECT, MANAGER_LEASE_TTL_MS, MANAGER_LEASE_ATTEMPT_MS, TTL_RECONCILE_CANARY_KEY, PRESENCE_STORAGE } from "./streams.js";
|
|
24
|
-
import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, JetStreamApiCodes, } from "@nats-io/jetstream";
|
|
26
|
+
import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, JetStreamApiCodes, JetStreamApiError, } from "@nats-io/jetstream";
|
|
25
27
|
import {} from "@nats-io/jetstream";
|
|
26
28
|
import { Kvm } from "@nats-io/kv";
|
|
27
29
|
import { Bucket, KvWatchInclude } from "@nats-io/kv/internal";
|
|
@@ -61,6 +63,31 @@ export const MULTI_FILTER_READ_CONCURRENCY = 4;
|
|
|
61
63
|
* minutes is what the nats.js ordered consumers it replaces carried, and it keeps a reader alive
|
|
62
64
|
* across the gap between pulls on a slow link. */
|
|
63
65
|
const HISTORY_READER_INACTIVE_MS = 5 * 60_000;
|
|
66
|
+
/** A DM-only endpoint's own `dm_` durable does not exist. The endpoint never creates it. */
|
|
67
|
+
export class DmDurableMissingError extends Error {
|
|
68
|
+
stream;
|
|
69
|
+
durable;
|
|
70
|
+
code = "dm-durable-missing";
|
|
71
|
+
constructor(stream, durable) {
|
|
72
|
+
super(`dm-durable-missing: durable "${durable}" on stream "${stream}" does not exist; a DM-only endpoint binds an existing durable and never creates one`);
|
|
73
|
+
this.stream = stream;
|
|
74
|
+
this.durable = durable;
|
|
75
|
+
this.name = "DmDurableMissingError";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** The broker refused a DM-only endpoint's bind of its own `dm_` durable. No other credential or
|
|
79
|
+
* read path is tried. */
|
|
80
|
+
export class DmBindRefusedError extends Error {
|
|
81
|
+
stream;
|
|
82
|
+
durable;
|
|
83
|
+
code = "dm-bind-refused";
|
|
84
|
+
constructor(stream, durable, cause) {
|
|
85
|
+
super(`dm-bind-refused: binding durable "${durable}" on stream "${stream}" was refused (${cause instanceof Error ? cause.message : String(cause)})`, { cause });
|
|
86
|
+
this.stream = stream;
|
|
87
|
+
this.durable = durable;
|
|
88
|
+
this.name = "DmBindRefusedError";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
64
91
|
/** A presence bucket that has refused consecutive writes for at least one full presence TTL.
|
|
65
92
|
* The condition is non-transient: ordinary heartbeat retry has already failed for the whole
|
|
66
93
|
* liveness window, so callers should report the roster as last-known until a write succeeds. */
|
|
@@ -169,6 +196,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
169
196
|
doWatchChannels;
|
|
170
197
|
readOnce;
|
|
171
198
|
doConsume;
|
|
199
|
+
dmOnly;
|
|
200
|
+
/** The DM-only handler now running, if any; stop awaits it. */
|
|
201
|
+
dmOnlyInFlight;
|
|
172
202
|
ackWaitMs;
|
|
173
203
|
inactiveThresholdMs;
|
|
174
204
|
nc;
|
|
@@ -560,6 +590,21 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
560
590
|
this.readOnce = opts.readOnce ?? false;
|
|
561
591
|
if (this.readOnce && (this.doConsume || this.doRegister))
|
|
562
592
|
throw new Error("EndpointOptions.readOnce requires consume and registerPresence false: a one-time read cannot keep a consumer's channel cache or its own presence current");
|
|
593
|
+
if (opts.dmOnly !== undefined) {
|
|
594
|
+
if (typeof opts.dmOnly.handler !== "function")
|
|
595
|
+
throw new Error("EndpointOptions.dmOnly.handler must be a function");
|
|
596
|
+
const settle = opts.dmOnly.settleTimeoutMs;
|
|
597
|
+
if (settle !== undefined && (!Number.isSafeInteger(settle) || settle <= 0))
|
|
598
|
+
throw new Error("EndpointOptions.dmOnly.settleTimeoutMs must be a positive safe integer");
|
|
599
|
+
if (!this.authed)
|
|
600
|
+
throw new Error("EndpointOptions.dmOnly requires creds or a bearer: it binds a provisioned durable and never creates one");
|
|
601
|
+
if (!this.doConsume || this.doRegister || this.doWatch || this.doWatchChannels || this.readOnce)
|
|
602
|
+
throw new Error("EndpointOptions.dmOnly requires consume true and registerPresence, watchPresence, watchChannels and readOnce false");
|
|
603
|
+
if (this.channels.length || opts.card.role || opts.acceptedToken !== undefined)
|
|
604
|
+
throw new Error("EndpointOptions.dmOnly takes no channels, role or acceptedToken: the endpoint reads only its own DM durable");
|
|
605
|
+
this.requireLifecycleUid("EndpointOptions.dmOnly");
|
|
606
|
+
this.dmOnly = opts.dmOnly;
|
|
607
|
+
}
|
|
563
608
|
// Seed the presence mirror so file-default channel modes are visible from the first publish
|
|
564
609
|
// (not only after the first runtime toggle). Mirror only — delivery reads the connector's state.
|
|
565
610
|
this.channelModes = opts.channelModes && Object.keys(opts.channelModes).length ? opts.channelModes : undefined;
|
|
@@ -1075,6 +1120,16 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1075
1120
|
this.armAuthExpiryReconnectFence(this.nc);
|
|
1076
1121
|
this.watchStatus();
|
|
1077
1122
|
this.js = jetstream(this.nc);
|
|
1123
|
+
// DM-only (EndpointOptions.dmOnly): the own DM durable is the whole binding. Nothing below runs:
|
|
1124
|
+
// no accepted-row read, presence, channel registry, JetStream manager, lease, membership,
|
|
1125
|
+
// Plane-3 or liveness responder, so the credential needs no row for any of them.
|
|
1126
|
+
if (this.dmOnly) {
|
|
1127
|
+
await this.bindDmOnly(this.dmOnly);
|
|
1128
|
+
if (this.stopped)
|
|
1129
|
+
return;
|
|
1130
|
+
this.emit("connection", { connected: true });
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1078
1133
|
// Discovery of the issued generation (SPEC 13.15): the ISSUER wrote the accepted reference
|
|
1079
1134
|
// under the token this launch was handed; the broker admits the read only under this
|
|
1080
1135
|
// connection's own per-key grant. What comes back must name this incarnation, and a fresh
|
|
@@ -1656,6 +1711,14 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1656
1711
|
/* best-effort graceful leave */
|
|
1657
1712
|
}
|
|
1658
1713
|
await this.settleDeliveryLeaseWatches();
|
|
1714
|
+
// DM-only: the pull above is stopped, so no new copy reaches the handler. Let a running handler
|
|
1715
|
+
// settle while the connection is still open, so its ack can reach the broker; close either way.
|
|
1716
|
+
let dmOnlyUnsettled = false;
|
|
1717
|
+
if (this.dmOnly && this.dmOnlyInFlight) {
|
|
1718
|
+
const settleMs = this.dmOnly.settleTimeoutMs ?? 30_000;
|
|
1719
|
+
dmOnlyUnsettled = await withDeadline(this.dmOnlyInFlight.then(() => false), settleMs, () => new Error("unsettled"))
|
|
1720
|
+
.catch(() => true);
|
|
1721
|
+
}
|
|
1659
1722
|
await this.closeWithoutLibraryReconnect(this.nc);
|
|
1660
1723
|
// #1356: stop() does its own teardown and never runs clearConnectionScoped, so clear here too —
|
|
1661
1724
|
// AFTER the best-effort offline publish above, which would otherwise re-record a refusal on its
|
|
@@ -1669,6 +1732,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1669
1732
|
await this.startPromise;
|
|
1670
1733
|
if ([...this.ownedConnections].some((nc) => !nc.isClosed()))
|
|
1671
1734
|
throw new Error("endpoint stop could not prove its connection closed; local consumer custody must be retained");
|
|
1735
|
+
if (dmOnlyUnsettled)
|
|
1736
|
+
throw new Error(`endpoint stop closed the connection but the DM-only handler did not settle within ${this.dmOnly?.settleTimeoutMs ?? 30_000}ms; its copy stays unacked and handler custody must be retained`);
|
|
1672
1737
|
}
|
|
1673
1738
|
// ---- messaging -----------------------------------------------------------
|
|
1674
1739
|
/** Multicast: broadcast to everyone on a channel. */
|
|
@@ -2451,6 +2516,28 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2451
2516
|
channelReplayWindow(channel) {
|
|
2452
2517
|
return this.channelConfigs.get(channel)?.replayWindow ?? this.channelDefaults.replayWindow;
|
|
2453
2518
|
}
|
|
2519
|
+
/** A channel's effective replay policy read straight from the registry bucket, for a caller that
|
|
2520
|
+
* reads it right after start, before any watch cache has caught up, or that watches nothing. A
|
|
2521
|
+
* wildcard reads the space defaults only. A space with no registry bucket has the built-in policy. */
|
|
2522
|
+
async channelReplayPolicy(channel) {
|
|
2523
|
+
if (!this.nc)
|
|
2524
|
+
throw new Error(this.notLiveMsg());
|
|
2525
|
+
const kv = this.channelKv ?? (await openChannelRegistry(this.nc, this.space, { create: false }));
|
|
2526
|
+
let cfg;
|
|
2527
|
+
let defaults;
|
|
2528
|
+
try {
|
|
2529
|
+
[cfg, defaults] = await Promise.all([
|
|
2530
|
+
isConcreteChannel(channel) ? readChannelConfig(kv, channel) : Promise.resolve(undefined),
|
|
2531
|
+
readChannelDefaults(kv),
|
|
2532
|
+
]);
|
|
2533
|
+
}
|
|
2534
|
+
catch (e) {
|
|
2535
|
+
// `open` binds lazily, so a space with no registry bucket surfaces on the first read.
|
|
2536
|
+
if (!isJetStreamMissing(e, JetStreamApiCodes.StreamNotFound))
|
|
2537
|
+
throw e;
|
|
2538
|
+
}
|
|
2539
|
+
return { replay: effectiveReplay(cfg, defaults), window: cfg?.replayWindow ?? defaults?.replayWindow ?? null };
|
|
2540
|
+
}
|
|
2454
2541
|
/** Effective delivery class for a channel (per-channel override ?? space default ?? "durable"),
|
|
2455
2542
|
* from the live watch cache — drives the non-gating delivery-health surface (only durable-class
|
|
2456
2543
|
* channels have a Plane-3 backstop to report on). */
|
|
@@ -2563,6 +2650,42 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2563
2650
|
this.joinSeq.delete(channel);
|
|
2564
2651
|
return { left: true };
|
|
2565
2652
|
}
|
|
2653
|
+
/**
|
|
2654
|
+
* What the chat stream still holds and what removes the rest. `perSender` is the stream's
|
|
2655
|
+
* per-subject cap: a subject is one sender on one channel, and past the cap that sender's oldest
|
|
2656
|
+
* message on the channel is discarded. `maxAgeMs` is the stream's age limit. Each is `null` when
|
|
2657
|
+
* the stream sets none. `senders` counts each sender's retained messages per channel, so a sender
|
|
2658
|
+
* at the cap marks a channel whose retained history has lost that sender's older messages while
|
|
2659
|
+
* other senders' messages from the same time remain.
|
|
2660
|
+
*/
|
|
2661
|
+
async chatRetention() {
|
|
2662
|
+
if (!this.nc)
|
|
2663
|
+
throw new Error(this.notLiveMsg());
|
|
2664
|
+
const mgr = await jetstreamManager(this.nc);
|
|
2665
|
+
let info;
|
|
2666
|
+
try {
|
|
2667
|
+
info = await mgr.streams.info(chatStream(this.space), { subjects_filter: ">" });
|
|
2668
|
+
}
|
|
2669
|
+
catch (e) {
|
|
2670
|
+
// As in listChannels: an absent CHAT stream holds nothing yet; any other failure stays loud.
|
|
2671
|
+
if (!isJetStreamMissing(e, JetStreamApiCodes.StreamNotFound))
|
|
2672
|
+
throw e;
|
|
2673
|
+
return { perSender: null, maxAgeMs: null, senders: [] };
|
|
2674
|
+
}
|
|
2675
|
+
const senders = [];
|
|
2676
|
+
for (const [subject, messages] of Object.entries(info.state.subjects ?? {})) {
|
|
2677
|
+
const p = parseSubject(subject);
|
|
2678
|
+
if (p?.kind === "chat" && isPrincipalOwnerToken(p.owner))
|
|
2679
|
+
senders.push({ channel: p.rest, sender: p.sender, messages });
|
|
2680
|
+
}
|
|
2681
|
+
const cap = info.config.max_msgs_per_subject;
|
|
2682
|
+
const age = info.config.max_age;
|
|
2683
|
+
return {
|
|
2684
|
+
perSender: typeof cap === "number" && cap > 0 ? cap : null,
|
|
2685
|
+
maxAgeMs: typeof age === "number" && age > 0 ? Math.round(age / 1_000_000) : null,
|
|
2686
|
+
senders: senders.sort((a, b) => a.channel.localeCompare(b.channel) || a.sender.localeCompare(b.sender)),
|
|
2687
|
+
};
|
|
2688
|
+
}
|
|
2566
2689
|
/** One coherent channel model for dashboards: every channel that has messages OR a registry
|
|
2567
2690
|
* entry (configured-but-empty), each tagged with its {@link ChannelConfig}. Works even on
|
|
2568
2691
|
* observer endpoints (no consumers needed). */
|
|
@@ -2905,6 +3028,94 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2905
3028
|
// history from any sender
|
|
2906
3029
|
return (await this.streamHistory(chatStream(this.space), [chatSubject(this.space, "*", "*", channel)], opts?.limit ?? 100, undefined, opts?.signal)).map((r) => r.msg);
|
|
2907
3030
|
}
|
|
3031
|
+
/** Read one concrete channel using this lifecycle's single-filter CHAT grant (SPEC §8/§9).
|
|
3032
|
+
* No observer authority, presence or delivery ACL mirror is required. Returns the newest page,
|
|
3033
|
+
* oldest-first, including this caller's own sends. `complete` says no older valid row remains.
|
|
3034
|
+
* Reads have a ten-second budget, shortened by `signal`. Concurrent connections sharing this
|
|
3035
|
+
* lifecycle wait for native consumer inactivity cleanup; they never delete another reader. */
|
|
3036
|
+
async channelHistoryPage(channel, opts) {
|
|
3037
|
+
assertValidChannel(channel);
|
|
3038
|
+
if (!isConcreteChannel(channel))
|
|
3039
|
+
throw new Error("channelHistoryPage requires one concrete channel");
|
|
3040
|
+
const limit = opts?.limit ?? 50;
|
|
3041
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
3042
|
+
throw new Error("channelHistoryPage limit must be a whole number from 1 to 1000");
|
|
3043
|
+
if (!this.nc || !this.js)
|
|
3044
|
+
throw new Error("endpoint not started");
|
|
3045
|
+
const jsm = this.jsm ??= await jetstreamManager(this.nc);
|
|
3046
|
+
const signal = AbortSignal.any([AbortSignal.timeout(10_000), ...(opts?.signal ? [opts.signal] : [])]);
|
|
3047
|
+
signal.throwIfAborted();
|
|
3048
|
+
const stream = chatStream(this.space);
|
|
3049
|
+
const name = chatHistDurable(this.owner, this.actor, this.requireLifecycleUid("channelHistoryPage"));
|
|
3050
|
+
const subject = chatSubject(this.space, "*", "*", channel);
|
|
3051
|
+
const marker = randomUUID();
|
|
3052
|
+
let created;
|
|
3053
|
+
for (;;) {
|
|
3054
|
+
signal.throwIfAborted();
|
|
3055
|
+
try {
|
|
3056
|
+
// The pinned client's add is CREATE-only. A unique description makes even an otherwise
|
|
3057
|
+
// identical occupied config conflict. Never bind/update it or pre-delete by shared name.
|
|
3058
|
+
created = await jsm.consumers.add(stream, {
|
|
3059
|
+
name, description: marker, filter_subject: subject,
|
|
3060
|
+
ack_policy: AckPolicy.None, deliver_policy: DeliverPolicy.All,
|
|
3061
|
+
mem_storage: true, inactive_threshold: nanos(250),
|
|
3062
|
+
});
|
|
3063
|
+
break;
|
|
3064
|
+
}
|
|
3065
|
+
catch (e) {
|
|
3066
|
+
if (!(e instanceof JetStreamApiError) || e.code !== 10148)
|
|
3067
|
+
throw e; // consumer already exists
|
|
3068
|
+
await delay(50, undefined, { signal });
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
signal.throwIfAborted();
|
|
3072
|
+
const owns = (info) => info.created === created.created &&
|
|
3073
|
+
info.config.description === marker && info.config.filter_subject === subject;
|
|
3074
|
+
if (!owns(created))
|
|
3075
|
+
throw new Error("channelHistoryPage: consumer ownership was not established");
|
|
3076
|
+
const consumer = await this.js.consumers.get(stream, name);
|
|
3077
|
+
const pending = created.num_pending;
|
|
3078
|
+
const out = [];
|
|
3079
|
+
let delivered = 0;
|
|
3080
|
+
if (pending > 0) {
|
|
3081
|
+
const iter = await consumer.consume({ max_messages: Math.min(pending, 32) });
|
|
3082
|
+
const stop = () => iter.stop(abortReason(signal));
|
|
3083
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
3084
|
+
try {
|
|
3085
|
+
signal.throwIfAborted();
|
|
3086
|
+
for await (const m of iter) {
|
|
3087
|
+
signal.throwIfAborted();
|
|
3088
|
+
if (!subjectMatches(subject, m.subject))
|
|
3089
|
+
throw new Error("channelHistoryPage: consumer delivered another channel; ownership was lost");
|
|
3090
|
+
delivered++;
|
|
3091
|
+
const msg = historyMessageFromDelivery(m);
|
|
3092
|
+
if (msg) {
|
|
3093
|
+
out.push(msg);
|
|
3094
|
+
if (out.length > limit + 1)
|
|
3095
|
+
out.shift();
|
|
3096
|
+
}
|
|
3097
|
+
if (delivered >= pending)
|
|
3098
|
+
break;
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3101
|
+
finally {
|
|
3102
|
+
signal.removeEventListener("abort", stop);
|
|
3103
|
+
iter.stop();
|
|
3104
|
+
}
|
|
3105
|
+
signal.throwIfAborted();
|
|
3106
|
+
if (delivered !== pending)
|
|
3107
|
+
throw new Error(`channelHistoryPage: read ${delivered} of ${pending} messages before the window was cut short`);
|
|
3108
|
+
}
|
|
3109
|
+
signal.throwIfAborted();
|
|
3110
|
+
// A legacy join/recall reader can delete/recreate this fixed name. A final ownership check
|
|
3111
|
+
// makes that interference loud. It is detection, not a fence against those legacy readers.
|
|
3112
|
+
if (!owns(await consumer.info()))
|
|
3113
|
+
throw new Error("channelHistoryPage: consumer ownership was lost");
|
|
3114
|
+
signal.throwIfAborted();
|
|
3115
|
+
// No DELETE, even after abort: a delayed owner could otherwise delete its successor between
|
|
3116
|
+
// INFO and DELETE. Native inactivity reaps this transient reader without that TOCTOU race.
|
|
3117
|
+
return { items: out.slice(-limit), complete: out.length <= limit };
|
|
3118
|
+
}
|
|
2908
3119
|
/**
|
|
2909
3120
|
* The newest `limit` chat messages across MANY channels at once, oldest-first within the page,
|
|
2910
3121
|
* each tagged with the channel the BROKER delivered it on.
|
|
@@ -2996,19 +3207,19 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2996
3207
|
// payload's `ts` here instead would change which messages the page holds, which is the
|
|
2997
3208
|
// selection question this pull request already had to answer once.
|
|
2998
3209
|
const rows = pages.flat().sort((a, b) => a.seq - b.seq).slice(-limit);
|
|
2999
|
-
return rows.map(({ subject, msg }) => {
|
|
3210
|
+
return rows.map(({ seq, subject, msg }) => {
|
|
3000
3211
|
const p = parseSubject(subject);
|
|
3001
3212
|
// The filter set is built from chat subjects, so this cannot fire against a healthy broker.
|
|
3002
3213
|
// It is here because the alternative to raising is tagging a message with a guess.
|
|
3003
3214
|
if (p?.kind !== "chat")
|
|
3004
3215
|
throw new Error(`multiChannelHistory: the broker delivered ${subject}, which is not a chat subject on this space`);
|
|
3005
|
-
return { channel: p.rest, msg };
|
|
3216
|
+
return { channel: p.rest, seq, msg };
|
|
3006
3217
|
});
|
|
3007
3218
|
}
|
|
3008
|
-
/** Read a channel's recent history
|
|
3009
|
-
*
|
|
3010
|
-
*
|
|
3011
|
-
*
|
|
3219
|
+
/** Read a channel's recent history through the delivery daemon, using a provisioned ACL mirror.
|
|
3220
|
+
* SPEC's ban on raw control-surface stream reads does not remove the single-channel CHAT grant.
|
|
3221
|
+
* Human callers without an ACL mirror use {@link channelHistoryPage}; this method adds fresh
|
|
3222
|
+
* mirror checks for provisioned readers. `items` is shape-identical to what {@link channelHistory}
|
|
3012
3223
|
* returns — the same `CotalMessage[]`, the same newest-N selection, the same oldest-first order
|
|
3013
3224
|
* within the page — so a caller migrates by reading `.items` and nothing else changes. The return
|
|
3014
3225
|
* is WRAPPED rather than bare precisely because of `complete`: a bare array cannot say whether
|
|
@@ -5179,14 +5390,25 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5179
5390
|
}
|
|
5180
5391
|
return { ok: false, error: `op "${req.op}" not supported on the delivery admin service` };
|
|
5181
5392
|
}
|
|
5393
|
+
/** Create one of the daemon's Plane-3 durables. A named create is idempotent for the same config,
|
|
5394
|
+
* and a denial is never taken for existence. The one conflict accepted is this durable as a full
|
|
5395
|
+
* restore recreated it, starting after its recorded ACK floor ({@link isRestoredConsumer}); every
|
|
5396
|
+
* other failure propagates. */
|
|
5397
|
+
async addPlane3Durable(stream, config) {
|
|
5398
|
+
try {
|
|
5399
|
+
await this.jsm.consumers.add(stream, config);
|
|
5400
|
+
}
|
|
5401
|
+
catch (e) {
|
|
5402
|
+
if (!isConsumerAlreadyExists(e) || !isRestoredConsumer(this.space, stream, await this.jsm.consumers.info(stream, config.durable_name), config))
|
|
5403
|
+
throw e;
|
|
5404
|
+
}
|
|
5405
|
+
}
|
|
5182
5406
|
/** Fan-out loop: bind the privileged `fanout` durable on CHAT and route each message (routing only —
|
|
5183
5407
|
* the trusted reader is the auth gate). */
|
|
5184
5408
|
async runFanout() {
|
|
5185
5409
|
if (!this.js || !this.jsm)
|
|
5186
5410
|
return;
|
|
5187
|
-
|
|
5188
|
-
// "already exists"; let the broker response decide and propagate every failure.
|
|
5189
|
-
await this.jsm.consumers.add(chatStream(this.space), fanoutDurableConfig(this.space, { ackWaitMs: this.ackWaitMs }));
|
|
5411
|
+
await this.addPlane3Durable(chatStream(this.space), fanoutDurableConfig(this.space, { ackWaitMs: this.ackWaitMs }));
|
|
5190
5412
|
const consumer = await this.js.consumers.get(chatStream(this.space), FANOUT_DURABLE);
|
|
5191
5413
|
const msgs = await consumer.consume();
|
|
5192
5414
|
this.streamMsgs.push(msgs);
|
|
@@ -5298,8 +5520,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5298
5520
|
async runReader() {
|
|
5299
5521
|
if (!this.js || !this.jsm)
|
|
5300
5522
|
return;
|
|
5301
|
-
|
|
5302
|
-
await this.jsm.consumers.add(inboxStream(this.space), inboxReaderConfig(this.space, { ackWaitMs: this.ackWaitMs }));
|
|
5523
|
+
await this.addPlane3Durable(inboxStream(this.space), inboxReaderConfig(this.space, { ackWaitMs: this.ackWaitMs }));
|
|
5303
5524
|
const consumer = await this.js.consumers.get(inboxStream(this.space), INBOX_READER_DURABLE);
|
|
5304
5525
|
const msgs = await consumer.consume();
|
|
5305
5526
|
this.streamMsgs.push(msgs);
|
|
@@ -5753,31 +5974,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5753
5974
|
this.streamMsgs.push(msgs);
|
|
5754
5975
|
void (async () => {
|
|
5755
5976
|
for await (const m of msgs) {
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
msg = m.json();
|
|
5759
|
-
}
|
|
5760
|
-
catch (e) {
|
|
5761
|
-
m.term(); // undecodable — never redeliver
|
|
5762
|
-
this.emit("error", e);
|
|
5763
|
-
continue;
|
|
5764
|
-
}
|
|
5765
|
-
// Authenticity guard (fail closed): the sender is encoded in the subject, which the
|
|
5766
|
-
// server policed who could publish. The payload `from` is advisory — it must match,
|
|
5767
|
-
// and a missing `from` or an unparseable subject on a delivery is itself an anomaly.
|
|
5768
|
-
// Reject (term — a spoof is permanently invalid, never redeliver) BEFORE any handler.
|
|
5769
|
-
if (!isRecord(msg) || !isUsableMessageId(msg.id)) {
|
|
5770
|
-
m.term(); // malformed envelope (SPEC sec 5): absent/non-string id — permanently invalid
|
|
5771
|
-
this.emit("error", new Error(`dropped message on ${m.subject}: absent or non-string id`));
|
|
5977
|
+
const authed = this.authenticateStreamMsg(m);
|
|
5978
|
+
if (!authed)
|
|
5772
5979
|
continue;
|
|
5773
|
-
}
|
|
5774
|
-
const parsed = parseSubject(m.subject);
|
|
5775
|
-
if (!parsed || !msg.from || msg.from.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner)) {
|
|
5776
|
-
m.term();
|
|
5777
|
-
this.emit("error", new Error(`dropped message on ${m.subject}: payload from ${msg.from?.id ?? "(none)"} ` +
|
|
5778
|
-
`does not match subject sender ${parsed?.sender ?? "(unparseable)"}`));
|
|
5779
|
-
continue;
|
|
5780
|
-
}
|
|
5980
|
+
const { msg, parsed } = authed;
|
|
5781
5981
|
// No-replay + dedup (chat only): drop a message at/below this channel's join watermark
|
|
5782
5982
|
// — pre-join history the New tail still carries for a *lagging* joiner (cursor behind the
|
|
5783
5983
|
// frontier), and the overlap a replay backfill already delivered. Must ack, or JetStream
|
|
@@ -5805,6 +6005,101 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5805
6005
|
this.emit("error", e);
|
|
5806
6006
|
});
|
|
5807
6007
|
}
|
|
6008
|
+
/** Decode one stream copy and apply the authenticity guard (fail closed): the sender is encoded in
|
|
6009
|
+
* the subject, which the server policed who could publish. The payload `from` is advisory — it
|
|
6010
|
+
* must match, and a missing `from` or an unparseable subject on a delivery is itself an anomaly.
|
|
6011
|
+
* A rejected copy is `term()`ed (permanently invalid, never redelivered) and reported, and
|
|
6012
|
+
* `undefined` returns, so no handler ever sees it. */
|
|
6013
|
+
authenticateStreamMsg(m) {
|
|
6014
|
+
let msg;
|
|
6015
|
+
try {
|
|
6016
|
+
msg = m.json();
|
|
6017
|
+
}
|
|
6018
|
+
catch (e) {
|
|
6019
|
+
m.term(); // undecodable — never redeliver
|
|
6020
|
+
this.emit("error", e);
|
|
6021
|
+
return undefined;
|
|
6022
|
+
}
|
|
6023
|
+
if (!isRecord(msg) || !isUsableMessageId(msg.id)) {
|
|
6024
|
+
m.term(); // malformed envelope (SPEC sec 5): absent/non-string id — permanently invalid
|
|
6025
|
+
this.emit("error", new Error(`dropped message on ${m.subject}: absent or non-string id`));
|
|
6026
|
+
return undefined;
|
|
6027
|
+
}
|
|
6028
|
+
const parsed = parseSubject(m.subject);
|
|
6029
|
+
if (!parsed || !msg.from || msg.from.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner)) {
|
|
6030
|
+
m.term();
|
|
6031
|
+
this.emit("error", new Error(`dropped message on ${m.subject}: payload from ${msg.from?.id ?? "(none)"} ` +
|
|
6032
|
+
`does not match subject sender ${parsed?.sender ?? "(unparseable)"}`));
|
|
6033
|
+
return undefined;
|
|
6034
|
+
}
|
|
6035
|
+
return { msg, parsed };
|
|
6036
|
+
}
|
|
6037
|
+
/** Bind this endpoint's own existing DM durable and drive it serially (EndpointOptions.dmOnly).
|
|
6038
|
+
* The bind is one CONSUMER.INFO on `dm_<owner>-<actor>-<uid>`; reading is MSG.NEXT with a batch
|
|
6039
|
+
* of one, and the next copy reaches the handler only after the previous handler settles. */
|
|
6040
|
+
async bindDmOnly(mode) {
|
|
6041
|
+
if (!this.js)
|
|
6042
|
+
throw new Error("endpoint not started");
|
|
6043
|
+
const stream = dmStream(this.space);
|
|
6044
|
+
const durable = dmDurable(this.owner, this.actor, this.requireLifecycleUid("consuming the DM inbox"));
|
|
6045
|
+
let consumer;
|
|
6046
|
+
try {
|
|
6047
|
+
consumer = await this.js.consumers.get(stream, durable);
|
|
6048
|
+
}
|
|
6049
|
+
catch (e) {
|
|
6050
|
+
if (isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound))
|
|
6051
|
+
throw new DmDurableMissingError(stream, durable);
|
|
6052
|
+
throw new DmBindRefusedError(stream, durable, e);
|
|
6053
|
+
}
|
|
6054
|
+
const ackWaitMs = await ackWaitOf(consumer); // cached by the bind's own CONSUMER.INFO, no request
|
|
6055
|
+
// A rebind after reconnect must not overlap the previous epoch's running handler. A handler that
|
|
6056
|
+
// does not settle within the stop bound fails this bind; the retry loop tries again.
|
|
6057
|
+
if (this.dmOnlyInFlight) {
|
|
6058
|
+
const settleMs = mode.settleTimeoutMs ?? 30_000;
|
|
6059
|
+
await withDeadline(this.dmOnlyInFlight, settleMs, () => new Error(`DM-only rebind waited ${settleMs}ms for the previous handler to settle; not reading the next DM yet`));
|
|
6060
|
+
}
|
|
6061
|
+
const msgs = await consumer.consume({ max_messages: 1 });
|
|
6062
|
+
this.streamMsgs.push(msgs);
|
|
6063
|
+
// The epoch this loop belongs to. nats.js can yield copies it already received after stop() began
|
|
6064
|
+
// or after a rebuild replaced the connection (a stacked pull after a missed-heartbeat reset hands
|
|
6065
|
+
// over several at once). Such a copy never reaches the handler and is neither acked nor nak'd:
|
|
6066
|
+
// the connection is closing or closed, so any reply could be lost, and the broker redelivers an
|
|
6067
|
+
// unacked copy after the durable's ack_wait to whichever epoch binds next.
|
|
6068
|
+
const boundNc = this.nc;
|
|
6069
|
+
const retired = () => this.stopped || this.nc !== boundNc || !boundNc || boundNc.isClosed();
|
|
6070
|
+
void (async () => {
|
|
6071
|
+
for await (const m of msgs) {
|
|
6072
|
+
if (retired())
|
|
6073
|
+
break;
|
|
6074
|
+
const authed = this.authenticateStreamMsg(m);
|
|
6075
|
+
if (!authed)
|
|
6076
|
+
continue;
|
|
6077
|
+
if (authed.parsed.kind !== "inst") {
|
|
6078
|
+
m.term(); // the DM stream filter admits only inst.* subjects; anything else is not a DM
|
|
6079
|
+
continue;
|
|
6080
|
+
}
|
|
6081
|
+
const run = Promise.resolve().then(() => mode.handler(authenticatedMessage(authed.msg, authed.parsed), dmOnlyDelivery(m, ackWaitMs, boundNc), {
|
|
6082
|
+
historical: false,
|
|
6083
|
+
kind: "dm",
|
|
6084
|
+
}));
|
|
6085
|
+
const flight = run.then(() => { }, () => { });
|
|
6086
|
+
this.dmOnlyInFlight = flight;
|
|
6087
|
+
try {
|
|
6088
|
+
await run;
|
|
6089
|
+
}
|
|
6090
|
+
catch (e) {
|
|
6091
|
+
this.emit("error", e instanceof Error ? e : new Error(String(e)));
|
|
6092
|
+
}
|
|
6093
|
+
finally {
|
|
6094
|
+
if (this.dmOnlyInFlight === flight)
|
|
6095
|
+
this.dmOnlyInFlight = undefined;
|
|
6096
|
+
}
|
|
6097
|
+
}
|
|
6098
|
+
})().catch((e) => {
|
|
6099
|
+
if (!this.stopped)
|
|
6100
|
+
this.emit("error", e);
|
|
6101
|
+
});
|
|
6102
|
+
}
|
|
5808
6103
|
/** Open a native core subscription to a channel's live feed (the manager-free live read path,
|
|
5809
6104
|
* broker-enforced by `sub.allow`). At-most-once — no replay, no ack; it is the live delivery for
|
|
5810
6105
|
* every channel (boot + runtime). For a `durable` channel it is also the low-latency wake-hint
|
|
@@ -6777,6 +7072,20 @@ function durableDelivery(m, ackWaitMs) {
|
|
|
6777
7072
|
redelivery: { deliveryCount: m.info.deliveryCount, ackWaitMs, working: () => m.working() },
|
|
6778
7073
|
};
|
|
6779
7074
|
}
|
|
7075
|
+
/** A DM-only copy's delivery. An ack, nak or in-progress signal on a closed connection is refused
|
|
7076
|
+
* with a throw instead of being dropped silently: the copy stays unacked and the broker redelivers it. */
|
|
7077
|
+
function dmOnlyDelivery(m, ackWaitMs, nc) {
|
|
7078
|
+
const open = (what) => {
|
|
7079
|
+
if (!nc || nc.isClosed())
|
|
7080
|
+
throw new Error(`DM-only ${what} refused: the connection that delivered this copy is closed; the broker will redeliver it`);
|
|
7081
|
+
};
|
|
7082
|
+
return {
|
|
7083
|
+
ack: () => { open("ack"); m.ack(); },
|
|
7084
|
+
nak: () => { open("nak"); m.nak(); },
|
|
7085
|
+
durable: true,
|
|
7086
|
+
redelivery: { deliveryCount: m.info.deliveryCount, ackWaitMs, working: () => { open("in-progress signal"); m.working(); } },
|
|
7087
|
+
};
|
|
7088
|
+
}
|
|
6780
7089
|
/** History drain keeps `m.json()` and used to throw the subject away. SPEC §5: on receive, verify
|
|
6781
7090
|
* `from.id` equals the subject sender; on mismatch, a missing `from`, or an unparseable delivery
|
|
6782
7091
|
* subject, reject and never surface. Fail closed on shape too: a stored JSON `null` or a truthy
|
|
@@ -7304,6 +7613,11 @@ function tcpDialable(server, timeoutMs) {
|
|
|
7304
7613
|
socket.on("close", () => finish(false));
|
|
7305
7614
|
});
|
|
7306
7615
|
}
|
|
7616
|
+
/** Whether a failed connect was the server refusing these credentials: a server answered, so it is
|
|
7617
|
+
* up. The same errors {@link isReachable} reads as up after its own authenticated connect. */
|
|
7618
|
+
export function isAuthRejection(e) {
|
|
7619
|
+
return e instanceof AuthorizationError || e instanceof UserAuthenticationExpiredError;
|
|
7620
|
+
}
|
|
7307
7621
|
/** Whether a NATS server is *running* at `servers`. With NO creds this is a SILENT plaintext
|
|
7308
7622
|
* liveness check ({@link tcpInfoProbe}): it reads the server's pre-auth `INFO` greeting and closes
|
|
7309
7623
|
* WITHOUT authenticating, so a live broker (open OR auth — INFO precedes auth) returns true while
|