@cotal-ai/core 0.16.0 → 0.17.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 +24 -1
- package/dist/endpoint-contract-store.d.ts.map +1 -1
- package/dist/endpoint-contract-store.js +79 -27
- 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 +48 -7
- package/dist/endpoint-grants.d.ts.map +1 -1
- package/dist/endpoint-grants.js +97 -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 +340 -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 +10 -1
- package/dist/endpoint-subjects.d.ts.map +1 -1
- package/dist/endpoint-subjects.js +10 -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 +136 -11
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +329 -24
- package/dist/endpoint.js.map +1 -1
- package/dist/identity.d.ts +19 -0
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +12 -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 +106 -9
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +506 -110
- 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 +53 -1
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +135 -8
- package/dist/streams.js.map +1 -1
- package/dist/subjects.d.ts +32 -26
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +38 -25
- 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
|
@@ -17,10 +17,13 @@
|
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import { decode, encodeOperator, encodeAccount, encodeUser, fmtCreds, } from "@nats-io/jwt";
|
|
19
19
|
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";
|
|
20
|
+
import { token, spacePrefix, chatSubject, assertValidChannel, channelInAllow, principalKey, parsePrincipalKey, deprovisionTargetPrincipal, principalTags, assertInboxConnId, DEV_OWNER, unicastSubject, anycastSubject, controlServiceSubject, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, CONTROL_AUTH_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";
|
|
21
|
+
import { epCallerGrantRows, epServeGrantRows, epBaselineGrantRows, spawnCallerCapabilities, operatorInstrumentCapabilities, epDescribeAllGrantRow, BASELINE_LIFECYCLE_ENDPOINT, } from "./endpoint-grants.js";
|
|
22
22
|
import { assertServeGrantMintable, finalizeServeIssuance } from "./endpoint-service.js";
|
|
23
|
-
import { effectsBindGrants, poolOwnerBindGrants } from "./endpoint-binding.js";
|
|
23
|
+
import { effectsBindGrants, poolOwnerBindGrants, goalWriterGrants, sessionLedgerGrants, epAuthBucket, sessionsBucket, epcStreamName, epjStreamName, epfStreamName, epeStreamName, eptReqStreamName, eprStreamName, eptStreamName, epwStreamName } from "./endpoint-binding.js";
|
|
24
|
+
import { epsSubject } from "./endpoint-subjects.js";
|
|
25
|
+
import { recordsBucket, recordSpecKey, recordStatusKey, recordAtomicKey, RECORD_KINDS, GOVERN_HEAD } from "./endpoint-records.js";
|
|
26
|
+
import { lifecycleHeadKey, uidReservationKey, issuanceGateKey, staticSlotKey, STATIC_SLOT_PREFIX, epgateKey, epcredFamilyPrefix } from "./lifecycle-state.js";
|
|
24
27
|
import { rawDigest } from "./canonical.js";
|
|
25
28
|
import { credsClaims } from "./identity.js";
|
|
26
29
|
import { backupProfilePermissions, restoreProfilePermissions, } from "./backup.js";
|
|
@@ -49,6 +52,8 @@ export const CREDENTIAL_LIFETIMES = {
|
|
|
49
52
|
provisioner: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "setup/spawn provisioning window only" },
|
|
50
53
|
deprovisioner: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "target-pinned teardown window only" },
|
|
51
54
|
"retirement-requester": { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "one despawn's retirement request window; request+reply only" },
|
|
55
|
+
"lifecycle-executor": { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "one static lifecycle operation's 13.1 state-write window (activation / terminal / renewal ledger append)" },
|
|
56
|
+
"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
57
|
operator: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "send/dm/join/probe-style operator command" },
|
|
53
58
|
purger: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "history purge command" },
|
|
54
59
|
backup: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "offline snapshot phase; exact stream and delivery subject, memory-only" },
|
|
@@ -61,6 +66,11 @@ export const CREDENTIAL_LIFETIMES = {
|
|
|
61
66
|
"control-caller-admin": { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "stop/attach admin control call" },
|
|
62
67
|
deployer: { class: "one-shot", note: "manifest deploy spans planning/launch/ledger; needs near-expiry guard or remint before default exp" },
|
|
63
68
|
"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" },
|
|
69
|
+
"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)" },
|
|
70
|
+
"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" },
|
|
71
|
+
"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" },
|
|
72
|
+
"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)" },
|
|
73
|
+
"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
74
|
"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
75
|
"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
76
|
};
|
|
@@ -322,8 +332,13 @@ export async function provisionAgentDurables(provisioner, pr, opts = {}) {
|
|
|
322
332
|
// live-only launcher, e.g. `cotal spawn --live-only`) opts out of the ACL row → the daemon never
|
|
323
333
|
// authorizes a durable backstop for it, so it stays live-only.
|
|
324
334
|
// ACL is keyed by the lifecycle-scoped dot-form <owner>.<actor>.<uid> (per-incarnation read authority).
|
|
325
|
-
|
|
326
|
-
|
|
335
|
+
// reissueAcl — rides THIS mint: allowSubscribe was just baked into the user JWT above. Separate
|
|
336
|
+
// from commitAcl so an ordinary registry writer cannot raise the ceiling by passing a flag
|
|
337
|
+
// (SPEC §9.6). Not crypto-bound to the JWT bytes — process discipline that this call stays next
|
|
338
|
+
// to the mint (ACL-authority panel residual).
|
|
339
|
+
if (opts.durableMembership !== false) {
|
|
340
|
+
await provisioner.reissueAcl(principalKey(pr.owner, pr.actor).key, uid, allowSubscribe);
|
|
341
|
+
}
|
|
327
342
|
if (opts.role)
|
|
328
343
|
await provisioner.provisionTaskQueue(opts.role);
|
|
329
344
|
return allowSubscribe;
|
|
@@ -434,6 +449,31 @@ export function permissionsFor(profile, space, pr, opts) {
|
|
|
434
449
|
const req = controlServiceSubject(space, CONTROL_AUTH_ADMIN, opts.retirementRequester.owner, opts.retirementRequester.actor);
|
|
435
450
|
return { pub: { allow: [req] }, sub: { allow: [`${req}.reply.>`, `_INBOX_${pr.connId}.>`] } };
|
|
436
451
|
}
|
|
452
|
+
if (profile === "endpoint-evictor") {
|
|
453
|
+
// P2 item 3 (slice 3a): a SCOPED delivery-admin caller for ONE re-registration's verify-evict.
|
|
454
|
+
// Publish EXACTLY this credential's OWN delivery-admin control subject + $JS.API.INFO; subscribe
|
|
455
|
+
// its own reply subtree + inbox. NO lease, presence, store read, consumer, KV, or executing
|
|
456
|
+
// right — the daemon does the $SYS scan/KICK (subject-gated authority, like the supervisor evictor
|
|
457
|
+
// it narrows) and the manager passes only the predecessor's principal. Narrower than the
|
|
458
|
+
// `supervisor` profile the auth barrier-evict reuses (its #30 residual, done here for the manager).
|
|
459
|
+
const req = controlServiceSubject(space, CONTROL_DELIVERY_ADMIN, pr.owner, pr.actor);
|
|
460
|
+
return { pub: { allow: [req, "$JS.API.INFO"] }, sub: { allow: [`${req}.reply.>`, `_INBOX_${pr.connId}.>`] } };
|
|
461
|
+
}
|
|
462
|
+
if (profile === "lifecycle-executor") {
|
|
463
|
+
if (!opts.lifecycleExecutor)
|
|
464
|
+
throw new Error("permissionsFor: lifecycle-executor requires opts.lifecycleExecutor ({owner, actor, lifecycleUid, alias} of the ONE incarnation it may move)");
|
|
465
|
+
return lifecycleExecutorPermissions(space, pr, opts.lifecycleExecutor);
|
|
466
|
+
}
|
|
467
|
+
if (profile === "endpoint-serve-executor") {
|
|
468
|
+
if (!opts.endpointServeExecutor)
|
|
469
|
+
throw new Error("permissionsFor: endpoint-serve-executor requires opts.endpointServeExecutor ({endpoint, instanceId} of the ONE endpoint instance it may register/serve-mint)");
|
|
470
|
+
return endpointServeExecutorPermissions(space, pr, opts.endpointServeExecutor);
|
|
471
|
+
}
|
|
472
|
+
if (profile === "goal-writer") {
|
|
473
|
+
if (!opts.goalWriter)
|
|
474
|
+
throw new Error("permissionsFor: goal-writer requires opts.goalWriter ({endpoint} whose action goals this connection binds + commits)");
|
|
475
|
+
return goalWriterPermissions(space, pr, opts.goalWriter);
|
|
476
|
+
}
|
|
437
477
|
if (profile === "purger")
|
|
438
478
|
return purgerPermissions(space, pr); // ephemeral history-purge (closure (ii))
|
|
439
479
|
if (profile === "backup") {
|
|
@@ -457,11 +497,17 @@ export function permissionsFor(profile, space, pr, opts) {
|
|
|
457
497
|
if (profile === "teardown")
|
|
458
498
|
return teardownPermissions(space, pr); // sole STREAM.DELETE holder (PR 1.5)
|
|
459
499
|
if (profile === "control-caller-privileged")
|
|
460
|
-
return controlCallerPermissions(space, pr,
|
|
500
|
+
return controlCallerPermissions(space, pr, "privileged", opts); // ps/start reads (PR 1.5)
|
|
461
501
|
if (profile === "control-caller-admin")
|
|
462
|
-
return controlCallerPermissions(space, pr,
|
|
502
|
+
return controlCallerPermissions(space, pr, "admin", opts); // any-mode stop/attach (PR 1.5)
|
|
463
503
|
if (profile === "deployer")
|
|
464
|
-
return deployerPermissions(space, pr, opts.controlTier ??
|
|
504
|
+
return deployerPermissions(space, pr, opts.controlTier ?? "admin", opts); // spawn -f deploy authority (PR 1.5; user-mode view rides privileged)
|
|
505
|
+
if (profile === "session-caller")
|
|
506
|
+
return sessionCallerPermissions(space, pr, opts.sessionCaller); // one §13.6 session's caller rails (P2 item 6)
|
|
507
|
+
if (profile === "session-serving")
|
|
508
|
+
return sessionServingPermissions(space, pr, opts.sessionServing); // one §13.6 session's SERVING rails (P2 item 6)
|
|
509
|
+
if (profile === "session-ledger")
|
|
510
|
+
return sessionLedgerPermissions(space, pr); // the dedicated session ledger, no rails (P2 item 6)
|
|
465
511
|
if (profile === "endpoint-serve")
|
|
466
512
|
// Serve rows are emitted ONLY by mintCreds behind the §13.1 issuance fence — never via this
|
|
467
513
|
// exported builder, so a direct signer/callout can't obtain unfenced serve rows (SPEC 13.1/13.9).
|
|
@@ -542,7 +588,6 @@ export function permissionsFor(profile, space, pr, opts) {
|
|
|
542
588
|
// channel must equal its wire token, or the minted grant would alias the logical ACL.
|
|
543
589
|
for (const ch of [...allowSubscribe, ...allowPublish])
|
|
544
590
|
assertValidChannel(ch);
|
|
545
|
-
const manager = opts.manager ?? CONTROL_PRIVILEGED;
|
|
546
591
|
if (!pr.lifecycleUid)
|
|
547
592
|
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
593
|
const uid = assertLifecycleToken(pr.lifecycleUid);
|
|
@@ -555,7 +600,8 @@ export function permissionsFor(profile, space, pr, opts) {
|
|
|
555
600
|
...allowPublish.map((ch) => chatSubject(space, pr.owner, pr.actor, ch)),
|
|
556
601
|
unicastSubject(space, "*", "*", pr.owner, pr.actor), // inst.*.*.<o>.<a> — DM any instance, as me
|
|
557
602
|
anycastSubject(space, "*", pr.owner, pr.actor), // svc.*.<o>.<a> — anycast any role, as me
|
|
558
|
-
|
|
603
|
+
// Self stop/despawn rides the v0.4 ep baseline (`stop` self-mode) — the manager `ctl` rail is
|
|
604
|
+
// deleted (1d). The delivery-daemon rail below is a separate service (Plane-3), kept.
|
|
559
605
|
// ctl.delivery.<o>.<a> — request a durable backstop join/leave/list from the SERVER-SIDE delivery
|
|
560
606
|
// daemon (NOT the manager). The reply rides this same subtree (`ctl.delivery.<o>.<a>.reply.<n>`, in
|
|
561
607
|
// sub.allow below) so the daemon can answer without broad inbox-publish — see CONTROL_DELIVERY.
|
|
@@ -620,20 +666,9 @@ export function permissionsFor(profile, space, pr, opts) {
|
|
|
620
666
|
// allowed — the privileged provisioner pre-creates svc_<role> filtered to svc.<role>.*.
|
|
621
667
|
pubAllow.push(`$JS.API.CONSUMER.INFO.${TASK}.${svcD}`, `$JS.API.CONSUMER.MSG.NEXT.${TASK}.${svcD}`, `$JS.ACK.${TASK}.${svcD}.>`);
|
|
622
668
|
}
|
|
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
|
-
}
|
|
669
|
+
// Spawn / admin capability control reach rides the v0.4 ep rows below (the manager `ctl` rail is
|
|
670
|
+
// deleted, 1d) — the spawn set (owner-mode manager lifecycle) and the admin instrument set
|
|
671
|
+
// (any-mode + manager.admin family) are added to the ep caller rows, not a ctl subject.
|
|
637
672
|
// v0.4 endpoint rails (SPEC §13.9 caller rows). EVERY agent gets the Appendix-B BASELINE set
|
|
638
673
|
// (wildcard describe + delivery join/leave/list + self-mode lifecycle + the reply rail), keyed
|
|
639
674
|
// on the SAME lifecycle uid as the agent's durables; the spawn capability adds the owner-mode
|
|
@@ -651,6 +686,13 @@ export function permissionsFor(profile, space, pr, opts) {
|
|
|
651
686
|
const epSub = [...baseline.sub];
|
|
652
687
|
if (opts.capabilities?.includes("spawn"))
|
|
653
688
|
pubAllow.push(...epCallerGrantRows(space, spawnCallerCapabilities(pr.owner), epCaller).pub);
|
|
689
|
+
if (opts.capabilities?.includes("admin"))
|
|
690
|
+
// The admin capability's ep mirror (the 1c grant-migration table): the v0.3 `ctl.<admin>`
|
|
691
|
+
// subject above grants the FULL admin-tier op reach, so its holder gets the admin instrument
|
|
692
|
+
// set on the ep rails — any-mode despawn/attach + the `manager.admin` family + the reads. In
|
|
693
|
+
// user mode this is the ledger `admin` scope arriving via the callout, the broker-enforced
|
|
694
|
+
// half of the tier; the manager's ledger-derived per-op admin flag stays on top of it.
|
|
695
|
+
pubAllow.push(...epCallerGrantRows(space, operatorInstrumentCapabilities("admin"), epCaller).pub);
|
|
654
696
|
if (opts.endpointCapabilities?.length) {
|
|
655
697
|
if (opts.lifecycleUid !== undefined && assertLifecycleToken(opts.lifecycleUid) !== uid)
|
|
656
698
|
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 +729,9 @@ export function permissionsFor(profile, space, pr, opts) {
|
|
|
687
729
|
// Replies to this agent's durable join/leave/list requests ride `ctl.delivery.<o>.<a>.>` (NOT the
|
|
688
730
|
// per-id _INBOX), so the scoped delivery daemon can answer without broad inbox-publish.
|
|
689
731
|
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] } };
|
|
732
|
+
// Manager control replies ride the v0.4 ep reply rail (in `epSub`, keyed on the caller triple) —
|
|
733
|
+
// the `ctl.<tier>.<id>.reply.>` subtrees are gone with the ctl rail (1d).
|
|
734
|
+
return { pub: { allow: pubAllow, deny: pubDeny }, sub: { allow: [inbox, deliveryReplies, ...subChat, ...epSub] } };
|
|
701
735
|
}
|
|
702
736
|
/** The long-lived SUPERVISOR permission set (closure (ii), residual 2) — the always-on manager daemon
|
|
703
737
|
* (`manager.ts` `this.ep`), carved down from the former allow-all `manager`. THIS is the cred whose
|
|
@@ -710,25 +744,27 @@ export function permissionsFor(profile, space, pr, opts) {
|
|
|
710
744
|
* `setActivity`, a presence write), NO DM/DLV read of any kind (no consumer-create, no native sub), NO
|
|
711
745
|
* stream CREATE/DELETE/PURGE/UPDATE, NO channel-registry access (the daemon sets `watchChannels:false`).
|
|
712
746
|
* `$JS` is an ENUMERATED allow-list — exactly the presence-watch + lease-KV verbs — never `$JS.>`. A
|
|
713
|
-
* leaked supervisor cred can hold
|
|
747
|
+
* leaked supervisor cred can hold the lease and read the public roster; it cannot read a DM, forge an
|
|
714
748
|
* actor, provision, purge, or tamper with a stream. */
|
|
715
749
|
function supervisorPermissions(space, pr) {
|
|
716
750
|
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.>`);
|
|
751
|
+
// 1d: the supervisor no longer serves the manager control tiers — the manager's control surface
|
|
752
|
+
// is its v0.4 `service` endpoint, served on a SEPARATE connection under its own `endpoint-serve`
|
|
753
|
+
// credential (its rails are that credential's grant, not the supervisor's). The supervisor now
|
|
754
|
+
// holds only the lease, presence, and the ONE delivery-admin call.
|
|
723
755
|
return {
|
|
724
756
|
pub: {
|
|
725
757
|
allow: [
|
|
726
758
|
"$JS.API.INFO",
|
|
727
|
-
//
|
|
728
|
-
// lease key (acquire/renew/release) + read
|
|
759
|
+
// Per-instance manager liveness lease (managerBucket, pre-created at `cotal up`): OPEN-ONLY bind +
|
|
760
|
+
// CAS this instance's own `lease.<instanceId>` key (acquire/renew/release) + read the subtree. P2
|
|
761
|
+
// item 3 demoted the per-space singleton to per-instance keys, so the write grant spans `lease.*`
|
|
762
|
+
// (every instance of this space shares this one supervisor principal — the isolation between
|
|
763
|
+
// instances is the logical-id/CAS boundary, not a cred boundary). NO STREAM.CREATE (pre-created),
|
|
764
|
+
// DELETE, or PURGE.
|
|
729
765
|
`$JS.API.STREAM.INFO.${MKV}`,
|
|
730
|
-
`$JS.API.STREAM.MSG.GET.${MKV}`, // readManagerLease + CAS-conflict kv.get
|
|
731
|
-
`$KV.${managerBucket(space)}.${MANAGER_LEASE_KEY}
|
|
766
|
+
`$JS.API.STREAM.MSG.GET.${MKV}`, // readManagerLease (last_by_subj lease.*) + CAS-conflict kv.get
|
|
767
|
+
`$KV.${managerBucket(space)}.${MANAGER_LEASE_KEY}.*`, // this instance's lease.<id> key (create/update/delete = $KV publishes)
|
|
732
768
|
// Presence: publish OWN key + watch the roster. Own key only (no peer-key forge — residual 3); no
|
|
733
769
|
// presence-stream purge/delete (no force-offline tamper). No presence kv.get (roster is the in-memory
|
|
734
770
|
// watch cache + sweep), so no STREAM.MSG.GET on presence.
|
|
@@ -737,9 +773,6 @@ function supervisorPermissions(space, pr) {
|
|
|
737
773
|
`$JS.API.CONSUMER.CREATE.${PKV}.>`, // kv.watch ordered consumer (roster)
|
|
738
774
|
`$JS.API.CONSUMER.INFO.${PKV}.>`,
|
|
739
775
|
"$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
776
|
// The ONE control service the supervisor CALLS (D5 slice 5): the delivery daemon's privileged
|
|
744
777
|
// admin rail — the manager is the class-2 renewal owner, and after re-signing the daemon creds
|
|
745
778
|
// files it requests `reloadCreds` here so adoption is an explicit, auditable event. Self-scoped
|
|
@@ -748,10 +781,10 @@ function supervisorPermissions(space, pr) {
|
|
|
748
781
|
],
|
|
749
782
|
},
|
|
750
783
|
sub: {
|
|
751
|
-
// Own reply inbox + the
|
|
752
|
-
//
|
|
753
|
-
// broad `$JS.>`/`$KV.>` (the residual-2 read/admin path is gone).
|
|
754
|
-
allow: [`_INBOX_${pr.connId}.>`,
|
|
784
|
+
// Own reply inbox + the delivery-admin reply subtree for its OWN requests. NO chat/inst/dlv
|
|
785
|
+
// native sub (the supervisor reads no feed), NO manager control-tier serve (1d: that moved to
|
|
786
|
+
// the endpoint-serve credential), NO broad `$JS.>`/`$KV.>` (the residual-2 read/admin path is gone).
|
|
787
|
+
allow: [`_INBOX_${pr.connId}.>`, `${controlServiceSubject(space, CONTROL_DELIVERY_ADMIN, pr.owner, pr.actor)}.>`],
|
|
755
788
|
},
|
|
756
789
|
};
|
|
757
790
|
}
|
|
@@ -852,21 +885,29 @@ function channelPurgerPermissions(space, pr) {
|
|
|
852
885
|
}
|
|
853
886
|
/** TEARDOWN (PR 1.5) — `cotal down -f` space teardown. The SOLE cred that keeps `STREAM.DELETE` (the
|
|
854
887
|
* face-b tamper verb). `down -f` is multi-step: `connectProbe` (presence-watch + channel-registry read)
|
|
855
|
-
* → `
|
|
856
|
-
*
|
|
888
|
+
* → invoke the manager's `ps` + any-mode `despawn` over the ep rails to politely stop the managed
|
|
889
|
+
* agents → `deleteChannels`
|
|
890
|
+
* (channel-registry key delete + CHAT purge) → `deleteSpace` (STREAM.DELETE all 13 space streams/buckets).
|
|
857
891
|
* So it reads state, CALLS admin control, deletes channels, and deletes streams — but NEVER reads a
|
|
858
892
|
* DM/DLV body, posts chat, or forges. Isolated here so no standing operator/provisioner/supervisor cred
|
|
859
893
|
* can delete a stream; a leaked teardown can wipe a space you own + stop its agents (that IS its job),
|
|
860
894
|
* nothing else. Minted ephemerally per teardown from the local trust material (same-checkout `down -f`). */
|
|
861
895
|
function teardownPermissions(space, pr) {
|
|
896
|
+
// The ep-rail mirror of the admin deploy tier (1c.2c): teardown reads `ps` and stops owned agents
|
|
897
|
+
// it did not spawn (any-mode despawn) - the admin instrument set. Lifecycle-keyed, so a uid is
|
|
898
|
+
// required at mint (fail-loud).
|
|
899
|
+
const ep = instrumentEpRows(space, pr, "admin");
|
|
862
900
|
const CHAT = chatStream(space);
|
|
863
901
|
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
|
|
902
|
+
// deleteSpace() deletes EVERY stream + KV bucket setup creates (5 streams + 7 KV buckets + the
|
|
903
|
+
// artifact object store = 13); each needs INFO (jsm existence) + DELETE. This is the ONLY cred that
|
|
904
|
+
// holds STREAM.DELETE (face-b isolated here). This list and deleteSpace()'s own array must agree:
|
|
905
|
+
// a stream in one and not the other is either an undeletable leak or a grant for nothing.
|
|
866
906
|
const del = [
|
|
867
907
|
CHAT, dmStream(space), taskStream(space), inboxStream(space), dlvStream(space),
|
|
868
908
|
PKV, CHKV, `KV_${membersBucket(space)}`, `KV_${aclBucket(space)}`,
|
|
869
909
|
`KV_${membershipBucket(space)}`, `KV_${deliveryBucket(space)}`, `KV_${managerBucket(space)}`,
|
|
910
|
+
objectStoreStream(artifactBucket(space)),
|
|
870
911
|
].flatMap((s) => [`$JS.API.STREAM.INFO.${s}`, `$JS.API.STREAM.DELETE.${s}`]);
|
|
871
912
|
return {
|
|
872
913
|
pub: {
|
|
@@ -880,44 +921,106 @@ function teardownPermissions(space, pr) {
|
|
|
880
921
|
`$JS.API.CONSUMER.CREATE.${CHKV}.>`,
|
|
881
922
|
`$JS.API.CONSUMER.INFO.${CHKV}.>`,
|
|
882
923
|
"$JS.FC.>", // ordered-consumer flow control
|
|
883
|
-
// Stop the managed agents
|
|
884
|
-
|
|
924
|
+
// Stop the managed agents over the v0.4 ep rails only (ps + any-mode despawn) — the admin
|
|
925
|
+
// instrument set. The manager `ctl` rail is deleted (1d).
|
|
926
|
+
...ep.pub,
|
|
885
927
|
...del,
|
|
886
928
|
// deleteChannels/clearChannel: purge the channel's chat messages + delete its registry key.
|
|
887
929
|
`$JS.API.STREAM.PURGE.${CHAT}`,
|
|
888
930
|
`$KV.${channelBucket(space)}.>`,
|
|
889
931
|
],
|
|
890
932
|
},
|
|
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.>`] },
|
|
933
|
+
// Own inbox (connectProbe presence-watch delivery + JS API responses) + the ep reply rail (the
|
|
934
|
+
// ps + any-mode despawn calls reply there). The `ctl.admin.<id>.reply.>` subtree is gone (1d).
|
|
935
|
+
sub: { allow: [`_INBOX_${pr.connId}.>`, ...ep.sub] },
|
|
896
936
|
};
|
|
897
937
|
}
|
|
898
|
-
/** CONTROL-CALLER (PR 1.5) — the operator's lifecycle commands
|
|
899
|
-
* `manager/commands.ts`). It
|
|
900
|
-
* reply on
|
|
938
|
+
/** CONTROL-CALLER (PR 1.5; ep-only since 1d) — the operator's lifecycle commands
|
|
939
|
+
* (`cotal ps/start/stop/attach`, `manager/commands.ts`). It invokes the manager's v0.4 service
|
|
940
|
+
* endpoint and reads the bounded reply on the ep reply rail. That is ALL — no `$JS`, no `$KV`,
|
|
941
|
+
* no chat/DM: it forges nothing, reads no body.
|
|
901
942
|
*
|
|
902
|
-
* The tiers
|
|
903
|
-
*
|
|
904
|
-
*
|
|
905
|
-
*
|
|
906
|
-
*
|
|
907
|
-
*
|
|
908
|
-
*
|
|
909
|
-
*
|
|
910
|
-
*
|
|
911
|
-
*
|
|
912
|
-
|
|
913
|
-
|
|
943
|
+
* The tiers stay SPLIT because the BROKER grant is load-bearing (the 1c decision): an any-mode
|
|
944
|
+
* despawn/attach row *is* cross-agent reach — the manager maps mode `any` to its admin
|
|
945
|
+
* authorization path, so which ROWS an instrument holds is the tier boundary. Therefore:
|
|
946
|
+
* • `control-caller-privileged` (ps/start) holds the manager reads + untargeted `spawn` +
|
|
947
|
+
* `define-persona` — structurally barred from cross-agent ops (no any-mode row). This is the
|
|
948
|
+
* high-frequency path; it never needs admin reach.
|
|
949
|
+
* • `control-caller-admin` (stop/attach) adds the any-mode despawn/attach rows + the
|
|
950
|
+
* `manager.admin` family — it genuinely needs cross-agent reach. Its containment is the
|
|
951
|
+
* broker gating the any-mode rows + the cred being ephemeral (mint → one request →
|
|
952
|
+
* disconnect, from the local signing seed); on a user mesh the manager's serve-time ledger
|
|
953
|
+
* re-check sits on top. */
|
|
954
|
+
function controlCallerPermissions(space, pr, epTier, opts = {}) {
|
|
955
|
+
// 1d: the manager `ctl` rail is gone — an operator instrument holds ONLY its v0.4 ep rows (the
|
|
956
|
+
// tier-matched request set, the reply rail, describe, the one epc fetch). The `epTier` selects
|
|
957
|
+
// privileged (ps/start reads) vs admin (any-mode stop/attach) exactly as the ctl tier did.
|
|
958
|
+
//
|
|
959
|
+
// B6 / `--on <instanceId>`: these instruments are ONE-SHOT, minted per control call, and the
|
|
960
|
+
// resolve that pins the instance happens BEFORE the mint. So the caller can hand the exact
|
|
961
|
+
// instance id down and get the exact `ep.inst.<endpoint>.<iid>.<command>` row for THIS invocation
|
|
962
|
+
// and nothing else — the least-privilege issuance, with no standing wildcard anywhere. The
|
|
963
|
+
// `extra` seam is the same one the deployer's owner-equality `launch` row already rides, and the
|
|
964
|
+
// emitter's `if (cap.instanceId)` branch validates the token, so a malformed id fails loud at
|
|
965
|
+
// mint rather than widening a subject.
|
|
966
|
+
const ep = instrumentEpRows(space, pr, epTier, opts.endpointCapabilities ?? []);
|
|
967
|
+
return {
|
|
968
|
+
// The PRIVILEGED tier (the `cotal ps` instrument) also carries the SCOPED §13.9 records read the
|
|
969
|
+
// class scatter's freeze rides (P2 item 3): `freezeExpectedSet` enumerates `svc.<endpoint>.*.spec`
|
|
970
|
+
// and LEADER-reads each frozen slot's svc spec/status before it scatters `ps` on the `all` rail.
|
|
971
|
+
// The admin tier (stop/attach) never scatters, so it gets no records read.
|
|
972
|
+
pub: { allow: epTier === "privileged" ? [...ep.pub, ...scatterFreezeReadRows(space)] : ep.pub },
|
|
973
|
+
sub: { allow: [`_INBOX_${pr.connId}.>`, ...ep.sub] },
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
/** The SCOPED §13.9 records-read rows the class-scatter freeze rides (P2 item 3, `cotal ps`): the
|
|
977
|
+
* `svc.*` enumeration consumer + the leader-served per-slot spec/status read of the endpoint's
|
|
978
|
+
* `svc` registry — a READ of exactly the service-registration keys, no write, no other bucket. A
|
|
979
|
+
* new D32 matrix row. The keyed Direct Get is subject-PINNED to `svc.>`; the enumeration consumer
|
|
980
|
+
* and the leader `STREAM.MSG.GET` are STREAM-scoped because the requested key rides the PAYLOAD
|
|
981
|
+
* (which a subject grant cannot narrow) — the SAME NAMED RESIDUAL every records reader already
|
|
982
|
+
* accepts (the provisioner, the lifecycle/serve executors): for this EPHEMERAL one-shot instrument's
|
|
983
|
+
* lifetime it can READ (never write) any records-store row — registration metadata, no secrets. */
|
|
984
|
+
function scatterFreezeReadRows(space) {
|
|
985
|
+
const REC = recordsBucket(space);
|
|
986
|
+
return [
|
|
987
|
+
// The `svc.<e>.*.spec` enumeration is a `STREAM.INFO` carrying a `subjects_filter` — ONE
|
|
988
|
+
// read-only metadata verb. It replaced a `kv.keys()` that rode an ordered ephemeral consumer and
|
|
989
|
+
// therefore needed CONSUMER.CREATE/INFO/DELETE on this bucket: three consumer-lifecycle verbs to
|
|
990
|
+
// list keys, on a credential that only ever wanted to read them.
|
|
991
|
+
//
|
|
992
|
+
// MEASURED, not assumed: with the enumeration converted and this row absent, the static/operator
|
|
993
|
+
// `cotal ps` is refused on `$JS.API.STREAM.INFO.KV_<records>` — a path that works today. The
|
|
994
|
+
// conversion and this row land TOGETHER or the operator path regresses.
|
|
995
|
+
`$JS.API.STREAM.INFO.KV_${REC}`,
|
|
996
|
+
// The per-slot spec/status reads (`freezeExpectedSet` + the registration reconcile) are
|
|
997
|
+
// leader-served `STREAM.MSG.GET` — stream-scoped (NAMED RESIDUAL above), plus the subject-pinned
|
|
998
|
+
// keyed Direct Get form for any direct-aware read path (scoped to the `svc.` registry prefix).
|
|
999
|
+
`$JS.API.STREAM.MSG.GET.KV_${REC}`,
|
|
1000
|
+
`$JS.API.DIRECT.GET.KV_${REC}.$KV.${REC}.svc.>`,
|
|
1001
|
+
];
|
|
1002
|
+
}
|
|
1003
|
+
/** The v0.4 ep-rail rows of an operator INSTRUMENT credential (the 1c grant-migration table's
|
|
1004
|
+
* admin row): the tier-matched {@link operatorInstrumentCapabilities} request rows + the caller's
|
|
1005
|
+
* reply rail, the wildcard `describe` form, and the ONE subject-scoped §13.7 contract-store fetch
|
|
1006
|
+
* row (the same shape the agent baseline holds; the D32 audit's single exemption). These mirror
|
|
1007
|
+
* the instrument's ctl tier onto the ep rails during dual-serve; at 1d the ctl row disappears and
|
|
1008
|
+
* these ARE the instrument. The caller triple pins the instrument's own mint-time lifecycle uid
|
|
1009
|
+
* ({@link MintPrincipal.lifecycleUid}) — REQUIRED here: without it the reply rail cannot be pinned
|
|
1010
|
+
* and the mint fails loud rather than emit a triple-less (unfenced) caller surface. `extra` lets a
|
|
1011
|
+
* profile append its tier-refined additions (the user-mode deployer's owner-equality `launch`). */
|
|
1012
|
+
function instrumentEpRows(space, pr, tier, extra = []) {
|
|
1013
|
+
if (!pr.lifecycleUid)
|
|
1014
|
+
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`);
|
|
1015
|
+
const epCaller = { owner: pr.owner, actor: pr.actor, uid: pr.lifecycleUid };
|
|
1016
|
+
const rows = epCallerGrantRows(space, [...operatorInstrumentCapabilities(tier), ...extra], epCaller);
|
|
914
1017
|
return {
|
|
915
|
-
pub:
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
sub:
|
|
1018
|
+
pub: [
|
|
1019
|
+
epDescribeAllGrantRow(space, epCaller),
|
|
1020
|
+
`$JS.API.DIRECT.GET.${epcStreamName(space)}.${spacePrefix(space)}.epc.>`,
|
|
1021
|
+
...rows.pub,
|
|
1022
|
+
],
|
|
1023
|
+
sub: rows.sub,
|
|
921
1024
|
};
|
|
922
1025
|
}
|
|
923
1026
|
/** ENDPOINT-SERVE (v0.4, SPEC §13.9 "Serve grants") — the per-instance serve credential:
|
|
@@ -970,23 +1073,34 @@ function endpointServePermissions(space, pr, opts) {
|
|
|
970
1073
|
}
|
|
971
1074
|
/** DEPLOYER (PR 1.5) — the `cotal spawn -f` manifest-deploy authority. `spawn -f` drives ONE
|
|
972
1075
|
* `connectProbe` endpoint that both READS live state (roster/presence watch, channel registry,
|
|
973
|
-
* membership feed, manager-singleton lease) AND
|
|
974
|
-
*
|
|
975
|
-
*
|
|
1076
|
+
* membership feed, manager-singleton lease) AND invokes the running manager's `launch` + `ps`
|
|
1077
|
+
* readiness over the v0.4 ep rails. Those interleave on one connection, so a strict 3-connection
|
|
1078
|
+
* split would only refactor `live.ts` for marginal gain; `deployer` is that one coherent,
|
|
976
1079
|
* ephemeral deploy cred. It is the SOLE profile that combines reads + admin-control — NOT a template a
|
|
977
1080
|
* 4th command should reach for (revisit the connection split before adding a second such caller).
|
|
978
1081
|
*
|
|
979
1082
|
* Boundaries (all enforced by omission / default-deny): NO self-post (`chat`/`inst`/`svc`), NO `$JS.>`,
|
|
980
1083
|
* 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,
|
|
1084
|
+
* writes (channel seeding rides a SEPARATE `channel-writer` cred). It holds the admin INSTRUMENT ep
|
|
1085
|
+
* set (any-mode despawn/attach + the manager.admin family + reads) because manifest launch/ps
|
|
1086
|
+
* genuinely need cross-agent reach — and that IS real power: the manager's any-mode authz is
|
|
1087
|
+
* subject-gated (the any-mode ep row is mintable only under operator policy), so holding it lets it
|
|
1088
|
+
* stop/attach/launch ANY agent. The BROKER gating that row is the boundary. Containment is therefore
|
|
1089
|
+
* the LIFETIME, not a manager re-check: minted from LOCAL same-checkout auth for one `spawn -f`,
|
|
1090
|
+
* memory-only, dropped after deploy. If it is ever persisted, handed to user-supplied `--creds`, or
|
|
1091
|
+
* reused as a general "read + admin" cred, revisit. */
|
|
1092
|
+
function deployerPermissions(space, pr, epTier = "admin", opts = {}) {
|
|
1093
|
+
// The v0.4 ep rows of the deploy authority: static (admin) deploys carry the admin instrument
|
|
1094
|
+
// set; the user-mode deployer VIEW (privileged) carries the privileged set PLUS an untargeted
|
|
1095
|
+
// `launch` row — its launch stays owner-equality-authorized (the manager's ledger-derived admin
|
|
1096
|
+
// flag is false for a spawn-scoped deployer), exactly the v0.3 user-mode privileged-tier launch.
|
|
1097
|
+
// B6 / `--on`: the per-invocation pin APPENDS to this profile's standing set, never replaces it -
|
|
1098
|
+
// the privileged deployer view keeps its owner-equality `launch` row and additionally gets the one
|
|
1099
|
+
// exact `ep.inst.<endpoint>.<iid>.<command>` row for the instance this deploy resolved.
|
|
1100
|
+
const pinned = opts.endpointCapabilities ?? [];
|
|
1101
|
+
const ep = epTier === "admin"
|
|
1102
|
+
? instrumentEpRows(space, pr, "admin", pinned)
|
|
1103
|
+
: instrumentEpRows(space, pr, "privileged", [{ endpoint: BASELINE_LIFECYCLE_ENDPOINT, command: "launch" }, ...pinned]);
|
|
990
1104
|
const PKV = `KV_${presenceBucket(space)}`, CHKV = `KV_${channelBucket(space)}`;
|
|
991
1105
|
const MSHIP = `KV_${membershipBucket(space)}`, MGRKV = `KV_${managerBucket(space)}`;
|
|
992
1106
|
const DLVKV = `KV_${deliveryBucket(space)}`;
|
|
@@ -1017,16 +1131,15 @@ function deployerPermissions(space, pr, tier = CONTROL_ADMIN) {
|
|
|
1017
1131
|
...kvPointRead(MGRKV), // manager-singleton lease keyed read (waitManagerReady) — point-get, NO write, NO watch
|
|
1018
1132
|
...kvPointRead(DLVKV), // delivery-lease keyed read (preserve-state quiescence proof) — point-get, NO write, NO watch
|
|
1019
1133
|
"$JS.FC.>", // ordered-consumer flow control
|
|
1020
|
-
//
|
|
1021
|
-
//
|
|
1022
|
-
//
|
|
1023
|
-
|
|
1134
|
+
// 1d: launch + ps readiness ride the v0.4 ep rows only (the manager `ctl` rail is gone).
|
|
1135
|
+
// Static deploys carry the admin instrument set; the user-mode deployer VIEW carries the
|
|
1136
|
+
// privileged set + an owner-equality `launch` row (the manager's ledger-derived admin flag
|
|
1137
|
+
// is false for a spawn-scoped deployer, so its launch stays owner-equality-authorized).
|
|
1138
|
+
...ep.pub,
|
|
1024
1139
|
],
|
|
1025
1140
|
},
|
|
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.>`] },
|
|
1141
|
+
// Own inbox (presence/registry watch delivery + JS API responses) + the ep reply rail.
|
|
1142
|
+
sub: { allow: [`_INBOX_${pr.connId}.>`, ...ep.sub] },
|
|
1030
1143
|
};
|
|
1031
1144
|
}
|
|
1032
1145
|
/** The ephemeral PURGER permission set (closure (ii), residual 2) — minted per-purge inside the daemon's
|
|
@@ -1077,12 +1190,35 @@ function provisionerPermissions(space, pr) {
|
|
|
1077
1190
|
// Every backing stream the provisioner pre-creates — the 5 message streams + the KV buckets (a bucket's
|
|
1078
1191
|
// backing stream is `KV_<bucket>`). `managerBucket` is now pre-created here too (so the supervisor binds
|
|
1079
1192
|
// its lease open-only); members/membership/delivery are written by other creds but created at setup here.
|
|
1193
|
+
// The §13.12 AUTHORITY stores (records + auth + the P2 item 6 session ledger) join the list for the
|
|
1194
|
+
// STATIC manager's start-time `createEndpointStreams` (a superset of ensureAuthorityStores +
|
|
1195
|
+
// createSessionsStore): create-or-verify only — the provisioner holds NO value-write on any of them
|
|
1196
|
+
// (lifecycle state moves through the key-pinned `lifecycle-executor` cred; session rows through the
|
|
1197
|
+
// scoped `session-ledger` cred).
|
|
1080
1198
|
const buckets = [
|
|
1081
1199
|
presenceBucket, channelBucket, membersBucket, aclBucket, membershipBucket, deliveryBucket, managerBucket,
|
|
1200
|
+
recordsBucket, epAuthBucket, sessionsBucket,
|
|
1082
1201
|
].map((b) => `KV_${b(space)}`);
|
|
1083
1202
|
// STREAM.CREATE + INFO for each (idempotent setup at `cotal up`; CREATE is create-if-matching, INFO covers
|
|
1084
1203
|
// the client's existence checks). NO DELETE/PURGE/UPDATE — provisioning never tears a stream down.
|
|
1085
|
-
|
|
1204
|
+
// The §13.7 CONTRACT store (EPC) joins the list for the static manager's start-time
|
|
1205
|
+
// `ensureContractStore` (P2 item 1, 1c): create-or-verify only — the provisioner holds no
|
|
1206
|
+
// artifact-publish grant on it (publication rides the scoped endpoint-serve executor).
|
|
1207
|
+
// The seven §13.12 ENDPOINT streams join the list (P2 item 2): spawn-as-action makes the manager
|
|
1208
|
+
// the first EPF (goal facts) + EPE (progress) writer, and nothing provisioned the endpoint streams
|
|
1209
|
+
// before (no manager code wrote to them), so `createEndpointStreams` now runs at the manager's
|
|
1210
|
+
// start-time ensure over this provisioner. Create-or-verify only (idempotent, fail-loud on drift);
|
|
1211
|
+
// the provisioner holds no value-write on any of them (goal facts ride the scoped goal-writer cred).
|
|
1212
|
+
const endpointStreams = [epjStreamName, epfStreamName, epeStreamName, eptReqStreamName, eprStreamName, eptStreamName, epwStreamName].map((f) => f(space));
|
|
1213
|
+
// The artifact Object Store joins the list: `setupSpaceStreams` creates it, and under auth mode the
|
|
1214
|
+
// provisioner is the cred doing that creating. Its backing stream is `OBJ_<bucket>` - named
|
|
1215
|
+
// explicitly, because `$O.<bucket>.>` is outside the `cotal.<space>.>` grammar and no space-prefix
|
|
1216
|
+
// grant reaches it. CREATE + INFO only: the provisioner never publishes an object, never creates a
|
|
1217
|
+
// consumer on it, and never deletes it. That confinement is load-bearing rather than tidy - the
|
|
1218
|
+
// object-store client reads by creating an ephemeral PUSH consumer with a caller-chosen
|
|
1219
|
+
// `deliver_subject`, so a CONSUMER.CREATE here would be an exporter of every artifact in the space.
|
|
1220
|
+
const OBJ = objectStoreStream(artifactBucket(space));
|
|
1221
|
+
const streamSetup = [CHAT, DM, TASK, INBOX, DLV, epcStreamName(space), OBJ, ...endpointStreams, ...buckets].flatMap((s) => [
|
|
1086
1222
|
`$JS.API.STREAM.CREATE.${s}`,
|
|
1087
1223
|
`$JS.API.STREAM.INFO.${s}`,
|
|
1088
1224
|
]);
|
|
@@ -1114,6 +1250,22 @@ function provisionerPermissions(space, pr) {
|
|
|
1114
1250
|
`$JS.API.DIRECT.GET.KV_${aclBucket(space)}.>`, // keyed get: `.>` (the key rides the subject)
|
|
1115
1251
|
`$JS.API.STREAM.MSG.GET.KV_${channelBucket(space)}`,
|
|
1116
1252
|
`$JS.API.DIRECT.GET.KV_${channelBucket(space)}.>`, // keyed get: `.>` (the key rides the subject)
|
|
1253
|
+
// The Unit B static-manager start path: `ensureAuthorityStores` UPDATEs the records store's
|
|
1254
|
+
// deny-flags exactly once at fresh creation (create → update → verify), and the boot
|
|
1255
|
+
// reconciliation sweep enumerates the manager's slot rows (`keys()` → an ordered consumer)
|
|
1256
|
+
// then reads each slot BODY (phase/uid/actor) to plan resume — the reads ride the
|
|
1257
|
+
// stream-scoped MSG.GET residual named below (records lifecycle metadata, no secrets).
|
|
1258
|
+
`$JS.API.STREAM.UPDATE.KV_${recordsBucket(space)}`,
|
|
1259
|
+
`$JS.API.CONSUMER.CREATE.KV_${recordsBucket(space)}.>`,
|
|
1260
|
+
`$JS.API.CONSUMER.INFO.KV_${recordsBucket(space)}.>`,
|
|
1261
|
+
`$JS.API.CONSUMER.DELETE.KV_${recordsBucket(space)}.>`,
|
|
1262
|
+
// ...and reads the slot-mapping rows so the sweep can plan resume actions. The KV client
|
|
1263
|
+
// reads a lazily-bound bucket via leader-served `STREAM.MSG.GET` — stream-scoped, NOT
|
|
1264
|
+
// key-scoped (the requested key rides the PAYLOAD). NAMED RESIDUAL: for its one-shot
|
|
1265
|
+
// lifetime the provisioner can READ (never write) any records-store row — lifecycle
|
|
1266
|
+
// metadata, no secrets. The keyed Direct Get grant stays for direct-aware read paths.
|
|
1267
|
+
`$JS.API.STREAM.MSG.GET.KV_${recordsBucket(space)}`,
|
|
1268
|
+
`$JS.API.DIRECT.GET.KV_${recordsBucket(space)}.$KV.${recordsBucket(space)}.${STATIC_SLOT_PREFIX}.>`,
|
|
1117
1269
|
],
|
|
1118
1270
|
},
|
|
1119
1271
|
// Replies only: every stream/consumer/KV-create PubAck and JS API response lands on the per-id inbox.
|
|
@@ -1121,6 +1273,185 @@ function provisionerPermissions(space, pr) {
|
|
|
1121
1273
|
sub: { allow: [`_INBOX_${pr.connId}.>`] },
|
|
1122
1274
|
};
|
|
1123
1275
|
}
|
|
1276
|
+
/** The ephemeral, LIFECYCLE-PINNED §13.1 state-write permission set for the STATIC manager's
|
|
1277
|
+
* lifecycle executor (Unit B). One credential per lifecycle OPERATION (activation, terminal,
|
|
1278
|
+
* renewal ledger append): every grant names exactly ONE incarnation's keys — the alias head,
|
|
1279
|
+
* the uid reservation, the manager slot row, the issuance gate, and the `cred.<uid>.>` ledger
|
|
1280
|
+
* family — so a leaked executor cred can move one incarnation's state machine and nothing else.
|
|
1281
|
+
*
|
|
1282
|
+
* Reads: records reads ride the keyed Direct Get form (the key is ON the subject, so the read
|
|
1283
|
+
* grant stays key-pinned); the auth store is leader-served (`allow_direct=false`), so its reads
|
|
1284
|
+
* are body-selected `STREAM.MSG.GET` — stream-scoped, NOT key-scoped (the requested key rides
|
|
1285
|
+
* the PAYLOAD, which a subject grant cannot see). NAMED RESIDUAL: for its one-shot lifetime the
|
|
1286
|
+
* executor can READ (never write) other rows in the auth store. */
|
|
1287
|
+
function lifecycleExecutorPermissions(space, pr, pin) {
|
|
1288
|
+
const REC = recordsBucket(space), AUTH = epAuthBucket(space);
|
|
1289
|
+
// ALL keys DERIVED here from the pin coordinates — the slot key is `staticSlotKey(owner, alias)`,
|
|
1290
|
+
// NOT a caller-supplied literal, so a mis-constructed pin can only ever name ONE coherent
|
|
1291
|
+
// incarnation's rows (guard the core: the profile enforces the "one incarnation" promise, it
|
|
1292
|
+
// does not merely assert it). The builders throw on any non-KV-safe segment.
|
|
1293
|
+
const recordKeys = [lifecycleHeadKey(pin.owner, pin.actor), uidReservationKey(pin.lifecycleUid), staticSlotKey(pin.owner, pin.alias)];
|
|
1294
|
+
return {
|
|
1295
|
+
pub: {
|
|
1296
|
+
allow: [
|
|
1297
|
+
"$JS.API.INFO",
|
|
1298
|
+
// Records-store CAS writes — the value-publish carries the key on the subject, so each
|
|
1299
|
+
// grant names exactly one of this incarnation's keys.
|
|
1300
|
+
...recordKeys.map((k) => `$KV.${REC}.${k}`),
|
|
1301
|
+
// Auth-store CAS writes — this incarnation's gate + its cred-ledger family (renewals
|
|
1302
|
+
// append rows here; the terminal's B1 revoke CASes them).
|
|
1303
|
+
`$KV.${AUTH}.${issuanceGateKey(pin.lifecycleUid)}`,
|
|
1304
|
+
`$KV.${AUTH}.cred.${pin.lifecycleUid}.>`,
|
|
1305
|
+
// Keyed Direct Get reads of the same records keys (key-pinned) for direct-aware read
|
|
1306
|
+
// paths, PLUS the leader-served `STREAM.MSG.GET` the lazily-bound KV client actually
|
|
1307
|
+
// uses — stream-scoped, NOT key-scoped (the requested key rides the PAYLOAD, which a
|
|
1308
|
+
// subject grant cannot see). NAMED RESIDUAL: for its one-shot lifetime the executor can
|
|
1309
|
+
// READ (never write) other rows in BOTH authority stores — lifecycle metadata, no
|
|
1310
|
+
// secrets; every WRITE stays key-pinned above.
|
|
1311
|
+
...recordKeys.map((k) => `$JS.API.DIRECT.GET.KV_${REC}.$KV.${REC}.${k}`),
|
|
1312
|
+
`$JS.API.STREAM.MSG.GET.KV_${REC}`,
|
|
1313
|
+
`$JS.API.STREAM.MSG.GET.KV_${AUTH}`,
|
|
1314
|
+
],
|
|
1315
|
+
},
|
|
1316
|
+
sub: { allow: [`_INBOX_${pr.connId}.>`] },
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
/** The ephemeral, ENDPOINT-INSTANCE-PINNED endpoint-serve executor permission set (P2 item 1,
|
|
1320
|
+
* 1a-serve): the manager mints this per registration/serve-mint op and drives the endpoint
|
|
1321
|
+
* registration barrier's `epgate` CAS + the mint fence's `epcred` stage/revoke THROUGH it — never
|
|
1322
|
+
* its standing seed/supervisor connection (critic #1's manager-specific "no seed shortcut"). Every
|
|
1323
|
+
* WRITE is key-pinned to exactly ONE (endpoint, instanceId): the gate `epgate.<ep>.<iid>`, its
|
|
1324
|
+
* serving ledger family `epcred.<ep>.<iid>.>`, and the registration's two records keys (the
|
|
1325
|
+
* instance's `svc` spec + the endpoint's governance head — `registerServiceInstance` drives the
|
|
1326
|
+
* slot-take/promote over this same connection). A leaked/mis-constructed executor can move exactly
|
|
1327
|
+
* one endpoint instance's serve state and nothing else. The auth store is `allow_direct=false`, so
|
|
1328
|
+
* reads are leader-served `STREAM.MSG.GET` (stream-scoped, NOT key-scoped — the key rides the
|
|
1329
|
+
* payload); enumeration of the epcred family rides an ordered `keys()` consumer. NAMED RESIDUAL:
|
|
1330
|
+
* for its one-shot lifetime the executor can READ (never write) other auth rows — endpoint/
|
|
1331
|
+
* credential metadata, no bearer bytes; every WRITE stays key-pinned. */
|
|
1332
|
+
/** The SELF-MEDIATED goal-writer profile (P2 item 2, spawn-as-action): exactly
|
|
1333
|
+
* {@link goalWriterGrants} for ITS endpoint — the goal bind + terminal facts, the goal-record KV
|
|
1334
|
+
* writes, and the leader-served fencing reads — plus the connection-scoped reply inbox. Disjoint
|
|
1335
|
+
* from the endpoint's serve credential (Q2): a serve connection carries none of these rows, so it
|
|
1336
|
+
* is broker-denied every goal write. */
|
|
1337
|
+
function goalWriterPermissions(space, pr, pin) {
|
|
1338
|
+
const g = goalWriterGrants(space, pin.endpoint, pr.connId);
|
|
1339
|
+
return { pub: { allow: g.publish }, sub: { allow: g.subscribe } };
|
|
1340
|
+
}
|
|
1341
|
+
/** The console/CLI per-session CALLER rows (P2 item 6): RAILS-ONLY for ONE §13.6 session — pub the
|
|
1342
|
+
* session's epoch-pinned `in` rail, sub its `out` rail plus the caller's own reply inbox, and
|
|
1343
|
+
* NOTHING else. Deliberately NO KV, NO JetStream API, NO store: the caller drives the terminal over
|
|
1344
|
+
* the two core-only eps subjects and never reads the session ledger, so there is no subject-blind
|
|
1345
|
+
* store read to widen (SPEC §13.9). The endpoint+sessionId+epoch pin the EXACT pair, so a cred for
|
|
1346
|
+
* session A authorizes nothing of session B (no wildcard). `epsSubject` validates every token, so a
|
|
1347
|
+
* malformed coordinate refuses at the mint rather than emitting a broadened subject. */
|
|
1348
|
+
function sessionCallerPermissions(space, pr, pin) {
|
|
1349
|
+
if (!pin)
|
|
1350
|
+
throw new Error('permissionsFor: "session-caller" requires opts.sessionCaller ({endpoint, sessionId, epoch}) - the ONE session\'s rails this cred may use');
|
|
1351
|
+
return {
|
|
1352
|
+
pub: { allow: [epsSubject(space, pin.endpoint, pin.sessionId, pin.epoch, "in")] },
|
|
1353
|
+
sub: { allow: [epsSubject(space, pin.endpoint, pin.sessionId, pin.epoch, "out"), `_INBOX_${pr.connId}.>`] },
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
/** The manager's per-session SERVING rows (P2 item 6): the EXACT mirror of
|
|
1357
|
+
* {@link sessionCallerPermissions} with the directions swapped — sub the ONE session's epoch-pinned
|
|
1358
|
+
* `in` rail (caller→serving), pub its `out` rail (serving→caller), plus the connection-scoped reply
|
|
1359
|
+
* inbox, and NOTHING else. Same asymmetry §13.6 states: "the caller publishes `in` and subscribes
|
|
1360
|
+
* `out`; the serving instance the reverse".
|
|
1361
|
+
*
|
|
1362
|
+
* Deliberately NO KV and NO JetStream API — not even the session ledger. The serving side drives
|
|
1363
|
+
* bytes; the ledger is the standing `session-ledger` credential's, on a different connection. That
|
|
1364
|
+
* separation is what lets this credential be one-shot and die with its session while the durable
|
|
1365
|
+
* revocation authority outlives it (§13.6).
|
|
1366
|
+
*
|
|
1367
|
+
* This REPLACES a standing writer that held `eps.<endpoint>.*.<epoch>.{in,out}` and so could read
|
|
1368
|
+
* and write every live session's bytes at its epoch. The pin makes a credential for session A
|
|
1369
|
+
* authorize nothing of session B, and `epsSubject` validates every token, so a malformed
|
|
1370
|
+
* coordinate refuses at the mint rather than emitting a broadened subject. */
|
|
1371
|
+
function sessionServingPermissions(space, pr, pin) {
|
|
1372
|
+
if (!pin)
|
|
1373
|
+
throw new Error('permissionsFor: "session-serving" requires opts.sessionServing ({endpoint, sessionId, epoch}) - the ONE session\'s rails this cred may serve');
|
|
1374
|
+
return {
|
|
1375
|
+
pub: { allow: [epsSubject(space, pin.endpoint, pin.sessionId, pin.epoch, "out")] },
|
|
1376
|
+
sub: { allow: [epsSubject(space, pin.endpoint, pin.sessionId, pin.epoch, "in"), `_INBOX_${pr.connId}.>`] },
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
/** The manager's SESSION-LEDGER rows (P2 item 6): the DEDICATED sessions-bucket rows and nothing
|
|
1380
|
+
* else — no session rail of any shape. Needs no pin: the grant carries no endpoint, epoch, or
|
|
1381
|
+
* session component, because §13.6's durable revocation authority is per-space, not per-session
|
|
1382
|
+
* (it must still be able to resolve and revoke a row after the endpoint that served it is gone).
|
|
1383
|
+
* {@link sessionLedgerGrants} carries the row rationale, the §13.9 subject-blindness confinement,
|
|
1384
|
+
* and the named `session.*` breadth residual. */
|
|
1385
|
+
function sessionLedgerPermissions(space, pr) {
|
|
1386
|
+
const g = sessionLedgerGrants(space, pr.connId);
|
|
1387
|
+
return { pub: { allow: g.publish }, sub: { allow: g.subscribe } };
|
|
1388
|
+
}
|
|
1389
|
+
function endpointServeExecutorPermissions(space, pr, pin) {
|
|
1390
|
+
const AUTH = epAuthBucket(space), REC = recordsBucket(space);
|
|
1391
|
+
// DERIVED from (endpoint, instanceId) via the core builders — never a caller literal.
|
|
1392
|
+
const gateKey = epgateKey(pin.endpoint, pin.instanceId);
|
|
1393
|
+
const credPrefix = epcredFamilyPrefix(pin.endpoint, pin.instanceId);
|
|
1394
|
+
// The registration writes this ONE instance's spec key plus the endpoint's governance head
|
|
1395
|
+
// (`registerServiceInstance` PHASE 1/3b: the slot-take + promote ride the SAME executor), and this
|
|
1396
|
+
// instance's own svc STATUS key (P2 item 3: the manager writes its CONVERGED `ready` status so it
|
|
1397
|
+
// is a §13.5 scatter member — `freezeExpectedSet` requires a status caught up to the current
|
|
1398
|
+
// registration; the write is epoch-fenced by `writeServiceStatus`).
|
|
1399
|
+
const recordKeys = [
|
|
1400
|
+
recordSpecKey(RECORD_KINDS.svc, [pin.endpoint, assertLifecycleToken(pin.instanceId, "instanceId")]),
|
|
1401
|
+
recordStatusKey(RECORD_KINDS.svc, [pin.endpoint, assertLifecycleToken(pin.instanceId, "instanceId")]),
|
|
1402
|
+
recordAtomicKey(GOVERN_HEAD, [pin.endpoint]),
|
|
1403
|
+
];
|
|
1404
|
+
return {
|
|
1405
|
+
pub: {
|
|
1406
|
+
allow: [
|
|
1407
|
+
"$JS.API.INFO",
|
|
1408
|
+
// The key-pinned WRITE grants: the gate (provision create + barrier freeze/reopen CAS),
|
|
1409
|
+
// the serving ledger family (mint-fence stage + barrier revoke), and the registration's
|
|
1410
|
+
// two records-store keys (spec CAS + governance slot/promote). The value-publish carries
|
|
1411
|
+
// the key on the subject, so each grant names exactly this instance's keys.
|
|
1412
|
+
`$KV.${AUTH}.${gateKey}`,
|
|
1413
|
+
`$KV.${AUTH}.${credPrefix}.>`,
|
|
1414
|
+
...recordKeys.map((k) => `$KV.${REC}.${k}`),
|
|
1415
|
+
// §13.7 contract-artifact publication (P2 item 1, 1c): the registration publishes the
|
|
1416
|
+
// endpoint's cluster document, closure manifests, and schema roots to the EPC store so
|
|
1417
|
+
// callers can fetch-verify-compile the registered digests. A digest subject is a SINGLE
|
|
1418
|
+
// hex token (`epc.<64hex>`), so the grant is the single-token `epc.*` form (matching
|
|
1419
|
+
// `contractPublisherGrants`), never the multi-token `epc.>`. Digest subjects cannot be
|
|
1420
|
+
// key-pinned pre-mint; the store's SHAPE is the defense (ensureContractStore): a digest
|
|
1421
|
+
// subject holds exactly one broker-immutable message (max_msgs_per_subject:1 +
|
|
1422
|
+
// discard-new-per-subject REJECTS a second publish regardless of the create-only header, so
|
|
1423
|
+
// a grant-holder CANNOT append a shadow over a published artifact), deny_delete/deny_purge
|
|
1424
|
+
// keep that message, and content addressing (verify-on-read, create-only-winner fallback)
|
|
1425
|
+
// makes a wrong-subject write unservable. NAMED RESIDUAL: for its one-shot lifetime the
|
|
1426
|
+
// executor can publish NEW digest-addressed artifacts at previously-unused subjects (a
|
|
1427
|
+
// bounded storage flood; unreferenced artifacts carry no authority) — it can NOT overwrite,
|
|
1428
|
+
// shadow, or replace an existing one.
|
|
1429
|
+
`${spacePrefix(space)}.epc.*`,
|
|
1430
|
+
// The lost-CAS verify-read `publishContractArtifact` runs when a re-publish (every re-up /
|
|
1431
|
+
// restart against an existing store) loses the create-only CAS: it fetches the recorded
|
|
1432
|
+
// artifact to confirm the idempotent no-op. BOTH Direct Get forms — the subject-scoped
|
|
1433
|
+
// `last_by_subj` (the fast path) and the bare stream form (the create-only-winner
|
|
1434
|
+
// `next_by_subj` fallback) — so the publish path never dies on a broker denial regardless
|
|
1435
|
+
// of stream config. Without this the manager exits on its SECOND boot (the tester's
|
|
1436
|
+
// upgrade-path regression). Reads public content-addressed artifacts only.
|
|
1437
|
+
`$JS.API.DIRECT.GET.${epcStreamName(space)}.${spacePrefix(space)}.epc.>`,
|
|
1438
|
+
`$JS.API.DIRECT.GET.${epcStreamName(space)}`,
|
|
1439
|
+
// Leader-served reads (the auth store is allow_direct=false): stream-scoped MSG.GET (the
|
|
1440
|
+
// barrier/fence read the gate + each epcred row), plus the ordered consumer the epcred
|
|
1441
|
+
// `keys()` enumeration binds. The records store IS direct-servable, so its reads add the
|
|
1442
|
+
// key-pinned DIRECT.GET forms beside the leader-served fallback. NAMED RESIDUAL: reads
|
|
1443
|
+
// any row in both stores for its one-shot lifetime (metadata, no bearer bytes).
|
|
1444
|
+
`$JS.API.STREAM.MSG.GET.KV_${AUTH}`,
|
|
1445
|
+
`$JS.API.CONSUMER.CREATE.KV_${AUTH}.>`,
|
|
1446
|
+
`$JS.API.CONSUMER.INFO.KV_${AUTH}.>`,
|
|
1447
|
+
`$JS.API.CONSUMER.DELETE.KV_${AUTH}.>`,
|
|
1448
|
+
...recordKeys.map((k) => `$JS.API.DIRECT.GET.KV_${REC}.$KV.${REC}.${k}`),
|
|
1449
|
+
`$JS.API.STREAM.MSG.GET.KV_${REC}`,
|
|
1450
|
+
],
|
|
1451
|
+
},
|
|
1452
|
+
sub: { allow: [`_INBOX_${pr.connId}.>`] },
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1124
1455
|
/** The ephemeral, TARGET-PINNED DEPROVISIONER permission set (#159 Part B) — the teardown counterpart
|
|
1125
1456
|
* to {@link provisionerPermissions}, minted per departed agent inside the manager's `deprovision` tail
|
|
1126
1457
|
* (`withProvisioner`-style: a fresh scoped cred per teardown is cheap). It deletes exactly the
|
|
@@ -1195,6 +1526,15 @@ function deliveryPermissions(space, pr) {
|
|
|
1195
1526
|
// chat (the fan-out consumes the whole stream), so a stream-wide CHAT consumer grant is no
|
|
1196
1527
|
// escalation. The catch-up ephemeral names (`cu_<owner>_<gen>`) are dynamic, so they can't be
|
|
1197
1528
|
// name-pinned; CHAT-wide is correct here.
|
|
1529
|
+
//
|
|
1530
|
+
// BOTH forms, and the bare one is not redundant: a create carrying `filter_subjects` cannot encode
|
|
1531
|
+
// its filter in the subject, so the client publishes to the BARE `…CREATE.<CHAT>` while a named
|
|
1532
|
+
// consumer goes to the `.>` form. The mediated history read (`readHistory`) builds exactly that
|
|
1533
|
+
// multi-filter ephemeral, and with only the `.>` grant it fails with a Permissions Violation on the
|
|
1534
|
+
// bare subject — measured, not predicted. The observer profile already carries both for the same
|
|
1535
|
+
// reason. Grants the daemon nothing new in substance: it may already create any named consumer on
|
|
1536
|
+
// this stream and already reads all of it.
|
|
1537
|
+
`$JS.API.CONSUMER.CREATE.${CHAT}`,
|
|
1198
1538
|
`$JS.API.CONSUMER.CREATE.${CHAT}.>`,
|
|
1199
1539
|
`$JS.API.CONSUMER.DURABLE.CREATE.${CHAT}.>`,
|
|
1200
1540
|
`$JS.API.CONSUMER.INFO.${CHAT}.>`,
|
|
@@ -1295,7 +1635,7 @@ function membershipObserverPermissions(accountId) {
|
|
|
1295
1635
|
* (`auth.account.pub`). Mirrors {@link mintCreds} but issues into the system account. */
|
|
1296
1636
|
export async function mintMembershipObserverCreds(auth, identity, opts = {}) {
|
|
1297
1637
|
if (!auth.sys.signingSeed)
|
|
1298
|
-
throw new Error("mintMembershipObserverCreds: no in-memory system-account signing seed - the observer can only be minted
|
|
1638
|
+
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
1639
|
const signer = fromSeed(new TextEncoder().encode(auth.sys.signingSeed));
|
|
1300
1640
|
const perms = membershipObserverPermissions(auth.account.pub);
|
|
1301
1641
|
// Bounded exp (D5 slice 5): the observer is `rotation-renewed` — it carries the matrix's default
|
|
@@ -1328,7 +1668,7 @@ function connectionEvictorPermissions() {
|
|
|
1328
1668
|
* only), same fail-loud when it's absent. Paired with the observer at `up`. */
|
|
1329
1669
|
export async function mintConnectionEvictorCreds(auth, identity, opts = {}) {
|
|
1330
1670
|
if (!auth.sys.signingSeed)
|
|
1331
|
-
throw new Error("mintConnectionEvictorCreds: no in-memory system-account signing seed - the evictor can only be minted
|
|
1671
|
+
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
1672
|
const signer = fromSeed(new TextEncoder().encode(auth.sys.signingSeed));
|
|
1333
1673
|
// Bounded exp (D5 slice 5): `rotation-renewed`, same posture as the observer above.
|
|
1334
1674
|
const validDates = userValidDates("connection-evictor", opts);
|
|
@@ -1347,6 +1687,53 @@ export async function mintConnectionEvictorCreds(auth, identity, opts = {}) {
|
|
|
1347
1687
|
* NOTE (W4): the MEMORY resolver is one static whole-broker map, so every mutation rewrites all of
|
|
1348
1688
|
* it. Concurrent add/remove of spaces needs a broker-authoritative inventory with generation/CAS
|
|
1349
1689
|
* and atomic promotion above this function; this renderer is deliberately pure. */
|
|
1690
|
+
/**
|
|
1691
|
+
* Render the config for an OPEN (no-auth) broker.
|
|
1692
|
+
*
|
|
1693
|
+
* This exists so that no path reaches a listener without naming its transport. Open mode used to
|
|
1694
|
+
* start nats-server from bare CLI flags (`-js -sd … -p … -a …`) and never called `serverConfig` at
|
|
1695
|
+
* all, which meant the required `transport` union protected the auth path and was silent on the
|
|
1696
|
+
* open one: an operator could pass a cert and key, watch `up` print its normal banner, and get a
|
|
1697
|
+
* cleartext listener. That is the silent downgrade this feature exists to prevent, reachable by
|
|
1698
|
+
* someone who did everything right — so open mode renders a config too.
|
|
1699
|
+
*
|
|
1700
|
+
* It deliberately does NOT reuse `serverConfig`: that renders the operator, system account and
|
|
1701
|
+
* MEMORY resolver, none of which a no-auth broker should carry. What the two share is the thing
|
|
1702
|
+
* that matters — a REQUIRED transport, so the choice cannot be omitted on either path.
|
|
1703
|
+
*
|
|
1704
|
+
* IMPORTANT, and it must be said wherever open-mode TLS is surfaced to an operator: TLS ON AN
|
|
1705
|
+
* OPEN MESH GIVES CONFIDENTIALITY, NOT AUTHENTICATION. It hides traffic from a passive observer.
|
|
1706
|
+
* It does not verify who is connecting, because an open broker has no credentials to check — so
|
|
1707
|
+
* anyone who can reach the port still gets in, encrypted. It is a legitimate configuration for a
|
|
1708
|
+
* mesh crossing a network nobody controls, and it is NOT "secure" in the sense a reader will
|
|
1709
|
+
* assume from seeing `cotals://`. Describe it as the caveat it is rather than as a feature.
|
|
1710
|
+
*/
|
|
1711
|
+
export function openServerConfig(opts) {
|
|
1712
|
+
const port = opts.port ?? 4222;
|
|
1713
|
+
const host = opts.host ?? "127.0.0.1";
|
|
1714
|
+
return `# Generated by \`cotal up\` - do not edit by hand.
|
|
1715
|
+
host: ${host}
|
|
1716
|
+
port: ${port}
|
|
1717
|
+
max_control_line: 65536
|
|
1718
|
+
${renderTlsBlock(opts.transport)}jetstream { store_dir: ${JSON.stringify(opts.storeDir)} }
|
|
1719
|
+
`;
|
|
1720
|
+
}
|
|
1721
|
+
/** The one place a `tls{}` block is produced, shared by both broker renderers.
|
|
1722
|
+
*
|
|
1723
|
+
* `allow_non_tls` is deliberately never emitted. It is a TOP-LEVEL knob (nested inside `tls{}`
|
|
1724
|
+
* nats-server rejects it as an unknown field), and it turns the listener into mixed mode: INFO
|
|
1725
|
+
* then advertises `tls_available` instead of `tls_required`, and a client that declines to
|
|
1726
|
+
* upgrade is served in cleartext — precisely the credential exposure this transport closes.
|
|
1727
|
+
* There is no supported migration mode; enabling TLS is all-or-nothing. */
|
|
1728
|
+
function renderTlsBlock(transport) {
|
|
1729
|
+
if (transport.kind === "plaintext")
|
|
1730
|
+
return "";
|
|
1731
|
+
return `tls {
|
|
1732
|
+
cert_file: ${JSON.stringify(transport.certFile)}
|
|
1733
|
+
key_file: ${JSON.stringify(transport.keyFile)}
|
|
1734
|
+
}
|
|
1735
|
+
`;
|
|
1736
|
+
}
|
|
1350
1737
|
export function serverConfig(broker, spaces, opts) {
|
|
1351
1738
|
if (!spaces.length)
|
|
1352
1739
|
throw new Error("serverConfig: at least one space account is required");
|
|
@@ -1361,6 +1748,14 @@ export function serverConfig(broker, spaces, opts) {
|
|
|
1361
1748
|
}
|
|
1362
1749
|
const port = opts.port ?? 4222;
|
|
1363
1750
|
const host = opts.host ?? "127.0.0.1";
|
|
1751
|
+
// The websocket listener (item 6): LOCALHOST by default, no_tls for the dev loopback. Emitted only
|
|
1752
|
+
// when wsPort is set — a broker with no console session client opens no ws surface.
|
|
1753
|
+
const websocket = opts.wsPort === undefined ? "" : `websocket {
|
|
1754
|
+
host: ${opts.wsHost ?? "127.0.0.1"}
|
|
1755
|
+
port: ${opts.wsPort}
|
|
1756
|
+
no_tls: true
|
|
1757
|
+
}
|
|
1758
|
+
`;
|
|
1364
1759
|
// A minted "agent" carries its full permission allow-list inline in its user JWT, which the
|
|
1365
1760
|
// client sends in the CONNECT protocol line. With per-channel + JetStream-API grants that JWT
|
|
1366
1761
|
// exceeds the 4 KB default max_control_line at ~2 channels, and the server then silently drops
|
|
@@ -1368,12 +1763,13 @@ export function serverConfig(broker, spaces, opts) {
|
|
|
1368
1763
|
// agent JWT — but right-sized, not generous: the CONNECT line is parsed BEFORE auth, so the cap
|
|
1369
1764
|
// is a per-connection pre-auth allocation under connection flooding. 64 KB clears a many-channel
|
|
1370
1765
|
// agent JWT (~4–8 KB) with wide margin while keeping the pre-auth surface ~16× tighter than 1 MB.
|
|
1766
|
+
const tlsBlock = renderTlsBlock(opts.transport);
|
|
1371
1767
|
return `# Generated by \`cotal up\` - do not edit by hand.
|
|
1372
1768
|
host: ${host}
|
|
1373
1769
|
port: ${port}
|
|
1374
1770
|
max_control_line: 65536
|
|
1375
|
-
jetstream { store_dir: ${JSON.stringify(opts.storeDir)} }
|
|
1376
|
-
operator: ${broker.operator.jwt}
|
|
1771
|
+
${tlsBlock}jetstream { store_dir: ${JSON.stringify(opts.storeDir)} }
|
|
1772
|
+
${websocket}operator: ${broker.operator.jwt}
|
|
1377
1773
|
system_account: ${broker.sys.pub}
|
|
1378
1774
|
resolver: MEMORY
|
|
1379
1775
|
resolver_preload: {
|