@cotal-ai/core 0.90.1 → 0.91.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 +144 -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/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 +7 -1
- package/dist/endpoint-grants.d.ts.map +1 -1
- package/dist/endpoint-grants.js +9 -1
- 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.d.ts +90 -5
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +329 -31
- 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 +12 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -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 +4 -1
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +35 -16
- 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/run-admission.d.ts +18 -1
- package/dist/run-admission.d.ts.map +1 -1
- package/dist/run-admission.js +18 -3
- package/dist/run-admission.js.map +1 -1
- 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";
|
|
@@ -21,7 +22,7 @@ import { ARTIFACT_PART_KIND, isArtifactPart } from "./artifact.js";
|
|
|
21
22
|
import { assertValidName } from "./resolve.js";
|
|
22
23
|
import { EVICT_PRINCIPALS_MAX } from "./evict.js";
|
|
23
24
|
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";
|
|
25
|
+
import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, JetStreamApiCodes, JetStreamApiError, } from "@nats-io/jetstream";
|
|
25
26
|
import {} from "@nats-io/jetstream";
|
|
26
27
|
import { Kvm } from "@nats-io/kv";
|
|
27
28
|
import { Bucket, KvWatchInclude } from "@nats-io/kv/internal";
|
|
@@ -61,6 +62,31 @@ export const MULTI_FILTER_READ_CONCURRENCY = 4;
|
|
|
61
62
|
* minutes is what the nats.js ordered consumers it replaces carried, and it keeps a reader alive
|
|
62
63
|
* across the gap between pulls on a slow link. */
|
|
63
64
|
const HISTORY_READER_INACTIVE_MS = 5 * 60_000;
|
|
65
|
+
/** A DM-only endpoint's own `dm_` durable does not exist. The endpoint never creates it. */
|
|
66
|
+
export class DmDurableMissingError extends Error {
|
|
67
|
+
stream;
|
|
68
|
+
durable;
|
|
69
|
+
code = "dm-durable-missing";
|
|
70
|
+
constructor(stream, durable) {
|
|
71
|
+
super(`dm-durable-missing: durable "${durable}" on stream "${stream}" does not exist; a DM-only endpoint binds an existing durable and never creates one`);
|
|
72
|
+
this.stream = stream;
|
|
73
|
+
this.durable = durable;
|
|
74
|
+
this.name = "DmDurableMissingError";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** The broker refused a DM-only endpoint's bind of its own `dm_` durable. No other credential or
|
|
78
|
+
* read path is tried. */
|
|
79
|
+
export class DmBindRefusedError extends Error {
|
|
80
|
+
stream;
|
|
81
|
+
durable;
|
|
82
|
+
code = "dm-bind-refused";
|
|
83
|
+
constructor(stream, durable, cause) {
|
|
84
|
+
super(`dm-bind-refused: binding durable "${durable}" on stream "${stream}" was refused (${cause instanceof Error ? cause.message : String(cause)})`, { cause });
|
|
85
|
+
this.stream = stream;
|
|
86
|
+
this.durable = durable;
|
|
87
|
+
this.name = "DmBindRefusedError";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
64
90
|
/** A presence bucket that has refused consecutive writes for at least one full presence TTL.
|
|
65
91
|
* The condition is non-transient: ordinary heartbeat retry has already failed for the whole
|
|
66
92
|
* liveness window, so callers should report the roster as last-known until a write succeeds. */
|
|
@@ -169,6 +195,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
169
195
|
doWatchChannels;
|
|
170
196
|
readOnce;
|
|
171
197
|
doConsume;
|
|
198
|
+
dmOnly;
|
|
199
|
+
/** The DM-only handler now running, if any; stop awaits it. */
|
|
200
|
+
dmOnlyInFlight;
|
|
172
201
|
ackWaitMs;
|
|
173
202
|
inactiveThresholdMs;
|
|
174
203
|
nc;
|
|
@@ -560,6 +589,21 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
560
589
|
this.readOnce = opts.readOnce ?? false;
|
|
561
590
|
if (this.readOnce && (this.doConsume || this.doRegister))
|
|
562
591
|
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");
|
|
592
|
+
if (opts.dmOnly !== undefined) {
|
|
593
|
+
if (typeof opts.dmOnly.handler !== "function")
|
|
594
|
+
throw new Error("EndpointOptions.dmOnly.handler must be a function");
|
|
595
|
+
const settle = opts.dmOnly.settleTimeoutMs;
|
|
596
|
+
if (settle !== undefined && (!Number.isSafeInteger(settle) || settle <= 0))
|
|
597
|
+
throw new Error("EndpointOptions.dmOnly.settleTimeoutMs must be a positive safe integer");
|
|
598
|
+
if (!this.authed)
|
|
599
|
+
throw new Error("EndpointOptions.dmOnly requires creds or a bearer: it binds a provisioned durable and never creates one");
|
|
600
|
+
if (!this.doConsume || this.doRegister || this.doWatch || this.doWatchChannels || this.readOnce)
|
|
601
|
+
throw new Error("EndpointOptions.dmOnly requires consume true and registerPresence, watchPresence, watchChannels and readOnce false");
|
|
602
|
+
if (this.channels.length || opts.card.role || opts.acceptedToken !== undefined)
|
|
603
|
+
throw new Error("EndpointOptions.dmOnly takes no channels, role or acceptedToken: the endpoint reads only its own DM durable");
|
|
604
|
+
this.requireLifecycleUid("EndpointOptions.dmOnly");
|
|
605
|
+
this.dmOnly = opts.dmOnly;
|
|
606
|
+
}
|
|
563
607
|
// Seed the presence mirror so file-default channel modes are visible from the first publish
|
|
564
608
|
// (not only after the first runtime toggle). Mirror only — delivery reads the connector's state.
|
|
565
609
|
this.channelModes = opts.channelModes && Object.keys(opts.channelModes).length ? opts.channelModes : undefined;
|
|
@@ -1075,6 +1119,16 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1075
1119
|
this.armAuthExpiryReconnectFence(this.nc);
|
|
1076
1120
|
this.watchStatus();
|
|
1077
1121
|
this.js = jetstream(this.nc);
|
|
1122
|
+
// DM-only (EndpointOptions.dmOnly): the own DM durable is the whole binding. Nothing below runs:
|
|
1123
|
+
// no accepted-row read, presence, channel registry, JetStream manager, lease, membership,
|
|
1124
|
+
// Plane-3 or liveness responder, so the credential needs no row for any of them.
|
|
1125
|
+
if (this.dmOnly) {
|
|
1126
|
+
await this.bindDmOnly(this.dmOnly);
|
|
1127
|
+
if (this.stopped)
|
|
1128
|
+
return;
|
|
1129
|
+
this.emit("connection", { connected: true });
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1078
1132
|
// Discovery of the issued generation (SPEC 13.15): the ISSUER wrote the accepted reference
|
|
1079
1133
|
// under the token this launch was handed; the broker admits the read only under this
|
|
1080
1134
|
// connection's own per-key grant. What comes back must name this incarnation, and a fresh
|
|
@@ -1656,6 +1710,14 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1656
1710
|
/* best-effort graceful leave */
|
|
1657
1711
|
}
|
|
1658
1712
|
await this.settleDeliveryLeaseWatches();
|
|
1713
|
+
// DM-only: the pull above is stopped, so no new copy reaches the handler. Let a running handler
|
|
1714
|
+
// settle while the connection is still open, so its ack can reach the broker; close either way.
|
|
1715
|
+
let dmOnlyUnsettled = false;
|
|
1716
|
+
if (this.dmOnly && this.dmOnlyInFlight) {
|
|
1717
|
+
const settleMs = this.dmOnly.settleTimeoutMs ?? 30_000;
|
|
1718
|
+
dmOnlyUnsettled = await withDeadline(this.dmOnlyInFlight.then(() => false), settleMs, () => new Error("unsettled"))
|
|
1719
|
+
.catch(() => true);
|
|
1720
|
+
}
|
|
1659
1721
|
await this.closeWithoutLibraryReconnect(this.nc);
|
|
1660
1722
|
// #1356: stop() does its own teardown and never runs clearConnectionScoped, so clear here too —
|
|
1661
1723
|
// AFTER the best-effort offline publish above, which would otherwise re-record a refusal on its
|
|
@@ -1669,6 +1731,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1669
1731
|
await this.startPromise;
|
|
1670
1732
|
if ([...this.ownedConnections].some((nc) => !nc.isClosed()))
|
|
1671
1733
|
throw new Error("endpoint stop could not prove its connection closed; local consumer custody must be retained");
|
|
1734
|
+
if (dmOnlyUnsettled)
|
|
1735
|
+
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
1736
|
}
|
|
1673
1737
|
// ---- messaging -----------------------------------------------------------
|
|
1674
1738
|
/** Multicast: broadcast to everyone on a channel. */
|
|
@@ -2451,6 +2515,28 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2451
2515
|
channelReplayWindow(channel) {
|
|
2452
2516
|
return this.channelConfigs.get(channel)?.replayWindow ?? this.channelDefaults.replayWindow;
|
|
2453
2517
|
}
|
|
2518
|
+
/** A channel's effective replay policy read straight from the registry bucket, for a caller that
|
|
2519
|
+
* reads it right after start, before any watch cache has caught up, or that watches nothing. A
|
|
2520
|
+
* wildcard reads the space defaults only. A space with no registry bucket has the built-in policy. */
|
|
2521
|
+
async channelReplayPolicy(channel) {
|
|
2522
|
+
if (!this.nc)
|
|
2523
|
+
throw new Error(this.notLiveMsg());
|
|
2524
|
+
const kv = this.channelKv ?? (await openChannelRegistry(this.nc, this.space, { create: false }));
|
|
2525
|
+
let cfg;
|
|
2526
|
+
let defaults;
|
|
2527
|
+
try {
|
|
2528
|
+
[cfg, defaults] = await Promise.all([
|
|
2529
|
+
isConcreteChannel(channel) ? readChannelConfig(kv, channel) : Promise.resolve(undefined),
|
|
2530
|
+
readChannelDefaults(kv),
|
|
2531
|
+
]);
|
|
2532
|
+
}
|
|
2533
|
+
catch (e) {
|
|
2534
|
+
// `open` binds lazily, so a space with no registry bucket surfaces on the first read.
|
|
2535
|
+
if (!isJetStreamMissing(e, JetStreamApiCodes.StreamNotFound))
|
|
2536
|
+
throw e;
|
|
2537
|
+
}
|
|
2538
|
+
return { replay: effectiveReplay(cfg, defaults), window: cfg?.replayWindow ?? defaults?.replayWindow ?? null };
|
|
2539
|
+
}
|
|
2454
2540
|
/** Effective delivery class for a channel (per-channel override ?? space default ?? "durable"),
|
|
2455
2541
|
* from the live watch cache — drives the non-gating delivery-health surface (only durable-class
|
|
2456
2542
|
* channels have a Plane-3 backstop to report on). */
|
|
@@ -2563,6 +2649,42 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2563
2649
|
this.joinSeq.delete(channel);
|
|
2564
2650
|
return { left: true };
|
|
2565
2651
|
}
|
|
2652
|
+
/**
|
|
2653
|
+
* What the chat stream still holds and what removes the rest. `perSender` is the stream's
|
|
2654
|
+
* per-subject cap: a subject is one sender on one channel, and past the cap that sender's oldest
|
|
2655
|
+
* message on the channel is discarded. `maxAgeMs` is the stream's age limit. Each is `null` when
|
|
2656
|
+
* the stream sets none. `senders` counts each sender's retained messages per channel, so a sender
|
|
2657
|
+
* at the cap marks a channel whose retained history has lost that sender's older messages while
|
|
2658
|
+
* other senders' messages from the same time remain.
|
|
2659
|
+
*/
|
|
2660
|
+
async chatRetention() {
|
|
2661
|
+
if (!this.nc)
|
|
2662
|
+
throw new Error(this.notLiveMsg());
|
|
2663
|
+
const mgr = await jetstreamManager(this.nc);
|
|
2664
|
+
let info;
|
|
2665
|
+
try {
|
|
2666
|
+
info = await mgr.streams.info(chatStream(this.space), { subjects_filter: ">" });
|
|
2667
|
+
}
|
|
2668
|
+
catch (e) {
|
|
2669
|
+
// As in listChannels: an absent CHAT stream holds nothing yet; any other failure stays loud.
|
|
2670
|
+
if (!isJetStreamMissing(e, JetStreamApiCodes.StreamNotFound))
|
|
2671
|
+
throw e;
|
|
2672
|
+
return { perSender: null, maxAgeMs: null, senders: [] };
|
|
2673
|
+
}
|
|
2674
|
+
const senders = [];
|
|
2675
|
+
for (const [subject, messages] of Object.entries(info.state.subjects ?? {})) {
|
|
2676
|
+
const p = parseSubject(subject);
|
|
2677
|
+
if (p?.kind === "chat" && isPrincipalOwnerToken(p.owner))
|
|
2678
|
+
senders.push({ channel: p.rest, sender: p.sender, messages });
|
|
2679
|
+
}
|
|
2680
|
+
const cap = info.config.max_msgs_per_subject;
|
|
2681
|
+
const age = info.config.max_age;
|
|
2682
|
+
return {
|
|
2683
|
+
perSender: typeof cap === "number" && cap > 0 ? cap : null,
|
|
2684
|
+
maxAgeMs: typeof age === "number" && age > 0 ? Math.round(age / 1_000_000) : null,
|
|
2685
|
+
senders: senders.sort((a, b) => a.channel.localeCompare(b.channel) || a.sender.localeCompare(b.sender)),
|
|
2686
|
+
};
|
|
2687
|
+
}
|
|
2566
2688
|
/** One coherent channel model for dashboards: every channel that has messages OR a registry
|
|
2567
2689
|
* entry (configured-but-empty), each tagged with its {@link ChannelConfig}. Works even on
|
|
2568
2690
|
* observer endpoints (no consumers needed). */
|
|
@@ -2905,6 +3027,94 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2905
3027
|
// history from any sender
|
|
2906
3028
|
return (await this.streamHistory(chatStream(this.space), [chatSubject(this.space, "*", "*", channel)], opts?.limit ?? 100, undefined, opts?.signal)).map((r) => r.msg);
|
|
2907
3029
|
}
|
|
3030
|
+
/** Read one concrete channel using this lifecycle's single-filter CHAT grant (SPEC §8/§9).
|
|
3031
|
+
* No observer authority, presence or delivery ACL mirror is required. Returns the newest page,
|
|
3032
|
+
* oldest-first, including this caller's own sends. `complete` says no older valid row remains.
|
|
3033
|
+
* Reads have a ten-second budget, shortened by `signal`. Concurrent connections sharing this
|
|
3034
|
+
* lifecycle wait for native consumer inactivity cleanup; they never delete another reader. */
|
|
3035
|
+
async channelHistoryPage(channel, opts) {
|
|
3036
|
+
assertValidChannel(channel);
|
|
3037
|
+
if (!isConcreteChannel(channel))
|
|
3038
|
+
throw new Error("channelHistoryPage requires one concrete channel");
|
|
3039
|
+
const limit = opts?.limit ?? 50;
|
|
3040
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
3041
|
+
throw new Error("channelHistoryPage limit must be a whole number from 1 to 1000");
|
|
3042
|
+
if (!this.nc || !this.js)
|
|
3043
|
+
throw new Error("endpoint not started");
|
|
3044
|
+
const jsm = this.jsm ??= await jetstreamManager(this.nc);
|
|
3045
|
+
const signal = AbortSignal.any([AbortSignal.timeout(10_000), ...(opts?.signal ? [opts.signal] : [])]);
|
|
3046
|
+
signal.throwIfAborted();
|
|
3047
|
+
const stream = chatStream(this.space);
|
|
3048
|
+
const name = chatHistDurable(this.owner, this.actor, this.requireLifecycleUid("channelHistoryPage"));
|
|
3049
|
+
const subject = chatSubject(this.space, "*", "*", channel);
|
|
3050
|
+
const marker = randomUUID();
|
|
3051
|
+
let created;
|
|
3052
|
+
for (;;) {
|
|
3053
|
+
signal.throwIfAborted();
|
|
3054
|
+
try {
|
|
3055
|
+
// The pinned client's add is CREATE-only. A unique description makes even an otherwise
|
|
3056
|
+
// identical occupied config conflict. Never bind/update it or pre-delete by shared name.
|
|
3057
|
+
created = await jsm.consumers.add(stream, {
|
|
3058
|
+
name, description: marker, filter_subject: subject,
|
|
3059
|
+
ack_policy: AckPolicy.None, deliver_policy: DeliverPolicy.All,
|
|
3060
|
+
mem_storage: true, inactive_threshold: nanos(250),
|
|
3061
|
+
});
|
|
3062
|
+
break;
|
|
3063
|
+
}
|
|
3064
|
+
catch (e) {
|
|
3065
|
+
if (!(e instanceof JetStreamApiError) || e.code !== 10148)
|
|
3066
|
+
throw e; // consumer already exists
|
|
3067
|
+
await delay(50, undefined, { signal });
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3070
|
+
signal.throwIfAborted();
|
|
3071
|
+
const owns = (info) => info.created === created.created &&
|
|
3072
|
+
info.config.description === marker && info.config.filter_subject === subject;
|
|
3073
|
+
if (!owns(created))
|
|
3074
|
+
throw new Error("channelHistoryPage: consumer ownership was not established");
|
|
3075
|
+
const consumer = await this.js.consumers.get(stream, name);
|
|
3076
|
+
const pending = created.num_pending;
|
|
3077
|
+
const out = [];
|
|
3078
|
+
let delivered = 0;
|
|
3079
|
+
if (pending > 0) {
|
|
3080
|
+
const iter = await consumer.consume({ max_messages: Math.min(pending, 32) });
|
|
3081
|
+
const stop = () => iter.stop(abortReason(signal));
|
|
3082
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
3083
|
+
try {
|
|
3084
|
+
signal.throwIfAborted();
|
|
3085
|
+
for await (const m of iter) {
|
|
3086
|
+
signal.throwIfAborted();
|
|
3087
|
+
if (!subjectMatches(subject, m.subject))
|
|
3088
|
+
throw new Error("channelHistoryPage: consumer delivered another channel; ownership was lost");
|
|
3089
|
+
delivered++;
|
|
3090
|
+
const msg = historyMessageFromDelivery(m);
|
|
3091
|
+
if (msg) {
|
|
3092
|
+
out.push(msg);
|
|
3093
|
+
if (out.length > limit + 1)
|
|
3094
|
+
out.shift();
|
|
3095
|
+
}
|
|
3096
|
+
if (delivered >= pending)
|
|
3097
|
+
break;
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
finally {
|
|
3101
|
+
signal.removeEventListener("abort", stop);
|
|
3102
|
+
iter.stop();
|
|
3103
|
+
}
|
|
3104
|
+
signal.throwIfAborted();
|
|
3105
|
+
if (delivered !== pending)
|
|
3106
|
+
throw new Error(`channelHistoryPage: read ${delivered} of ${pending} messages before the window was cut short`);
|
|
3107
|
+
}
|
|
3108
|
+
signal.throwIfAborted();
|
|
3109
|
+
// A legacy join/recall reader can delete/recreate this fixed name. A final ownership check
|
|
3110
|
+
// makes that interference loud. It is detection, not a fence against those legacy readers.
|
|
3111
|
+
if (!owns(await consumer.info()))
|
|
3112
|
+
throw new Error("channelHistoryPage: consumer ownership was lost");
|
|
3113
|
+
signal.throwIfAborted();
|
|
3114
|
+
// No DELETE, even after abort: a delayed owner could otherwise delete its successor between
|
|
3115
|
+
// INFO and DELETE. Native inactivity reaps this transient reader without that TOCTOU race.
|
|
3116
|
+
return { items: out.slice(-limit), complete: out.length <= limit };
|
|
3117
|
+
}
|
|
2908
3118
|
/**
|
|
2909
3119
|
* The newest `limit` chat messages across MANY channels at once, oldest-first within the page,
|
|
2910
3120
|
* each tagged with the channel the BROKER delivered it on.
|
|
@@ -2996,19 +3206,19 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2996
3206
|
// payload's `ts` here instead would change which messages the page holds, which is the
|
|
2997
3207
|
// selection question this pull request already had to answer once.
|
|
2998
3208
|
const rows = pages.flat().sort((a, b) => a.seq - b.seq).slice(-limit);
|
|
2999
|
-
return rows.map(({ subject, msg }) => {
|
|
3209
|
+
return rows.map(({ seq, subject, msg }) => {
|
|
3000
3210
|
const p = parseSubject(subject);
|
|
3001
3211
|
// The filter set is built from chat subjects, so this cannot fire against a healthy broker.
|
|
3002
3212
|
// It is here because the alternative to raising is tagging a message with a guess.
|
|
3003
3213
|
if (p?.kind !== "chat")
|
|
3004
3214
|
throw new Error(`multiChannelHistory: the broker delivered ${subject}, which is not a chat subject on this space`);
|
|
3005
|
-
return { channel: p.rest, msg };
|
|
3215
|
+
return { channel: p.rest, seq, msg };
|
|
3006
3216
|
});
|
|
3007
3217
|
}
|
|
3008
|
-
/** Read a channel's recent history
|
|
3009
|
-
*
|
|
3010
|
-
*
|
|
3011
|
-
*
|
|
3218
|
+
/** Read a channel's recent history through the delivery daemon, using a provisioned ACL mirror.
|
|
3219
|
+
* SPEC's ban on raw control-surface stream reads does not remove the single-channel CHAT grant.
|
|
3220
|
+
* Human callers without an ACL mirror use {@link channelHistoryPage}; this method adds fresh
|
|
3221
|
+
* mirror checks for provisioned readers. `items` is shape-identical to what {@link channelHistory}
|
|
3012
3222
|
* returns — the same `CotalMessage[]`, the same newest-N selection, the same oldest-first order
|
|
3013
3223
|
* within the page — so a caller migrates by reading `.items` and nothing else changes. The return
|
|
3014
3224
|
* is WRAPPED rather than bare precisely because of `complete`: a bare array cannot say whether
|
|
@@ -5753,31 +5963,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5753
5963
|
this.streamMsgs.push(msgs);
|
|
5754
5964
|
void (async () => {
|
|
5755
5965
|
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);
|
|
5966
|
+
const authed = this.authenticateStreamMsg(m);
|
|
5967
|
+
if (!authed)
|
|
5763
5968
|
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`));
|
|
5772
|
-
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
|
-
}
|
|
5969
|
+
const { msg, parsed } = authed;
|
|
5781
5970
|
// No-replay + dedup (chat only): drop a message at/below this channel's join watermark
|
|
5782
5971
|
// — pre-join history the New tail still carries for a *lagging* joiner (cursor behind the
|
|
5783
5972
|
// frontier), and the overlap a replay backfill already delivered. Must ack, or JetStream
|
|
@@ -5805,6 +5994,101 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5805
5994
|
this.emit("error", e);
|
|
5806
5995
|
});
|
|
5807
5996
|
}
|
|
5997
|
+
/** Decode one stream copy and apply the authenticity guard (fail closed): the sender is encoded in
|
|
5998
|
+
* the subject, which the server policed who could publish. The payload `from` is advisory — it
|
|
5999
|
+
* must match, and a missing `from` or an unparseable subject on a delivery is itself an anomaly.
|
|
6000
|
+
* A rejected copy is `term()`ed (permanently invalid, never redelivered) and reported, and
|
|
6001
|
+
* `undefined` returns, so no handler ever sees it. */
|
|
6002
|
+
authenticateStreamMsg(m) {
|
|
6003
|
+
let msg;
|
|
6004
|
+
try {
|
|
6005
|
+
msg = m.json();
|
|
6006
|
+
}
|
|
6007
|
+
catch (e) {
|
|
6008
|
+
m.term(); // undecodable — never redeliver
|
|
6009
|
+
this.emit("error", e);
|
|
6010
|
+
return undefined;
|
|
6011
|
+
}
|
|
6012
|
+
if (!isRecord(msg) || !isUsableMessageId(msg.id)) {
|
|
6013
|
+
m.term(); // malformed envelope (SPEC sec 5): absent/non-string id — permanently invalid
|
|
6014
|
+
this.emit("error", new Error(`dropped message on ${m.subject}: absent or non-string id`));
|
|
6015
|
+
return undefined;
|
|
6016
|
+
}
|
|
6017
|
+
const parsed = parseSubject(m.subject);
|
|
6018
|
+
if (!parsed || !msg.from || msg.from.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner)) {
|
|
6019
|
+
m.term();
|
|
6020
|
+
this.emit("error", new Error(`dropped message on ${m.subject}: payload from ${msg.from?.id ?? "(none)"} ` +
|
|
6021
|
+
`does not match subject sender ${parsed?.sender ?? "(unparseable)"}`));
|
|
6022
|
+
return undefined;
|
|
6023
|
+
}
|
|
6024
|
+
return { msg, parsed };
|
|
6025
|
+
}
|
|
6026
|
+
/** Bind this endpoint's own existing DM durable and drive it serially (EndpointOptions.dmOnly).
|
|
6027
|
+
* The bind is one CONSUMER.INFO on `dm_<owner>-<actor>-<uid>`; reading is MSG.NEXT with a batch
|
|
6028
|
+
* of one, and the next copy reaches the handler only after the previous handler settles. */
|
|
6029
|
+
async bindDmOnly(mode) {
|
|
6030
|
+
if (!this.js)
|
|
6031
|
+
throw new Error("endpoint not started");
|
|
6032
|
+
const stream = dmStream(this.space);
|
|
6033
|
+
const durable = dmDurable(this.owner, this.actor, this.requireLifecycleUid("consuming the DM inbox"));
|
|
6034
|
+
let consumer;
|
|
6035
|
+
try {
|
|
6036
|
+
consumer = await this.js.consumers.get(stream, durable);
|
|
6037
|
+
}
|
|
6038
|
+
catch (e) {
|
|
6039
|
+
if (isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound))
|
|
6040
|
+
throw new DmDurableMissingError(stream, durable);
|
|
6041
|
+
throw new DmBindRefusedError(stream, durable, e);
|
|
6042
|
+
}
|
|
6043
|
+
const ackWaitMs = await ackWaitOf(consumer); // cached by the bind's own CONSUMER.INFO, no request
|
|
6044
|
+
// A rebind after reconnect must not overlap the previous epoch's running handler. A handler that
|
|
6045
|
+
// does not settle within the stop bound fails this bind; the retry loop tries again.
|
|
6046
|
+
if (this.dmOnlyInFlight) {
|
|
6047
|
+
const settleMs = mode.settleTimeoutMs ?? 30_000;
|
|
6048
|
+
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`));
|
|
6049
|
+
}
|
|
6050
|
+
const msgs = await consumer.consume({ max_messages: 1 });
|
|
6051
|
+
this.streamMsgs.push(msgs);
|
|
6052
|
+
// The epoch this loop belongs to. nats.js can yield copies it already received after stop() began
|
|
6053
|
+
// or after a rebuild replaced the connection (a stacked pull after a missed-heartbeat reset hands
|
|
6054
|
+
// over several at once). Such a copy never reaches the handler and is neither acked nor nak'd:
|
|
6055
|
+
// the connection is closing or closed, so any reply could be lost, and the broker redelivers an
|
|
6056
|
+
// unacked copy after the durable's ack_wait to whichever epoch binds next.
|
|
6057
|
+
const boundNc = this.nc;
|
|
6058
|
+
const retired = () => this.stopped || this.nc !== boundNc || !boundNc || boundNc.isClosed();
|
|
6059
|
+
void (async () => {
|
|
6060
|
+
for await (const m of msgs) {
|
|
6061
|
+
if (retired())
|
|
6062
|
+
break;
|
|
6063
|
+
const authed = this.authenticateStreamMsg(m);
|
|
6064
|
+
if (!authed)
|
|
6065
|
+
continue;
|
|
6066
|
+
if (authed.parsed.kind !== "inst") {
|
|
6067
|
+
m.term(); // the DM stream filter admits only inst.* subjects; anything else is not a DM
|
|
6068
|
+
continue;
|
|
6069
|
+
}
|
|
6070
|
+
const run = Promise.resolve().then(() => mode.handler(authenticatedMessage(authed.msg, authed.parsed), dmOnlyDelivery(m, ackWaitMs, boundNc), {
|
|
6071
|
+
historical: false,
|
|
6072
|
+
kind: "dm",
|
|
6073
|
+
}));
|
|
6074
|
+
const flight = run.then(() => { }, () => { });
|
|
6075
|
+
this.dmOnlyInFlight = flight;
|
|
6076
|
+
try {
|
|
6077
|
+
await run;
|
|
6078
|
+
}
|
|
6079
|
+
catch (e) {
|
|
6080
|
+
this.emit("error", e instanceof Error ? e : new Error(String(e)));
|
|
6081
|
+
}
|
|
6082
|
+
finally {
|
|
6083
|
+
if (this.dmOnlyInFlight === flight)
|
|
6084
|
+
this.dmOnlyInFlight = undefined;
|
|
6085
|
+
}
|
|
6086
|
+
}
|
|
6087
|
+
})().catch((e) => {
|
|
6088
|
+
if (!this.stopped)
|
|
6089
|
+
this.emit("error", e);
|
|
6090
|
+
});
|
|
6091
|
+
}
|
|
5808
6092
|
/** Open a native core subscription to a channel's live feed (the manager-free live read path,
|
|
5809
6093
|
* broker-enforced by `sub.allow`). At-most-once — no replay, no ack; it is the live delivery for
|
|
5810
6094
|
* every channel (boot + runtime). For a `durable` channel it is also the low-latency wake-hint
|
|
@@ -6777,6 +7061,20 @@ function durableDelivery(m, ackWaitMs) {
|
|
|
6777
7061
|
redelivery: { deliveryCount: m.info.deliveryCount, ackWaitMs, working: () => m.working() },
|
|
6778
7062
|
};
|
|
6779
7063
|
}
|
|
7064
|
+
/** A DM-only copy's delivery. An ack, nak or in-progress signal on a closed connection is refused
|
|
7065
|
+
* with a throw instead of being dropped silently: the copy stays unacked and the broker redelivers it. */
|
|
7066
|
+
function dmOnlyDelivery(m, ackWaitMs, nc) {
|
|
7067
|
+
const open = (what) => {
|
|
7068
|
+
if (!nc || nc.isClosed())
|
|
7069
|
+
throw new Error(`DM-only ${what} refused: the connection that delivered this copy is closed; the broker will redeliver it`);
|
|
7070
|
+
};
|
|
7071
|
+
return {
|
|
7072
|
+
ack: () => { open("ack"); m.ack(); },
|
|
7073
|
+
nak: () => { open("nak"); m.nak(); },
|
|
7074
|
+
durable: true,
|
|
7075
|
+
redelivery: { deliveryCount: m.info.deliveryCount, ackWaitMs, working: () => { open("in-progress signal"); m.working(); } },
|
|
7076
|
+
};
|
|
7077
|
+
}
|
|
6780
7078
|
/** History drain keeps `m.json()` and used to throw the subject away. SPEC §5: on receive, verify
|
|
6781
7079
|
* `from.id` equals the subject sender; on mismatch, a missing `from`, or an unparseable delivery
|
|
6782
7080
|
* subject, reject and never surface. Fail closed on shape too: a stored JSON `null` or a truthy
|