@cotal-ai/core 0.16.0 → 0.18.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 +21 -0
- package/dist/acls.d.ts.map +1 -1
- package/dist/acls.js +44 -1
- package/dist/acls.js.map +1 -1
- package/dist/artifact.d.ts +45 -0
- package/dist/artifact.d.ts.map +1 -0
- package/dist/artifact.js +82 -0
- package/dist/artifact.js.map +1 -0
- package/dist/backup-config.d.ts +9 -2
- package/dist/backup-config.d.ts.map +1 -1
- package/dist/backup-config.js +6 -2
- package/dist/backup-config.js.map +1 -1
- package/dist/broker-tls.d.ts +70 -0
- package/dist/broker-tls.d.ts.map +1 -0
- package/dist/broker-tls.js +195 -0
- package/dist/broker-tls.js.map +1 -0
- package/dist/canonical.d.ts +14 -1
- package/dist/canonical.d.ts.map +1 -1
- package/dist/canonical.js +21 -1
- package/dist/canonical.js.map +1 -1
- package/dist/channels.js +4 -4
- package/dist/channels.js.map +1 -1
- package/dist/endpoint-action.d.ts +108 -3
- package/dist/endpoint-action.d.ts.map +1 -1
- package/dist/endpoint-action.js +209 -20
- package/dist/endpoint-action.js.map +1 -1
- package/dist/endpoint-binding.d.ts +138 -8
- package/dist/endpoint-binding.d.ts.map +1 -1
- package/dist/endpoint-binding.js +233 -26
- package/dist/endpoint-binding.js.map +1 -1
- package/dist/endpoint-contract-store.d.ts +30 -1
- package/dist/endpoint-contract-store.d.ts.map +1 -1
- package/dist/endpoint-contract-store.js +100 -29
- package/dist/endpoint-contract-store.js.map +1 -1
- package/dist/endpoint-envelope.d.ts +15 -37
- package/dist/endpoint-envelope.d.ts.map +1 -1
- package/dist/endpoint-envelope.js +93 -44
- package/dist/endpoint-envelope.js.map +1 -1
- package/dist/endpoint-error.d.ts +33 -0
- package/dist/endpoint-error.d.ts.map +1 -0
- package/dist/endpoint-error.js +34 -0
- package/dist/endpoint-error.js.map +1 -0
- package/dist/endpoint-grants.d.ts +63 -7
- package/dist/endpoint-grants.d.ts.map +1 -1
- package/dist/endpoint-grants.js +118 -9
- package/dist/endpoint-grants.js.map +1 -1
- package/dist/endpoint-invoke.d.ts +138 -0
- package/dist/endpoint-invoke.d.ts.map +1 -0
- package/dist/endpoint-invoke.js +393 -0
- package/dist/endpoint-invoke.js.map +1 -0
- package/dist/endpoint-records.d.ts.map +1 -1
- package/dist/endpoint-records.js +17 -0
- package/dist/endpoint-records.js.map +1 -1
- package/dist/endpoint-serve-kv.d.ts +97 -0
- package/dist/endpoint-serve-kv.d.ts.map +1 -0
- package/dist/endpoint-serve-kv.js +336 -0
- package/dist/endpoint-serve-kv.js.map +1 -0
- package/dist/endpoint-serve.d.ts.map +1 -1
- package/dist/endpoint-serve.js +21 -1
- package/dist/endpoint-serve.js.map +1 -1
- package/dist/endpoint-service.d.ts +1 -1
- package/dist/endpoint-service.d.ts.map +1 -1
- package/dist/endpoint-service.js +34 -10
- package/dist/endpoint-service.js.map +1 -1
- package/dist/endpoint-session-rail.d.ts +127 -0
- package/dist/endpoint-session-rail.d.ts.map +1 -0
- package/dist/endpoint-session-rail.js +325 -0
- package/dist/endpoint-session-rail.js.map +1 -0
- package/dist/endpoint-session.d.ts +1 -115
- package/dist/endpoint-session.d.ts.map +1 -1
- package/dist/endpoint-session.js +4 -316
- package/dist/endpoint-session.js.map +1 -1
- package/dist/endpoint-subjects.d.ts +20 -1
- package/dist/endpoint-subjects.d.ts.map +1 -1
- package/dist/endpoint-subjects.js +20 -1
- package/dist/endpoint-subjects.js.map +1 -1
- package/dist/endpoint-verbs.d.ts +1 -2
- package/dist/endpoint-verbs.d.ts.map +1 -1
- package/dist/endpoint-verbs.js +6 -6
- package/dist/endpoint-verbs.js.map +1 -1
- package/dist/endpoint.d.ts +157 -11
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +471 -47
- package/dist/endpoint.js.map +1 -1
- package/dist/evict.d.ts +47 -0
- package/dist/evict.d.ts.map +1 -1
- package/dist/evict.js +191 -59
- package/dist/evict.js.map +1 -1
- package/dist/identity.d.ts +34 -0
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +46 -0
- package/dist/identity.js.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/kv-scan.d.ts +8 -0
- package/dist/kv-scan.d.ts.map +1 -1
- package/dist/kv-scan.js +8 -0
- package/dist/kv-scan.js.map +1 -1
- package/dist/lease.d.ts +12 -5
- package/dist/lease.d.ts.map +1 -1
- package/dist/lease.js.map +1 -1
- package/dist/lifecycle-saga.d.ts +174 -0
- package/dist/lifecycle-saga.d.ts.map +1 -0
- package/dist/lifecycle-saga.js +418 -0
- package/dist/lifecycle-saga.js.map +1 -0
- package/dist/lifecycle-state.d.ts +202 -0
- package/dist/lifecycle-state.d.ts.map +1 -0
- package/dist/lifecycle-state.js +347 -0
- package/dist/lifecycle-state.js.map +1 -0
- package/dist/parts.d.ts +13 -0
- package/dist/parts.d.ts.map +1 -0
- package/dist/parts.js +17 -0
- package/dist/parts.js.map +1 -0
- package/dist/provision.d.ts +124 -15
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +578 -121
- package/dist/provision.js.map +1 -1
- package/dist/schema-profile.d.ts +3 -2
- package/dist/schema-profile.d.ts.map +1 -1
- package/dist/schema-profile.js +242 -18
- package/dist/schema-profile.js.map +1 -1
- package/dist/session-browser.d.ts +15 -0
- package/dist/session-browser.d.ts.map +1 -0
- package/dist/session-browser.js +14 -0
- package/dist/session-browser.js.map +1 -0
- package/dist/session-terminal-frames.d.ts +36 -0
- package/dist/session-terminal-frames.d.ts.map +1 -0
- package/dist/session-terminal-frames.js +149 -0
- package/dist/session-terminal-frames.js.map +1 -0
- package/dist/spaces.d.ts.map +1 -1
- package/dist/spaces.js +6 -1
- package/dist/spaces.js.map +1 -1
- package/dist/streams.d.ts +168 -1
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +299 -21
- package/dist/streams.js.map +1 -1
- package/dist/subjects.d.ts +34 -36
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +46 -35
- package/dist/subjects.js.map +1 -1
- package/dist/types.d.ts +27 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -1
package/dist/provision.js
CHANGED
|
@@ -14,13 +14,17 @@
|
|
|
14
14
|
* D5 adds the first credential-death primitive: profile-classified user-JWT lifetimes. Full revocation,
|
|
15
15
|
* live eviction, standing-host renewal, and issuance audit still land in later D5 slices.
|
|
16
16
|
*/
|
|
17
|
+
import { ttlBuckets } from "./streams.js";
|
|
17
18
|
import { join } from "node:path";
|
|
18
19
|
import { decode, encodeOperator, encodeAccount, encodeUser, fmtCreds, } from "@nats-io/jwt";
|
|
19
20
|
import { createOperator, createAccount, fromPublic, fromSeed } from "@nats-io/nkeys";
|
|
20
|
-
import { token, spacePrefix, chatSubject, assertValidChannel, channelInAllow, principalKey, parsePrincipalKey, deprovisionTargetPrincipal, principalTags, assertInboxConnId, DEV_OWNER, unicastSubject, anycastSubject, controlServiceSubject,
|
|
21
|
-
import { epCallerGrantRows, epServeGrantRows, epBaselineGrantRows, spawnCallerCapabilities } from "./endpoint-grants.js";
|
|
21
|
+
import { token, spacePrefix, chatSubject, assertValidChannel, channelInAllow, principalKey, parsePrincipalKey, deprovisionTargetPrincipal, principalTags, assertInboxConnId, DEV_OWNER, unicastSubject, anycastSubject, controlServiceSubject, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, artifactBucket, objectStoreStream, chatStream, dmStream, taskStream, dlvStream, inboxStream, chatHistDurable, dmDurable, taskDurable, dlvDurable, presenceBucket, channelBucket, membersBucket, aclBucket, aclKey, assertLifecycleToken, membershipBucket, deliveryBucket, managerBucket, MANAGER_LEASE_KEY, connzRequestSubject, accountConnectSubject, accountDisconnectSubject, MEMBERSHIP_INBOX_PREFIX, FANOUT_DURABLE, INBOX_READER_DURABLE, } from "./subjects.js";
|
|
22
|
+
import { epCallerGrantRows, epServeGrantRows, epBaselineGrantRows, spawnCallerCapabilities, epRequestGrantRows, operatorInstrumentCapabilities, epDescribeAllGrantRow, BASELINE_LIFECYCLE_ENDPOINT, } from "./endpoint-grants.js";
|
|
22
23
|
import { assertServeGrantMintable, finalizeServeIssuance } from "./endpoint-service.js";
|
|
23
|
-
import { effectsBindGrants, poolOwnerBindGrants } from "./endpoint-binding.js";
|
|
24
|
+
import { effectsBindGrants, poolOwnerBindGrants, goalWriterGrants, sessionLedgerGrants, epAuthBucket, sessionsBucket, epcStreamName, epjStreamName, epfStreamName, epeStreamName, eptReqStreamName, eprStreamName, eptStreamName, epwStreamName } from "./endpoint-binding.js";
|
|
25
|
+
import { epsSubject, epCallerReplyFilter, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE } from "./endpoint-subjects.js";
|
|
26
|
+
import { recordsBucket, recordSpecKey, recordStatusKey, recordAtomicKey, RECORD_KINDS, GOVERN_HEAD } from "./endpoint-records.js";
|
|
27
|
+
import { lifecycleHeadKey, uidReservationKey, issuanceGateKey, staticSlotKey, STATIC_SLOT_PREFIX, epgateKey, epcredFamilyPrefix } from "./lifecycle-state.js";
|
|
24
28
|
import { rawDigest } from "./canonical.js";
|
|
25
29
|
import { credsClaims } from "./identity.js";
|
|
26
30
|
import { backupProfilePermissions, restoreProfilePermissions, } from "./backup.js";
|
|
@@ -49,6 +53,8 @@ export const CREDENTIAL_LIFETIMES = {
|
|
|
49
53
|
provisioner: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "setup/spawn provisioning window only" },
|
|
50
54
|
deprovisioner: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "target-pinned teardown window only" },
|
|
51
55
|
"retirement-requester": { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "one despawn's retirement request window; request+reply only" },
|
|
56
|
+
"lifecycle-executor": { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "one static lifecycle operation's 13.1 state-write window (activation / terminal / renewal ledger append)" },
|
|
57
|
+
"endpoint-serve-executor": { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "one endpoint registration/serve-mint window (13.1 epgate CAS + epcred stage/revoke for one (endpoint, instanceId))" },
|
|
52
58
|
operator: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "send/dm/join/probe-style operator command" },
|
|
53
59
|
purger: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "history purge command" },
|
|
54
60
|
backup: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "offline snapshot phase; exact stream and delivery subject, memory-only" },
|
|
@@ -61,6 +67,11 @@ export const CREDENTIAL_LIFETIMES = {
|
|
|
61
67
|
"control-caller-admin": { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "stop/attach admin control call" },
|
|
62
68
|
deployer: { class: "one-shot", note: "manifest deploy spans planning/launch/ledger; needs near-expiry guard or remint before default exp" },
|
|
63
69
|
"endpoint-serve": { class: "standing-renewable", defaultTtlSeconds: STANDING_RENEWABLE_TTL_SEC, renewalOwner: "manager", note: "per-instance endpoint serve credential (SPEC 13.9); the managing authority re-mints on renewal and on takeover (new epoch), and the 13.1 barrier revokes the superseded one" },
|
|
70
|
+
"goal-writer": { class: "standing-renewable", defaultTtlSeconds: STANDING_RENEWABLE_TTL_SEC, renewalOwner: "manager", note: "self-mediated goal-writer for spawn-as-action (P2 item 2); the manager re-mints for the SAME nkey on renewal, disjoint from the serve credential (Q2)" },
|
|
71
|
+
"session-caller": { class: "one-shot", defaultTtlSeconds: 24 * 60 * 60, note: "per-session console/CLI caller cred (P2 item 6): rails-only for ONE §13.6 session; TTL-BOUND to the session (the face mints with expiresAt = the session exp; the 24h default is the SESSION_GRANT_MAX_TTL ceiling, never a standing lifetime); NEVER renewed - a new session mints a new cred" },
|
|
72
|
+
"session-serving": { class: "one-shot", defaultTtlSeconds: 24 * 60 * 60, note: "per-session SERVING cred (P2 item 6): rails-only for ONE §13.6 session, the mirror of session-caller with the directions swapped; minted at redemption and TTL-BOUND to the session (the 24h default is the SESSION_GRANT_MAX_TTL ceiling, never a standing lifetime); NEVER renewed - a new session mints a new cred, and the session's terminal revokes this one by name" },
|
|
73
|
+
"session-ledger": { class: "standing-renewable", defaultTtlSeconds: STANDING_RENEWABLE_TTL_SEC, renewalOwner: "manager", note: "manager's session LEDGER (P2 item 6): the dedicated sessions-bucket `session.<id>` rows and NOTHING else - no session rail of any shape. Standing because SPEC 13.6 makes it the durable revocation authority that must survive the serving endpoint; the manager re-mints for the SAME nkey on the half-TTL loop (the goal-writer precedent)" },
|
|
74
|
+
"endpoint-evictor": { class: "one-shot", defaultTtlSeconds: 60, note: "one re-registration's verify-evict window (P2 item 3): a scoped delivery-admin caller that kicks+verifies the SUPERSEDED serve family before the epoch advances; 60s bounds a copied cred to a minute" },
|
|
64
75
|
"membership-observer": { class: "rotation-renewed", defaultTtlSeconds: ROTATION_RENEWED_TTL_SEC, renewalOwner: "system-account rotation", note: "$SYS-account CONNZ observer; NOT online-renewable ($SYS seed dies at `up`) - bounded exp, renewed only by rotateSystemAccount + broker restart; doctor warns near expiry" },
|
|
65
76
|
"connection-evictor": { class: "rotation-renewed", defaultTtlSeconds: ROTATION_RENEWED_TTL_SEC, renewalOwner: "system-account rotation", note: "$SYS-account KICK-only live-eviction cred (D5 slice 4); same rotation-renewed posture as the observer" },
|
|
66
77
|
};
|
|
@@ -322,8 +333,13 @@ export async function provisionAgentDurables(provisioner, pr, opts = {}) {
|
|
|
322
333
|
// live-only launcher, e.g. `cotal spawn --live-only`) opts out of the ACL row → the daemon never
|
|
323
334
|
// authorizes a durable backstop for it, so it stays live-only.
|
|
324
335
|
// ACL is keyed by the lifecycle-scoped dot-form <owner>.<actor>.<uid> (per-incarnation read authority).
|
|
325
|
-
|
|
326
|
-
|
|
336
|
+
// reissueAcl — rides THIS mint: allowSubscribe was just baked into the user JWT above. Separate
|
|
337
|
+
// from commitAcl so an ordinary registry writer cannot raise the ceiling by passing a flag
|
|
338
|
+
// (SPEC §9.6). Not crypto-bound to the JWT bytes — process discipline that this call stays next
|
|
339
|
+
// to the mint (ACL-authority panel residual).
|
|
340
|
+
if (opts.durableMembership !== false) {
|
|
341
|
+
await provisioner.reissueAcl(principalKey(pr.owner, pr.actor).key, uid, allowSubscribe);
|
|
342
|
+
}
|
|
327
343
|
if (opts.role)
|
|
328
344
|
await provisioner.provisionTaskQueue(opts.role);
|
|
329
345
|
return allowSubscribe;
|
|
@@ -425,14 +441,78 @@ export function permissionsFor(profile, space, pr, opts) {
|
|
|
425
441
|
return deprovisionerPermissions(space, pr, opts.deprovisionTarget);
|
|
426
442
|
}
|
|
427
443
|
if (profile === "retirement-requester") {
|
|
428
|
-
// Ephemeral request+reply on the
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
444
|
+
// Ephemeral request+reply on the AUTH ENDPOINT rail (#29 piece 3; moved off `ctl` by #350):
|
|
445
|
+
// publish EXACTLY this caller triple's own request subject for the ONE target it names +
|
|
446
|
+
// subscribe its own reply-plane filter and inbox. No store reads, no barrier/scanner/plane
|
|
447
|
+
// authority - the requester only asks; the auth plane holds every executing right and
|
|
448
|
+
// re-checks the serve registration at serve time.
|
|
449
|
+
// Validate the WHOLE shape, not just its presence. Each of these is a subject token: a missing
|
|
450
|
+
// one would otherwise surface as a raw "cannot read properties of undefined" from deep inside
|
|
451
|
+
// the subject builder, which tells the operator nothing about WHICH field it owes. The `target`
|
|
452
|
+
// arrived with #350 (the handle triple moved into the subject), so a caller written against the
|
|
453
|
+
// pre-#350 `{owner, actor}` shape lands here — and must be told exactly that.
|
|
454
|
+
const rr = opts.retirementRequester;
|
|
455
|
+
if (!rr)
|
|
456
|
+
throw new Error("permissionsFor: retirement-requester requires opts.retirementRequester ({owner, actor, uid, target} of the requesting manager)");
|
|
457
|
+
for (const [k, v] of [["owner", rr.owner], ["actor", rr.actor], ["uid", rr.uid]])
|
|
458
|
+
if (typeof v !== "string" || v.length === 0)
|
|
459
|
+
throw new Error(`permissionsFor: retirement-requester requires opts.retirementRequester.${k} (the caller triple is <owner>.<actor>.<uid>; since #350 the rail carries the CALLER's uid, not a two-token alias)`);
|
|
460
|
+
if (!rr.target || typeof rr.target.owner !== "string" || typeof rr.target.actor !== "string" || typeof rr.target.lifecycleUid !== "string")
|
|
461
|
+
throw new Error("permissionsFor: retirement-requester requires opts.retirementRequester.target ({owner, actor, lifecycleUid} of the ONE incarnation this credential may retire) - since #350 the handle target rides the SUBJECT and is grant-pinned, so it can no longer be supplied in the request body");
|
|
462
|
+
const { owner, actor, uid, target } = rr;
|
|
463
|
+
const caller = { owner, actor, uid };
|
|
464
|
+
// DEVIATION FROM `handle`'s NORMATIVE PROVENANCE, stated where the row is minted (SPEC
|
|
465
|
+
// 1314-1319, 1838-1863). `handle` is normatively REDEMPTION-MINTED: its triple is pinned at
|
|
466
|
+
// redemption from an ISSUER-SIGNED capability artifact, and the mode carries attenuation
|
|
467
|
+
// (`effective = presenter-cred INTERSECT handle.grants INTERSECT issuer-authority`), conferral
|
|
468
|
+
// through the trusted auth service, and ledgered `sourceChain` lineage.
|
|
469
|
+
// THIS PATH HAS NONE OF THAT: there is NO issuer-signed artifact, NO redemption step and NO
|
|
470
|
+
// sourceChain. The row is built directly from the manager's own coordinates under root
|
|
471
|
+
// authority. It is used because `handle` is the ONLY mode with arity 3 - every other mode
|
|
472
|
+
// resolves against the CURRENT mapping, which is the wrong semantics for retiring a NAMED
|
|
473
|
+
// incarnation - and because the reader-facing invariant ("the validator re-checks only
|
|
474
|
+
// currency") IS honoured: the auth handler fresh-checks the triple against the lifecycle
|
|
475
|
+
// mapping and refuses a stale incarnation.
|
|
476
|
+
// What is genuinely absent is delegation lineage and artifact revocation. There is no
|
|
477
|
+
// independent issuer/holder boundary on this one-shot path whose revocation would change this
|
|
478
|
+
// requester's authority, which is why the deviation is accepted rather than papered over with
|
|
479
|
+
// a manufactured artifact. NAMED RESIDUAL: Cotal #399 tracks making this genuinely
|
|
480
|
+
// redemption-shaped if real artifact semantics are ever intended.
|
|
481
|
+
// The `handle` target is grant-pinned, so this credential can ask to retire the ONE
|
|
482
|
+
// incarnation it was minted for and nothing else - the same confinement the pre-#350 grant got
|
|
483
|
+
// from naming an exact `ctl` subject, now covering the TARGET as well as the caller. The nonce
|
|
484
|
+
// is the only wildcard token (§13.9): a bounded per-request suffix, not an addressing widening.
|
|
485
|
+
const rows = epRequestGrantRows(space, {
|
|
486
|
+
endpoint: AUTH_ENDPOINT,
|
|
487
|
+
command: EP_CMD_RETIRE_LIFECYCLE,
|
|
488
|
+
target: { mode: "handle", tOwner: target.owner, tActor: target.actor, tUid: target.lifecycleUid },
|
|
489
|
+
}, caller);
|
|
490
|
+
return { pub: { allow: rows }, sub: { allow: [epCallerReplyFilter(space, caller), `_INBOX_${pr.connId}.>`] } };
|
|
491
|
+
}
|
|
492
|
+
if (profile === "endpoint-evictor") {
|
|
493
|
+
// P2 item 3 (slice 3a): a SCOPED delivery-admin caller for ONE re-registration's verify-evict.
|
|
494
|
+
// Publish EXACTLY this credential's OWN delivery-admin control subject + $JS.API.INFO; subscribe
|
|
495
|
+
// its own reply subtree + inbox. NO lease, presence, store read, consumer, KV, or executing
|
|
496
|
+
// right — the daemon does the $SYS scan/KICK (subject-gated authority, like the supervisor evictor
|
|
497
|
+
// it narrows) and the manager passes only the predecessor's principal. Narrower than the
|
|
498
|
+
// `supervisor` profile the auth barrier-evict reuses (its #30 residual, done here for the manager).
|
|
499
|
+
const req = controlServiceSubject(space, CONTROL_DELIVERY_ADMIN, pr.owner, pr.actor);
|
|
500
|
+
return { pub: { allow: [req, "$JS.API.INFO"] }, sub: { allow: [`${req}.reply.>`, `_INBOX_${pr.connId}.>`] } };
|
|
501
|
+
}
|
|
502
|
+
if (profile === "lifecycle-executor") {
|
|
503
|
+
if (!opts.lifecycleExecutor)
|
|
504
|
+
throw new Error("permissionsFor: lifecycle-executor requires opts.lifecycleExecutor ({owner, actor, lifecycleUid, alias} of the ONE incarnation it may move)");
|
|
505
|
+
return lifecycleExecutorPermissions(space, pr, opts.lifecycleExecutor);
|
|
506
|
+
}
|
|
507
|
+
if (profile === "endpoint-serve-executor") {
|
|
508
|
+
if (!opts.endpointServeExecutor)
|
|
509
|
+
throw new Error("permissionsFor: endpoint-serve-executor requires opts.endpointServeExecutor ({endpoint, instanceId} of the ONE endpoint instance it may register/serve-mint)");
|
|
510
|
+
return endpointServeExecutorPermissions(space, pr, opts.endpointServeExecutor);
|
|
511
|
+
}
|
|
512
|
+
if (profile === "goal-writer") {
|
|
513
|
+
if (!opts.goalWriter)
|
|
514
|
+
throw new Error("permissionsFor: goal-writer requires opts.goalWriter ({endpoint} whose action goals this connection binds + commits)");
|
|
515
|
+
return goalWriterPermissions(space, pr, opts.goalWriter);
|
|
436
516
|
}
|
|
437
517
|
if (profile === "purger")
|
|
438
518
|
return purgerPermissions(space, pr); // ephemeral history-purge (closure (ii))
|
|
@@ -457,11 +537,17 @@ export function permissionsFor(profile, space, pr, opts) {
|
|
|
457
537
|
if (profile === "teardown")
|
|
458
538
|
return teardownPermissions(space, pr); // sole STREAM.DELETE holder (PR 1.5)
|
|
459
539
|
if (profile === "control-caller-privileged")
|
|
460
|
-
return controlCallerPermissions(space, pr,
|
|
540
|
+
return controlCallerPermissions(space, pr, "privileged", opts); // ps/start reads (PR 1.5)
|
|
461
541
|
if (profile === "control-caller-admin")
|
|
462
|
-
return controlCallerPermissions(space, pr,
|
|
542
|
+
return controlCallerPermissions(space, pr, "admin", opts); // any-mode stop/attach (PR 1.5)
|
|
463
543
|
if (profile === "deployer")
|
|
464
|
-
return deployerPermissions(space, pr, opts.controlTier ??
|
|
544
|
+
return deployerPermissions(space, pr, opts.controlTier ?? "admin", opts); // spawn -f deploy authority (PR 1.5; user-mode view rides privileged)
|
|
545
|
+
if (profile === "session-caller")
|
|
546
|
+
return sessionCallerPermissions(space, pr, opts.sessionCaller); // one §13.6 session's caller rails (P2 item 6)
|
|
547
|
+
if (profile === "session-serving")
|
|
548
|
+
return sessionServingPermissions(space, pr, opts.sessionServing); // one §13.6 session's SERVING rails (P2 item 6)
|
|
549
|
+
if (profile === "session-ledger")
|
|
550
|
+
return sessionLedgerPermissions(space, pr); // the dedicated session ledger, no rails (P2 item 6)
|
|
465
551
|
if (profile === "endpoint-serve")
|
|
466
552
|
// Serve rows are emitted ONLY by mintCreds behind the §13.1 issuance fence — never via this
|
|
467
553
|
// exported builder, so a direct signer/callout can't obtain unfenced serve rows (SPEC 13.1/13.9).
|
|
@@ -542,7 +628,6 @@ export function permissionsFor(profile, space, pr, opts) {
|
|
|
542
628
|
// channel must equal its wire token, or the minted grant would alias the logical ACL.
|
|
543
629
|
for (const ch of [...allowSubscribe, ...allowPublish])
|
|
544
630
|
assertValidChannel(ch);
|
|
545
|
-
const manager = opts.manager ?? CONTROL_PRIVILEGED;
|
|
546
631
|
if (!pr.lifecycleUid)
|
|
547
632
|
throw new Error("permissionsFor(agent): a lifecycleUid is required - the agent's dm/dlv/chathist grants are lifecycle-keyed exact names (SPEC 13.1)");
|
|
548
633
|
const uid = assertLifecycleToken(pr.lifecycleUid);
|
|
@@ -555,7 +640,8 @@ export function permissionsFor(profile, space, pr, opts) {
|
|
|
555
640
|
...allowPublish.map((ch) => chatSubject(space, pr.owner, pr.actor, ch)),
|
|
556
641
|
unicastSubject(space, "*", "*", pr.owner, pr.actor), // inst.*.*.<o>.<a> — DM any instance, as me
|
|
557
642
|
anycastSubject(space, "*", pr.owner, pr.actor), // svc.*.<o>.<a> — anycast any role, as me
|
|
558
|
-
|
|
643
|
+
// Self stop/despawn rides the v0.4 ep baseline (`stop` self-mode) — the manager `ctl` rail is
|
|
644
|
+
// deleted (1d). The delivery-daemon rail below is a separate service (Plane-3), kept.
|
|
559
645
|
// ctl.delivery.<o>.<a> — request a durable backstop join/leave/list from the SERVER-SIDE delivery
|
|
560
646
|
// daemon (NOT the manager). The reply rides this same subtree (`ctl.delivery.<o>.<a>.reply.<n>`, in
|
|
561
647
|
// sub.allow below) so the daemon can answer without broad inbox-publish — see CONTROL_DELIVERY.
|
|
@@ -620,20 +706,9 @@ export function permissionsFor(profile, space, pr, opts) {
|
|
|
620
706
|
// allowed — the privileged provisioner pre-creates svc_<role> filtered to svc.<role>.*.
|
|
621
707
|
pubAllow.push(`$JS.API.CONSUMER.INFO.${TASK}.${svcD}`, `$JS.API.CONSUMER.MSG.NEXT.${TASK}.${svcD}`, `$JS.ACK.${TASK}.${svcD}.>`);
|
|
622
708
|
}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
// allow-list, so nats-server rejects the publish — no handler check, no deny-entry (a
|
|
627
|
-
// blanket `ctl.<mgr>.>` deny would override this grant too, since NATS deny beats allow).
|
|
628
|
-
// The self-service subject above is granted to all regardless of capability.
|
|
629
|
-
pubAllow.push(controlServiceSubject(space, manager, pr.owner, pr.actor));
|
|
630
|
-
}
|
|
631
|
-
if (opts.capabilities?.includes("admin")) {
|
|
632
|
-
// Admin capability → the ADMIN control tier (cross-agent stop/attach, manifest launch). In user
|
|
633
|
-
// mode this arrives via the ledger row's scope (`cotal actor grant … --scope admin`) — the
|
|
634
|
-
// broker-enforced half of the tier split; the manager's per-op checks stay on top of it.
|
|
635
|
-
pubAllow.push(controlServiceSubject(space, CONTROL_ADMIN, pr.owner, pr.actor));
|
|
636
|
-
}
|
|
709
|
+
// Spawn / admin capability control reach rides the v0.4 ep rows below (the manager `ctl` rail is
|
|
710
|
+
// deleted, 1d) — the spawn set (owner-mode manager lifecycle) and the admin instrument set
|
|
711
|
+
// (any-mode + manager.admin family) are added to the ep caller rows, not a ctl subject.
|
|
637
712
|
// v0.4 endpoint rails (SPEC §13.9 caller rows). EVERY agent gets the Appendix-B BASELINE set
|
|
638
713
|
// (wildcard describe + delivery join/leave/list + self-mode lifecycle + the reply rail), keyed
|
|
639
714
|
// on the SAME lifecycle uid as the agent's durables; the spawn capability adds the owner-mode
|
|
@@ -651,6 +726,13 @@ export function permissionsFor(profile, space, pr, opts) {
|
|
|
651
726
|
const epSub = [...baseline.sub];
|
|
652
727
|
if (opts.capabilities?.includes("spawn"))
|
|
653
728
|
pubAllow.push(...epCallerGrantRows(space, spawnCallerCapabilities(pr.owner), epCaller).pub);
|
|
729
|
+
if (opts.capabilities?.includes("admin"))
|
|
730
|
+
// The admin capability's ep mirror (the 1c grant-migration table): the v0.3 `ctl.<admin>`
|
|
731
|
+
// subject above grants the FULL admin-tier op reach, so its holder gets the admin instrument
|
|
732
|
+
// set on the ep rails — any-mode despawn/attach + the `manager.admin` family + the reads. In
|
|
733
|
+
// user mode this is the ledger `admin` scope arriving via the callout, the broker-enforced
|
|
734
|
+
// half of the tier; the manager's ledger-derived per-op admin flag stays on top of it.
|
|
735
|
+
pubAllow.push(...epCallerGrantRows(space, operatorInstrumentCapabilities("admin"), epCaller).pub);
|
|
654
736
|
if (opts.endpointCapabilities?.length) {
|
|
655
737
|
if (opts.lifecycleUid !== undefined && assertLifecycleToken(opts.lifecycleUid) !== uid)
|
|
656
738
|
throw new Error(`permissionsFor: opts.lifecycleUid "${opts.lifecycleUid}" disagrees with the principal's lifecycleUid "${uid}" - one credential names ONE incarnation on the caller rail (SPEC 13.1/13.2)`);
|
|
@@ -687,17 +769,9 @@ export function permissionsFor(profile, space, pr, opts) {
|
|
|
687
769
|
// Replies to this agent's durable join/leave/list requests ride `ctl.delivery.<o>.<a>.>` (NOT the
|
|
688
770
|
// per-id _INBOX), so the scoped delivery daemon can answer without broad inbox-publish.
|
|
689
771
|
const deliveryReplies = `${controlServiceSubject(space, CONTROL_DELIVERY, pr.owner, pr.actor)}.>`;
|
|
690
|
-
//
|
|
691
|
-
// `ctl.<tier>.<id>.reply.>`
|
|
692
|
-
|
|
693
|
-
// admin tiers' replies are granted only with the matching capability (which also grants the request
|
|
694
|
-
// publish above).
|
|
695
|
-
const controlReplies = [`${controlServiceSubject(space, CONTROL_SELF_SERVICE, pr.owner, pr.actor)}.reply.>`];
|
|
696
|
-
if (opts.capabilities?.includes("spawn"))
|
|
697
|
-
controlReplies.push(`${controlServiceSubject(space, CONTROL_PRIVILEGED, pr.owner, pr.actor)}.reply.>`);
|
|
698
|
-
if (opts.capabilities?.includes("admin"))
|
|
699
|
-
controlReplies.push(`${controlServiceSubject(space, CONTROL_ADMIN, pr.owner, pr.actor)}.reply.>`);
|
|
700
|
-
return { pub: { allow: pubAllow, deny: pubDeny }, sub: { allow: [inbox, deliveryReplies, ...controlReplies, ...subChat, ...epSub] } };
|
|
772
|
+
// Manager control replies ride the v0.4 ep reply rail (in `epSub`, keyed on the caller triple) —
|
|
773
|
+
// the `ctl.<tier>.<id>.reply.>` subtrees are gone with the ctl rail (1d).
|
|
774
|
+
return { pub: { allow: pubAllow, deny: pubDeny }, sub: { allow: [inbox, deliveryReplies, ...subChat, ...epSub] } };
|
|
701
775
|
}
|
|
702
776
|
/** The long-lived SUPERVISOR permission set (closure (ii), residual 2) — the always-on manager daemon
|
|
703
777
|
* (`manager.ts` `this.ep`), carved down from the former allow-all `manager`. THIS is the cred whose
|
|
@@ -710,25 +784,27 @@ export function permissionsFor(profile, space, pr, opts) {
|
|
|
710
784
|
* `setActivity`, a presence write), NO DM/DLV read of any kind (no consumer-create, no native sub), NO
|
|
711
785
|
* stream CREATE/DELETE/PURGE/UPDATE, NO channel-registry access (the daemon sets `watchChannels:false`).
|
|
712
786
|
* `$JS` is an ENUMERATED allow-list — exactly the presence-watch + lease-KV verbs — never `$JS.>`. A
|
|
713
|
-
* leaked supervisor cred can hold
|
|
787
|
+
* leaked supervisor cred can hold the lease and read the public roster; it cannot read a DM, forge an
|
|
714
788
|
* actor, provision, purge, or tamper with a stream. */
|
|
715
789
|
function supervisorPermissions(space, pr) {
|
|
716
790
|
const PKV = `KV_${presenceBucket(space)}`, MKV = `KV_${managerBucket(space)}`;
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
//
|
|
720
|
-
|
|
721
|
-
const ctlServe = tiers.map((t) => controlServiceSubject(space, t, "*", "*")); // ctl.<tier>.*.*
|
|
722
|
-
const ctlReplies = tiers.map((t) => `${controlServiceSubject(space, t, "*", "*")}.reply.>`);
|
|
791
|
+
// 1d: the supervisor no longer serves the manager control tiers — the manager's control surface
|
|
792
|
+
// is its v0.4 `service` endpoint, served on a SEPARATE connection under its own `endpoint-serve`
|
|
793
|
+
// credential (its rails are that credential's grant, not the supervisor's). The supervisor now
|
|
794
|
+
// holds only the lease, presence, and the ONE delivery-admin call.
|
|
723
795
|
return {
|
|
724
796
|
pub: {
|
|
725
797
|
allow: [
|
|
726
798
|
"$JS.API.INFO",
|
|
727
|
-
//
|
|
728
|
-
// lease key (acquire/renew/release) + read
|
|
799
|
+
// Per-instance manager liveness lease (managerBucket, pre-created at `cotal up`): OPEN-ONLY bind +
|
|
800
|
+
// CAS this instance's own `lease.<instanceId>` key (acquire/renew/release) + read the subtree. P2
|
|
801
|
+
// item 3 demoted the per-space singleton to per-instance keys, so the write grant spans `lease.*`
|
|
802
|
+
// (every instance of this space shares this one supervisor principal — the isolation between
|
|
803
|
+
// instances is the logical-id/CAS boundary, not a cred boundary). NO STREAM.CREATE (pre-created),
|
|
804
|
+
// DELETE, or PURGE.
|
|
729
805
|
`$JS.API.STREAM.INFO.${MKV}`,
|
|
730
|
-
`$JS.API.STREAM.MSG.GET.${MKV}`, // readManagerLease + CAS-conflict kv.get
|
|
731
|
-
`$KV.${managerBucket(space)}.${MANAGER_LEASE_KEY}
|
|
806
|
+
`$JS.API.STREAM.MSG.GET.${MKV}`, // readManagerLease (last_by_subj lease.*) + CAS-conflict kv.get
|
|
807
|
+
`$KV.${managerBucket(space)}.${MANAGER_LEASE_KEY}.*`, // this instance's lease.<id> key (create/update/delete = $KV publishes)
|
|
732
808
|
// Presence: publish OWN key + watch the roster. Own key only (no peer-key forge — residual 3); no
|
|
733
809
|
// presence-stream purge/delete (no force-offline tamper). No presence kv.get (roster is the in-memory
|
|
734
810
|
// watch cache + sweep), so no STREAM.MSG.GET on presence.
|
|
@@ -737,9 +813,6 @@ function supervisorPermissions(space, pr) {
|
|
|
737
813
|
`$JS.API.CONSUMER.CREATE.${PKV}.>`, // kv.watch ordered consumer (roster)
|
|
738
814
|
`$JS.API.CONSUMER.INFO.${PKV}.>`,
|
|
739
815
|
"$JS.FC.>", // ordered-consumer flow control
|
|
740
|
-
// Control: reply to any caller on each SERVED tier (bounded). It SERVES (does not call), so no
|
|
741
|
-
// request-publish grant and no position-1 wildcard — EXCEPT the delivery-admin rail below.
|
|
742
|
-
...ctlReplies,
|
|
743
816
|
// The ONE control service the supervisor CALLS (D5 slice 5): the delivery daemon's privileged
|
|
744
817
|
// admin rail — the manager is the class-2 renewal owner, and after re-signing the daemon creds
|
|
745
818
|
// files it requests `reloadCreds` here so adoption is an explicit, auditable event. Self-scoped
|
|
@@ -748,10 +821,10 @@ function supervisorPermissions(space, pr) {
|
|
|
748
821
|
],
|
|
749
822
|
},
|
|
750
823
|
sub: {
|
|
751
|
-
// Own reply inbox + the
|
|
752
|
-
//
|
|
753
|
-
// broad `$JS.>`/`$KV.>` (the residual-2 read/admin path is gone).
|
|
754
|
-
allow: [`_INBOX_${pr.connId}.>`,
|
|
824
|
+
// Own reply inbox + the delivery-admin reply subtree for its OWN requests. NO chat/inst/dlv
|
|
825
|
+
// native sub (the supervisor reads no feed), NO manager control-tier serve (1d: that moved to
|
|
826
|
+
// the endpoint-serve credential), NO broad `$JS.>`/`$KV.>` (the residual-2 read/admin path is gone).
|
|
827
|
+
allow: [`_INBOX_${pr.connId}.>`, `${controlServiceSubject(space, CONTROL_DELIVERY_ADMIN, pr.owner, pr.actor)}.>`],
|
|
755
828
|
},
|
|
756
829
|
};
|
|
757
830
|
}
|
|
@@ -852,21 +925,29 @@ function channelPurgerPermissions(space, pr) {
|
|
|
852
925
|
}
|
|
853
926
|
/** TEARDOWN (PR 1.5) — `cotal down -f` space teardown. The SOLE cred that keeps `STREAM.DELETE` (the
|
|
854
927
|
* face-b tamper verb). `down -f` is multi-step: `connectProbe` (presence-watch + channel-registry read)
|
|
855
|
-
* → `
|
|
856
|
-
*
|
|
928
|
+
* → invoke the manager's `ps` + any-mode `despawn` over the ep rails to politely stop the managed
|
|
929
|
+
* agents → `deleteChannels`
|
|
930
|
+
* (channel-registry key delete + CHAT purge) → `deleteSpace` (STREAM.DELETE all 13 space streams/buckets).
|
|
857
931
|
* So it reads state, CALLS admin control, deletes channels, and deletes streams — but NEVER reads a
|
|
858
932
|
* DM/DLV body, posts chat, or forges. Isolated here so no standing operator/provisioner/supervisor cred
|
|
859
933
|
* can delete a stream; a leaked teardown can wipe a space you own + stop its agents (that IS its job),
|
|
860
934
|
* nothing else. Minted ephemerally per teardown from the local trust material (same-checkout `down -f`). */
|
|
861
935
|
function teardownPermissions(space, pr) {
|
|
936
|
+
// The ep-rail mirror of the admin deploy tier (1c.2c): teardown reads `ps` and stops owned agents
|
|
937
|
+
// it did not spawn (any-mode despawn) - the admin instrument set. Lifecycle-keyed, so a uid is
|
|
938
|
+
// required at mint (fail-loud).
|
|
939
|
+
const ep = instrumentEpRows(space, pr, "admin");
|
|
862
940
|
const CHAT = chatStream(space);
|
|
863
941
|
const PKV = `KV_${presenceBucket(space)}`, CHKV = `KV_${channelBucket(space)}`;
|
|
864
|
-
// deleteSpace() deletes EVERY stream + KV bucket setup creates (5 streams + 7 buckets
|
|
865
|
-
// INFO (jsm existence) + DELETE. This is the ONLY cred that
|
|
942
|
+
// deleteSpace() deletes EVERY stream + KV bucket setup creates (5 streams + 7 KV buckets + the
|
|
943
|
+
// artifact object store = 13); each needs INFO (jsm existence) + DELETE. This is the ONLY cred that
|
|
944
|
+
// holds STREAM.DELETE (face-b isolated here). This list and deleteSpace()'s own array must agree:
|
|
945
|
+
// a stream in one and not the other is either an undeletable leak or a grant for nothing.
|
|
866
946
|
const del = [
|
|
867
947
|
CHAT, dmStream(space), taskStream(space), inboxStream(space), dlvStream(space),
|
|
868
948
|
PKV, CHKV, `KV_${membersBucket(space)}`, `KV_${aclBucket(space)}`,
|
|
869
949
|
`KV_${membershipBucket(space)}`, `KV_${deliveryBucket(space)}`, `KV_${managerBucket(space)}`,
|
|
950
|
+
objectStoreStream(artifactBucket(space)),
|
|
870
951
|
].flatMap((s) => [`$JS.API.STREAM.INFO.${s}`, `$JS.API.STREAM.DELETE.${s}`]);
|
|
871
952
|
return {
|
|
872
953
|
pub: {
|
|
@@ -880,44 +961,106 @@ function teardownPermissions(space, pr) {
|
|
|
880
961
|
`$JS.API.CONSUMER.CREATE.${CHKV}.>`,
|
|
881
962
|
`$JS.API.CONSUMER.INFO.${CHKV}.>`,
|
|
882
963
|
"$JS.FC.>", // ordered-consumer flow control
|
|
883
|
-
// Stop the managed agents
|
|
884
|
-
|
|
964
|
+
// Stop the managed agents over the v0.4 ep rails only (ps + any-mode despawn) — the admin
|
|
965
|
+
// instrument set. The manager `ctl` rail is deleted (1d).
|
|
966
|
+
...ep.pub,
|
|
885
967
|
...del,
|
|
886
968
|
// deleteChannels/clearChannel: purge the channel's chat messages + delete its registry key.
|
|
887
969
|
`$JS.API.STREAM.PURGE.${CHAT}`,
|
|
888
970
|
`$KV.${channelBucket(space)}.>`,
|
|
889
971
|
],
|
|
890
972
|
},
|
|
891
|
-
// Own inbox (connectProbe presence-watch delivery + JS API responses) + the
|
|
892
|
-
//
|
|
893
|
-
|
|
894
|
-
// never stopped before the streams are deleted.
|
|
895
|
-
sub: { allow: [`_INBOX_${pr.connId}.>`, `${controlServiceSubject(space, CONTROL_ADMIN, pr.owner, pr.actor)}.reply.>`] },
|
|
973
|
+
// Own inbox (connectProbe presence-watch delivery + JS API responses) + the ep reply rail (the
|
|
974
|
+
// ps + any-mode despawn calls reply there). The `ctl.admin.<id>.reply.>` subtree is gone (1d).
|
|
975
|
+
sub: { allow: [`_INBOX_${pr.connId}.>`, ...ep.sub] },
|
|
896
976
|
};
|
|
897
977
|
}
|
|
898
|
-
/** CONTROL-CALLER (PR 1.5) — the operator's lifecycle commands
|
|
899
|
-
* `manager/commands.ts`). It
|
|
900
|
-
* reply on
|
|
978
|
+
/** CONTROL-CALLER (PR 1.5; ep-only since 1d) — the operator's lifecycle commands
|
|
979
|
+
* (`cotal ps/start/stop/attach`, `manager/commands.ts`). It invokes the manager's v0.4 service
|
|
980
|
+
* endpoint and reads the bounded reply on the ep reply rail. That is ALL — no `$JS`, no `$KV`,
|
|
981
|
+
* no chat/DM: it forges nothing, reads no body.
|
|
901
982
|
*
|
|
902
|
-
* The tiers
|
|
903
|
-
*
|
|
904
|
-
*
|
|
905
|
-
*
|
|
906
|
-
*
|
|
907
|
-
*
|
|
908
|
-
*
|
|
909
|
-
*
|
|
910
|
-
*
|
|
911
|
-
*
|
|
912
|
-
|
|
913
|
-
|
|
983
|
+
* The tiers stay SPLIT because the BROKER grant is load-bearing (the 1c decision): an any-mode
|
|
984
|
+
* despawn/attach row *is* cross-agent reach — the manager maps mode `any` to its admin
|
|
985
|
+
* authorization path, so which ROWS an instrument holds is the tier boundary. Therefore:
|
|
986
|
+
* • `control-caller-privileged` (ps/start) holds the manager reads + untargeted `spawn` +
|
|
987
|
+
* `define-persona` — structurally barred from cross-agent ops (no any-mode row). This is the
|
|
988
|
+
* high-frequency path; it never needs admin reach.
|
|
989
|
+
* • `control-caller-admin` (stop/attach) adds the any-mode despawn/attach rows + the
|
|
990
|
+
* `manager.admin` family — it genuinely needs cross-agent reach. Its containment is the
|
|
991
|
+
* broker gating the any-mode rows + the cred being ephemeral (mint → one request →
|
|
992
|
+
* disconnect, from the local signing seed); on a user mesh the manager's serve-time ledger
|
|
993
|
+
* re-check sits on top. */
|
|
994
|
+
function controlCallerPermissions(space, pr, epTier, opts = {}) {
|
|
995
|
+
// 1d: the manager `ctl` rail is gone — an operator instrument holds ONLY its v0.4 ep rows (the
|
|
996
|
+
// tier-matched request set, the reply rail, describe, the one epc fetch). The `epTier` selects
|
|
997
|
+
// privileged (ps/start reads) vs admin (any-mode stop/attach) exactly as the ctl tier did.
|
|
998
|
+
//
|
|
999
|
+
// B6 / `--on <instanceId>`: these instruments are ONE-SHOT, minted per control call, and the
|
|
1000
|
+
// resolve that pins the instance happens BEFORE the mint. So the caller can hand the exact
|
|
1001
|
+
// instance id down and get the exact `ep.inst.<endpoint>.<iid>.<command>` row for THIS invocation
|
|
1002
|
+
// and nothing else — the least-privilege issuance, with no standing wildcard anywhere. The
|
|
1003
|
+
// `extra` seam is the same one the deployer's owner-equality `launch` row already rides, and the
|
|
1004
|
+
// emitter's `if (cap.instanceId)` branch validates the token, so a malformed id fails loud at
|
|
1005
|
+
// mint rather than widening a subject.
|
|
1006
|
+
const ep = instrumentEpRows(space, pr, epTier, opts.endpointCapabilities ?? []);
|
|
1007
|
+
return {
|
|
1008
|
+
// The PRIVILEGED tier (the `cotal ps` instrument) also carries the SCOPED §13.9 records read the
|
|
1009
|
+
// class scatter's freeze rides (P2 item 3): `freezeExpectedSet` enumerates `svc.<endpoint>.*.spec`
|
|
1010
|
+
// and LEADER-reads each frozen slot's svc spec/status before it scatters `ps` on the `all` rail.
|
|
1011
|
+
// The admin tier (stop/attach) never scatters, so it gets no records read.
|
|
1012
|
+
pub: { allow: epTier === "privileged" ? [...ep.pub, ...scatterFreezeReadRows(space)] : ep.pub },
|
|
1013
|
+
sub: { allow: [`_INBOX_${pr.connId}.>`, ...ep.sub] },
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
/** The SCOPED §13.9 records-read rows the class-scatter freeze rides (P2 item 3, `cotal ps`): the
|
|
1017
|
+
* `svc.*` enumeration consumer + the leader-served per-slot spec/status read of the endpoint's
|
|
1018
|
+
* `svc` registry — a READ of exactly the service-registration keys, no write, no other bucket. A
|
|
1019
|
+
* new D32 matrix row. The keyed Direct Get is subject-PINNED to `svc.>`; the enumeration consumer
|
|
1020
|
+
* and the leader `STREAM.MSG.GET` are STREAM-scoped because the requested key rides the PAYLOAD
|
|
1021
|
+
* (which a subject grant cannot narrow) — the SAME NAMED RESIDUAL every records reader already
|
|
1022
|
+
* accepts (the provisioner, the lifecycle/serve executors): for this EPHEMERAL one-shot instrument's
|
|
1023
|
+
* lifetime it can READ (never write) any records-store row — registration metadata, no secrets. */
|
|
1024
|
+
function scatterFreezeReadRows(space) {
|
|
1025
|
+
const REC = recordsBucket(space);
|
|
1026
|
+
return [
|
|
1027
|
+
// The `svc.<e>.*.spec` enumeration is a `STREAM.INFO` carrying a `subjects_filter` — ONE
|
|
1028
|
+
// read-only metadata verb. It replaced a `kv.keys()` that rode an ordered ephemeral consumer and
|
|
1029
|
+
// therefore needed CONSUMER.CREATE/INFO/DELETE on this bucket: three consumer-lifecycle verbs to
|
|
1030
|
+
// list keys, on a credential that only ever wanted to read them.
|
|
1031
|
+
//
|
|
1032
|
+
// MEASURED, not assumed: with the enumeration converted and this row absent, the static/operator
|
|
1033
|
+
// `cotal ps` is refused on `$JS.API.STREAM.INFO.KV_<records>` — a path that works today. The
|
|
1034
|
+
// conversion and this row land TOGETHER or the operator path regresses.
|
|
1035
|
+
`$JS.API.STREAM.INFO.KV_${REC}`,
|
|
1036
|
+
// The per-slot spec/status reads (`freezeExpectedSet` + the registration reconcile) are
|
|
1037
|
+
// leader-served `STREAM.MSG.GET` — stream-scoped (NAMED RESIDUAL above), plus the subject-pinned
|
|
1038
|
+
// keyed Direct Get form for any direct-aware read path (scoped to the `svc.` registry prefix).
|
|
1039
|
+
`$JS.API.STREAM.MSG.GET.KV_${REC}`,
|
|
1040
|
+
`$JS.API.DIRECT.GET.KV_${REC}.$KV.${REC}.svc.>`,
|
|
1041
|
+
];
|
|
1042
|
+
}
|
|
1043
|
+
/** The v0.4 ep-rail rows of an operator INSTRUMENT credential (the 1c grant-migration table's
|
|
1044
|
+
* admin row): the tier-matched {@link operatorInstrumentCapabilities} request rows + the caller's
|
|
1045
|
+
* reply rail, the wildcard `describe` form, and the ONE subject-scoped §13.7 contract-store fetch
|
|
1046
|
+
* row (the same shape the agent baseline holds; the D32 audit's single exemption). These mirror
|
|
1047
|
+
* the instrument's ctl tier onto the ep rails during dual-serve; at 1d the ctl row disappears and
|
|
1048
|
+
* these ARE the instrument. The caller triple pins the instrument's own mint-time lifecycle uid
|
|
1049
|
+
* ({@link MintPrincipal.lifecycleUid}) — REQUIRED here: without it the reply rail cannot be pinned
|
|
1050
|
+
* and the mint fails loud rather than emit a triple-less (unfenced) caller surface. `extra` lets a
|
|
1051
|
+
* profile append its tier-refined additions (the user-mode deployer's owner-equality `launch`). */
|
|
1052
|
+
function instrumentEpRows(space, pr, tier, extra = []) {
|
|
1053
|
+
if (!pr.lifecycleUid)
|
|
1054
|
+
throw new Error(`permissionsFor: an operator instrument's ep caller rows are lifecycle-keyed (SPEC 13.1/13.2) - mint with opts.lifecycleUid (mintLifecycleUid()) so the reply rail pins the instrument's own incarnation`);
|
|
1055
|
+
const epCaller = { owner: pr.owner, actor: pr.actor, uid: pr.lifecycleUid };
|
|
1056
|
+
const rows = epCallerGrantRows(space, [...operatorInstrumentCapabilities(tier), ...extra], epCaller);
|
|
914
1057
|
return {
|
|
915
|
-
pub:
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
sub:
|
|
1058
|
+
pub: [
|
|
1059
|
+
epDescribeAllGrantRow(space, epCaller),
|
|
1060
|
+
`$JS.API.DIRECT.GET.${epcStreamName(space)}.${spacePrefix(space)}.epc.>`,
|
|
1061
|
+
...rows.pub,
|
|
1062
|
+
],
|
|
1063
|
+
sub: rows.sub,
|
|
921
1064
|
};
|
|
922
1065
|
}
|
|
923
1066
|
/** ENDPOINT-SERVE (v0.4, SPEC §13.9 "Serve grants") — the per-instance serve credential:
|
|
@@ -970,23 +1113,34 @@ function endpointServePermissions(space, pr, opts) {
|
|
|
970
1113
|
}
|
|
971
1114
|
/** DEPLOYER (PR 1.5) — the `cotal spawn -f` manifest-deploy authority. `spawn -f` drives ONE
|
|
972
1115
|
* `connectProbe` endpoint that both READS live state (roster/presence watch, channel registry,
|
|
973
|
-
* membership feed, manager-singleton lease) AND
|
|
974
|
-
*
|
|
975
|
-
*
|
|
1116
|
+
* membership feed, manager-singleton lease) AND invokes the running manager's `launch` + `ps`
|
|
1117
|
+
* readiness over the v0.4 ep rails. Those interleave on one connection, so a strict 3-connection
|
|
1118
|
+
* split would only refactor `live.ts` for marginal gain; `deployer` is that one coherent,
|
|
976
1119
|
* ephemeral deploy cred. It is the SOLE profile that combines reads + admin-control — NOT a template a
|
|
977
1120
|
* 4th command should reach for (revisit the connection split before adding a second such caller).
|
|
978
1121
|
*
|
|
979
1122
|
* Boundaries (all enforced by omission / default-deny): NO self-post (`chat`/`inst`/`svc`), NO `$JS.>`,
|
|
980
1123
|
* NO `STREAM.DELETE`/`PURGE`/`UPDATE`, NO DM/DLV/TASK `CONSUMER.CREATE` (no body-read surface), NO `$KV`
|
|
981
|
-
* writes (channel seeding rides a SEPARATE `channel-writer` cred)
|
|
982
|
-
*
|
|
983
|
-
* that IS real
|
|
984
|
-
* (
|
|
985
|
-
* launch ANY agent
|
|
986
|
-
*
|
|
987
|
-
*
|
|
988
|
-
*
|
|
989
|
-
function deployerPermissions(space, pr,
|
|
1124
|
+
* writes (channel seeding rides a SEPARATE `channel-writer` cred). It holds the admin INSTRUMENT ep
|
|
1125
|
+
* set (any-mode despawn/attach + the manager.admin family + reads) because manifest launch/ps
|
|
1126
|
+
* genuinely need cross-agent reach — and that IS real power: the manager's any-mode authz is
|
|
1127
|
+
* subject-gated (the any-mode ep row is mintable only under operator policy), so holding it lets it
|
|
1128
|
+
* stop/attach/launch ANY agent. The BROKER gating that row is the boundary. Containment is therefore
|
|
1129
|
+
* the LIFETIME, not a manager re-check: minted from LOCAL same-checkout auth for one `spawn -f`,
|
|
1130
|
+
* memory-only, dropped after deploy. If it is ever persisted, handed to user-supplied `--creds`, or
|
|
1131
|
+
* reused as a general "read + admin" cred, revisit. */
|
|
1132
|
+
function deployerPermissions(space, pr, epTier = "admin", opts = {}) {
|
|
1133
|
+
// The v0.4 ep rows of the deploy authority: static (admin) deploys carry the admin instrument
|
|
1134
|
+
// set; the user-mode deployer VIEW (privileged) carries the privileged set PLUS an untargeted
|
|
1135
|
+
// `launch` row — its launch stays owner-equality-authorized (the manager's ledger-derived admin
|
|
1136
|
+
// flag is false for a spawn-scoped deployer), exactly the v0.3 user-mode privileged-tier launch.
|
|
1137
|
+
// B6 / `--on`: the per-invocation pin APPENDS to this profile's standing set, never replaces it -
|
|
1138
|
+
// the privileged deployer view keeps its owner-equality `launch` row and additionally gets the one
|
|
1139
|
+
// exact `ep.inst.<endpoint>.<iid>.<command>` row for the instance this deploy resolved.
|
|
1140
|
+
const pinned = opts.endpointCapabilities ?? [];
|
|
1141
|
+
const ep = epTier === "admin"
|
|
1142
|
+
? instrumentEpRows(space, pr, "admin", pinned)
|
|
1143
|
+
: instrumentEpRows(space, pr, "privileged", [{ endpoint: BASELINE_LIFECYCLE_ENDPOINT, command: "launch" }, ...pinned]);
|
|
990
1144
|
const PKV = `KV_${presenceBucket(space)}`, CHKV = `KV_${channelBucket(space)}`;
|
|
991
1145
|
const MSHIP = `KV_${membershipBucket(space)}`, MGRKV = `KV_${managerBucket(space)}`;
|
|
992
1146
|
const DLVKV = `KV_${deliveryBucket(space)}`;
|
|
@@ -1017,16 +1171,15 @@ function deployerPermissions(space, pr, tier = CONTROL_ADMIN) {
|
|
|
1017
1171
|
...kvPointRead(MGRKV), // manager-singleton lease keyed read (waitManagerReady) — point-get, NO write, NO watch
|
|
1018
1172
|
...kvPointRead(DLVKV), // delivery-lease keyed read (preserve-state quiescence proof) — point-get, NO write, NO watch
|
|
1019
1173
|
"$JS.FC.>", // ordered-consumer flow control
|
|
1020
|
-
//
|
|
1021
|
-
//
|
|
1022
|
-
//
|
|
1023
|
-
|
|
1174
|
+
// 1d: launch + ps readiness ride the v0.4 ep rows only (the manager `ctl` rail is gone).
|
|
1175
|
+
// Static deploys carry the admin instrument set; the user-mode deployer VIEW carries the
|
|
1176
|
+
// privileged set + an owner-equality `launch` row (the manager's ledger-derived admin flag
|
|
1177
|
+
// is false for a spawn-scoped deployer, so its launch stays owner-equality-authorized).
|
|
1178
|
+
...ep.pub,
|
|
1024
1179
|
],
|
|
1025
1180
|
},
|
|
1026
|
-
// Own inbox (presence/registry watch delivery + JS API responses) + the
|
|
1027
|
-
|
|
1028
|
-
// so without this grant the launch + ps-readiness calls hang to timeout.
|
|
1029
|
-
sub: { allow: [`_INBOX_${pr.connId}.>`, `${controlServiceSubject(space, tier, pr.owner, pr.actor)}.reply.>`] },
|
|
1181
|
+
// Own inbox (presence/registry watch delivery + JS API responses) + the ep reply rail.
|
|
1182
|
+
sub: { allow: [`_INBOX_${pr.connId}.>`, ...ep.sub] },
|
|
1030
1183
|
};
|
|
1031
1184
|
}
|
|
1032
1185
|
/** The ephemeral PURGER permission set (closure (ii), residual 2) — minted per-purge inside the daemon's
|
|
@@ -1068,8 +1221,14 @@ function purgerPermissions(space, pr) {
|
|
|
1068
1221
|
*
|
|
1069
1222
|
* `$JS` is an ENUMERATED allow-list, never `$JS.>`: STREAM.CREATE + INFO for the space streams/buckets,
|
|
1070
1223
|
* DM/DLV/TASK consumer CREATE/DURABLE.CREATE/INFO — and deliberately NO `MSG.NEXT`/`MSG.GET`/`ACK` on
|
|
1071
|
-
* DM/DLV (it creates the bind-only mailbox but never reads it), NO STREAM.DELETE/PURGE/
|
|
1072
|
-
* (it provisions, it does not tear down
|
|
1224
|
+
* DM/DLV (it creates the bind-only mailbox but never reads it), and NO STREAM.DELETE/PURGE/MSG.DELETE
|
|
1225
|
+
* (it provisions, it does not tear down). STREAM.UPDATE is held on EXACTLY four streams and no others:
|
|
1226
|
+
* the three TTL'd KV buckets (presence + the two leases, #286 — an existing bucket's `max_age` cannot be
|
|
1227
|
+
* fixed by `kvm.create`, so reconciling a pre-TTL deployment requires updating it) and the records store.
|
|
1228
|
+
* Stated positively on purpose: this docblock previously read "NO …/UPDATE", which was already untrue of
|
|
1229
|
+
* the records stream and became untrue of the buckets, and a comment that denies a credential's real
|
|
1230
|
+
* power is worse than none — it is the document a reader trusts instead of checking. KV value-writes are
|
|
1231
|
+
* scoped to exactly the two
|
|
1073
1232
|
* registries provisioning touches: the read-ACL bucket (`commitAcl`) and the channel registry (seed). */
|
|
1074
1233
|
function provisionerPermissions(space, pr) {
|
|
1075
1234
|
const CHAT = chatStream(space), DM = dmStream(space), TASK = taskStream(space);
|
|
@@ -1077,15 +1236,52 @@ function provisionerPermissions(space, pr) {
|
|
|
1077
1236
|
// Every backing stream the provisioner pre-creates — the 5 message streams + the KV buckets (a bucket's
|
|
1078
1237
|
// backing stream is `KV_<bucket>`). `managerBucket` is now pre-created here too (so the supervisor binds
|
|
1079
1238
|
// its lease open-only); members/membership/delivery are written by other creds but created at setup here.
|
|
1239
|
+
// The §13.12 AUTHORITY stores (records + auth + the P2 item 6 session ledger) join the list for the
|
|
1240
|
+
// STATIC manager's start-time `createEndpointStreams` (a superset of ensureAuthorityStores +
|
|
1241
|
+
// createSessionsStore): create-or-verify only — the provisioner holds NO value-write on any of them
|
|
1242
|
+
// (lifecycle state moves through the key-pinned `lifecycle-executor` cred; session rows through the
|
|
1243
|
+
// scoped `session-ledger` cred).
|
|
1080
1244
|
const buckets = [
|
|
1081
1245
|
presenceBucket, channelBucket, membersBucket, aclBucket, membershipBucket, deliveryBucket, managerBucket,
|
|
1246
|
+
recordsBucket, epAuthBucket, sessionsBucket,
|
|
1082
1247
|
].map((b) => `KV_${b(space)}`);
|
|
1083
1248
|
// STREAM.CREATE + INFO for each (idempotent setup at `cotal up`; CREATE is create-if-matching, INFO covers
|
|
1084
|
-
// the client's existence checks). NO DELETE/PURGE
|
|
1085
|
-
|
|
1249
|
+
// the client's existence checks). NO DELETE/PURGE — provisioning never tears a stream down.
|
|
1250
|
+
// The §13.7 CONTRACT store (EPC) joins the list for the static manager's start-time
|
|
1251
|
+
// `ensureContractStore` (P2 item 1, 1c): create-or-verify only — the provisioner holds no
|
|
1252
|
+
// artifact-publish grant on it (publication rides the scoped endpoint-serve executor).
|
|
1253
|
+
// The seven §13.12 ENDPOINT streams join the list (P2 item 2): spawn-as-action makes the manager
|
|
1254
|
+
// the first EPF (goal facts) + EPE (progress) writer, and nothing provisioned the endpoint streams
|
|
1255
|
+
// before (no manager code wrote to them), so `createEndpointStreams` now runs at the manager's
|
|
1256
|
+
// start-time ensure over this provisioner. Create-or-verify only (idempotent, fail-loud on drift);
|
|
1257
|
+
// the provisioner holds no value-write on any of them (goal facts ride the scoped goal-writer cred).
|
|
1258
|
+
const endpointStreams = [epjStreamName, epfStreamName, epeStreamName, eptReqStreamName, eprStreamName, eptStreamName, epwStreamName].map((f) => f(space));
|
|
1259
|
+
// The artifact Object Store joins the list: `setupSpaceStreams` creates it, and under auth mode the
|
|
1260
|
+
// provisioner is the cred doing that creating. Its backing stream is `OBJ_<bucket>` - named
|
|
1261
|
+
// explicitly, because `$O.<bucket>.>` is outside the `cotal.<space>.>` grammar and no space-prefix
|
|
1262
|
+
// grant reaches it. CREATE + INFO only: the provisioner never publishes an object, never creates a
|
|
1263
|
+
// consumer on it, and never deletes it. That confinement is load-bearing rather than tidy - the
|
|
1264
|
+
// object-store client reads by creating an ephemeral PUSH consumer with a caller-chosen
|
|
1265
|
+
// `deliver_subject`, so a CONSUMER.CREATE here would be an exporter of every artifact in the space.
|
|
1266
|
+
const OBJ = objectStoreStream(artifactBucket(space));
|
|
1267
|
+
const streamSetup = [CHAT, DM, TASK, INBOX, DLV, epcStreamName(space), OBJ, ...endpointStreams, ...buckets].flatMap((s) => [
|
|
1086
1268
|
`$JS.API.STREAM.CREATE.${s}`,
|
|
1087
1269
|
`$JS.API.STREAM.INFO.${s}`,
|
|
1088
1270
|
]);
|
|
1271
|
+
// #286: STREAM.UPDATE on EXACTLY the three TTL'd KV streams (presence + the two leases). `kvm.create`
|
|
1272
|
+
// never updates an existing bucket's config, so a bucket created by a cotal that predates the `max_age`
|
|
1273
|
+
// TTL keeps NO expiry forever — dead presence records (and stale leases) never age out. `setupSpaceStreams`
|
|
1274
|
+
// reconciles their `max_age` via STREAM.UPDATE at every `cotal up`, which needs this grant. Scoped to these
|
|
1275
|
+
// three streams only — the durable streams (chat/dm/task/inbox/dlv, channel/members/acl/membership
|
|
1276
|
+
// registries) are never updated — and still NO DELETE/PURGE. The supervisor profile keeps its full UPDATE
|
|
1277
|
+
// denial; this widening is provisioning-only.
|
|
1278
|
+
// Derived from the SAME inventory that creates and reconciles them, not a third hand-kept copy.
|
|
1279
|
+
// Review found this list was the last independent one: a fourth TTL'd bucket added to `ttlBuckets`
|
|
1280
|
+
// would be created and reconciled correctly and then die on a permissions violation here, because
|
|
1281
|
+
// the grant never learned about it. Same defect one seam out — a bucket the code knows to maintain
|
|
1282
|
+
// and the credential is not allowed to.
|
|
1283
|
+
const ttlStreams = ttlBuckets(space).map(([bucket]) => `KV_${bucket}`);
|
|
1284
|
+
const streamReconcile = ttlStreams.map((s) => `$JS.API.STREAM.UPDATE.${s}`);
|
|
1089
1285
|
// DM/DLV/TASK durable pre-create (bind-only mailboxes): both the new-API CREATE and legacy DURABLE.CREATE
|
|
1090
1286
|
// forms (the client's consumer-add path varies by version), plus INFO (the add returns ConsumerInfo).
|
|
1091
1287
|
// NO MSG.NEXT/MSG.GET/ACK — the provisioner creates the consumer but MUST NOT read its body.
|
|
@@ -1099,6 +1295,7 @@ function provisionerPermissions(space, pr) {
|
|
|
1099
1295
|
allow: [
|
|
1100
1296
|
"$JS.API.INFO",
|
|
1101
1297
|
...streamSetup,
|
|
1298
|
+
...streamReconcile,
|
|
1102
1299
|
...consumerCreate,
|
|
1103
1300
|
// KV value-writes — exactly the two registries provisioning writes: the agent read-ACL registry
|
|
1104
1301
|
// (`commitAcl` at provision) and the channel registry (seed defaults at `cotal up`, channel admin).
|
|
@@ -1114,6 +1311,22 @@ function provisionerPermissions(space, pr) {
|
|
|
1114
1311
|
`$JS.API.DIRECT.GET.KV_${aclBucket(space)}.>`, // keyed get: `.>` (the key rides the subject)
|
|
1115
1312
|
`$JS.API.STREAM.MSG.GET.KV_${channelBucket(space)}`,
|
|
1116
1313
|
`$JS.API.DIRECT.GET.KV_${channelBucket(space)}.>`, // keyed get: `.>` (the key rides the subject)
|
|
1314
|
+
// The Unit B static-manager start path: `ensureAuthorityStores` UPDATEs the records store's
|
|
1315
|
+
// deny-flags exactly once at fresh creation (create → update → verify), and the boot
|
|
1316
|
+
// reconciliation sweep enumerates the manager's slot rows (`keys()` → an ordered consumer)
|
|
1317
|
+
// then reads each slot BODY (phase/uid/actor) to plan resume — the reads ride the
|
|
1318
|
+
// stream-scoped MSG.GET residual named below (records lifecycle metadata, no secrets).
|
|
1319
|
+
`$JS.API.STREAM.UPDATE.KV_${recordsBucket(space)}`,
|
|
1320
|
+
`$JS.API.CONSUMER.CREATE.KV_${recordsBucket(space)}.>`,
|
|
1321
|
+
`$JS.API.CONSUMER.INFO.KV_${recordsBucket(space)}.>`,
|
|
1322
|
+
`$JS.API.CONSUMER.DELETE.KV_${recordsBucket(space)}.>`,
|
|
1323
|
+
// ...and reads the slot-mapping rows so the sweep can plan resume actions. The KV client
|
|
1324
|
+
// reads a lazily-bound bucket via leader-served `STREAM.MSG.GET` — stream-scoped, NOT
|
|
1325
|
+
// key-scoped (the requested key rides the PAYLOAD). NAMED RESIDUAL: for its one-shot
|
|
1326
|
+
// lifetime the provisioner can READ (never write) any records-store row — lifecycle
|
|
1327
|
+
// metadata, no secrets. The keyed Direct Get grant stays for direct-aware read paths.
|
|
1328
|
+
`$JS.API.STREAM.MSG.GET.KV_${recordsBucket(space)}`,
|
|
1329
|
+
`$JS.API.DIRECT.GET.KV_${recordsBucket(space)}.$KV.${recordsBucket(space)}.${STATIC_SLOT_PREFIX}.>`,
|
|
1117
1330
|
],
|
|
1118
1331
|
},
|
|
1119
1332
|
// Replies only: every stream/consumer/KV-create PubAck and JS API response lands on the per-id inbox.
|
|
@@ -1121,6 +1334,185 @@ function provisionerPermissions(space, pr) {
|
|
|
1121
1334
|
sub: { allow: [`_INBOX_${pr.connId}.>`] },
|
|
1122
1335
|
};
|
|
1123
1336
|
}
|
|
1337
|
+
/** The ephemeral, LIFECYCLE-PINNED §13.1 state-write permission set for the STATIC manager's
|
|
1338
|
+
* lifecycle executor (Unit B). One credential per lifecycle OPERATION (activation, terminal,
|
|
1339
|
+
* renewal ledger append): every grant names exactly ONE incarnation's keys — the alias head,
|
|
1340
|
+
* the uid reservation, the manager slot row, the issuance gate, and the `cred.<uid>.>` ledger
|
|
1341
|
+
* family — so a leaked executor cred can move one incarnation's state machine and nothing else.
|
|
1342
|
+
*
|
|
1343
|
+
* Reads: records reads ride the keyed Direct Get form (the key is ON the subject, so the read
|
|
1344
|
+
* grant stays key-pinned); the auth store is leader-served (`allow_direct=false`), so its reads
|
|
1345
|
+
* are body-selected `STREAM.MSG.GET` — stream-scoped, NOT key-scoped (the requested key rides
|
|
1346
|
+
* the PAYLOAD, which a subject grant cannot see). NAMED RESIDUAL: for its one-shot lifetime the
|
|
1347
|
+
* executor can READ (never write) other rows in the auth store. */
|
|
1348
|
+
function lifecycleExecutorPermissions(space, pr, pin) {
|
|
1349
|
+
const REC = recordsBucket(space), AUTH = epAuthBucket(space);
|
|
1350
|
+
// ALL keys DERIVED here from the pin coordinates — the slot key is `staticSlotKey(owner, alias)`,
|
|
1351
|
+
// NOT a caller-supplied literal, so a mis-constructed pin can only ever name ONE coherent
|
|
1352
|
+
// incarnation's rows (guard the core: the profile enforces the "one incarnation" promise, it
|
|
1353
|
+
// does not merely assert it). The builders throw on any non-KV-safe segment.
|
|
1354
|
+
const recordKeys = [lifecycleHeadKey(pin.owner, pin.actor), uidReservationKey(pin.lifecycleUid), staticSlotKey(pin.owner, pin.alias)];
|
|
1355
|
+
return {
|
|
1356
|
+
pub: {
|
|
1357
|
+
allow: [
|
|
1358
|
+
"$JS.API.INFO",
|
|
1359
|
+
// Records-store CAS writes — the value-publish carries the key on the subject, so each
|
|
1360
|
+
// grant names exactly one of this incarnation's keys.
|
|
1361
|
+
...recordKeys.map((k) => `$KV.${REC}.${k}`),
|
|
1362
|
+
// Auth-store CAS writes — this incarnation's gate + its cred-ledger family (renewals
|
|
1363
|
+
// append rows here; the terminal's B1 revoke CASes them).
|
|
1364
|
+
`$KV.${AUTH}.${issuanceGateKey(pin.lifecycleUid)}`,
|
|
1365
|
+
`$KV.${AUTH}.cred.${pin.lifecycleUid}.>`,
|
|
1366
|
+
// Keyed Direct Get reads of the same records keys (key-pinned) for direct-aware read
|
|
1367
|
+
// paths, PLUS the leader-served `STREAM.MSG.GET` the lazily-bound KV client actually
|
|
1368
|
+
// uses — stream-scoped, NOT key-scoped (the requested key rides the PAYLOAD, which a
|
|
1369
|
+
// subject grant cannot see). NAMED RESIDUAL: for its one-shot lifetime the executor can
|
|
1370
|
+
// READ (never write) other rows in BOTH authority stores — lifecycle metadata, no
|
|
1371
|
+
// secrets; every WRITE stays key-pinned above.
|
|
1372
|
+
...recordKeys.map((k) => `$JS.API.DIRECT.GET.KV_${REC}.$KV.${REC}.${k}`),
|
|
1373
|
+
`$JS.API.STREAM.MSG.GET.KV_${REC}`,
|
|
1374
|
+
`$JS.API.STREAM.MSG.GET.KV_${AUTH}`,
|
|
1375
|
+
],
|
|
1376
|
+
},
|
|
1377
|
+
sub: { allow: [`_INBOX_${pr.connId}.>`] },
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
/** The ephemeral, ENDPOINT-INSTANCE-PINNED endpoint-serve executor permission set (P2 item 1,
|
|
1381
|
+
* 1a-serve): the manager mints this per registration/serve-mint op and drives the endpoint
|
|
1382
|
+
* registration barrier's `epgate` CAS + the mint fence's `epcred` stage/revoke THROUGH it — never
|
|
1383
|
+
* its standing seed/supervisor connection (critic #1's manager-specific "no seed shortcut"). Every
|
|
1384
|
+
* WRITE is key-pinned to exactly ONE (endpoint, instanceId): the gate `epgate.<ep>.<iid>`, its
|
|
1385
|
+
* serving ledger family `epcred.<ep>.<iid>.>`, and the registration's two records keys (the
|
|
1386
|
+
* instance's `svc` spec + the endpoint's governance head — `registerServiceInstance` drives the
|
|
1387
|
+
* slot-take/promote over this same connection). A leaked/mis-constructed executor can move exactly
|
|
1388
|
+
* one endpoint instance's serve state and nothing else. The auth store is `allow_direct=false`, so
|
|
1389
|
+
* reads are leader-served `STREAM.MSG.GET` (stream-scoped, NOT key-scoped — the key rides the
|
|
1390
|
+
* payload); enumeration of the epcred family rides an ordered `keys()` consumer. NAMED RESIDUAL:
|
|
1391
|
+
* for its one-shot lifetime the executor can READ (never write) other auth rows — endpoint/
|
|
1392
|
+
* credential metadata, no bearer bytes; every WRITE stays key-pinned. */
|
|
1393
|
+
/** The SELF-MEDIATED goal-writer profile (P2 item 2, spawn-as-action): exactly
|
|
1394
|
+
* {@link goalWriterGrants} for ITS endpoint — the goal bind + terminal facts, the goal-record KV
|
|
1395
|
+
* writes, and the leader-served fencing reads — plus the connection-scoped reply inbox. Disjoint
|
|
1396
|
+
* from the endpoint's serve credential (Q2): a serve connection carries none of these rows, so it
|
|
1397
|
+
* is broker-denied every goal write. */
|
|
1398
|
+
function goalWriterPermissions(space, pr, pin) {
|
|
1399
|
+
const g = goalWriterGrants(space, pin.endpoint, pr.connId);
|
|
1400
|
+
return { pub: { allow: g.publish }, sub: { allow: g.subscribe } };
|
|
1401
|
+
}
|
|
1402
|
+
/** The console/CLI per-session CALLER rows (P2 item 6): RAILS-ONLY for ONE §13.6 session — pub the
|
|
1403
|
+
* session's epoch-pinned `in` rail, sub its `out` rail plus the caller's own reply inbox, and
|
|
1404
|
+
* NOTHING else. Deliberately NO KV, NO JetStream API, NO store: the caller drives the terminal over
|
|
1405
|
+
* the two core-only eps subjects and never reads the session ledger, so there is no subject-blind
|
|
1406
|
+
* store read to widen (SPEC §13.9). The endpoint+sessionId+epoch pin the EXACT pair, so a cred for
|
|
1407
|
+
* session A authorizes nothing of session B (no wildcard). `epsSubject` validates every token, so a
|
|
1408
|
+
* malformed coordinate refuses at the mint rather than emitting a broadened subject. */
|
|
1409
|
+
function sessionCallerPermissions(space, pr, pin) {
|
|
1410
|
+
if (!pin)
|
|
1411
|
+
throw new Error('permissionsFor: "session-caller" requires opts.sessionCaller ({endpoint, sessionId, epoch}) - the ONE session\'s rails this cred may use');
|
|
1412
|
+
return {
|
|
1413
|
+
pub: { allow: [epsSubject(space, pin.endpoint, pin.sessionId, pin.epoch, "in")] },
|
|
1414
|
+
sub: { allow: [epsSubject(space, pin.endpoint, pin.sessionId, pin.epoch, "out"), `_INBOX_${pr.connId}.>`] },
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
/** The manager's per-session SERVING rows (P2 item 6): the EXACT mirror of
|
|
1418
|
+
* {@link sessionCallerPermissions} with the directions swapped — sub the ONE session's epoch-pinned
|
|
1419
|
+
* `in` rail (caller→serving), pub its `out` rail (serving→caller), plus the connection-scoped reply
|
|
1420
|
+
* inbox, and NOTHING else. Same asymmetry §13.6 states: "the caller publishes `in` and subscribes
|
|
1421
|
+
* `out`; the serving instance the reverse".
|
|
1422
|
+
*
|
|
1423
|
+
* Deliberately NO KV and NO JetStream API — not even the session ledger. The serving side drives
|
|
1424
|
+
* bytes; the ledger is the standing `session-ledger` credential's, on a different connection. That
|
|
1425
|
+
* separation is what lets this credential be one-shot and die with its session while the durable
|
|
1426
|
+
* revocation authority outlives it (§13.6).
|
|
1427
|
+
*
|
|
1428
|
+
* This REPLACES a standing writer that held `eps.<endpoint>.*.<epoch>.{in,out}` and so could read
|
|
1429
|
+
* and write every live session's bytes at its epoch. The pin makes a credential for session A
|
|
1430
|
+
* authorize nothing of session B, and `epsSubject` validates every token, so a malformed
|
|
1431
|
+
* coordinate refuses at the mint rather than emitting a broadened subject. */
|
|
1432
|
+
function sessionServingPermissions(space, pr, pin) {
|
|
1433
|
+
if (!pin)
|
|
1434
|
+
throw new Error('permissionsFor: "session-serving" requires opts.sessionServing ({endpoint, sessionId, epoch}) - the ONE session\'s rails this cred may serve');
|
|
1435
|
+
return {
|
|
1436
|
+
pub: { allow: [epsSubject(space, pin.endpoint, pin.sessionId, pin.epoch, "out")] },
|
|
1437
|
+
sub: { allow: [epsSubject(space, pin.endpoint, pin.sessionId, pin.epoch, "in"), `_INBOX_${pr.connId}.>`] },
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
/** The manager's SESSION-LEDGER rows (P2 item 6): the DEDICATED sessions-bucket rows and nothing
|
|
1441
|
+
* else — no session rail of any shape. Needs no pin: the grant carries no endpoint, epoch, or
|
|
1442
|
+
* session component, because §13.6's durable revocation authority is per-space, not per-session
|
|
1443
|
+
* (it must still be able to resolve and revoke a row after the endpoint that served it is gone).
|
|
1444
|
+
* {@link sessionLedgerGrants} carries the row rationale, the §13.9 subject-blindness confinement,
|
|
1445
|
+
* and the named `session.*` breadth residual. */
|
|
1446
|
+
function sessionLedgerPermissions(space, pr) {
|
|
1447
|
+
const g = sessionLedgerGrants(space, pr.connId);
|
|
1448
|
+
return { pub: { allow: g.publish }, sub: { allow: g.subscribe } };
|
|
1449
|
+
}
|
|
1450
|
+
function endpointServeExecutorPermissions(space, pr, pin) {
|
|
1451
|
+
const AUTH = epAuthBucket(space), REC = recordsBucket(space);
|
|
1452
|
+
// DERIVED from (endpoint, instanceId) via the core builders — never a caller literal.
|
|
1453
|
+
const gateKey = epgateKey(pin.endpoint, pin.instanceId);
|
|
1454
|
+
const credPrefix = epcredFamilyPrefix(pin.endpoint, pin.instanceId);
|
|
1455
|
+
// The registration writes this ONE instance's spec key plus the endpoint's governance head
|
|
1456
|
+
// (`registerServiceInstance` PHASE 1/3b: the slot-take + promote ride the SAME executor), and this
|
|
1457
|
+
// instance's own svc STATUS key (P2 item 3: the manager writes its CONVERGED `ready` status so it
|
|
1458
|
+
// is a §13.5 scatter member — `freezeExpectedSet` requires a status caught up to the current
|
|
1459
|
+
// registration; the write is epoch-fenced by `writeServiceStatus`).
|
|
1460
|
+
const recordKeys = [
|
|
1461
|
+
recordSpecKey(RECORD_KINDS.svc, [pin.endpoint, assertLifecycleToken(pin.instanceId, "instanceId")]),
|
|
1462
|
+
recordStatusKey(RECORD_KINDS.svc, [pin.endpoint, assertLifecycleToken(pin.instanceId, "instanceId")]),
|
|
1463
|
+
recordAtomicKey(GOVERN_HEAD, [pin.endpoint]),
|
|
1464
|
+
];
|
|
1465
|
+
return {
|
|
1466
|
+
pub: {
|
|
1467
|
+
allow: [
|
|
1468
|
+
"$JS.API.INFO",
|
|
1469
|
+
// The key-pinned WRITE grants: the gate (provision create + barrier freeze/reopen CAS),
|
|
1470
|
+
// the serving ledger family (mint-fence stage + barrier revoke), and the registration's
|
|
1471
|
+
// two records-store keys (spec CAS + governance slot/promote). The value-publish carries
|
|
1472
|
+
// the key on the subject, so each grant names exactly this instance's keys.
|
|
1473
|
+
`$KV.${AUTH}.${gateKey}`,
|
|
1474
|
+
`$KV.${AUTH}.${credPrefix}.>`,
|
|
1475
|
+
...recordKeys.map((k) => `$KV.${REC}.${k}`),
|
|
1476
|
+
// §13.7 contract-artifact publication (P2 item 1, 1c): the registration publishes the
|
|
1477
|
+
// endpoint's cluster document, closure manifests, and schema roots to the EPC store so
|
|
1478
|
+
// callers can fetch-verify-compile the registered digests. A digest subject is a SINGLE
|
|
1479
|
+
// hex token (`epc.<64hex>`), so the grant is the single-token `epc.*` form (matching
|
|
1480
|
+
// `contractPublisherGrants`), never the multi-token `epc.>`. Digest subjects cannot be
|
|
1481
|
+
// key-pinned pre-mint; the store's SHAPE is the defense (ensureContractStore): a digest
|
|
1482
|
+
// subject holds exactly one broker-immutable message (max_msgs_per_subject:1 +
|
|
1483
|
+
// discard-new-per-subject REJECTS a second publish regardless of the create-only header, so
|
|
1484
|
+
// a grant-holder CANNOT append a shadow over a published artifact), deny_delete/deny_purge
|
|
1485
|
+
// keep that message, and content addressing (verify-on-read, create-only-winner fallback)
|
|
1486
|
+
// makes a wrong-subject write unservable. NAMED RESIDUAL: for its one-shot lifetime the
|
|
1487
|
+
// executor can publish NEW digest-addressed artifacts at previously-unused subjects (a
|
|
1488
|
+
// bounded storage flood; unreferenced artifacts carry no authority) — it can NOT overwrite,
|
|
1489
|
+
// shadow, or replace an existing one.
|
|
1490
|
+
`${spacePrefix(space)}.epc.*`,
|
|
1491
|
+
// The lost-CAS verify-read `publishContractArtifact` runs when a re-publish (every re-up /
|
|
1492
|
+
// restart against an existing store) loses the create-only CAS: it fetches the recorded
|
|
1493
|
+
// artifact to confirm the idempotent no-op. BOTH Direct Get forms — the subject-scoped
|
|
1494
|
+
// `last_by_subj` (the fast path) and the bare stream form (the create-only-winner
|
|
1495
|
+
// `next_by_subj` fallback) — so the publish path never dies on a broker denial regardless
|
|
1496
|
+
// of stream config. Without this the manager exits on its SECOND boot (the tester's
|
|
1497
|
+
// upgrade-path regression). Reads public content-addressed artifacts only.
|
|
1498
|
+
`$JS.API.DIRECT.GET.${epcStreamName(space)}.${spacePrefix(space)}.epc.>`,
|
|
1499
|
+
`$JS.API.DIRECT.GET.${epcStreamName(space)}`,
|
|
1500
|
+
// Leader-served reads (the auth store is allow_direct=false): stream-scoped MSG.GET (the
|
|
1501
|
+
// barrier/fence read the gate + each epcred row), plus the ordered consumer the epcred
|
|
1502
|
+
// `keys()` enumeration binds. The records store IS direct-servable, so its reads add the
|
|
1503
|
+
// key-pinned DIRECT.GET forms beside the leader-served fallback. NAMED RESIDUAL: reads
|
|
1504
|
+
// any row in both stores for its one-shot lifetime (metadata, no bearer bytes).
|
|
1505
|
+
`$JS.API.STREAM.MSG.GET.KV_${AUTH}`,
|
|
1506
|
+
`$JS.API.CONSUMER.CREATE.KV_${AUTH}.>`,
|
|
1507
|
+
`$JS.API.CONSUMER.INFO.KV_${AUTH}.>`,
|
|
1508
|
+
`$JS.API.CONSUMER.DELETE.KV_${AUTH}.>`,
|
|
1509
|
+
...recordKeys.map((k) => `$JS.API.DIRECT.GET.KV_${REC}.$KV.${REC}.${k}`),
|
|
1510
|
+
`$JS.API.STREAM.MSG.GET.KV_${REC}`,
|
|
1511
|
+
],
|
|
1512
|
+
},
|
|
1513
|
+
sub: { allow: [`_INBOX_${pr.connId}.>`] },
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1124
1516
|
/** The ephemeral, TARGET-PINNED DEPROVISIONER permission set (#159 Part B) — the teardown counterpart
|
|
1125
1517
|
* to {@link provisionerPermissions}, minted per departed agent inside the manager's `deprovision` tail
|
|
1126
1518
|
* (`withProvisioner`-style: a fresh scoped cred per teardown is cheap). It deletes exactly the
|
|
@@ -1195,6 +1587,15 @@ function deliveryPermissions(space, pr) {
|
|
|
1195
1587
|
// chat (the fan-out consumes the whole stream), so a stream-wide CHAT consumer grant is no
|
|
1196
1588
|
// escalation. The catch-up ephemeral names (`cu_<owner>_<gen>`) are dynamic, so they can't be
|
|
1197
1589
|
// name-pinned; CHAT-wide is correct here.
|
|
1590
|
+
//
|
|
1591
|
+
// BOTH forms, and the bare one is not redundant: a create carrying `filter_subjects` cannot encode
|
|
1592
|
+
// its filter in the subject, so the client publishes to the BARE `…CREATE.<CHAT>` while a named
|
|
1593
|
+
// consumer goes to the `.>` form. The mediated history read (`readHistory`) builds exactly that
|
|
1594
|
+
// multi-filter ephemeral, and with only the `.>` grant it fails with a Permissions Violation on the
|
|
1595
|
+
// bare subject — measured, not predicted. The observer profile already carries both for the same
|
|
1596
|
+
// reason. Grants the daemon nothing new in substance: it may already create any named consumer on
|
|
1597
|
+
// this stream and already reads all of it.
|
|
1598
|
+
`$JS.API.CONSUMER.CREATE.${CHAT}`,
|
|
1198
1599
|
`$JS.API.CONSUMER.CREATE.${CHAT}.>`,
|
|
1199
1600
|
`$JS.API.CONSUMER.DURABLE.CREATE.${CHAT}.>`,
|
|
1200
1601
|
`$JS.API.CONSUMER.INFO.${CHAT}.>`,
|
|
@@ -1295,7 +1696,7 @@ function membershipObserverPermissions(accountId) {
|
|
|
1295
1696
|
* (`auth.account.pub`). Mirrors {@link mintCreds} but issues into the system account. */
|
|
1296
1697
|
export async function mintMembershipObserverCreds(auth, identity, opts = {}) {
|
|
1297
1698
|
if (!auth.sys.signingSeed)
|
|
1298
|
-
throw new Error("mintMembershipObserverCreds: no in-memory system-account signing seed - the observer can only be minted
|
|
1699
|
+
throw new Error("mintMembershipObserverCreds: no in-memory system-account signing seed - the observer can only be minted from a system account that is being (re)provisioned, because the $SYS seed is never persisted. Rotate the system account to mint a fresh one (`cotal down` then `cotal up --rotate-sys`); a plain re-`up` reuses the existing account and its existing creds.");
|
|
1299
1700
|
const signer = fromSeed(new TextEncoder().encode(auth.sys.signingSeed));
|
|
1300
1701
|
const perms = membershipObserverPermissions(auth.account.pub);
|
|
1301
1702
|
// Bounded exp (D5 slice 5): the observer is `rotation-renewed` — it carries the matrix's default
|
|
@@ -1328,7 +1729,7 @@ function connectionEvictorPermissions() {
|
|
|
1328
1729
|
* only), same fail-loud when it's absent. Paired with the observer at `up`. */
|
|
1329
1730
|
export async function mintConnectionEvictorCreds(auth, identity, opts = {}) {
|
|
1330
1731
|
if (!auth.sys.signingSeed)
|
|
1331
|
-
throw new Error("mintConnectionEvictorCreds: no in-memory system-account signing seed - the evictor can only be minted
|
|
1732
|
+
throw new Error("mintConnectionEvictorCreds: no in-memory system-account signing seed - the evictor can only be minted from a system account that is being (re)provisioned, because the $SYS seed is never persisted. Rotate the system account to mint a fresh one (`cotal down` then `cotal up --rotate-sys`); a plain re-`up` reuses the existing account and its existing creds.");
|
|
1332
1733
|
const signer = fromSeed(new TextEncoder().encode(auth.sys.signingSeed));
|
|
1333
1734
|
// Bounded exp (D5 slice 5): `rotation-renewed`, same posture as the observer above.
|
|
1334
1735
|
const validDates = userValidDates("connection-evictor", opts);
|
|
@@ -1347,6 +1748,53 @@ export async function mintConnectionEvictorCreds(auth, identity, opts = {}) {
|
|
|
1347
1748
|
* NOTE (W4): the MEMORY resolver is one static whole-broker map, so every mutation rewrites all of
|
|
1348
1749
|
* it. Concurrent add/remove of spaces needs a broker-authoritative inventory with generation/CAS
|
|
1349
1750
|
* and atomic promotion above this function; this renderer is deliberately pure. */
|
|
1751
|
+
/**
|
|
1752
|
+
* Render the config for an OPEN (no-auth) broker.
|
|
1753
|
+
*
|
|
1754
|
+
* This exists so that no path reaches a listener without naming its transport. Open mode used to
|
|
1755
|
+
* start nats-server from bare CLI flags (`-js -sd … -p … -a …`) and never called `serverConfig` at
|
|
1756
|
+
* all, which meant the required `transport` union protected the auth path and was silent on the
|
|
1757
|
+
* open one: an operator could pass a cert and key, watch `up` print its normal banner, and get a
|
|
1758
|
+
* cleartext listener. That is the silent downgrade this feature exists to prevent, reachable by
|
|
1759
|
+
* someone who did everything right — so open mode renders a config too.
|
|
1760
|
+
*
|
|
1761
|
+
* It deliberately does NOT reuse `serverConfig`: that renders the operator, system account and
|
|
1762
|
+
* MEMORY resolver, none of which a no-auth broker should carry. What the two share is the thing
|
|
1763
|
+
* that matters — a REQUIRED transport, so the choice cannot be omitted on either path.
|
|
1764
|
+
*
|
|
1765
|
+
* IMPORTANT, and it must be said wherever open-mode TLS is surfaced to an operator: TLS ON AN
|
|
1766
|
+
* OPEN MESH GIVES CONFIDENTIALITY, NOT AUTHENTICATION. It hides traffic from a passive observer.
|
|
1767
|
+
* It does not verify who is connecting, because an open broker has no credentials to check — so
|
|
1768
|
+
* anyone who can reach the port still gets in, encrypted. It is a legitimate configuration for a
|
|
1769
|
+
* mesh crossing a network nobody controls, and it is NOT "secure" in the sense a reader will
|
|
1770
|
+
* assume from seeing `cotals://`. Describe it as the caveat it is rather than as a feature.
|
|
1771
|
+
*/
|
|
1772
|
+
export function openServerConfig(opts) {
|
|
1773
|
+
const port = opts.port ?? 4222;
|
|
1774
|
+
const host = opts.host ?? "127.0.0.1";
|
|
1775
|
+
return `# Generated by \`cotal up\` - do not edit by hand.
|
|
1776
|
+
host: ${host}
|
|
1777
|
+
port: ${port}
|
|
1778
|
+
max_control_line: 65536
|
|
1779
|
+
${renderTlsBlock(opts.transport)}jetstream { store_dir: ${JSON.stringify(opts.storeDir)} }
|
|
1780
|
+
`;
|
|
1781
|
+
}
|
|
1782
|
+
/** The one place a `tls{}` block is produced, shared by both broker renderers.
|
|
1783
|
+
*
|
|
1784
|
+
* `allow_non_tls` is deliberately never emitted. It is a TOP-LEVEL knob (nested inside `tls{}`
|
|
1785
|
+
* nats-server rejects it as an unknown field), and it turns the listener into mixed mode: INFO
|
|
1786
|
+
* then advertises `tls_available` instead of `tls_required`, and a client that declines to
|
|
1787
|
+
* upgrade is served in cleartext — precisely the credential exposure this transport closes.
|
|
1788
|
+
* There is no supported migration mode; enabling TLS is all-or-nothing. */
|
|
1789
|
+
function renderTlsBlock(transport) {
|
|
1790
|
+
if (transport.kind === "plaintext")
|
|
1791
|
+
return "";
|
|
1792
|
+
return `tls {
|
|
1793
|
+
cert_file: ${JSON.stringify(transport.certFile)}
|
|
1794
|
+
key_file: ${JSON.stringify(transport.keyFile)}
|
|
1795
|
+
}
|
|
1796
|
+
`;
|
|
1797
|
+
}
|
|
1350
1798
|
export function serverConfig(broker, spaces, opts) {
|
|
1351
1799
|
if (!spaces.length)
|
|
1352
1800
|
throw new Error("serverConfig: at least one space account is required");
|
|
@@ -1361,6 +1809,14 @@ export function serverConfig(broker, spaces, opts) {
|
|
|
1361
1809
|
}
|
|
1362
1810
|
const port = opts.port ?? 4222;
|
|
1363
1811
|
const host = opts.host ?? "127.0.0.1";
|
|
1812
|
+
// The websocket listener (item 6): LOCALHOST by default, no_tls for the dev loopback. Emitted only
|
|
1813
|
+
// when wsPort is set — a broker with no console session client opens no ws surface.
|
|
1814
|
+
const websocket = opts.wsPort === undefined ? "" : `websocket {
|
|
1815
|
+
host: ${opts.wsHost ?? "127.0.0.1"}
|
|
1816
|
+
port: ${opts.wsPort}
|
|
1817
|
+
no_tls: true
|
|
1818
|
+
}
|
|
1819
|
+
`;
|
|
1364
1820
|
// A minted "agent" carries its full permission allow-list inline in its user JWT, which the
|
|
1365
1821
|
// client sends in the CONNECT protocol line. With per-channel + JetStream-API grants that JWT
|
|
1366
1822
|
// exceeds the 4 KB default max_control_line at ~2 channels, and the server then silently drops
|
|
@@ -1368,12 +1824,13 @@ export function serverConfig(broker, spaces, opts) {
|
|
|
1368
1824
|
// agent JWT — but right-sized, not generous: the CONNECT line is parsed BEFORE auth, so the cap
|
|
1369
1825
|
// is a per-connection pre-auth allocation under connection flooding. 64 KB clears a many-channel
|
|
1370
1826
|
// agent JWT (~4–8 KB) with wide margin while keeping the pre-auth surface ~16× tighter than 1 MB.
|
|
1827
|
+
const tlsBlock = renderTlsBlock(opts.transport);
|
|
1371
1828
|
return `# Generated by \`cotal up\` - do not edit by hand.
|
|
1372
1829
|
host: ${host}
|
|
1373
1830
|
port: ${port}
|
|
1374
1831
|
max_control_line: 65536
|
|
1375
|
-
jetstream { store_dir: ${JSON.stringify(opts.storeDir)} }
|
|
1376
|
-
operator: ${broker.operator.jwt}
|
|
1832
|
+
${tlsBlock}jetstream { store_dir: ${JSON.stringify(opts.storeDir)} }
|
|
1833
|
+
${websocket}operator: ${broker.operator.jwt}
|
|
1377
1834
|
system_account: ${broker.sys.pub}
|
|
1378
1835
|
resolver: MEMORY
|
|
1379
1836
|
resolver_preload: {
|