@cotal-ai/core 0.50.1 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth-provider.d.ts +91 -2
- package/dist/auth-provider.d.ts.map +1 -1
- package/dist/auth-provider.js +6 -0
- package/dist/auth-provider.js.map +1 -1
- package/dist/command.d.ts +7 -0
- package/dist/command.d.ts.map +1 -1
- package/dist/command.js.map +1 -1
- package/dist/connector.d.ts +6 -2
- package/dist/connector.d.ts.map +1 -1
- package/dist/connector.js.map +1 -1
- package/dist/endpoint-grants.d.ts +7 -5
- package/dist/endpoint-grants.d.ts.map +1 -1
- package/dist/endpoint-grants.js +11 -6
- package/dist/endpoint-grants.js.map +1 -1
- package/dist/endpoint-serve.d.ts +8 -1
- package/dist/endpoint-serve.d.ts.map +1 -1
- package/dist/endpoint-serve.js +13 -2
- package/dist/endpoint-serve.js.map +1 -1
- package/dist/endpoint-verbs.d.ts +17 -5
- package/dist/endpoint-verbs.d.ts.map +1 -1
- package/dist/endpoint-verbs.js +49 -8
- package/dist/endpoint-verbs.js.map +1 -1
- package/dist/endpoint.d.ts +58 -8
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +294 -20
- package/dist/endpoint.js.map +1 -1
- package/dist/environment.d.ts +302 -0
- package/dist/environment.d.ts.map +1 -0
- package/dist/environment.js +58 -0
- package/dist/environment.js.map +1 -0
- package/dist/evict.d.ts +9 -5
- package/dist/evict.d.ts.map +1 -1
- package/dist/evict.js +24 -16
- package/dist/evict.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/launch-material.d.ts +2 -0
- package/dist/launch-material.d.ts.map +1 -1
- package/dist/launch-material.js +5 -0
- package/dist/launch-material.js.map +1 -1
- package/dist/launch.d.ts +3 -0
- package/dist/launch.d.ts.map +1 -1
- package/dist/membership-feed.d.ts +7 -5
- package/dist/membership-feed.d.ts.map +1 -1
- package/dist/membership-feed.js +60 -16
- package/dist/membership-feed.js.map +1 -1
- package/dist/provision.d.ts +6 -0
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +21 -3
- package/dist/provision.js.map +1 -1
- package/dist/run-host.d.ts +15 -0
- package/dist/run-host.d.ts.map +1 -1
- package/dist/types.d.ts +44 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/endpoint.js
CHANGED
|
@@ -141,6 +141,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
141
141
|
* `DrainingConnectionError` out of `reset()` on a timer nothing awaits. */
|
|
142
142
|
presenceWatchIter;
|
|
143
143
|
channelWatchIter;
|
|
144
|
+
/** The daemon's lease-loss watch (#1596): the INTENT that survives a connection rebuild, plus the
|
|
145
|
+
* live iterator. See {@link watchDeliveryLease}. */
|
|
146
|
+
leaseWatchIntent;
|
|
147
|
+
leaseWatchIter;
|
|
144
148
|
/** Plane-3 durable-membership registry KV — lazily opened by the privileged delivery daemon (or a
|
|
145
149
|
* short-lived provisioner). */
|
|
146
150
|
membersKv;
|
|
@@ -221,6 +225,14 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
221
225
|
presenceWriteFailingSince;
|
|
222
226
|
/** #1356: the broker's last refusal message, kept alongside the start time for diagnosis. */
|
|
223
227
|
lastPresenceWriteError;
|
|
228
|
+
/** #1461: monotonic per-put generation on the presence path, so a settle can tell whether it is
|
|
229
|
+
* the latest evidence on its epoch. {@link publishPresence} snapshots it at put start and every
|
|
230
|
+
* settle records its generation here: a settle is the latest evidence only while no put that
|
|
231
|
+
* started after it has already settled. */
|
|
232
|
+
presencePutGeneration = 0;
|
|
233
|
+
/** #1461: the generation of the newest presence put that has SETTLED (succeeded or rejected),
|
|
234
|
+
* or `undefined` when none ever has. A put that is merely in flight is not evidence yet. */
|
|
235
|
+
presencePutSettled;
|
|
224
236
|
roster = new Map();
|
|
225
237
|
/** Resolves when the current presence watch has consumed its complete initial KV snapshot. */
|
|
226
238
|
presenceSnapshot = Promise.resolve();
|
|
@@ -249,6 +261,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
249
261
|
presenceRebindAt = 0;
|
|
250
262
|
status = "idle";
|
|
251
263
|
activity;
|
|
264
|
+
condition;
|
|
265
|
+
/** Advances on every condition change so an older in-flight put cannot be the final KV state. */
|
|
266
|
+
conditionRevision = 0;
|
|
267
|
+
/** Read once at construction. Core publishes this opaque provider reference and never parses it. */
|
|
268
|
+
environment;
|
|
252
269
|
/** Mirror of the connector's authoritative attention state, published in presence (advisory). The
|
|
253
270
|
* endpoint never reads these back into delivery — they exist only to broadcast. */
|
|
254
271
|
attentionMode;
|
|
@@ -300,6 +317,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
300
317
|
* only evidence the split rate exists. Always on, never behind a flag — a counter you have to
|
|
301
318
|
* enable is not there when the thing you needed it for happened. */
|
|
302
319
|
splitsRecovered = 0;
|
|
320
|
+
/** Presence rows rejected because their embedded id did not match the ACL-scoped KV key. */
|
|
321
|
+
presenceBindingDrops = 0;
|
|
303
322
|
/** This endpoint's wire principal (owner + actor tokens, §13.2) — what its minted grant rows
|
|
304
323
|
* pin. Public so a caller can build owner-mode target blocks for {@link invokeService}. */
|
|
305
324
|
get principal() {
|
|
@@ -310,6 +329,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
310
329
|
get splitRecoveryCount() {
|
|
311
330
|
return this.splitsRecovered;
|
|
312
331
|
}
|
|
332
|
+
/** Mis-keyed presence rows this reader rejected. The warning event may be missed; the count cannot. */
|
|
333
|
+
get presenceBindingDropCount() {
|
|
334
|
+
return this.presenceBindingDrops;
|
|
335
|
+
}
|
|
313
336
|
/** The endpoint's own lifecycle UID, REQUIRED for every lifecycle-keyed messaging resource; absent
|
|
314
337
|
* ⇒ loud refusal naming the operation (the hard cut of SPEC §13.1 — no alias-keyed fallback). */
|
|
315
338
|
requireLifecycleUid(what) {
|
|
@@ -419,6 +442,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
419
442
|
// principalKey validates both tokens.
|
|
420
443
|
const principal = principalKey(this.owner, this.actor);
|
|
421
444
|
this.card = { ...opts.card, id: principal.key, owner: this.owner, actor: this.actor };
|
|
445
|
+
this.environment = process.env.COTAL_ENVIRONMENT?.trim() || undefined;
|
|
422
446
|
this.servers = opts.servers ?? DEFAULT_SERVER;
|
|
423
447
|
this.token = opts.token;
|
|
424
448
|
this.user = opts.user;
|
|
@@ -971,6 +995,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
971
995
|
if (watchChannels)
|
|
972
996
|
await this.startChannelWatch();
|
|
973
997
|
}
|
|
998
|
+
// Rebind the daemon's lease-loss watch onto the fresh connection (#1596). The intent survived
|
|
999
|
+
// clearConnectionScoped; a bind that completes after a stop or another rebuild found no intent
|
|
1000
|
+
// and released itself. The replay re-delivers the row's current state, so a change that landed
|
|
1001
|
+
// in the null window still reaches the trigger.
|
|
1002
|
+
if (this.leaseWatchIntent)
|
|
1003
|
+
await this.bindDeliveryLeaseWatch();
|
|
974
1004
|
// FAIL BEFORE PRESENCE (SPEC 13.1): an AUTHED endpoint that will register on the roster or
|
|
975
1005
|
// bind lifecycle-keyed consumers must hold its launcher-supplied lifecycle uid BEFORE anything
|
|
976
1006
|
// makes it visible - a missing uid must never leave a roster ghost that could not bind (false
|
|
@@ -1095,6 +1125,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1095
1125
|
/* already closed with the connection */
|
|
1096
1126
|
}
|
|
1097
1127
|
this.channelWatchIter = undefined;
|
|
1128
|
+
this.stopDeliveryLeaseWatch();
|
|
1098
1129
|
for (const sub of this.chatSubs.values()) {
|
|
1099
1130
|
try {
|
|
1100
1131
|
sub.unsubscribe();
|
|
@@ -1189,6 +1220,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1189
1220
|
this.subs.length = 0;
|
|
1190
1221
|
if (!failedNc)
|
|
1191
1222
|
return;
|
|
1223
|
+
// The old status iterator is stale after nc is cleared, so its close cannot report this edge.
|
|
1224
|
+
// As in doRebuild, teardown itself must announce the no-nc window before closing the socket.
|
|
1225
|
+
this.emit("transport", { connected: false });
|
|
1226
|
+
this.emit("connection", { connected: false });
|
|
1192
1227
|
// Layered teardown, comments kept OUT of the code span below on purpose: the mutation fixture
|
|
1193
1228
|
// bin/smoke/mutations/failed-bind-cleanup.json anchors on that span verbatim and the fixture
|
|
1194
1229
|
// census (bin/smoke/mutation-fixtures.smoke.ts) reddens an anchor that crosses a comment line.
|
|
@@ -1467,6 +1502,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1467
1502
|
/* already closed */
|
|
1468
1503
|
}
|
|
1469
1504
|
this.channelWatchIter = undefined;
|
|
1505
|
+
this.stopDeliveryLeaseWatch();
|
|
1470
1506
|
try {
|
|
1471
1507
|
if (this.doRegister) {
|
|
1472
1508
|
this.status = "offline";
|
|
@@ -1671,6 +1707,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1671
1707
|
if (!Array.isArray(opts.parts) || opts.parts.length === 0)
|
|
1672
1708
|
throw new Error("multicastExpecting requires at least one part");
|
|
1673
1709
|
const message = this.casEnvelope(opts);
|
|
1710
|
+
assertPartsSerializable(message.parts);
|
|
1674
1711
|
// Publish DIRECTLY rather than through publishMsg: this path must set the expectation and read
|
|
1675
1712
|
// the ack, and publishMsg deliberately does neither.
|
|
1676
1713
|
const ack = await this.js.publish(chatSubject(this.space, this.owner, this.actor, opts.channel), JSON.stringify(message), { msgID: opts.id, expect: { lastSubjectSequence: expected } });
|
|
@@ -2057,6 +2094,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2057
2094
|
this.status = status;
|
|
2058
2095
|
await this.publishPresence();
|
|
2059
2096
|
}
|
|
2097
|
+
/** Publish a harness-reported condition, or clear it. Core stores the relay without interpretation. */
|
|
2098
|
+
async setCondition(condition) {
|
|
2099
|
+
this.condition = condition ?? undefined;
|
|
2100
|
+
this.conditionRevision++;
|
|
2101
|
+
await this.publishPresence();
|
|
2102
|
+
}
|
|
2060
2103
|
/** Publish the agent's global attention mode into presence (advisory observability). Mirror only —
|
|
2061
2104
|
* delivery decisions stay in the connector's authoritative state. */
|
|
2062
2105
|
async setAttention(attention) {
|
|
@@ -2507,7 +2550,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2507
2550
|
}));
|
|
2508
2551
|
}
|
|
2509
2552
|
/** Fetch recent messages from a channel's JetStream backlog. `signal` cancels the active pull and
|
|
2510
|
-
* reclaims its ephemeral consumer before the promise rejects.
|
|
2553
|
+
* reclaims its ephemeral consumer before the promise rejects. Returns `HistoryMessage[]`
|
|
2554
|
+
* (#1413): every field present on a row was checked by the drain before the row was
|
|
2555
|
+
* returned — a stored row missing `ts` / `space` / `parts` / `from.name` is dropped, never
|
|
2556
|
+
* returned with that member silently `undefined`. */
|
|
2511
2557
|
async channelHistory(channel, opts) {
|
|
2512
2558
|
// history from any sender
|
|
2513
2559
|
return (await this.streamHistory(chatStream(this.space), [chatSubject(this.space, "*", "*", channel)], opts?.limit ?? 100, undefined, opts?.signal)).map((r) => r.msg);
|
|
@@ -2654,7 +2700,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2654
2700
|
/** Fetch recent DMs (any sender→any recipient) from the space's DM backlog. `signal` cancels the
|
|
2655
2701
|
* active pull and reclaims its ephemeral consumer. God-view only:
|
|
2656
2702
|
* a normal agent/observer's ACL denies CONSUMER.CREATE on DM_<space>, so this throws-and-
|
|
2657
|
-
* skips for them — only an `admin`-profile cred can read it.
|
|
2703
|
+
* skips for them — only an `admin`-profile cred can read it. Returns `HistoryMessage[]`
|
|
2704
|
+
* (#1413): every field present on a row was checked by the drain before the row was
|
|
2705
|
+
* returned — a stored row missing `ts` / `space` / `parts` / `from.name` is dropped, never
|
|
2706
|
+
* returned with that member silently `undefined`. */
|
|
2658
2707
|
async dmHistory(opts) {
|
|
2659
2708
|
// every inst.<recipOwner>.<recipActor>.<sndOwner>.<sndActor> DM — the whole DM subtree (god-view)
|
|
2660
2709
|
return (await this.streamHistory(dmStream(this.space), [`${spacePrefix(this.space)}.inst.>`], opts?.limit ?? 100, undefined, opts?.signal)).map((r) => r.msg);
|
|
@@ -3032,6 +3081,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3032
3081
|
async publishMsg(subject, msg) {
|
|
3033
3082
|
if (!this.js)
|
|
3034
3083
|
throw new Error(this.notLiveMsg());
|
|
3084
|
+
assertPartsSerializable(msg.parts);
|
|
3035
3085
|
// msgID = message id → free server-side dedup across JetStream redelivery.
|
|
3036
3086
|
await this.js.publish(subject, JSON.stringify(msg), { msgID: msg.id });
|
|
3037
3087
|
}
|
|
@@ -3283,6 +3333,68 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3283
3333
|
async readDeliveryLease(shardIndex) {
|
|
3284
3334
|
return (await this.readDeliveryLeaseEntry(shardIndex))?.info;
|
|
3285
3335
|
}
|
|
3336
|
+
/** Watch THIS shard's lease key and nothing else, for the daemon's loss trigger (#1596).
|
|
3337
|
+
*
|
|
3338
|
+
* A KV watch is the native JetStream push for "the row changed hands NOW": a filtered ordered
|
|
3339
|
+
* consumer on the lease bucket delivers the row's own writes the moment the broker applies them,
|
|
3340
|
+
* instead of the daemon waiting for its next renew tick to notice. The watch is a TRIGGER, never a
|
|
3341
|
+
* decision: every event is handed to `onEvent` with the row as the broker now states it (PUT) or
|
|
3342
|
+
* the fact it is gone (DEL/PURGE, `info` undefined), and the caller re-runs the SAME decision tree
|
|
3343
|
+
* its renew tick would (`leaseAction`, `mayServeOn`, `readOwnLease`-style ownership tests). An
|
|
3344
|
+
* event the daemon itself caused (its acquire, its renew, its ready flip) is an ordinary input to
|
|
3345
|
+
* that tree — the row still reads `held` by this endpoint — so it never quiesces on its own write;
|
|
3346
|
+
* no event filtering happens here.
|
|
3347
|
+
*
|
|
3348
|
+
* Like the presence watch, this survives a connection rebuild as INTENT: the iterator dies with
|
|
3349
|
+
* its connection and {@link connectAndBind} rebinds it onto the fresh one, so a reconnect can
|
|
3350
|
+
* never leave the daemon blind to the row for the rest of its life. The rebind replays the
|
|
3351
|
+
* bucket's current last-per-subject state, so a change that landed during the null window is
|
|
3352
|
+
* delivered on the fresh watch. Resolves a stop handle. */
|
|
3353
|
+
async watchDeliveryLease(shardIndex, onEvent) {
|
|
3354
|
+
this.leaseWatchIntent = { shardIndex, onEvent };
|
|
3355
|
+
await this.bindDeliveryLeaseWatch();
|
|
3356
|
+
return () => {
|
|
3357
|
+
if (this.leaseWatchIntent?.onEvent !== onEvent)
|
|
3358
|
+
return; // a later watch owns the slot
|
|
3359
|
+
this.leaseWatchIntent = undefined;
|
|
3360
|
+
this.stopDeliveryLeaseWatch();
|
|
3361
|
+
};
|
|
3362
|
+
}
|
|
3363
|
+
stopDeliveryLeaseWatch() {
|
|
3364
|
+
try {
|
|
3365
|
+
this.leaseWatchIter?.stop();
|
|
3366
|
+
}
|
|
3367
|
+
catch { /* already closed with its connection */ }
|
|
3368
|
+
this.leaseWatchIter = undefined;
|
|
3369
|
+
}
|
|
3370
|
+
async bindDeliveryLeaseWatch() {
|
|
3371
|
+
const intent = this.leaseWatchIntent;
|
|
3372
|
+
const iter = await (await this.deliveryRegistry()).watch({ key: leaseKey(intent.shardIndex) });
|
|
3373
|
+
if (this.leaseWatchIntent !== intent) {
|
|
3374
|
+
try {
|
|
3375
|
+
iter.stop();
|
|
3376
|
+
}
|
|
3377
|
+
catch { /* already closed */ }
|
|
3378
|
+
return;
|
|
3379
|
+
}
|
|
3380
|
+
this.leaseWatchIter = iter;
|
|
3381
|
+
void (async () => {
|
|
3382
|
+
for await (const e of iter) {
|
|
3383
|
+
if (this.leaseWatchIter !== iter)
|
|
3384
|
+
break;
|
|
3385
|
+
if (e.operation === "DEL" || e.operation === "PURGE") {
|
|
3386
|
+
intent.onEvent(undefined);
|
|
3387
|
+
continue;
|
|
3388
|
+
}
|
|
3389
|
+
try {
|
|
3390
|
+
intent.onEvent(e.json());
|
|
3391
|
+
}
|
|
3392
|
+
catch {
|
|
3393
|
+
intent.onEvent(undefined);
|
|
3394
|
+
}
|
|
3395
|
+
}
|
|
3396
|
+
})().catch((e) => this.emit("error", e));
|
|
3397
|
+
}
|
|
3286
3398
|
/** The lease row AND the KV revision it is at. The revision is the CAS token every renew and the
|
|
3287
3399
|
* CAS release are argued against, so a caller re-establishing ownership after a failed renew
|
|
3288
3400
|
* needs the BROKER's sequence, not the one it last cached: a renew can fail with its write
|
|
@@ -5148,6 +5260,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5148
5260
|
// omitted only where the endpoint has none (a pure operator/daemon connection never registers).
|
|
5149
5261
|
...(this.ownLifecycleUid !== undefined ? { lifecycleUid: this.ownLifecycleUid } : {}),
|
|
5150
5262
|
status: this.status,
|
|
5263
|
+
condition: this.condition,
|
|
5264
|
+
environment: this.environment,
|
|
5151
5265
|
activity: this.activity,
|
|
5152
5266
|
attention: this.attentionMode,
|
|
5153
5267
|
channelModes: this.channelModes,
|
|
@@ -5164,6 +5278,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5164
5278
|
// it, a heartbeat put (default 2s) whose ~5s JetStream timeout elapses after a rebuild plants a
|
|
5165
5279
|
// refusal on the connection that just published successfully.
|
|
5166
5280
|
const epoch = this.presenceEpoch;
|
|
5281
|
+
const conditionRevision = this.conditionRevision;
|
|
5282
|
+
// #1461: snapshot the put's generation BEFORE the put. A settle (success or rejection) is the
|
|
5283
|
+
// latest evidence on its epoch only while no put that started after it has ALREADY settled —
|
|
5284
|
+
// a newer put merely in flight is not evidence yet. Same-epoch puts overlap routinely
|
|
5285
|
+
// (heartbeat 2s against a ~5s JetStream put timeout), so "succeeded" alone is not "newest".
|
|
5286
|
+
const generation = ++this.presencePutGeneration;
|
|
5167
5287
|
try {
|
|
5168
5288
|
await this.kv.put(this.card.id, JSON.stringify(record));
|
|
5169
5289
|
}
|
|
@@ -5173,21 +5293,39 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5173
5293
|
// nothing else here notices. Record WHEN the refusals started, at the one site that knows the
|
|
5174
5294
|
// failing write was a presence write; a caller cannot infer that from the generic `warning`
|
|
5175
5295
|
// stream, which carries any recoverable error.
|
|
5176
|
-
|
|
5296
|
+
//
|
|
5297
|
+
// #1461: a rejection speaks when it is the newest SETTLED evidence — a newer put that has
|
|
5298
|
+
// merely started does not silence it, but a newer put that already settled (either way)
|
|
5299
|
+
// does.
|
|
5300
|
+
const superseded = (this.presencePutSettled ?? 0) > generation;
|
|
5301
|
+
this.presencePutSettled = Math.max(this.presencePutSettled ?? 0, generation);
|
|
5302
|
+
if (epoch === this.presenceEpoch && !this.stopped && !superseded) {
|
|
5177
5303
|
this.presenceWriteFailingSince ??= Date.now();
|
|
5178
5304
|
this.lastPresenceWriteError = e?.message ?? String(e);
|
|
5179
5305
|
}
|
|
5180
5306
|
throw e;
|
|
5181
5307
|
}
|
|
5182
|
-
// A late SUCCESS
|
|
5183
|
-
//
|
|
5184
|
-
//
|
|
5185
|
-
//
|
|
5186
|
-
//
|
|
5187
|
-
//
|
|
5188
|
-
//
|
|
5189
|
-
|
|
5308
|
+
// A late SUCCESS cannot erase a refusal from newer evidence. Two fences, each answering half:
|
|
5309
|
+
// the epoch fence (#1356) keeps a put that outlives its connection from writing or erasing a
|
|
5310
|
+
// record that belongs to a later connection; the generation fence (#1461) orders puts WITHIN
|
|
5311
|
+
// one epoch by SETTLE order, not start order: a settle is the latest evidence only while no
|
|
5312
|
+
// put that started after it has already settled, whichever way that newer put settled. So an
|
|
5313
|
+
// earlier put settling AFTER a later put settled no longer speaks — success cannot clear a
|
|
5314
|
+
// newer refusal (#1461's original case), and a newer settle is not blocked by being merely
|
|
5315
|
+
// started (panel round 1). Without the fence, an earlier put succeeding late cleared a later
|
|
5316
|
+
// put's refusal while the bucket still refused writes (heartbeat 2s against a ~5s put timeout,
|
|
5317
|
+
// so the overlap is routine rather than a corner).
|
|
5318
|
+
const superseded = (this.presencePutSettled ?? 0) > generation;
|
|
5319
|
+
this.presencePutSettled = Math.max(this.presencePutSettled ?? 0, generation);
|
|
5320
|
+
if (epoch !== this.presenceEpoch || this.stopped || superseded)
|
|
5190
5321
|
return;
|
|
5322
|
+
// Presence writes may overlap. If this put carried an older condition, repair the KV with the
|
|
5323
|
+
// latest local state before returning so a late failure publish cannot resurrect after a new
|
|
5324
|
+
// turn cleared it. The revision is condition-specific; unrelated heartbeat overlap is unchanged.
|
|
5325
|
+
if (conditionRevision !== this.conditionRevision) {
|
|
5326
|
+
await this.publishPresence();
|
|
5327
|
+
return;
|
|
5328
|
+
}
|
|
5191
5329
|
this.clearPresenceWriteFailure();
|
|
5192
5330
|
}
|
|
5193
5331
|
/** #1356: drop the presence-refusal record when the connection that OBSERVED those refusals goes
|
|
@@ -5204,8 +5342,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5204
5342
|
this.lastPresenceWriteError = undefined;
|
|
5205
5343
|
}
|
|
5206
5344
|
/** #1356: the presence bucket has been refusing writes since this time, or `undefined` when the
|
|
5207
|
-
* last publish succeeded. Cleared by
|
|
5208
|
-
* healthy and only a SUSTAINED failure carries a
|
|
5345
|
+
* last publish succeeded. Cleared by a successful write that is the latest evidence on its
|
|
5346
|
+
* epoch (#1461), so a survived blip reads as healthy and only a SUSTAINED failure carries a
|
|
5347
|
+
* duration.
|
|
5209
5348
|
*
|
|
5210
5349
|
* Presence writes are the CANARY, not the scope: the broker can disable JetStream account-wide
|
|
5211
5350
|
* while the NATS connection stays up, so a caller must not read this as "only presence is
|
|
@@ -5440,8 +5579,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5440
5579
|
// with its bucket key is forged or corrupt. Drop it rather than surface a spoofed roster identity.
|
|
5441
5580
|
// The write-side scoping ($KV.<presenceBucket>.<own-id>) is the primary guard; this rejects a
|
|
5442
5581
|
// mis-keyed record even if a broad writer slips one in under another agent's key.
|
|
5443
|
-
if (raw.card?.id !== id)
|
|
5582
|
+
if (raw.card?.id !== id) {
|
|
5583
|
+
this.presenceBindingDrops++;
|
|
5584
|
+
this.emitRecoverable(new Error(`dropped presence entry for key ${JSON.stringify(id)}: card.id ${JSON.stringify(raw.card?.id)} does not match its KV key`));
|
|
5444
5585
|
return;
|
|
5586
|
+
}
|
|
5445
5587
|
const prev = this.roster.get(id);
|
|
5446
5588
|
const stale = Date.now() - raw.ts > this.ttlMs;
|
|
5447
5589
|
// A watch recovering from a stall replays the bucket. Those PUTs still carry the publisher's
|
|
@@ -5481,6 +5623,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5481
5623
|
prev.lifecycleUid === p.lifecycleUid &&
|
|
5482
5624
|
prev.status === p.status &&
|
|
5483
5625
|
prev.activity === p.activity &&
|
|
5626
|
+
sameCondition(prev.condition, p.condition) &&
|
|
5627
|
+
prev.environment === p.environment &&
|
|
5484
5628
|
prev.attention === p.attention &&
|
|
5485
5629
|
sameChannelModes(prev.channelModes, p.channelModes)) {
|
|
5486
5630
|
this.roster.set(id, p);
|
|
@@ -5592,7 +5736,8 @@ function kindFromParsed(kind) {
|
|
|
5592
5736
|
* unparseable subject, or `from.id !== parsed.sender` (SPEC §5). This derives the remaining
|
|
5593
5737
|
* routing tokens from the subject for rows that survived — it does not rewrite a mismatched
|
|
5594
5738
|
* `from.id`. Live tails, channel backfill, and channel recall skip the mismatch; history
|
|
5595
|
-
* does the same (#388).
|
|
5739
|
+
* does the same (#388). The row's other fields pass through UNTOUCHED, so what the caller's
|
|
5740
|
+
* row type already verified stays verified (#1413). */
|
|
5596
5741
|
function authenticatedMessage(msg, parsed) {
|
|
5597
5742
|
if (parsed.kind === "chat")
|
|
5598
5743
|
return authenticatedChannelMessage(msg, parsed.rest);
|
|
@@ -5627,6 +5772,8 @@ function historyMessageFromDelivery(m) {
|
|
|
5627
5772
|
}
|
|
5628
5773
|
if (!isHistoryDrainEnvelope(raw))
|
|
5629
5774
|
return undefined;
|
|
5775
|
+
if (!isVerifiedHistoryRow(raw))
|
|
5776
|
+
return undefined;
|
|
5630
5777
|
const parsed = parseSubject(m.subject);
|
|
5631
5778
|
if (!parsed || !isPrincipalOwnerToken(parsed.owner))
|
|
5632
5779
|
return undefined;
|
|
@@ -5634,17 +5781,59 @@ function historyMessageFromDelivery(m) {
|
|
|
5634
5781
|
return undefined;
|
|
5635
5782
|
return authenticatedMessage(raw, parsed);
|
|
5636
5783
|
}
|
|
5784
|
+
/** The members `HistoryMessage` promises beyond the drain envelope (#1413): a full
|
|
5785
|
+
* `EndpointRef` (`from.name`, and `from.role` only when a string), a finite `ts`, a string
|
|
5786
|
+
* `space`, `parts` the drain could read, and the optional members in their promised shape
|
|
5787
|
+
* when present. Every field the PUBLIC row type promises is checked here, before the row is
|
|
5788
|
+
* returned. A row missing one is dropped, never returned with that member silently
|
|
5789
|
+
* `undefined` under the full type — a consumer reading `msg.ts`, `msg.space`, `msg.parts`
|
|
5790
|
+
* or `msg.from.name` must not be able to reach one that was never there. Nothing is invented
|
|
5791
|
+
* for an absent field.
|
|
5792
|
+
*
|
|
5793
|
+
* `parts` is held to READABLE, not to `isMessagePart`: every member must be an object with a
|
|
5794
|
+
* string `kind` (so `partsToText` can read `part.kind` without throwing), but a keyless
|
|
5795
|
+
* `{kind:"data"}` row from a pre-#1404 producer is not dropped — history must surface it.
|
|
5796
|
+
* `mentions`, `replyTo`, and `contextId` keep exactly their `CotalMessage` shapes when
|
|
5797
|
+
* present: an array of strings, a string, a string. */
|
|
5798
|
+
function isVerifiedHistoryRow(row) {
|
|
5799
|
+
if (typeof row.from.name !== "string")
|
|
5800
|
+
return false;
|
|
5801
|
+
if (row.from.role !== undefined && typeof row.from.role !== "string")
|
|
5802
|
+
return false;
|
|
5803
|
+
if (typeof row.ts !== "number" || !Number.isFinite(row.ts))
|
|
5804
|
+
return false;
|
|
5805
|
+
if (typeof row.space !== "string")
|
|
5806
|
+
return false;
|
|
5807
|
+
if (!Array.isArray(row.parts) || !row.parts.every(isReadableMessagePart))
|
|
5808
|
+
return false;
|
|
5809
|
+
if (row.mentions !== undefined &&
|
|
5810
|
+
(!Array.isArray(row.mentions) || !row.mentions.every((name) => typeof name === "string")))
|
|
5811
|
+
return false;
|
|
5812
|
+
if (row.replyTo !== undefined && typeof row.replyTo !== "string")
|
|
5813
|
+
return false;
|
|
5814
|
+
if (row.contextId !== undefined && typeof row.contextId !== "string")
|
|
5815
|
+
return false;
|
|
5816
|
+
return true;
|
|
5817
|
+
}
|
|
5818
|
+
/** A part `partsToText` can read without throwing: an object with a string `kind` (#1413).
|
|
5819
|
+
* Deliberately weaker than `isMessagePart` — a keyless `{kind:"data"}` part passes here —
|
|
5820
|
+
* because history must surface rows a pre-#1404 producer wrote, not drop them. */
|
|
5821
|
+
function isReadableMessagePart(value) {
|
|
5822
|
+
return isRecord(value) && typeof value.kind === "string";
|
|
5823
|
+
}
|
|
5637
5824
|
/**
|
|
5638
5825
|
* Narrow enough for the type checker and for fail-closed history: object envelope, usable `id`,
|
|
5639
5826
|
* object `from`. That is what lets `from.id !== parsed.sender` run without throwing, and what
|
|
5640
5827
|
* lets `authenticatedMessage` take the row without a cast.
|
|
5641
5828
|
*
|
|
5642
5829
|
* Does NOT verify SPEC §5 message shape. It does not require a string `from.id` (the SPEC §5
|
|
5643
|
-
* comparison still rejects a mismatch)
|
|
5644
|
-
*
|
|
5645
|
-
*
|
|
5646
|
-
*
|
|
5647
|
-
*
|
|
5830
|
+
* comparison still rejects a mismatch) or exactly one route key. The members the PUBLIC
|
|
5831
|
+
* history row promises beyond this envelope — a full EndpointRef `from` (`name`/`role`), a
|
|
5832
|
+
* finite `ts`, a string `space` — are checked by {@link isVerifiedHistoryRow} before the row
|
|
5833
|
+
* is returned (#1413). Well-formed `parts` still belong to `isCotalMessage` (Plane-3), and
|
|
5834
|
+
* history must not use that guard: a publisher now REFUSES a `data` part carrying a non-JSON
|
|
5835
|
+
* value, but a keyless `{kind:"data"}` row from a pre-fix producer can still sit in a stream,
|
|
5836
|
+
* and history must surface it rather than drop it.
|
|
5648
5837
|
*/
|
|
5649
5838
|
function isHistoryDrainEnvelope(value) {
|
|
5650
5839
|
return isRecord(value) && isUsableMessageId(value.id) && isRecord(value.from);
|
|
@@ -5702,6 +5891,85 @@ function isMessagePart(value) {
|
|
|
5702
5891
|
function isRecord(value) {
|
|
5703
5892
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
5704
5893
|
}
|
|
5894
|
+
/** A `data` part value the wire can carry faithfully: a JSON value at EVERY depth (SPEC §5's
|
|
5895
|
+
* `<any JSON value>`). `JSON.stringify` does not reject what it cannot represent — it silently
|
|
5896
|
+
* rewrites: `undefined` (and a function-valued member) drops the key, `NaN`/`Infinity` store as
|
|
5897
|
+
* `null`, a `Date` stores as a string, a sparse array's holes store as `null`, a `Map` stores as
|
|
5898
|
+
* `{}`, a `Buffer` stores as `{"type":"Buffer",...}`. A reader then cannot tell a stored `null`
|
|
5899
|
+
* from a real one (#1404 fix round). So the producer checks structurally, matching the exported
|
|
5900
|
+
* `JsonValue`: null, boolean, finite number, string, an array whose every slot (holes included)
|
|
5901
|
+
* passes, or a plain object (prototype `null` or `Object.prototype`) whose every defined member
|
|
5902
|
+
* passes. A cycle is refused here too, never left for stringify's TypeError.
|
|
5903
|
+
*
|
|
5904
|
+
* THE OBJECT/ARRAY ASYMMETRY ON `undefined`: an `undefined`-valued MEMBER of a plain object is
|
|
5905
|
+
* allowed — stringify drops just that key, which is faithful (and the exported type says so) —
|
|
5906
|
+
* while `undefined` at the top level or in an ARRAY SLOT is refused, because there stringify
|
|
5907
|
+
* cannot drop a position: it stores `null`, which is a rewrite a reader cannot distinguish from
|
|
5908
|
+
* a real `null`.
|
|
5909
|
+
*
|
|
5910
|
+
* `seen` is the set of ANCESTORS on the current path, not every object visited: it is deleted
|
|
5911
|
+
* from on the way out, so a SHARED subtree (`{a: x, b: x}`) passes — stringify carries it twice,
|
|
5912
|
+
* which is faithful — while an object that contains itself at any depth still reports its path. */
|
|
5913
|
+
function jsonPathProblem(path, value, seen) {
|
|
5914
|
+
if (value === null)
|
|
5915
|
+
return undefined;
|
|
5916
|
+
const t = typeof value;
|
|
5917
|
+
if (t === "boolean" || t === "string")
|
|
5918
|
+
return undefined;
|
|
5919
|
+
if (t === "number")
|
|
5920
|
+
return Number.isFinite(value) ? undefined : `${path} is not a finite number`;
|
|
5921
|
+
if (t === "undefined" || t === "function" || t === "symbol" || t === "bigint")
|
|
5922
|
+
return `${path} is not a JSON value`;
|
|
5923
|
+
if (typeof value === "object") {
|
|
5924
|
+
if (seen.has(value))
|
|
5925
|
+
return `${path} is cyclic`;
|
|
5926
|
+
seen.add(value);
|
|
5927
|
+
let problem;
|
|
5928
|
+
if (Array.isArray(value)) {
|
|
5929
|
+
for (let i = 0; i < value.length; i++) {
|
|
5930
|
+
problem = jsonPathProblem(`${path}[${i}]`, value[i], seen);
|
|
5931
|
+
if (problem)
|
|
5932
|
+
return problem;
|
|
5933
|
+
}
|
|
5934
|
+
}
|
|
5935
|
+
else {
|
|
5936
|
+
const proto = Object.getPrototypeOf(value);
|
|
5937
|
+
if (proto !== null && proto !== Object.prototype) {
|
|
5938
|
+
seen.delete(value);
|
|
5939
|
+
return `${path} is not a plain object`;
|
|
5940
|
+
}
|
|
5941
|
+
for (const key of Object.keys(value)) {
|
|
5942
|
+
// A member whose value is undefined is the allowed key-drop, not a defect: stringify omits
|
|
5943
|
+
// the key, the object stays a JSON object, so skip it rather than walk it.
|
|
5944
|
+
if (value[key] === undefined)
|
|
5945
|
+
continue;
|
|
5946
|
+
problem = jsonPathProblem(`${path}.${key}`, value[key], seen);
|
|
5947
|
+
if (problem)
|
|
5948
|
+
return problem;
|
|
5949
|
+
}
|
|
5950
|
+
}
|
|
5951
|
+
seen.delete(value);
|
|
5952
|
+
return undefined;
|
|
5953
|
+
}
|
|
5954
|
+
return `${path} is not a JSON value`;
|
|
5955
|
+
}
|
|
5956
|
+
/** Refuse, at publish, a `data` part `JSON.stringify` would silently rewrite. The guard is the
|
|
5957
|
+
* runtime half of `Part`'s `data: JsonValue` arm: with it, a non-JSON value at any depth never
|
|
5958
|
+
* reaches the wire, so every reader (live core-sub, Plane-3 durable, history) gives one answer —
|
|
5959
|
+
* the message was never sent — instead of the old split where history returned a rewritten row
|
|
5960
|
+
* while Plane-3 terminated a keyless one as malformed. The error names the path to the offending
|
|
5961
|
+
* value (e.g. `data[2].at`), so a caller learns which member to fix rather than that stringify
|
|
5962
|
+
* would have mangled something. Throws rather than coercing: no fallback. */
|
|
5963
|
+
function assertPartsSerializable(parts) {
|
|
5964
|
+
for (const p of parts) {
|
|
5965
|
+
if (p.kind !== "data")
|
|
5966
|
+
continue;
|
|
5967
|
+
const problem = jsonPathProblem("data", p.data, new Set());
|
|
5968
|
+
if (problem !== undefined) {
|
|
5969
|
+
throw new Error(`cannot publish a data part carrying a non-JSON value (SPEC §5) - ${problem}`);
|
|
5970
|
+
}
|
|
5971
|
+
}
|
|
5972
|
+
}
|
|
5705
5973
|
/** Shallow-equal two per-channel-mode maps (presence dedup): a change must re-emit, so an attention
|
|
5706
5974
|
* toggle isn't swallowed as a quiet heartbeat. Absent and empty compare equal. */
|
|
5707
5975
|
function sameChannelModes(a, b) {
|
|
@@ -5711,6 +5979,12 @@ function sameChannelModes(a, b) {
|
|
|
5711
5979
|
return false;
|
|
5712
5980
|
return ak.every((k) => a[k] === b?.[k]);
|
|
5713
5981
|
}
|
|
5982
|
+
function sameCondition(a, b) {
|
|
5983
|
+
return a?.code === b?.code
|
|
5984
|
+
&& a?.source === b?.source
|
|
5985
|
+
&& a?.message === b?.message
|
|
5986
|
+
&& a?.since === b?.since;
|
|
5987
|
+
}
|
|
5714
5988
|
function authOpts(a) {
|
|
5715
5989
|
const tls = a.tls ? {} : undefined;
|
|
5716
5990
|
// USER MODE: present the shared auth-account sentinel creds AND the user bearer as `auth_token` (an
|