@cotal-ai/core 0.10.1 → 0.11.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/agent-file.d.ts +10 -1
- package/dist/agent-file.d.ts.map +1 -1
- package/dist/agent-file.js +65 -80
- package/dist/agent-file.js.map +1 -1
- package/dist/auth-provider.d.ts +207 -0
- package/dist/auth-provider.d.ts.map +1 -0
- package/dist/auth-provider.js +13 -0
- package/dist/auth-provider.js.map +1 -0
- package/dist/channels.d.ts +10 -0
- package/dist/channels.d.ts.map +1 -1
- package/dist/channels.js +9 -27
- package/dist/channels.js.map +1 -1
- package/dist/command.d.ts +6 -1
- package/dist/command.d.ts.map +1 -1
- package/dist/command.js +5 -1
- package/dist/command.js.map +1 -1
- package/dist/connector-config.js +1 -1
- package/dist/connector.d.ts +16 -0
- package/dist/connector.d.ts.map +1 -1
- package/dist/endpoint.d.ts +148 -17
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +470 -97
- package/dist/endpoint.js.map +1 -1
- package/dist/evict.d.ts +87 -0
- package/dist/evict.d.ts.map +1 -0
- package/dist/evict.js +231 -0
- package/dist/evict.js.map +1 -0
- package/dist/identity.d.ts +15 -0
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +26 -2
- package/dist/identity.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/launch.d.ts +6 -0
- package/dist/launch.d.ts.map +1 -1
- package/dist/members.d.ts.map +1 -1
- package/dist/members.js +5 -2
- package/dist/members.js.map +1 -1
- package/dist/membership-feed.d.ts +17 -3
- package/dist/membership-feed.d.ts.map +1 -1
- package/dist/membership-feed.js +41 -13
- package/dist/membership-feed.js.map +1 -1
- package/dist/provision.d.ts +99 -14
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +267 -128
- package/dist/provision.js.map +1 -1
- package/dist/resolve.js +1 -1
- package/dist/secret-fs.d.ts +9 -0
- package/dist/secret-fs.d.ts.map +1 -1
- package/dist/secret-fs.js +26 -2
- package/dist/secret-fs.js.map +1 -1
- package/dist/streams.d.ts +33 -7
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +53 -30
- package/dist/streams.js.map +1 -1
- package/dist/subjects.d.ts +267 -75
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +401 -110
- package/dist/subjects.js.map +1 -1
- package/dist/types.d.ts +13 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -2
package/dist/endpoint.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { createConnection } from "node:net";
|
|
4
|
-
import { connect, credsAuthenticator, nanos, AuthorizationError, PermissionViolationError, UserAuthenticationExpiredError, NoRespondersError, RequestError, } from "@nats-io/transport-node";
|
|
5
|
-
import { idFromCreds } from "./identity.js";
|
|
4
|
+
import { connect, credsAuthenticator, tokenAuthenticator, nanos, AuthorizationError, PermissionViolationError, UserAuthenticationExpiredError, NoRespondersError, RequestError, } from "@nats-io/transport-node";
|
|
5
|
+
import { credsClaims, idFromCreds } from "./identity.js";
|
|
6
|
+
import { inspectCredHealth } from "./provision.js";
|
|
6
7
|
import { assertValidName } from "./resolve.js";
|
|
7
8
|
import { createSpaceStreams, dmDurableConfig, dlvDurableConfig, taskDurableConfig, fanoutDurableConfig, inboxReaderConfig, MAX_MSGS_PER_SUBJECT, MANAGER_LEASE_TTL_MS } from "./streams.js";
|
|
8
9
|
import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, } from "@nats-io/jetstream";
|
|
@@ -11,7 +12,7 @@ import { openMembersRegistry, commitMember, tombstoneMember, activateMember, rea
|
|
|
11
12
|
import { openAclRegistry, readAcl, commitAcl as writeAclRecord } from "./acls.js";
|
|
12
13
|
import { openDeliveryRegistry } from "./lease.js";
|
|
13
14
|
import { openChannelRegistry, effectiveReplay, effectiveReplayWindowMs, effectiveDeliveryClass, readChannelConfig, readChannelDefaults, } from "./channels.js";
|
|
14
|
-
import { anycastSubject, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, controlServiceSubject, CONTROL_SELF_SERVICE, CONTROL_DELIVERY, dmStream, dmDurable, dlvStream, dlvDurable, dlvSubject, dinboxSubject, inboxStream,
|
|
15
|
+
import { anycastSubject, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, controlServiceSubject, CONTROL_SELF_SERVICE, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, dmStream, dmDurable, dlvStream, dlvDurable, dlvSubject, dinboxSubject, inboxStream, parseDinboxPrincipal, FANOUT_DURABLE, INBOX_READER_DURABLE, leaseKey, managerBucket, MANAGER_LEASE_KEY, chatWildcard, assertValidChannel, channelInAllow, isConcreteChannel, normalizeMentions, parseSubject, isPrincipalOwnerToken, assertInboxConnId, presenceBucket, membershipBucket, MEMBERSHIP_FEED_KEY, principalKey, parsePrincipalKey, DEV_OWNER, spacePrefix, spaceWildcard, subjectMatches, taskStream, taskDurable, token, unicastSubject, unicastRecvFilter, } from "./subjects.js";
|
|
15
16
|
export const DEFAULT_SERVER = "nats://127.0.0.1:4222";
|
|
16
17
|
/** Space joined when none is given on the CLI (the `cotal-<space>` cmux tab, etc.). */
|
|
17
18
|
export const DEFAULT_SPACE = "main";
|
|
@@ -39,7 +40,20 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
39
40
|
token;
|
|
40
41
|
user;
|
|
41
42
|
pass;
|
|
42
|
-
creds;
|
|
43
|
+
/** The creds source, when standing renewal is on (bounded supervisor/daemon creds); undefined for
|
|
44
|
+
* a static string. Mirrors {@link bearerSource} exactly — same fetch-ahead + pin + retry shape. */
|
|
45
|
+
credsSource;
|
|
46
|
+
/** The freshest creds — what every (re)connect attempt presents. Static callers set it once. */
|
|
47
|
+
currentCreds;
|
|
48
|
+
credsTimer;
|
|
49
|
+
/** True in user mode (bearer string OR source) — gates the callout-shaped connect. */
|
|
50
|
+
userMode = false;
|
|
51
|
+
/** The bearer source, when auth refreshes (spawned agents); undefined for a one-shot string. */
|
|
52
|
+
bearerSource;
|
|
53
|
+
/** The freshest bearer — what every (re)connect attempt presents. */
|
|
54
|
+
currentBearer;
|
|
55
|
+
bearerTimer;
|
|
56
|
+
sentinelCreds;
|
|
43
57
|
tls;
|
|
44
58
|
heartbeatMs;
|
|
45
59
|
ttlMs;
|
|
@@ -60,10 +74,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
60
74
|
aclKv;
|
|
61
75
|
deliveryKv;
|
|
62
76
|
managerLeaseKv;
|
|
63
|
-
|
|
77
|
+
membershipFeedKv;
|
|
64
78
|
/** The live `ctl.delivery` serve subscription (delivery daemon) — re-created on every (re)connect by
|
|
65
79
|
* {@link armDeliveryControl}; tracked so the stale one is dropped on reconnect. */
|
|
66
80
|
deliveryServeSub;
|
|
81
|
+
deliveryAdminServeSub;
|
|
67
82
|
/** When set, this endpoint hosts the Plane-3 fan-out writer + trusted reader (the server-side delivery
|
|
68
83
|
* daemon). `aclFor` maps an owner id to its current read ACL (`allowSubscribe`) for the reader's
|
|
69
84
|
* re-authorization — read FRESH per entry from the durable ACL registry KV, hence async. */
|
|
@@ -141,6 +156,15 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
141
156
|
backoffResolve;
|
|
142
157
|
backoffTimer;
|
|
143
158
|
retryMs = 3000;
|
|
159
|
+
/** The connection's authenticated nkey — dev: the creds' identity; user mode: the per-connection
|
|
160
|
+
* ephemeral. Distinct from the {@link owner}+{@link actor} principal: it names the CONNECTION (the
|
|
161
|
+
* broker-authenticated user), and scopes the private reply inbox (`_INBOX_<connId>`) + the credId
|
|
162
|
+
* equality check. The principal (owner+actor) is what the WIRE grammar and every per-agent key use. */
|
|
163
|
+
connId;
|
|
164
|
+
/** This endpoint's owner token (principal half 1) — `"local"` in the dev default. */
|
|
165
|
+
owner;
|
|
166
|
+
/** This endpoint's actor token (principal half 2) — the connection id in the dev default. */
|
|
167
|
+
actor;
|
|
144
168
|
constructor(opts) {
|
|
145
169
|
super();
|
|
146
170
|
this.space = opts.space;
|
|
@@ -148,20 +172,74 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
148
172
|
// (the future owner/name separator) and surrounding whitespace at the one identity choke
|
|
149
173
|
// point every join/spawn path flows through.
|
|
150
174
|
assertValidName(opts.card.name);
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
175
|
+
// Auth mode is EITHER static creds (dev/no-login) OR a user bearer (login → callout) — never both.
|
|
176
|
+
if (opts.bearer) {
|
|
177
|
+
if (opts.creds || opts.token || opts.user || opts.pass)
|
|
178
|
+
throw new Error("bearer (user-mode auth) is mutually exclusive with creds/token/user/pass");
|
|
179
|
+
if (!opts.sentinelCreds)
|
|
180
|
+
throw new Error("user-mode bearer requires sentinelCreds (the shared auth-account creds presented alongside it)");
|
|
181
|
+
}
|
|
182
|
+
if (opts.bearer) {
|
|
183
|
+
// USER MODE. The owner+actor PRINCIPAL comes from the bearer (server-authored: owner is callout-
|
|
184
|
+
// derived, actor is the spawn-ledger actor) — never from the card. The connection nkey is minted
|
|
185
|
+
// per-connect by NATS and is unknown to the client pre-connect, so the client cannot key its inbox
|
|
186
|
+
// on it; instead it picks its OWN random inbox NONCE (the connId), passes it as the connect `name`,
|
|
187
|
+
// and the callout scopes `_INBOX_<connId>.>` on that. `card.id`, if given, must match the bearer.
|
|
188
|
+
this.userMode = true;
|
|
189
|
+
if (typeof opts.bearer === "function") {
|
|
190
|
+
// Bearer SOURCE: no token exists yet, so the principal must be declared up front; every
|
|
191
|
+
// fetched bearer is checked against it (refreshBearer), keeping the card honest for life.
|
|
192
|
+
if (!opts.card.owner || !opts.card.actor)
|
|
193
|
+
throw new Error("a bearer source requires explicit card.owner + card.actor (no bearer to derive them from at construction)");
|
|
194
|
+
this.owner = opts.card.owner;
|
|
195
|
+
this.actor = opts.card.actor;
|
|
196
|
+
this.bearerSource = opts.bearer;
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
const claims = decodeBearerPrincipal(opts.bearer);
|
|
200
|
+
if (opts.card.owner && opts.card.owner !== claims.owner)
|
|
201
|
+
throw new Error(`card.owner ${opts.card.owner} != bearer owner ${claims.owner}`);
|
|
202
|
+
if (opts.card.actor && opts.card.actor !== claims.actor)
|
|
203
|
+
throw new Error(`card.actor ${opts.card.actor} != bearer actor ${claims.actor}`);
|
|
204
|
+
this.owner = claims.owner;
|
|
205
|
+
this.actor = claims.actor;
|
|
206
|
+
this.currentBearer = opts.bearer;
|
|
207
|
+
}
|
|
208
|
+
this.connId = assertInboxConnId(`ibx${randomUUID().replace(/-/g, "")}`);
|
|
209
|
+
this.sentinelCreds = opts.sentinelCreds;
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
// DEV / STATIC. Connection identity precedence: an explicit card.id, else the creds' identity, else
|
|
213
|
+
// a random (dash-free, valid-actor-token) id. When both an id and creds are given they MUST name the
|
|
214
|
+
// same nkey — else the connection would authenticate as one user while its grants name another. The
|
|
215
|
+
// owner+actor PRINCIPAL defaults to owner = DEV_OWNER ("local"), actor = the connection id.
|
|
216
|
+
if (typeof opts.creds === "function") {
|
|
217
|
+
// Creds SOURCE (standing renewal): no cred exists yet, so the identity must be declared up
|
|
218
|
+
// front; every fetched cred is checked against it (refreshCreds), so a renewal can never
|
|
219
|
+
// silently swap the connection's nkey.
|
|
220
|
+
if (!opts.card.id)
|
|
221
|
+
throw new Error("a creds source requires an explicit card.id (no cred to derive the identity from at construction)");
|
|
222
|
+
this.credsSource = opts.creds;
|
|
223
|
+
this.connId = opts.card.id;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
const credId = opts.creds ? idFromCreds(opts.creds) : undefined;
|
|
227
|
+
if (opts.card.id && credId && opts.card.id !== credId)
|
|
228
|
+
throw new Error(`card.id ${opts.card.id} != creds identity ${credId} - they must be the same nkey`);
|
|
229
|
+
this.currentCreds = opts.creds;
|
|
230
|
+
this.connId = opts.card.id ?? credId ?? randomUUID().replace(/-/g, "");
|
|
231
|
+
}
|
|
232
|
+
this.owner = opts.card.owner ?? DEV_OWNER;
|
|
233
|
+
this.actor = opts.card.actor ?? this.connId;
|
|
234
|
+
}
|
|
235
|
+
// `card.id` is the principal DOT-FORM `<owner>.<actor>` — the wire identity every `from.id` carries;
|
|
236
|
+
// principalKey validates both tokens.
|
|
237
|
+
const principal = principalKey(this.owner, this.actor);
|
|
238
|
+
this.card = { ...opts.card, id: principal.key, owner: this.owner, actor: this.actor };
|
|
160
239
|
this.servers = opts.servers ?? DEFAULT_SERVER;
|
|
161
240
|
this.token = opts.token;
|
|
162
241
|
this.user = opts.user;
|
|
163
242
|
this.pass = opts.pass;
|
|
164
|
-
this.creds = opts.creds;
|
|
165
243
|
this.tls = opts.tls ?? false;
|
|
166
244
|
this.channels = opts.channels ?? ["general"];
|
|
167
245
|
this.heartbeatMs = opts.heartbeatMs ?? 2000;
|
|
@@ -179,6 +257,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
179
257
|
ref() {
|
|
180
258
|
return { id: this.card.id, name: this.card.name, role: this.card.role };
|
|
181
259
|
}
|
|
260
|
+
/** True on any AUTHED broker (static creds OR user-mode bearer) — the gate every open-vs-auth
|
|
261
|
+
* branch keys on: authed endpoints OPEN pre-created streams/KVs and BIND pre-provisioned
|
|
262
|
+
* durables (creates are denied to agents); only the open dev broker lazy-creates. */
|
|
263
|
+
get authed() {
|
|
264
|
+
return Boolean(this.currentCreds || this.credsSource) || this.userMode;
|
|
265
|
+
}
|
|
182
266
|
async start() {
|
|
183
267
|
await this.connectAndBind();
|
|
184
268
|
// nats.js auto-reconnects transient drops; when it exhausts its attempts and the
|
|
@@ -187,22 +271,143 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
187
271
|
// connect — a first-connect failure throws to the caller's connect-retry loop instead.
|
|
188
272
|
this.superviseConnection();
|
|
189
273
|
}
|
|
274
|
+
/** How far ahead of the current bearer's `exp` a refresh fires, and how soon a FAILED refresh
|
|
275
|
+
* retries. The margin must clear a reconnect window (nats.js retries use the sync token getter,
|
|
276
|
+
* so whatever `currentBearer` holds is what every attempt presents). */
|
|
277
|
+
static BEARER_REFRESH_MARGIN_MS = 60_000;
|
|
278
|
+
static BEARER_RETRY_MS = 15_000;
|
|
279
|
+
/** Fetch a fresh bearer from the source, pin its principal to ours, arm the next refresh. On a
|
|
280
|
+
* fetch/principal failure: THROWS when `initial` (start() must fail loud before first connect);
|
|
281
|
+
* otherwise emits "error" and retries — the live connection keeps working until its current JWT
|
|
282
|
+
* expiry, so a dead auth service is loud without instantly dropping the mesh. */
|
|
283
|
+
async refreshBearer(initial = false) {
|
|
284
|
+
try {
|
|
285
|
+
const bearer = await this.bearerSource();
|
|
286
|
+
const claims = decodeBearerPrincipal(bearer);
|
|
287
|
+
if (claims.owner !== this.owner || claims.actor !== this.actor)
|
|
288
|
+
throw new Error(`bearer source returned principal ${claims.owner}.${claims.actor}, expected ${this.owner}.${this.actor}`);
|
|
289
|
+
this.currentBearer = bearer;
|
|
290
|
+
this.armBearerRefresh(bearerExpiryMs(bearer) - Date.now() - CotalEndpoint.BEARER_REFRESH_MARGIN_MS);
|
|
291
|
+
}
|
|
292
|
+
catch (e) {
|
|
293
|
+
if (initial)
|
|
294
|
+
throw e;
|
|
295
|
+
this.emit("error", new Error(`bearer refresh failed (${e instanceof Error ? e.message : String(e)}) - retrying; this connection dies at its current token's expiry if the auth service stays down`));
|
|
296
|
+
this.armBearerRefresh(CotalEndpoint.BEARER_RETRY_MS);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
armBearerRefresh(delayMs) {
|
|
300
|
+
if (this.stopped)
|
|
301
|
+
return;
|
|
302
|
+
clearTimeout(this.bearerTimer);
|
|
303
|
+
this.bearerTimer = setTimeout(() => void this.refreshBearer(), Math.max(5_000, delayMs));
|
|
304
|
+
this.bearerTimer.unref?.();
|
|
305
|
+
}
|
|
306
|
+
/** How soon a FAILED creds refresh retries. Successful refreshes schedule by lifetime fraction
|
|
307
|
+
* (75% of iat→exp), not a fixed margin — standing creds span hours to days, bearers minutes. */
|
|
308
|
+
static CREDS_RETRY_MS = 60_000;
|
|
309
|
+
/** Fetch fresh creds from the source, pin their identity to ours, cache them, and arm the next
|
|
310
|
+
* refresh at 75% of the new JWT's lifetime. THROWS on fetch/pin failure — the callers decide the
|
|
311
|
+
* failure posture (loud-and-retry for the timer, a structured error reply for an explicit
|
|
312
|
+
* {@link reloadCreds}). */
|
|
313
|
+
async fetchFreshCreds() {
|
|
314
|
+
const creds = await this.credsSource();
|
|
315
|
+
const id = idFromCreds(creds);
|
|
316
|
+
if (id !== this.connId)
|
|
317
|
+
throw new Error(`creds source returned identity ${id}, expected ${this.connId} - renewal may not swap the connection's nkey`);
|
|
318
|
+
this.currentCreds = creds;
|
|
319
|
+
this.armCredsRefresh(credsRenewalDelayMs(creds));
|
|
320
|
+
const { iat, exp } = credsClaims(creds);
|
|
321
|
+
return { iat, exp };
|
|
322
|
+
}
|
|
323
|
+
/** Swap the live connection onto the freshest cached cred with a controlled `nc.reconnect()`
|
|
324
|
+
* (nats.js re-evaluates the creds getter per attempt). Swapping now, instead of waiting for the
|
|
325
|
+
* broker to close the connection at `exp`, means the wire never carries a near-dead JWT and the
|
|
326
|
+
* operator never sees a spurious "authentication expired" — the broker's expiry-close remains the
|
|
327
|
+
* BACKSTOP if a swap is missed, not the mechanism. Already-closed/draining rejections are the
|
|
328
|
+
* supervise loop's to own (its rebuild re-fetches); an already-disconnected client is a no-op
|
|
329
|
+
* (its own reconnect loop presents the fresh cred). */
|
|
330
|
+
async swapConnectionOntoFreshCreds() {
|
|
331
|
+
if (this.nc && !this.stopped)
|
|
332
|
+
await this.nc.reconnect().catch(() => { });
|
|
333
|
+
}
|
|
334
|
+
/** The 75%-of-lifetime renewal timer tick (and rebuild re-fetch) — the passive BACKSTOP behind the
|
|
335
|
+
* explicit {@link reloadCreds}. Failure shape mirrors {@link refreshBearer}: THROWS when
|
|
336
|
+
* `initial`, else emits "error" and retries. */
|
|
337
|
+
async refreshCreds(initial = false) {
|
|
338
|
+
try {
|
|
339
|
+
await this.fetchFreshCreds();
|
|
340
|
+
if (!initial)
|
|
341
|
+
await this.swapConnectionOntoFreshCreds();
|
|
342
|
+
}
|
|
343
|
+
catch (e) {
|
|
344
|
+
if (initial)
|
|
345
|
+
throw e;
|
|
346
|
+
this.emit("error", new Error(`creds refresh failed (${e instanceof Error ? e.message : String(e)}) - retrying; this connection dies at its current JWT's expiry if renewal keeps failing`));
|
|
347
|
+
this.armCredsRefresh(CotalEndpoint.CREDS_RETRY_MS);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/** EXPLICIT credential reload — the auditable adoption step of D5 class-2 standing renewal (served
|
|
351
|
+
* to the renewal owner via the delivery-admin rail). Re-invokes the source NOW, pins + adopts the
|
|
352
|
+
* fresh cred on the live connection, and returns the adopted JWT's window so the caller can record
|
|
353
|
+
* proof of adoption. THROWS (structured for the reply) when the source fails — e.g. the creds file
|
|
354
|
+
* was not actually re-signed — so "file written" can never masquerade as "daemon adopted". */
|
|
355
|
+
async reloadCreds() {
|
|
356
|
+
if (!this.credsSource)
|
|
357
|
+
throw new Error("reloadCreds: this endpoint has no creds source (a static cred cannot be renewed in place)");
|
|
358
|
+
const { iat, exp } = await this.fetchFreshCreds();
|
|
359
|
+
await this.swapConnectionOntoFreshCreds();
|
|
360
|
+
return { identity: this.connId, iat, exp };
|
|
361
|
+
}
|
|
362
|
+
armCredsRefresh(delayMs) {
|
|
363
|
+
if (this.stopped)
|
|
364
|
+
return;
|
|
365
|
+
clearTimeout(this.credsTimer);
|
|
366
|
+
// 1s floor (vs the bearer's 5s): standing-renewal smokes exercise second-scale TTLs; production
|
|
367
|
+
// lifetimes are hours+ so the floor never engages there.
|
|
368
|
+
this.credsTimer = setTimeout(() => void this.refreshCreds(), Math.max(1_000, delayMs));
|
|
369
|
+
this.credsTimer.unref?.();
|
|
370
|
+
}
|
|
190
371
|
/** Open the connection and bind everything that hangs off it: status watch, presence
|
|
191
372
|
* watch + heartbeat, channel registry, and the durable consumers. Re-runnable — a
|
|
192
373
|
* reconnect calls it again after {@link clearConnectionScoped}; every binding is
|
|
193
374
|
* idempotent (durables bind by name, JetStream dedups by msgID, KV opens are idempotent). */
|
|
194
375
|
async connectAndBind() {
|
|
195
376
|
this.clearConnectionScoped();
|
|
377
|
+
// Bearer-source endpoints fetch before the FIRST connect, and re-fetch on a rebuild whose
|
|
378
|
+
// cached token is already inside the refresh margin (a rebuild after a long outage would
|
|
379
|
+
// otherwise present a dead bearer for its first attempts).
|
|
380
|
+
if (this.bearerSource) {
|
|
381
|
+
const stale = !this.currentBearer ||
|
|
382
|
+
bearerExpiryMs(this.currentBearer) - Date.now() < CotalEndpoint.BEARER_REFRESH_MARGIN_MS;
|
|
383
|
+
if (stale)
|
|
384
|
+
await this.refreshBearer(!this.currentBearer);
|
|
385
|
+
}
|
|
386
|
+
// Creds-source endpoints likewise fetch before the FIRST connect, and re-fetch on a rebuild
|
|
387
|
+
// whose cached cred is expired or inside its renewal window.
|
|
388
|
+
if (this.credsSource) {
|
|
389
|
+
const stale = !this.currentCreds || credsRenewalDelayMs(this.currentCreds) <= 0;
|
|
390
|
+
if (stale)
|
|
391
|
+
await this.refreshCreds(!this.currentCreds);
|
|
392
|
+
}
|
|
196
393
|
this.nc = await connect({
|
|
197
394
|
servers: this.servers,
|
|
198
|
-
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
//
|
|
202
|
-
// (
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
395
|
+
// In USER MODE the connection `name` carries the client-chosen inbox nonce (= connId) the callout
|
|
396
|
+
// scopes `_INBOX_<connId>.>` on (see EndpointOptions.bearer); otherwise it's the display handle.
|
|
397
|
+
name: this.userMode ? this.connId : `cotal:${this.card.name}`,
|
|
398
|
+
// Per-CONNECTION inbox namespace (the "Private Inbox" pattern), keyed on the connection nkey
|
|
399
|
+
// (NOT the owner+actor principal): the reply inbox is per-connection plumbing, and under the auth
|
|
400
|
+
// callout the principal is unknown to the client pre-connect (owner is derived server-side) while
|
|
401
|
+
// the connection id always is. nats.js routes ALL generated inboxes — request replies, JetStream
|
|
402
|
+
// pull delivery, kv.watch ordered-consumer delivery — through this prefix. Paired with
|
|
403
|
+
// sub.allow=[_INBOX_<connId>.>] it stops a peer from subscribing the wildcard inbox to sniff
|
|
404
|
+
// others' DM deliveries. Set unconditionally so the prefix can never drift from the ACL.
|
|
405
|
+
inboxPrefix: `_INBOX_${this.connId}`,
|
|
406
|
+
// The bearer rides a GETTER: nats.js re-evaluates the token authenticator per (re)connect
|
|
407
|
+
// attempt, so internal reconnects present whatever refreshBearer last fetched.
|
|
408
|
+
// Creds likewise ride a GETTER when a source renews them, so internal reconnects (incl. the
|
|
409
|
+
// one the broker forces at JWT `exp`) present whatever refreshCreds last fetched.
|
|
410
|
+
...authOpts({ token: this.token, user: this.user, pass: this.pass, creds: this.credsSource ? () => this.currentCreds : this.currentCreds, bearer: this.userMode ? () => this.currentBearer : undefined, sentinelCreds: this.sentinelCreds, tls: this.tls }),
|
|
206
411
|
});
|
|
207
412
|
this.watchStatus();
|
|
208
413
|
this.js = jetstream(this.nc);
|
|
@@ -210,7 +415,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
210
415
|
const kvm = new Kvm(this.nc);
|
|
211
416
|
// The presence bucket is a JetStream stream. Open mode lazily creates it; auth mode
|
|
212
417
|
// OPENs it (it's pre-created at `cotal up`; KV stream-create is denied to agents).
|
|
213
|
-
this.kv = this.
|
|
418
|
+
this.kv = this.authed
|
|
214
419
|
? await kvm.open(presenceBucket(this.space))
|
|
215
420
|
: await kvm.create(presenceBucket(this.space), { ttl: this.ttlMs });
|
|
216
421
|
}
|
|
@@ -225,7 +430,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
225
430
|
// pre-created at `cotal up`; open mode lazily creates it.
|
|
226
431
|
const watchChannels = this.doWatch && this.doWatchChannels;
|
|
227
432
|
if (watchChannels || this.doConsume) {
|
|
228
|
-
this.channelKv = await openChannelRegistry(this.nc, this.space, { create: !this.
|
|
433
|
+
this.channelKv = await openChannelRegistry(this.nc, this.space, { create: !this.authed });
|
|
229
434
|
if (watchChannels)
|
|
230
435
|
await this.startChannelWatch();
|
|
231
436
|
}
|
|
@@ -239,7 +444,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
239
444
|
this.jsm = await jetstreamManager(this.nc);
|
|
240
445
|
// Open mode: lazily create the streams on the first endpoint. Auth mode: they are
|
|
241
446
|
// pre-created at `cotal up` and STREAM.CREATE is denied to agents, so skip.
|
|
242
|
-
if (!this.
|
|
447
|
+
if (!this.authed)
|
|
243
448
|
await this.ensureStreams();
|
|
244
449
|
await this.startConsumers();
|
|
245
450
|
}
|
|
@@ -323,7 +528,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
323
528
|
if (this.nc !== nc)
|
|
324
529
|
return; // epoch-stale — a rebuild already swapped this connection
|
|
325
530
|
this.emit("connection", { connected: false }); // dropped — report it before the rebuild kicks in
|
|
326
|
-
this.emit("error", new Error(`mesh connection closed${err ? `: ${err.message}` : ""}
|
|
531
|
+
this.emit("error", new Error(`mesh connection closed${err ? `: ${err.message}` : ""} - re-establishing`));
|
|
327
532
|
void this.reestablishLoop();
|
|
328
533
|
});
|
|
329
534
|
}
|
|
@@ -425,7 +630,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
425
630
|
* can report it. */
|
|
426
631
|
async reconnect() {
|
|
427
632
|
if (this.stopped)
|
|
428
|
-
throw new Error("endpoint stopped
|
|
633
|
+
throw new Error("endpoint stopped - cannot reconnect");
|
|
429
634
|
this.kickBackoff();
|
|
430
635
|
try {
|
|
431
636
|
await this.rebuild();
|
|
@@ -446,6 +651,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
446
651
|
clearInterval(this.heartbeatTimer);
|
|
447
652
|
if (this.sweepTimer)
|
|
448
653
|
clearInterval(this.sweepTimer);
|
|
654
|
+
if (this.bearerTimer)
|
|
655
|
+
clearTimeout(this.bearerTimer);
|
|
656
|
+
if (this.credsTimer)
|
|
657
|
+
clearTimeout(this.credsTimer);
|
|
449
658
|
for (const msgs of this.streamMsgs) {
|
|
450
659
|
try {
|
|
451
660
|
msgs.stop();
|
|
@@ -478,7 +687,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
478
687
|
// itself be a wildcard subscription like `team.>`).
|
|
479
688
|
const channel = opts?.channel ?? this.channels.find(isConcreteChannel) ?? "general";
|
|
480
689
|
if (!isConcreteChannel(channel))
|
|
481
|
-
throw new Error(`cannot publish to wildcard channel "${channel}"
|
|
690
|
+
throw new Error(`cannot publish to wildcard channel "${channel}" - pick a concrete sub-channel`);
|
|
482
691
|
const msg = {
|
|
483
692
|
id: randomUUID(),
|
|
484
693
|
ts: Date.now(),
|
|
@@ -492,7 +701,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
492
701
|
replyTo: opts?.replyTo,
|
|
493
702
|
contextId: opts?.contextId,
|
|
494
703
|
};
|
|
495
|
-
await this.publishMsg(chatSubject(this.space, this.
|
|
704
|
+
await this.publishMsg(chatSubject(this.space, this.owner, this.actor, channel), msg);
|
|
496
705
|
return msg;
|
|
497
706
|
}
|
|
498
707
|
/** Unicast: direct message to one specific instance. */
|
|
@@ -507,7 +716,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
507
716
|
replyTo: opts?.replyTo,
|
|
508
717
|
contextId: opts?.contextId,
|
|
509
718
|
};
|
|
510
|
-
|
|
719
|
+
// The recipient id is a principal dot-form `<owner>.<actor>` (owner+actor grammar); the 4-token DM
|
|
720
|
+
// subject forge-locks recipient AND sender, so both are split into their tokens here.
|
|
721
|
+
const recip = parsePrincipalKey(instanceId);
|
|
722
|
+
if (!recip)
|
|
723
|
+
throw new Error(`unicast: "${instanceId}" is not a valid recipient principal <owner>.<actor>`);
|
|
724
|
+
await this.publishMsg(unicastSubject(this.space, recip.owner, recip.actor, this.owner, this.actor), msg);
|
|
511
725
|
return msg;
|
|
512
726
|
}
|
|
513
727
|
/** Anycast: deliver to ANY one instance of a service (role) — queue-group load balancing. */
|
|
@@ -522,7 +736,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
522
736
|
replyTo: opts?.replyTo,
|
|
523
737
|
contextId: opts?.contextId,
|
|
524
738
|
};
|
|
525
|
-
await this.publishMsg(anycastSubject(this.space, service, this.
|
|
739
|
+
await this.publishMsg(anycastSubject(this.space, service, this.owner, this.actor), msg);
|
|
526
740
|
return msg;
|
|
527
741
|
}
|
|
528
742
|
/** Subscribe to a read-only observer feed. Defaults to the whole space; an observer under
|
|
@@ -561,7 +775,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
561
775
|
serveControl(service, handler, opts = {}) {
|
|
562
776
|
if (!this.nc)
|
|
563
777
|
throw new Error("endpoint not started");
|
|
564
|
-
const sub = this.nc.subscribe(controlServiceSubject(this.space, service, "*"), {
|
|
778
|
+
const sub = this.nc.subscribe(controlServiceSubject(this.space, service, "*", "*"), {
|
|
565
779
|
queue: service,
|
|
566
780
|
});
|
|
567
781
|
this.subs.push(sub);
|
|
@@ -581,10 +795,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
581
795
|
// the server policed who could publish; the payload `from` is advisory and must
|
|
582
796
|
// match. Reject before the handler acts on a request claiming a forged sender.
|
|
583
797
|
const parsed = parseSubject(m.subject);
|
|
584
|
-
if (!parsed || req.from?.id !== parsed.sender) {
|
|
798
|
+
if (!parsed || req.from?.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner)) {
|
|
585
799
|
this.emit("error", new Error(`rejected control request on ${m.subject}: from ${req.from?.id ?? "(none)"} ` +
|
|
586
800
|
`does not match subject sender ${parsed?.sender ?? "(unparseable)"}`));
|
|
587
|
-
reply = { ok: false, error: "sender mismatch
|
|
801
|
+
reply = { ok: false, error: "sender mismatch - request rejected" };
|
|
588
802
|
}
|
|
589
803
|
else {
|
|
590
804
|
reply = await handler(req);
|
|
@@ -618,7 +832,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
618
832
|
async requestControl(service, req, timeoutMs = 5000) {
|
|
619
833
|
if (!this.nc)
|
|
620
834
|
throw new Error(this.notLiveMsg());
|
|
621
|
-
const reqSubject = controlServiceSubject(this.space, service, this.
|
|
835
|
+
const reqSubject = controlServiceSubject(this.space, service, this.owner, this.actor);
|
|
622
836
|
const reply = `${reqSubject}.reply.${randomUUID()}`;
|
|
623
837
|
const body = { ...req, from: req.from ?? this.ref() };
|
|
624
838
|
const m = await this.nc.request(reqSubject, JSON.stringify(body), { timeout: timeoutMs, noMux: true, reply });
|
|
@@ -632,7 +846,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
632
846
|
async requestDelivery(op, args, timeoutMs = 5000) {
|
|
633
847
|
if (!this.nc)
|
|
634
848
|
throw new Error(this.notLiveMsg());
|
|
635
|
-
const reqSubject = controlServiceSubject(this.space, CONTROL_DELIVERY, this.
|
|
849
|
+
const reqSubject = controlServiceSubject(this.space, CONTROL_DELIVERY, this.owner, this.actor); // ctl.delivery.<owner>.<actor>
|
|
636
850
|
// Reply rides the sender's OWN subtree so the daemon's serveControl boundReply guard accepts it
|
|
637
851
|
// (`${reqSubject}.reply.…`). The sender-bound guard is the COMPLETE confused-deputy closure. The
|
|
638
852
|
// random suffix is genuine defense-in-depth (NOT cosmetic): `noMux` subscribes this SPECIFIC named
|
|
@@ -644,6 +858,21 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
644
858
|
const m = await this.nc.request(reqSubject, JSON.stringify(body), { timeout: timeoutMs, noMux: true, reply });
|
|
645
859
|
return m.json();
|
|
646
860
|
}
|
|
861
|
+
/** Send a PRIVILEGED delivery-admin request to the server-side delivery daemon and await its reply
|
|
862
|
+
* (the D5 rail-split: `reloadCreds` now, the eviction executor next). Same bounded-reply shape as
|
|
863
|
+
* {@link requestDelivery}; the cred layer is the real gate — only the manager's supervisor profile
|
|
864
|
+
* holds the request-publish grant, so an agent calling this gets a broker denial, not a handler
|
|
865
|
+
* refusal. NoResponders (no daemon) surfaces as the thrown request error — callers decide whether
|
|
866
|
+
* that degrades (renewal falls back to the daemon's 75% re-read backstop) or fails. */
|
|
867
|
+
async requestDeliveryAdmin(op, args, timeoutMs = 5000) {
|
|
868
|
+
if (!this.nc)
|
|
869
|
+
throw new Error(this.notLiveMsg());
|
|
870
|
+
const reqSubject = controlServiceSubject(this.space, CONTROL_DELIVERY_ADMIN, this.owner, this.actor);
|
|
871
|
+
const reply = `${reqSubject}.reply.${randomUUID()}`;
|
|
872
|
+
const body = { op, args, from: this.ref() };
|
|
873
|
+
const m = await this.nc.request(reqSubject, JSON.stringify(body), { timeout: timeoutMs, noMux: true, reply });
|
|
874
|
+
return m.json();
|
|
875
|
+
}
|
|
647
876
|
// ---- presence ------------------------------------------------------------
|
|
648
877
|
getRoster() {
|
|
649
878
|
return [...this.roster.values()].sort((a, b) => a.card.name.localeCompare(b.card.name));
|
|
@@ -734,7 +963,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
734
963
|
this.joinSeq.delete(channel);
|
|
735
964
|
throw new Error(`cannot join "${channel}": live subscription could not be confirmed (${e.message})`);
|
|
736
965
|
}
|
|
737
|
-
this.confirmingChatSubs.delete(chatSubject(this.space, "*", channel));
|
|
966
|
+
this.confirmingChatSubs.delete(chatSubject(this.space, "*", "*", channel));
|
|
738
967
|
if (this.chatSubDenied.has(channel)) {
|
|
739
968
|
this.unsubscribeChat(channel);
|
|
740
969
|
this.joinSeq.delete(channel);
|
|
@@ -786,7 +1015,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
786
1015
|
// tombstone can't be confirmed propagates (live sub stays up, mirror intact) for the caller to retry
|
|
787
1016
|
// — reporting `left` while the trusted reader keeps transferring to DLV is the fail-open leak. A
|
|
788
1017
|
// genuine no-responder (open / no delivery daemon, no Plane-3) means there is no membership to tombstone.
|
|
789
|
-
if (this.
|
|
1018
|
+
if (this.authed && effectiveDeliveryClass(this.channelConfigs.get(channel), this.channelDefaults) === "durable") {
|
|
790
1019
|
let generation = this.plane3Channels.get(channel);
|
|
791
1020
|
if (generation === undefined)
|
|
792
1021
|
generation = (await this.fetchMemberships())?.find((m) => m.channel === channel)?.generation;
|
|
@@ -817,7 +1046,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
817
1046
|
if (info.state.subjects) {
|
|
818
1047
|
for (const [subject, count] of Object.entries(info.state.subjects)) {
|
|
819
1048
|
const p = parseSubject(subject);
|
|
820
|
-
|
|
1049
|
+
// Same surfacing-boundary defense as the message guards: parseSubject splits only, so an
|
|
1050
|
+
// old-shape alias (`chat.<nkey>.team.backend`) structurally parses with a raw-nkey owner and a
|
|
1051
|
+
// misattributed channel. Reject a non-principal owner token here too, or retained pre-flip
|
|
1052
|
+
// subjects would inflate this channel-count surface with phantom channels.
|
|
1053
|
+
if (p?.kind === "chat" && isPrincipalOwnerToken(p.owner))
|
|
821
1054
|
counts.set(p.rest, (counts.get(p.rest) ?? 0) + count);
|
|
822
1055
|
}
|
|
823
1056
|
}
|
|
@@ -867,13 +1100,15 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
867
1100
|
arr.sort(byName);
|
|
868
1101
|
return map;
|
|
869
1102
|
}
|
|
870
|
-
/** Lazily open the
|
|
871
|
-
*
|
|
872
|
-
|
|
1103
|
+
/** Lazily open the DERIVED membership FEED KV (`cotal_membership_<space>`; admin/observer read, the
|
|
1104
|
+
* delivery daemon writes it) — the display-only who-is-subscribed view. Distinct from the authoritative
|
|
1105
|
+
* {@link membersRegistry} (`cotal_members_<space>`, the Plane-3 durable-membership source of truth): the
|
|
1106
|
+
* two names look alike, so this one is explicitly "feed". Read-only here; agents hold no grant. */
|
|
1107
|
+
async membershipFeedRegistry() {
|
|
873
1108
|
if (!this.nc)
|
|
874
1109
|
throw new Error("endpoint not started");
|
|
875
|
-
this.
|
|
876
|
-
return this.
|
|
1110
|
+
this.membershipFeedKv ??= await new Kvm(this.nc).open(membershipBucket(this.space));
|
|
1111
|
+
return this.membershipFeedKv;
|
|
877
1112
|
}
|
|
878
1113
|
/**
|
|
879
1114
|
* Snapshot the broker-sourced channel-membership feed (admin/observer read): every agent's
|
|
@@ -883,7 +1118,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
883
1118
|
* when the feed has never been written (no daemon → the dashboard degrades to traffic-only).
|
|
884
1119
|
*/
|
|
885
1120
|
async readMembership() {
|
|
886
|
-
const kv = await this.
|
|
1121
|
+
const kv = await this.membershipFeedRegistry();
|
|
887
1122
|
const members = [];
|
|
888
1123
|
let asOf;
|
|
889
1124
|
for await (const key of await kv.keys()) {
|
|
@@ -910,7 +1145,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
910
1145
|
* stop handle. Best-effort: a feed the cred can't read (or absent) surfaces as an `error` event and
|
|
911
1146
|
* the dashboard keeps its last snapshot. */
|
|
912
1147
|
async watchMembership(onChange) {
|
|
913
|
-
const kv = await this.
|
|
1148
|
+
const kv = await this.membershipFeedRegistry();
|
|
914
1149
|
const iter = await kv.watch();
|
|
915
1150
|
void (async () => {
|
|
916
1151
|
for await (const _ of iter)
|
|
@@ -921,14 +1156,14 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
921
1156
|
/** Fetch recent messages from a channel's JetStream backlog. */
|
|
922
1157
|
async channelHistory(channel, opts) {
|
|
923
1158
|
// history from any sender
|
|
924
|
-
return this.streamHistory(chatStream(this.space), chatSubject(this.space, "*", channel), opts?.limit ?? 100);
|
|
1159
|
+
return this.streamHistory(chatStream(this.space), chatSubject(this.space, "*", "*", channel), opts?.limit ?? 100);
|
|
925
1160
|
}
|
|
926
1161
|
/** Fetch recent DMs (any sender→any recipient) from the space's DM backlog. God-view only:
|
|
927
1162
|
* a normal agent/observer's ACL denies CONSUMER.CREATE on DM_<space>, so this throws-and-
|
|
928
1163
|
* skips for them — only an `admin`-profile cred can read it. */
|
|
929
1164
|
async dmHistory(opts) {
|
|
930
|
-
// every inst.<
|
|
931
|
-
return this.streamHistory(dmStream(this.space),
|
|
1165
|
+
// every inst.<recipOwner>.<recipActor>.<sndOwner>.<sndActor> DM — the whole DM subtree (god-view)
|
|
1166
|
+
return this.streamHistory(dmStream(this.space), `${spacePrefix(this.space)}.inst.>`, opts?.limit ?? 100);
|
|
932
1167
|
}
|
|
933
1168
|
/** Drain up to `limit` recent messages matching `subject` from a stream's backlog via a
|
|
934
1169
|
* throwaway consumer. Fetches exactly the pending count (from consumer info) so it returns
|
|
@@ -992,7 +1227,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
992
1227
|
* else "endpoint not started" (genuine pre-start). */
|
|
993
1228
|
notLiveMsg() {
|
|
994
1229
|
return this.reconnecting || this.reestablishing
|
|
995
|
-
? "reconnecting
|
|
1230
|
+
? "reconnecting - try again shortly"
|
|
996
1231
|
: "endpoint not started";
|
|
997
1232
|
}
|
|
998
1233
|
async publishMsg(subject, msg) {
|
|
@@ -1019,9 +1254,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1019
1254
|
* creating a durable filtered to someone else's inbox. Idempotent (byte-identical config),
|
|
1020
1255
|
* safe to call again on manager restart. The caller must be permissive on DM_<space>.
|
|
1021
1256
|
*/
|
|
1022
|
-
async provisionDmInbox(
|
|
1257
|
+
async provisionDmInbox(owner, actor) {
|
|
1023
1258
|
const jsm = await this.manager();
|
|
1024
|
-
await jsm.consumers.add(dmStream(this.space), dmDurableConfig(this.space,
|
|
1259
|
+
await jsm.consumers.add(dmStream(this.space), dmDurableConfig(this.space, owner, actor));
|
|
1025
1260
|
}
|
|
1026
1261
|
/**
|
|
1027
1262
|
* Privileged: pre-create an agent's bind-only Plane-3 DELIVER durable (`dlv_<id>`, filtered to
|
|
@@ -1030,9 +1265,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1030
1265
|
* the agent never does. The trusted reader transfers re-authorized copies onto `dlv.<id>`; the agent
|
|
1031
1266
|
* acks them via native JetStream (SPEC §8). Idempotent. The caller must be permissive on DLV.
|
|
1032
1267
|
*/
|
|
1033
|
-
async provisionDlvInbox(
|
|
1268
|
+
async provisionDlvInbox(owner, actor) {
|
|
1034
1269
|
const jsm = await this.manager();
|
|
1035
|
-
await jsm.consumers.add(dlvStream(this.space), dlvDurableConfig(this.space,
|
|
1270
|
+
await jsm.consumers.add(dlvStream(this.space), dlvDurableConfig(this.space, owner, actor));
|
|
1036
1271
|
}
|
|
1037
1272
|
/**
|
|
1038
1273
|
* Privileged: pre-create a role's shared TASK work-queue durable (auth mode), so agents
|
|
@@ -1149,7 +1384,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1149
1384
|
if (this.managerLeaseKv)
|
|
1150
1385
|
return this.managerLeaseKv;
|
|
1151
1386
|
const kvm = new Kvm(this.nc);
|
|
1152
|
-
if (this.
|
|
1387
|
+
if (this.authed) {
|
|
1153
1388
|
this.managerLeaseKv = await kvm.open(managerBucket(this.space));
|
|
1154
1389
|
}
|
|
1155
1390
|
else {
|
|
@@ -1235,13 +1470,17 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1235
1470
|
const matches = [...this.roster.values()].filter((p) => p.card.name.toLowerCase() === name.toLowerCase());
|
|
1236
1471
|
return matches.length === 1 ? matches[0].card.id : undefined;
|
|
1237
1472
|
}
|
|
1238
|
-
/** Publish one fan-out entry into
|
|
1239
|
-
* (`<msgId>:<
|
|
1240
|
-
|
|
1473
|
+
/** Publish one fan-out entry into a member principal's mixed inbox, idempotent via `Nats-Msg-Id`
|
|
1474
|
+
* (`<msgId>:<principal>:<generation>`) so a catch-up copy and a racing fan-out copy collapse. The
|
|
1475
|
+
* `principal` is the member's owner+actor dot-form (dinbox is per-agent); split for the subject. */
|
|
1476
|
+
async publishDinbox(principal, entry) {
|
|
1241
1477
|
if (!this.js)
|
|
1242
1478
|
return;
|
|
1243
|
-
|
|
1244
|
-
|
|
1479
|
+
const p = parsePrincipalKey(principal);
|
|
1480
|
+
if (!p)
|
|
1481
|
+
throw new Error(`publishDinbox: "${principal}" is not a valid member principal <owner>.<actor>`);
|
|
1482
|
+
await this.js.publish(dinboxSubject(this.space, p.owner, p.actor), JSON.stringify(entry), {
|
|
1483
|
+
msgID: `${entry.msg.id}:${principal}:${entry.generation}`,
|
|
1245
1484
|
});
|
|
1246
1485
|
}
|
|
1247
1486
|
/** The fan-out consumer's delivered stream-seq — the activation-fence upper bound (red-team
|
|
@@ -1330,14 +1569,19 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1330
1569
|
async catchupCopy(owner, channel, fromSeqExcl, toSeqIncl, generation) {
|
|
1331
1570
|
if (!this.js || !this.jsm || toSeqIncl <= fromSeqExcl)
|
|
1332
1571
|
return { copied: 0, evicted: false };
|
|
1333
|
-
const subject = chatSubject(this.space, "*", channel);
|
|
1572
|
+
const subject = chatSubject(this.space, "*", "*", channel);
|
|
1334
1573
|
// Eviction = a message in `(joinCursor, …]` on THIS channel's subject aged out under discard=Old.
|
|
1335
1574
|
// Judged PER-SUBJECT (reuse channelDropped: oldest-retained-for-subject vs the watermark, only at
|
|
1336
1575
|
// the per-subject cap), NOT against the stream-global joinCursor+1 — other channels' traffic
|
|
1337
1576
|
// inflates the global seq, so a naive "first delivered seq > joinCursor+1" false-positives on any
|
|
1338
1577
|
// busy multi-channel space (impl-review HIGH-2). A true eviction → durableJoin reports durable:false.
|
|
1339
1578
|
const evicted = await this.channelDropped(subject, fromSeqExcl);
|
|
1340
|
-
|
|
1579
|
+
// Consumer NAME must be JetStream-safe (no `.`) AND collision-free: use the principal DASH-form
|
|
1580
|
+
// (`<owner>-<actor>`, `-` reserved as the sole separator), NOT `token(owner)` — `token()` maps the
|
|
1581
|
+
// dot-form `.`→`_`, which is NOT collision-free (`_` is legal inside a token, so `a.b_c` and `a_b.c`
|
|
1582
|
+
// would both underscore to `a_b_c`).
|
|
1583
|
+
const cuP = parsePrincipalKey(owner);
|
|
1584
|
+
const name = `cu_${cuP ? principalKey(cuP.owner, cuP.actor).name : token(owner)}_${generation}`;
|
|
1341
1585
|
try {
|
|
1342
1586
|
await this.jsm.consumers.delete(chatStream(this.space), name);
|
|
1343
1587
|
}
|
|
@@ -1366,7 +1610,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1366
1610
|
continue;
|
|
1367
1611
|
}
|
|
1368
1612
|
const parsed = parseSubject(m.subject);
|
|
1369
|
-
if (!parsed || msg.from?.id !== parsed.sender || msg.from.id === owner)
|
|
1613
|
+
if (!parsed || msg.from?.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner) || msg.from.id === owner)
|
|
1370
1614
|
continue;
|
|
1371
1615
|
await this.publishDinbox(owner, { msg, channel, seq: m.seq, reason: "durable-channel", generation });
|
|
1372
1616
|
copied++;
|
|
@@ -1391,10 +1635,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1391
1635
|
* resume on a daemon restart. Both the JS loops AND the `ctl.delivery` subscription are (re)bound by
|
|
1392
1636
|
* {@link armPlane3} on EVERY (re)connect — a reconnect drains the old connection, so re-binding both
|
|
1393
1637
|
* is required, not optional (the responder would otherwise be lost on a broker blip). */
|
|
1394
|
-
async startPlane3(aclFor) {
|
|
1638
|
+
async startPlane3(aclFor, opts = {}) {
|
|
1395
1639
|
if (!this.js)
|
|
1396
1640
|
throw new Error("endpoint not started");
|
|
1397
|
-
this.plane3 = { aclFor };
|
|
1641
|
+
this.plane3 = { aclFor, reloadMembershipCreds: opts.reloadMembershipCreds, evictPrincipal: opts.evictPrincipal };
|
|
1398
1642
|
await this.armPlane3();
|
|
1399
1643
|
}
|
|
1400
1644
|
/** Serve one runtime durable-membership control request (the server-side delivery daemon). The caller
|
|
@@ -1499,6 +1743,51 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1499
1743
|
this.subs.splice(i, 1);
|
|
1500
1744
|
}
|
|
1501
1745
|
this.deliveryServeSub = this.serveControl(CONTROL_DELIVERY, (req) => this.handleDeliveryControl(req), { boundReply: true });
|
|
1746
|
+
if (this.deliveryAdminServeSub) {
|
|
1747
|
+
try {
|
|
1748
|
+
this.deliveryAdminServeSub.unsubscribe();
|
|
1749
|
+
}
|
|
1750
|
+
catch { /* dead with the old connection */ }
|
|
1751
|
+
const i = this.subs.indexOf(this.deliveryAdminServeSub);
|
|
1752
|
+
if (i >= 0)
|
|
1753
|
+
this.subs.splice(i, 1);
|
|
1754
|
+
}
|
|
1755
|
+
this.deliveryAdminServeSub = this.serveControl(CONTROL_DELIVERY_ADMIN, (req) => this.handleDeliveryAdmin(req), { boundReply: true });
|
|
1756
|
+
}
|
|
1757
|
+
/** Serve one PRIVILEGED delivery-admin request (the D5 rail-split). The cred layer is the caller
|
|
1758
|
+
* boundary — only the supervisor profile can publish here — and `serveControl`'s sender check +
|
|
1759
|
+
* bounded reply still apply on top. `reloadCreds` is the class-2 renewal ADOPTION step: re-read
|
|
1760
|
+
* the renewal-owner-re-signed creds file, pin, swap the live connection, reconnect the membership
|
|
1761
|
+
* feed's rw connection, and reply with proof (identities + the adopted JWT windows) — or a
|
|
1762
|
+
* structured failure (e.g. the file was never re-signed), never a silent partial. */
|
|
1763
|
+
async handleDeliveryAdmin(req) {
|
|
1764
|
+
if (req.op === "reloadCreds") {
|
|
1765
|
+
try {
|
|
1766
|
+
const delivery = await this.reloadCreds();
|
|
1767
|
+
const membership = this.plane3?.reloadMembershipCreds ? await this.plane3.reloadMembershipCreds() : undefined;
|
|
1768
|
+
return { ok: true, data: { delivery, ...(membership !== undefined ? { membership } : {}) } };
|
|
1769
|
+
}
|
|
1770
|
+
catch (e) {
|
|
1771
|
+
return { ok: false, error: e.message };
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
if (req.op === "evictPrincipal") {
|
|
1775
|
+
// The LIVE-EVICTION executor (D5 slice 6): force-drop a denied principal's connections.
|
|
1776
|
+
// Composition-root hook because the $SYS observer/evictor creds live outside this endpoint's
|
|
1777
|
+
// trust boundary; absent hook = a daemon build without the executor, refused loudly.
|
|
1778
|
+
if (!this.plane3?.evictPrincipal)
|
|
1779
|
+
return { ok: false, error: "evictPrincipal: no eviction executor wired on this daemon" };
|
|
1780
|
+
const principal = typeof req.args?.principal === "string" ? req.args.principal.trim() : "";
|
|
1781
|
+
if (!principal)
|
|
1782
|
+
return { ok: false, error: "evictPrincipal: a principal (owner.actor dot-form) is required" };
|
|
1783
|
+
try {
|
|
1784
|
+
return { ok: true, data: await this.plane3.evictPrincipal(principal) };
|
|
1785
|
+
}
|
|
1786
|
+
catch (e) {
|
|
1787
|
+
return { ok: false, error: e.message };
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
return { ok: false, error: `op "${req.op}" not supported on the delivery admin service` };
|
|
1502
1791
|
}
|
|
1503
1792
|
/** Fan-out loop: bind the privileged `fanout` durable on CHAT and route each message (routing only —
|
|
1504
1793
|
* the trusted reader is the auth gate). */
|
|
@@ -1547,10 +1836,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1547
1836
|
m.ack();
|
|
1548
1837
|
return;
|
|
1549
1838
|
}
|
|
1550
|
-
if (!msg.from || msg.from.id !== parsed.sender) {
|
|
1839
|
+
if (!msg.from || msg.from.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner)) {
|
|
1551
1840
|
m.ack();
|
|
1552
1841
|
return;
|
|
1553
|
-
} // authenticity
|
|
1842
|
+
} // authenticity (owner must be a real principal, not an old-shape alias)
|
|
1554
1843
|
const seq = m.seq;
|
|
1555
1844
|
if ((await this.deliveryClassFresh(channel)) === "durable") {
|
|
1556
1845
|
for (const rec of await listMembers(await this.membersRegistry(), { channel })) {
|
|
@@ -1607,11 +1896,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1607
1896
|
* revoked/narrowed ACL or out-of-interval seq; on transfer success, ack the mixed entry (durability
|
|
1608
1897
|
* has moved to DLV — an §8 equivalent per-member at-least-once mechanism). The agent acks DLV. */
|
|
1609
1898
|
async readerHandle(m) {
|
|
1610
|
-
const
|
|
1611
|
-
if (!
|
|
1899
|
+
const pr = parseDinboxPrincipal(m.subject);
|
|
1900
|
+
if (!pr) {
|
|
1612
1901
|
m.ack();
|
|
1613
1902
|
return;
|
|
1614
1903
|
} // unparseable subject — not a real entry
|
|
1904
|
+
const owner = `${pr.owner}.${pr.actor}`; // the member principal dot-form (acl/member keys, msgID)
|
|
1615
1905
|
let entry;
|
|
1616
1906
|
try {
|
|
1617
1907
|
entry = m.json();
|
|
@@ -1653,7 +1943,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1653
1943
|
}
|
|
1654
1944
|
}
|
|
1655
1945
|
try {
|
|
1656
|
-
await this.js.publish(dlvSubject(this.space, owner), JSON.stringify(entry.msg), {
|
|
1946
|
+
await this.js.publish(dlvSubject(this.space, pr.owner, pr.actor), JSON.stringify(entry.msg), {
|
|
1657
1947
|
msgID: `${entry.msg.id}:${owner}:${entry.generation}`,
|
|
1658
1948
|
});
|
|
1659
1949
|
}
|
|
@@ -1680,7 +1970,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1680
1970
|
return;
|
|
1681
1971
|
let consumer;
|
|
1682
1972
|
try {
|
|
1683
|
-
consumer = await this.js.consumers.get(dlvStream(this.space), dlvDurable(this.
|
|
1973
|
+
consumer = await this.js.consumers.get(dlvStream(this.space), dlvDurable(this.owner, this.actor));
|
|
1684
1974
|
}
|
|
1685
1975
|
catch {
|
|
1686
1976
|
return;
|
|
@@ -1747,7 +2037,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1747
2037
|
}
|
|
1748
2038
|
catch (e) {
|
|
1749
2039
|
if (attempt === 0)
|
|
1750
|
-
this.emit("error", new Error(`channel "${channel}": Plane-3 durable membership (generation ${generation}) not yet tombstoned after a refused live sub
|
|
2040
|
+
this.emit("error", 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})`));
|
|
1751
2041
|
await new Promise((r) => setTimeout(r, Math.min(30_000, 1000 * 2 ** attempt)));
|
|
1752
2042
|
}
|
|
1753
2043
|
}
|
|
@@ -1845,7 +2135,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1845
2135
|
}
|
|
1846
2136
|
catch (e) {
|
|
1847
2137
|
if (attempt === 0 && !this.isNoResponders(e))
|
|
1848
|
-
this.emit("error", new Error(`channel "${channel}": boot durable self-join not yet established
|
|
2138
|
+
this.emit("error", new Error(`channel "${channel}": boot durable self-join not yet established - retrying until the delivery daemon is reachable (${e.message})`));
|
|
1849
2139
|
}
|
|
1850
2140
|
}
|
|
1851
2141
|
}
|
|
@@ -1867,17 +2157,16 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1867
2157
|
async startConsumers() {
|
|
1868
2158
|
if (!this.jsm)
|
|
1869
2159
|
throw new Error("endpoint not started");
|
|
1870
|
-
|
|
1871
|
-
//
|
|
1872
|
-
//
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
await this.jsm.consumers.add(dmStream(this.space), dmDurableConfig(this.space, id, {
|
|
2160
|
+
// Unicast: this instance's private DM inbox, keyed on this endpoint's owner+actor principal. Open
|
|
2161
|
+
// mode self-creates; auth mode BINDS a durable the provisioner pre-created (agents are denied
|
|
2162
|
+
// CONSUMER.CREATE on DM_<space>, since the create-time filter_subject is the attack surface).
|
|
2163
|
+
if (!this.authed) {
|
|
2164
|
+
await this.jsm.consumers.add(dmStream(this.space), dmDurableConfig(this.space, this.owner, this.actor, {
|
|
1876
2165
|
ackWaitMs: this.ackWaitMs,
|
|
1877
2166
|
inactiveThresholdMs: this.inactiveThresholdMs,
|
|
1878
2167
|
}));
|
|
1879
2168
|
}
|
|
1880
|
-
await this.pump(dmStream(this.space), dmDurable(
|
|
2169
|
+
await this.pump(dmStream(this.space), dmDurable(this.owner, this.actor));
|
|
1881
2170
|
// Plane-3 (SPEC §8): bind + pump our per-member DELIVER durable (`dlv_<id>`) — the re-authorized
|
|
1882
2171
|
// durable-backstop channel copies the trusted reader transfers to us. No-op when it isn't present
|
|
1883
2172
|
// (open mode / un-provisioned). Auth-only feature; the pump self-guards on the durable's existence.
|
|
@@ -1899,14 +2188,14 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1899
2188
|
this.subscribeChat(ch);
|
|
1900
2189
|
await this.confirmChatSub();
|
|
1901
2190
|
for (const ch of this.channels)
|
|
1902
|
-
this.confirmingChatSubs.delete(chatSubject(this.space, "*", ch));
|
|
2191
|
+
this.confirmingChatSubs.delete(chatSubject(this.space, "*", "*", ch));
|
|
1903
2192
|
if (armed)
|
|
1904
2193
|
await this.backfillArmed(armed);
|
|
1905
2194
|
}
|
|
1906
2195
|
// First connect, auth mode: self-join BOOT durable channels via the server-side delivery daemon
|
|
1907
2196
|
// (it owns membership now — there is no manager-written boot membership). Seeds plane3Channels so a
|
|
1908
2197
|
// later leave can tombstone the §7 boundary; idempotent on relaunch. Open mode has no Plane-3.
|
|
1909
|
-
if (this.firstConnect && this.
|
|
2198
|
+
if (this.firstConnect && this.authed && this.channels.length)
|
|
1910
2199
|
await this.armBootDurableMemberships();
|
|
1911
2200
|
this.firstConnect = false;
|
|
1912
2201
|
// Anycast: a shared work-queue consumer for our role — one instance grabs each task.
|
|
@@ -1914,7 +2203,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1914
2203
|
// durable (agents are denied CONSUMER.CREATE on TASK_<space>, since the create-time
|
|
1915
2204
|
// filter is the cross-role-drain attack surface — see provisionTaskQueue).
|
|
1916
2205
|
if (this.card.role) {
|
|
1917
|
-
if (!this.
|
|
2206
|
+
if (!this.authed) {
|
|
1918
2207
|
await this.jsm.consumers.add(taskStream(this.space), taskDurableConfig(this.space, this.card.role, { ackWaitMs: this.ackWaitMs }));
|
|
1919
2208
|
}
|
|
1920
2209
|
await this.pump(taskStream(this.space), taskDurable(this.card.role));
|
|
@@ -1943,7 +2232,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1943
2232
|
// and a missing `from` or an unparseable subject on a delivery is itself an anomaly.
|
|
1944
2233
|
// Reject (term — a spoof is permanently invalid, never redeliver) BEFORE any handler.
|
|
1945
2234
|
const parsed = parseSubject(m.subject);
|
|
1946
|
-
if (!parsed || !msg.from || msg.from.id !== parsed.sender) {
|
|
2235
|
+
if (!parsed || !msg.from || msg.from.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner)) {
|
|
1947
2236
|
m.term();
|
|
1948
2237
|
this.emit("error", new Error(`dropped message on ${m.subject}: payload from ${msg.from?.id ?? "(none)"} ` +
|
|
1949
2238
|
`does not match subject sender ${parsed?.sender ?? "(unparseable)"}`));
|
|
@@ -1990,7 +2279,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1990
2279
|
if (!this.nc || this.chatSubs.has(channel))
|
|
1991
2280
|
return;
|
|
1992
2281
|
this.chatSubDenied.delete(channel);
|
|
1993
|
-
const subject = chatSubject(this.space, "*", channel);
|
|
2282
|
+
const subject = chatSubject(this.space, "*", "*", channel);
|
|
1994
2283
|
this.confirmingChatSubs.add(subject);
|
|
1995
2284
|
const sub = this.nc.subscribe(subject, {
|
|
1996
2285
|
callback: (err, m) => {
|
|
@@ -2032,8 +2321,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2032
2321
|
this.emit("error", e);
|
|
2033
2322
|
return;
|
|
2034
2323
|
}
|
|
2035
|
-
if (!msg.from || msg.from.id !== parsed.sender)
|
|
2036
|
-
return; // spoof/malformed — drop (at-most-once)
|
|
2324
|
+
if (!msg.from || msg.from.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner))
|
|
2325
|
+
return; // spoof/malformed/old-shape-alias — drop (at-most-once)
|
|
2037
2326
|
if (msg.from.id === this.card.id)
|
|
2038
2327
|
return; // our own echo
|
|
2039
2328
|
const delivery = { ack: () => { }, nak: () => { }, durable: false }; // live = at-most-once, not acked
|
|
@@ -2047,7 +2336,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2047
2336
|
}
|
|
2048
2337
|
/** Close a channel's core subscription (manager-free leave). */
|
|
2049
2338
|
unsubscribeChat(channel) {
|
|
2050
|
-
this.confirmingChatSubs.delete(chatSubject(this.space, "*", channel));
|
|
2339
|
+
this.confirmingChatSubs.delete(chatSubject(this.space, "*", "*", channel));
|
|
2051
2340
|
const sub = this.chatSubs.get(channel);
|
|
2052
2341
|
if (sub) {
|
|
2053
2342
|
try {
|
|
@@ -2164,7 +2453,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2164
2453
|
if (!this.jsm || !this.js)
|
|
2165
2454
|
throw new Error("endpoint not started");
|
|
2166
2455
|
const stream = chatStream(this.space);
|
|
2167
|
-
const name = chatHistDurable(this.
|
|
2456
|
+
const name = chatHistDurable(this.owner, this.actor);
|
|
2168
2457
|
const out = [];
|
|
2169
2458
|
// Clear any consumer leaked by a crashed prior read before re-creating it with THIS read's
|
|
2170
2459
|
// single filter (the read ACL is enforced at create — see the doc above).
|
|
@@ -2219,7 +2508,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2219
2508
|
* `start_time` (now − window); unset ⇒ the full retained window. New messages (`seq > upToSeq`)
|
|
2220
2509
|
* are skipped — the live tail owns them. Reads through the contained {@link collectHistory}. */
|
|
2221
2510
|
async backfillChannel(channel, upToSeq, sinceMs) {
|
|
2222
|
-
const subject = chatSubject(this.space, "*", channel);
|
|
2511
|
+
const subject = chatSubject(this.space, "*", "*", channel);
|
|
2223
2512
|
const start = sinceMs === undefined ? { seq: 1 } : { time: new Date(Date.now() - sinceMs) };
|
|
2224
2513
|
let msgs;
|
|
2225
2514
|
try {
|
|
@@ -2241,7 +2530,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2241
2530
|
}
|
|
2242
2531
|
// Same authenticity guard as the tail; skip our own echoes in history.
|
|
2243
2532
|
const parsed = parseSubject(sm.subject);
|
|
2244
|
-
if (!parsed || msg.from?.id !== parsed.sender || msg.from.id === this.card.id)
|
|
2533
|
+
if (!parsed || msg.from?.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner) || msg.from.id === this.card.id)
|
|
2245
2534
|
continue;
|
|
2246
2535
|
// Backfill only ever reads the chat stream, so the authenticated class is always "channel".
|
|
2247
2536
|
this.emit("message", msg, noop, { historical: true, kind: "channel" });
|
|
@@ -2269,7 +2558,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2269
2558
|
const policy = await this.joinPolicyFresh(channel);
|
|
2270
2559
|
if (!policy.replay)
|
|
2271
2560
|
return { messages: [], dropped: false };
|
|
2272
|
-
const subject = chatSubject(this.space, "*", channel);
|
|
2561
|
+
const subject = chatSubject(this.space, "*", "*", channel);
|
|
2273
2562
|
let raw;
|
|
2274
2563
|
try {
|
|
2275
2564
|
raw = await this.collectHistory(subject, { seq: sinceSeq + 1 });
|
|
@@ -2289,7 +2578,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2289
2578
|
}
|
|
2290
2579
|
// Same authenticity guard as the tail/backfill; skip our own echoes.
|
|
2291
2580
|
const parsed = parseSubject(sm.subject);
|
|
2292
|
-
if (!parsed || msg.from?.id !== parsed.sender || msg.from.id === this.card.id)
|
|
2581
|
+
if (!parsed || msg.from?.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner) || msg.from.id === this.card.id)
|
|
2293
2582
|
continue;
|
|
2294
2583
|
collected.push(msg);
|
|
2295
2584
|
}
|
|
@@ -2511,20 +2800,90 @@ function sameChannelModes(a, b) {
|
|
|
2511
2800
|
}
|
|
2512
2801
|
function authOpts(a) {
|
|
2513
2802
|
const tls = a.tls ? {} : undefined;
|
|
2803
|
+
// USER MODE: present the shared auth-account sentinel creds AND the user bearer as `auth_token` (an
|
|
2804
|
+
// authenticator ARRAY — nats.js merges them into one CONNECT). The connect lands in the callout
|
|
2805
|
+
// account, which validates the bearer and re-binds the client into the data account with a scoped JWT.
|
|
2806
|
+
if (a.bearer) {
|
|
2807
|
+
if (a.creds || a.token || a.user || a.pass)
|
|
2808
|
+
throw new Error("bearer (user-mode auth) is mutually exclusive with creds/token/user/pass");
|
|
2809
|
+
if (!a.sentinelCreds)
|
|
2810
|
+
throw new Error("user-mode bearer requires sentinelCreds");
|
|
2811
|
+
return {
|
|
2812
|
+
authenticator: [credsAuthenticator(new TextEncoder().encode(a.sentinelCreds)), tokenAuthenticator(a.bearer)],
|
|
2813
|
+
tls,
|
|
2814
|
+
};
|
|
2815
|
+
}
|
|
2514
2816
|
// creds (JWT/nkey) are mutually exclusive with token/user/pass — reject rather than
|
|
2515
2817
|
// silently pick one, so a misconfigured caller fails loud.
|
|
2516
2818
|
if (a.creds) {
|
|
2517
2819
|
if (a.token || a.user || a.pass)
|
|
2518
2820
|
throw new Error("creds are mutually exclusive with token/user/pass auth");
|
|
2519
|
-
|
|
2821
|
+
const creds = a.creds;
|
|
2822
|
+
// A getter re-wraps per (re)connect attempt so each attempt signs with the freshest cred;
|
|
2823
|
+
// nats.js invokes the authenticator function on every attempt, including internal reconnects.
|
|
2824
|
+
const authenticator = typeof creds === "function"
|
|
2825
|
+
? (nonce) => credsAuthenticator(new TextEncoder().encode(creds()))(nonce)
|
|
2826
|
+
: credsAuthenticator(new TextEncoder().encode(creds));
|
|
2827
|
+
return { authenticator, tls };
|
|
2520
2828
|
}
|
|
2521
2829
|
return { token: a.token, user: a.user, pass: a.pass, tls };
|
|
2522
2830
|
}
|
|
2831
|
+
/** Decode the owner+actor PRINCIPAL from a user bearer WITHOUT verifying it — the client trusts its own
|
|
2832
|
+
* bearer only to build its subjects; the broker's minted grant (from the callout, which DOES verify the
|
|
2833
|
+
* bearer) is the real boundary, so a client that lied to itself would just be denied. Per the token
|
|
2834
|
+
* claim semantics the OWNER is the JWT `sub` (`act.owner` merely restates it) and the ACTOR is
|
|
2835
|
+
* `act.actor`. Throws on a structurally-unusable bearer (fail-loud). */
|
|
2836
|
+
/** Ms until a source-fed cred's RENEWAL point — 75% of its iat→exp lifetime (the cert-manager-style
|
|
2837
|
+
* renew-early convention: the remaining 25% is the loud-failure window, wide for day-scale standing
|
|
2838
|
+
* creds). Negative when already past it. A source-fed cred WITHOUT a numeric `exp` is fail-loud:
|
|
2839
|
+
* the renewal seam exists precisely for bounded creds, so an unbounded one signals a matrix/caller
|
|
2840
|
+
* mismatch, not a cred to keep silently forever. */
|
|
2841
|
+
function credsRenewalDelayMs(creds) {
|
|
2842
|
+
const claims = credsClaims(creds); // throws on a structurally-unusable file (fail-loud)
|
|
2843
|
+
if (typeof claims.exp !== "number")
|
|
2844
|
+
throw new Error("creds source returned a cred without a numeric exp - a standing-renewal endpoint requires bounded creds (mint with a lifetime, or pass a static string instead of a source)");
|
|
2845
|
+
const iatMs = (typeof claims.iat === "number" ? claims.iat : Date.now() / 1000) * 1000;
|
|
2846
|
+
const expMs = claims.exp * 1000;
|
|
2847
|
+
return iatMs + 0.75 * (expMs - iatMs) - Date.now();
|
|
2848
|
+
}
|
|
2849
|
+
/** The bearer's `exp` as epoch ms — what the refresh schedule keys on. A bearer without a numeric
|
|
2850
|
+
* `exp` is structurally unusable for a refreshing endpoint (fail-loud, like the principal decode). */
|
|
2851
|
+
function bearerExpiryMs(bearer) {
|
|
2852
|
+
const payload = bearer.split(".")[1];
|
|
2853
|
+
if (!payload)
|
|
2854
|
+
throw new Error("user-mode bearer is not a JWT (no payload segment)");
|
|
2855
|
+
let claims;
|
|
2856
|
+
try {
|
|
2857
|
+
claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
2858
|
+
}
|
|
2859
|
+
catch {
|
|
2860
|
+
throw new Error("user-mode bearer payload is not valid base64url JSON");
|
|
2861
|
+
}
|
|
2862
|
+
if (typeof claims.exp !== "number")
|
|
2863
|
+
throw new Error("user-mode bearer is missing a numeric exp claim");
|
|
2864
|
+
return claims.exp * 1000;
|
|
2865
|
+
}
|
|
2866
|
+
function decodeBearerPrincipal(bearer) {
|
|
2867
|
+
const payload = bearer.split(".")[1];
|
|
2868
|
+
if (!payload)
|
|
2869
|
+
throw new Error("user-mode bearer is not a JWT (no payload segment)");
|
|
2870
|
+
let claims;
|
|
2871
|
+
try {
|
|
2872
|
+
claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
2873
|
+
}
|
|
2874
|
+
catch {
|
|
2875
|
+
throw new Error("user-mode bearer payload is not valid base64url JSON");
|
|
2876
|
+
}
|
|
2877
|
+
const owner = claims.sub, actor = claims.act?.actor;
|
|
2878
|
+
if (typeof owner !== "string" || typeof actor !== "string")
|
|
2879
|
+
throw new Error("user-mode bearer is missing a string sub (owner) / act.actor claim");
|
|
2880
|
+
return { owner, actor };
|
|
2881
|
+
}
|
|
2523
2882
|
/** Turn a raw async-status error into one whose message says *why* — a permission
|
|
2524
2883
|
* violation looks like absence unless it's named as a denial. */
|
|
2525
2884
|
function describeStatusError(err) {
|
|
2526
2885
|
if (err instanceof PermissionViolationError) {
|
|
2527
|
-
return new Error(`NATS permission denied: cannot ${err.operation} "${err.subject}"
|
|
2886
|
+
return new Error(`NATS permission denied: cannot ${err.operation} "${err.subject}" - check this ` +
|
|
2528
2887
|
`endpoint's ACLs (a denied peer looks "absent" rather than blocked)`, { cause: err });
|
|
2529
2888
|
}
|
|
2530
2889
|
return err;
|
|
@@ -2653,8 +3012,22 @@ export async function probeConnect(server = DEFAULT_SERVER, opts = {}) {
|
|
|
2653
3012
|
return { ok: true };
|
|
2654
3013
|
}
|
|
2655
3014
|
catch (e) {
|
|
2656
|
-
if (e instanceof
|
|
3015
|
+
if (e instanceof UserAuthenticationExpiredError)
|
|
3016
|
+
return { ok: false, reason: "stale-auth" };
|
|
3017
|
+
if (e instanceof AuthorizationError) {
|
|
3018
|
+
// The broker's denial is generic; local knowledge isn't. A presented cred that is PROVABLY
|
|
3019
|
+
// expired by its own JWT is stale-auth (credential death), not "wrong mesh/creds" — the
|
|
3020
|
+
// repair differs (doctor auth vs re-target), so the classification must too. Unreadable
|
|
3021
|
+
// content stays a plain rejection (no false stale diagnosis from garbage).
|
|
3022
|
+
if (typeof opts.creds === "string") {
|
|
3023
|
+
try {
|
|
3024
|
+
if (inspectCredHealth(opts.creds).state === "expired")
|
|
3025
|
+
return { ok: false, reason: "stale-auth" };
|
|
3026
|
+
}
|
|
3027
|
+
catch { /* not introspectable — keep the wire truth */ }
|
|
3028
|
+
}
|
|
2657
3029
|
return { ok: false, reason: "auth-required" };
|
|
3030
|
+
}
|
|
2658
3031
|
return { ok: false, reason: "unreachable" };
|
|
2659
3032
|
}
|
|
2660
3033
|
}
|