@cotal-ai/core 0.112.0 → 0.113.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 (65) hide show
  1. package/dist/checkpoint-answer.js +3 -3
  2. package/dist/checkpoint-answer.js.map +1 -1
  3. package/dist/connector.d.ts +5 -0
  4. package/dist/connector.d.ts.map +1 -1
  5. package/dist/connector.js.map +1 -1
  6. package/dist/endpoint-action.js +5 -5
  7. package/dist/endpoint-action.js.map +1 -1
  8. package/dist/endpoint-checkpoint.js +7 -7
  9. package/dist/endpoint-checkpoint.js.map +1 -1
  10. package/dist/endpoint-records.d.ts +7 -0
  11. package/dist/endpoint-records.d.ts.map +1 -1
  12. package/dist/endpoint-records.js +16 -7
  13. package/dist/endpoint-records.js.map +1 -1
  14. package/dist/endpoint-service.d.ts.map +1 -1
  15. package/dist/endpoint-service.js +9 -17
  16. package/dist/endpoint-service.js.map +1 -1
  17. package/dist/endpoint-virtual.d.ts.map +1 -1
  18. package/dist/endpoint-virtual.js +3 -4
  19. package/dist/endpoint-virtual.js.map +1 -1
  20. package/dist/endpoint-work.d.ts.map +1 -1
  21. package/dist/endpoint-work.js +6 -14
  22. package/dist/endpoint-work.js.map +1 -1
  23. package/dist/endpoint.d.ts +23 -1
  24. package/dist/endpoint.d.ts.map +1 -1
  25. package/dist/endpoint.js +123 -74
  26. package/dist/endpoint.js.map +1 -1
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +1 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/issued-authority.d.ts +8 -0
  32. package/dist/issued-authority.d.ts.map +1 -1
  33. package/dist/issued-authority.js +18 -6
  34. package/dist/issued-authority.js.map +1 -1
  35. package/dist/issued-store-recreate.d.ts +45 -0
  36. package/dist/issued-store-recreate.d.ts.map +1 -0
  37. package/dist/issued-store-recreate.js +135 -0
  38. package/dist/issued-store-recreate.js.map +1 -0
  39. package/dist/lifecycle-state.d.ts.map +1 -1
  40. package/dist/lifecycle-state.js +7 -44
  41. package/dist/lifecycle-state.js.map +1 -1
  42. package/dist/members.d.ts +4 -0
  43. package/dist/members.d.ts.map +1 -1
  44. package/dist/members.js +29 -1
  45. package/dist/members.js.map +1 -1
  46. package/dist/provision.d.ts +1 -1
  47. package/dist/provision.d.ts.map +1 -1
  48. package/dist/provision.js +25 -3
  49. package/dist/provision.js.map +1 -1
  50. package/dist/run-migration.d.ts.map +1 -1
  51. package/dist/run-migration.js +2 -2
  52. package/dist/run-migration.js.map +1 -1
  53. package/dist/run-notice.d.ts.map +1 -1
  54. package/dist/run-notice.js +2 -2
  55. package/dist/run-notice.js.map +1 -1
  56. package/dist/schema-profile.d.ts.map +1 -1
  57. package/dist/schema-profile.js +7 -16
  58. package/dist/schema-profile.js.map +1 -1
  59. package/dist/spaces.js +1 -1
  60. package/dist/spaces.js.map +1 -1
  61. package/dist/subjects.d.ts +8 -0
  62. package/dist/subjects.d.ts.map +1 -1
  63. package/dist/subjects.js +25 -0
  64. package/dist/subjects.js.map +1 -1
  65. package/package.json +1 -1
package/dist/endpoint.js CHANGED
@@ -32,7 +32,7 @@ import { openMembersRegistry, commitMember, tombstoneMember, activateMember, rea
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, membershipBucket, MEMBERSHIP_FEED_KEY, principalKey, parsePrincipalKey, assertLifecycleToken, mintLifecycleUid, lifecycleNameKey, DEV_OWNER, spacePrefix, spaceWildcard, subjectMatches, taskStream, taskDurable, token, unicastSubject, unicastRecvFilter, } from "./subjects.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";
36
36
  import { isPermissionDenied } from "./permission-denied.js";
37
37
  export { isPermissionDenied };
38
38
  export const DEFAULT_SERVER = "nats://127.0.0.1:4222";
@@ -255,6 +255,15 @@ export class CotalEndpoint extends EventEmitter {
255
255
  * {@link armDeliveryControl}; tracked so the stale one is dropped on reconnect. */
256
256
  deliveryServeSub;
257
257
  deliveryAdminServeSub;
258
+ /** The lifecycles whose membership inventory this daemon has been asked for, as principal to uids
259
+ * (#4965). A durable join or leave bound to one of them is refused, so no row of a retiring lifecycle
260
+ * is written after its inventory read starts. An entry goes when a later inventory for the same
261
+ * principal finds its ACL row retired; an alias that never retires again keeps its one entry until
262
+ * the daemon restarts. */
263
+ retiringLifecycles = new Map();
264
+ /** The durable joins and leaves running now, by `<principal>/<lifecycleUid>`: the writes an
265
+ * inventory of that lifecycle waits for before it reads. */
266
+ membershipWrites = new Map();
258
267
  /** True once {@link quiescePlane3} has stopped serving this shard pending an ownership answer.
259
268
  * Guards {@link armPlane3} so a RECONNECT cannot silently resume serving mid-question. */
260
269
  plane3Quiesced = false;
@@ -2139,7 +2148,12 @@ export class CotalEndpoint extends EventEmitter {
2139
2148
  * PEER's reply lane (`ctl.delivery.<victim>.reply.<n>`) and turn the responder into a confused
2140
2149
  * deputy — the broker does NOT permission-check the requester's embedded reply subject. With it, a
2141
2150
  * reply is published only when `m.reply` is under the AUTHENTICATED request subject
2142
- * (`${m.subject}.reply.…`), binding the reply to the broker-policed sender token. */
2151
+ * (`${m.subject}.reply.…`), binding the reply to the broker-policed sender token.
2152
+ *
2153
+ * Requests are answered one at a time, in arrival order. A request `concurrent` accepts is answered
2154
+ * in its own time instead: it waits for none of the requests in front of it, including one still
2155
+ * running, and holds up none behind it. Only for a request that shares no state with the service's
2156
+ * other requests. */
2143
2157
  serveControl(service, handler, opts = {}) {
2144
2158
  if (!this.nc)
2145
2159
  throw new Error("endpoint not started");
@@ -2147,6 +2161,11 @@ export class CotalEndpoint extends EventEmitter {
2147
2161
  queue: service,
2148
2162
  });
2149
2163
  this.subs.push(sub);
2164
+ const inOwnTime = opts.concurrent;
2165
+ // With `concurrent`, the in-order requests are answered one after another on this chain, and the
2166
+ // reading below never waits on it, so a request answered in its own time is read and answered
2167
+ // while an in-order one is still running.
2168
+ let inOrder = Promise.resolve();
2150
2169
  void (async () => {
2151
2170
  for await (const m of sub) {
2152
2171
  // Sender-bound reply guard (confused-deputy fix): never respond to a reply target outside the
@@ -2155,47 +2174,64 @@ export class CotalEndpoint extends EventEmitter {
2155
2174
  this.emit("error", new Error(`rejected ${service} request on ${m.subject}: reply target "${m.reply ?? "(none)"}" is not under the sender's own reply subtree`));
2156
2175
  continue;
2157
2176
  }
2158
- let body;
2159
- try {
2160
- let reply;
2161
- const req = m.json();
2162
- // Authenticity guard (fail closed): control is the most privileged surface
2163
- // (start/stop). The sender is encoded in the subject (ctl.<svc>.<sender>), which
2164
- // the server policed who could publish; the payload `from` is advisory and must
2165
- // match. Reject before the handler acts on a request claiming a forged sender.
2166
- const parsed = parseSubject(m.subject);
2167
- if (!parsed || req.from?.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner)) {
2168
- this.emit("error", new Error(`rejected control request on ${m.subject}: from ${req.from?.id ?? "(none)"} ` +
2169
- `does not match subject sender ${parsed?.sender ?? "(unparseable)"}`));
2170
- reply = { ok: false, error: "sender mismatch - request rejected" };
2171
- }
2172
- else {
2173
- reply = await handler(req);
2174
- }
2175
- body = JSON.stringify(reply);
2176
- }
2177
- catch (e) {
2178
- body = JSON.stringify({ ok: false, error: e.message });
2177
+ const answer = () => this.answerControl(service, handler, m);
2178
+ if (inOwnTime === undefined) {
2179
+ await answer();
2180
+ continue;
2179
2181
  }
2182
+ let own = false;
2180
2183
  try {
2181
- m.respond(body);
2182
- }
2183
- catch (e) {
2184
- // `respond` signals a missing reply inbox by returning false; it throws when the reply exceeds the
2185
- // broker's `max_payload`. Silence would leave the caller a bare timeout it cannot tell apart from
2186
- // a dead service.
2187
- const refused = { ok: false, error: `the ${service} reply is ${Buffer.byteLength(body)} bytes and was not published: ${e.message}` };
2188
- try {
2189
- m.respond(JSON.stringify(refused));
2190
- }
2191
- catch (err) {
2192
- this.emit("error", new Error(`could not answer ${service} request on ${m.subject}: ${refused.error}, and the refusal failed too: ${err.message}`));
2193
- }
2184
+ own = inOwnTime(m.json());
2194
2185
  }
2186
+ catch { /* unreadable: answered in order, as a refusal */ }
2187
+ if (own)
2188
+ void answer().catch((e) => this.emit("error", e));
2189
+ else
2190
+ inOrder = inOrder.then(answer).catch((e) => { this.emit("error", e); });
2195
2191
  }
2196
2192
  })().catch((e) => this.emit("error", e));
2197
2193
  return sub;
2198
2194
  }
2195
+ /** Answer one control request for {@link serveControl}: the sender guard, the handler, and the reply. */
2196
+ async answerControl(service, handler, m) {
2197
+ let body;
2198
+ try {
2199
+ let reply;
2200
+ const req = m.json();
2201
+ // Authenticity guard (fail closed): control is the most privileged surface
2202
+ // (start/stop). The sender is encoded in the subject (ctl.<svc>.<sender>), which
2203
+ // the server policed who could publish; the payload `from` is advisory and must
2204
+ // match. Reject before the handler acts on a request claiming a forged sender.
2205
+ const parsed = parseSubject(m.subject);
2206
+ if (!parsed || req.from?.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner)) {
2207
+ this.emit("error", new Error(`rejected control request on ${m.subject}: from ${req.from?.id ?? "(none)"} ` +
2208
+ `does not match subject sender ${parsed?.sender ?? "(unparseable)"}`));
2209
+ reply = { ok: false, error: "sender mismatch - request rejected" };
2210
+ }
2211
+ else {
2212
+ reply = await handler(req);
2213
+ }
2214
+ body = JSON.stringify(reply);
2215
+ }
2216
+ catch (e) {
2217
+ body = JSON.stringify({ ok: false, error: e.message });
2218
+ }
2219
+ try {
2220
+ m.respond(body);
2221
+ }
2222
+ catch (e) {
2223
+ // `respond` signals a missing reply inbox by returning false; it throws when the reply exceeds the
2224
+ // broker's `max_payload`. Silence would leave the caller a bare timeout it cannot tell apart from
2225
+ // a dead service.
2226
+ const refused = { ok: false, error: `the ${service} reply is ${Buffer.byteLength(body)} bytes and was not published: ${e.message}` };
2227
+ try {
2228
+ m.respond(JSON.stringify(refused));
2229
+ }
2230
+ catch (err) {
2231
+ this.emit("error", new Error(`could not answer ${service} request on ${m.subject}: ${refused.error}, and the refusal failed too: ${err.message}`));
2232
+ }
2233
+ }
2234
+ }
2199
2235
  /** Send a control request to a service and await its reply (client side). Like {@link requestDelivery},
2200
2236
  * the reply rides a BOUNDED subject UNDER the request subject (`ctl.<service>.<id>.reply.<uuid>`), not
2201
2237
  * the per-id `_INBOX`.
@@ -4922,13 +4958,36 @@ export class CotalEndpoint extends EventEmitter {
4922
4958
  return { ok: false, error: `durableJoin: no read ACL on record for ${caller} (not provisioned for durable delivery)` };
4923
4959
  if (!channelInAllow(acl.allowSubscribe, channel))
4924
4960
  return { ok: false, error: `channel "${channel}" is not within your read ACL [${acl.allowSubscribe.join(", ")}]` };
4961
+ const uid = acl.lifecycleUid;
4925
4962
  try {
4926
- return { ok: true, data: await this.durableJoinFor(caller, channel, acl.lifecycleUid) };
4963
+ return { ok: true, data: await this.membershipWrite(caller, uid, () => this.durableJoinFor(caller, channel, uid)) };
4927
4964
  }
4928
4965
  catch (e) {
4929
4966
  return { ok: false, error: e.message };
4930
4967
  }
4931
4968
  }
4969
+ /** Run one durable join or leave that writes `principal`'s rows of `lifecycleUid`, refused once that
4970
+ * lifecycle's inventory has been asked for ({@link retiringLifecycles}). The check and the
4971
+ * registration have no await between them, so an inventory either finds this write running and
4972
+ * waits for it, or this write finds the inventory's mark and is refused. */
4973
+ async membershipWrite(principal, lifecycleUid, write) {
4974
+ if (this.retiringLifecycles.get(principal)?.has(lifecycleUid))
4975
+ throw new Error(`lifecycle ${lifecycleUid} of ${principal} is retiring: its membership inventory was requested, so its durable memberships no longer change`);
4976
+ const key = `${principal}/${lifecycleUid}`;
4977
+ const running = write();
4978
+ let writes = this.membershipWrites.get(key);
4979
+ if (writes === undefined)
4980
+ this.membershipWrites.set(key, (writes = new Set()));
4981
+ writes.add(running);
4982
+ try {
4983
+ return await running;
4984
+ }
4985
+ finally {
4986
+ writes.delete(running);
4987
+ if (writes.size === 0)
4988
+ this.membershipWrites.delete(key);
4989
+ }
4990
+ }
4932
4991
  /** LEAVE must NOT require current-ACL coverage. Leave fires precisely when the ACL was narrowed/revoked
4933
4992
  * (a refused live sub → {@link closeRefusedMembership}); gating the tombstone on the current ACL would
4934
4993
  * loop forever and leave the SPEC §7 boundary open (the membership could resume if the ACL is later
@@ -4959,8 +5018,9 @@ export class CotalEndpoint extends EventEmitter {
4959
5018
  const existing = await readMember(await this.membersRegistry(), channel, caller, uid);
4960
5019
  if (!existing)
4961
5020
  return { ok: true, data: { channel, alreadyLeft: true } }; // nothing to tombstone — idempotent
5021
+ const generation = args.generation;
4962
5022
  try {
4963
- await this.durableLeaveFor(caller, channel, uid, args.generation);
5023
+ await this.membershipWrite(caller, uid, () => this.durableLeaveFor(caller, channel, uid, generation));
4964
5024
  }
4965
5025
  catch (e) {
4966
5026
  return { ok: false, error: e.message };
@@ -5183,7 +5243,13 @@ export class CotalEndpoint extends EventEmitter {
5183
5243
  if (i >= 0)
5184
5244
  this.subs.splice(i, 1);
5185
5245
  }
5186
- this.deliveryAdminServeSub = this.serveControl(CONTROL_DELIVERY_ADMIN, (req) => this.handleDeliveryAdmin(req), { boundReply: true });
5246
+ // The membership inventory is read-only and shares nothing with the other admin commands, so it
5247
+ // does not queue behind them. In a mass stop each retirement's inventory followed every other
5248
+ // seat's `evictPrincipal` on this rail and missed its 5 s deadline (#4965).
5249
+ this.deliveryAdminServeSub = this.serveControl(CONTROL_DELIVERY_ADMIN, (req) => this.handleDeliveryAdmin(req), {
5250
+ boundReply: true,
5251
+ concurrent: (req) => req.op === "lifecycleMemberships",
5252
+ });
5187
5253
  }
5188
5254
  // ---- the peer-readable liveness surface (#1577) --------------------------
5189
5255
  /** Bind this endpoint as the liveness RESPONDER for one plane, answering presence and nothing
@@ -5509,6 +5575,19 @@ export class CotalEndpoint extends EventEmitter {
5509
5575
  catch (e) {
5510
5576
  return { ok: false, error: `lifecycleMemberships: ${e.message}` };
5511
5577
  }
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.
5581
+ let marked = this.retiringLifecycles.get(principal);
5582
+ if (marked === undefined)
5583
+ this.retiringLifecycles.set(principal, (marked = new Set()));
5584
+ // An earlier lifecycle of this principal whose ACL row is retired can bind no join or leave any
5585
+ // more, so its mark goes. A failed read keeps the mark, which only ever refuses.
5586
+ for (const earlier of [...marked])
5587
+ if (earlier !== uid && await aclRetired(await this.aclRegistry(), principal, earlier).catch(() => false))
5588
+ marked.delete(earlier);
5589
+ marked.add(uid);
5590
+ await Promise.allSettled(this.membershipWrites.get(`${principal}/${uid}`) ?? []);
5512
5591
  try {
5513
5592
  const channels = await listLifecycleMemberChannels(await this.membersRegistry(), principal, uid);
5514
5593
  return { ok: true, data: { complete: true, channels } };
@@ -7099,21 +7178,14 @@ export class CotalEndpoint extends EventEmitter {
7099
7178
  catch {
7100
7179
  return undefined;
7101
7180
  }
7102
- if (!isUsablePresence(p)) {
7181
+ if (!isPresenceRow(p, e.key)) {
7103
7182
  this.presenceBindingDrops++;
7104
- this.emitRecoverable(new Error(`dropped presence entry for key ${JSON.stringify(e.key)}: not a record with a string card.name, a known status, a finite ts, string display fields and a condition with a string code`));
7105
- return undefined;
7106
- }
7107
- // Defense-in-depth: the KV key IS the publisher's identity —
7108
- // publishPresence() writes its record under card.id — so a record whose embedded card.id disagrees
7109
- // with its bucket key is forged or corrupt. Drop it rather than surface a spoofed roster identity.
7110
- // The write-side scoping ($KV.<presenceBucket>.<own-id>) is the primary guard; this rejects a
7111
- // mis-keyed record even if a broad writer slips one in under another agent's key.
7112
- if (p.card.id !== e.key) {
7113
- this.presenceBindingDrops++;
7114
- this.emitRecoverable(new Error(`dropped presence entry for key ${JSON.stringify(e.key)}: card.id ${JSON.stringify(p.card.id)} does not match its KV key`));
7183
+ this.emitRecoverable(new Error(`dropped presence entry for key ${JSON.stringify(e.key)}: not a usable presence row (a record with card.id equal to its KV key, a string card.name, a known status, a finite ts, string display fields and a condition with a string code)`));
7115
7184
  return undefined;
7116
7185
  }
7186
+ // isPresenceRow is the one gate: it refuses a row whose card.id disagrees with the KV key, so a
7187
+ // forged or mis-keyed record never surfaces a spoofed roster identity. The write-side scoping
7188
+ // ($KV.<presenceBucket>.<own-id>) is the primary guard.
7117
7189
  return p;
7118
7190
  }
7119
7191
  applyPresence(id, raw) {
@@ -7537,29 +7609,6 @@ function assertPartsSerializable(parts) {
7537
7609
  }
7538
7610
  }
7539
7611
  }
7540
- /** A `Record` so that adding a status to {@link PresenceStatus} fails to compile until the reader accepts it. */
7541
- const PRESENCE_STATUSES = { idle: true, waiting: true, working: true, offline: true };
7542
- /** The presence fields this observer computes with, in their SPEC §6 types. A missing `ts` or text
7543
- * such as `"nope"` makes the liveness arithmetic NaN, and a JSON `1e400` parses as `Infinity`;
7544
- * neither age is ever greater than the TTL, so the row would stay live after its key expired
7545
- * (#2612). `card.name` is the roster sort key: a non-string throws inside the watch loop and
7546
- * ends the watch. `status` decides offline and is what roster consumers read. `card.role`,
7547
- * `activity`, `attention` and `condition.code` are printed as text, and the peer writes its own
7548
- * row: an object such as `{"toString":null,"valueOf":null}` throws on conversion and would fail
7549
- * every reader's roster. A present `condition` must be an object with a string `code`, which SPEC
7550
- * §6 makes required: `null`, a bare string or an object with no code is dropped too. `card.id` is
7551
- * left to the KV-key check, which rejects anything but the key string. */
7552
- function isUsablePresence(value) {
7553
- return isRecord(value) && isRecord(value.card) && typeof value.card.name === "string" &&
7554
- isOptionalString(value.card.role) &&
7555
- typeof value.status === "string" && Object.hasOwn(PRESENCE_STATUSES, value.status) &&
7556
- typeof value.ts === "number" && Number.isFinite(value.ts) &&
7557
- isOptionalString(value.activity) && isOptionalString(value.attention) &&
7558
- (value.condition === undefined || (isRecord(value.condition) && typeof value.condition.code === "string"));
7559
- }
7560
- function isOptionalString(value) {
7561
- return value === undefined || typeof value === "string";
7562
- }
7563
7612
  /** Shallow-equal two per-channel-mode maps (presence dedup): a change must re-emit, so an attention
7564
7613
  * toggle isn't swallowed as a quiet heartbeat. Absent and empty compare equal. */
7565
7614
  function sameChannelModes(a, b) {