@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.
Files changed (169) hide show
  1. package/dist/agent-file.d.ts +2 -1
  2. package/dist/agent-file.d.ts.map +1 -1
  3. package/dist/agent-file.js +5 -3
  4. package/dist/agent-file.js.map +1 -1
  5. package/dist/auth-provider.d.ts +155 -1
  6. package/dist/auth-provider.d.ts.map +1 -1
  7. package/dist/auth-provider.js +24 -0
  8. package/dist/auth-provider.js.map +1 -1
  9. package/dist/authority-store-config.d.ts +31 -0
  10. package/dist/authority-store-config.d.ts.map +1 -0
  11. package/dist/authority-store-config.js +45 -0
  12. package/dist/authority-store-config.js.map +1 -0
  13. package/dist/backup-config.d.ts +32 -1
  14. package/dist/backup-config.d.ts.map +1 -1
  15. package/dist/backup-config.js +11 -0
  16. package/dist/backup-config.js.map +1 -1
  17. package/dist/backup.d.ts +4 -0
  18. package/dist/backup.d.ts.map +1 -1
  19. package/dist/backup.js +14 -0
  20. package/dist/backup.js.map +1 -1
  21. package/dist/broker-identity.d.ts +24 -0
  22. package/dist/broker-identity.d.ts.map +1 -0
  23. package/dist/broker-identity.js +36 -0
  24. package/dist/broker-identity.js.map +1 -0
  25. package/dist/browser-control.d.ts +1 -0
  26. package/dist/browser-control.d.ts.map +1 -1
  27. package/dist/browser-control.js +1 -0
  28. package/dist/browser-control.js.map +1 -1
  29. package/dist/browser-validators-build.d.ts +1 -0
  30. package/dist/browser-validators-build.d.ts.map +1 -1
  31. package/dist/channels.d.ts +14 -0
  32. package/dist/channels.d.ts.map +1 -1
  33. package/dist/channels.js +19 -5
  34. package/dist/channels.js.map +1 -1
  35. package/dist/command.d.ts +57 -0
  36. package/dist/command.d.ts.map +1 -1
  37. package/dist/command.js +49 -2
  38. package/dist/command.js.map +1 -1
  39. package/dist/connector-setup.d.ts +6 -0
  40. package/dist/connector-setup.d.ts.map +1 -1
  41. package/dist/connector.d.ts +3 -0
  42. package/dist/connector.d.ts.map +1 -1
  43. package/dist/connector.js.map +1 -1
  44. package/dist/endpoint-binding.d.ts +66 -1
  45. package/dist/endpoint-binding.d.ts.map +1 -1
  46. package/dist/endpoint-binding.js +167 -118
  47. package/dist/endpoint-binding.js.map +1 -1
  48. package/dist/endpoint-call.js +1 -1
  49. package/dist/endpoint-call.js.map +1 -1
  50. package/dist/endpoint-caller.d.ts +2 -0
  51. package/dist/endpoint-caller.d.ts.map +1 -1
  52. package/dist/endpoint-caller.js.map +1 -1
  53. package/dist/endpoint-grants.d.ts +15 -5
  54. package/dist/endpoint-grants.d.ts.map +1 -1
  55. package/dist/endpoint-grants.js +17 -5
  56. package/dist/endpoint-grants.js.map +1 -1
  57. package/dist/endpoint-invoke.d.ts +9 -0
  58. package/dist/endpoint-invoke.d.ts.map +1 -1
  59. package/dist/endpoint-invoke.js +7 -0
  60. package/dist/endpoint-invoke.js.map +1 -1
  61. package/dist/endpoint-schema-diff.d.ts +20 -0
  62. package/dist/endpoint-schema-diff.d.ts.map +1 -0
  63. package/dist/endpoint-schema-diff.js +15 -0
  64. package/dist/endpoint-schema-diff.js.map +1 -0
  65. package/dist/endpoint-serve.d.ts +4 -0
  66. package/dist/endpoint-serve.d.ts.map +1 -1
  67. package/dist/endpoint-serve.js +1 -1
  68. package/dist/endpoint-serve.js.map +1 -1
  69. package/dist/endpoint.d.ts +98 -5
  70. package/dist/endpoint.d.ts.map +1 -1
  71. package/dist/endpoint.js +350 -36
  72. package/dist/endpoint.js.map +1 -1
  73. package/dist/identity.d.ts +18 -0
  74. package/dist/identity.d.ts.map +1 -1
  75. package/dist/identity.js +28 -1
  76. package/dist/identity.js.map +1 -1
  77. package/dist/index.d.ts +15 -1
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +15 -1
  80. package/dist/index.js.map +1 -1
  81. package/dist/issued-authority.d.ts +25 -1
  82. package/dist/issued-authority.d.ts.map +1 -1
  83. package/dist/issued-authority.js +24 -7
  84. package/dist/issued-authority.js.map +1 -1
  85. package/dist/membership-feed.d.ts +16 -12
  86. package/dist/membership-feed.d.ts.map +1 -1
  87. package/dist/membership-feed.js +143 -12
  88. package/dist/membership-feed.js.map +1 -1
  89. package/dist/one-line.d.ts +5 -4
  90. package/dist/one-line.d.ts.map +1 -1
  91. package/dist/one-line.js +6 -5
  92. package/dist/one-line.js.map +1 -1
  93. package/dist/provision.d.ts +8 -1
  94. package/dist/provision.d.ts.map +1 -1
  95. package/dist/provision.js +39 -17
  96. package/dist/provision.js.map +1 -1
  97. package/dist/registry.d.ts +8 -4
  98. package/dist/registry.d.ts.map +1 -1
  99. package/dist/registry.js +58 -20
  100. package/dist/registry.js.map +1 -1
  101. package/dist/remote-manager-authority.d.ts +34 -2
  102. package/dist/remote-manager-authority.d.ts.map +1 -1
  103. package/dist/remote-manager-authority.js.map +1 -1
  104. package/dist/remote-service-authority.d.ts +10 -6
  105. package/dist/remote-service-authority.d.ts.map +1 -1
  106. package/dist/remote-service-authority.js +4 -1
  107. package/dist/remote-service-authority.js.map +1 -1
  108. package/dist/run-admission.d.ts +24 -1
  109. package/dist/run-admission.d.ts.map +1 -1
  110. package/dist/run-admission.js +60 -3
  111. package/dist/run-admission.js.map +1 -1
  112. package/dist/run-cancel.d.ts +43 -0
  113. package/dist/run-cancel.d.ts.map +1 -0
  114. package/dist/run-cancel.js +72 -0
  115. package/dist/run-cancel.js.map +1 -0
  116. package/dist/run-host.d.ts +12 -0
  117. package/dist/run-host.d.ts.map +1 -1
  118. package/dist/run-submission-client.d.ts +35 -0
  119. package/dist/run-submission-client.d.ts.map +1 -0
  120. package/dist/run-submission-client.js +61 -0
  121. package/dist/run-submission-client.js.map +1 -0
  122. package/dist/run-submission.d.ts +78 -0
  123. package/dist/run-submission.d.ts.map +1 -0
  124. package/dist/run-submission.js +158 -0
  125. package/dist/run-submission.js.map +1 -0
  126. package/dist/sealed-reply.d.ts +21 -0
  127. package/dist/sealed-reply.d.ts.map +1 -0
  128. package/dist/sealed-reply.js +75 -0
  129. package/dist/sealed-reply.js.map +1 -0
  130. package/dist/secret-fs.d.ts +29 -6
  131. package/dist/secret-fs.d.ts.map +1 -1
  132. package/dist/secret-fs.js +107 -18
  133. package/dist/secret-fs.js.map +1 -1
  134. package/dist/space-deletion-targets.d.ts +4 -0
  135. package/dist/space-deletion-targets.d.ts.map +1 -0
  136. package/dist/space-deletion-targets.js +15 -0
  137. package/dist/space-deletion-targets.js.map +1 -0
  138. package/dist/space-initialization-diff.d.ts +47 -0
  139. package/dist/space-initialization-diff.d.ts.map +1 -0
  140. package/dist/space-initialization-diff.js +100 -0
  141. package/dist/space-initialization-diff.js.map +1 -0
  142. package/dist/space-initialization-observation.d.ts +52 -0
  143. package/dist/space-initialization-observation.d.ts.map +1 -0
  144. package/dist/space-initialization-observation.js +133 -0
  145. package/dist/space-initialization-observation.js.map +1 -0
  146. package/dist/space-resource-definitions.d.ts +86 -0
  147. package/dist/space-resource-definitions.d.ts.map +1 -0
  148. package/dist/space-resource-definitions.js +133 -0
  149. package/dist/space-resource-definitions.js.map +1 -0
  150. package/dist/space-schema-diff.d.ts +45 -0
  151. package/dist/space-schema-diff.d.ts.map +1 -0
  152. package/dist/space-schema-diff.js +204 -0
  153. package/dist/space-schema-diff.js.map +1 -0
  154. package/dist/space-schema-observation.d.ts +45 -0
  155. package/dist/space-schema-observation.d.ts.map +1 -0
  156. package/dist/space-schema-observation.js +78 -0
  157. package/dist/space-schema-observation.js.map +1 -0
  158. package/dist/spaces.d.ts.map +1 -1
  159. package/dist/spaces.js +5 -25
  160. package/dist/spaces.js.map +1 -1
  161. package/dist/stream-schema-diff.d.ts +31 -0
  162. package/dist/stream-schema-diff.d.ts.map +1 -0
  163. package/dist/stream-schema-diff.js +45 -0
  164. package/dist/stream-schema-diff.js.map +1 -0
  165. package/dist/streams.d.ts +30 -1
  166. package/dist/streams.d.ts.map +1 -1
  167. package/dist/streams.js +41 -18
  168. package/dist/streams.js.map +1 -1
  169. 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 THROUGH THE DELIVERY DAEMON instead of through a consumer this
3009
- * connection creates itself — the mediated read of SPEC's "Mediated reads (normative)" rule (no raw
3010
- * consumer / `DIRECT.GET` / `STREAM.MSG.GET` for an untrusted holder; the trusted reader serves it
3011
- * onto the caller's own confined rail). `items` is shape-identical to what {@link channelHistory}
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
- // Named consumer creation is idempotent for the same config. A denial must not be mistaken for
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
- // Same fail-loud rule as fan-out: idempotent success is success, denial is not existence.
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
- let msg;
5757
- try {
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