@cotal-ai/core 0.57.0 → 0.59.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 +25 -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 +12 -0
- package/dist/connector.d.ts.map +1 -1
- package/dist/connector.js.map +1 -1
- package/dist/endpoint-envelope.js +2 -2
- package/dist/endpoint-envelope.js.map +1 -1
- package/dist/endpoint-grants.d.ts.map +1 -1
- package/dist/endpoint-grants.js +4 -2
- package/dist/endpoint-grants.js.map +1 -1
- package/dist/endpoint-handle.d.ts.map +1 -1
- package/dist/endpoint-handle.js +5 -2
- package/dist/endpoint-handle.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-subjects.d.ts +6 -3
- package/dist/endpoint-subjects.d.ts.map +1 -1
- package/dist/endpoint-subjects.js +5 -5
- package/dist/endpoint-subjects.js.map +1 -1
- package/dist/endpoint-verbs.d.ts +1 -1
- package/dist/endpoint-verbs.d.ts.map +1 -1
- package/dist/endpoint-verbs.js +2 -2
- package/dist/endpoint-verbs.js.map +1 -1
- package/dist/endpoint.d.ts +114 -8
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +393 -45
- 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 +95 -38
- 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/kv-scan.d.ts +5 -2
- package/dist/kv-scan.d.ts.map +1 -1
- package/dist/kv-scan.js +103 -27
- package/dist/kv-scan.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/lease.d.ts +5 -0
- package/dist/lease.d.ts.map +1 -1
- package/dist/lease.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/members.d.ts +10 -0
- package/dist/members.d.ts.map +1 -1
- package/dist/members.js +28 -1
- package/dist/members.js.map +1 -1
- package/dist/membership-feed.d.ts.map +1 -1
- package/dist/membership-feed.js +19 -6
- package/dist/membership-feed.js.map +1 -1
- package/dist/provision.d.ts +24 -11
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +154 -66
- package/dist/provision.js.map +1 -1
- package/dist/remote-manager-authority.d.ts +193 -3
- 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-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 +24 -5
- package/dist/run-record.d.ts.map +1 -1
- package/dist/run-record.js +20 -0
- package/dist/run-record.js.map +1 -1
- 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 +59 -3
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +226 -21
- package/dist/streams.js.map +1 -1
- package/dist/subjects.d.ts +52 -7
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +62 -3
- 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,7 +3,9 @@ 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";
|
|
8
|
+
import { requireBrokerFloor } from "./broker-floor.js";
|
|
7
9
|
import { inspectCredHealth } from "./provision.js";
|
|
8
10
|
import { parseSecretStoreIdentity, } from "./secret-store.js";
|
|
9
11
|
import { resolveService, invokeCommand, submitAndFollowGoal } from "./endpoint-invoke.js";
|
|
@@ -14,16 +16,17 @@ import { readAcceptedRow } from "./issued-authority.js";
|
|
|
14
16
|
import { liveKvEntries } from "./kv-scan.js";
|
|
15
17
|
import { ARTIFACT_PART_KIND, isArtifactPart } from "./artifact.js";
|
|
16
18
|
import { assertValidName } from "./resolve.js";
|
|
17
|
-
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";
|
|
18
21
|
import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, JetStreamApiCodes, JetStreamApiError, } from "@nats-io/jetstream";
|
|
19
22
|
import {} from "@nats-io/jetstream";
|
|
20
23
|
import { Kvm } from "@nats-io/kv";
|
|
21
24
|
import { Bucket, KvWatchInclude } from "@nats-io/kv/internal";
|
|
22
|
-
import { openMembersRegistry, commitMember, tombstoneMember, activateMember, readMember, listMembers, durableEligible, StaleMembershipWrite, } from "./members.js";
|
|
23
|
-
import { openAclRegistry, readAcl, readAclForAlias, AmbiguousAclAlias, commitAcl as writeAclRecord, reissueAcl as writeAclReissue } from "./acls.js";
|
|
25
|
+
import { openMembersRegistry, commitMember, tombstoneMember, activateMember, readMember, listMembers, listLifecycleMemberChannels, durableEligible, StaleMembershipWrite, } from "./members.js";
|
|
26
|
+
import { openAclRegistry, readAcl, readAclForAlias, aclRetired, AmbiguousAclAlias, commitAcl as writeAclRecord, reissueAcl as writeAclReissue } from "./acls.js";
|
|
24
27
|
import { openDeliveryRegistry } from "./lease.js";
|
|
25
28
|
import { openChannelRegistry, effectiveReplay, effectiveReplayWindowMs, effectiveDeliveryClass, readChannelConfig, readChannelDefaults, } from "./channels.js";
|
|
26
|
-
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";
|
|
27
30
|
export const DEFAULT_SERVER = "nats://127.0.0.1:4222";
|
|
28
31
|
const PLANE3_FRAME_HEADER = "Cotal-Delivery-Frame";
|
|
29
32
|
/** Space joined when none is given on the CLI (the `cotal-<space>` cmux tab, etc.). */
|
|
@@ -143,6 +146,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
143
146
|
authExpiryReconnectTimer;
|
|
144
147
|
sentinelCreds;
|
|
145
148
|
tls;
|
|
149
|
+
transportPingIntervalMs;
|
|
150
|
+
transportMaxPingOut;
|
|
146
151
|
heartbeatMs;
|
|
147
152
|
ttlMs;
|
|
148
153
|
doRegister;
|
|
@@ -184,12 +189,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
184
189
|
* {@link armDeliveryControl}; tracked so the stale one is dropped on reconnect. */
|
|
185
190
|
deliveryServeSub;
|
|
186
191
|
deliveryAdminServeSub;
|
|
187
|
-
/** When set, this endpoint hosts the Plane-3 fan-out writer + trusted reader (the server-side delivery
|
|
188
|
-
* daemon). `aclFor` maps an owner id to its current read ACL (`allowSubscribe`) for the reader's
|
|
189
|
-
* re-authorization — read FRESH per entry from the durable ACL registry KV, hence async. */
|
|
190
192
|
/** True once {@link quiescePlane3} has stopped serving this shard pending an ownership answer.
|
|
191
193
|
* Guards {@link armPlane3} so a RECONNECT cannot silently resume serving mid-question. */
|
|
192
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. */
|
|
193
198
|
plane3;
|
|
194
199
|
/** Live local cache of the channel registry (key = channel token), kept by a KV watch. */
|
|
195
200
|
channelConfigs = new Map();
|
|
@@ -203,6 +208,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
203
208
|
* `chathist_<id>` consumer, so overlapping reads would delete/recreate it under one another. */
|
|
204
209
|
histLock = Promise.resolve();
|
|
205
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();
|
|
206
216
|
streamMsgs = [];
|
|
207
217
|
/** Per-channel native core subscriptions (SPEC v0.3) — the manager-free live read path for boot +
|
|
208
218
|
* runtime channels (there is no per-instance chat durable). Keyed by channel so leave unsubscribes
|
|
@@ -286,6 +296,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
286
296
|
presenceRebindAt = 0;
|
|
287
297
|
status = "idle";
|
|
288
298
|
activity;
|
|
299
|
+
/** Last harness-reported work progress. Carried by the next heartbeat, never published per event. */
|
|
300
|
+
activeAt;
|
|
289
301
|
condition;
|
|
290
302
|
/** Advances on every condition change so an older in-flight put cannot be the final KV state. */
|
|
291
303
|
conditionRevision = 0;
|
|
@@ -483,6 +495,14 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
483
495
|
this.user = opts.user;
|
|
484
496
|
this.pass = opts.pass;
|
|
485
497
|
this.tls = opts.tls ?? false;
|
|
498
|
+
for (const [label, value] of [["transportPingIntervalMs", opts.transportPingIntervalMs], ["transportMaxPingOut", opts.transportMaxPingOut]]) {
|
|
499
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0))
|
|
500
|
+
throw new Error(`EndpointOptions.${label} must be a positive safe integer`);
|
|
501
|
+
}
|
|
502
|
+
if ((opts.transportPingIntervalMs === undefined) !== (opts.transportMaxPingOut === undefined))
|
|
503
|
+
throw new Error("EndpointOptions transportPingIntervalMs and transportMaxPingOut must be configured together");
|
|
504
|
+
this.transportPingIntervalMs = opts.transportPingIntervalMs;
|
|
505
|
+
this.transportMaxPingOut = opts.transportMaxPingOut;
|
|
486
506
|
// No implicit channel: an endpoint reads exactly what its caller lists. Omitted means none.
|
|
487
507
|
this.channels = opts.channels ?? [];
|
|
488
508
|
this.heartbeatMs = opts.heartbeatMs ?? 2000;
|
|
@@ -849,6 +869,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
849
869
|
CotalEndpoint.assertRenewableGeneration(candidate, this.currentCreds, delay);
|
|
850
870
|
this.currentCreds = candidate;
|
|
851
871
|
this.armCredsRefresh(delay);
|
|
872
|
+
this.emit("creds-adopted", credsClaims(candidate));
|
|
852
873
|
return credsClaims(candidate);
|
|
853
874
|
}
|
|
854
875
|
/** The 75%-of-lifetime renewal timer tick: prove + adopt + swap the LIVE connection. Preflights (it
|
|
@@ -979,6 +1000,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
979
1000
|
// sub.allow=[_INBOX_<connId>.>] it stops a peer from subscribing the wildcard inbox to sniff
|
|
980
1001
|
// others' DM deliveries. Set unconditionally so the prefix can never drift from the ACL.
|
|
981
1002
|
inboxPrefix: `_INBOX_${this.connId}`,
|
|
1003
|
+
...(this.transportPingIntervalMs === undefined ? {} : { pingInterval: this.transportPingIntervalMs }),
|
|
1004
|
+
...(this.transportMaxPingOut === undefined ? {} : { maxPingOut: this.transportMaxPingOut }),
|
|
982
1005
|
// The bearer rides a GETTER: nats.js re-evaluates the token authenticator per (re)connect
|
|
983
1006
|
// attempt, so internal reconnects present whatever refreshBearer last fetched.
|
|
984
1007
|
// Creds ALWAYS ride the CHECKED getter, renewed or static, so every attempt — including the
|
|
@@ -992,6 +1015,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
992
1015
|
// instead. Anonymous access stays reachable the only way it should be: by passing no creds.
|
|
993
1016
|
...authOpts({ token: this.token, user: this.user, pass: this.pass, creds: this.currentCreds !== undefined || this.credsSource ? () => this.credsForWire() : undefined, bearer: this.userMode ? () => this.currentBearer : undefined, sentinelCreds: this.sentinelCreds, tls: this.tls }),
|
|
994
1017
|
});
|
|
1018
|
+
// SPEC §13.12: the control surface requires nats-server >= 2.12; this runs on every
|
|
1019
|
+
// fresh connection, including the reconnects the library performs on its own here.
|
|
1020
|
+
requireBrokerFloor(this.nc);
|
|
995
1021
|
this.armAuthExpiryReconnectFence(this.nc);
|
|
996
1022
|
this.watchStatus();
|
|
997
1023
|
this.js = jetstream(this.nc);
|
|
@@ -1013,7 +1039,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1013
1039
|
// OPENs it (it's pre-created at `cotal up`; KV stream-create is denied to agents).
|
|
1014
1040
|
this.kv = this.authed
|
|
1015
1041
|
? await kvm.open(presenceBucket(this.space))
|
|
1016
|
-
: await kvm.create(presenceBucket(this.space), { ttl: this.ttlMs });
|
|
1042
|
+
: await kvm.create(presenceBucket(this.space), { ttl: this.ttlMs, storage: PRESENCE_STORAGE });
|
|
1017
1043
|
}
|
|
1018
1044
|
if (this.doWatch) {
|
|
1019
1045
|
await this.startPresenceWatch();
|
|
@@ -1101,6 +1127,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1101
1127
|
// endpoint hosts it. The first arm comes from startPlane3 (after start()); this re-binds the loops
|
|
1102
1128
|
// a reconnect's clearConnectionScoped() tore down, so a broker blip doesn't silently kill the backstop.
|
|
1103
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);
|
|
1104
1134
|
// Bound and live — covers initial start, manual reconnect, AND background self-heal (every
|
|
1105
1135
|
// path lands here). The single signal an in-process agent's connected flag tracks.
|
|
1106
1136
|
//
|
|
@@ -2223,6 +2253,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2223
2253
|
this.status = status;
|
|
2224
2254
|
await this.publishPresence();
|
|
2225
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
|
+
}
|
|
2226
2262
|
/** Publish a harness-reported condition, or clear it. Core stores the relay without interpretation. */
|
|
2227
2263
|
async setCondition(condition) {
|
|
2228
2264
|
this.condition = condition ?? undefined;
|
|
@@ -3390,8 +3426,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3390
3426
|
* daemon's cred is a file on disk that every restart re-reads, so `card.id` is stable across
|
|
3391
3427
|
* processes by design. See {@link DeliveryLeaseInfo.incarnation}. */
|
|
3392
3428
|
leaseIncarnation = randomUUID();
|
|
3393
|
-
|
|
3394
|
-
|
|
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 }));
|
|
3395
3434
|
}
|
|
3396
3435
|
/** Is this shard's lease row one THIS ENDPOINT INSTANCE wrote? The question a daemon whose renew
|
|
3397
3436
|
* just failed has to answer before it decides whether it still owns the shard.
|
|
@@ -3411,13 +3450,17 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3411
3450
|
* freeing a re-acquire. Acquired BEFORE binding (single-flight gate); {@link markDeliveryLeaseReady}
|
|
3412
3451
|
* flips it ready AFTER the loops + `ctl.delivery` are bound. Returns the lease revision. */
|
|
3413
3452
|
async acquireDeliveryLease(shardIndex) {
|
|
3414
|
-
|
|
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;
|
|
3415
3458
|
}
|
|
3416
3459
|
/** Flip the held lease to READY (CAS `kv.update`) AFTER `startPlane3` has bound the loops + the
|
|
3417
3460
|
* `ctl.delivery` responder — so "lease ready" proves the responder is up, not just that the slot was
|
|
3418
3461
|
* claimed. Returns the new revision. */
|
|
3419
3462
|
async markDeliveryLeaseReady(shardIndex, revision) {
|
|
3420
|
-
return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(true), revision);
|
|
3463
|
+
return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(shardIndex, true), revision);
|
|
3421
3464
|
}
|
|
3422
3465
|
/** Flip the held lease back to NOT-ready, the counterpart to {@link markDeliveryLeaseReady}, for a
|
|
3423
3466
|
* holder that has UNBOUND its loops and control responder but has not given up the shard.
|
|
@@ -3429,13 +3472,13 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3429
3472
|
* the row (rather than deleting it) is deliberate: the shard is still claimed, so no third daemon
|
|
3430
3473
|
* should be invited in; what is being withdrawn is only the claim to be answering. */
|
|
3431
3474
|
async markDeliveryLeaseNotReady(shardIndex, revision) {
|
|
3432
|
-
return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(false), revision);
|
|
3475
|
+
return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(shardIndex, false), revision);
|
|
3433
3476
|
}
|
|
3434
3477
|
/** Renew the held lease (CAS `kv.update` against `revision`, keeping `ready:true`) to refresh it before
|
|
3435
3478
|
* the bucket TTL expires it. Returns the new revision. Throws if the revision moved (lost the lease —
|
|
3436
3479
|
* the daemon should exit). */
|
|
3437
3480
|
async renewDeliveryLease(shardIndex, revision) {
|
|
3438
|
-
return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(true), revision);
|
|
3481
|
+
return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(shardIndex, true), revision);
|
|
3439
3482
|
}
|
|
3440
3483
|
/** Release the held lease on clean shutdown so a replacement daemon re-acquires immediately (best
|
|
3441
3484
|
* effort, a crash just lets the bucket TTL expire it).
|
|
@@ -3980,7 +4023,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3980
4023
|
continue;
|
|
3981
4024
|
}
|
|
3982
4025
|
const parsed = parseSubject(m.subject);
|
|
3983
|
-
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)
|
|
3984
4027
|
continue;
|
|
3985
4028
|
await this.publishDinbox(owner, lifecycleUid, { msg, channel, seq: m.seq, reason: "durable-channel", generation });
|
|
3986
4029
|
copied++;
|
|
@@ -4005,13 +4048,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4005
4048
|
async startPlane3(aclFor, opts = {}) {
|
|
4006
4049
|
if (!this.js)
|
|
4007
4050
|
throw new Error("endpoint not started");
|
|
4008
|
-
this.plane3 = { aclFor, reloadMembershipCreds: opts.reloadMembershipCreds, evictPrincipal: opts.evictPrincipal, planeConnLiveness: opts.planeConnLiveness, principalLiveness: opts.principalLiveness, reloadStoreIdentity: opts.reloadStoreIdentity };
|
|
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 };
|
|
4009
4052
|
await this.armPlane3();
|
|
4010
4053
|
}
|
|
4011
|
-
/** Serve one runtime durable-membership control request (the server-side delivery daemon). The caller
|
|
4012
|
-
* id is the authenticated subject sender ({@link serveControl} fail-closes on a mismatch). Validation
|
|
4013
|
-
* is against the durable ACL registry — the SAME KV the reader re-auths against (single source of
|
|
4014
|
-
* truth, no in-memory ledger to drift). */
|
|
4015
4054
|
/** Whether an ALREADY-DISPATCHED unit of Plane-3 work may still take effect.
|
|
4016
4055
|
*
|
|
4017
4056
|
* Unsubscribing stops NEW work; it cannot recall work already in flight. A handler that entered
|
|
@@ -4029,6 +4068,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4029
4068
|
plane3MayAct() {
|
|
4030
4069
|
return !this.plane3Quiesced;
|
|
4031
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). */
|
|
4032
4075
|
async handleDeliveryControl(req) {
|
|
4033
4076
|
// FENCE: entered before a quiesce, resuming after it. Answering now would put a second server on
|
|
4034
4077
|
// this shard's control rail while the winner is already READY.
|
|
@@ -4371,6 +4414,216 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4371
4414
|
}
|
|
4372
4415
|
this.deliveryAdminServeSub = this.serveControl(CONTROL_DELIVERY_ADMIN, (req) => this.handleDeliveryAdmin(req), { boundReply: true });
|
|
4373
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
|
+
}
|
|
4374
4627
|
/** Serve one PRIVILEGED delivery-admin request (the D5 rail-split). The cred layer is the caller
|
|
4375
4628
|
* boundary — only the supervisor profile can publish here — and `serveControl`'s sender check +
|
|
4376
4629
|
* bounded reply still apply on top. `reloadCreds` is the class-2 renewal ADOPTION step: re-read
|
|
@@ -4382,6 +4635,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4382
4635
|
if (!this.plane3MayAct())
|
|
4383
4636
|
return { ok: false, error: "delivery: this daemon is not serving this shard (it is re-checking ownership); retry" };
|
|
4384
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" };
|
|
4385
4644
|
// The renewal owner's EXPECTED-generation tokens (SHA-256 of each JWT it re-signed), per
|
|
4386
4645
|
// component. A missing entry means "no expectation" (the passive backstop still adopts).
|
|
4387
4646
|
const expected = (req.args?.expected ?? {});
|
|
@@ -4405,8 +4664,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4405
4664
|
// Arm the resident wire swap ONLY now — after BOTH proofs settled, right before the reply is
|
|
4406
4665
|
// returned+responded — so a slow membership proof can never let the delivery reconnect strand
|
|
4407
4666
|
// this reply. Only when delivery actually adopted a new candidate (currentCreds was updated).
|
|
4408
|
-
if (delivery.ok)
|
|
4667
|
+
if (delivery.ok) {
|
|
4668
|
+
this.plane3?.onDeliveryCredsAdopted?.();
|
|
4409
4669
|
this.scheduleResidentSwap();
|
|
4670
|
+
}
|
|
4410
4671
|
return failures.length
|
|
4411
4672
|
? { ok: false, error: failures.join("; "), data: { delivery, membership } }
|
|
4412
4673
|
: { ok: true, data: { delivery, membership } };
|
|
@@ -4427,6 +4688,24 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4427
4688
|
return { ok: false, error: e.message };
|
|
4428
4689
|
}
|
|
4429
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
|
+
}
|
|
4430
4709
|
if (req.op === "planeConnLiveness") {
|
|
4431
4710
|
// The plane-claim liveness oracle (#29 HIGH 3): a CLOSED read-only verb — two claimed
|
|
4432
4711
|
// scanner tuples in, two bound verdicts + sweep completeness out. The executor hook owns
|
|
@@ -4442,6 +4721,29 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4442
4721
|
return { ok: false, error: e.message };
|
|
4443
4722
|
}
|
|
4444
4723
|
}
|
|
4724
|
+
if (req.op === "lifecycleMemberships") {
|
|
4725
|
+
// The terminal teardown's membership INVENTORY: a complete, read-only, lifecycle-exact listing
|
|
4726
|
+
// of one principal's durable membership rows (tombstones included) from the trusted daemon that
|
|
4727
|
+
// owns the members bucket. The caller deletes only the exact keys it gets back, through its
|
|
4728
|
+
// target-pinned deprovisioner grant; this verb deletes nothing and returns no other lifecycle.
|
|
4729
|
+
const principal = typeof req.args?.principal === "string" ? req.args.principal.trim() : "";
|
|
4730
|
+
const uid = typeof req.args?.lifecycleUid === "string" ? req.args.lifecycleUid : "";
|
|
4731
|
+
if (!parsePrincipalKey(principal))
|
|
4732
|
+
return { ok: false, error: "lifecycleMemberships: a principal (owner.actor dot-form) is required" };
|
|
4733
|
+
try {
|
|
4734
|
+
assertLifecycleToken(uid);
|
|
4735
|
+
}
|
|
4736
|
+
catch (e) {
|
|
4737
|
+
return { ok: false, error: `lifecycleMemberships: ${e.message}` };
|
|
4738
|
+
}
|
|
4739
|
+
try {
|
|
4740
|
+
const channels = await listLifecycleMemberChannels(await this.membersRegistry(), principal, uid);
|
|
4741
|
+
return { ok: true, data: { complete: true, channels } };
|
|
4742
|
+
}
|
|
4743
|
+
catch (e) {
|
|
4744
|
+
return { ok: false, error: `lifecycleMemberships: the inventory read did not complete (${e.message})` };
|
|
4745
|
+
}
|
|
4746
|
+
}
|
|
4445
4747
|
if (req.op === "principalLiveness") {
|
|
4446
4748
|
// The freeze-holder liveness probe (#391): the READ-ONLY half of `evictPrincipal`. A repair
|
|
4447
4749
|
// that must REFUSE while the holder is alive cannot use eviction as its own precheck — that
|
|
@@ -4550,7 +4852,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4550
4852
|
m.ack();
|
|
4551
4853
|
return;
|
|
4552
4854
|
}
|
|
4553
|
-
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)) {
|
|
4554
4856
|
m.ack();
|
|
4555
4857
|
return;
|
|
4556
4858
|
} // authenticity (owner must be a real principal, not an old-shape alias)
|
|
@@ -4660,12 +4962,52 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4660
4962
|
m.ack();
|
|
4661
4963
|
return;
|
|
4662
4964
|
} // undecodable — drop
|
|
4965
|
+
if (!isRecord(entry)) {
|
|
4966
|
+
m.ack();
|
|
4967
|
+
return;
|
|
4968
|
+
} // non-object envelope — permanently invalid
|
|
4663
4969
|
const redeliveries = m.info?.deliveryCount ?? 1; // JsMsg delivery attempts (1 on first delivery)
|
|
4664
4970
|
// Lifecycle-exact ACL re-auth (SPEC 13.1): the entry was addressed to pr.lifecycleUid's inbox, so
|
|
4665
4971
|
// the row read is that lifecycle's exact key — a retired lifecycle's purged row reads as unknown
|
|
4666
|
-
// and its residual entries
|
|
4972
|
+
// and its residual entries are removed below, never authorized against the successor's row.
|
|
4667
4973
|
const acl = await this.plane3?.aclFor(owner, pr.lifecycleUid);
|
|
4668
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
|
+
}
|
|
4669
5011
|
// UNKNOWN owner — the manager has not (re)hydrated this owner's ACL yet (e.g. right after a
|
|
4670
5012
|
// manager PROCESS restart). This is NOT a revocation: DEFER (redeliver), never drop — an ack here
|
|
4671
5013
|
// would lose at-least-once on restart (impl-review BLOCKER-2). A delayed nak + a redelivery
|
|
@@ -4847,7 +5189,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4847
5189
|
}
|
|
4848
5190
|
catch (e) {
|
|
4849
5191
|
if (attempt === 0)
|
|
4850
|
-
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})`));
|
|
4851
5193
|
await new Promise((r) => setTimeout(r, Math.min(30_000, 1000 * 2 ** attempt)));
|
|
4852
5194
|
}
|
|
4853
5195
|
}
|
|
@@ -5041,7 +5383,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5041
5383
|
// server policed who could publish. The payload `from` is advisory — it must match,
|
|
5042
5384
|
// and a missing `from` or an unparseable subject on a delivery is itself an anomaly.
|
|
5043
5385
|
// Reject (term — a spoof is permanently invalid, never redeliver) BEFORE any handler.
|
|
5044
|
-
if (!isUsableMessageId(msg.id)) {
|
|
5386
|
+
if (!isRecord(msg) || !isUsableMessageId(msg.id)) {
|
|
5045
5387
|
m.term(); // malformed envelope (SPEC sec 5): absent/non-string id — permanently invalid
|
|
5046
5388
|
this.emit("error", new Error(`dropped message on ${m.subject}: absent or non-string id`));
|
|
5047
5389
|
continue;
|
|
@@ -5136,7 +5478,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5136
5478
|
this.emit("error", e);
|
|
5137
5479
|
return;
|
|
5138
5480
|
}
|
|
5139
|
-
if (!isUsableMessageId(msg.id))
|
|
5481
|
+
if (!isRecord(msg) || !isUsableMessageId(msg.id))
|
|
5140
5482
|
return; // malformed envelope (SPEC sec 5) — live is at-most-once: drop
|
|
5141
5483
|
if (!msg.from || msg.from.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner))
|
|
5142
5484
|
return; // spoof/malformed/old-shape-alias — drop (at-most-once)
|
|
@@ -5372,7 +5714,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5372
5714
|
continue; // skip undecodable
|
|
5373
5715
|
}
|
|
5374
5716
|
// Same authenticity guard as the tail; skip our own echoes in history.
|
|
5375
|
-
if (!isUsableMessageId(msg.id))
|
|
5717
|
+
if (!isRecord(msg) || !isUsableMessageId(msg.id))
|
|
5376
5718
|
continue; // malformed envelope (SPEC sec 5) — history skips
|
|
5377
5719
|
const parsed = parseSubject(sm.subject);
|
|
5378
5720
|
if (!parsed || msg.from?.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner) || msg.from.id === this.card.id)
|
|
@@ -5389,7 +5731,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5389
5731
|
* not a push into context) plus `dropped: true` when the window is not complete: either the
|
|
5390
5732
|
* channel's earliest *retained* message is already newer than the watermark (some ambient aged
|
|
5391
5733
|
* out of the per-subject window), or replay is off for the channel below. Either way the caller
|
|
5392
|
-
* 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.
|
|
5393
5738
|
*
|
|
5394
5739
|
* Honors the **same** per-channel replay gate as join-backfill ({@link joinPolicyFresh}): a
|
|
5395
5740
|
* `replay=off` channel returns no messages, so `focus` can't become a history bypass for a
|
|
@@ -5403,10 +5748,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5403
5748
|
if (!this.jsm)
|
|
5404
5749
|
throw new Error(this.notLiveMsg());
|
|
5405
5750
|
if (!isConcreteChannel(channel))
|
|
5406
|
-
return { messages: [], dropped: false };
|
|
5751
|
+
return { messages: [], seqs: [], dropped: false, unanswered: true };
|
|
5407
5752
|
const policy = await this.joinPolicyFresh(channel);
|
|
5408
5753
|
if (!policy.replay)
|
|
5409
|
-
return { messages: [], dropped: true };
|
|
5754
|
+
return { messages: [], seqs: [], dropped: true, unanswered: true };
|
|
5410
5755
|
const subject = chatSubject(this.space, "*", "*", channel);
|
|
5411
5756
|
let raw;
|
|
5412
5757
|
try {
|
|
@@ -5416,9 +5761,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5416
5761
|
this.emit("error", e);
|
|
5417
5762
|
if (isPermissionDenied(e))
|
|
5418
5763
|
throw e;
|
|
5419
|
-
|
|
5764
|
+
return { messages: [], seqs: [], dropped: true, unanswered: true };
|
|
5420
5765
|
}
|
|
5421
5766
|
const collected = [];
|
|
5767
|
+
const seqs = [];
|
|
5422
5768
|
for (const sm of raw) {
|
|
5423
5769
|
let msg;
|
|
5424
5770
|
try {
|
|
@@ -5428,15 +5774,16 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5428
5774
|
continue; // skip undecodable
|
|
5429
5775
|
}
|
|
5430
5776
|
// Same authenticity guard as the tail/backfill; skip our own echoes.
|
|
5431
|
-
if (!isUsableMessageId(msg.id))
|
|
5777
|
+
if (!isRecord(msg) || !isUsableMessageId(msg.id))
|
|
5432
5778
|
continue; // malformed envelope (SPEC sec 5) — recall skips
|
|
5433
5779
|
const parsed = parseSubject(sm.subject);
|
|
5434
5780
|
if (!parsed || msg.from?.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner) || msg.from.id === this.card.id)
|
|
5435
5781
|
continue;
|
|
5436
5782
|
collected.push(authenticatedMessage(msg, parsed));
|
|
5783
|
+
seqs.push(sm.seq);
|
|
5437
5784
|
}
|
|
5438
5785
|
const dropped = await this.channelDropped(subject, sinceSeq);
|
|
5439
|
-
return { messages: collected, dropped };
|
|
5786
|
+
return { messages: collected, seqs, dropped, unanswered: false };
|
|
5440
5787
|
}
|
|
5441
5788
|
/** Did focus recall on `subject` miss ambient that aged out past the watermark? Ambient is only
|
|
5442
5789
|
* ever discarded once a sender-subject reaches {@link MAX_MSGS_PER_SUBJECT} (`DiscardPolicy.Old`);
|
|
@@ -5491,6 +5838,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5491
5838
|
condition: this.condition,
|
|
5492
5839
|
environment: this.environment,
|
|
5493
5840
|
activity: this.activity,
|
|
5841
|
+
activeAt: this.activeAt,
|
|
5494
5842
|
attention: this.attentionMode,
|
|
5495
5843
|
channelModes: this.channelModes,
|
|
5496
5844
|
ts: Date.now(),
|
|
@@ -5950,22 +6298,22 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5950
6298
|
this.emit("roster", this.getRoster());
|
|
5951
6299
|
}
|
|
5952
6300
|
}
|
|
5953
|
-
/** Map an authenticated parsed-subject kind to the message class surfaced to "message" listeners.
|
|
5954
|
-
* Throws on `ctl` (control-plane is request/reply, never a "message") — per repo convention, no
|
|
5955
|
-
* silent default: an unexpected delivering kind is a bug, not something to swallow. */
|
|
5956
|
-
/** A usable delivery-message id (#624): a string, possibly empty (the never-a-key case), but
|
|
5957
|
-
* never absent and never a non-string. An absent or non-string id is a malformed envelope under
|
|
5958
|
-
* SPEC sec 5; each delivery pump handles it per its own class (durable term, live drop, history
|
|
5959
|
-
* skip) so it never reaches the receiver's id-keyed machinery as `undefined`. */
|
|
5960
6301
|
/** What a history read failure NAMES when it could not finish. One filter subject is the useful
|
|
5961
6302
|
* thing to print; a set of sixty-nine of them is a wall of text in a message a human has to read,
|
|
5962
6303
|
* so a set says its size and the stream it was read from instead. */
|
|
5963
6304
|
function subjectLabel(subjects) {
|
|
5964
6305
|
return subjects.length === 1 ? subjects[0] : `${subjects.length} filtered subjects`;
|
|
5965
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`. */
|
|
5966
6311
|
function isUsableMessageId(id) {
|
|
5967
6312
|
return typeof id === "string";
|
|
5968
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. */
|
|
5969
6317
|
function kindFromParsed(kind) {
|
|
5970
6318
|
switch (kind) {
|
|
5971
6319
|
case "chat":
|
|
@@ -6262,11 +6610,6 @@ function authOpts(a) {
|
|
|
6262
6610
|
}
|
|
6263
6611
|
return { token: a.token, user: a.user, pass: a.pass, tls };
|
|
6264
6612
|
}
|
|
6265
|
-
/** Decode the owner+actor PRINCIPAL from a user bearer WITHOUT verifying it — the client trusts its own
|
|
6266
|
-
* bearer only to build its subjects; the broker's minted grant (from the callout, which DOES verify the
|
|
6267
|
-
* bearer) is the real boundary, so a client that lied to itself would just be denied. Per the token
|
|
6268
|
-
* claim semantics the OWNER is the JWT `sub` (`act.owner` merely restates it) and the ACTOR is
|
|
6269
|
-
* `act.actor`. Throws on a structurally-unusable bearer (fail-loud). */
|
|
6270
6613
|
/** The bearer's `exp` as epoch ms — what the refresh schedule keys on. A bearer without a numeric
|
|
6271
6614
|
* `exp` is structurally unusable for a refreshing endpoint (fail-loud, like the principal decode). */
|
|
6272
6615
|
function bearerExpiryMs(bearer) {
|
|
@@ -6284,6 +6627,11 @@ function bearerExpiryMs(bearer) {
|
|
|
6284
6627
|
throw new Error("user-mode bearer is missing a numeric exp claim");
|
|
6285
6628
|
return claims.exp * 1000;
|
|
6286
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). */
|
|
6287
6635
|
function decodeBearerPrincipal(bearer) {
|
|
6288
6636
|
const payload = bearer.split(".")[1];
|
|
6289
6637
|
if (!payload)
|