@cotal-ai/core 0.113.0 → 0.113.3

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/endpoint.js CHANGED
@@ -28,11 +28,12 @@ import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, JetStreamApiCode
28
28
  import {} from "@nats-io/jetstream";
29
29
  import { Kvm } from "@nats-io/kv";
30
30
  import { Bucket, KvWatchInclude } from "@nats-io/kv/internal";
31
- import { openMembersRegistry, commitMember, tombstoneMember, activateMember, readMember, listMembers, listLifecycleMemberChannels, durableEligible, StaleMembershipWrite, } from "./members.js";
31
+ import { openMembersRegistry, commitMember, commitMemberRevision, tombstoneMember, activateMember, readMember, deleteMember, listMembers, listLifecycleMemberChannels, durableEligible, StaleMembershipWrite, } from "./members.js";
32
32
  import { openAclRegistry, readAcl, readAclForAlias, aclRetired, AmbiguousAclAlias, commitAcl as writeAclRecord, reissueAcl as writeAclReissue } from "./acls.js";
33
33
  import { openDeliveryRegistry } from "./lease.js";
34
34
  import { openChannelRegistry, effectiveReplay, effectiveReplayWindowMs, effectiveDeliveryClass, readChannelConfig, readChannelDefaults, decodeChannelEntry, } from "./channels.js";
35
- import { anycastSubject, assertValidRole, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, historyReaderConsumer, calloutReaderId, HISTORY_READERS, controlServiceSubject, livenessSubject, livenessServeFilter, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, dmStream, dmDurable, dlvStream, dlvDurable, dlvSubject, dinboxSubject, inboxStream, parseDinboxPrincipal, FANOUT_DURABLE, INBOX_READER_DURABLE, leaseKey, managerBucket, MANAGER_LEASE_KEY, MANAGER_RENEWAL_LEASE_KEY, managerLeaseKey, chatWildcard, assertValidChannel, channelInAllow, isConcreteChannel, normalizeMentions, parseSubject, isPrincipalOwnerToken, assertInboxConnId, presenceBucket, isPresenceRow, membershipBucket, MEMBERSHIP_FEED_KEY, principalKey, parsePrincipalKey, assertLifecycleToken, mintLifecycleUid, lifecycleNameKey, DEV_OWNER, spacePrefix, spaceWildcard, subjectMatches, taskStream, taskDurable, token, unicastSubject, unicastRecvFilter, } from "./subjects.js";
35
+ import { isCasLoss } from "./endpoint-records.js";
36
+ import { anycastSubject, assertValidRole, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, historyReaderConsumer, calloutReaderId, HISTORY_READERS, controlServiceSubject, livenessSubject, livenessServeFilter, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, dmStream, dmDurable, dlvStream, dlvDurable, dlvSubject, dinboxSubject, inboxStream, parseDinboxPrincipal, FANOUT_DURABLE, INBOX_READER_DURABLE, leaseKey, managerBucket, MANAGER_LEASE_KEY, MANAGER_RENEWAL_LEASE_KEY, managerLeaseKey, chatWildcard, assertValidChannel, channelInAllow, isConcreteChannel, normalizeMentions, parseSubject, isPrincipalOwnerToken, assertInboxConnId, presenceBucket, isPresenceRow, membershipBucket, MEMBERSHIP_FEED_KEY, principalKey, parsePrincipalKey, assertLifecycleToken, mintLifecycleUid, lifecycleNameKey, memberKey, DEV_OWNER, spacePrefix, spaceWildcard, subjectMatches, taskStream, taskDurable, token, unicastSubject, unicastRecvFilter, } from "./subjects.js";
36
37
  import { isPermissionDenied } from "./permission-denied.js";
37
38
  export { isPermissionDenied };
38
39
  export const DEFAULT_SERVER = "nats://127.0.0.1:4222";
@@ -259,7 +260,9 @@ export class CotalEndpoint extends EventEmitter {
259
260
  * (#4965). A durable join or leave bound to one of them is refused, so no row of a retiring lifecycle
260
261
  * is written after its inventory read starts. An entry goes when a later inventory for the same
261
262
  * principal finds its ACL row retired; an alias that never retires again keeps its one entry until
262
- * the daemon restarts. */
263
+ * the daemon restarts. This is the second layer of the fence: it covers a manager that does not
264
+ * narrow the ACL row before the inventory, on this daemon process only. The first is the ACL row
265
+ * each join re-reads around its write (`durableJoinFor`'s `aclFence`), which holds across daemons. */
263
266
  retiringLifecycles = new Map();
264
267
  /** The durable joins and leaves running now, by `<principal>/<lifecycleUid>`: the writes an
265
268
  * inventory of that lifecycle waits for before it reads. */
@@ -4728,8 +4731,15 @@ export class CotalEndpoint extends EventEmitter {
4728
4731
  *
4729
4732
  * Runs on the daemon (which hosts the fan-out/reader loops + the members KV), so catch-up + the
4730
4733
  * activation fence read are in-process — no cross-process cursor read.
4734
+ *
4735
+ * `aclFence` (the `ctl.delivery` join always sets it) re-reads the lifecycle's exact ACL row
4736
+ * immediately before the write that creates or reopens its row, and again after it. When the channel
4737
+ * is no longer within that row, the join is refused, and a row it already wrote is removed first.
4738
+ * A retirement narrows that ACL row before it reads the lifecycle's inventory (fenceLifecycleAcl),
4739
+ * so a row written too late for the inventory removes itself, whichever daemon process wrote it.
4740
+ * Without the option the write is a privileged assertion that consults no ACL.
4731
4741
  */
4732
- async durableJoinFor(owner, channel, lifecycleUid) {
4742
+ async durableJoinFor(owner, channel, lifecycleUid, opts = {}) {
4733
4743
  if (!this.js)
4734
4744
  throw new Error("endpoint not started");
4735
4745
  await this.manager(); // ensure jsm — a non-consuming provisioner inits it lazily; catch-up + fence need it
@@ -4752,8 +4762,13 @@ export class CotalEndpoint extends EventEmitter {
4752
4762
  channel, owner, lifecycleUid, state: "durable-active", joinCursor, generation,
4753
4763
  activated: false, writerIdentity: this.card.id, updatedAt: Date.now(),
4754
4764
  };
4755
- if (!open)
4756
- await commitMember(kv, base);
4765
+ if (!open) {
4766
+ if (opts.aclFence && !(await this.aclAllows(owner, lifecycleUid, channel)))
4767
+ throw new Error(`durableJoin: "${channel}" is no longer within the read ACL of lifecycle ${lifecycleUid} (narrowed or retired); no membership row was written`);
4768
+ const written = await commitMemberRevision(kv, base);
4769
+ if (opts.aclFence)
4770
+ await this.keepRowIfAclAllows(kv, owner, lifecycleUid, channel, written.revision);
4771
+ }
4757
4772
  const fence = Math.max(await this.chatFrontier(), await this.fanoutDeliveredSeq());
4758
4773
  const cu = await this.catchupCopy(owner, lifecycleUid, channel, joinCursor, fence, generation);
4759
4774
  if (cu.evicted) {
@@ -4781,6 +4796,44 @@ export class CotalEndpoint extends EventEmitter {
4781
4796
  return { durable: false, reason: "activation superseded by a concurrent leave or rejoin", generation };
4782
4797
  return { durable: true, generation };
4783
4798
  }
4799
+ /** Whether one lifecycle's exact ACL row allows `channel` now. A leader-served read: the handle is
4800
+ * opened without direct reads, so a row narrowed before this read began is the row it returns. */
4801
+ async aclAllows(owner, lifecycleUid, channel) {
4802
+ const row = await readAcl(await this.aclRegistry(), owner, lifecycleUid);
4803
+ return row !== undefined && channelInAllow(row.record.allowSubscribe, channel);
4804
+ }
4805
+ /** The post-write half of {@link durableJoinFor}'s `aclFence`: keep the row just written (at
4806
+ * `revision`) only on proof that the lifecycle's ACL row still allows the channel, and refuse the
4807
+ * join otherwise. When the ACL row proves the channel is no longer allowed, no row of the lifecycle
4808
+ * on that channel is wanted, so the row goes by its exact key whoever wrote its latest revision.
4809
+ * When the read itself fails, nothing is proven either way, so only this write is taken back: a
4810
+ * later revision belongs to another join of the same lifecycle, which may have been authorized. */
4811
+ async keepRowIfAclAllows(kv, owner, lifecycleUid, channel, revision) {
4812
+ let disallowed = false;
4813
+ let why;
4814
+ try {
4815
+ if (await this.aclAllows(owner, lifecycleUid, channel))
4816
+ return;
4817
+ disallowed = true;
4818
+ why = "was narrowed or retired while the row was written";
4819
+ }
4820
+ catch (e) {
4821
+ why = `could not be re-read after the row was written (${e.message})`;
4822
+ }
4823
+ const fail = (outcome, cause) => new Error(`durableJoin: the read ACL of lifecycle ${lifecycleUid} ${why}; ${outcome}`, cause === undefined ? undefined : { cause });
4824
+ try {
4825
+ if (disallowed)
4826
+ await deleteMember(kv, channel, owner, lifecycleUid);
4827
+ else
4828
+ await kv.purge(memberKey(channel, owner, lifecycleUid), { previousSeq: revision });
4829
+ }
4830
+ catch (e) {
4831
+ if (!disallowed && isCasLoss(e))
4832
+ throw fail(`a later write replaced its "${channel}" row, which was left to that write`);
4833
+ throw fail(`removing its "${channel}" row failed (${e.message})`, e);
4834
+ }
4835
+ throw fail(`its "${channel}" row was removed`);
4836
+ }
4784
4837
  /** Privileged durable-LEAVE write: tombstone the membership at `leaveCursor = frontier` so the
4785
4838
  * backstop denies `seq > leaveCursor` while a pre-leave entry stays deliverable (SPEC §7 interval). */
4786
4839
  async durableLeaveFor(owner, channel, lifecycleUid, expectedGeneration) {
@@ -4959,8 +5012,10 @@ export class CotalEndpoint extends EventEmitter {
4959
5012
  if (!channelInAllow(acl.allowSubscribe, channel))
4960
5013
  return { ok: false, error: `channel "${channel}" is not within your read ACL [${acl.allowSubscribe.join(", ")}]` };
4961
5014
  const uid = acl.lifecycleUid;
5015
+ // The lookup above may be stale by the time the row is written (a retirement can fence and purge
5016
+ // this lifecycle meanwhile), so the write re-checks the exact ACL row itself (`aclFence`).
4962
5017
  try {
4963
- return { ok: true, data: await this.membershipWrite(caller, uid, () => this.durableJoinFor(caller, channel, uid)) };
5018
+ return { ok: true, data: await this.membershipWrite(caller, uid, () => this.durableJoinFor(caller, channel, uid, { aclFence: true })) };
4964
5019
  }
4965
5020
  catch (e) {
4966
5021
  return { ok: false, error: e.message };
@@ -5575,9 +5630,11 @@ export class CotalEndpoint extends EventEmitter {
5575
5630
  catch (e) {
5576
5631
  return { ok: false, error: `lifecycleMemberships: ${e.message}` };
5577
5632
  }
5578
- // The lifecycle is retiring, so its memberships stop changing here (#4965): a join or leave bound
5579
- // to it is refused from now on, and the read starts after the ones already running have finished.
5580
- // A row they write is then in the read; nothing it misses can be written afterwards.
5633
+ // A retirement fences the lifecycle's ACL row before it asks (fenceLifecycleAcl), and every join
5634
+ // re-reads that row around the row it creates, so a row written after this read begins removes
5635
+ // itself, on whichever daemon wrote it. For a manager that does not fence, this daemon's own
5636
+ // memberships of the lifecycle stop changing here (#4965): a join or leave bound to it is refused
5637
+ // from now on, and the read starts after the ones already running have finished.
5581
5638
  let marked = this.retiringLifecycles.get(principal);
5582
5639
  if (marked === undefined)
5583
5640
  this.retiringLifecycles.set(principal, (marked = new Set()));