@cotal-ai/core 0.10.1 → 0.11.1
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/subjects.js
CHANGED
|
@@ -51,15 +51,26 @@ function channelPath(channel) {
|
|
|
51
51
|
})
|
|
52
52
|
.join(".");
|
|
53
53
|
}
|
|
54
|
-
/** A routing token (
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
54
|
+
/** A routing token (target, role, service), preserving the literal `*` wildcard used on the
|
|
55
|
+
* subscribe/allow side but sanitizing everything else. A no-op on real ids and equal to `token()`
|
|
56
|
+
* on every concrete value — it only additionally lets `*` through, e.g. for `svc.*.…` allow rules.
|
|
57
|
+
* Used for the *service/role* slots (`svc.<role>`, `ctl.<tier>`); the owner/actor identity slots go
|
|
58
|
+
* through {@link ownerToken} (fail-loud, never rewritten). */
|
|
58
59
|
function routeToken(s) {
|
|
59
60
|
return s === "*" ? "*" : token(s);
|
|
60
61
|
}
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
/** An owner/actor identity token in a wire subject: the literal `*` wildcard passes through (for
|
|
63
|
+
* allow/subscribe rules like `chat.*.*.<ch>`), and every concrete value is {@link assertValidOwnerToken}-
|
|
64
|
+
* validated (fail loud — a `.`/`*`/`>`/`-` in an id is lane breakout, NEVER silently rewritten the way
|
|
65
|
+
* `token()`/`routeToken()` would). The owner+actor grammar's per-subject enforcement point. */
|
|
66
|
+
function ownerToken(s) {
|
|
67
|
+
return s === "*" ? "*" : assertValidOwnerToken(s);
|
|
68
|
+
}
|
|
69
|
+
/** Multicast: `chat.<owner>.<actor>.<channel…>` — the publishing principal (owner+actor) precedes the
|
|
70
|
+
* channel (owner+actor grammar, Shape A). Either identity slot may be `*` for subscribe/allow rules
|
|
71
|
+
* (`chat.*.*.<ch>` = read a channel from any principal). */
|
|
72
|
+
export function chatSubject(space, owner, actor, channel) {
|
|
73
|
+
return `${spacePrefix(space)}.chat.${ownerToken(owner)}.${ownerToken(actor)}.${channelPath(channel)}`;
|
|
63
74
|
}
|
|
64
75
|
/** True if a channel names a concrete sub-channel (no `*`/`>`) — i.e. it can be
|
|
65
76
|
* *published* to. Subscriptions may be wildcard; publishes must be concrete. */
|
|
@@ -108,36 +119,224 @@ export function assertValidChannel(channel) {
|
|
|
108
119
|
if (s === "*")
|
|
109
120
|
return;
|
|
110
121
|
if (!/^[A-Za-z0-9_-]+$/.test(s))
|
|
111
|
-
throw new Error(`invalid channel "${channel}": segment "${s}" must be a NATS-safe token ([A-Za-z0-9_-]), '*', or '>'
|
|
122
|
+
throw new Error(`invalid channel "${channel}": segment "${s}" must be a NATS-safe token ([A-Za-z0-9_-]), '*', or '>' - ` +
|
|
112
123
|
`policy channel names can't contain characters the wire layer would rewrite`);
|
|
113
124
|
});
|
|
114
125
|
return channel;
|
|
115
126
|
}
|
|
116
|
-
/** Validate an **owner
|
|
117
|
-
*
|
|
118
|
-
* {@link
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
127
|
+
/** Validate an **owner or actor token** of the owner+actor grammar (the per-user-auth cutover).
|
|
128
|
+
* Defined AHEAD of use: today this has no call sites — persisted owner-bearing keys
|
|
129
|
+
* ({@link memberKey}, {@link aclKey}, {@link dinboxSubject}, {@link dlvSubject}) still ride raw
|
|
130
|
+
* nkeys through `token()`/`routeToken()` (a no-op on base32) — the cutover wires this in at every
|
|
131
|
+
* mint/callout boundary and persisted owner/actor-bearing key. STRICTER than
|
|
132
|
+
* {@link assertValidChannel}: a token is exactly ONE NATS-safe segment — `[A-Za-z0-9_]+`, with NO
|
|
133
|
+
* dots, NO `*`, NO `>`, and NO `-`. Dots/wildcards in an id are lane breakout or aliasing (they would
|
|
134
|
+
* let one owner's grant span, or collide with, another's); `-` is excluded because it is reserved as
|
|
135
|
+
* the sole separator of {@link principalKey}'s JetStream-name form — a `-` *inside* a token would
|
|
136
|
+
* make `<owner>-<actor>` ambiguous. The ASCII-only alphabet also makes NFC-normalization trivially
|
|
137
|
+
* hold (any non-ASCII input fails). FAILS LOUD rather than sanitizing — do NOT substitute
|
|
138
|
+
* {@link token}/`routeToken` here: they silently rewrite illegal characters, and a rewrite hides an
|
|
139
|
+
* aliasing attempt. Returns the token unchanged when valid so callers can use it inline. */
|
|
125
140
|
export function assertValidOwnerToken(owner) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
141
|
+
// typeof guard is load-bearing at JS/JSON boundaries: RegExp.test() coerces its argument, so
|
|
142
|
+
// without it a number like 123 stringifies, matches, and is returned UN-coerced.
|
|
143
|
+
if (typeof owner !== "string" || !/^[A-Za-z0-9_]+$/.test(owner))
|
|
144
|
+
throw new Error(`invalid owner/actor token "${owner}": must be a single NATS-safe token ([A-Za-z0-9_]) - ` +
|
|
145
|
+
`no dots, '*', '>', or '-'. A separator or wildcard in an id is lane breakout or aliasing, ` +
|
|
146
|
+
`and '-' is reserved as the principal name-form separator, so it is rejected rather than ` +
|
|
147
|
+
`silently rewritten.`);
|
|
130
148
|
return owner;
|
|
131
149
|
}
|
|
132
|
-
/**
|
|
133
|
-
*
|
|
150
|
+
/** The canonical serialization of a **principal** (`owner`+`actor`) — the key every authority that
|
|
151
|
+
* enforces per-agent grants checks against. It is TWO forms, not one, because the same principal
|
|
152
|
+
* lands in two namespaces with incompatible rules:
|
|
153
|
+
* - `key` — the subject / KV-key dot-form `<owner>.<actor>`, for wire subjects and KV keys,
|
|
154
|
+
* where `.` is the token boundary;
|
|
155
|
+
* - `name` — the JetStream-name form `<owner>-<actor>`, for durable / consumer / stream and
|
|
156
|
+
* chat-history names, where JetStream forbids `.` `*` `>` `/` and whitespace, so the dot-form
|
|
157
|
+
* is illegal.
|
|
158
|
+
* Both tokens are {@link assertValidOwnerToken}-validated, and `-` is banned *inside* tokens and
|
|
159
|
+
* reserved as the sole name separator, so both forms are collision-free — distinct (owner, actor)
|
|
160
|
+
* pairs can never serialize to the same string. All principal serialization goes through here: no
|
|
161
|
+
* ad-hoc string joins, and never a `<owner>.<actor>` fed to a JetStream name. Defined ahead of the
|
|
162
|
+
* owner+actor cutover — call sites (durables, member/acl keys, grants) arrive with the flip. */
|
|
163
|
+
export function principalKey(owner, actor) {
|
|
164
|
+
assertValidOwnerToken(owner);
|
|
165
|
+
assertValidOwnerToken(actor);
|
|
166
|
+
return { key: `${owner}.${actor}`, name: `${owner}-${actor}` };
|
|
167
|
+
}
|
|
168
|
+
/** Principal key form for subjects and KV keys. Alias for callers/tests that need one form explicitly. */
|
|
134
169
|
export function principalSubjectKey(owner, actor) {
|
|
135
|
-
return
|
|
170
|
+
return principalKey(owner, actor).key;
|
|
136
171
|
}
|
|
137
|
-
/** Principal key form for JetStream names
|
|
138
|
-
* the reserved separator and is banned inside each owner/actor token by {@link assertValidOwnerToken}. */
|
|
172
|
+
/** Principal key form for JetStream names. Alias for callers/tests that need one form explicitly. */
|
|
139
173
|
export function principalNameKey(owner, actor) {
|
|
140
|
-
return
|
|
174
|
+
return principalKey(owner, actor).name;
|
|
175
|
+
}
|
|
176
|
+
/** Inverse of {@link principalKey}'s dot-form `key`: split a principal `<owner>.<actor>` back into its
|
|
177
|
+
* two tokens, or `null` if it isn't a valid one. Owner/actor tokens are `[A-Za-z0-9_]+` (dot-free), so a
|
|
178
|
+
* single `.` separates them unambiguously — exactly two segments, both {@link assertValidOwnerToken}-valid.
|
|
179
|
+
* Used where a stored principal (a member/from.id dot-form) must be re-split to feed the owner+actor
|
|
180
|
+
* subject builders (e.g. fan-out → `dinboxSubject`). */
|
|
181
|
+
/** Resolve a deprovision target: a full principal dot-form (`u_….<actor>`, user-mode agents) or a
|
|
182
|
+
* bare static/dev actor id (an nkey pub — never contains a dot), keyed under {@link DEV_OWNER}.
|
|
183
|
+
* Shared by the deprovisioner permission pin and the teardown helper so they can't diverge. */
|
|
184
|
+
export function deprovisionTargetPrincipal(target) {
|
|
185
|
+
return parsePrincipalKey(target) ?? { owner: DEV_OWNER, actor: target };
|
|
186
|
+
}
|
|
187
|
+
export function parsePrincipalKey(key) {
|
|
188
|
+
if (typeof key !== "string")
|
|
189
|
+
return null;
|
|
190
|
+
const dot = key.indexOf(".");
|
|
191
|
+
if (dot <= 0 || dot >= key.length - 1)
|
|
192
|
+
return null;
|
|
193
|
+
const owner = key.slice(0, dot);
|
|
194
|
+
const actor = key.slice(dot + 1);
|
|
195
|
+
if (!/^[A-Za-z0-9_]+$/.test(owner) || !/^[A-Za-z0-9_]+$/.test(actor))
|
|
196
|
+
return null;
|
|
197
|
+
return { owner, actor };
|
|
198
|
+
}
|
|
199
|
+
/** Validate a connection's **connId** — the single token that fills `_INBOX_<connId>.>`, the private
|
|
200
|
+
* reply-inbox grant. In dev/static mode connId is the agent's own nkey (56 uppercase base32 chars); in
|
|
201
|
+
* user mode it is a client-CHOSEN random inbox nonce (the auth callout can't know the NATS-minted
|
|
202
|
+
* per-connection nkey pre-connect, and the client can't either, so the client picks its own nonce and
|
|
203
|
+
* passes it via the connection `name`). Because that nonce is untrusted client input, the grant builder
|
|
204
|
+
* MUST reject any subject metacharacter here: a connId of `>` would mint `_INBOX_>.>` (every inbox) and a
|
|
205
|
+
* `*`/`.` would widen it too. `[A-Za-z0-9_-]{8,120}` admits nkeys and high-entropy nonces while barring
|
|
206
|
+
* `* > . space` — so `_INBOX_<connId>.>` can never escalate past one connection's own inbox. Fail-loud. */
|
|
207
|
+
export function assertInboxConnId(connId) {
|
|
208
|
+
if (typeof connId !== "string" || !/^[A-Za-z0-9_-]{8,120}$/.test(connId))
|
|
209
|
+
throw new Error(`invalid connId/inbox nonce ${JSON.stringify(connId)} - must match [A-Za-z0-9_-]{8,120} ` +
|
|
210
|
+
`(no subject metacharacters; guards the _INBOX_<connId>.> grant against wildcard escalation)`);
|
|
211
|
+
return connId;
|
|
212
|
+
}
|
|
213
|
+
/** The `tags` prefix carrying a connection's principal dot-form in its minted user JWT. CONNZ
|
|
214
|
+
* surfaces a JWT-authed connection's principal identity DIFFERENTLY by cred shape, proven live on
|
|
215
|
+
* nats-server 2.10/2.14 (see {@link principalFromConnz}): a STATICALLY-minted user (`mintCreds`)
|
|
216
|
+
* surfaces this `principal:` tag (`authorized_user` is the ephemeral connection nkey), while a
|
|
217
|
+
* CALLOUT-minted user surfaces the JWT `name` (the principal NAME-form) as `authorized_user` and NO
|
|
218
|
+
* tags at all. So this tag is the attribution field for STATIC connections only; callout connections
|
|
219
|
+
* are attributed via `authorized_user`. NEVER reintroduce a tags-ONLY read — it silently misses every
|
|
220
|
+
* user-mode connection. Always attribute through {@link principalFromConnz}, which handles both. */
|
|
221
|
+
export const PRINCIPAL_TAG_PREFIX = "principal:";
|
|
222
|
+
/** The identity `tags` stamped into every minted user JWT (dev mint AND the auth callout, via this one
|
|
223
|
+
* helper) — recoverable from a STATIC connection's CONNZ record (a callout connection surfaces the
|
|
224
|
+
* principal via `authorized_user` instead; see {@link PRINCIPAL_TAG_PREFIX}). `owner:`/`actor:` are
|
|
225
|
+
* human/debug breadcrumbs; `principal:` (the dot-form {@link principalKey} `key`) is the one attribution
|
|
226
|
+
* reads. Single source of the tag format — never hand-join these elsewhere. */
|
|
227
|
+
export function principalTags(owner, actor) {
|
|
228
|
+
const { key } = principalKey(owner, actor);
|
|
229
|
+
return [`owner:${owner}`, `actor:${actor}`, `${PRINCIPAL_TAG_PREFIX}${key}`];
|
|
230
|
+
}
|
|
231
|
+
/** Recover a connection's principal dot-form from its CONNZ `tags`, or `null` if absent/malformed. The
|
|
232
|
+
* membership feed calls this to key a live connection by its principal and **fails closed** on `null`
|
|
233
|
+
* (it drops the connection rather than fall back to the ephemeral nkey — a tagless connection is not a
|
|
234
|
+
* principal we can attribute). Validates via {@link parsePrincipalKey} so a forged/garbled tag can't
|
|
235
|
+
* smuggle a non-principal string into the feed. */
|
|
236
|
+
export function principalFromTags(tags) {
|
|
237
|
+
if (!tags)
|
|
238
|
+
return null;
|
|
239
|
+
const tag = tags.find((t) => t.startsWith(PRINCIPAL_TAG_PREFIX));
|
|
240
|
+
if (!tag)
|
|
241
|
+
return null;
|
|
242
|
+
const key = tag.slice(PRINCIPAL_TAG_PREFIX.length);
|
|
243
|
+
// Same trust boundary as the message-surfacing guards ({@link isPrincipalOwnerToken}): a recovered
|
|
244
|
+
// principal must have a REAL owner (derived `u_…` or `local`), not just valid dot-form syntax — else a
|
|
245
|
+
// `principal:<nkey>.team` tag would key a live feed entry on an nkey-shaped owner the surfacing path
|
|
246
|
+
// rejects. Fail closed on anything else.
|
|
247
|
+
const p = parsePrincipalKey(key);
|
|
248
|
+
return p && isPrincipalOwnerToken(p.owner) ? key : null;
|
|
249
|
+
}
|
|
250
|
+
/** Inverse of {@link principalKey}'s `name` form (`<owner>-<actor>`): recover the principal dot-form
|
|
251
|
+
* from a name-form string, or `null` if it isn't one. Owner tokens are `[A-Za-z0-9_]+` and actor
|
|
252
|
+
* tokens too — `-` is reserved as the name-form separator — so the FIRST `-` splits owner from actor
|
|
253
|
+
* unambiguously, and both halves must be {@link parsePrincipalKey}-valid with a real principal owner.
|
|
254
|
+
* An nkey (no `-`) or any other non-name-form returns null. */
|
|
255
|
+
export function principalFromName(name) {
|
|
256
|
+
if (typeof name !== "string")
|
|
257
|
+
return null;
|
|
258
|
+
const dash = name.indexOf("-");
|
|
259
|
+
if (dash <= 0 || dash >= name.length - 1)
|
|
260
|
+
return null;
|
|
261
|
+
const key = `${name.slice(0, dash)}.${name.slice(dash + 1)}`;
|
|
262
|
+
const p = parsePrincipalKey(key);
|
|
263
|
+
return p && isPrincipalOwnerToken(p.owner) ? key : null;
|
|
264
|
+
}
|
|
265
|
+
/** Recover a connection's principal dot-form from a `$SYS` CONNZ record, across BOTH credential
|
|
266
|
+
* shapes — the one place that knows nats-server surfaces principal identity differently for each:
|
|
267
|
+
* - a STATICALLY-minted user (`mintCreds`) surfaces its `principal:` TAG (`authorized_user` is the
|
|
268
|
+
* ephemeral connection nkey);
|
|
269
|
+
* - a CALLOUT-minted user surfaces the JWT `name` (the principal NAME-form) as `authorized_user`,
|
|
270
|
+
* and does NOT surface `tags` at all (proven live on nats-server 2.10.22 + 2.14.2).
|
|
271
|
+
* So attribution must try the tag first, then the `authorized_user` name-form; anything else (an
|
|
272
|
+
* un-tagged nkey, open mode, infra) is `null` — unattributable, dropped fail-closed by callers.
|
|
273
|
+
* The membership feed and live eviction both key on this, so it lives here as the single source. */
|
|
274
|
+
export function principalFromConnz(conn) {
|
|
275
|
+
return principalFromTags(conn.tags) ?? principalFromName(conn.authorized_user);
|
|
276
|
+
}
|
|
277
|
+
/** The reserved owner token for the **no-login local/dev path** — the static-creds default when there
|
|
278
|
+
* is no user identity (the plan's zero-login local default). A valid {@link assertValidOwnerToken}
|
|
279
|
+
* token, and — being lowercase-alpha with no `u_` prefix — trivially distinct from both nkeys and
|
|
280
|
+
* {@link assertDerivedOwnerToken} `u_…` owners, so a dev agent can never collide with a real owner
|
|
281
|
+
* lane. In dev the actor is the connection id, so `local.<id>` is the agent's principal. */
|
|
282
|
+
export const DEV_OWNER = "local";
|
|
283
|
+
/** Prefix of every **derived owner token** — see {@link assertDerivedOwnerToken}. */
|
|
284
|
+
export const DERIVED_OWNER_PREFIX = "u_";
|
|
285
|
+
/** Validate the structural format of a **derived owner token** — the opaque, per-space, non-PII
|
|
286
|
+
* token the auth callout derives server-side for a human owner: `u_` + 26 lowercase base32
|
|
287
|
+
* (`[a-z2-7]`) chars (128 keyed-HMAC bits; the derivation lives in `@cotal-ai/auth`, this is the
|
|
288
|
+
* format contract). The format is **structurally disjoint from nkeys by construction** — the
|
|
289
|
+
* owner+actor flip's acceptance criterion 2: an nkey public key is 56 chars of UPPERCASE base32
|
|
290
|
+
* (`[A-Z2-7]`), while a derived token is 28 chars, contains `_`, and its body is lowercase — three
|
|
291
|
+
* independent properties an nkey can never satisfy. So a pre-flip agent id (an nkey) can NEVER
|
|
292
|
+
* parse as a valid new owner, which closes the old-shape-aliases-new-read lane
|
|
293
|
+
* (`chat.<nkey>.team.backend` matching `chat.*.*.backend` cannot yield a plausible owner). Every
|
|
294
|
+
* derived token also trivially passes {@link assertValidOwnerToken} (subset alphabet, no `-`).
|
|
295
|
+
* Defined ahead of use: enforced at the callout/mint boundary when the cutover lands. Returns the
|
|
296
|
+
* token unchanged when valid so callers can use it inline. */
|
|
297
|
+
export function assertDerivedOwnerToken(owner) {
|
|
298
|
+
if (typeof owner !== "string" || !/^u_[a-z2-7]{26}$/.test(owner))
|
|
299
|
+
throw new Error(`invalid derived owner token "${owner}": expected "${DERIVED_OWNER_PREFIX}" + 26 lowercase ` +
|
|
300
|
+
`base32 chars ([a-z2-7]). Owner tokens are derived server-side at the auth callout and are ` +
|
|
301
|
+
`structurally disjoint from nkeys; anything else at an owner boundary is rejected, not rewritten.`);
|
|
302
|
+
return owner;
|
|
303
|
+
}
|
|
304
|
+
/** Validate an owner token at a READ / persisted-owner TRUST boundary — STRICTER than
|
|
305
|
+
* {@link assertValidOwnerToken}, which (by design) still accepts nkey-shaped uppercase tokens and so does
|
|
306
|
+
* NOT by itself satisfy the flip's acceptance criterion 2. A *real* owner is EITHER a derived owner
|
|
307
|
+
* ({@link assertDerivedOwnerToken}: `u_` + 26 lowercase base32, minted at the callout) OR — when
|
|
308
|
+
* `allowLocal` — the reserved no-login dev owner {@link DEV_OWNER} (`"local"`). An nkey (56-char UPPERCASE
|
|
309
|
+
* base32) satisfies neither, so a stray old-shape frame `chat.<nkey>.team.backend` can never be TRUSTED as
|
|
310
|
+
* a valid owner even if it structurally parses — the belt to the from.id≠sender guard's braces and cred
|
|
311
|
+
* death. Use at every boundary that reads an owner from a wire subject / persisted key and then trusts it
|
|
312
|
+
* for keying, surfacing, or authorization (membership feed re-key, history surfacing). Actors stay on
|
|
313
|
+
* {@link assertValidOwnerToken} — they are server-derived from the ledger, not disjointness-constrained.
|
|
314
|
+
* User-mode MINT boundaries (callout/bridge) use {@link assertDerivedOwnerToken} directly (no `local`). */
|
|
315
|
+
export function assertPrincipalOwnerToken(owner, opts = {}) {
|
|
316
|
+
if (opts.allowLocal && owner === DEV_OWNER)
|
|
317
|
+
return owner;
|
|
318
|
+
try {
|
|
319
|
+
return assertDerivedOwnerToken(owner);
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
throw new Error(`invalid principal owner "${owner}" at a trust boundary: expected a derived owner (u_…)` +
|
|
323
|
+
`${opts.allowLocal ? ` or the reserved dev owner "${DEV_OWNER}"` : ""} - an nkey-shaped or arbitrary ` +
|
|
324
|
+
`token is not a real owner (flip criterion 2: owners are nkey-disjoint).`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/** Non-throwing {@link assertPrincipalOwnerToken} for hot per-message drop guards — true iff `owner` is a
|
|
328
|
+
* real principal owner (a derived `u_…`, or `local` when `allowLocal`). The message-surfacing paths call
|
|
329
|
+
* this on `parsed.owner` alongside the `from.id === parsed.sender` check, so a structurally-valid old-shape
|
|
330
|
+
* alias (`chat.<nkey>.team.backend`, owner = an nkey) is DROPPED at read time — belt to cred death, not a
|
|
331
|
+
* dependency on it. `allowLocal` defaults true: the dev/static path is a legitimate live sender. */
|
|
332
|
+
export function isPrincipalOwnerToken(owner, opts = { allowLocal: true }) {
|
|
333
|
+
try {
|
|
334
|
+
assertPrincipalOwnerToken(owner, { allowLocal: opts.allowLocal ?? true });
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
141
340
|
}
|
|
142
341
|
/** Is `channel` within a read/post ACL `allow` (a list of channel patterns)? True when some
|
|
143
342
|
* entry covers it — exact, or a wildcard subtree (`team.>` covers `team.backend`). Channels are
|
|
@@ -147,6 +346,43 @@ export function principalNameKey(owner, actor) {
|
|
|
147
346
|
export function channelInAllow(allow, channel) {
|
|
148
347
|
return allow.some((a) => subjectMatches(a, channel));
|
|
149
348
|
}
|
|
349
|
+
/** Does policy pattern `cap` COVER policy pattern `pattern` — i.e. is every channel matched by
|
|
350
|
+
* `pattern` also matched by `cap`? Both sides use the {@link assertValidChannel} grammar
|
|
351
|
+
* (`*` = one token, `>` = one-or-more trailing). This is the DELEGATION primitive
|
|
352
|
+
* ({@link patternInAllow}): where {@link channelInAllow} asks "is this concrete channel readable
|
|
353
|
+
* under this ACL", this asks "is this ACL *entry* grantable under this ACL" — pattern vs pattern,
|
|
354
|
+
* so `review.>` is within `review.>` but exceeds `review.pua`. */
|
|
355
|
+
export function patternCovers(cap, pattern) {
|
|
356
|
+
const c = cap.split(".");
|
|
357
|
+
const p = pattern.split(".");
|
|
358
|
+
// A non-terminal '>' is outside the policy grammar (assertValidChannel) — fail closed on BOTH
|
|
359
|
+
// sides: a malformed cap must never widen an envelope (`review.>.x` is not `review.>`), and a
|
|
360
|
+
// malformed request must never be admitted.
|
|
361
|
+
if (c.slice(0, -1).includes(">") || p.slice(0, -1).includes(">"))
|
|
362
|
+
return false;
|
|
363
|
+
for (let i = 0; i < c.length; i++) {
|
|
364
|
+
if (c[i] === ">")
|
|
365
|
+
return p.length > i; // every match of `pattern` has ≥ i+1 tokens iff pattern still has a token here
|
|
366
|
+
if (i >= p.length)
|
|
367
|
+
return false; // pattern's matches are exactly p.length tokens — shorter than cap requires
|
|
368
|
+
if (p[i] === ">")
|
|
369
|
+
return false; // pattern fans out into arbitrary depth this cap segment can't cover
|
|
370
|
+
if (c[i] === "*")
|
|
371
|
+
continue; // any single token is covered
|
|
372
|
+
if (p[i] === "*")
|
|
373
|
+
return false; // pattern admits tokens this literal cap segment does not
|
|
374
|
+
if (c[i] !== p[i])
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
return c.length === p.length;
|
|
378
|
+
}
|
|
379
|
+
/** Is ACL entry `pattern` within capability list `allow` — covered by SOME single entry? A union of
|
|
380
|
+
* entries is deliberately NOT considered (`[a.b, a.c]` does not admit `a.*`): per-entry containment
|
|
381
|
+
* is conservative — it can refuse a technically-covered pattern, never admit an uncovered one —
|
|
382
|
+
* and keeps the refusal explainable ("name the entry that covers it, or widen the grant"). */
|
|
383
|
+
export function patternInAllow(allow, pattern) {
|
|
384
|
+
return allow.some((a) => patternCovers(a, pattern));
|
|
385
|
+
}
|
|
150
386
|
/** Drop exact duplicates and any subject subsumed by a more-general one — JetStream
|
|
151
387
|
* rejects a consumer whose `filter_subjects` overlap, so `[team.>, team.backend]`
|
|
152
388
|
* must collapse to `[team.>]` before binding the chat consumer. A parent and its subtree
|
|
@@ -156,18 +392,33 @@ export function collapseFilterSubjects(subjects) {
|
|
|
156
392
|
const uniq = [...new Set(subjects)];
|
|
157
393
|
return uniq.filter((x) => !uniq.some((y) => y !== x && subjectMatches(y, x)));
|
|
158
394
|
}
|
|
159
|
-
/** Unicast:
|
|
160
|
-
*
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
export function
|
|
170
|
-
return `${spacePrefix(space)}.
|
|
395
|
+
/** Unicast: `inst.<recipOwner>.<recipActor>.<sndOwner>.<sndActor>` — the recipient principal, then the
|
|
396
|
+
* sender principal (owner+actor grammar). The 4-token form is deliberate: it lets a native publish grant
|
|
397
|
+
* forge-lock the sender suffix (`inst.*.*.<myOwner>.<myActor>`), so the daemon needn't re-verify a
|
|
398
|
+
* payload sender claim. Any slot may be `*` for allow rules (`inst.*.*.<o>.<a>` to send as me;
|
|
399
|
+
* {@link unicastRecvFilter} for the receive side). */
|
|
400
|
+
export function unicastSubject(space, recipOwner, recipActor, sndOwner, sndActor) {
|
|
401
|
+
return `${spacePrefix(space)}.inst.${ownerToken(recipOwner)}.${ownerToken(recipActor)}.${ownerToken(sndOwner)}.${ownerToken(sndActor)}`;
|
|
402
|
+
}
|
|
403
|
+
/** The receive-side DM filter/grant for a recipient principal: `inst.<owner>.<actor>.>` — every DM
|
|
404
|
+
* addressed to me, from any sender. (The send side is the 4-token {@link unicastSubject}.) */
|
|
405
|
+
export function unicastRecvFilter(space, owner, actor) {
|
|
406
|
+
return `${spacePrefix(space)}.inst.${ownerToken(owner)}.${ownerToken(actor)}.>`;
|
|
407
|
+
}
|
|
408
|
+
/** Anycast: `svc.<service>.<owner>.<actor>` — a service (role), tagged with the sender principal.
|
|
409
|
+
* Subscribers join a queue group so one instance receives. Identity slots accept `*`. */
|
|
410
|
+
export function anycastSubject(space, service, owner, actor) {
|
|
411
|
+
return `${spacePrefix(space)}.svc.${routeToken(service)}.${ownerToken(owner)}.${ownerToken(actor)}`;
|
|
412
|
+
}
|
|
413
|
+
/** The serve-side TASK filter/grant for a role: `svc.<role>.>` — every anycast to the role, from any
|
|
414
|
+
* sender principal (the sender slot widened from one token to two, so the tail is `.>`). */
|
|
415
|
+
export function anycastServeFilter(space, service) {
|
|
416
|
+
return `${spacePrefix(space)}.svc.${routeToken(service)}.>`;
|
|
417
|
+
}
|
|
418
|
+
/** Control request/reply to a service (e.g. the manager): `ctl.<service>.<owner>.<actor>` — tagged with
|
|
419
|
+
* the caller principal; anycast via queue group. Identity slots accept `*` (serve side: `ctl.<tier>.*.*`). */
|
|
420
|
+
export function controlServiceSubject(space, service, owner, actor) {
|
|
421
|
+
return `${spacePrefix(space)}.ctl.${routeToken(service)}.${ownerToken(owner)}.${ownerToken(actor)}`;
|
|
171
422
|
}
|
|
172
423
|
/** Control-plane service names — the three-tier split (P2a). The manager subscribes to ALL
|
|
173
424
|
* three; the cred layer grants {@link CONTROL_SELF_SERVICE} to every agent and
|
|
@@ -190,6 +441,13 @@ export const CONTROL_ADMIN = "admin";
|
|
|
190
441
|
* agent (only the allow-all manager could reply into the per-id `_INBOX_<id>` prefix). Lifecycle ops
|
|
191
442
|
* (spawn/stop/despawn) stay on the manager's tiers; durable membership is the daemon's. */
|
|
192
443
|
export const CONTROL_DELIVERY = "delivery";
|
|
444
|
+
/** The delivery daemon's PRIVILEGED admin rail (the D5 rail-split): control-plane ops the daemon
|
|
445
|
+
* EXECUTES for the mesh's renewal/repair owner — credential reload (`reloadCreds`, the class-2
|
|
446
|
+
* standing-renewal adoption step) now; the live-eviction executor rides here next. Cred-enforced
|
|
447
|
+
* caller set: only the manager's `supervisor` profile holds the request-publish grant (every agent
|
|
448
|
+
* cred is default-denied — nats-server is the boundary, same pattern as {@link CONTROL_ADMIN});
|
|
449
|
+
* the `delivery` cred holds the serve + bounded-reply side. */
|
|
450
|
+
export const CONTROL_DELIVERY_ADMIN = "delivery-admin";
|
|
193
451
|
export function traceSubject(space, agentId) {
|
|
194
452
|
return `${spacePrefix(space)}.trace.${token(agentId)}`;
|
|
195
453
|
}
|
|
@@ -206,29 +464,36 @@ export function chatWildcard(space) {
|
|
|
206
464
|
return `${spacePrefix(space)}.chat.>`;
|
|
207
465
|
}
|
|
208
466
|
/**
|
|
209
|
-
* The single authority on the subject layout — every reader of a wire subject goes
|
|
210
|
-
*
|
|
211
|
-
* chat.<
|
|
212
|
-
* inst.<
|
|
213
|
-
* svc.<role>.<
|
|
214
|
-
* ctl.<service>.<
|
|
215
|
-
* Validates the prefix and per-kind
|
|
216
|
-
*
|
|
467
|
+
* The single authority on the subject layout — every reader of a wire subject goes through this, so the
|
|
468
|
+
* owner+actor sender positions live in exactly one place (`kind` stays at [2]):
|
|
469
|
+
* chat.<owner>.<actor>.<channel…> sender owner[3] actor[4], channel is everything after
|
|
470
|
+
* inst.<recipOwner>.<recipActor>.<sndOwner>.<sndActor> sender owner[5] actor[6], recipient = [3].[4]
|
|
471
|
+
* svc.<role>.<owner>.<actor> sender owner[4] actor[5], role = [3]
|
|
472
|
+
* ctl.<service>.<owner>.<actor> sender owner[4] actor[5], service = [3]
|
|
473
|
+
* Validates the prefix and per-kind arity first and returns `null` on anything else, so a malformed
|
|
474
|
+
* subject can never be read as if it carried a principal. This SPLITS ONLY (no owner-token validation)
|
|
475
|
+
* — the broker already forge-locked the identity slots via the minted grant; a reader recovers them.
|
|
217
476
|
*/
|
|
218
477
|
export function parseSubject(subject) {
|
|
219
478
|
const parts = subject.split(".");
|
|
220
479
|
if (parts[0] !== ROOT)
|
|
221
480
|
return null; // cotal.<space>.<kind>.…
|
|
222
481
|
const kind = parts[2];
|
|
482
|
+
const mk = (kind, owner, actor, rest) => ({ kind, owner, actor, sender: `${owner}.${actor}`, rest });
|
|
223
483
|
if (kind === "chat") {
|
|
224
|
-
if (parts.length <
|
|
225
|
-
return null; // cotal.<space>.chat.<
|
|
226
|
-
return
|
|
484
|
+
if (parts.length < 6)
|
|
485
|
+
return null; // cotal.<space>.chat.<owner>.<actor>.<channel…>
|
|
486
|
+
return mk(kind, parts[3], parts[4], parts.slice(5).join("."));
|
|
227
487
|
}
|
|
228
|
-
if (kind === "inst"
|
|
229
|
-
if (parts.length !==
|
|
230
|
-
return null; // cotal.<space>.<
|
|
231
|
-
return
|
|
488
|
+
if (kind === "inst") {
|
|
489
|
+
if (parts.length !== 7)
|
|
490
|
+
return null; // cotal.<space>.inst.<recipOwner>.<recipActor>.<sndOwner>.<sndActor>
|
|
491
|
+
return mk(kind, parts[5], parts[6], `${parts[3]}.${parts[4]}`);
|
|
492
|
+
}
|
|
493
|
+
if (kind === "svc" || kind === "ctl") {
|
|
494
|
+
if (parts.length !== 6)
|
|
495
|
+
return null; // cotal.<space>.<kind>.<route>.<owner>.<actor>
|
|
496
|
+
return mk(kind, parts[4], parts[5], parts[3]);
|
|
232
497
|
}
|
|
233
498
|
return null;
|
|
234
499
|
}
|
|
@@ -262,21 +527,23 @@ export const CHANNEL_DEFAULTS_KEY = "=defaults";
|
|
|
262
527
|
export function membersBucket(space) {
|
|
263
528
|
return `cotal_members_${token(space)}`;
|
|
264
529
|
}
|
|
265
|
-
/** KV key for one membership record: `<channel>/<
|
|
266
|
-
*
|
|
267
|
-
* (
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
|
|
530
|
+
/** KV key for one membership record: `<channel>/<principal>`, where `principal` is the member's
|
|
531
|
+
* owner+actor dot-form (`<owner>.<actor>` = {@link principalKey}`.key`) — membership is per-agent
|
|
532
|
+
* (owner+actor), not per-owner-shared. The channel is concrete (no `*`/`>`, validated at the write
|
|
533
|
+
* path) so it is dotted-but-`/`-free, and a principal dot-form is `[A-Za-z0-9_.]` (also `/`-free), so
|
|
534
|
+
* the single `/` separates them unambiguously — both halves recover via {@link parseMemberKey}. `/`,
|
|
535
|
+
* `.`, and `[A-Za-z0-9_]` are all legal KV-key chars (`/^[-/=.\w]+$/`), so no encoding is needed. */
|
|
536
|
+
export function memberKey(channel, principal) {
|
|
537
|
+
return `${channel}/${principal}`;
|
|
538
|
+
}
|
|
539
|
+
/** Inverse of {@link memberKey}: split a member key back into `{ channel, principal }`, or `null` if
|
|
540
|
+
* it isn't one (no `/`). Splits on the single separator — channels and principal dot-forms are both
|
|
541
|
+
* `/`-free (the `.` inside a principal is not a `/`). */
|
|
275
542
|
export function parseMemberKey(key) {
|
|
276
543
|
const i = key.indexOf("/");
|
|
277
544
|
if (i <= 0 || i >= key.length - 1)
|
|
278
545
|
return null;
|
|
279
|
-
return { channel: key.slice(0, i),
|
|
546
|
+
return { channel: key.slice(0, i), principal: key.slice(i + 1) };
|
|
280
547
|
}
|
|
281
548
|
/** Name of the KV bucket holding the durable read-ACL registry (Plane-3) for a space — a
|
|
282
549
|
* privileged-write sibling of the members/channels buckets. One record per OWNER (key = owner id),
|
|
@@ -287,10 +554,12 @@ export function parseMemberKey(key) {
|
|
|
287
554
|
export function aclBucket(space) {
|
|
288
555
|
return `cotal_acl_${token(space)}`;
|
|
289
556
|
}
|
|
290
|
-
/** KV key for one
|
|
291
|
-
*
|
|
292
|
-
|
|
293
|
-
|
|
557
|
+
/** KV key for one principal's read-ACL record: the member's owner+actor dot-form (`<owner>.<actor>` =
|
|
558
|
+
* {@link principalKey}`.key`) — the read ACL is per-agent (owner+actor), like membership. The dot-form
|
|
559
|
+
* is a legal KV key (`/^[-/=.\w]+$/`); pass it through UNCHANGED — do NOT run it through `token()`,
|
|
560
|
+
* which rewrites `.`→`_` and would alias distinct principals onto one key. */
|
|
561
|
+
export function aclKey(principal) {
|
|
562
|
+
return principal;
|
|
294
563
|
}
|
|
295
564
|
// ---- Authoritative channel membership (broker CONNZ → derived feed) ----
|
|
296
565
|
//
|
|
@@ -306,10 +575,13 @@ export function aclKey(owner) {
|
|
|
306
575
|
export function membershipBucket(space) {
|
|
307
576
|
return `cotal_membership_${token(space)}`;
|
|
308
577
|
}
|
|
309
|
-
/** KV key for one agent's membership record: the agent
|
|
310
|
-
*
|
|
311
|
-
|
|
312
|
-
|
|
578
|
+
/** KV key for one agent's membership-feed record: the agent's owner+actor dot-form (`<owner>.<actor>` =
|
|
579
|
+
* {@link principalKey}`.key`) — one record per agent (owner+actor), keyed like acl/members. Pass the
|
|
580
|
+
* dot-form through UNCHANGED — do NOT run it through `token()` (which rewrites `.`→`_` and would alias
|
|
581
|
+
* distinct principals). Both feed sources (the CONNZ live side and the durable members side) must
|
|
582
|
+
* resolve to this same dot-form so the union does not double-key one agent. */
|
|
583
|
+
export function membershipKey(principal) {
|
|
584
|
+
return principal;
|
|
313
585
|
}
|
|
314
586
|
/** Reserved membership-bucket key for the feed's freshness heartbeat — the daemon re-stamps it every
|
|
315
587
|
* successful poll (even when no membership changed), so the dashboard can tell "feed is live" from "feed
|
|
@@ -339,18 +611,30 @@ export function accountConnectSubject(accountId) {
|
|
|
339
611
|
export function accountDisconnectSubject(accountId) {
|
|
340
612
|
return `$SYS.ACCOUNT.${accountId}.DISCONNECT`;
|
|
341
613
|
}
|
|
614
|
+
/** The per-server client-KICK request subject (`$SYS.REQ.SERVER.<serverID>.KICK`, nats-server
|
|
615
|
+
* `events.go` `clientKickReqSubj`). Payload `{cid}` → the server disconnects that live client by
|
|
616
|
+
* connection id. PER-SERVER: `serverID` MUST come from the SAME CONNZ reply that yielded the cid
|
|
617
|
+
* (KICK is not fan-out; a cid is only meaningful on its own server). System-account only. There is
|
|
618
|
+
* no per-cid success ack — the caller re-scans CONNZ to confirm the connection is gone. This is the
|
|
619
|
+
* kill-live half of revocation; it is ALWAYS paired with a deny-new (ledger revoke / cred expiry /
|
|
620
|
+
* signer strip / ACL removal), since a kicked client reconnects with a fresh cid until its cred dies. */
|
|
621
|
+
export function serverKickSubject(serverId) {
|
|
622
|
+
return `$SYS.REQ.SERVER.${serverId}.KICK`;
|
|
623
|
+
}
|
|
342
624
|
/** Extract the channel pattern from a live chat SUBSCRIPTION subject in this space, or `null` if it
|
|
343
|
-
* isn't one. A subscription's sender
|
|
344
|
-
* channel portion (wildcards preserved, e.g. `team.>`) is everything after
|
|
345
|
-
* membership daemon uses to turn a connection's subscription list into its
|
|
346
|
-
* matched against the chat grammar (not a denylist of known plumbing, which
|
|
347
|
-
* subject is added). Drops `_INBOX`, JetStream API, other-space, and non-chat
|
|
348
|
-
* god-view (`chat
|
|
625
|
+
* isn't one. A subscription's sender slots are `*` (an agent's `sub.allow` is `chat.*.*.<channel>`), so
|
|
626
|
+
* the channel portion (wildcards preserved, e.g. `team.>`) is everything after the two identity tokens.
|
|
627
|
+
* This is the ALLOWLIST the membership daemon uses to turn a connection's subscription list into its
|
|
628
|
+
* channel-subscription set — matched against the chat grammar (not a denylist of known plumbing, which
|
|
629
|
+
* rots when a new plumbing subject is added). Drops `_INBOX`, JetStream API, other-space, and non-chat
|
|
630
|
+
* subjects. The whole-chat god-view (`chat.*.*.>` → rest `">"`) IS returned here; the SHORT-wildcard
|
|
631
|
+
* taps (`chat.>`, `chat.*.>`) fail the ≥6-token arity and return `null` — the caller's god-tap detector
|
|
632
|
+
* must surface those as reads-all separately (a length-checking parse must not let them vanish).
|
|
349
633
|
*
|
|
350
634
|
* COUPLING (mitnick): both this extraction AND the membership daemon's god-tap detection ride
|
|
351
|
-
* {@link parseSubject}'s
|
|
352
|
-
*
|
|
353
|
-
*
|
|
635
|
+
* {@link parseSubject}'s owner+actor chat layout (channel after [4]). A future grammar reorder would move
|
|
636
|
+
* where the channel portion begins and silently break both — revisit this + the daemon's exclusion
|
|
637
|
+
* (and their tests) together. */
|
|
354
638
|
export function channelFromChatSubscription(space, subject) {
|
|
355
639
|
if (!subject.startsWith(`${spacePrefix(space)}.chat.`))
|
|
356
640
|
return null;
|
|
@@ -424,25 +708,31 @@ export function inboxStream(space) {
|
|
|
424
708
|
export function dlvStream(space) {
|
|
425
709
|
return `DLV_${token(space)}`;
|
|
426
710
|
}
|
|
427
|
-
/** Subject of
|
|
428
|
-
|
|
429
|
-
|
|
711
|
+
/** Subject of a principal's mixed durable inbox: `cotal.<space>.dinbox.<owner>.<actor>` (one per agent —
|
|
712
|
+
* the fan-out delivers a per-member copy, and a member is a principal). Either identity slot accepts `*`
|
|
713
|
+
* for the daemon's fan-out write grant (`dinbox.*.*`). */
|
|
714
|
+
export function dinboxSubject(space, owner, actor) {
|
|
715
|
+
return `${spacePrefix(space)}.dinbox.${ownerToken(owner)}.${ownerToken(actor)}`;
|
|
430
716
|
}
|
|
431
|
-
/** Subject of
|
|
432
|
-
export function dlvSubject(space, owner) {
|
|
433
|
-
return `${spacePrefix(space)}.dlv.${
|
|
717
|
+
/** Subject of a principal's post-auth delivery: `cotal.<space>.dlv.<owner>.<actor>` (one per agent). */
|
|
718
|
+
export function dlvSubject(space, owner, actor) {
|
|
719
|
+
return `${spacePrefix(space)}.dlv.${ownerToken(owner)}.${ownerToken(actor)}`;
|
|
434
720
|
}
|
|
435
|
-
/** Parse the
|
|
436
|
-
* The trusted reader is a SINGLE consumer over `dinbox.>` (all
|
|
437
|
-
* owner from the subject (
|
|
438
|
-
export function
|
|
721
|
+
/** Parse the principal out of a mixed-inbox subject `cotal.<space>.dinbox.<owner>.<actor>`, or null.
|
|
722
|
+
* The trusted reader is a SINGLE consumer over `dinbox.>` (all principals), so it recovers the
|
|
723
|
+
* per-message owner+actor from the subject (split only — the broker forge-locked the slots at write). */
|
|
724
|
+
export function parseDinboxPrincipal(subject) {
|
|
439
725
|
const parts = subject.split(".");
|
|
440
|
-
// cotal.<space>.dinbox.<owner>
|
|
441
|
-
return parts.length ===
|
|
726
|
+
// cotal.<space>.dinbox.<owner>.<actor>
|
|
727
|
+
return parts.length === 5 && parts[0] === ROOT && parts[2] === "dinbox"
|
|
728
|
+
? { owner: parts[3], actor: parts[4] }
|
|
729
|
+
: null;
|
|
442
730
|
}
|
|
443
|
-
/** An agent's bind-only per-
|
|
444
|
-
|
|
445
|
-
|
|
731
|
+
/** An agent's bind-only per-member consumer on {@link dlvStream} (filter `dlv.<owner>.<actor>`). The
|
|
732
|
+
* durable NAME is the principal's JetStream-safe dash-form (`dlv_<owner>-<actor>`) — a `.` is illegal
|
|
733
|
+
* in a durable name, so this uses {@link principalKey}`.name`, never the dot-form. */
|
|
734
|
+
export function dlvDurable(owner, actor) {
|
|
735
|
+
return `dlv_${principalKey(owner, actor).name}`;
|
|
446
736
|
}
|
|
447
737
|
/** The single privileged fan-out consumer on the CHAT stream (delivery-daemon-pumped; routing, not
|
|
448
738
|
* auth). N=1 keeps this exact name (see {@link fanoutDurable}). */
|
|
@@ -463,20 +753,21 @@ export function readerDurable(shard = 0, shards = 1) {
|
|
|
463
753
|
}
|
|
464
754
|
/** Name of the REMOVED per-instance chat live-tail durable. Retained only as the canonical name the
|
|
465
755
|
* read-ACL conformance test asserts an agent can NOT create — it has no live callers, the live read is
|
|
466
|
-
* now a native core subscription. */
|
|
467
|
-
export function chatDurable(
|
|
468
|
-
return `chat_${
|
|
469
|
-
}
|
|
470
|
-
/** Consumer name for an
|
|
471
|
-
* drop-marker). A single per-
|
|
472
|
-
* delete grants name-scope to that
|
|
473
|
-
* `filter_subject` is what the create-time ACL
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
756
|
+
* now a native core subscription. Principal name-form (`chat_<owner>-<actor>`) for namespace consistency. */
|
|
757
|
+
export function chatDurable(owner, actor) {
|
|
758
|
+
return `chat_${principalKey(owner, actor).name}`;
|
|
759
|
+
}
|
|
760
|
+
/** Consumer name for an agent's short-lived chat **history** reads (join-backfill, focus-recall,
|
|
761
|
+
* drop-marker). A single per-agent name in the principal's JetStream-safe dash-form
|
|
762
|
+
* (`chathist_<owner>-<actor>`) so its create/info/fetch/delete grants name-scope to that principal —
|
|
763
|
+
* a peer can never bind it — while the per-read single `filter_subject` is what the create-time ACL
|
|
764
|
+
* pins to `allowSubscribe`. */
|
|
765
|
+
export function chatHistDurable(owner, actor) {
|
|
766
|
+
return `chathist_${principalKey(owner, actor).name}`;
|
|
767
|
+
}
|
|
768
|
+
/** Durable consumer name for an agent's private DM inbox — the principal's dash-form (`dm_<owner>-<actor>`). */
|
|
769
|
+
export function dmDurable(owner, actor) {
|
|
770
|
+
return `dm_${principalKey(owner, actor).name}`;
|
|
480
771
|
}
|
|
481
772
|
/** Durable consumer name (shared across instances of a role) for the task queue. */
|
|
482
773
|
export function taskDurable(service) {
|