@cotal-ai/core 0.58.0 → 0.60.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/acls.d.ts +7 -0
- package/dist/acls.d.ts.map +1 -1
- package/dist/acls.js +10 -0
- package/dist/acls.js.map +1 -1
- package/dist/auth-provider.d.ts +13 -1
- package/dist/auth-provider.d.ts.map +1 -1
- package/dist/auth-provider.js.map +1 -1
- package/dist/backup-config.d.ts +10 -2
- package/dist/backup-config.d.ts.map +1 -1
- package/dist/backup-config.js +12 -3
- package/dist/backup-config.js.map +1 -1
- package/dist/broker-tls.d.ts +15 -0
- package/dist/broker-tls.d.ts.map +1 -1
- package/dist/broker-tls.js +6 -6
- package/dist/broker-tls.js.map +1 -1
- package/dist/checkpoint-answer.d.ts +24 -0
- package/dist/checkpoint-answer.d.ts.map +1 -1
- package/dist/checkpoint-answer.js +45 -4
- package/dist/checkpoint-answer.js.map +1 -1
- package/dist/command.d.ts +4 -3
- package/dist/command.d.ts.map +1 -1
- package/dist/command.js +20 -4
- package/dist/command.js.map +1 -1
- package/dist/connector-setup.d.ts +27 -0
- package/dist/connector-setup.d.ts.map +1 -1
- package/dist/connector.d.ts +25 -3
- package/dist/connector.d.ts.map +1 -1
- package/dist/connector.js.map +1 -1
- package/dist/endpoint-reconcile.d.ts +5 -1
- package/dist/endpoint-reconcile.d.ts.map +1 -1
- package/dist/endpoint-reconcile.js +43 -21
- package/dist/endpoint-reconcile.js.map +1 -1
- package/dist/endpoint-service.d.ts +25 -0
- package/dist/endpoint-service.d.ts.map +1 -1
- package/dist/endpoint-service.js +25 -25
- package/dist/endpoint-service.js.map +1 -1
- package/dist/endpoint.d.ts +105 -8
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +349 -43
- package/dist/endpoint.js.map +1 -1
- package/dist/evict.d.ts +27 -0
- package/dist/evict.d.ts.map +1 -1
- package/dist/evict.js +99 -42
- package/dist/evict.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/issued-authority.d.ts +0 -2
- package/dist/issued-authority.d.ts.map +1 -1
- package/dist/issued-authority.js +0 -2
- package/dist/issued-authority.js.map +1 -1
- package/dist/launch-artifacts.d.ts +42 -0
- package/dist/launch-artifacts.d.ts.map +1 -0
- package/dist/launch-artifacts.js +155 -0
- package/dist/launch-artifacts.js.map +1 -0
- package/dist/launch.d.ts +5 -1
- package/dist/launch.d.ts.map +1 -1
- package/dist/lease.d.ts +5 -0
- package/dist/lease.d.ts.map +1 -1
- package/dist/lease.js.map +1 -1
- package/dist/lifecycle-state.d.ts +1 -1
- package/dist/lifecycle-state.js +3 -3
- package/dist/lifecycle-state.js.map +1 -1
- package/dist/liveness.d.ts +180 -0
- package/dist/liveness.d.ts.map +1 -0
- package/dist/liveness.js +114 -0
- package/dist/liveness.js.map +1 -0
- package/dist/provision.d.ts +24 -11
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +127 -42
- package/dist/provision.js.map +1 -1
- package/dist/remote-manager-authority.d.ts +109 -2
- package/dist/remote-manager-authority.d.ts.map +1 -1
- package/dist/remote-manager-authority.js +104 -0
- package/dist/remote-manager-authority.js.map +1 -1
- package/dist/run-driver-grants.d.ts +1 -0
- package/dist/run-driver-grants.d.ts.map +1 -1
- package/dist/run-driver-grants.js +3 -2
- package/dist/run-driver-grants.js.map +1 -1
- package/dist/run-host.d.ts +66 -2
- package/dist/run-host.d.ts.map +1 -1
- package/dist/run-journal.d.ts +14 -6
- package/dist/run-journal.d.ts.map +1 -1
- package/dist/run-journal.js +14 -14
- package/dist/run-journal.js.map +1 -1
- package/dist/run-record.d.ts +5 -5
- package/dist/runtime.d.ts +6 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/schema-profile.d.ts.map +1 -1
- package/dist/schema-profile.js +4 -3
- package/dist/schema-profile.js.map +1 -1
- package/dist/streams.d.ts +22 -16
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +26 -6
- package/dist/streams.js.map +1 -1
- package/dist/subjects.d.ts +65 -8
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +76 -9
- package/dist/subjects.js.map +1 -1
- package/dist/types.d.ts +4 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/endpoint.js
CHANGED
|
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { createConnection } from "node:net";
|
|
4
4
|
import { connect, credsAuthenticator, headers, tokenAuthenticator, nanos, AuthorizationError, PermissionViolationError, UserAuthenticationExpiredError, NoRespondersError, RequestError, } from "@nats-io/transport-node";
|
|
5
5
|
import { wsconnect } from "@nats-io/nats-core";
|
|
6
|
+
import { parseLivenessAnswer, responderFromLease, responderFromProbe, isLivenessPlane, LIVENESS_PLANES, } from "./liveness.js";
|
|
6
7
|
import { credsClaims, credsFingerprint, credsRenewalDelayMs, idFromCreds } from "./identity.js";
|
|
7
8
|
import { requireBrokerFloor } from "./broker-floor.js";
|
|
8
9
|
import { inspectCredHealth } from "./provision.js";
|
|
@@ -15,16 +16,17 @@ import { readAcceptedRow } from "./issued-authority.js";
|
|
|
15
16
|
import { liveKvEntries } from "./kv-scan.js";
|
|
16
17
|
import { ARTIFACT_PART_KIND, isArtifactPart } from "./artifact.js";
|
|
17
18
|
import { assertValidName } from "./resolve.js";
|
|
18
|
-
import {
|
|
19
|
+
import { EVICT_PRINCIPALS_MAX } from "./evict.js";
|
|
20
|
+
import { createSpaceStreams, dmDurableConfig, dlvDurableConfig, taskDurableConfig, fanoutDurableConfig, inboxReaderConfig, MAX_MSGS_PER_SUBJECT, MANAGER_LEASE_TTL_MS, MANAGER_LEASE_ATTEMPT_MS, TTL_RECONCILE_CANARY_KEY, PRESENCE_STORAGE } from "./streams.js";
|
|
19
21
|
import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, JetStreamApiCodes, JetStreamApiError, } from "@nats-io/jetstream";
|
|
20
22
|
import {} from "@nats-io/jetstream";
|
|
21
23
|
import { Kvm } from "@nats-io/kv";
|
|
22
24
|
import { Bucket, KvWatchInclude } from "@nats-io/kv/internal";
|
|
23
25
|
import { openMembersRegistry, commitMember, tombstoneMember, activateMember, readMember, listMembers, listLifecycleMemberChannels, durableEligible, StaleMembershipWrite, } from "./members.js";
|
|
24
|
-
import { openAclRegistry, readAcl, readAclForAlias, AmbiguousAclAlias, commitAcl as writeAclRecord, reissueAcl as writeAclReissue } from "./acls.js";
|
|
26
|
+
import { openAclRegistry, readAcl, readAclForAlias, aclRetired, AmbiguousAclAlias, commitAcl as writeAclRecord, reissueAcl as writeAclReissue } from "./acls.js";
|
|
25
27
|
import { openDeliveryRegistry } from "./lease.js";
|
|
26
28
|
import { openChannelRegistry, effectiveReplay, effectiveReplayWindowMs, effectiveDeliveryClass, readChannelConfig, readChannelDefaults, } from "./channels.js";
|
|
27
|
-
import { anycastSubject, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, controlServiceSubject, 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";
|
|
29
|
+
import { anycastSubject, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, 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";
|
|
28
30
|
export const DEFAULT_SERVER = "nats://127.0.0.1:4222";
|
|
29
31
|
const PLANE3_FRAME_HEADER = "Cotal-Delivery-Frame";
|
|
30
32
|
/** Space joined when none is given on the CLI (the `cotal-<space>` cmux tab, etc.). */
|
|
@@ -187,12 +189,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
187
189
|
* {@link armDeliveryControl}; tracked so the stale one is dropped on reconnect. */
|
|
188
190
|
deliveryServeSub;
|
|
189
191
|
deliveryAdminServeSub;
|
|
190
|
-
/** When set, this endpoint hosts the Plane-3 fan-out writer + trusted reader (the server-side delivery
|
|
191
|
-
* daemon). `aclFor` maps an owner id to its current read ACL (`allowSubscribe`) for the reader's
|
|
192
|
-
* re-authorization — read FRESH per entry from the durable ACL registry KV, hence async. */
|
|
193
192
|
/** True once {@link quiescePlane3} has stopped serving this shard pending an ownership answer.
|
|
194
193
|
* Guards {@link armPlane3} so a RECONNECT cannot silently resume serving mid-question. */
|
|
195
194
|
plane3Quiesced = false;
|
|
195
|
+
/** When set, this endpoint hosts the Plane-3 fan-out writer + trusted reader (the server-side delivery
|
|
196
|
+
* daemon). `aclFor` maps an owner id to its current read ACL (`allowSubscribe`) for the reader's
|
|
197
|
+
* re-authorization — read FRESH per entry from the durable ACL registry KV, hence async. */
|
|
196
198
|
plane3;
|
|
197
199
|
/** Live local cache of the channel registry (key = channel token), kept by a KV watch. */
|
|
198
200
|
channelConfigs = new Map();
|
|
@@ -206,6 +208,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
206
208
|
* `chathist_<id>` consumer, so overlapping reads would delete/recreate it under one another. */
|
|
207
209
|
histLock = Promise.resolve();
|
|
208
210
|
subs = [];
|
|
211
|
+
/** The liveness responders {@link serveLiveness} was asked for, by plane. This is INTENT, re-bound
|
|
212
|
+
* by {@link bindLiveness} on every (re)connect: a rebuild closes the old connection and every
|
|
213
|
+
* subscription on it, so a responder bound once would leave its plane to the broker's
|
|
214
|
+
* no-responders answer, which a peer grades `unbound`, while this endpoint is connected and serving. */
|
|
215
|
+
livenessResponders = new Map();
|
|
209
216
|
streamMsgs = [];
|
|
210
217
|
/** Per-channel native core subscriptions (SPEC v0.3) — the manager-free live read path for boot +
|
|
211
218
|
* runtime channels (there is no per-instance chat durable). Keyed by channel so leave unsubscribes
|
|
@@ -289,6 +296,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
289
296
|
presenceRebindAt = 0;
|
|
290
297
|
status = "idle";
|
|
291
298
|
activity;
|
|
299
|
+
/** Last harness-reported work progress. Carried by the next heartbeat, never published per event. */
|
|
300
|
+
activeAt;
|
|
292
301
|
condition;
|
|
293
302
|
/** Advances on every condition change so an older in-flight put cannot be the final KV state. */
|
|
294
303
|
conditionRevision = 0;
|
|
@@ -1030,7 +1039,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1030
1039
|
// OPENs it (it's pre-created at `cotal up`; KV stream-create is denied to agents).
|
|
1031
1040
|
this.kv = this.authed
|
|
1032
1041
|
? await kvm.open(presenceBucket(this.space))
|
|
1033
|
-
: await kvm.create(presenceBucket(this.space), { ttl: this.ttlMs });
|
|
1042
|
+
: await kvm.create(presenceBucket(this.space), { ttl: this.ttlMs, storage: PRESENCE_STORAGE });
|
|
1034
1043
|
}
|
|
1035
1044
|
if (this.doWatch) {
|
|
1036
1045
|
await this.startPresenceWatch();
|
|
@@ -1118,6 +1127,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1118
1127
|
// endpoint hosts it. The first arm comes from startPlane3 (after start()); this re-binds the loops
|
|
1119
1128
|
// a reconnect's clearConnectionScoped() tore down, so a broker blip doesn't silently kill the backstop.
|
|
1120
1129
|
await this.armPlane3();
|
|
1130
|
+
// Re-bind the liveness responders on this connection, for armPlane3's reason: the rebuild closed
|
|
1131
|
+
// the old connection and the responders with it.
|
|
1132
|
+
for (const plane of this.livenessResponders.keys())
|
|
1133
|
+
this.bindLiveness(plane);
|
|
1121
1134
|
// Bound and live — covers initial start, manual reconnect, AND background self-heal (every
|
|
1122
1135
|
// path lands here). The single signal an in-process agent's connected flag tracks.
|
|
1123
1136
|
//
|
|
@@ -2240,6 +2253,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2240
2253
|
this.status = status;
|
|
2241
2254
|
await this.publishPresence();
|
|
2242
2255
|
}
|
|
2256
|
+
/** Record harness-reported work progress as `activeAt`. It writes nothing itself: the next heartbeat
|
|
2257
|
+
* carries it, so a stream of events costs no presence writes. */
|
|
2258
|
+
noteActivity(at = Date.now()) {
|
|
2259
|
+
if (Number.isFinite(at) && at > (this.activeAt ?? 0))
|
|
2260
|
+
this.activeAt = at;
|
|
2261
|
+
}
|
|
2243
2262
|
/** Publish a harness-reported condition, or clear it. Core stores the relay without interpretation. */
|
|
2244
2263
|
async setCondition(condition) {
|
|
2245
2264
|
this.condition = condition ?? undefined;
|
|
@@ -3407,8 +3426,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3407
3426
|
* daemon's cred is a file on disk that every restart re-reads, so `card.id` is stable across
|
|
3408
3427
|
* processes by design. See {@link DeliveryLeaseInfo.incarnation}. */
|
|
3409
3428
|
leaseIncarnation = randomUUID();
|
|
3410
|
-
|
|
3411
|
-
|
|
3429
|
+
/** When this endpoint last WON each shard's lease create. Every later write of that row carries it
|
|
3430
|
+
* unchanged, while `since` is re-stamped. See {@link DeliveryLeaseInfo.acquiredAt}. */
|
|
3431
|
+
leaseAcquiredAt = new Map();
|
|
3432
|
+
encodeLease(shardIndex, ready, acquiredAt = this.leaseAcquiredAt.get(shardIndex)) {
|
|
3433
|
+
return new TextEncoder().encode(JSON.stringify({ holder: this.card.id, incarnation: this.leaseIncarnation, acquiredAt, since: Date.now(), ready }));
|
|
3412
3434
|
}
|
|
3413
3435
|
/** Is this shard's lease row one THIS ENDPOINT INSTANCE wrote? The question a daemon whose renew
|
|
3414
3436
|
* just failed has to answer before it decides whether it still owns the shard.
|
|
@@ -3428,13 +3450,17 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3428
3450
|
* freeing a re-acquire. Acquired BEFORE binding (single-flight gate); {@link markDeliveryLeaseReady}
|
|
3429
3451
|
* flips it ready AFTER the loops + `ctl.delivery` are bound. Returns the lease revision. */
|
|
3430
3452
|
async acquireDeliveryLease(shardIndex) {
|
|
3431
|
-
|
|
3453
|
+
// Recorded only once the create wins: a refused create leaves the held row, and so its time, as it was.
|
|
3454
|
+
const acquiredAt = Date.now();
|
|
3455
|
+
const revision = await (await this.deliveryRegistry()).create(leaseKey(shardIndex), this.encodeLease(shardIndex, false, acquiredAt));
|
|
3456
|
+
this.leaseAcquiredAt.set(shardIndex, acquiredAt);
|
|
3457
|
+
return revision;
|
|
3432
3458
|
}
|
|
3433
3459
|
/** Flip the held lease to READY (CAS `kv.update`) AFTER `startPlane3` has bound the loops + the
|
|
3434
3460
|
* `ctl.delivery` responder — so "lease ready" proves the responder is up, not just that the slot was
|
|
3435
3461
|
* claimed. Returns the new revision. */
|
|
3436
3462
|
async markDeliveryLeaseReady(shardIndex, revision) {
|
|
3437
|
-
return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(true), revision);
|
|
3463
|
+
return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(shardIndex, true), revision);
|
|
3438
3464
|
}
|
|
3439
3465
|
/** Flip the held lease back to NOT-ready, the counterpart to {@link markDeliveryLeaseReady}, for a
|
|
3440
3466
|
* holder that has UNBOUND its loops and control responder but has not given up the shard.
|
|
@@ -3446,13 +3472,13 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3446
3472
|
* the row (rather than deleting it) is deliberate: the shard is still claimed, so no third daemon
|
|
3447
3473
|
* should be invited in; what is being withdrawn is only the claim to be answering. */
|
|
3448
3474
|
async markDeliveryLeaseNotReady(shardIndex, revision) {
|
|
3449
|
-
return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(false), revision);
|
|
3475
|
+
return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(shardIndex, false), revision);
|
|
3450
3476
|
}
|
|
3451
3477
|
/** Renew the held lease (CAS `kv.update` against `revision`, keeping `ready:true`) to refresh it before
|
|
3452
3478
|
* the bucket TTL expires it. Returns the new revision. Throws if the revision moved (lost the lease —
|
|
3453
3479
|
* the daemon should exit). */
|
|
3454
3480
|
async renewDeliveryLease(shardIndex, revision) {
|
|
3455
|
-
return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(true), revision);
|
|
3481
|
+
return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(shardIndex, true), revision);
|
|
3456
3482
|
}
|
|
3457
3483
|
/** Release the held lease on clean shutdown so a replacement daemon re-acquires immediately (best
|
|
3458
3484
|
* effort, a crash just lets the bucket TTL expire it).
|
|
@@ -3997,7 +4023,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3997
4023
|
continue;
|
|
3998
4024
|
}
|
|
3999
4025
|
const parsed = parseSubject(m.subject);
|
|
4000
|
-
if (!parsed || msg.from?.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner) || msg.from.id === owner)
|
|
4026
|
+
if (!isRecord(msg) || !parsed || msg.from?.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner) || msg.from.id === owner)
|
|
4001
4027
|
continue;
|
|
4002
4028
|
await this.publishDinbox(owner, lifecycleUid, { msg, channel, seq: m.seq, reason: "durable-channel", generation });
|
|
4003
4029
|
copied++;
|
|
@@ -4022,13 +4048,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4022
4048
|
async startPlane3(aclFor, opts = {}) {
|
|
4023
4049
|
if (!this.js)
|
|
4024
4050
|
throw new Error("endpoint not started");
|
|
4025
|
-
this.plane3 = { aclFor, reloadMembershipCreds: opts.reloadMembershipCreds, evictPrincipal: opts.evictPrincipal, planeConnLiveness: opts.planeConnLiveness, principalLiveness: opts.principalLiveness, reloadStoreIdentity: opts.reloadStoreIdentity, onDeliveryCredsAdopted: opts.onDeliveryCredsAdopted };
|
|
4051
|
+
this.plane3 = { aclFor, reloadMembershipCreds: opts.reloadMembershipCreds, evictPrincipal: opts.evictPrincipal, evictPrincipals: opts.evictPrincipals, planeConnLiveness: opts.planeConnLiveness, principalLiveness: opts.principalLiveness, reloadStoreIdentity: opts.reloadStoreIdentity, onDeliveryCredsAdopted: opts.onDeliveryCredsAdopted, startupComplete: opts.startupComplete };
|
|
4026
4052
|
await this.armPlane3();
|
|
4027
4053
|
}
|
|
4028
|
-
/** Serve one runtime durable-membership control request (the server-side delivery daemon). The caller
|
|
4029
|
-
* id is the authenticated subject sender ({@link serveControl} fail-closes on a mismatch). Validation
|
|
4030
|
-
* is against the durable ACL registry — the SAME KV the reader re-auths against (single source of
|
|
4031
|
-
* truth, no in-memory ledger to drift). */
|
|
4032
4054
|
/** Whether an ALREADY-DISPATCHED unit of Plane-3 work may still take effect.
|
|
4033
4055
|
*
|
|
4034
4056
|
* Unsubscribing stops NEW work; it cannot recall work already in flight. A handler that entered
|
|
@@ -4046,6 +4068,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4046
4068
|
plane3MayAct() {
|
|
4047
4069
|
return !this.plane3Quiesced;
|
|
4048
4070
|
}
|
|
4071
|
+
/** Serve one runtime durable-membership control request (the server-side delivery daemon). The caller
|
|
4072
|
+
* id is the authenticated subject sender ({@link serveControl} fail-closes on a mismatch). Validation
|
|
4073
|
+
* is against the durable ACL registry — the SAME KV the reader re-auths against (single source of
|
|
4074
|
+
* truth, no in-memory ledger to drift). */
|
|
4049
4075
|
async handleDeliveryControl(req) {
|
|
4050
4076
|
// FENCE: entered before a quiesce, resuming after it. Answering now would put a second server on
|
|
4051
4077
|
// this shard's control rail while the winner is already READY.
|
|
@@ -4388,6 +4414,216 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4388
4414
|
}
|
|
4389
4415
|
this.deliveryAdminServeSub = this.serveControl(CONTROL_DELIVERY_ADMIN, (req) => this.handleDeliveryAdmin(req), { boundReply: true });
|
|
4390
4416
|
}
|
|
4417
|
+
// ---- the peer-readable liveness surface (#1577) --------------------------
|
|
4418
|
+
/** Bind this endpoint as the liveness RESPONDER for one plane, answering presence and nothing
|
|
4419
|
+
* else to any credentialed peer that asks (`live.<plane>.<owner>.<actor>`).
|
|
4420
|
+
*
|
|
4421
|
+
* THE HANDLER IS THE PRIVACY BOUNDARY, and that is the entire reason this is a request/reply
|
|
4422
|
+
* probe rather than a KV read grant. `readState` returns the responder's OWN verdict, derived
|
|
4423
|
+
* from the lease it can already read; the LEASE ROW NEVER LEAVES THIS PROCESS. A peer therefore
|
|
4424
|
+
* learns one enum about one plane and cannot learn the holder, the workspace root, the pid, the
|
|
4425
|
+
* instance id, the runtime, or that any of those exist. Handing a peer the manager bucket instead
|
|
4426
|
+
* would have handed it the operator's filesystem path and a pid to signal.
|
|
4427
|
+
*
|
|
4428
|
+
* `queue`-grouped by plane, so several manager instances in one space answer a probe ONCE rather
|
|
4429
|
+
* than N times. A queue group is correct here and would be wrong on a roster-style surface: the
|
|
4430
|
+
* question is "is ANY responder bound", which any one member can answer.
|
|
4431
|
+
*
|
|
4432
|
+
* THE ANSWER NAMES WHICH RESPONDER GAVE IT (`instance`), and that field exists because the queue
|
|
4433
|
+
* group alone leaves a real ambiguity. Manager instances coexist per instance id by design, each
|
|
4434
|
+
* member answers only about ITSELF, and the group delivers one probe to an arbitrary member. So
|
|
4435
|
+
* when two instances hold opposite verdicts, identical probes alternate between them, and without
|
|
4436
|
+
* a discriminator the two answers are indistinguishable from one instance changing state. With
|
|
4437
|
+
* the field, a caller that probes more than once can see that two DIFFERENT responders answered
|
|
4438
|
+
* and that the verdicts disagree. The field is the responder's own endpoint-scoped instance
|
|
4439
|
+
* token, never the lease row's `instanceId`, its holder, its pid or its root: see
|
|
4440
|
+
* {@link LivenessAnswer} for why none of those may cross the wire.
|
|
4441
|
+
*
|
|
4442
|
+
* ONE PROBE STILL SAMPLES ONE MEMBER. This does not aggregate N instances, and a single probe
|
|
4443
|
+
* cannot report a split; it makes the split OBSERVABLE to a caller that asks again, where before
|
|
4444
|
+
* it was not observable at all.
|
|
4445
|
+
*
|
|
4446
|
+
* BOUNDED REPLY, for the same confused-deputy reason `serveControl` documents: this responder
|
|
4447
|
+
* holds a wildcard publish grant over `live.<plane>.*.*.reply.>`, so without the bound check an
|
|
4448
|
+
* authenticated caller could name a PEER's reply lane as its reply target and have us publish
|
|
4449
|
+
* into it. The broker does not permission-check a requester's embedded reply subject; we do.
|
|
4450
|
+
*
|
|
4451
|
+
* A HANDLER THAT THROWS ANSWERS `unknown`, never silence and never health. Silence would reach
|
|
4452
|
+
* the prober as a timeout, which it correctly grades `unknown` anyway — but only after burning
|
|
4453
|
+
* the full deadline, and an operator staring at a hung probe learns less than one told plainly
|
|
4454
|
+
* that the axis could not be checked.
|
|
4455
|
+
*
|
|
4456
|
+
* THE BINDING IS KEPT AS INTENT and re-bound on every (re)connect, so a reconnect does not leave
|
|
4457
|
+
* the plane answered by the broker's no-responders status while this endpoint is serving. A second
|
|
4458
|
+
* call for the same plane replaces the first. */
|
|
4459
|
+
serveLiveness(plane, readState) {
|
|
4460
|
+
if (!this.nc)
|
|
4461
|
+
throw new Error("endpoint not started");
|
|
4462
|
+
const held = this.livenessResponders.get(plane);
|
|
4463
|
+
if (held)
|
|
4464
|
+
held.readState = readState;
|
|
4465
|
+
else
|
|
4466
|
+
this.livenessResponders.set(plane, { readState });
|
|
4467
|
+
this.bindLiveness(plane);
|
|
4468
|
+
}
|
|
4469
|
+
/** (Re)bind one plane's liveness responder on the CURRENT connection, dropping the stale sub first
|
|
4470
|
+
* as {@link armDeliveryControl} does. */
|
|
4471
|
+
bindLiveness(plane) {
|
|
4472
|
+
const held = this.livenessResponders.get(plane);
|
|
4473
|
+
if (!held || !this.nc)
|
|
4474
|
+
return;
|
|
4475
|
+
if (held.sub) {
|
|
4476
|
+
try {
|
|
4477
|
+
held.sub.unsubscribe();
|
|
4478
|
+
}
|
|
4479
|
+
catch { /* dead with the old connection */ }
|
|
4480
|
+
const i = this.subs.indexOf(held.sub);
|
|
4481
|
+
if (i >= 0)
|
|
4482
|
+
this.subs.splice(i, 1);
|
|
4483
|
+
}
|
|
4484
|
+
const { readState } = held;
|
|
4485
|
+
const sub = this.nc.subscribe(livenessServeFilter(this.space, plane), { queue: `live.${plane}` });
|
|
4486
|
+
held.sub = sub;
|
|
4487
|
+
this.subs.push(sub);
|
|
4488
|
+
// The responder token this bind answers under: AN OPAQUE VALUE MINTED HERE, and deliberately
|
|
4489
|
+
// not any name this process already has. It is what makes "which responder answered" askable
|
|
4490
|
+
// without making "who is this responder" answerable — the two properties the surface has to
|
|
4491
|
+
// hold at once. The endpoint's own `card.id` would be its principal, the lease row's
|
|
4492
|
+
// `instanceId` is a field of a bucket an agent holds no grant on, and either would turn a
|
|
4493
|
+
// presence probe into an identity read for every credentialed peer in the space. A fresh
|
|
4494
|
+
// random token correlates with nothing but this plane's other answers, which is the whole job:
|
|
4495
|
+
// two answers carrying two different tokens came from two different responders.
|
|
4496
|
+
//
|
|
4497
|
+
// Per BIND rather than per process, so a responder that goes away and comes back answers under
|
|
4498
|
+
// a new token. That is the honest reading: a caller comparing answers across a restart is
|
|
4499
|
+
// comparing two different incarnations, and a token that survived the restart would say
|
|
4500
|
+
// otherwise. A re-bind after a reconnect is such a return: the plane had no responder here for
|
|
4501
|
+
// the gap.
|
|
4502
|
+
const instance = randomUUID();
|
|
4503
|
+
void (async () => {
|
|
4504
|
+
for await (const m of sub) {
|
|
4505
|
+
// Sender-bound reply guard. Drop silently rather than publishing somewhere else: a reply
|
|
4506
|
+
// aimed outside the sender's own subtree is not a request we can answer safely.
|
|
4507
|
+
//
|
|
4508
|
+
// REPORTED ON `warning`, NOT ON `error`, AND THAT IS A SAFETY PROPERTY RATHER THAN A CHOICE
|
|
4509
|
+
// OF CHANNEL. A rejected probe is a condition this responder is already surviving: it drops
|
|
4510
|
+
// the frame and keeps serving. `error` cannot carry such a notice on this class, because
|
|
4511
|
+
// Node's `EventEmitter` RETHROWS an `error` emit that has no listener attached, so the
|
|
4512
|
+
// notice would end the process of any embedder that had not attached one — and the plane
|
|
4513
|
+
// would then genuinely be unbound, which a peer's next probe reports as `unbound`. The
|
|
4514
|
+
// condition would have manufactured the state it described. `warning` is observable and
|
|
4515
|
+
// never fatal without a listener (see {@link emitRecoverable} and #891, where retry notices
|
|
4516
|
+
// on `error` killed hosts the endpoint intended to keep running).
|
|
4517
|
+
//
|
|
4518
|
+
// Since ANY credentialed peer may publish a probe, and the reply target inside it is chosen
|
|
4519
|
+
// by that peer and not permission-checked by the broker, reaching this branch is a peer's
|
|
4520
|
+
// decision, not an operator's. Safety here must therefore hold with no listener attached,
|
|
4521
|
+
// which is what CELL F1 runs.
|
|
4522
|
+
if (!m.reply || !m.reply.startsWith(`${m.subject}.reply.`)) {
|
|
4523
|
+
this.emitRecoverable(new Error(`rejected liveness probe on ${m.subject}: reply target "${m.reply ?? "(none)"}" is not under the sender's own reply subtree`));
|
|
4524
|
+
continue;
|
|
4525
|
+
}
|
|
4526
|
+
let responder;
|
|
4527
|
+
try {
|
|
4528
|
+
responder = await readState();
|
|
4529
|
+
}
|
|
4530
|
+
catch {
|
|
4531
|
+
responder = "unknown";
|
|
4532
|
+
}
|
|
4533
|
+
const answer = { plane, responder, instance };
|
|
4534
|
+
try {
|
|
4535
|
+
m.respond(JSON.stringify(answer));
|
|
4536
|
+
}
|
|
4537
|
+
catch { /* the requester is gone */ }
|
|
4538
|
+
}
|
|
4539
|
+
// The loop itself ends only on a SUBSCRIPTION-level fault (the connection went away), which
|
|
4540
|
+
// is not something a peer's probe can cause: the guard above `continue`s, `readState` is
|
|
4541
|
+
// caught, and the respond is caught. So this arm stays on `error` — it reports that this
|
|
4542
|
+
// responder has stopped answering at all, which is a fault an embedder should not be able to
|
|
4543
|
+
// miss, and no credentialed peer can reach it.
|
|
4544
|
+
})().catch((e) => this.emit("error", e));
|
|
4545
|
+
}
|
|
4546
|
+
/** Bind the liveness responder for the DELIVERY plane, grading itself from its own shard-0 lease
|
|
4547
|
+
* through the shared classifier. The daemon is the one process that can answer this honestly: it
|
|
4548
|
+
* knows its own incarnation, so a predecessor's `ready:true` corpse in the bucket classifies
|
|
4549
|
+
* `stale` rather than as its own health.
|
|
4550
|
+
*
|
|
4551
|
+
* It reads the lease at PROBE time rather than caching a flag set at bind time. A cached flag
|
|
4552
|
+
* would answer `bound` for as long as this process lived, including after it had lost the shard
|
|
4553
|
+
* and stood down — a responder that reports its intent instead of its state, which is the whole
|
|
4554
|
+
* family of bug this issue is about. */
|
|
4555
|
+
serveDeliveryLiveness(shardIndex = 0) {
|
|
4556
|
+
this.serveLiveness("delivery", async () => responderFromLease(await this.readDeliveryLease(shardIndex), this.card.id));
|
|
4557
|
+
}
|
|
4558
|
+
/** Ask whether a responder is bound for `plane`, as a credentialed peer that need not own it.
|
|
4559
|
+
*
|
|
4560
|
+
* EVERY WAY THIS CAN END IS CLASSIFIED, and the classification lives in ONE place
|
|
4561
|
+
* ({@link responderFromProbe}) so no call site can invent its own mapping. Only the broker's own
|
|
4562
|
+
* no-responders 503 becomes `unbound`. A timeout, a permission refusal, a transport failure and
|
|
4563
|
+
* an unreadable reply ALL become `unknown`, because each is a failure to find out, and this
|
|
4564
|
+
* issue exists because failures to find out were being reported as findings.
|
|
4565
|
+
*
|
|
4566
|
+
* `noMux` with a named reply subject is required rather than stylistic: the muxed inbox would
|
|
4567
|
+
* swallow the 503 into an ordinary timeout, and the 503 is the only outcome that carries a
|
|
4568
|
+
* positive verdict. Collapsing it would leave a probe that can say `bound` or `unknown` and
|
|
4569
|
+
* never `unbound` — an instrument that cannot report the failure it was built to report.
|
|
4570
|
+
*
|
|
4571
|
+
* The reply rides `<request>.reply.<uuid>`, the sender's own subtree, so the responder's bound
|
|
4572
|
+
* reply guard accepts it and the responder needs no broad inbox-publish grant to answer. */
|
|
4573
|
+
async probeLiveness(plane, timeoutMs = 2_000) {
|
|
4574
|
+
if (!isLivenessPlane(plane))
|
|
4575
|
+
// A closed set, refused loudly. Answering an unknown plane at all would make this a probe
|
|
4576
|
+
// that returns something for every input, which is indistinguishable from one that is not
|
|
4577
|
+
// measuring anything.
|
|
4578
|
+
throw new Error(`liveness: "${plane}" is not a plane this surface answers for (${[...LIVENESS_PLANES].join(", ")})`);
|
|
4579
|
+
if (!this.nc)
|
|
4580
|
+
throw new Error(this.notLiveMsg());
|
|
4581
|
+
const reqSubject = livenessSubject(this.space, plane, this.owner, this.actor);
|
|
4582
|
+
const reply = `${reqSubject}.reply.${randomUUID()}`;
|
|
4583
|
+
let outcome;
|
|
4584
|
+
let answered;
|
|
4585
|
+
let instance;
|
|
4586
|
+
try {
|
|
4587
|
+
const m = await this.nc.request(reqSubject, "", { timeout: timeoutMs, noMux: true, reply });
|
|
4588
|
+
let body;
|
|
4589
|
+
try {
|
|
4590
|
+
body = m.json();
|
|
4591
|
+
}
|
|
4592
|
+
catch {
|
|
4593
|
+
body = undefined;
|
|
4594
|
+
}
|
|
4595
|
+
const parsed = parseLivenessAnswer(body, plane);
|
|
4596
|
+
// A reply we cannot read is `malformed`, which grades `unknown`. It is NOT promoted to
|
|
4597
|
+
// `bound` on the strength of having replied at all: that promotion is the `pgrep` error,
|
|
4598
|
+
// where evidence a process exists was read as evidence it works.
|
|
4599
|
+
outcome = parsed ? "replied" : "malformed";
|
|
4600
|
+
answered = parsed?.responder;
|
|
4601
|
+
instance = parsed?.instance;
|
|
4602
|
+
}
|
|
4603
|
+
catch (e) {
|
|
4604
|
+
outcome = this.isNoResponders(e) ? "noResponders" : this.probeFailureOutcome(e);
|
|
4605
|
+
}
|
|
4606
|
+
// `instance` rides ONLY a reply that was read. Every other outcome means no responder answered,
|
|
4607
|
+
// so there is no responder to name, and a token attached to an `unbound` or an `unknown` would
|
|
4608
|
+
// claim one had. It stays absent on those arms by construction: nothing assigns it.
|
|
4609
|
+
const verdict = { plane, responder: responderFromProbe(outcome, answered) };
|
|
4610
|
+
return instance === undefined ? verdict : { ...verdict, instance };
|
|
4611
|
+
}
|
|
4612
|
+
/** Grade a failed probe that was NOT a no-responders answer. Both arms return an outcome that
|
|
4613
|
+
* maps to `unknown`; they are told apart so the distinction stays legible at the call site and so
|
|
4614
|
+
* a future caller can render the two differently (a refusal is about YOUR credential, a timeout
|
|
4615
|
+
* is about something being wedged). Neither may ever mean health. */
|
|
4616
|
+
probeFailureOutcome(e) {
|
|
4617
|
+
if (e instanceof AuthorizationError || e instanceof PermissionViolationError)
|
|
4618
|
+
return "refused";
|
|
4619
|
+
const name = e?.name;
|
|
4620
|
+
const msg = e?.message ?? "";
|
|
4621
|
+
if (name === "TimeoutError" || /timeout/i.test(msg))
|
|
4622
|
+
return "timeout";
|
|
4623
|
+
// Anything else is a transport or client failure: it says something about our link, not about
|
|
4624
|
+
// the plane, so it is graded like a refusal rather than guessed at.
|
|
4625
|
+
return "refused";
|
|
4626
|
+
}
|
|
4391
4627
|
/** Serve one PRIVILEGED delivery-admin request (the D5 rail-split). The cred layer is the caller
|
|
4392
4628
|
* boundary — only the supervisor profile can publish here — and `serveControl`'s sender check +
|
|
4393
4629
|
* bounded reply still apply on top. `reloadCreds` is the class-2 renewal ADOPTION step: re-read
|
|
@@ -4399,6 +4635,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4399
4635
|
if (!this.plane3MayAct())
|
|
4400
4636
|
return { ok: false, error: "delivery: this daemon is not serving this shard (it is re-checking ownership); retry" };
|
|
4401
4637
|
if (req.op === "reloadCreds") {
|
|
4638
|
+
// NOT BEFORE START-UP IS DONE (#2304). Both halves below would run: the delivery half commits
|
|
4639
|
+
// and schedules `nc.reconnect()`, which lands underneath start-up awaits still in flight on
|
|
4640
|
+
// this connection (the lease watch's consumer create times out and the daemon exits). Refuse
|
|
4641
|
+
// before either half acts, so nothing is adopted and the renewal owner records why.
|
|
4642
|
+
if (this.plane3?.startupComplete?.() === false)
|
|
4643
|
+
return { ok: false, error: "delivery: this daemon has not finished starting; nothing adopted - the next renewal pass or the daemon's 75% re-read adopts the re-signed creds" };
|
|
4402
4644
|
// The renewal owner's EXPECTED-generation tokens (SHA-256 of each JWT it re-signed), per
|
|
4403
4645
|
// component. A missing entry means "no expectation" (the passive backstop still adopts).
|
|
4404
4646
|
const expected = (req.args?.expected ?? {});
|
|
@@ -4446,6 +4688,24 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4446
4688
|
return { ok: false, error: e.message };
|
|
4447
4689
|
}
|
|
4448
4690
|
}
|
|
4691
|
+
if (req.op === "evictPrincipals") {
|
|
4692
|
+
// The same executor over a SET: one shared sweep instead of one request and one scan per
|
|
4693
|
+
// principal. The set is bounded so one request stays inside one sweep's work.
|
|
4694
|
+
if (!this.plane3?.evictPrincipals)
|
|
4695
|
+
return { ok: false, error: "evictPrincipals: no batch eviction executor wired on this daemon" };
|
|
4696
|
+
const raw = req.args?.principals;
|
|
4697
|
+
const principals = Array.isArray(raw) ? raw.map((p) => (typeof p === "string" ? p.trim() : "")) : [];
|
|
4698
|
+
if (principals.length === 0 || principals.some((p) => !p))
|
|
4699
|
+
return { ok: false, error: "evictPrincipals: principals must be a non-empty array of owner.actor dot-form principals" };
|
|
4700
|
+
if (principals.length > EVICT_PRINCIPALS_MAX || new Set(principals).size !== principals.length)
|
|
4701
|
+
return { ok: false, error: `evictPrincipals: principals must be distinct and at most ${EVICT_PRINCIPALS_MAX}` };
|
|
4702
|
+
try {
|
|
4703
|
+
return { ok: true, data: await this.plane3.evictPrincipals(principals) };
|
|
4704
|
+
}
|
|
4705
|
+
catch (e) {
|
|
4706
|
+
return { ok: false, error: e.message };
|
|
4707
|
+
}
|
|
4708
|
+
}
|
|
4449
4709
|
if (req.op === "planeConnLiveness") {
|
|
4450
4710
|
// The plane-claim liveness oracle (#29 HIGH 3): a CLOSED read-only verb — two claimed
|
|
4451
4711
|
// scanner tuples in, two bound verdicts + sweep completeness out. The executor hook owns
|
|
@@ -4592,7 +4852,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4592
4852
|
m.ack();
|
|
4593
4853
|
return;
|
|
4594
4854
|
}
|
|
4595
|
-
if (!msg.from || msg.from.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner)) {
|
|
4855
|
+
if (!isRecord(msg) || !msg.from || msg.from.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner)) {
|
|
4596
4856
|
m.ack();
|
|
4597
4857
|
return;
|
|
4598
4858
|
} // authenticity (owner must be a real principal, not an old-shape alias)
|
|
@@ -4702,12 +4962,52 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4702
4962
|
m.ack();
|
|
4703
4963
|
return;
|
|
4704
4964
|
} // undecodable — drop
|
|
4965
|
+
if (!isRecord(entry)) {
|
|
4966
|
+
m.ack();
|
|
4967
|
+
return;
|
|
4968
|
+
} // non-object envelope — permanently invalid
|
|
4705
4969
|
const redeliveries = m.info?.deliveryCount ?? 1; // JsMsg delivery attempts (1 on first delivery)
|
|
4706
4970
|
// Lifecycle-exact ACL re-auth (SPEC 13.1): the entry was addressed to pr.lifecycleUid's inbox, so
|
|
4707
4971
|
// the row read is that lifecycle's exact key — a retired lifecycle's purged row reads as unknown
|
|
4708
|
-
// and its residual entries
|
|
4972
|
+
// and its residual entries are removed below, never authorized against the successor's row.
|
|
4709
4973
|
const acl = await this.plane3?.aclFor(owner, pr.lifecycleUid);
|
|
4710
4974
|
if (acl === undefined) {
|
|
4975
|
+
// RETIRED lifecycle: its exact ACL key carries retirement's tombstone, so no attempt can ever
|
|
4976
|
+
// deliver this entry. Drop it AND remove it from the store. Deferring it to the ceiling and
|
|
4977
|
+
// keeping it would make every reader that starts from a fresh cursor pay the same sweep again.
|
|
4978
|
+
// An ACL row that is merely absent proves nothing and still defers below.
|
|
4979
|
+
if (await aclRetired(await this.aclRegistry(), owner, pr.lifecycleUid)) {
|
|
4980
|
+
if (!this.plane3MayAct())
|
|
4981
|
+
return;
|
|
4982
|
+
// Remove BEFORE the ack. The ack ends the reader's work on this entry, so acking first and
|
|
4983
|
+
// then failing the delete would keep the entry with nothing left to remove it. A failed delete
|
|
4984
|
+
// stays pending and retries, bounded by the same ceiling; an entry the stream no longer holds
|
|
4985
|
+
// is already removed (the broker answers a delete of a missing sequence with 10043).
|
|
4986
|
+
let failure;
|
|
4987
|
+
try {
|
|
4988
|
+
await this.jsm.streams.deleteMessage(inboxStream(this.space), m.seq, false);
|
|
4989
|
+
}
|
|
4990
|
+
catch (e) {
|
|
4991
|
+
if (!isJetStreamMissing(e, 10043))
|
|
4992
|
+
failure = e instanceof Error ? e : new Error(String(e));
|
|
4993
|
+
}
|
|
4994
|
+
// FENCE AFTER THE DELETE, which is broker I/O. A daemon that stopped serving while it was in
|
|
4995
|
+
// flight leaves the entry to the holder: a term here would move the shared durable past an entry
|
|
4996
|
+
// the failed delete left stored, so the holder would never retry it.
|
|
4997
|
+
if (!this.plane3MayAct())
|
|
4998
|
+
return;
|
|
4999
|
+
if (failure) {
|
|
5000
|
+
if (redeliveries >= READER_MAX_REDELIVERIES) {
|
|
5001
|
+
m.term();
|
|
5002
|
+
this.emit("error", new Error(`plane-3 reader: gave up removing entry ${m.seq} for retired lifecycle ${owner}.${pr.lifecycleUid} after ${redeliveries} redeliveries: ${failure.message}`));
|
|
5003
|
+
return;
|
|
5004
|
+
}
|
|
5005
|
+
m.nak(2000);
|
|
5006
|
+
return;
|
|
5007
|
+
}
|
|
5008
|
+
m.ack();
|
|
5009
|
+
return;
|
|
5010
|
+
}
|
|
4711
5011
|
// UNKNOWN owner — the manager has not (re)hydrated this owner's ACL yet (e.g. right after a
|
|
4712
5012
|
// manager PROCESS restart). This is NOT a revocation: DEFER (redeliver), never drop — an ack here
|
|
4713
5013
|
// would lose at-least-once on restart (impl-review BLOCKER-2). A delayed nak + a redelivery
|
|
@@ -4889,7 +5189,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4889
5189
|
}
|
|
4890
5190
|
catch (e) {
|
|
4891
5191
|
if (attempt === 0)
|
|
4892
|
-
this.
|
|
5192
|
+
this.emitRecoverable(new Error(`channel "${channel}": Plane-3 durable membership (generation ${generation}) not yet tombstoned after a refused live sub - retrying; §7 boundary may be open until it succeeds (${e.message})`));
|
|
4893
5193
|
await new Promise((r) => setTimeout(r, Math.min(30_000, 1000 * 2 ** attempt)));
|
|
4894
5194
|
}
|
|
4895
5195
|
}
|
|
@@ -5083,7 +5383,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5083
5383
|
// server policed who could publish. The payload `from` is advisory — it must match,
|
|
5084
5384
|
// and a missing `from` or an unparseable subject on a delivery is itself an anomaly.
|
|
5085
5385
|
// Reject (term — a spoof is permanently invalid, never redeliver) BEFORE any handler.
|
|
5086
|
-
if (!isUsableMessageId(msg.id)) {
|
|
5386
|
+
if (!isRecord(msg) || !isUsableMessageId(msg.id)) {
|
|
5087
5387
|
m.term(); // malformed envelope (SPEC sec 5): absent/non-string id — permanently invalid
|
|
5088
5388
|
this.emit("error", new Error(`dropped message on ${m.subject}: absent or non-string id`));
|
|
5089
5389
|
continue;
|
|
@@ -5178,7 +5478,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5178
5478
|
this.emit("error", e);
|
|
5179
5479
|
return;
|
|
5180
5480
|
}
|
|
5181
|
-
if (!isUsableMessageId(msg.id))
|
|
5481
|
+
if (!isRecord(msg) || !isUsableMessageId(msg.id))
|
|
5182
5482
|
return; // malformed envelope (SPEC sec 5) — live is at-most-once: drop
|
|
5183
5483
|
if (!msg.from || msg.from.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner))
|
|
5184
5484
|
return; // spoof/malformed/old-shape-alias — drop (at-most-once)
|
|
@@ -5414,7 +5714,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5414
5714
|
continue; // skip undecodable
|
|
5415
5715
|
}
|
|
5416
5716
|
// Same authenticity guard as the tail; skip our own echoes in history.
|
|
5417
|
-
if (!isUsableMessageId(msg.id))
|
|
5717
|
+
if (!isRecord(msg) || !isUsableMessageId(msg.id))
|
|
5418
5718
|
continue; // malformed envelope (SPEC sec 5) — history skips
|
|
5419
5719
|
const parsed = parseSubject(sm.subject);
|
|
5420
5720
|
if (!parsed || msg.from?.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner) || msg.from.id === this.card.id)
|
|
@@ -5431,7 +5731,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5431
5731
|
* not a push into context) plus `dropped: true` when the window is not complete: either the
|
|
5432
5732
|
* channel's earliest *retained* message is already newer than the watermark (some ambient aged
|
|
5433
5733
|
* out of the per-subject window), or replay is off for the channel below. Either way the caller
|
|
5434
|
-
* must say so rather than silently reporting an empty, complete window.
|
|
5734
|
+
* must say so rather than silently reporting an empty, complete window. `seqs[i]` is the chat
|
|
5735
|
+
* stream sequence of `messages[i]`, the one identity two identical messages do not share.
|
|
5736
|
+
* `unanswered` is true when no history read answered (replay is off, or the read failed), so
|
|
5737
|
+
* `messages` says nothing about what the channel retains.
|
|
5435
5738
|
*
|
|
5436
5739
|
* Honors the **same** per-channel replay gate as join-backfill ({@link joinPolicyFresh}): a
|
|
5437
5740
|
* `replay=off` channel returns no messages, so `focus` can't become a history bypass for a
|
|
@@ -5445,10 +5748,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5445
5748
|
if (!this.jsm)
|
|
5446
5749
|
throw new Error(this.notLiveMsg());
|
|
5447
5750
|
if (!isConcreteChannel(channel))
|
|
5448
|
-
return { messages: [], dropped: false };
|
|
5751
|
+
return { messages: [], seqs: [], dropped: false, unanswered: true };
|
|
5449
5752
|
const policy = await this.joinPolicyFresh(channel);
|
|
5450
5753
|
if (!policy.replay)
|
|
5451
|
-
return { messages: [], dropped: true };
|
|
5754
|
+
return { messages: [], seqs: [], dropped: true, unanswered: true };
|
|
5452
5755
|
const subject = chatSubject(this.space, "*", "*", channel);
|
|
5453
5756
|
let raw;
|
|
5454
5757
|
try {
|
|
@@ -5458,9 +5761,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5458
5761
|
this.emit("error", e);
|
|
5459
5762
|
if (isPermissionDenied(e))
|
|
5460
5763
|
throw e;
|
|
5461
|
-
|
|
5764
|
+
return { messages: [], seqs: [], dropped: true, unanswered: true };
|
|
5462
5765
|
}
|
|
5463
5766
|
const collected = [];
|
|
5767
|
+
const seqs = [];
|
|
5464
5768
|
for (const sm of raw) {
|
|
5465
5769
|
let msg;
|
|
5466
5770
|
try {
|
|
@@ -5470,15 +5774,16 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5470
5774
|
continue; // skip undecodable
|
|
5471
5775
|
}
|
|
5472
5776
|
// Same authenticity guard as the tail/backfill; skip our own echoes.
|
|
5473
|
-
if (!isUsableMessageId(msg.id))
|
|
5777
|
+
if (!isRecord(msg) || !isUsableMessageId(msg.id))
|
|
5474
5778
|
continue; // malformed envelope (SPEC sec 5) — recall skips
|
|
5475
5779
|
const parsed = parseSubject(sm.subject);
|
|
5476
5780
|
if (!parsed || msg.from?.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner) || msg.from.id === this.card.id)
|
|
5477
5781
|
continue;
|
|
5478
5782
|
collected.push(authenticatedMessage(msg, parsed));
|
|
5783
|
+
seqs.push(sm.seq);
|
|
5479
5784
|
}
|
|
5480
5785
|
const dropped = await this.channelDropped(subject, sinceSeq);
|
|
5481
|
-
return { messages: collected, dropped };
|
|
5786
|
+
return { messages: collected, seqs, dropped, unanswered: false };
|
|
5482
5787
|
}
|
|
5483
5788
|
/** Did focus recall on `subject` miss ambient that aged out past the watermark? Ambient is only
|
|
5484
5789
|
* ever discarded once a sender-subject reaches {@link MAX_MSGS_PER_SUBJECT} (`DiscardPolicy.Old`);
|
|
@@ -5533,6 +5838,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5533
5838
|
condition: this.condition,
|
|
5534
5839
|
environment: this.environment,
|
|
5535
5840
|
activity: this.activity,
|
|
5841
|
+
activeAt: this.activeAt,
|
|
5536
5842
|
attention: this.attentionMode,
|
|
5537
5843
|
channelModes: this.channelModes,
|
|
5538
5844
|
ts: Date.now(),
|
|
@@ -5992,22 +6298,22 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5992
6298
|
this.emit("roster", this.getRoster());
|
|
5993
6299
|
}
|
|
5994
6300
|
}
|
|
5995
|
-
/** Map an authenticated parsed-subject kind to the message class surfaced to "message" listeners.
|
|
5996
|
-
* Throws on `ctl` (control-plane is request/reply, never a "message") — per repo convention, no
|
|
5997
|
-
* silent default: an unexpected delivering kind is a bug, not something to swallow. */
|
|
5998
|
-
/** A usable delivery-message id (#624): a string, possibly empty (the never-a-key case), but
|
|
5999
|
-
* never absent and never a non-string. An absent or non-string id is a malformed envelope under
|
|
6000
|
-
* SPEC sec 5; each delivery pump handles it per its own class (durable term, live drop, history
|
|
6001
|
-
* skip) so it never reaches the receiver's id-keyed machinery as `undefined`. */
|
|
6002
6301
|
/** What a history read failure NAMES when it could not finish. One filter subject is the useful
|
|
6003
6302
|
* thing to print; a set of sixty-nine of them is a wall of text in a message a human has to read,
|
|
6004
6303
|
* so a set says its size and the stream it was read from instead. */
|
|
6005
6304
|
function subjectLabel(subjects) {
|
|
6006
6305
|
return subjects.length === 1 ? subjects[0] : `${subjects.length} filtered subjects`;
|
|
6007
6306
|
}
|
|
6307
|
+
/** A usable delivery-message id (#624): a string, possibly empty (the never-a-key case), but
|
|
6308
|
+
* never absent and never a non-string. An absent or non-string id is a malformed envelope under
|
|
6309
|
+
* SPEC sec 5; each delivery pump handles it per its own class (durable term, live drop, history
|
|
6310
|
+
* skip) so it never reaches the receiver's id-keyed machinery as `undefined`. */
|
|
6008
6311
|
function isUsableMessageId(id) {
|
|
6009
6312
|
return typeof id === "string";
|
|
6010
6313
|
}
|
|
6314
|
+
/** Map an authenticated parsed-subject kind to the message class surfaced to "message" listeners.
|
|
6315
|
+
* Throws on `ctl` (control-plane is request/reply, never a "message") — per repo convention, no
|
|
6316
|
+
* silent default: an unexpected delivering kind is a bug, not something to swallow. */
|
|
6011
6317
|
function kindFromParsed(kind) {
|
|
6012
6318
|
switch (kind) {
|
|
6013
6319
|
case "chat":
|
|
@@ -6304,11 +6610,6 @@ function authOpts(a) {
|
|
|
6304
6610
|
}
|
|
6305
6611
|
return { token: a.token, user: a.user, pass: a.pass, tls };
|
|
6306
6612
|
}
|
|
6307
|
-
/** Decode the owner+actor PRINCIPAL from a user bearer WITHOUT verifying it — the client trusts its own
|
|
6308
|
-
* bearer only to build its subjects; the broker's minted grant (from the callout, which DOES verify the
|
|
6309
|
-
* bearer) is the real boundary, so a client that lied to itself would just be denied. Per the token
|
|
6310
|
-
* claim semantics the OWNER is the JWT `sub` (`act.owner` merely restates it) and the ACTOR is
|
|
6311
|
-
* `act.actor`. Throws on a structurally-unusable bearer (fail-loud). */
|
|
6312
6613
|
/** The bearer's `exp` as epoch ms — what the refresh schedule keys on. A bearer without a numeric
|
|
6313
6614
|
* `exp` is structurally unusable for a refreshing endpoint (fail-loud, like the principal decode). */
|
|
6314
6615
|
function bearerExpiryMs(bearer) {
|
|
@@ -6326,6 +6627,11 @@ function bearerExpiryMs(bearer) {
|
|
|
6326
6627
|
throw new Error("user-mode bearer is missing a numeric exp claim");
|
|
6327
6628
|
return claims.exp * 1000;
|
|
6328
6629
|
}
|
|
6630
|
+
/** Decode the owner+actor PRINCIPAL from a user bearer WITHOUT verifying it — the client trusts its own
|
|
6631
|
+
* bearer only to build its subjects; the broker's minted grant (from the callout, which DOES verify the
|
|
6632
|
+
* bearer) is the real boundary, so a client that lied to itself would just be denied. Per the token
|
|
6633
|
+
* claim semantics the OWNER is the JWT `sub` (`act.owner` merely restates it) and the ACTOR is
|
|
6634
|
+
* `act.actor`. Throws on a structurally-unusable bearer (fail-loud). */
|
|
6329
6635
|
function decodeBearerPrincipal(bearer) {
|
|
6330
6636
|
const payload = bearer.split(".")[1];
|
|
6331
6637
|
if (!payload)
|