@cotal-ai/core 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-file.d.ts +10 -1
- package/dist/agent-file.d.ts.map +1 -1
- package/dist/agent-file.js +65 -80
- package/dist/agent-file.js.map +1 -1
- package/dist/auth-provider.d.ts +207 -0
- package/dist/auth-provider.d.ts.map +1 -0
- package/dist/auth-provider.js +13 -0
- package/dist/auth-provider.js.map +1 -0
- package/dist/channels.d.ts +10 -0
- package/dist/channels.d.ts.map +1 -1
- package/dist/channels.js +9 -27
- package/dist/channels.js.map +1 -1
- package/dist/command.d.ts +6 -1
- package/dist/command.d.ts.map +1 -1
- package/dist/command.js +5 -1
- package/dist/command.js.map +1 -1
- package/dist/connector-config.js +1 -1
- package/dist/connector.d.ts +16 -0
- package/dist/connector.d.ts.map +1 -1
- package/dist/endpoint.d.ts +148 -17
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +470 -97
- package/dist/endpoint.js.map +1 -1
- package/dist/evict.d.ts +87 -0
- package/dist/evict.d.ts.map +1 -0
- package/dist/evict.js +231 -0
- package/dist/evict.js.map +1 -0
- package/dist/identity.d.ts +15 -0
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +26 -2
- package/dist/identity.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/launch.d.ts +6 -0
- package/dist/launch.d.ts.map +1 -1
- package/dist/members.d.ts.map +1 -1
- package/dist/members.js +5 -2
- package/dist/members.js.map +1 -1
- package/dist/membership-feed.d.ts +17 -3
- package/dist/membership-feed.d.ts.map +1 -1
- package/dist/membership-feed.js +41 -13
- package/dist/membership-feed.js.map +1 -1
- package/dist/provision.d.ts +99 -14
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +267 -128
- package/dist/provision.js.map +1 -1
- package/dist/resolve.js +1 -1
- package/dist/secret-fs.d.ts +9 -0
- package/dist/secret-fs.d.ts.map +1 -1
- package/dist/secret-fs.js +26 -2
- package/dist/secret-fs.js.map +1 -1
- package/dist/streams.d.ts +33 -7
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +53 -30
- package/dist/streams.js.map +1 -1
- package/dist/subjects.d.ts +267 -75
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +401 -110
- package/dist/subjects.js.map +1 -1
- package/dist/types.d.ts +13 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -2
package/dist/provision.js
CHANGED
|
@@ -17,17 +17,30 @@
|
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import { 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, unicastSubject, anycastSubject, controlServiceSubject, CONTROL_PRIVILEGED, CONTROL_SELF_SERVICE, CONTROL_ADMIN, CONTROL_DELIVERY, chatStream, dmStream, taskStream, dlvStream, inboxStream, chatHistDurable, dmDurable, taskDurable, dlvDurable, presenceBucket, channelBucket, membersBucket, aclBucket, aclKey, membershipBucket, deliveryBucket, managerBucket, MANAGER_LEASE_KEY, connzRequestSubject, accountConnectSubject, accountDisconnectSubject, MEMBERSHIP_INBOX_PREFIX, FANOUT_DURABLE, INBOX_READER_DURABLE, } from "./subjects.js";
|
|
20
|
+
import { token, spacePrefix, chatSubject, assertValidChannel, channelInAllow, principalKey, parsePrincipalKey, deprovisionTargetPrincipal, principalTags, assertInboxConnId, DEV_OWNER, unicastSubject, anycastSubject, controlServiceSubject, CONTROL_PRIVILEGED, CONTROL_SELF_SERVICE, CONTROL_ADMIN, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, chatStream, dmStream, taskStream, dlvStream, inboxStream, chatHistDurable, dmDurable, taskDurable, dlvDurable, presenceBucket, channelBucket, membersBucket, aclBucket, aclKey, membershipBucket, deliveryBucket, managerBucket, MANAGER_LEASE_KEY, connzRequestSubject, accountConnectSubject, accountDisconnectSubject, MEMBERSHIP_INBOX_PREFIX, FANOUT_DURABLE, INBOX_READER_DURABLE, } from "./subjects.js";
|
|
21
|
+
import { credsClaims } from "./identity.js";
|
|
21
22
|
const FIVE_MINUTES = 5 * 60;
|
|
23
|
+
/** Bounded lifetime for `standing-renewable` credentials whose renewal owner is ONLINE (D5 slice 5):
|
|
24
|
+
* the holder (or its launcher) re-mints at 75% of the lifetime via the endpoint's creds-source seam,
|
|
25
|
+
* so a copied cred is broker-dead within a day while renewal never involves an operator. 24h keeps
|
|
26
|
+
* the remaining-25% loud-failure window at ~6h — wide enough to notice and repair before expiry. */
|
|
27
|
+
export const STANDING_RENEWABLE_TTL_SEC = 24 * 60 * 60;
|
|
28
|
+
/** Bounded lifetime for the `rotation-renewed` $SYS credentials (membership-observer + connection-
|
|
29
|
+
* evictor). They are NOT online-renewable (the $SYS seed dies at end of `up`), so this exp is the
|
|
30
|
+
* credential-death horizon: a copied observer/evictor cred becomes broker-dead after it, and the
|
|
31
|
+
* operator is expected to have run a coordinated system-account rotation + broker restart within it
|
|
32
|
+
* (the doctor surface warns ahead — slice 6). 30 days balances "copied cred eventually dies" against
|
|
33
|
+
* a comfortable monthly rotation cadence; tune here as one named knob. */
|
|
34
|
+
export const ROTATION_RENEWED_TTL_SEC = 30 * 24 * 60 * 60;
|
|
22
35
|
/** D5 profile matrix. This is intentionally centralized so every new mint profile must classify its
|
|
23
36
|
* credential-death behavior instead of silently inheriting non-expiring static creds. */
|
|
24
37
|
export const CREDENTIAL_LIFETIMES = {
|
|
25
38
|
agent: { class: "mixed", note: "manager children, foreground spawn/join, and cotal mint static outputs all use this profile; split or repair flow required before default exp" },
|
|
26
39
|
observer: { class: "static-operator-managed", note: "out-of-band dashboard/audit credential from cotal mint" },
|
|
27
40
|
admin: { class: "static-operator-managed", note: "out-of-band elevated dashboard/audit credential from cotal mint" },
|
|
28
|
-
supervisor: { class: "standing-renewable", renewalOwner: "manager", note: "manager's always-on endpoint;
|
|
29
|
-
delivery: { class: "standing-renewable", renewalOwner: "
|
|
30
|
-
"membership-rw": { class: "standing-renewable", renewalOwner: "
|
|
41
|
+
supervisor: { class: "standing-renewable", defaultTtlSeconds: STANDING_RENEWABLE_TTL_SEC, renewalOwner: "manager", note: "manager's always-on endpoint; the manager holds the DATA seed and self-remints via the endpoint creds source (D5 slice 5 class 1)" },
|
|
42
|
+
delivery: { class: "standing-renewable", defaultTtlSeconds: STANDING_RENEWABLE_TTL_SEC, renewalOwner: "manager", note: "server-side Plane-3 daemon; seed-less - the manager re-signs .cotal/delivery.creds for the SAME nkey, requests delivery-admin reloadCreds for explicit adoption, and the endpoint source re-read is only a backstop (D5 slice 5 class 2)" },
|
|
43
|
+
"membership-rw": { class: "standing-renewable", defaultTtlSeconds: STANDING_RENEWABLE_TTL_SEC, renewalOwner: "manager", note: "membership feed writer; seed-less - the manager re-signs .cotal/membership-rw.creds for the SAME nkey, delivery-admin reloadCreds reconnects the rw feed explicitly, and source re-read is only a backstop (D5 slice 5 class 2)" },
|
|
31
44
|
provisioner: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "setup/spawn provisioning window only" },
|
|
32
45
|
deprovisioner: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "target-pinned teardown window only" },
|
|
33
46
|
operator: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "send/dm/join/probe-style operator command" },
|
|
@@ -39,11 +52,32 @@ export const CREDENTIAL_LIFETIMES = {
|
|
|
39
52
|
"control-caller-privileged": { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "ps/start control call" },
|
|
40
53
|
"control-caller-admin": { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "stop/attach admin control call" },
|
|
41
54
|
deployer: { class: "one-shot", note: "manifest deploy spans planning/launch/ledger; needs near-expiry guard or remint before default exp" },
|
|
42
|
-
"membership-observer": { class: "
|
|
55
|
+
"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" },
|
|
56
|
+
"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" },
|
|
43
57
|
};
|
|
44
58
|
export function credentialLifetime(kind) {
|
|
45
59
|
return CREDENTIAL_LIFETIMES[kind];
|
|
46
60
|
}
|
|
61
|
+
export function inspectCredHealth(creds, nowSec = Math.floor(Date.now() / 1000)) {
|
|
62
|
+
let claims;
|
|
63
|
+
try {
|
|
64
|
+
claims = credsClaims(creds);
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
return { state: "unreadable", error: e.message };
|
|
68
|
+
}
|
|
69
|
+
const { iat, exp } = claims;
|
|
70
|
+
if (typeof exp !== "number")
|
|
71
|
+
return { state: "unbounded", iat };
|
|
72
|
+
if (typeof iat !== "number")
|
|
73
|
+
return { state: "unreadable", iat: undefined, exp, error: "user JWT carries exp but no iat - cannot place the renewal point" };
|
|
74
|
+
const renewAt = Math.floor(iat + 0.75 * (exp - iat));
|
|
75
|
+
if (nowSec >= exp)
|
|
76
|
+
return { state: "expired", iat, exp, renewAt };
|
|
77
|
+
if (nowSec >= renewAt)
|
|
78
|
+
return { state: "near-expiry", iat, exp, renewAt };
|
|
79
|
+
return { state: "healthy", iat, exp, renewAt };
|
|
80
|
+
}
|
|
47
81
|
// Unlimited account limits — without explicit limits a JWT account defaults to 0 conns
|
|
48
82
|
// (every connect denied). JetStream needs storage on the data account but MUST stay off
|
|
49
83
|
// the system account (the server refuses to start otherwise).
|
|
@@ -140,7 +174,11 @@ export async function createSpaceAuth(space) {
|
|
|
140
174
|
sys: { pub: sysPub, jwt: sysJwt, signingSeed: dec(syskp.getSeed()) },
|
|
141
175
|
};
|
|
142
176
|
}
|
|
143
|
-
|
|
177
|
+
/** Compute a minted credential's `{ exp? }` from an explicit override or the centralized matrix
|
|
178
|
+
* default. Widened to {@link CredentialKind} (not just {@link Profile}) so the bespoke $SYS minters
|
|
179
|
+
* — `membership-observer` / `connection-evictor`, which are kinds, not profiles — thread the same
|
|
180
|
+
* bounded-lifetime policy instead of minting non-expiring $SYS creds. */
|
|
181
|
+
function userValidDates(kind, opts) {
|
|
144
182
|
if (opts.expiresAt !== undefined && opts.expiresInSeconds !== undefined)
|
|
145
183
|
throw new Error("mintCreds: pass only one of expiresAt or expiresInSeconds");
|
|
146
184
|
if (opts.expiresAt !== undefined) {
|
|
@@ -148,13 +186,25 @@ function userValidDates(profile, opts) {
|
|
|
148
186
|
throw new Error("mintCreds: expiresAt must be a non-negative integer timestamp (seconds)");
|
|
149
187
|
return { exp: opts.expiresAt };
|
|
150
188
|
}
|
|
151
|
-
const ttl = opts.expiresInSeconds ?? CREDENTIAL_LIFETIMES[
|
|
189
|
+
const ttl = opts.expiresInSeconds ?? CREDENTIAL_LIFETIMES[kind].defaultTtlSeconds;
|
|
152
190
|
if (ttl === undefined)
|
|
153
191
|
return {};
|
|
154
192
|
if (!Number.isInteger(ttl) || ttl <= 0)
|
|
155
193
|
throw new Error("mintCreds: expiresInSeconds must be a positive integer");
|
|
156
194
|
return { exp: Math.floor(Date.now() / 1000) + ttl };
|
|
157
195
|
}
|
|
196
|
+
/** Resolve a {@link MintPrincipal} for the STATIC/dev mint path from an {@link Identity} + optional
|
|
197
|
+
* explicit principal. No-login dev default: owner = {@link DEV_OWNER} ("local"), actor = the connection
|
|
198
|
+
* id, so the agent's lane is `local.<id>`. The connection nkey is always the identity's id here (the
|
|
199
|
+
* creds bind to it). User mode does NOT flow through here — the callout mints directly with the
|
|
200
|
+
* server-derived owner + ledger actor. */
|
|
201
|
+
function principalOf(identity, principal) {
|
|
202
|
+
return {
|
|
203
|
+
owner: principal?.owner ?? DEV_OWNER,
|
|
204
|
+
actor: principal?.actor ?? identity.id,
|
|
205
|
+
connId: identity.id,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
158
208
|
/** Onboard an agent for launch (auth mode): pre-create its bind-only DM (+ Plane-3 DELIVER + role
|
|
159
209
|
* TASK) durables, RECORD its read ACL in the durable registry (unless `durableMembership:false`), and
|
|
160
210
|
* mint its scoped creds. Live delivery is the agent's own core subscription — there is no per-instance
|
|
@@ -162,6 +212,15 @@ function userValidDates(profile, opts) {
|
|
|
162
212
|
* via the server-side delivery daemon's `ctl.delivery` op at connect. A live-only launcher
|
|
163
213
|
* (`durableMembership:false`, e.g. direct `cotal spawn`) gets no ACL row and stays live-only. */
|
|
164
214
|
export async function provisionAgent(provisioner, auth, identity, opts = {}) {
|
|
215
|
+
const allowSubscribe = await provisionAgentDurables(provisioner, principalOf(identity, opts.principal), opts);
|
|
216
|
+
return mintCreds(auth, identity, "agent", { ...opts, allowSubscribe });
|
|
217
|
+
}
|
|
218
|
+
/** The DURABLE half of agent onboarding, principal-keyed and credential-agnostic: pre-create the
|
|
219
|
+
* bind-only DM + DELIVER durables, record the read ACL, ensure the role TASK queue. The static
|
|
220
|
+
* path ({@link provisionAgent}) follows it with a mint; the USER-MODE spawn path runs it alone —
|
|
221
|
+
* a user agent's credential is its bearer (callout-minted per connect), never a static cred.
|
|
222
|
+
* Returns the resolved read ACL so both callers scope from the same computed set. */
|
|
223
|
+
export async function provisionAgentDurables(provisioner, pr, opts = {}) {
|
|
165
224
|
const subscribe = opts.subscribe?.length ? opts.subscribe : ["general"];
|
|
166
225
|
const allowSubscribe = opts.allowSubscribe?.length ? opts.allowSubscribe : subscribe;
|
|
167
226
|
// Reject channel names the wire layer would rewrite (the pre-created filter rides token() too).
|
|
@@ -173,19 +232,20 @@ export async function provisionAgent(provisioner, auth, identity, opts = {}) {
|
|
|
173
232
|
for (const ch of subscribe)
|
|
174
233
|
if (!channelInAllow(allowSubscribe, ch))
|
|
175
234
|
throw new Error(`provisionAgent: subscribe "${ch}" is not within allowSubscribe [${allowSubscribe.join(", ")}]`);
|
|
176
|
-
await provisioner.provisionDmInbox(
|
|
177
|
-
await provisioner.provisionDlvInbox(
|
|
235
|
+
await provisioner.provisionDmInbox(pr.owner, pr.actor);
|
|
236
|
+
await provisioner.provisionDlvInbox(pr.owner, pr.actor);
|
|
178
237
|
// Record the agent's read ACL in the durable registry (the same act as baking it into the JWT) so the
|
|
179
238
|
// server-side delivery daemon can re-authorize this agent's durable entries + validate its runtime
|
|
180
239
|
// durable-joins — it holds no in-memory ledger. The agent SELF-JOINS its durable boot channels via the
|
|
181
240
|
// daemon at connect (no manager-written boot membership). `durableMembership:false` (a live-only
|
|
182
241
|
// launcher, e.g. direct `cotal spawn` with no daemon) opts out of the ACL row → the daemon never
|
|
183
242
|
// authorizes a durable backstop for it, so it stays live-only.
|
|
243
|
+
// ACL is keyed by the agent's owner+actor principal dot-form (per-agent read authority).
|
|
184
244
|
if (opts.durableMembership !== false)
|
|
185
|
-
await provisioner.commitAcl(
|
|
245
|
+
await provisioner.commitAcl(principalKey(pr.owner, pr.actor).key, allowSubscribe);
|
|
186
246
|
if (opts.role)
|
|
187
247
|
await provisioner.provisionTaskQueue(opts.role);
|
|
188
|
-
return
|
|
248
|
+
return allowSubscribe;
|
|
189
249
|
}
|
|
190
250
|
/** Mint a user creds file for an agent {@link Identity} (its stable id+seed from
|
|
191
251
|
* {@link newIdentity}). The account signing key signs over ONLY the public key
|
|
@@ -196,9 +256,14 @@ export async function provisionAgent(provisioner, auth, identity, opts = {}) {
|
|
|
196
256
|
* enumerated least-privilege — there is no allow-all cred (the former `manager` is deleted). */
|
|
197
257
|
export async function mintCreds(auth, identity, profile, opts = {}) {
|
|
198
258
|
const signer = fromSeed(new TextEncoder().encode(auth.account.signingSeed));
|
|
199
|
-
const
|
|
259
|
+
const pr = principalOf(identity, opts.principal);
|
|
260
|
+
const perms = permissionsFor(profile, auth.space, pr, opts);
|
|
200
261
|
const validDates = userValidDates(profile, opts);
|
|
201
|
-
const userJwt = await encodeUser(profile, fromPublic(identity.id), fromPublic(auth.account.pub),
|
|
262
|
+
const userJwt = await encodeUser(profile, fromPublic(identity.id), fromPublic(auth.account.pub),
|
|
263
|
+
// Stamp the principal `tags` so this connection's identity is CONNZ-recoverable by the membership
|
|
264
|
+
// feed — the SAME tags the auth callout stamps (user mode), via core's single-source builder. The
|
|
265
|
+
// JWT `name` stays the profile label (a debug breadcrumb; not a surfaced/queryable CONNZ field).
|
|
266
|
+
{ ...perms, tags: principalTags(pr.owner, pr.actor) }, { signer, ...validDates });
|
|
202
267
|
const creds = fmtCreds(userJwt, fromSeed(new TextEncoder().encode(identity.seed)));
|
|
203
268
|
return new TextDecoder().decode(creds);
|
|
204
269
|
}
|
|
@@ -206,47 +271,63 @@ export async function mintCreds(auth, identity, profile, opts = {}) {
|
|
|
206
271
|
* exactly what each profile does. Every profile is now enumerated least-privilege — the former
|
|
207
272
|
* allow-all `manager` is gone (its roles split across supervisor/provisioner/operator/purger and the
|
|
208
273
|
* PR 1.5 CLI-surface profiles). Subject/stream/durable names come from the shared builders so the ACLs
|
|
209
|
-
* can't drift from the wire layout.
|
|
210
|
-
|
|
274
|
+
* can't drift from the wire layout.
|
|
275
|
+
*
|
|
276
|
+
* PRINCIPAL-PARAMETERIZED + MODE-AGNOSTIC (owner+actor grammar): `pr` carries the owner+actor wire
|
|
277
|
+
* principal (chat/inst/svc/ctl subjects, per-agent durables, the presence key all scope to it) PLUS the
|
|
278
|
+
* connection nkey (`pr.connId`, which scopes the private reply inbox `_INBOX_<connId>`). Core does NOT
|
|
279
|
+
* fork on dev-vs-user — the composition root supplies the principal: the auth callout passes the derived
|
|
280
|
+
* owner + ledger actor + the per-connection ephemeral nkey; the static/dev mint passes
|
|
281
|
+
* `{owner:"local", actor:<id>, connId:<id>}` via {@link principalOf}. EXPORTED so the callout's injected
|
|
282
|
+
* `permissionsFor` hook can feed a validated principal straight into the same builder. */
|
|
283
|
+
export function permissionsFor(profile, space, pr, opts) {
|
|
284
|
+
// Guard the connId BEFORE any profile builds `_INBOX_<connId>.>`: in user mode connId is a client-
|
|
285
|
+
// chosen nonce (untrusted), so a metacharacter here would escalate the inbox grant to every inbox.
|
|
286
|
+
// Assert once, for all profiles (each early-returning profile builds its own inbox from pr.connId).
|
|
287
|
+
assertInboxConnId(pr.connId);
|
|
211
288
|
if (profile === "delivery")
|
|
212
|
-
return deliveryPermissions(space,
|
|
289
|
+
return deliveryPermissions(space, pr); // scoped server-side Plane-3 infra
|
|
213
290
|
if (profile === "membership-rw")
|
|
214
|
-
return membershipRwPermissions(space,
|
|
291
|
+
return membershipRwPermissions(space, pr); // scoped graph-feed reader/writer
|
|
215
292
|
if (profile === "supervisor")
|
|
216
|
-
return supervisorPermissions(space,
|
|
293
|
+
return supervisorPermissions(space, pr); // always-on daemon (closure (ii) gate)
|
|
217
294
|
if (profile === "provisioner")
|
|
218
|
-
return provisionerPermissions(space,
|
|
295
|
+
return provisionerPermissions(space, pr); // ephemeral onboarding authority (closure (ii))
|
|
219
296
|
if (profile === "deprovisioner") {
|
|
220
|
-
// Ephemeral, TARGET-PINNED teardown (#159 B) — the counterpart to `provisioner`.
|
|
221
|
-
//
|
|
297
|
+
// Ephemeral, TARGET-PINNED teardown (#159 B) — the counterpart to `provisioner`. The target is a
|
|
298
|
+
// full principal dot-form for user-mode agents, or a bare static/dev actor id (keyed under
|
|
299
|
+
// DEV_OWNER) — see {@link deprovisionTargetPrincipal}.
|
|
222
300
|
if (!opts.deprovisionTarget)
|
|
223
|
-
throw new Error("permissionsFor: deprovisioner requires opts.deprovisionTarget (the departed agent's id)");
|
|
224
|
-
return deprovisionerPermissions(space,
|
|
301
|
+
throw new Error("permissionsFor: deprovisioner requires opts.deprovisionTarget (the departed agent's actor id)");
|
|
302
|
+
return deprovisionerPermissions(space, pr, opts.deprovisionTarget);
|
|
225
303
|
}
|
|
226
304
|
if (profile === "purger")
|
|
227
|
-
return purgerPermissions(space,
|
|
305
|
+
return purgerPermissions(space, pr); // ephemeral history-purge (closure (ii))
|
|
228
306
|
if (profile === "operator")
|
|
229
|
-
return operatorPermissions(space,
|
|
307
|
+
return operatorPermissions(space, pr); // human-CLI client (send/dm/ask) (closure (ii))
|
|
230
308
|
if (profile === "probe")
|
|
231
|
-
return probePermissions(
|
|
309
|
+
return probePermissions(pr); // connect-only liveness/auth preflight (PR 1.5)
|
|
232
310
|
if (profile === "channel-writer")
|
|
233
|
-
return channelWriterPermissions(space,
|
|
311
|
+
return channelWriterPermissions(space, pr); // channel-registry writes (PR 1.5)
|
|
234
312
|
if (profile === "channel-purger")
|
|
235
|
-
return channelPurgerPermissions(space,
|
|
313
|
+
return channelPurgerPermissions(space, pr); // channel-writer + CHAT purge (PR 1.5)
|
|
236
314
|
if (profile === "teardown")
|
|
237
|
-
return teardownPermissions(space,
|
|
315
|
+
return teardownPermissions(space, pr); // sole STREAM.DELETE holder (PR 1.5)
|
|
238
316
|
if (profile === "control-caller-privileged")
|
|
239
|
-
return controlCallerPermissions(space,
|
|
317
|
+
return controlCallerPermissions(space, pr, CONTROL_PRIVILEGED); // ps/start (PR 1.5)
|
|
240
318
|
if (profile === "control-caller-admin")
|
|
241
|
-
return controlCallerPermissions(space,
|
|
319
|
+
return controlCallerPermissions(space, pr, CONTROL_ADMIN); // stop/attach (PR 1.5)
|
|
242
320
|
if (profile === "deployer")
|
|
243
|
-
return deployerPermissions(space,
|
|
321
|
+
return deployerPermissions(space, pr, opts.controlTier ?? CONTROL_ADMIN); // spawn -f deploy authority (PR 1.5; user-mode view rides privileged)
|
|
244
322
|
const CHAT = chatStream(space), DM = dmStream(space), TASK = taskStream(space);
|
|
245
323
|
const KV = `KV_${presenceBucket(space)}`;
|
|
246
324
|
const CHKV = `KV_${channelBucket(space)}`; // channel registry (read-only for everyone)
|
|
247
325
|
const MEMKV = `KV_${membershipBucket(space)}`; // derived graph membership feed (read-only — dashboard)
|
|
248
326
|
const DLVKV = `KV_${deliveryBucket(space)}`; // delivery lease/readiness (read-only — Component 6 health)
|
|
249
|
-
|
|
327
|
+
// Wire identity: owner+actor for subjects/durables/presence (dot-form `pk.key`, name-form `pk.name`);
|
|
328
|
+
// the reply inbox keys on the CONNECTION nkey, not the principal (see MintPrincipal).
|
|
329
|
+
const pk = principalKey(pr.owner, pr.actor);
|
|
330
|
+
const inbox = `_INBOX_${pr.connId}.>`;
|
|
250
331
|
if (profile === "observer" || profile === "admin") {
|
|
251
332
|
// Read-only: live feed via tap, history + presence via ephemeral/ordered consumers it
|
|
252
333
|
// creates on CHAT + the presence KV. No chat/inst/svc/ctl publish → can't post.
|
|
@@ -303,7 +384,7 @@ function permissionsFor(profile, space, id, opts) {
|
|
|
303
384
|
// stale/unwired profile string (e.g. a JS caller bypassing the closed `Profile` union). Fail loud rather
|
|
304
385
|
// than mint it agent perms by accident (the no-fallbacks rule; matches the deleted `manager`'s intent).
|
|
305
386
|
if (profile !== "agent")
|
|
306
|
-
throw new Error(`permissionsFor: unhandled profile "${profile}"
|
|
387
|
+
throw new Error(`permissionsFor: unhandled profile "${profile}" - add an explicit arm, do not fall through to agent`);
|
|
307
388
|
const allowPublish = opts.allowPublish ?? []; // post ACL — DEFAULT-DENY (publish must be declared)
|
|
308
389
|
const allowSubscribe = opts.allowSubscribe?.length ? opts.allowSubscribe : ["general"]; // read ACL
|
|
309
390
|
// Re-assert at the mint chokepoint (covers mint/spawn paths that bypass the file loader): a policy
|
|
@@ -311,20 +392,20 @@ function permissionsFor(profile, space, id, opts) {
|
|
|
311
392
|
for (const ch of [...allowSubscribe, ...allowPublish])
|
|
312
393
|
assertValidChannel(ch);
|
|
313
394
|
const manager = opts.manager ?? CONTROL_PRIVILEGED;
|
|
314
|
-
const chatHistD = chatHistDurable(
|
|
315
|
-
const DLV = dlvStream(space), dlvD = dlvDurable(
|
|
395
|
+
const chatHistD = chatHistDurable(pr.owner, pr.actor), dmD = dmDurable(pr.owner, pr.actor);
|
|
396
|
+
const DLV = dlvStream(space), dlvD = dlvDurable(pr.owner, pr.actor); // Plane-3 per-member delivery (bind-only)
|
|
316
397
|
const svcD = opts.role ? taskDurable(opts.role) : undefined;
|
|
317
398
|
const pubAllow = [
|
|
318
|
-
// peer publish — identity + channel scope, built from the real builders. Default-deny:
|
|
319
|
-
// declared allowPublish channels (none by default) get a chat-publish grant.
|
|
320
|
-
...allowPublish.map((ch) => chatSubject(space,
|
|
321
|
-
unicastSubject(space, "*",
|
|
322
|
-
anycastSubject(space, "*",
|
|
323
|
-
controlServiceSubject(space, CONTROL_SELF_SERVICE,
|
|
324
|
-
// ctl.delivery.<
|
|
325
|
-
// daemon (NOT the manager). The reply rides this same subtree (`ctl.delivery.<
|
|
399
|
+
// peer publish — owner+actor identity + channel scope, built from the real builders. Default-deny:
|
|
400
|
+
// ONLY the declared allowPublish channels (none by default) get a chat-publish grant.
|
|
401
|
+
...allowPublish.map((ch) => chatSubject(space, pr.owner, pr.actor, ch)),
|
|
402
|
+
unicastSubject(space, "*", "*", pr.owner, pr.actor), // inst.*.*.<o>.<a> — DM any instance, as me
|
|
403
|
+
anycastSubject(space, "*", pr.owner, pr.actor), // svc.*.<o>.<a> — anycast any role, as me
|
|
404
|
+
controlServiceSubject(space, CONTROL_SELF_SERVICE, pr.owner, pr.actor), // ctl.self.<o>.<a> — self stop/despawn
|
|
405
|
+
// ctl.delivery.<o>.<a> — request a durable backstop join/leave/list from the SERVER-SIDE delivery
|
|
406
|
+
// daemon (NOT the manager). The reply rides this same subtree (`ctl.delivery.<o>.<a>.reply.<n>`, in
|
|
326
407
|
// sub.allow below) so the daemon can answer without broad inbox-publish — see CONTROL_DELIVERY.
|
|
327
|
-
controlServiceSubject(space, CONTROL_DELIVERY,
|
|
408
|
+
controlServiceSubject(space, CONTROL_DELIVERY, pr.owner, pr.actor),
|
|
328
409
|
// JetStream control plane — scoped to this agent's own streams/durables.
|
|
329
410
|
"$JS.API.INFO",
|
|
330
411
|
// STREAM.INFO: CHAT (join watermark, recall drop-marker, channel-list counts — a documented
|
|
@@ -343,7 +424,7 @@ function permissionsFor(profile, space, id, opts) {
|
|
|
343
424
|
// old unfiltered DIRECT.GET.<CHAT> (which could fetch ANY message regardless of channel). The
|
|
344
425
|
// name is the agent's own, so info/fetch/delete can't reach a peer's consumer. NO broad
|
|
345
426
|
// CONSUMER.CREATE.<CHAT> / .> deny here: NATS deny beats allow, which would also kill these.
|
|
346
|
-
...allowSubscribe.map((ch) => `$JS.API.CONSUMER.CREATE.${CHAT}.${chatHistD}.${chatSubject(space, "*", ch)}`),
|
|
427
|
+
...allowSubscribe.map((ch) => `$JS.API.CONSUMER.CREATE.${CHAT}.${chatHistD}.${chatSubject(space, "*", "*", ch)}`),
|
|
347
428
|
`$JS.API.CONSUMER.INFO.${CHAT}.${chatHistD}`,
|
|
348
429
|
`$JS.API.CONSUMER.MSG.NEXT.${CHAT}.${chatHistD}`,
|
|
349
430
|
`$JS.API.CONSUMER.DELETE.${CHAT}.${chatHistD}`,
|
|
@@ -362,7 +443,7 @@ function permissionsFor(profile, space, id, opts) {
|
|
|
362
443
|
`$JS.API.CONSUMER.CREATE.${KV}.>`,
|
|
363
444
|
`$JS.API.CONSUMER.INFO.${KV}.>`,
|
|
364
445
|
"$JS.FC.>",
|
|
365
|
-
`$KV.${presenceBucket(space)}.${
|
|
446
|
+
`$KV.${presenceBucket(space)}.${pk.key}`, // own presence key (owner+actor) only — can't spoof peers
|
|
366
447
|
// Channel registry: read-only (watch + direct kv.get for the join-time replay decision).
|
|
367
448
|
// No `$KV.${channelBucket(space)}.*` publish — privileged-write, default-deny gives that free.
|
|
368
449
|
`$JS.API.STREAM.MSG.GET.${CHKV}`,
|
|
@@ -391,7 +472,13 @@ function permissionsFor(profile, space, id, opts) {
|
|
|
391
472
|
// allow-list, so nats-server rejects the publish — no handler check, no deny-entry (a
|
|
392
473
|
// blanket `ctl.<mgr>.>` deny would override this grant too, since NATS deny beats allow).
|
|
393
474
|
// The self-service subject above is granted to all regardless of capability.
|
|
394
|
-
pubAllow.push(controlServiceSubject(space, manager,
|
|
475
|
+
pubAllow.push(controlServiceSubject(space, manager, pr.owner, pr.actor));
|
|
476
|
+
}
|
|
477
|
+
if (opts.capabilities?.includes("admin")) {
|
|
478
|
+
// Admin capability → the ADMIN control tier (cross-agent stop/attach, manifest launch). In user
|
|
479
|
+
// mode this arrives via the ledger row's scope (`cotal actor grant … --scope admin`) — the
|
|
480
|
+
// broker-enforced half of the tier split; the manager's per-op checks stay on top of it.
|
|
481
|
+
pubAllow.push(controlServiceSubject(space, CONTROL_ADMIN, pr.owner, pr.actor));
|
|
395
482
|
}
|
|
396
483
|
// Explicit create-deny (defense-in-depth over default-deny) on the two streams whose
|
|
397
484
|
// create-time filter_subject is the attack surface — DM (private content) and TASK
|
|
@@ -416,18 +503,20 @@ function permissionsFor(profile, space, id, opts) {
|
|
|
416
503
|
// with NO manager: join = nc.subscribe, broker-enforced per-subscribe, no consumer name to confine,
|
|
417
504
|
// so an open ACL needs no enumeration. This sub.allow grant IS the live read path — there is no
|
|
418
505
|
// per-instance chat durable; the durable backstop is Plane-3 (delivery-daemon fan-out → per-member DELIVER).
|
|
419
|
-
const subChat = allowSubscribe.map((ch) => chatSubject(space, "*", ch));
|
|
420
|
-
// Replies to this agent's durable join/leave/list requests ride `ctl.delivery.<
|
|
506
|
+
const subChat = allowSubscribe.map((ch) => chatSubject(space, "*", "*", ch));
|
|
507
|
+
// Replies to this agent's durable join/leave/list requests ride `ctl.delivery.<o>.<a>.>` (NOT the
|
|
421
508
|
// per-id _INBOX), so the scoped delivery daemon can answer without broad inbox-publish.
|
|
422
|
-
const deliveryReplies = `${controlServiceSubject(space, CONTROL_DELIVERY,
|
|
509
|
+
const deliveryReplies = `${controlServiceSubject(space, CONTROL_DELIVERY, pr.owner, pr.actor)}.>`;
|
|
423
510
|
// Bounded control replies (closure (i)): the manager's lifecycle tiers now reply on
|
|
424
511
|
// `ctl.<tier>.<id>.reply.>` (not the per-id `_INBOX`), so each agent must subscribe the reply subtree
|
|
425
|
-
// for the tiers it may call. Every agent can self-stop ⇒ always grant the self tier; the privileged
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
const controlReplies = [`${controlServiceSubject(space, CONTROL_SELF_SERVICE,
|
|
512
|
+
// for the tiers it may call. Every agent can self-stop ⇒ always grant the self tier; the privileged /
|
|
513
|
+
// admin tiers' replies are granted only with the matching capability (which also grants the request
|
|
514
|
+
// publish above).
|
|
515
|
+
const controlReplies = [`${controlServiceSubject(space, CONTROL_SELF_SERVICE, pr.owner, pr.actor)}.reply.>`];
|
|
429
516
|
if (opts.capabilities?.includes("spawn"))
|
|
430
|
-
controlReplies.push(`${controlServiceSubject(space, CONTROL_PRIVILEGED,
|
|
517
|
+
controlReplies.push(`${controlServiceSubject(space, CONTROL_PRIVILEGED, pr.owner, pr.actor)}.reply.>`);
|
|
518
|
+
if (opts.capabilities?.includes("admin"))
|
|
519
|
+
controlReplies.push(`${controlServiceSubject(space, CONTROL_ADMIN, pr.owner, pr.actor)}.reply.>`);
|
|
431
520
|
return { pub: { allow: pubAllow, deny: pubDeny }, sub: { allow: [inbox, deliveryReplies, ...controlReplies, ...subChat] } };
|
|
432
521
|
}
|
|
433
522
|
/** The long-lived SUPERVISOR permission set (closure (ii), residual 2) — the always-on manager daemon
|
|
@@ -443,14 +532,14 @@ function permissionsFor(profile, space, id, opts) {
|
|
|
443
532
|
* `$JS` is an ENUMERATED allow-list — exactly the presence-watch + lease-KV verbs — never `$JS.>`. A
|
|
444
533
|
* leaked supervisor cred can hold/serve control and read the public roster; it cannot read a DM, forge an
|
|
445
534
|
* actor, provision, purge, or tamper with a stream. */
|
|
446
|
-
function supervisorPermissions(space,
|
|
535
|
+
function supervisorPermissions(space, pr) {
|
|
447
536
|
const PKV = `KV_${presenceBucket(space)}`, MKV = `KV_${managerBucket(space)}`;
|
|
448
|
-
// The three SERVED lifecycle tiers (manager.ts serveControl): subscribe `ctl.<tier
|
|
449
|
-
//
|
|
450
|
-
//
|
|
537
|
+
// The three SERVED lifecycle tiers (manager.ts serveControl): subscribe `ctl.<tier>.*.*` (queue-grouped,
|
|
538
|
+
// the owner+actor caller slots widened from one token to two) and reply on the bounded
|
|
539
|
+
// `ctl.<tier>.<owner>.<actor>.reply.<uuid>` subtree. Plain NATS request/reply — no `$JS.ACK`.
|
|
451
540
|
const tiers = [CONTROL_PRIVILEGED, CONTROL_SELF_SERVICE, CONTROL_ADMIN];
|
|
452
|
-
const ctlServe = tiers.map((t) => controlServiceSubject(space, t, "*")); // ctl.<tier
|
|
453
|
-
const ctlReplies = tiers.map((t) => `${controlServiceSubject(space, t, "*")}.reply.>`);
|
|
541
|
+
const ctlServe = tiers.map((t) => controlServiceSubject(space, t, "*", "*")); // ctl.<tier>.*.*
|
|
542
|
+
const ctlReplies = tiers.map((t) => `${controlServiceSubject(space, t, "*", "*")}.reply.>`);
|
|
454
543
|
return {
|
|
455
544
|
pub: {
|
|
456
545
|
allow: [
|
|
@@ -463,20 +552,26 @@ function supervisorPermissions(space, id) {
|
|
|
463
552
|
// Presence: publish OWN key + watch the roster. Own key only (no peer-key forge — residual 3); no
|
|
464
553
|
// presence-stream purge/delete (no force-offline tamper). No presence kv.get (roster is the in-memory
|
|
465
554
|
// watch cache + sweep), so no STREAM.MSG.GET on presence.
|
|
466
|
-
`$KV.${presenceBucket(space)}.${
|
|
555
|
+
`$KV.${presenceBucket(space)}.${principalKey(pr.owner, pr.actor).key}`,
|
|
467
556
|
`$JS.API.STREAM.INFO.${PKV}`,
|
|
468
557
|
`$JS.API.CONSUMER.CREATE.${PKV}.>`, // kv.watch ordered consumer (roster)
|
|
469
558
|
`$JS.API.CONSUMER.INFO.${PKV}.>`,
|
|
470
559
|
"$JS.FC.>", // ordered-consumer flow control
|
|
471
560
|
// Control: reply to any caller on each SERVED tier (bounded). It SERVES (does not call), so no
|
|
472
|
-
// request-publish grant and no position-1 wildcard.
|
|
561
|
+
// request-publish grant and no position-1 wildcard — EXCEPT the delivery-admin rail below.
|
|
473
562
|
...ctlReplies,
|
|
563
|
+
// The ONE control service the supervisor CALLS (D5 slice 5): the delivery daemon's privileged
|
|
564
|
+
// admin rail — the manager is the class-2 renewal owner, and after re-signing the daemon creds
|
|
565
|
+
// files it requests `reloadCreds` here so adoption is an explicit, auditable event. Self-scoped
|
|
566
|
+
// request subject (its own owner+actor slots), bounded reply subtree in sub.allow below.
|
|
567
|
+
controlServiceSubject(space, CONTROL_DELIVERY_ADMIN, pr.owner, pr.actor),
|
|
474
568
|
],
|
|
475
569
|
},
|
|
476
570
|
sub: {
|
|
477
|
-
// Own reply inbox + the three served control tiers (queue-grouped)
|
|
478
|
-
//
|
|
479
|
-
|
|
571
|
+
// Own reply inbox + the three served control tiers (queue-grouped) + the delivery-admin reply
|
|
572
|
+
// subtree for its OWN requests. NO chat/inst/dlv native sub (the supervisor reads no feed), NO
|
|
573
|
+
// broad `$JS.>`/`$KV.>` (the residual-2 read/admin path is gone).
|
|
574
|
+
allow: [`_INBOX_${pr.connId}.>`, ...ctlServe, `${controlServiceSubject(space, CONTROL_DELIVERY_ADMIN, pr.owner, pr.actor)}.>`],
|
|
480
575
|
},
|
|
481
576
|
};
|
|
482
577
|
}
|
|
@@ -489,17 +584,17 @@ function supervisorPermissions(space, id) {
|
|
|
489
584
|
* provisioning. A leaked operator cred can post as itself and read the roster — the same surface as the
|
|
490
585
|
* human who ran the command. (The interactive `cotal join` console — chat read + own-DM receive — is a
|
|
491
586
|
* separate, fuller surface, deferred: it needs the unprovisioned-console DM self-create fixed first.) */
|
|
492
|
-
function operatorPermissions(space,
|
|
587
|
+
function operatorPermissions(space, pr) {
|
|
493
588
|
const PKV = `KV_${presenceBucket(space)}`, CHKV = `KV_${channelBucket(space)}`;
|
|
494
589
|
return {
|
|
495
590
|
pub: {
|
|
496
591
|
allow: [
|
|
497
|
-
// Post AS itself only — self-scoped, so a leaked operator cred can never forge a
|
|
498
|
-
// attributable to another
|
|
499
|
-
chatSubject(space,
|
|
500
|
-
unicastSubject(space, "*",
|
|
501
|
-
anycastSubject(space, "*",
|
|
502
|
-
`$KV.${presenceBucket(space)}.${
|
|
592
|
+
// Post AS itself only — self-scoped (owner+actor), so a leaked operator cred can never forge a
|
|
593
|
+
// message attributable to another principal.
|
|
594
|
+
chatSubject(space, pr.owner, pr.actor, ">"), // chat.<o>.<a>.> — multicast any channel as me
|
|
595
|
+
unicastSubject(space, "*", "*", pr.owner, pr.actor), // inst.*.*.<o>.<a> — DM any peer as me
|
|
596
|
+
anycastSubject(space, "*", pr.owner, pr.actor), // svc.*.<o>.<a> — anycast any role as me
|
|
597
|
+
`$KV.${presenceBucket(space)}.${principalKey(pr.owner, pr.actor).key}`, // own presence key only
|
|
503
598
|
"$JS.API.INFO",
|
|
504
599
|
// Presence watch (name→id resolution + the live roster) — read-only ordered consumer. No
|
|
505
600
|
// STREAM.MSG.GET (the roster is the in-memory watch cache).
|
|
@@ -520,7 +615,7 @@ function operatorPermissions(space, id) {
|
|
|
520
615
|
},
|
|
521
616
|
// Own reply inbox only (presence/channel watch ordered-consumer delivery + any request replies land
|
|
522
617
|
// here). NO chat/inst/dlv native sub — the operator posts and reads the roster, it receives no feed.
|
|
523
|
-
sub: { allow: [`_INBOX_${
|
|
618
|
+
sub: { allow: [`_INBOX_${pr.connId}.>`] },
|
|
524
619
|
};
|
|
525
620
|
}
|
|
526
621
|
/** Connect-only PROBE (PR 1.5) — the liveness/auth preflight (`preflight.ts preflightTarget`, minted on
|
|
@@ -528,14 +623,14 @@ function operatorPermissions(space, id) {
|
|
|
528
623
|
* and the creds are accepted, then closes it — it performs NO pub/sub. So the tightest possible grant:
|
|
529
624
|
* deny ALL publish, subscribe only to the own reply inbox. A leaked probe cred can open a socket and do
|
|
530
625
|
* nothing else. (Was the broad `manager` cred — minted on nearly every command, the worst over-grant.) */
|
|
531
|
-
function probePermissions(
|
|
532
|
-
return { pub: { deny: [">"] }, sub: { allow: [`_INBOX_${
|
|
626
|
+
function probePermissions(pr) {
|
|
627
|
+
return { pub: { deny: [">"] }, sub: { allow: [`_INBOX_${pr.connId}.>`] } };
|
|
533
628
|
}
|
|
534
629
|
/** CHANNEL-WRITER (PR 1.5) — edits the channel registry ONLY: `cotal channels set/default` and the
|
|
535
630
|
* `spawn -f` new-channel seed (`seedChannelRegistry`). It VALUE-writes `$KV.<channelBucket>` (a channel's
|
|
536
631
|
* config key) and read-before-writes it. NO stream data, NO other bucket, NO chat/DM — a leaked
|
|
537
632
|
* channel-writer can only rewrite channel config, never post, read a body, or tear a stream down. */
|
|
538
|
-
function channelWriterPermissions(space,
|
|
633
|
+
function channelWriterPermissions(space, pr) {
|
|
539
634
|
const CHKV = `KV_${channelBucket(space)}`;
|
|
540
635
|
return {
|
|
541
636
|
pub: {
|
|
@@ -549,14 +644,14 @@ function channelWriterPermissions(space, id) {
|
|
|
549
644
|
`$JS.API.DIRECT.GET.${CHKV}.>`,
|
|
550
645
|
],
|
|
551
646
|
},
|
|
552
|
-
sub: { allow: [`_INBOX_${
|
|
647
|
+
sub: { allow: [`_INBOX_${pr.connId}.>`] },
|
|
553
648
|
};
|
|
554
649
|
}
|
|
555
650
|
/** CHANNEL-PURGER (PR 1.5) — the `cotal web` dashboard's ONLY write path: delete a channel
|
|
556
651
|
* (`clearChannel` = filtered `STREAM.PURGE.CHAT` to drop the channel's messages + a `$KV.<channelBucket>`
|
|
557
652
|
* key delete). Pre-minted once by `web` so the account signing seed falls out of scope; the dashboard's
|
|
558
653
|
* READ side runs on the separate read-only `admin` cred. = channel-writer + the scoped CHAT purge. */
|
|
559
|
-
function channelPurgerPermissions(space,
|
|
654
|
+
function channelPurgerPermissions(space, pr) {
|
|
560
655
|
const CHKV = `KV_${channelBucket(space)}`;
|
|
561
656
|
// `clearChannel` only kvm.OPENs the (already-created) bucket, key-deletes, and purges — it never
|
|
562
657
|
// kvm.creates, so — unlike channel-writer's set/default back-compat path — this cred gets NO
|
|
@@ -572,7 +667,7 @@ function channelPurgerPermissions(space, id) {
|
|
|
572
667
|
`$JS.API.STREAM.PURGE.${chatStream(space)}`, // drop the channel's chat messages
|
|
573
668
|
],
|
|
574
669
|
},
|
|
575
|
-
sub: { allow: [`_INBOX_${
|
|
670
|
+
sub: { allow: [`_INBOX_${pr.connId}.>`] },
|
|
576
671
|
};
|
|
577
672
|
}
|
|
578
673
|
/** TEARDOWN (PR 1.5) — `cotal down -f` space teardown. The SOLE cred that keeps `STREAM.DELETE` (the
|
|
@@ -583,7 +678,7 @@ function channelPurgerPermissions(space, id) {
|
|
|
583
678
|
* DM/DLV body, posts chat, or forges. Isolated here so no standing operator/provisioner/supervisor cred
|
|
584
679
|
* can delete a stream; a leaked teardown can wipe a space you own + stop its agents (that IS its job),
|
|
585
680
|
* nothing else. Minted ephemerally per teardown from the local trust material (same-checkout `down -f`). */
|
|
586
|
-
function teardownPermissions(space,
|
|
681
|
+
function teardownPermissions(space, pr) {
|
|
587
682
|
const CHAT = chatStream(space);
|
|
588
683
|
const PKV = `KV_${presenceBucket(space)}`, CHKV = `KV_${channelBucket(space)}`;
|
|
589
684
|
// deleteSpace() deletes EVERY stream + KV bucket setup creates (5 streams + 7 buckets); each needs
|
|
@@ -606,7 +701,7 @@ function teardownPermissions(space, id) {
|
|
|
606
701
|
`$JS.API.CONSUMER.INFO.${CHKV}.>`,
|
|
607
702
|
"$JS.FC.>", // ordered-consumer flow control
|
|
608
703
|
// Stop the managed agents via the admin control tier (ps + per-agent stop).
|
|
609
|
-
controlServiceSubject(space, CONTROL_ADMIN,
|
|
704
|
+
controlServiceSubject(space, CONTROL_ADMIN, pr.owner, pr.actor),
|
|
610
705
|
...del,
|
|
611
706
|
// deleteChannels/clearChannel: purge the channel's chat messages + delete its registry key.
|
|
612
707
|
`$JS.API.STREAM.PURGE.${CHAT}`,
|
|
@@ -617,7 +712,7 @@ function teardownPermissions(space, id) {
|
|
|
617
712
|
// subtree: the agent-stop step is `requestControl(CONTROL_ADMIN, ps/stop)`, whose reply rides
|
|
618
713
|
// `ctl.admin.<id>.reply.<uuid>` (NOT `_INBOX`) — without this grant those calls hang and the agents are
|
|
619
714
|
// never stopped before the streams are deleted.
|
|
620
|
-
sub: { allow: [`_INBOX_${
|
|
715
|
+
sub: { allow: [`_INBOX_${pr.connId}.>`, `${controlServiceSubject(space, CONTROL_ADMIN, pr.owner, pr.actor)}.reply.>`] },
|
|
621
716
|
};
|
|
622
717
|
}
|
|
623
718
|
/** CONTROL-CALLER (PR 1.5) — the operator's lifecycle commands (`cotal ps/start/stop/attach`,
|
|
@@ -634,15 +729,15 @@ function teardownPermissions(space, id) {
|
|
|
634
729
|
* • `control-caller-admin` (stop/attach) gets ONLY `ctl.<admin>.<id>` — it genuinely needs cross-agent
|
|
635
730
|
* reach. Its containment is NOT a manager re-check (there is none): it is the broker gating the admin
|
|
636
731
|
* subject + the cred being ephemeral (mint → one request → disconnect, from the local signing seed). */
|
|
637
|
-
function controlCallerPermissions(space,
|
|
638
|
-
const reqSubject = controlServiceSubject(space, tier,
|
|
732
|
+
function controlCallerPermissions(space, pr, tier) {
|
|
733
|
+
const reqSubject = controlServiceSubject(space, tier, pr.owner, pr.actor);
|
|
639
734
|
return {
|
|
640
735
|
pub: { allow: [reqSubject] }, // exactly ONE tier — ps/start XOR stop/attach
|
|
641
736
|
// Own inbox + the BOUNDED control-reply subtree. `requestControl` issues a `noMux` request whose reply
|
|
642
737
|
// rides `ctl.<tier>.<id>.reply.<uuid>` (UNDER its own request subject, NOT `_INBOX`), so it must be able
|
|
643
738
|
// to subscribe that subtree — without this grant the reply sub is broker-denied and every control call
|
|
644
739
|
// hangs to timeout (endpoint.ts:803-806 predicts exactly this).
|
|
645
|
-
sub: { allow: [`_INBOX_${
|
|
740
|
+
sub: { allow: [`_INBOX_${pr.connId}.>`, `${reqSubject}.reply.>`] },
|
|
646
741
|
};
|
|
647
742
|
}
|
|
648
743
|
/** DEPLOYER (PR 1.5) — the `cotal spawn -f` manifest-deploy authority. `spawn -f` drives ONE
|
|
@@ -663,7 +758,7 @@ function controlCallerPermissions(space, id, tier) {
|
|
|
663
758
|
* boundary. Containment is therefore the LIFETIME, not a manager re-check: minted from LOCAL same-checkout
|
|
664
759
|
* auth for one `spawn -f`, memory-only, dropped after deploy. If it is ever persisted, handed to
|
|
665
760
|
* user-supplied `--creds`, or reused as a general "read + admin" cred, revisit. */
|
|
666
|
-
function deployerPermissions(space,
|
|
761
|
+
function deployerPermissions(space, pr, tier = CONTROL_ADMIN) {
|
|
667
762
|
const PKV = `KV_${presenceBucket(space)}`, CHKV = `KV_${channelBucket(space)}`;
|
|
668
763
|
const MSHIP = `KV_${membershipBucket(space)}`, MGRKV = `KV_${managerBucket(space)}`;
|
|
669
764
|
// Read verbs for a KV bucket SCANNED/WATCHED via an ordered consumer (presence, channel registry, and
|
|
@@ -692,14 +787,16 @@ function deployerPermissions(space, id) {
|
|
|
692
787
|
...kvScan(MSHIP), // membership FEED read (readMembership → detectUnmanagedActors) — the membership_ bucket
|
|
693
788
|
...kvPointRead(MGRKV), // manager-singleton lease keyed read (waitManagerReady) — point-get, NO write, NO watch
|
|
694
789
|
"$JS.FC.>", // ordered-consumer flow control
|
|
695
|
-
//
|
|
696
|
-
|
|
790
|
+
// ONE control tier — launch + ps readiness. Static operator deploy creds ride CONTROL_ADMIN
|
|
791
|
+
// (the historical shape); the user-mode `deployer` VIEW rides CONTROL_PRIVILEGED so the
|
|
792
|
+
// manager's owner-equality launch authorization governs (never the admin-tier bypass).
|
|
793
|
+
controlServiceSubject(space, tier, pr.owner, pr.actor),
|
|
697
794
|
],
|
|
698
795
|
},
|
|
699
|
-
// Own inbox (presence/registry watch delivery + JS API responses) + the BOUNDED
|
|
700
|
-
// subtree: `requestControl(
|
|
701
|
-
// without this grant the launch + ps-readiness calls hang to timeout.
|
|
702
|
-
sub: { allow: [`_INBOX_${
|
|
796
|
+
// Own inbox (presence/registry watch delivery + JS API responses) + the BOUNDED control-reply
|
|
797
|
+
// subtree for the same tier: `requestControl(tier, launch/ps)` subscribes `ctl.<tier>.<id>.reply.<uuid>`,
|
|
798
|
+
// so without this grant the launch + ps-readiness calls hang to timeout.
|
|
799
|
+
sub: { allow: [`_INBOX_${pr.connId}.>`, `${controlServiceSubject(space, tier, pr.owner, pr.actor)}.reply.>`] },
|
|
703
800
|
};
|
|
704
801
|
}
|
|
705
802
|
/** The ephemeral PURGER permission set (closure (ii), residual 2) — minted per-purge inside the daemon's
|
|
@@ -707,7 +804,7 @@ function deployerPermissions(space, id) {
|
|
|
707
804
|
* (`STREAM.PURGE.CHAT` + `STREAM.PURGE.DM`) off the always-on supervisor: `--dms` purges the DM stream,
|
|
708
805
|
* exactly the grant the supervisor must not hold. It PURGES but never READS — no DM/chat consumer, no
|
|
709
806
|
* `MSG.GET` — so a leaked purger can drop history but cannot read a body. Short-lived (one purge call). */
|
|
710
|
-
function purgerPermissions(space,
|
|
807
|
+
function purgerPermissions(space, pr) {
|
|
711
808
|
const CHAT = chatStream(space), DM = dmStream(space);
|
|
712
809
|
return {
|
|
713
810
|
pub: {
|
|
@@ -719,7 +816,7 @@ function purgerPermissions(space, id) {
|
|
|
719
816
|
// NOTE: this profile does NOT cover `clearChannel` (web/`down -f` channel-delete) — that also does a
|
|
720
817
|
// `$KV.<channelBucket>.<ch>` registry delete this cred lacks; it stays on the broad operator/CLI cred.
|
|
721
818
|
},
|
|
722
|
-
sub: { allow: [`_INBOX_${
|
|
819
|
+
sub: { allow: [`_INBOX_${pr.connId}.>`] },
|
|
723
820
|
};
|
|
724
821
|
}
|
|
725
822
|
/** The ephemeral PROVISIONER permission set (closure (ii), residual 2) — the onboarding authority,
|
|
@@ -744,7 +841,7 @@ function purgerPermissions(space, id) {
|
|
|
744
841
|
* DM/DLV (it creates the bind-only mailbox but never reads it), NO STREAM.DELETE/PURGE/UPDATE/MSG.DELETE
|
|
745
842
|
* (it provisions, it does not tear down or tamper). KV value-writes are scoped to exactly the two
|
|
746
843
|
* registries provisioning touches: the read-ACL bucket (`commitAcl`) and the channel registry (seed). */
|
|
747
|
-
function provisionerPermissions(space,
|
|
844
|
+
function provisionerPermissions(space, pr) {
|
|
748
845
|
const CHAT = chatStream(space), DM = dmStream(space), TASK = taskStream(space);
|
|
749
846
|
const INBOX = inboxStream(space), DLV = dlvStream(space);
|
|
750
847
|
// Every backing stream the provisioner pre-creates — the 5 message streams + the KV buckets (a bucket's
|
|
@@ -791,15 +888,16 @@ function provisionerPermissions(space, id) {
|
|
|
791
888
|
},
|
|
792
889
|
// Replies only: every stream/consumer/KV-create PubAck and JS API response lands on the per-id inbox.
|
|
793
890
|
// NO chat/inst/dlv/ctl subscription — the provisioner never serves control nor reads any feed.
|
|
794
|
-
sub: { allow: [`_INBOX_${
|
|
891
|
+
sub: { allow: [`_INBOX_${pr.connId}.>`] },
|
|
795
892
|
};
|
|
796
893
|
}
|
|
797
894
|
/** The ephemeral, TARGET-PINNED DEPROVISIONER permission set (#159 Part B) — the teardown counterpart
|
|
798
895
|
* to {@link provisionerPermissions}, minted per departed agent inside the manager's `deprovision` tail
|
|
799
|
-
* (`withProvisioner`-style: a fresh scoped cred per teardown is cheap). It deletes exactly the
|
|
800
|
-
* footprint the provisioner created for ONE agent: that agent's two bind-only
|
|
801
|
-
* `
|
|
802
|
-
* can tear down that one
|
|
896
|
+
* (`withProvisioner`-style: a fresh scoped cred per teardown is cheap). It deletes exactly the
|
|
897
|
+
* dev/static principal footprint the provisioner created for ONE agent: that agent's two bind-only
|
|
898
|
+
* durables (`dm_local-<actor>`, `dlv_local-<actor>`) and its read-ACL row — pinned BY NAME to the target
|
|
899
|
+
* actor under {@link DEV_OWNER}, so a leaked deprovisioner cred can tear down that one already-dead
|
|
900
|
+
* agent and NOTHING else.
|
|
803
901
|
*
|
|
804
902
|
* Deliberately NOT granted (least-privilege / correctness): the role-SHARED `svc_<role>` TASK durable
|
|
805
903
|
* (one consumer for ALL agents of a role — deleting it on one agent's exit would break its siblings; it
|
|
@@ -809,29 +907,31 @@ function provisionerPermissions(space, id) {
|
|
|
809
907
|
* need no grant here — they are ephemeral (`mem_storage`, 30s inactive threshold) and agent-deleted
|
|
810
908
|
* after each read, so they self-clean on the agent's disconnect.
|
|
811
909
|
*
|
|
812
|
-
* Blast radius of a leaked cred (minted for target T): it can delete T's `
|
|
813
|
-
* purge T's ACL row — a denial-of-DELIVERY for T (broken DM/DLV bind + the reader DEFERs on
|
|
814
|
-
* ACL) if fired while T is still alive. It CANNOT read T's bodies, impersonate T, reach any peer, or
|
|
910
|
+
* Blast radius of a leaked cred (minted for target T): it can delete T's `dm_local-<T>`/`dlv_local-<T>`
|
|
911
|
+
* durables + purge T's ACL row — a denial-of-DELIVERY for T (broken DM/DLV bind + the reader DEFERs on
|
|
912
|
+
* the absent ACL) if fired while T is still alive. It CANNOT read T's bodies, impersonate T, reach any peer, or
|
|
815
913
|
* delete a stream — and it is ephemeral (one per-exit teardown, minted then dropped). Contained and
|
|
816
914
|
* recoverable (re-provision T). */
|
|
817
|
-
function deprovisionerPermissions(space,
|
|
915
|
+
function deprovisionerPermissions(space, pr, targetActor) {
|
|
818
916
|
const DM = dmStream(space), DLV = dlvStream(space);
|
|
917
|
+
const t = deprovisionTargetPrincipal(targetActor);
|
|
918
|
+
const target = principalKey(t.owner, t.actor);
|
|
819
919
|
return {
|
|
820
920
|
pub: {
|
|
821
921
|
allow: [
|
|
822
922
|
"$JS.API.INFO", // jetstreamManager bootstrap
|
|
823
|
-
// Delete the target's two bind-only durables BY EXACT NAME
|
|
824
|
-
`$JS.API.CONSUMER.DELETE.${DM}.${dmDurable(
|
|
825
|
-
`$JS.API.CONSUMER.DELETE.${DLV}.${dlvDurable(
|
|
923
|
+
// Delete the target's two bind-only durables BY EXACT NAME — no `.>`, no cross-agent reach.
|
|
924
|
+
`$JS.API.CONSUMER.DELETE.${DM}.${dmDurable(t.owner, t.actor)}`,
|
|
925
|
+
`$JS.API.CONSUMER.DELETE.${DLV}.${dlvDurable(t.owner, t.actor)}`,
|
|
826
926
|
// Purge the target's read-ACL row (own-target key only — the reader then treats it as an unknown
|
|
827
927
|
// owner). `kvm.open` binds the pre-created bucket; the purge rides `$KV.<aclBucket>.<key>`.
|
|
828
928
|
`$JS.API.STREAM.INFO.KV_${aclBucket(space)}`,
|
|
829
|
-
`$KV.${aclBucket(space)}.${aclKey(target)}`,
|
|
929
|
+
`$KV.${aclBucket(space)}.${aclKey(target.key)}`,
|
|
830
930
|
],
|
|
831
931
|
},
|
|
832
|
-
// Replies only: the CONSUMER.DELETE PubAcks + KV purge ack land on the per-
|
|
932
|
+
// Replies only: the CONSUMER.DELETE PubAcks + KV purge ack land on the per-connection inbox. NO chat/DM/ctl
|
|
833
933
|
// subscription — the deprovisioner serves nothing and reads no feed.
|
|
834
|
-
sub: { allow: [`_INBOX_${
|
|
934
|
+
sub: { allow: [`_INBOX_${pr.connId}.>`] },
|
|
835
935
|
};
|
|
836
936
|
}
|
|
837
937
|
/** The scoped `delivery` daemon permission set (server-side Plane-3 infra; NEVER allow-all, never
|
|
@@ -843,7 +943,7 @@ function deprovisionerPermissions(space, id, target) {
|
|
|
843
943
|
* subscription — a leaked cred can't natively sniff the mixed pre-auth store. Honest blast radius
|
|
844
944
|
* (delivery-daemon.md): it can write any owner's `dlv` (the post-auth store agents trust); the future
|
|
845
945
|
* fan-out/reader cred split bounds that. */
|
|
846
|
-
function deliveryPermissions(space,
|
|
946
|
+
function deliveryPermissions(space, pr) {
|
|
847
947
|
const p = spacePrefix(space);
|
|
848
948
|
const CHAT = chatStream(space), INBOX = inboxStream(space), DLV = dlvStream(space);
|
|
849
949
|
const PKV = `KV_${presenceBucket(space)}`, CHKV = `KV_${channelBucket(space)}`;
|
|
@@ -884,17 +984,22 @@ function deliveryPermissions(space, id) {
|
|
|
884
984
|
// Delivery lease/readiness KV: read the bucket (renew CAS) + write ONLY lease keys.
|
|
885
985
|
`$JS.API.STREAM.INFO.${DKV}`, `$JS.API.STREAM.MSG.GET.${DKV}`,
|
|
886
986
|
`$KV.${deliveryBucket(space)}.lease.*`,
|
|
887
|
-
// Plane-3 data writes: dinbox (fan-out target) + dlv (post-auth handoff) for ANY
|
|
888
|
-
|
|
987
|
+
// Plane-3 data writes: dinbox (fan-out target) + dlv (post-auth handoff) for ANY principal — the
|
|
988
|
+
// owner+actor slots widen to `.*.*` (dinbox/dlv are per-agent now).
|
|
989
|
+
`${p}.dinbox.*.*`, `${p}.dlv.*.*`,
|
|
889
990
|
// ctl.delivery control REPLIES ONLY (requests arrive on the sub below; the daemon only ever
|
|
890
|
-
// m.respond()s to a requester's reply subject `ctl.delivery.<
|
|
891
|
-
// `.reply.>` leaf so the daemon can't publish to the request subjects themselves — tighter than a
|
|
892
|
-
// blanket `ctl.delivery.>` (fact-check precision, review panel).
|
|
893
|
-
`${p}.ctl.delivery
|
|
991
|
+
// m.respond()s to a requester's reply subject `ctl.delivery.<owner>.<actor>.reply.<n>`). Scoped to
|
|
992
|
+
// the `.reply.>` leaf so the daemon can't publish to the request subjects themselves — tighter than a
|
|
993
|
+
// blanket `ctl.delivery.>` (fact-check precision, review panel). The caller slots widened to `.*.*`.
|
|
994
|
+
`${p}.ctl.delivery.*.*.reply.>`,
|
|
995
|
+
// The privileged delivery-admin rail (D5 slice 5/6): same replies-only shape. Requests reach the
|
|
996
|
+
// daemon on the sub below; only the supervisor cred can PUBLISH them (nats-server is the boundary).
|
|
997
|
+
`${p}.ctl.delivery-admin.*.*.reply.>`,
|
|
894
998
|
];
|
|
895
999
|
const sub = [
|
|
896
|
-
`_INBOX_${
|
|
897
|
-
`${p}.ctl.delivery
|
|
1000
|
+
`_INBOX_${pr.connId}.>`,
|
|
1001
|
+
`${p}.ctl.delivery.*.*`, // serve the delivery control service (queue-grouped; owner+actor caller slots)
|
|
1002
|
+
`${p}.ctl.delivery-admin.*.*`, // serve the privileged admin rail (reloadCreds; eviction executor next)
|
|
898
1003
|
];
|
|
899
1004
|
return { pub: { allow: pub }, sub: { allow: sub } };
|
|
900
1005
|
}
|
|
@@ -905,7 +1010,7 @@ function deliveryPermissions(space, id) {
|
|
|
905
1010
|
* isolation keeps the system-account CONNZ read on the SEPARATE conn-A cred). A leaked conn-B cred can
|
|
906
1011
|
* read durable-membership records and forge the feed — bounded to "dashboard integrity" by the
|
|
907
1012
|
* display-only invariant; it reads no message bodies and admins nothing. */
|
|
908
|
-
function membershipRwPermissions(space,
|
|
1013
|
+
function membershipRwPermissions(space, pr) {
|
|
909
1014
|
const MKV = `KV_${membersBucket(space)}`; // durable arm — read
|
|
910
1015
|
const MEMKV = `KV_${membershipBucket(space)}`; // derived feed — read (diff/prune) + write
|
|
911
1016
|
const kvRead = (bucket) => [
|
|
@@ -922,7 +1027,7 @@ function membershipRwPermissions(space, id) {
|
|
|
922
1027
|
`$KV.${membershipBucket(space)}.>`, // write derived feed (kv.put + kv.delete)
|
|
923
1028
|
"$JS.FC.>", // ordered-consumer flow control
|
|
924
1029
|
];
|
|
925
|
-
return { pub: { allow: pub }, sub: { allow: [`_INBOX_${
|
|
1030
|
+
return { pub: { allow: pub }, sub: { allow: [`_INBOX_${pr.connId}.>`] } };
|
|
926
1031
|
}
|
|
927
1032
|
/** The scoped SYSTEM-account `membership-observer` permission set (the graph feed's conn A). An EXPLICIT
|
|
928
1033
|
* block is MANDATORY: a system-account user with NO permissions block defaults to ALLOW-ALL = full
|
|
@@ -953,12 +1058,46 @@ function membershipObserverPermissions(accountId) {
|
|
|
953
1058
|
* original `up`): the observer can only be minted at the (re-)provision that creates the account — a
|
|
954
1059
|
* documented migration property, not a silent no-op. The CONNZ/event subjects pin the DATA account id
|
|
955
1060
|
* (`auth.account.pub`). Mirrors {@link mintCreds} but issues into the system account. */
|
|
956
|
-
export async function mintMembershipObserverCreds(auth, identity) {
|
|
1061
|
+
export async function mintMembershipObserverCreds(auth, identity, opts = {}) {
|
|
957
1062
|
if (!auth.sys.signingSeed)
|
|
958
|
-
throw new Error("mintMembershipObserverCreds: no in-memory system-account signing seed
|
|
1063
|
+
throw new Error("mintMembershipObserverCreds: no in-memory system-account signing seed - the observer can only be minted at the `up` that provisions the account (the $SYS seed is never persisted). Re-provision (down/up) to enable broker-sourced membership.");
|
|
959
1064
|
const signer = fromSeed(new TextEncoder().encode(auth.sys.signingSeed));
|
|
960
1065
|
const perms = membershipObserverPermissions(auth.account.pub);
|
|
961
|
-
|
|
1066
|
+
// Bounded exp (D5 slice 5): the observer is `rotation-renewed` — it carries the matrix's default
|
|
1067
|
+
// lifetime so a copied cred becomes broker-dead, but there is NO online renewal (the $SYS seed is
|
|
1068
|
+
// gone after `up`); renewal is a coordinated system-account rotation + restart.
|
|
1069
|
+
const validDates = userValidDates("membership-observer", opts);
|
|
1070
|
+
const userJwt = await encodeUser("membership-observer", fromPublic(identity.id), fromPublic(auth.sys.pub), perms, { signer, ...validDates });
|
|
1071
|
+
const creds = fmtCreds(userJwt, fromSeed(new TextEncoder().encode(identity.seed)));
|
|
1072
|
+
return new TextDecoder().decode(creds);
|
|
1073
|
+
}
|
|
1074
|
+
/** The KICK-ONLY connection-evictor permission set (D5 slice 4) — a SYSTEM-account user that can do
|
|
1075
|
+
* exactly ONE thing: `$SYS.REQ.SERVER.*.KICK` (disconnect a live client by cid). It CANNOT read
|
|
1076
|
+
* CONNZ (discovery stays on the separate observer cred — never one broad sys user that both
|
|
1077
|
+
* enumerates and kills), touch any other `$SYS` verb, or reach another account's data. A leaked
|
|
1078
|
+
* evictor cred can DoS live connections on this broker (KICK is not account-scoped — the honest
|
|
1079
|
+
* blast radius), which is why it is a HIGH-POWER standing credential: minted only at `up`,
|
|
1080
|
+
* rate-limited + audited by its one caller (the delivery daemon), and its cid/server-id inputs come
|
|
1081
|
+
* only from the observer's own CONNZ scan, never a user-facing API. Wildcard `*` over server id
|
|
1082
|
+
* because a cluster's server ids aren't known at mint time; the scan pins the exact id per KICK. */
|
|
1083
|
+
function connectionEvictorPermissions() {
|
|
1084
|
+
return {
|
|
1085
|
+
pub: { allow: ["$SYS.REQ.SERVER.*.KICK"] },
|
|
1086
|
+
// Request/reply KICK replies land on the client's default inbox; no other subscription — it
|
|
1087
|
+
// serves nothing and reads no feed.
|
|
1088
|
+
sub: { allow: ["_INBOX.>"] },
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
/** Mint the scoped `connection-evictor` creds — the kick-only SYSTEM-account user D5 slice 4's live
|
|
1092
|
+
* eviction holds. Same mint-only-at-provision property as the observer (the $SYS seed is in-memory
|
|
1093
|
+
* only), same fail-loud when it's absent. Paired with the observer at `up`. */
|
|
1094
|
+
export async function mintConnectionEvictorCreds(auth, identity, opts = {}) {
|
|
1095
|
+
if (!auth.sys.signingSeed)
|
|
1096
|
+
throw new Error("mintConnectionEvictorCreds: no in-memory system-account signing seed - the evictor can only be minted at the `up` that provisions the account (the $SYS seed is never persisted). Re-provision (down/up) to enable live eviction.");
|
|
1097
|
+
const signer = fromSeed(new TextEncoder().encode(auth.sys.signingSeed));
|
|
1098
|
+
// Bounded exp (D5 slice 5): `rotation-renewed`, same posture as the observer above.
|
|
1099
|
+
const validDates = userValidDates("connection-evictor", opts);
|
|
1100
|
+
const userJwt = await encodeUser("connection-evictor", fromPublic(identity.id), fromPublic(auth.sys.pub), connectionEvictorPermissions(), { signer, ...validDates });
|
|
962
1101
|
const creds = fmtCreds(userJwt, fromSeed(new TextEncoder().encode(identity.seed)));
|
|
963
1102
|
return new TextDecoder().decode(creds);
|
|
964
1103
|
}
|
|
@@ -974,7 +1113,7 @@ export function serverConfig(auth, opts) {
|
|
|
974
1113
|
// agent JWT — but right-sized, not generous: the CONNECT line is parsed BEFORE auth, so the cap
|
|
975
1114
|
// is a per-connection pre-auth allocation under connection flooding. 64 KB clears a many-channel
|
|
976
1115
|
// agent JWT (~4–8 KB) with wide margin while keeping the pre-auth surface ~16× tighter than 1 MB.
|
|
977
|
-
return `# Generated by \`cotal up\`
|
|
1116
|
+
return `# Generated by \`cotal up\` - do not edit by hand.
|
|
978
1117
|
host: ${host}
|
|
979
1118
|
port: ${port}
|
|
980
1119
|
max_control_line: 65536
|
|
@@ -984,7 +1123,7 @@ system_account: ${auth.sys.pub}
|
|
|
984
1123
|
resolver: MEMORY
|
|
985
1124
|
resolver_preload: {
|
|
986
1125
|
${auth.account.pub}: ${auth.account.jwt}
|
|
987
|
-
${auth.sys.pub}: ${auth.sys.jwt}
|
|
1126
|
+
${auth.sys.pub}: ${auth.sys.jwt}${(opts.extraAccounts ?? []).map((a) => `\n ${a.pub}: ${a.jwt}`).join("")}
|
|
988
1127
|
}
|
|
989
1128
|
`;
|
|
990
1129
|
}
|