@cotal-ai/core 0.100.2 → 0.101.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 (100) 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 +6 -1
  4. package/dist/agent-file.js.map +1 -1
  5. package/dist/auth-provider.d.ts +3 -1
  6. package/dist/auth-provider.d.ts.map +1 -1
  7. package/dist/auth-provider.js.map +1 -1
  8. package/dist/backup-config.d.ts +8 -6
  9. package/dist/backup-config.d.ts.map +1 -1
  10. package/dist/backup-config.js +30 -7
  11. package/dist/backup-config.js.map +1 -1
  12. package/dist/backup.d.ts +5 -2
  13. package/dist/backup.d.ts.map +1 -1
  14. package/dist/backup.js +12 -5
  15. package/dist/backup.js.map +1 -1
  16. package/dist/channels.d.ts +5 -4
  17. package/dist/channels.d.ts.map +1 -1
  18. package/dist/channels.js +14 -9
  19. package/dist/channels.js.map +1 -1
  20. package/dist/command.d.ts +10 -2
  21. package/dist/command.d.ts.map +1 -1
  22. package/dist/command.js +28 -2
  23. package/dist/command.js.map +1 -1
  24. package/dist/endpoint-action.d.ts +2 -2
  25. package/dist/endpoint-action.d.ts.map +1 -1
  26. package/dist/endpoint-action.js +1 -2
  27. package/dist/endpoint-action.js.map +1 -1
  28. package/dist/endpoint-call.d.ts.map +1 -1
  29. package/dist/endpoint-call.js +3 -1
  30. package/dist/endpoint-call.js.map +1 -1
  31. package/dist/endpoint-serve-kv.d.ts +1 -1
  32. package/dist/endpoint-serve-kv.js +1 -1
  33. package/dist/endpoint-service.d.ts.map +1 -1
  34. package/dist/endpoint-service.js +3 -1
  35. package/dist/endpoint-service.js.map +1 -1
  36. package/dist/endpoint.d.ts +40 -24
  37. package/dist/endpoint.d.ts.map +1 -1
  38. package/dist/endpoint.js +122 -56
  39. package/dist/endpoint.js.map +1 -1
  40. package/dist/evict.d.ts.map +1 -1
  41. package/dist/evict.js +9 -8
  42. package/dist/evict.js.map +1 -1
  43. package/dist/index.d.ts +1 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +1 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/jetstream-missing.d.ts +2 -0
  48. package/dist/jetstream-missing.d.ts.map +1 -1
  49. package/dist/jetstream-missing.js +5 -1
  50. package/dist/jetstream-missing.js.map +1 -1
  51. package/dist/lease.d.ts.map +1 -1
  52. package/dist/lease.js +4 -3
  53. package/dist/lease.js.map +1 -1
  54. package/dist/lifecycle-saga.js +1 -1
  55. package/dist/lifecycle-state.d.ts +2 -2
  56. package/dist/link.d.ts +4 -0
  57. package/dist/link.d.ts.map +1 -1
  58. package/dist/link.js +6 -0
  59. package/dist/link.js.map +1 -1
  60. package/dist/liveness.d.ts +2 -1
  61. package/dist/liveness.d.ts.map +1 -1
  62. package/dist/liveness.js +3 -2
  63. package/dist/liveness.js.map +1 -1
  64. package/dist/membership-feed.d.ts +1 -1
  65. package/dist/membership-feed.d.ts.map +1 -1
  66. package/dist/membership-feed.js +6 -5
  67. package/dist/membership-feed.js.map +1 -1
  68. package/dist/one-line.d.ts +4 -0
  69. package/dist/one-line.d.ts.map +1 -1
  70. package/dist/one-line.js +7 -0
  71. package/dist/one-line.js.map +1 -1
  72. package/dist/provision.d.ts +7 -0
  73. package/dist/provision.d.ts.map +1 -1
  74. package/dist/provision.js +26 -1
  75. package/dist/provision.js.map +1 -1
  76. package/dist/run-host.d.ts +31 -5
  77. package/dist/run-host.d.ts.map +1 -1
  78. package/dist/run-record.d.ts +16 -0
  79. package/dist/run-record.d.ts.map +1 -1
  80. package/dist/run-record.js +12 -0
  81. package/dist/run-record.js.map +1 -1
  82. package/dist/runtime.d.ts +2 -0
  83. package/dist/runtime.d.ts.map +1 -1
  84. package/dist/runtime.js +3 -1
  85. package/dist/runtime.js.map +1 -1
  86. package/dist/spaces.js +4 -4
  87. package/dist/spaces.js.map +1 -1
  88. package/dist/streams.d.ts +4 -1
  89. package/dist/streams.d.ts.map +1 -1
  90. package/dist/streams.js +35 -8
  91. package/dist/streams.js.map +1 -1
  92. package/dist/subjects.d.ts +1 -5
  93. package/dist/subjects.d.ts.map +1 -1
  94. package/dist/subjects.js +4 -6
  95. package/dist/subjects.js.map +1 -1
  96. package/dist/text-encoding.d.ts +6 -0
  97. package/dist/text-encoding.d.ts.map +1 -0
  98. package/dist/text-encoding.js +8 -0
  99. package/dist/text-encoding.js.map +1 -0
  100. package/package.json +1 -1
package/dist/endpoint.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { setTimeout as delay } from "node:timers/promises";
4
- import { createConnection } from "node:net";
4
+ import { createConnection, isIP } from "node:net";
5
5
  import { connect, credsAuthenticator, headers, tokenAuthenticator, nanos, millis, AuthorizationError, PermissionViolationError, UserAuthenticationExpiredError, NoRespondersError, RequestError, TimeoutError, ClosedConnectionError, } from "@nats-io/transport-node";
6
6
  import { wsconnect } from "@nats-io/nats-core";
7
7
  import { parseLivenessAnswer, responderFromLease, responderFromProbe, isLivenessPlane, LIVENESS_PLANES, } from "./liveness.js";
@@ -63,6 +63,8 @@ export const MULTI_FILTER_READ_CONCURRENCY = 4;
63
63
  * minutes is what the nats.js ordered consumers it replaces carried, and it keeps a reader alive
64
64
  * across the gap between pulls on a slow link. */
65
65
  const HISTORY_READER_INACTIVE_MS = 5 * 60_000;
66
+ /** The longest delay `setTimeout` honors, 2^31-1 ms. */
67
+ const MAX_TIMER_MS = 2_147_483_647;
66
68
  /** A DM-only endpoint's own `dm_` durable does not exist. The endpoint never creates it. */
67
69
  export class DmDurableMissingError extends Error {
68
70
  stream;
@@ -898,10 +900,14 @@ export class CotalEndpoint extends EventEmitter {
898
900
  : undefined;
899
901
  if (expiryMs === undefined || this.stopped)
900
902
  return;
901
- this.authExpiryReconnectTimer = setTimeout(() => {
902
- if (this.nc === nc)
903
- this.disableLibraryReconnect(nc);
904
- }, Math.max(0, expiryMs - Date.now() - 500));
903
+ // Node fires a delay above 2^31-1 ms (about 24.8 days) after 1 ms, which would disable reconnect
904
+ // at once. A later expiry waits in steps of that bound, and the fence is armed again from there.
905
+ const delay = Math.max(0, expiryMs - Date.now() - 500);
906
+ this.authExpiryReconnectTimer = delay > MAX_TIMER_MS
907
+ ? setTimeout(() => { if (this.nc === nc)
908
+ this.armAuthExpiryReconnectFence(nc); }, MAX_TIMER_MS)
909
+ : setTimeout(() => { if (this.nc === nc)
910
+ this.disableLibraryReconnect(nc); }, delay);
905
911
  this.authExpiryReconnectTimer.unref?.();
906
912
  }
907
913
  /** The connectAndBind PRE-CONNECT fetch: pull the freshest source cred and pin it into
@@ -2131,7 +2137,7 @@ export class CotalEndpoint extends EventEmitter {
2131
2137
  }
2132
2138
  /** This endpoint's v0.4 caller triple (§13.2) — the identity its minted ep-rail rows pin. The
2133
2139
  * owner/actor principal is mode-correct by construction (static: DEV_OWNER + the connection
2134
- * identity; user mode: the bearer's callout-derived pair — 1c.2c), and the lifecycle UID is the
2140
+ * identity; user mode: the bearer's callout-derived pair), and the lifecycle UID is the
2135
2141
  * launcher-supplied incarnation the rows are keyed on (ledger-consistent: the §13.1 presence
2136
2142
  * lifecycle-proof refuses a divergent uid before any publish). */
2137
2143
  serviceCaller() {
@@ -2480,7 +2486,13 @@ export class CotalEndpoint extends EventEmitter {
2480
2486
  this.activity = activity;
2481
2487
  await this.publishPresence();
2482
2488
  }
2483
- async setStatus(status) {
2489
+ /** Set the status, and with `activity` the activity line too, in one presence write, so no peer sees
2490
+ * the new activity beside the old status. */
2491
+ async setStatus(status, activity) {
2492
+ if (activity !== undefined && activity !== this.activity)
2493
+ this.statusSince = this.activitySince = Date.now();
2494
+ if (activity !== undefined)
2495
+ this.activity = activity;
2484
2496
  if (status !== this.status)
2485
2497
  this.statusSince = Date.now();
2486
2498
  this.status = status;
@@ -2562,7 +2574,8 @@ export class CotalEndpoint extends EventEmitter {
2562
2574
  }
2563
2575
  /** A channel's effective replay policy read straight from the registry bucket, for a caller that
2564
2576
  * reads it right after start, before any watch cache has caught up, or that watches nothing. A
2565
- * wildcard reads the space defaults only. A space with no registry bucket has the built-in policy. */
2577
+ * wildcard reads the space defaults only. A space with no registry bucket has the built-in policy.
2578
+ * `registered` says whether the channel has its own registry entry. */
2566
2579
  async channelReplayPolicy(channel) {
2567
2580
  if (!this.nc)
2568
2581
  throw new Error(this.notLiveMsg());
@@ -2580,7 +2593,7 @@ export class CotalEndpoint extends EventEmitter {
2580
2593
  if (!isJetStreamMissing(e, JetStreamApiCodes.StreamNotFound))
2581
2594
  throw e;
2582
2595
  }
2583
- return { replay: effectiveReplay(cfg, defaults), window: cfg?.replayWindow ?? defaults?.replayWindow ?? null };
2596
+ return { replay: effectiveReplay(cfg, defaults), window: cfg?.replayWindow ?? defaults?.replayWindow ?? null, registered: cfg !== undefined };
2584
2597
  }
2585
2598
  /** Effective delivery class for a channel (per-channel override ?? space default ?? "durable"),
2586
2599
  * from the live watch cache — drives the non-gating delivery-health surface (only durable-class
@@ -2699,7 +2712,8 @@ export class CotalEndpoint extends EventEmitter {
2699
2712
  * message on the channel is discarded. `maxAgeMs` is the stream's age limit. Each is `null` when
2700
2713
  * the stream sets none. `senders` counts each sender's retained messages per channel, so a sender
2701
2714
  * at the cap marks a channel whose retained history has lost that sender's older messages while
2702
- * other senders' messages from the same time remain.
2715
+ * other senders' messages from the same time remain. `stream` is false when the chat stream does
2716
+ * not exist, which holds nothing. `lastSeq` is the stream's newest sequence, 0 when it has none.
2703
2717
  */
2704
2718
  async chatRetention() {
2705
2719
  if (!this.nc)
@@ -2713,7 +2727,7 @@ export class CotalEndpoint extends EventEmitter {
2713
2727
  // As in listChannels: an absent CHAT stream holds nothing yet; any other failure stays loud.
2714
2728
  if (!isJetStreamMissing(e, JetStreamApiCodes.StreamNotFound))
2715
2729
  throw e;
2716
- return { perSender: null, maxAgeMs: null, senders: [] };
2730
+ return { perSender: null, maxAgeMs: null, senders: [], stream: false, lastSeq: 0 };
2717
2731
  }
2718
2732
  const senders = [];
2719
2733
  for (const [subject, messages] of Object.entries(info.state.subjects ?? {})) {
@@ -2727,6 +2741,8 @@ export class CotalEndpoint extends EventEmitter {
2727
2741
  perSender: typeof cap === "number" && cap > 0 ? cap : null,
2728
2742
  maxAgeMs: typeof age === "number" && age > 0 ? Math.round(age / 1_000_000) : null,
2729
2743
  senders: senders.sort((a, b) => a.channel.localeCompare(b.channel) || a.sender.localeCompare(b.sender)),
2744
+ stream: true,
2745
+ lastSeq: info.state.last_seq,
2730
2746
  };
2731
2747
  }
2732
2748
  /** One coherent channel model for dashboards: every channel that has messages OR a registry
@@ -3163,22 +3179,13 @@ export class CotalEndpoint extends EventEmitter {
3163
3179
  * The newest `limit` chat messages across MANY channels at once, oldest-first within the page,
3164
3180
  * each tagged with the channel the BROKER delivered it on.
3165
3181
  *
3166
- * **One read, not one per channel.** The CHAT stream already interleaves every channel into one
3167
- * sequence space, so "the newest N across these channels" is the tail of ONE stream, and a
3168
- * consumer takes a SET of filter subjects. The dashboard's activity feed used to answer this by
3169
- * calling {@link channelHistory} once per channel and merging: each of those is a widening probe
3170
- * loop, so the cost carried two multipliers (a probe loop per channel, and a fan-out across every
3171
- * channel). Counted on the wire over a seeded corpus of 69 chat channels and 24 event channels at
3172
- * limit 200: 2524 broker requests and about 8.0 MB transferred to return a 143,401-byte page,
3173
- * against 143 requests and about 0.91 MB here. With no link cost the counts and the page size
3174
- * repeat exactly across runs; the byte totals move by tens of bytes. `pnpm smoke:web-activity-read-cost` reproduces the
3175
- * second column and a frozen copy of the fan-out shape; the first is that same suite run against
3176
- * `544a974b7` (Cotal #1210).
3182
+ * The CHAT stream interleaves every channel into one sequence space. Consumers take sets of
3183
+ * filter subjects for the requested channels. Large filter sets are read in batches, and the
3184
+ * pages are merged by stream sequence to select the newest `limit` messages overall.
3177
3185
  *
3178
3186
  * **The broker does the filtering, so the wire carries only what is asked for.** A channel left
3179
3187
  * out of `channels` costs nothing: its messages are never delivered, so a space whose volume is
3180
- * dominated by channels the caller does not want stays cheap. That is the same "filter before the
3181
- * fetch" property the per-channel fan-out had, kept rather than traded away.
3188
+ * dominated by channels the caller does not want stays cheap.
3182
3189
  *
3183
3190
  * **The channel comes from the SUBJECT, never from the payload.** A message claims a `channel`
3184
3191
  * field, and this method ignores it: the tag is derived from the subject the broker routed the
@@ -3956,7 +3963,7 @@ export class CotalEndpoint extends EventEmitter {
3956
3963
  /**
3957
3964
  * Privileged: pre-create a role's shared TASK work-queue durable (auth mode), so agents
3958
3965
  * of that role can BIND it without holding CONSUMER.CREATE on TASK_<space>. The creator
3959
- * sets the filter to svc.<role>.* — agents never choose it, which stops cross-role drain.
3966
+ * sets the filter to svc.<role>.> — agents never choose it, which stops cross-role drain.
3960
3967
  * Idempotent per role. The caller must be permissive on TASK_<space>.
3961
3968
  */
3962
3969
  async provisionTaskQueue(role) {
@@ -4090,8 +4097,8 @@ export class CotalEndpoint extends EventEmitter {
4090
4097
  async renewDeliveryLease(shardIndex, revision) {
4091
4098
  return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(shardIndex, true), revision);
4092
4099
  }
4093
- /** Release the held lease on clean shutdown so a replacement daemon re-acquires immediately (best
4094
- * effort, a crash just lets the bucket TTL expire it).
4100
+ /** Release the held lease on clean shutdown so a replacement daemon re-acquires immediately (a
4101
+ * crash just lets the bucket TTL expire it).
4095
4102
  *
4096
4103
  * THE REVISION IS WHAT MAKES THIS A RELEASE RATHER THAN A DELETE. An unconditional delete removes
4097
4104
  * whatever row is there, and by shutdown time the row is not necessarily still ours: the exit
@@ -4101,6 +4108,10 @@ export class CotalEndpoint extends EventEmitter {
4101
4108
  * swap (`previousSeq` becomes JetStream's `ExpectedLastSubjectSequence`), so a row that has moved
4102
4109
  * on is left alone.
4103
4110
  *
4111
+ * A RELEASE THAT DID NOT COMMIT REJECTS. The broker's CAS refusal and any broker failure reach the
4112
+ * caller, because only the caller knows whether that matters: a shutdown that must finish anyway
4113
+ * catches it, and a close that reports whether a successor can start learns the outcome here.
4114
+ *
4104
4115
  * THE ARGUMENT IS REQUIRED, AND EXPLICITLY NULLABLE RATHER THAN OPTIONAL. `undefined` means "this
4105
4116
  * process no longer holds a revision it can argue for", which is the takeover paths' honest
4106
4117
  * answer and correctly releases nothing, the bucket TTL is the crash-safe authority and expires
@@ -4112,14 +4123,7 @@ export class CotalEndpoint extends EventEmitter {
4112
4123
  async releaseDeliveryLease(shardIndex, revision) {
4113
4124
  if (revision === undefined)
4114
4125
  return;
4115
- try {
4116
- await (await this.deliveryRegistry()).delete(leaseKey(shardIndex), { previousSeq: revision });
4117
- }
4118
- catch {
4119
- // Intentionally best-effort for EVERY failure: the lease TTL is the crash-safe release authority,
4120
- // and clean shutdown must continue even when the broker is already gone or draining. A refused
4121
- // CAS lands here too, which is the correct outcome, someone else owns the row.
4122
- }
4126
+ await (await this.deliveryRegistry()).delete(leaseKey(shardIndex), { previousSeq: revision });
4123
4127
  }
4124
4128
  /** Read a shard's delivery lease (the daemon-availability signal), or `undefined` if none is live.
4125
4129
  * READ-ONLY surface — drives Component 6's `cotal_channels` delivery-health field (an agent reads it
@@ -4339,8 +4343,8 @@ export class CotalEndpoint extends EventEmitter {
4339
4343
  await (await this.managerLeaseRegistry()).delete(MANAGER_RENEWAL_LEASE_KEY, { previousSeq: held });
4340
4344
  }
4341
4345
  catch {
4342
- // Best-effort, like releaseManagerLease: a moved revision means it is not ours, and a broker
4343
- // failure is recovered by the bucket TTL. Shutdown must not claim deletion.
4346
+ // Best-effort: a moved revision means it is not ours, and a broker failure is recovered by the
4347
+ // bucket TTL. Shutdown must not claim deletion.
4344
4348
  }
4345
4349
  }
4346
4350
  encodeDaemonRenewalLease(instanceId) {
@@ -4389,20 +4393,15 @@ export class CotalEndpoint extends EventEmitter {
4389
4393
  return { info: JSON.parse(new TextDecoder().decode(e.value)), revision: e.revision, created: e.created.getTime() };
4390
4394
  }
4391
4395
  /** Release THIS instance's key on clean shutdown so a same-id restart re-acquires immediately. CAS-guarded
4392
- * by `revision`: if we already LOST it (renew gap) the stored revision has moved, the conditional delete
4393
- * no-ops. Keyed per instance, so a release NEVER touches a sibling manager's key (security pin 6). */
4396
+ * by `revision`: if we already LOST it (renew gap) the stored revision has moved, and the conditional
4397
+ * delete is refused and rejects, like any broker failure; a shutdown that must finish anyway catches
4398
+ * it. Keyed per instance, so a release NEVER touches a sibling manager's key (security pin 6). */
4394
4399
  async releaseManagerLease(instanceId, revision) {
4395
- try {
4396
- const kv = await this.managerLeaseRegistry();
4397
- if (revision === undefined)
4398
- await kv.delete(managerLeaseKey(instanceId));
4399
- else
4400
- await kv.delete(managerLeaseKey(instanceId), { previousSeq: revision });
4401
- }
4402
- catch {
4403
- // Intentionally best-effort for EVERY failure: a revision mismatch means the lease is no longer
4404
- // ours, while a broker failure is recovered by the bucket TTL. Shutdown must not claim deletion.
4405
- }
4400
+ const kv = await this.managerLeaseRegistry();
4401
+ if (revision === undefined)
4402
+ await kv.delete(managerLeaseKey(instanceId));
4403
+ else
4404
+ await kv.delete(managerLeaseKey(instanceId), { previousSeq: revision });
4406
4405
  }
4407
4406
  /** Read a live manager liveness lease, or undefined if NONE (no manager instance holds the space). A
4408
4407
  * presence/existence check for the CLI's `spawn -f` reuse and `waitLeaseGone`, which only need "is any
@@ -5309,7 +5308,7 @@ export class CotalEndpoint extends EventEmitter {
5309
5308
  : { ok: true, data: { delivery, membership } };
5310
5309
  }
5311
5310
  if (req.op === "evictPrincipal") {
5312
- // The LIVE-EVICTION executor (D5 slice 6): force-drop a denied principal's connections.
5311
+ // The LIVE-EVICTION executor: force-drop a denied principal's connections.
5313
5312
  // Composition-root hook because the $SYS observer/evictor creds live outside this endpoint's
5314
5313
  // trust boundary; absent hook = a daemon build without the executor, refused loudly.
5315
5314
  if (!this.plane3?.evictPrincipal)
@@ -5964,7 +5963,7 @@ export class CotalEndpoint extends EventEmitter {
5964
5963
  inactiveThresholdMs: this.inactiveThresholdMs,
5965
5964
  });
5966
5965
  }
5967
- await this.pump(dmStream(this.space), dmDurable(this.owner, this.actor, ownUid));
5966
+ await this.pump(dmStream(this.space), dmDurable(this.owner, this.actor, ownUid), { inbox: true });
5968
5967
  // Plane-3 (SPEC §8): bind + pump our per-member DELIVER durable (`dlv_<id>`) — the re-authorized
5969
5968
  // durable-backstop channel copies the trusted reader transfers to us. No-op when it isn't present
5970
5969
  // (open mode / un-provisioned). Auth-only feature; the pump self-guards on the durable's existence.
@@ -6008,13 +6007,15 @@ export class CotalEndpoint extends EventEmitter {
6008
6007
  /** Drive one consumer: decode and hand each message to listeners with ack control. Our own sends are
6009
6008
  * delivered too: on the DM inbox and the role queue they were addressed to us, so none is an echo,
6010
6009
  * and acking one unseen on the work queue would delete the only copy of the request. */
6011
- async pump(stream, durable) {
6010
+ async pump(stream, durable, opts = {}) {
6012
6011
  if (!this.js)
6013
6012
  throw new Error("endpoint not started");
6014
6013
  const consumer = await this.js.consumers.get(stream, durable);
6015
6014
  const ackWaitMs = await ackWaitOf(consumer);
6016
6015
  const msgs = await consumer.consume();
6017
6016
  this.streamMsgs.push(msgs);
6017
+ if (opts.inbox)
6018
+ this.watchInbox(msgs, stream, durable);
6018
6019
  void (async () => {
6019
6020
  for await (const m of msgs) {
6020
6021
  const authed = this.authenticateStreamMsg(m);
@@ -6048,6 +6049,42 @@ export class CotalEndpoint extends EventEmitter {
6048
6049
  this.emit("error", e);
6049
6050
  });
6050
6051
  }
6052
+ /** Warn once when the broker reports this endpoint's own DM inbox missing while the connection is
6053
+ * up, as after a store reset that the client's reconnect rode through. nats.js keeps checking and
6054
+ * resumes pulling if the inbox comes back, so this only reports. One report can be a passing state,
6055
+ * so the warning waits for a second one; the several 409s a delete answers pending pulls with count
6056
+ * once. A pull means the consumer was found again, which re-arms the warning. The loop ends when
6057
+ * the consumer is stopped, by `stop()` or by a rebuild replacing this connection. */
6058
+ watchInbox(msgs, stream, durable) {
6059
+ const boundNc = this.nc;
6060
+ void (async () => {
6061
+ let absences = 0;
6062
+ let deleted = false;
6063
+ for await (const s of msgs.status()) {
6064
+ if (this.stopped || this.nc !== boundNc)
6065
+ break;
6066
+ if (s.type === "next") {
6067
+ absences = 0;
6068
+ deleted = false;
6069
+ continue;
6070
+ }
6071
+ if (s.type === "consumer_deleted") {
6072
+ if (deleted)
6073
+ continue;
6074
+ deleted = true;
6075
+ }
6076
+ else if (s.type !== "consumer_not_found" && s.type !== "stream_not_found")
6077
+ continue;
6078
+ if (++absences === 2)
6079
+ this.emitRecoverable(new Error(`DM inbox missing: the broker reports consumer "${durable}" on stream "${stream}" missing, so direct ` +
6080
+ "messages to this endpoint are not delivered. The endpoint keeps checking; if the inbox does not " +
6081
+ "come back, end this session and start a new one"));
6082
+ }
6083
+ })().catch((e) => {
6084
+ if (!this.stopped)
6085
+ this.emit("error", e);
6086
+ });
6087
+ }
6051
6088
  /** Decode one stream copy and apply the authenticity guard (fail closed): the sender is encoded in
6052
6089
  * the subject, which the server policed who could publish. The payload `from` is advisory — it
6053
6090
  * must match, and a missing `from` or an unparseable subject on a delivery is itself an anomaly.
@@ -6280,6 +6317,12 @@ export class CotalEndpoint extends EventEmitter {
6280
6317
  const jsm = await this.manager();
6281
6318
  return (await jsm.streams.info(chatStream(this.space))).state.last_seq;
6282
6319
  }
6320
+ /** The bytes this connection's account holds in JetStream file storage, as `$JS.API.INFO`
6321
+ * reports them. A broker's `max_file_store` cap counts every account it serves, so this is a
6322
+ * lower bound on what that cap measures. */
6323
+ async storedBytes() {
6324
+ return (await (await this.manager()).getAccountInfo()).storage;
6325
+ }
6283
6326
  /** Phase 1 of a join — arm each channel's tail-drop watermark at the current frontier. MUST run
6284
6327
  * BEFORE opening the core subscription so the live tail can never carry a just-joined message
6285
6328
  * un-watermarked — which would double-emit it (live + backfill).
@@ -6919,7 +6962,7 @@ export class CotalEndpoint extends EventEmitter {
6919
6962
  this.applyPresence(e.key, p);
6920
6963
  }
6921
6964
  applyPresence(id, raw) {
6922
- // Defense-in-depth (per-user-auth closure (i), residual 3): the KV key IS the publisher's identity —
6965
+ // Defense-in-depth: the KV key IS the publisher's identity —
6923
6966
  // publishPresence() writes its record under card.id — so a record whose embedded card.id disagrees
6924
6967
  // with its bucket key is forged or corrupt. Drop it rather than surface a spoofed roster identity.
6925
6968
  // The write-side scoping ($KV.<presenceBucket>.<own-id>) is the primary guard; this rejects a
@@ -7537,15 +7580,38 @@ export function defaultProbeTimeoutMs(servers) {
7537
7580
  * `wss://host/path`), everything else through the TCP transport. The websocket dial OWNS its
7538
7581
  * transport options: the URL scheme already decides TLS there, and the w3c transport refuses a
7539
7582
  * `tls` block outright ("'tls' is not configurable"), so it is stripped here — at the one point
7540
- * that knows which transport is dialing — rather than at every caller composing auth options. */
7583
+ * that knows which transport is dialing — rather than at every caller composing auth options.
7584
+ * A TCP dial that requires TLS to one IP address names that address as the host its certificate
7585
+ * must cover (see {@link withDialedTlsHost}). */
7541
7586
  export function dialerFor(servers) {
7542
7587
  if (!wsServers(servers))
7543
- return connect;
7588
+ return ((opts) => connect(withDialedTlsHost(servers, opts)));
7544
7589
  return ((opts) => {
7545
7590
  const { tls: _tls, ...rest } = (opts ?? {});
7546
7591
  return wsconnect(rest);
7547
7592
  });
7548
7593
  }
7594
+ /** `opts` for a dial to `servers`, with the IP address it names as the host a TLS certificate must
7595
+ * cover when the dial requires TLS: a `tls` block, or a `tls://` address. The TCP transport upgrades
7596
+ * to TLS with no server name for an IP, and Node then checks the certificate against "localhost":
7597
+ * a certificate for the dialed address alone is refused, and one naming localhost passes for any
7598
+ * address. A dial that does not require TLS keeps its options, because a `tls` block refuses a
7599
+ * plaintext broker, and so does a list of several servers, whose one host the options cannot name. */
7600
+ function withDialedTlsHost(servers, opts) {
7601
+ const urls = servers.split(",").map((url) => url.trim()).filter(Boolean);
7602
+ const tls = opts?.tls;
7603
+ if (urls.length !== 1 || tls === null || tls === undefined && !/^tls:\/\//i.test(urls[0]))
7604
+ return opts;
7605
+ let host;
7606
+ try {
7607
+ ({ host } = hostPort(urls[0]));
7608
+ }
7609
+ catch {
7610
+ return opts;
7611
+ }
7612
+ // Node checks an upgraded socket's certificate against `host` when no server name is set.
7613
+ return isIP(host) ? { ...opts, tls: { host, ...tls } } : opts;
7614
+ }
7549
7615
  /** Parse a NATS server URL (`nats://host:port`, `host:port`, a bare host, or a comma list — the
7550
7616
  * first entry wins) into a host+port for {@link tcpInfoProbe}. Defaults the port to 4222. */
7551
7617
  function hostPort(server) {