@cotal-ai/core 0.11.6 → 0.13.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.
Files changed (170) hide show
  1. package/dist/acls.d.ts +31 -7
  2. package/dist/acls.d.ts.map +1 -1
  3. package/dist/acls.js +105 -17
  4. package/dist/acls.js.map +1 -1
  5. package/dist/agent-file.js +1 -1
  6. package/dist/agent-file.js.map +1 -1
  7. package/dist/auth-provider.d.ts +90 -10
  8. package/dist/auth-provider.d.ts.map +1 -1
  9. package/dist/auth-provider.js.map +1 -1
  10. package/dist/backup-config.d.ts +55 -0
  11. package/dist/backup-config.d.ts.map +1 -0
  12. package/dist/backup-config.js +243 -0
  13. package/dist/backup-config.js.map +1 -0
  14. package/dist/backup.d.ts +86 -0
  15. package/dist/backup.d.ts.map +1 -0
  16. package/dist/backup.js +497 -0
  17. package/dist/backup.js.map +1 -0
  18. package/dist/broker-floor.d.ts +34 -0
  19. package/dist/broker-floor.d.ts.map +1 -0
  20. package/dist/broker-floor.js +39 -0
  21. package/dist/broker-floor.js.map +1 -0
  22. package/dist/canonical.d.ts +24 -0
  23. package/dist/canonical.d.ts.map +1 -0
  24. package/dist/canonical.js +159 -0
  25. package/dist/canonical.js.map +1 -0
  26. package/dist/channels.d.ts.map +1 -1
  27. package/dist/channels.js +8 -1
  28. package/dist/channels.js.map +1 -1
  29. package/dist/connector.d.ts +4 -0
  30. package/dist/connector.d.ts.map +1 -1
  31. package/dist/endpoint-action.d.ts +424 -0
  32. package/dist/endpoint-action.d.ts.map +1 -0
  33. package/dist/endpoint-action.js +910 -0
  34. package/dist/endpoint-action.js.map +1 -0
  35. package/dist/endpoint-binding.d.ts +344 -0
  36. package/dist/endpoint-binding.d.ts.map +1 -0
  37. package/dist/endpoint-binding.js +753 -0
  38. package/dist/endpoint-binding.js.map +1 -0
  39. package/dist/endpoint-checkpoint.d.ts +292 -0
  40. package/dist/endpoint-checkpoint.d.ts.map +1 -0
  41. package/dist/endpoint-checkpoint.js +818 -0
  42. package/dist/endpoint-checkpoint.js.map +1 -0
  43. package/dist/endpoint-cluster.d.ts +95 -0
  44. package/dist/endpoint-cluster.d.ts.map +1 -0
  45. package/dist/endpoint-cluster.js +194 -0
  46. package/dist/endpoint-cluster.js.map +1 -0
  47. package/dist/endpoint-contract-store.d.ts +90 -0
  48. package/dist/endpoint-contract-store.d.ts.map +1 -0
  49. package/dist/endpoint-contract-store.js +348 -0
  50. package/dist/endpoint-contract-store.js.map +1 -0
  51. package/dist/endpoint-envelope.d.ts +165 -0
  52. package/dist/endpoint-envelope.d.ts.map +1 -0
  53. package/dist/endpoint-envelope.js +346 -0
  54. package/dist/endpoint-envelope.js.map +1 -0
  55. package/dist/endpoint-grants.d.ts +123 -0
  56. package/dist/endpoint-grants.d.ts.map +1 -0
  57. package/dist/endpoint-grants.js +215 -0
  58. package/dist/endpoint-grants.js.map +1 -0
  59. package/dist/endpoint-guard.d.ts +222 -0
  60. package/dist/endpoint-guard.d.ts.map +1 -0
  61. package/dist/endpoint-guard.js +468 -0
  62. package/dist/endpoint-guard.js.map +1 -0
  63. package/dist/endpoint-handle.d.ts +176 -0
  64. package/dist/endpoint-handle.d.ts.map +1 -0
  65. package/dist/endpoint-handle.js +608 -0
  66. package/dist/endpoint-handle.js.map +1 -0
  67. package/dist/endpoint-journal.d.ts +225 -0
  68. package/dist/endpoint-journal.d.ts.map +1 -0
  69. package/dist/endpoint-journal.js +431 -0
  70. package/dist/endpoint-journal.js.map +1 -0
  71. package/dist/endpoint-receipt.d.ts +225 -0
  72. package/dist/endpoint-receipt.d.ts.map +1 -0
  73. package/dist/endpoint-receipt.js +417 -0
  74. package/dist/endpoint-receipt.js.map +1 -0
  75. package/dist/endpoint-records.d.ts +243 -0
  76. package/dist/endpoint-records.d.ts.map +1 -0
  77. package/dist/endpoint-records.js +591 -0
  78. package/dist/endpoint-records.js.map +1 -0
  79. package/dist/endpoint-serve.d.ts +176 -0
  80. package/dist/endpoint-serve.d.ts.map +1 -0
  81. package/dist/endpoint-serve.js +446 -0
  82. package/dist/endpoint-serve.js.map +1 -0
  83. package/dist/endpoint-service.d.ts +518 -0
  84. package/dist/endpoint-service.d.ts.map +1 -0
  85. package/dist/endpoint-service.js +973 -0
  86. package/dist/endpoint-service.js.map +1 -0
  87. package/dist/endpoint-session.d.ts +398 -0
  88. package/dist/endpoint-session.d.ts.map +1 -0
  89. package/dist/endpoint-session.js +834 -0
  90. package/dist/endpoint-session.js.map +1 -0
  91. package/dist/endpoint-signing.d.ts +105 -0
  92. package/dist/endpoint-signing.d.ts.map +1 -0
  93. package/dist/endpoint-signing.js +190 -0
  94. package/dist/endpoint-signing.js.map +1 -0
  95. package/dist/endpoint-subjects.d.ts +222 -0
  96. package/dist/endpoint-subjects.d.ts.map +1 -0
  97. package/dist/endpoint-subjects.js +381 -0
  98. package/dist/endpoint-subjects.js.map +1 -0
  99. package/dist/endpoint-supervisor.d.ts +19 -0
  100. package/dist/endpoint-supervisor.d.ts.map +1 -0
  101. package/dist/endpoint-supervisor.js +12 -0
  102. package/dist/endpoint-supervisor.js.map +1 -0
  103. package/dist/endpoint-traits.d.ts +225 -0
  104. package/dist/endpoint-traits.d.ts.map +1 -0
  105. package/dist/endpoint-traits.js +469 -0
  106. package/dist/endpoint-traits.js.map +1 -0
  107. package/dist/endpoint-verbs.d.ts +277 -0
  108. package/dist/endpoint-verbs.d.ts.map +1 -0
  109. package/dist/endpoint-verbs.js +576 -0
  110. package/dist/endpoint-verbs.js.map +1 -0
  111. package/dist/endpoint-virtual.d.ts +308 -0
  112. package/dist/endpoint-virtual.d.ts.map +1 -0
  113. package/dist/endpoint-virtual.js +440 -0
  114. package/dist/endpoint-virtual.js.map +1 -0
  115. package/dist/endpoint-work.d.ts +304 -0
  116. package/dist/endpoint-work.d.ts.map +1 -0
  117. package/dist/endpoint-work.js +728 -0
  118. package/dist/endpoint-work.js.map +1 -0
  119. package/dist/endpoint.d.ts +66 -22
  120. package/dist/endpoint.d.ts.map +1 -1
  121. package/dist/endpoint.js +289 -89
  122. package/dist/endpoint.js.map +1 -1
  123. package/dist/evict.d.ts +95 -32
  124. package/dist/evict.d.ts.map +1 -1
  125. package/dist/evict.js +193 -3
  126. package/dist/evict.js.map +1 -1
  127. package/dist/index.d.ts +29 -0
  128. package/dist/index.d.ts.map +1 -1
  129. package/dist/index.js +29 -0
  130. package/dist/index.js.map +1 -1
  131. package/dist/lease.d.ts +4 -0
  132. package/dist/lease.d.ts.map +1 -1
  133. package/dist/lease.js.map +1 -1
  134. package/dist/members.d.ts +4 -4
  135. package/dist/members.d.ts.map +1 -1
  136. package/dist/members.js +12 -12
  137. package/dist/members.js.map +1 -1
  138. package/dist/provision.d.ts +78 -20
  139. package/dist/provision.d.ts.map +1 -1
  140. package/dist/provision.js +214 -37
  141. package/dist/provision.js.map +1 -1
  142. package/dist/runtime.d.ts +3 -0
  143. package/dist/runtime.d.ts.map +1 -1
  144. package/dist/safe-pattern.d.ts +33 -0
  145. package/dist/safe-pattern.d.ts.map +1 -0
  146. package/dist/safe-pattern.js +486 -0
  147. package/dist/safe-pattern.js.map +1 -0
  148. package/dist/schema-profile.d.ts +93 -0
  149. package/dist/schema-profile.d.ts.map +1 -0
  150. package/dist/schema-profile.js +284 -0
  151. package/dist/schema-profile.js.map +1 -0
  152. package/dist/secret-store.d.ts +46 -0
  153. package/dist/secret-store.d.ts.map +1 -0
  154. package/dist/secret-store.js +2 -0
  155. package/dist/secret-store.js.map +1 -0
  156. package/dist/space-auth.d.ts +9 -0
  157. package/dist/space-auth.d.ts.map +1 -0
  158. package/dist/space-auth.js +126 -0
  159. package/dist/space-auth.js.map +1 -0
  160. package/dist/streams.d.ts +14 -10
  161. package/dist/streams.d.ts.map +1 -1
  162. package/dist/streams.js +47 -76
  163. package/dist/streams.js.map +1 -1
  164. package/dist/subjects.d.ts +100 -40
  165. package/dist/subjects.d.ts.map +1 -1
  166. package/dist/subjects.js +136 -60
  167. package/dist/subjects.js.map +1 -1
  168. package/dist/types.d.ts +9 -0
  169. package/dist/types.d.ts.map +1 -1
  170. package/package.json +6 -2
@@ -0,0 +1,753 @@
1
+ /**
2
+ * v0.4 NATS + JetStream binding (SPEC §13.12) — the per-space control-surface resources and
3
+ * the §13.9 consumer-name grammar with the infrastructure consumer configs over them.
4
+ *
5
+ * Streams are space infrastructure: `STREAM.CREATE` is denied to agents, so
6
+ * {@link createEndpointStreams} runs once at space setup (like `createSpaceStreams`). It is the
7
+ * single source of the resource definitions — the table in §13.12 — so setup and every consumer
8
+ * of a stream name can never diverge. Consumer CONFIGS here are equally single-source: each is
9
+ * created by exactly one trusted principal (provisioner or the owning infra principal) and the
10
+ * §13.9 grant rows are generated against these same names and filters.
11
+ */
12
+ import { AckPolicy, DeliverPolicy, RetentionPolicy, StorageType, } from "@nats-io/jetstream";
13
+ import { nanos } from "@nats-io/transport-node";
14
+ import { spacePrefix, token, assertInboxConnId } from "./subjects.js";
15
+ import { endpointToken, assertIdToken, assertGrantId, assertPoolToken, assertLifecycleToken, callerTokens, } from "./endpoint-subjects.js";
16
+ import { AUTHORITY_KIND_DEFS, callerReadableRecordKind } from "./endpoint-records.js";
17
+ import { epjStreamName, epfStreamName, canonDurable } from "./endpoint-journal.js";
18
+ import { recordsBucket } from "./endpoint-records.js";
19
+ // Re-exported so the binding module presents the complete §13.12 name table even though the
20
+ // journal/records helpers own the definitions their own logic is written against.
21
+ export { epjStreamName, epfStreamName, canonDurable, recordsBucket };
22
+ /** §13.12 stream names for the remaining per-space control-surface streams. */
23
+ export function epeStreamName(space) { return `EPE_${token(space)}`; }
24
+ export function eptReqStreamName(space) { return `EPT_REQ_${token(space)}`; }
25
+ export function eptStreamName(space) { return `EPT_${token(space)}`; }
26
+ export function eprStreamName(space) { return `EPR_${token(space)}`; }
27
+ export function epwStreamName(space) { return `EPW_${token(space)}`; }
28
+ export function epcStreamName(space) { return `EPC_${token(space)}`; }
29
+ /** The CLOSED set of streams a §13.1 retirement may record a frontier cutoff over: exactly the
30
+ * per-space streams that carry a retired lifecycle's durable data a later durable reader can
31
+ * replay (facts EPF, work EPW, events EPE, and the records KV). A retirement intent's
32
+ * `frontierStreams` must be a subset of this set; it is NOT a caller-selectable arbitrary stream
33
+ * list. This is the ONE source consumed by both the intent validation and the barrier's
34
+ * `STREAM.INFO` grant, so the frontier authority and the frontier-writable set never drift
35
+ * (nats-server subject ACLs cannot scope INFO to an intent-selected name). The auth store is
36
+ * deliberately absent: it is the control plane, never a lifecycle-data frontier. */
37
+ export function retirementFrontierStreams(space) {
38
+ return [epfStreamName(space), epwStreamName(space), epeStreamName(space), recordsKvStreamName(space)];
39
+ }
40
+ /** The per-space auth store (§13.12): credential ledger + issuance/source gates + session
41
+ * ledger. Trusted auth path ONLY — no agent/endpoint/observer/admin/host profile holds any
42
+ * grant — and `allow_direct=false`: every fence on it is a leader-served revision-pinned CAS,
43
+ * and Direct Get's follower/mirror reads would defeat read-your-writes (§13.1). */
44
+ export function epAuthBucket(space) {
45
+ return `cotal_auth_${token(space)}`;
46
+ }
47
+ // ---- §13.12 retention knobs (documented defaults, overridable per space policy) ----
48
+ /** EPJ duplicate window: the server MINIMUM (100 ms), set explicitly. A `0` is not accepted
49
+ * (it normalizes to the 120 s default), and native dedupe is deliberately NOT relied upon —
50
+ * submitters never set `Nats-Msg-Id`, and a wide window is exactly the cross-caller
51
+ * suppression surface §13.4 refuses — so the window is pinned as small as the server allows. */
52
+ export const EPJ_DUPLICATE_WINDOW_MS = 100;
53
+ /** Default age bound on raw submissions (EPJ). The §13.12 floor is "≥ recovery/redelivery
54
+ * lag" of the canonicalizer; 24 h covers any realistic canonicalizer outage while keeping the
55
+ * untrusted log from growing unbounded. */
56
+ export const EP_SUBMISSION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
57
+ /** Default age bound on events (EPE) — progress/catch-up telemetry, space policy. */
58
+ export const EP_EVENT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
59
+ /** Default age bound on the two writer-ingress streams (EPT_REQ, EPR). The floor is
60
+ * "≥ writer recovery lag"; the same 24 h envelope as EPJ. */
61
+ export const EP_INGRESS_MAX_AGE_MS = 24 * 60 * 60 * 1000;
62
+ /** Default age bound on authoritative schedules + fires (EPT). The floor is
63
+ * "≥ max deadline + margin": a schedule stored longer than this cannot outlive its stream
64
+ * row, so the default admits deadlines up to ~30 days. */
65
+ export const EP_TIMER_MAX_AGE_MS = 31 * 24 * 60 * 60 * 1000;
66
+ /** Delete-marker TTL on the auth store — enables the stream's per-key TTL machinery
67
+ * (`allow_msg_ttl`), which `cred.`/`bysrc.` rows use (per-key TTL ≤ credential TTL). The
68
+ * bucket itself carries NO age retention: `gate.`/`srcgate.`/`session.` authority keys
69
+ * persist until explicitly terminal (§13.12). */
70
+ export const EP_AUTH_MARKER_TTL_MS = 60 * 60 * 1000;
71
+ /**
72
+ * Create (idempotently) the §13.12 per-space control-surface resources: the seven JetStream
73
+ * streams, the work-pool WorkQueue, and the two KV buckets. Privileged — runs at space setup.
74
+ * `jsm.streams.add`/`kvm.create` are idempotent for an identical config and FAIL LOUD on a
75
+ * config delta, which is wanted: a drifted resource is an operator error, never silently adopted.
76
+ *
77
+ * Sessions (`eps`) are deliberately absent: core-only, never captured (§13.12).
78
+ */
79
+ export async function createEndpointStreams(jsm, kvm, space, opts = {}) {
80
+ const p = spacePrefix(space);
81
+ // EPJ — raw submissions, untrusted, at-least-once. NO allow_direct (nothing reads it but the
82
+ // canonicalizer's durable and harness MSG.GET); duplicate window pinned to the server minimum.
83
+ await jsm.streams.add({
84
+ name: epjStreamName(space),
85
+ subjects: [`${p}.epj.>`],
86
+ retention: RetentionPolicy.Limits,
87
+ storage: StorageType.File,
88
+ max_age: nanos(opts.submissionMaxAgeMs ?? EP_SUBMISSION_MAX_AGE_MS),
89
+ duplicate_window: nanos(EPJ_DUPLICATE_WINDOW_MS),
90
+ });
91
+ // EPF — canonical facts; acceptance is create-only CAS; allow_direct serves the §13.9
92
+ // last-by-subject fact reads (trusted principals only; callers read via the mediator).
93
+ await jsm.streams.add({
94
+ name: epfStreamName(space),
95
+ subjects: [`${p}.epf.>`],
96
+ retention: RetentionPolicy.Limits,
97
+ storage: StorageType.File,
98
+ allow_direct: true,
99
+ ...(opts.factMaxAgeMs ? { max_age: nanos(opts.factMaxAgeMs) } : {}),
100
+ });
101
+ // EPE — events/progress.
102
+ await jsm.streams.add({
103
+ name: epeStreamName(space),
104
+ subjects: [`${p}.epe.>`],
105
+ retention: RetentionPolicy.Limits,
106
+ storage: StorageType.File,
107
+ max_age: nanos(opts.eventMaxAgeMs ?? EP_EVENT_MAX_AGE_MS),
108
+ });
109
+ // EPT_REQ — schedule REQUESTS. Message schedules DISABLED (the default; asserted by the
110
+ // smoke): a client-set scheduling header here cannot arm anything, which is what closes the
111
+ // ADR-51 confused deputy — only the timer writer's `.armed` publish (on EPT) schedules.
112
+ await jsm.streams.add({
113
+ name: eptReqStreamName(space),
114
+ subjects: [`${p}.ept.*.*.*.*.schedule`],
115
+ retention: RetentionPolicy.Limits,
116
+ storage: StorageType.File,
117
+ max_age: nanos(opts.ingressMaxAgeMs ?? EP_INGRESS_MAX_AGE_MS),
118
+ });
119
+ // EPR — record-write ingress, consumed only by the per-kind record writers.
120
+ await jsm.streams.add({
121
+ name: eprStreamName(space),
122
+ subjects: [`${p}.epr.>`],
123
+ retention: RetentionPolicy.Limits,
124
+ storage: StorageType.File,
125
+ max_age: nanos(opts.ingressMaxAgeMs ?? EP_INGRESS_MAX_AGE_MS),
126
+ });
127
+ // EPT — authoritative schedules (.armed) + fires (.fire). AllowMsgSchedules; each schedule
128
+ // targets its sibling `.fire` (ADR-51 forbids target = publish subject), and both patterns
129
+ // live on THIS stream because ADR-51 requires the target be captured by the same stream.
130
+ await jsm.streams.add({
131
+ name: eptStreamName(space),
132
+ subjects: [`${p}.ept.*.*.*.*.armed`, `${p}.ept.*.*.*.*.fire`],
133
+ retention: RetentionPolicy.Limits,
134
+ storage: StorageType.File,
135
+ allow_msg_schedules: true,
136
+ max_age: nanos(opts.timerMaxAgeMs ?? EP_TIMER_MAX_AGE_MS),
137
+ });
138
+ // EPW — work pools, one item per subject. NO allow_direct: the §13.6 reconciliation probe
139
+ // (an acked item leaves the WorkQueue, an in-flight one remains readable — exactly the
140
+ // predicate) is a FENCING read that gates the re-enqueue decision, so it goes leader-served
141
+ // STREAM.MSG.GET (SPEC 13.6:1797-1799), never a follower-servable Direct Get whose stale miss
142
+ // would re-arm settled work. Nothing else reads EPW: the pool workers drain it via the
143
+ // WorkQueue consumer (CONSUMER.MSG.NEXT), not by subject read.
144
+ await jsm.streams.add({
145
+ name: epwStreamName(space),
146
+ subjects: [`${p}.epw.>`],
147
+ retention: RetentionPolicy.Workqueue,
148
+ storage: StorageType.File,
149
+ allow_direct: false,
150
+ });
151
+ // EPC — content-addressed contract artifacts: one immutable message per digest subject,
152
+ // create-only mediated publication, NO age eviction (artifacts are permanent). allow_direct:
153
+ // the subject-scoped last-by-subject read IS the fetch path. Permanence is BROKER-ENFORCED,
154
+ // not just configured-by-omission: deny_delete/deny_purge reject the message-delete and purge
155
+ // APIs even from a stream-API-holding principal, so a digest subject cannot be emptied and
156
+ // re-created through them. Per §13.12 the flags alone are NOT the whole claim: permanence is
157
+ // their COMBINATION with the retention floor (no age eviction, no teardown), verify-on-read
158
+ // pinning WHAT a subject carries, and stream management held by no profile.
159
+ await jsm.streams.add({
160
+ name: epcStreamName(space),
161
+ subjects: [`${p}.epc.>`],
162
+ retention: RetentionPolicy.Limits,
163
+ storage: StorageType.File,
164
+ allow_direct: true,
165
+ deny_delete: true,
166
+ deny_purge: true,
167
+ });
168
+ await ensureAuthorityStores(jsm, kvm, space);
169
+ }
170
+ /**
171
+ * Ensure the two per-space AUTHORITY stores exist with their normative shape (§13.12):
172
+ *
173
+ * - **Records KV** (`cotal_records_<space>`) — per-key CAS; rows are never deleted.
174
+ * deny_delete/deny_purge close stream-API erasure as defense in depth (a raw KV subject grant
175
+ * can still emit a DEL marker, which every reader treats as corruption); rollups off. Fenced
176
+ * reads stay leader-served STREAM.MSG.GET (§13.9).
177
+ * - **Auth KV** (`cotal_auth_<space>`) — leader-served only (`allow_direct=false`); per-key TTL
178
+ * machinery on (`cred.`/`bysrc.` rows), NO bucket age.
179
+ *
180
+ * Create-or-verify, so it is safe at EVERY authority-daemon boot (not only first setup): a fresh
181
+ * space gets both stores created; an existing store is verified against the exact flags above and
182
+ * a drift FAILS LOUD naming the store — a drifted authority store is an operator error, never
183
+ * silently adopted (§13.12: the flags are load-bearing for deny-new and the barrier CAS fences).
184
+ */
185
+ export async function ensureAuthorityStores(jsm, kvm, space) {
186
+ // CREATE-IF-ABSENT then UNCONDITIONALLY VERIFY: `Kvm.create` opens an existing stream WITHOUT
187
+ // comparing its config (a drifted pre-existing stream is the NORMAL success path, not an error),
188
+ // so the create call proves nothing. Both the just-created and the pre-existing store flow
189
+ // through the SAME final `streams.info` + full normative verify — a drifted authority store is
190
+ // an operator error, never silently adopted (§13.12: the flags + store-binding are load-bearing
191
+ // for deny-new and the barrier CAS fences).
192
+ const recordBucket = recordsBucket(space);
193
+ const recordStream = `KV_${recordBucket}`;
194
+ if (await jsm.streams.info(recordStream).catch(() => undefined) === undefined) {
195
+ await kvm.create(recordBucket, { allow_direct: true });
196
+ const recordConfig = (await jsm.streams.info(recordStream)).config;
197
+ await jsm.streams.update(recordStream, { ...recordConfig, allow_rollup_hdrs: false, deny_delete: true, deny_purge: true });
198
+ }
199
+ const recordCfg = (await jsm.streams.info(recordStream)).config;
200
+ if (recordCfg.allow_direct !== true || recordCfg.allow_rollup_hdrs !== false || recordCfg.deny_delete !== true || recordCfg.deny_purge !== true)
201
+ throw new Error(`the records store ${recordBucket} has a drifted shape (allow_direct=${String(recordCfg.allow_direct)}, allow_rollup_hdrs=${String(recordCfg.allow_rollup_hdrs)}, deny_delete=${String(recordCfg.deny_delete)}, deny_purge=${String(recordCfg.deny_purge)}); an authority store is never silently adopted - reprovision it (SPEC 13.12)`);
202
+ assertAuthorityStoreBinding(recordCfg, recordBucket);
203
+ const authBucket = epAuthBucket(space);
204
+ const authStream = `KV_${authBucket}`;
205
+ if (await jsm.streams.info(authStream).catch(() => undefined) === undefined)
206
+ await kvm.create(authBucket, { allow_direct: false, markerTTL: EP_AUTH_MARKER_TTL_MS });
207
+ const authCfg = (await jsm.streams.info(authStream)).config;
208
+ if (authCfg.allow_direct !== false || authCfg.allow_msg_ttl !== true)
209
+ throw new Error(`the auth store ${authBucket} has a drifted shape (allow_direct=${String(authCfg.allow_direct)}, allow_msg_ttl=${String(authCfg.allow_msg_ttl)}); an authority store is never silently adopted - reprovision it (SPEC 13.12)`);
210
+ assertAuthorityStoreBinding(authCfg, authBucket);
211
+ }
212
+ /** The store-BINDING half of the verify (SPEC 13.12): a stream wearing an authority bucket's name
213
+ * must BE that KV bucket — exactly the one `$KV.<bucket>.>` subject (an extra captured subject
214
+ * would put foreign bodies inside every body-selected MSG.GET grant on the stream) and durable
215
+ * file storage (a memory store forgets fences and revocations on restart). */
216
+ function assertAuthorityStoreBinding(cfg, bucket) {
217
+ const expected = `$KV.${bucket}.>`;
218
+ if (!Array.isArray(cfg.subjects) || cfg.subjects.length !== 1 || cfg.subjects[0] !== expected)
219
+ throw new Error(`the store ${bucket} does not carry exactly the subject ${expected} (got ${JSON.stringify(cfg.subjects)}); a stream that captures anything else is not this KV bucket - reprovision it (SPEC 13.12)`);
220
+ if (cfg.storage !== "file")
221
+ throw new Error(`the store ${bucket} has storage ${JSON.stringify(cfg.storage)}, not file; a non-durable authority store forgets fences and revocations on restart - reprovision it (SPEC 13.12)`);
222
+ }
223
+ // ---- §13.9 consumer-name grammar (normative; dash-form, collision-free by construction) ----
224
+ /** `poolD = pool_<e>_<pool>` — parses uniquely from its LAST `_` because a pool token contains
225
+ * no `_` (`[a-z0-9-]`) while `<e>` may. */
226
+ export function poolDurable(endpoint, pool) {
227
+ return `pool_${endpointToken(endpoint)}_${assertPoolToken(pool)}`;
228
+ }
229
+ /** `timerD = timerw_<space>` — the space's single timer-writer durable. */
230
+ export function timerWriterDurable(space) {
231
+ return `timerw_${token(space)}`;
232
+ }
233
+ /** `recwD-k = recw_<space>-<kind>` — ONE record-writer durable per record kind (§13.9's writer
234
+ * separation). Parses from its LAST `-`? No — from the FIRST `-` after the fixed prefix is
235
+ * ambiguous when the space token contains `-`; the collision-freedom argument is simpler: the
236
+ * durable exists once per (space, kind) pair inside a per-space stream, so only the `<kind>`
237
+ * tail must be unique within one space, and kinds are unique by the registry. The kind token
238
+ * is the `epr` subject's kind token (id grammar, dot-free). */
239
+ export function recordWriterDurable(space, kind) {
240
+ return `recw_${token(space)}-${assertIdToken(kind, "record kind")}`;
241
+ }
242
+ /** `effD = eff_<e>` — the endpoint's ONE shared effects durable (instances pull-compete). */
243
+ export function effectsDurable(endpoint) {
244
+ return `eff_${endpointToken(endpoint)}`;
245
+ }
246
+ /** `decD = dec_<uid>-<e>` — a caller's decision-reader durable (one per journal capability).
247
+ * Parses from its FIRST `-`: `<uid>` is `[a-z0-9]` and contains none. */
248
+ export function decisionReaderDurable(uid, endpoint) {
249
+ return `dec_${assertLifecycleToken(uid)}-${endpointToken(endpoint)}`;
250
+ }
251
+ /** `goalD = goal_<uid>-<e>` — a caller's goal-result durable (one per action capability). */
252
+ export function goalReaderDurable(uid, endpoint) {
253
+ return `goal_${assertLifecycleToken(uid)}-${endpointToken(endpoint)}`;
254
+ }
255
+ /** `eveD = eve_<uid>-<e>-<gid>-<n>` — one per granted event subtree: `<gid>` is the mint-time
256
+ * grant id, `<n>` the subtree's zero-based index within THAT grant. INJECTIVE by construction:
257
+ * `<uid>` is `-`-free (leading), `<n>` is digits (trailing), `<gid>` is separator-free
258
+ * (`assertGrantId`), so `<e>` is the ONLY `-`-bearing component and its extent is unambiguous
259
+ * (parse `<n>` and `<gid>` off the right, `<uid>` off the left, `<e>` is what remains). Without
260
+ * the separator-free `<gid>` the two soft components `<e>` and `<gid>` would collide
261
+ * (`eve_<uid>-a-b-c-0` = endpoint `a-b`/gid `c` OR endpoint `a`/gid `b-c`, §13.9). */
262
+ export function eventReaderDurable(uid, endpoint, grantId, n) {
263
+ if (!Number.isInteger(n) || n < 0)
264
+ throw new Error(`event-reader subtree index must be a non-negative integer, got ${n}`);
265
+ return `eve_${assertLifecycleToken(uid)}-${endpointToken(endpoint)}-${assertGrantId(grantId)}-${n}`;
266
+ }
267
+ /** `recD = rec_<uid>-<gid>-<n>` — one per granted record subtree (grammar as {@link eventReaderDurable};
268
+ * `<gid>` separator-free, `<uid>` `-`-free, `<n>` digits, so the single soft component is bounded). */
269
+ export function recordReaderDurable(uid, grantId, n) {
270
+ if (!Number.isInteger(n) || n < 0)
271
+ throw new Error(`record-reader subtree index must be a non-negative integer, got ${n}`);
272
+ return `rec_${assertLifecycleToken(uid)}-${assertGrantId(grantId)}-${n}`;
273
+ }
274
+ const FAMILY = new WeakMap();
275
+ function family(stream, cfg) {
276
+ FAMILY.set(cfg, { stream, durable: cfg.durable_name, filter: cfg.filter_subject });
277
+ return cfg;
278
+ }
279
+ function assertFamilyPair(stream, cfg, what) {
280
+ const bond = FAMILY.get(cfg);
281
+ if (bond === undefined)
282
+ throw new Error(`${what} requires a consumer config minted by a §13.9 family builder, not a raw config (durable ${JSON.stringify(cfg.durable_name ?? "")})`);
283
+ if (bond.stream !== stream)
284
+ throw new Error(`${what}: durable ${JSON.stringify(cfg.durable_name ?? "")} belongs to stream ${JSON.stringify(bond.stream)}, not ${JSON.stringify(stream)} (§13.9: no cross-family pairing)`);
285
+ if (cfg.durable_name !== bond.durable || cfg.filter_subject !== bond.filter)
286
+ throw new Error(`${what}: the config's durable/filter diverged from the tuple its family builder minted (minted ${JSON.stringify(bond.durable)} on ${JSON.stringify(bond.filter)}, now ${JSON.stringify(cfg.durable_name ?? "")} on ${JSON.stringify(cfg.filter_subject ?? "")}); a mutated config is not §13.9 authority`);
287
+ // §13.9 pre-created consumers are PULL-only: a create's delivery target is body-set and
288
+ // unconfined, so a post-mint deliver_subject would fan the stream out to an arbitrary subject.
289
+ if (cfg.deliver_subject !== undefined)
290
+ throw new Error(`${what}: the config carries a deliver_subject; §13.9 family consumers are PULL-only, a push delivery target is unconfined`);
291
+ }
292
+ /** The canonicalizer's durable on EPJ (`canon_<e>`): every raw submission to one endpoint.
293
+ * Acks only after the durable decision (and, for pool routes, after the enqueue), §13.4. */
294
+ export function canonConsumerConfig(space, endpoint, opts = {}) {
295
+ return family(epjStreamName(space), {
296
+ durable_name: canonDurable(endpoint),
297
+ filter_subject: `${spacePrefix(space)}.epj.${endpointToken(endpoint)}.>`,
298
+ ack_policy: AckPolicy.Explicit,
299
+ ack_wait: nanos(opts.ackWaitMs ?? 60_000),
300
+ deliver_policy: DeliverPolicy.All,
301
+ max_ack_pending: opts.maxAckPending ?? 1000,
302
+ });
303
+ }
304
+ /** The endpoint's ONE shared effects durable on EPF (`eff_<e>`, filter `epf.<e>.dec.>`):
305
+ * instances pull-compete so each accepted decision effects exactly once live (at-least-once);
306
+ * ack ONLY after the effect is durably recorded (§13.9 ack barrier). */
307
+ export function effectsConsumerConfig(space, endpoint, opts = {}) {
308
+ return family(epfStreamName(space), {
309
+ durable_name: effectsDurable(endpoint),
310
+ filter_subject: `${spacePrefix(space)}.epf.${endpointToken(endpoint)}.dec.>`,
311
+ ack_policy: AckPolicy.Explicit,
312
+ ack_wait: nanos(opts.ackWaitMs ?? 60_000),
313
+ deliver_policy: DeliverPolicy.All,
314
+ max_ack_pending: opts.maxAckPending ?? 1000,
315
+ });
316
+ }
317
+ /** A record kind's writer durable on EPR (`recw_<space>-<kind>`) — one principal and one
318
+ * consumer PER KIND, never a single writer draining every kind (§13.9). The filter is DERIVED
319
+ * from the kind's qualifier arity: a NATS `>` matches one-or-more tokens (it does NOT match a
320
+ * bare parent), so a kind with ≥1 qualifier filters `…<kind>.>` while a ZERO-qualifier kind
321
+ * (a single space-wide record) filters exactly `…<kind>` — else the writer would miss every
322
+ * write for that registered grammar. Takes the RecordKindDef so the arity cannot be guessed. */
323
+ export function recordWriterConsumerConfig(space, def, opts = {}) {
324
+ const kind = assertIdToken(def.kind, "record kind");
325
+ const tail = def.qualifiers.length > 0 ? `.${kind}.>` : `.${kind}`;
326
+ return family(eprStreamName(space), {
327
+ durable_name: recordWriterDurable(space, def.kind),
328
+ filter_subject: `${spacePrefix(space)}.epr.*.*.*${tail}`,
329
+ ack_policy: AckPolicy.Explicit,
330
+ ack_wait: nanos(opts.ackWaitMs ?? 60_000),
331
+ deliver_policy: DeliverPolicy.All,
332
+ });
333
+ }
334
+ /** The timer writer's durable on EPT_REQ (`timerw_<space>`, full-tail filter on `.schedule`).
335
+ * The writer validates each request (rejecting any client scheduling header and any
336
+ * stale-generation request) before publishing the authoritative `.armed` on EPT. */
337
+ export function timerWriterConsumerConfig(space, opts = {}) {
338
+ return family(eptReqStreamName(space), {
339
+ durable_name: timerWriterDurable(space),
340
+ filter_subject: `${spacePrefix(space)}.ept.*.*.*.*.schedule`,
341
+ ack_policy: AckPolicy.Explicit,
342
+ ack_wait: nanos(opts.ackWaitMs ?? 60_000),
343
+ deliver_policy: DeliverPolicy.All,
344
+ });
345
+ }
346
+ /** A pool's durable on the EPW WorkQueue (`pool_<e>_<pool>`, exact filter
347
+ * `epw.<e>.<pool>.>`) — provisioner-pre-created; the owning endpoint binds it (§13.5). Exact
348
+ * per-pool filters keep WorkQueue consumers non-overlapping by construction. `ack_wait` is
349
+ * ONLY the broker's redelivery-to-owner timer; the authoritative lease deadline lives in the
350
+ * owner's lease record (§13.12). */
351
+ export function poolConsumerConfig(space, endpoint, pool, opts = {}) {
352
+ return family(epwStreamName(space), {
353
+ durable_name: poolDurable(endpoint, pool),
354
+ filter_subject: `${spacePrefix(space)}.epw.${endpointToken(endpoint)}.${assertPoolToken(pool)}.>`,
355
+ ack_policy: AckPolicy.Explicit,
356
+ ack_wait: nanos(opts.ackWaitMs ?? 60_000),
357
+ // UNLIMITED delivery, pinned EXPLICITLY (not left to the server default): the §13.6 virtual
358
+ // admission fence counts pool occupancy as num_pending + num_ack_pending, and a message that
359
+ // exhausts a FINITE max_deliver stays stored but leaves both counters, silently falsifying
360
+ // the count. MaxDeliver is editable post-create, so the occupancy reader ALSO fails closed
361
+ // on any reported value other than -1 (readPoolOccupancy); this pin makes intent explicit
362
+ // and the drift check enforceable.
363
+ max_deliver: -1,
364
+ });
365
+ }
366
+ /** A caller's decision-reader durable on EPF (`dec_<uid>-<e>`, exact filter on the caller's
367
+ * own `dec` triple) — pre-created PULL by the provisioner at capability mint; owned and bound
368
+ * by the READ MEDIATOR, never the caller (§13.9 mediated reads). */
369
+ export function decisionReaderConfig(space, endpoint, caller, opts = {}) {
370
+ return family(epfStreamName(space), {
371
+ durable_name: decisionReaderDurable(caller.uid, endpoint),
372
+ filter_subject: `${spacePrefix(space)}.epf.${endpointToken(endpoint)}.dec.${callerTokens(caller).join(".")}.>`,
373
+ ack_policy: AckPolicy.Explicit,
374
+ ack_wait: nanos(opts.ackWaitMs ?? 60_000),
375
+ deliver_policy: DeliverPolicy.All,
376
+ });
377
+ }
378
+ /** A caller's goal-result durable on EPF (`goal_<uid>-<e>`; grammar as {@link decisionReaderConfig}). */
379
+ export function goalReaderConfig(space, endpoint, caller, opts = {}) {
380
+ return family(epfStreamName(space), {
381
+ durable_name: goalReaderDurable(caller.uid, endpoint),
382
+ filter_subject: `${spacePrefix(space)}.epf.${endpointToken(endpoint)}.goal.${callerTokens(caller).join(".")}.>`,
383
+ ack_policy: AckPolicy.Explicit,
384
+ ack_wait: nanos(opts.ackWaitMs ?? 60_000),
385
+ deliver_policy: DeliverPolicy.All,
386
+ });
387
+ }
388
+ /** Assert a granted subtree filter is a full tail under `prefix` (§13.9 "JetStream API tails
389
+ * are always spelled in FULL"): a relative tail matches nothing, and a bare `prefix` or one
390
+ * climbing outside it would widen the reader past its capability. Tokens are literal or full
391
+ * `*` wildcards (whole-token `*` is NORMATIVE in granted subtrees — the per-goal event row
392
+ * wildcards the instanceId/epoch positions, §13.9; mint-time literalness constrains the
393
+ * DURABLE name, not the filter interior), with at most ONE trailing `.>` and never `>` alone
394
+ * (a whole-plane read is a trusted-reader grant family, not a caller capability). Returns the
395
+ * tail tokens (after `prefix.`) for provenance checks. */
396
+ function assertFullTail(filter, prefix, what) {
397
+ if (!filter.startsWith(`${prefix}.`))
398
+ throw new Error(`${what} filter ${JSON.stringify(filter)} must be a full tail under ${JSON.stringify(prefix)} (§13.9)`);
399
+ const toks = filter.slice(prefix.length + 1).split(".");
400
+ toks.forEach((t, i) => {
401
+ if (t === "*")
402
+ return;
403
+ if (t === ">" && i === toks.length - 1 && i > 0)
404
+ return; // one trailing subtree wildcard, never the whole tail
405
+ if (t.length === 0 || /[*>\s]/.test(t))
406
+ throw new Error(`${what} filter ${JSON.stringify(filter)} token ${JSON.stringify(t)} is not a literal token, a full "*" token, or one trailing ">" (§13.9)`);
407
+ });
408
+ return toks;
409
+ }
410
+ /** `eveD = eve_<uid>-<e>-<gid>-<n>` — one per GRANTED event subtree (§13.9): a PULL durable the
411
+ * provisioner pre-creates with the capability's EXACT full-tail event filter, bound by the read
412
+ * mediator (never the caller). `subtree` is the granted `cotal.<space>.epe.…` tail verbatim
413
+ * (`<n>` is its zero-based index within the grant, sorted at mint). Live event progress is the
414
+ * caller's own core subscription; this durable is the mediator's catch-up reader. */
415
+ export function eventReaderConfig(space, args, opts = {}) {
416
+ const tail = assertFullTail(args.subtree, `${spacePrefix(space)}.epe`, "event-reader subtree");
417
+ // Durable and filter must carry ONE provenance: the durable's `<e>` names the endpoint the
418
+ // grant was minted for, so a subtree addressing a DIFFERENT endpoint's events would let the
419
+ // attributed durable read outside its mint scope.
420
+ if (tail[0] !== endpointToken(args.endpoint))
421
+ throw new Error(`event-reader subtree ${JSON.stringify(args.subtree)} names endpoint token ${JSON.stringify(tail[0])} but the durable is minted for ${JSON.stringify(endpointToken(args.endpoint))} (§13.9: durable and filter provenance must agree)`);
422
+ return family(epeStreamName(space), {
423
+ durable_name: eventReaderDurable(args.uid, args.endpoint, args.grantId, args.index),
424
+ filter_subject: args.subtree,
425
+ ack_policy: AckPolicy.Explicit,
426
+ ack_wait: nanos(opts.ackWaitMs ?? 60_000),
427
+ deliver_policy: DeliverPolicy.All,
428
+ });
429
+ }
430
+ /** PRIVATE frozen `kind → authority-head arity` snapshot, built ONCE at module load from the
431
+ * canonical (runtime-frozen) {@link AUTHORITY_KIND_DEFS}. The reader seam consults THIS map,
432
+ * never the live export, on every call: the public collection is frozen for contract honesty,
433
+ * and the seam's guard additionally survives even a hypothetical defeat of that freeze (the
434
+ * panel's identity-vs-integrity closure: single-source provenance is not post-construction
435
+ * integrity; the seam reads a private immutable copy). */
436
+ const AUTHORITY_HEAD_ARITY = new Map(AUTHORITY_KIND_DEFS.map((d) => [d.kind, 1 + d.qualifiers.length]));
437
+ /** `recD = rec_<uid>-<gid>-<n>` — one per GRANTED record subtree (§13.9): a PULL durable over the
438
+ * records KV stream (`KV_cotal_records_<space>`), pre-created by the provisioner with the
439
+ * capability's EXACT full `$KV.cotal_records_<space>.…` subtree tail, bound by the read
440
+ * mediator. `<n>` is the subtree's zero-based index within the grant. */
441
+ export function recordReaderConfig(space, args, opts = {}) {
442
+ const tail = assertFullTail(args.subtree, `$KV.${recordsBucket(space)}`, "record-reader subtree");
443
+ const kind = tail[0];
444
+ // The grant family is a per-kind subtree: the kind token pins it. A `*` kind would read
445
+ // across every registered kind — a trusted-reader grant family, not a caller capability.
446
+ if (kind === "*")
447
+ throw new Error(`record-reader subtree ${JSON.stringify(args.subtree)} must pin its record kind (a cross-kind read is not a caller capability, §13.9)`);
448
+ // ENFORCED partition, ALLOWLIST not deny-list (panel + freelance a559d9c re-verify, #8274): the
449
+ // kind MUST be a registered CALLER-readable record kind. This refuses every AUTHORITY-CONTROL
450
+ // kind (oblig, the sealed records scanner's EXCLUSIVE domain, plus uid/govern/policy/frontier)
451
+ // AND any UNREGISTERED kind, both of which a reader durable would durably EXPORT past revoke. It
452
+ // consumes the same canonical {@link AUTHORITY_KIND_DEFS} the registry is built from, so a new
453
+ // authority kind is excluded by construction: no parallel list to drift. The two refusals are
454
+ // DISTINCT messages (the ux review): an authority kind is forbidden by design with no recourse,
455
+ // while an unregistered kind is usually a typo or a missing registerRecordKind and the caller
456
+ // needs that next step, not the forbidden-kinds list.
457
+ if (!callerReadableRecordKind(kind)) {
458
+ if (AUTHORITY_HEAD_ARITY.has(kind)) {
459
+ const authorityKinds = AUTHORITY_KIND_DEFS.filter((d) => !callerReadableRecordKind(d.kind)).map((d) => d.kind).join("/");
460
+ throw new Error(`record-reader subtree ${JSON.stringify(args.subtree)} targets the authority-control kind ${JSON.stringify(kind)}; authority-control kinds (${authorityKinds}) are never caller reader capabilities by design: their keys are the auth process's own authority state, enumerated only by its sealed scanner (§13.9, nats-server#8274)`);
461
+ }
462
+ throw new Error(`record-reader subtree ${JSON.stringify(args.subtree)} targets ${JSON.stringify(kind)}, which is not a registered record kind; check the kind for a typo, or register it (registerRecordKind, §13.7) before granting a reader over it`);
463
+ }
464
+ // DUAL-token head-disjointness: a kind that is caller-readable AND also carries an authority head
465
+ // (only `lifecycle` today: its atomic HEAD `lifecycle.<owner>.<actor>` is the authority mapping,
466
+ // while `lifecycle.<owner>.<actor>.<uid>.{spec,status}` is the caller audit detail). A reader
467
+ // admits ONLY filters STRICTLY DEEPER than the head (§13.9): never one that can match the atomic
468
+ // head key itself (that key IS the authority mapping), and never a fully-concrete filter
469
+ // SHALLOWER than the head, which can match no record at all (the head is exactly head-arity
470
+ // tokens and the audit detail is deeper), so a dead reader grant refuses loud at mint time.
471
+ // The arity comes from the PRIVATE module-load snapshot, never the live export.
472
+ const headLen = AUTHORITY_HEAD_ARITY.get(kind);
473
+ if (headLen !== undefined) {
474
+ const endsWild = tail[tail.length - 1] === ">";
475
+ const concreteLen = endsWild ? tail.length - 1 : tail.length;
476
+ // A trailing `>` matches subjects of length >= concreteLen+1, so it can match the headLen-token
477
+ // head iff concreteLen < headLen; a fully-concrete filter matches iff its length equals headLen.
478
+ const canMatchHead = endsWild ? concreteLen < headLen : tail.length === headLen;
479
+ if (canMatchHead)
480
+ throw new Error(`record-reader subtree ${JSON.stringify(args.subtree)} can match the authority HEAD key ${JSON.stringify(kind)}.<${headLen - 1} token(s)>; a caller reader may read only the deeper per-UID audit detail, never the ${JSON.stringify(kind)} authority head (§13.9, nats-server#8274)`);
481
+ if (!endsWild && tail.length < headLen)
482
+ throw new Error(`record-reader subtree ${JSON.stringify(args.subtree)} is SHALLOWER than the ${JSON.stringify(kind)} authority head (${headLen} tokens); it can match no record (the caller-readable audit detail is strictly deeper than the head), so the grant would be dead; deepen the filter to the per-UID audit subtree (§13.9)`);
483
+ }
484
+ return family(recordsKvStreamName(space), {
485
+ durable_name: recordReaderDurable(args.uid, args.grantId, args.index),
486
+ filter_subject: args.subtree,
487
+ ack_policy: AckPolicy.Explicit,
488
+ ack_wait: nanos(opts.ackWaitMs ?? 60_000),
489
+ deliver_policy: DeliverPolicy.All,
490
+ });
491
+ }
492
+ /** The backing JetStream STREAM of the records KV (its grant rows key on `KV_<bucket>`, §13.9). */
493
+ export function recordsKvStreamName(space) { return `KV_${recordsBucket(space)}`; }
494
+ // ---- §13.9 JetStream API grant rows (the single source: derived from the SAME stream + config) ----
495
+ // permissionsFor folds these into a profile's `pub.allow`. Every CONSUMER.CREATE row pins the
496
+ // EXACT full-tail filter from the consumer config, so a holder can only create the consumer the
497
+ // matrix names, never a body-filter-selectable one; a bind-only holder gets INFO/MSG.NEXT/ACK
498
+ // with NO create and NO delete. The rows and the consumer configs come from one place here, so
499
+ // "the grant and the consumer cannot diverge" is structural, not a convention.
500
+ const JSAPI = "$JS.API";
501
+ /** A grant NAME component (stream or durable) occupies ONE token of an emitted permission row:
502
+ * it must be a literal wildcard-free name, or the row silently broadens to every stream/durable
503
+ * the wildcard matches (a `*` durable grants INFO/MSG.NEXT/ACK on ALL durables of the stream).
504
+ * Every grammar in this module emits `[A-Za-z0-9_-]`, so anything else is refused loudly. */
505
+ function assertGrantName(v, what) {
506
+ if (!/^[A-Za-z0-9_-]+$/.test(v))
507
+ throw new Error(`${what} ${JSON.stringify(v)} must be a literal wildcard-free name component ([A-Za-z0-9_-]+)`);
508
+ return v;
509
+ }
510
+ /** A consume-create row embeds the consumer's filter verbatim, so the filter's tokens become
511
+ * permission tokens: each must be a literal token, a full `*` token (the matrix's principal
512
+ * wildcards, e.g. the record writer's `epr.*.*.*`), or ONE trailing `>` — a malformed or
513
+ * mid-filter `>` token would broaden the row past the §13.9 matrix. */
514
+ function assertGrantFilter(filter, what) {
515
+ const toks = filter.split(".");
516
+ toks.forEach((t, i) => {
517
+ if (t === "*")
518
+ return;
519
+ if (t === ">" && i === toks.length - 1 && i > 0)
520
+ return; // never the WHOLE filter
521
+ if (t.length === 0 || /[*>\s]/.test(t))
522
+ throw new Error(`${what} filter ${JSON.stringify(filter)} token ${JSON.stringify(t)} is not a literal token, a full "*" token, or one trailing ">"`);
523
+ });
524
+ return filter;
525
+ }
526
+ function consumeCreateRow(stream, cfg) {
527
+ // A create row is AUTHORITY: only a (config, stream) pair minted together by a §13.9 family
528
+ // builder may become one — syntax checks alone cannot stop a raw config carrying a broad or
529
+ // foreign-family filter under a legitimate stream + durable.
530
+ assertFamilyPair(stream, cfg, "a consume-create grant");
531
+ if (!cfg.durable_name || !cfg.filter_subject)
532
+ throw new Error("a consume-create grant needs a durable_name and a full-tail filter_subject");
533
+ // The extended-create form embeds the stored-subject filter tail verbatim (§13.9): pinning it
534
+ // is what stops a body-selected filter.
535
+ return `${JSAPI}.CONSUMER.CREATE.${assertGrantName(stream, "grant stream")}.${assertGrantName(cfg.durable_name, "grant durable")}.${assertGrantFilter(cfg.filter_subject, "consume-create")}`;
536
+ }
537
+ function consumeBindRows(stream, durable) {
538
+ assertGrantName(stream, "grant stream");
539
+ assertGrantName(durable, "grant durable");
540
+ return [
541
+ `${JSAPI}.CONSUMER.INFO.${stream}.${durable}`,
542
+ `${JSAPI}.CONSUMER.MSG.NEXT.${stream}.${durable}`,
543
+ `$JS.ACK.${stream}.${durable}.>`,
544
+ ];
545
+ }
546
+ function consumeDeleteRow(stream, durable) {
547
+ return `${JSAPI}.CONSUMER.DELETE.${assertGrantName(stream, "grant stream")}.${assertGrantName(durable, "grant durable")}`;
548
+ }
549
+ /** The canonicalizer principal's EPJ rows: it OWNS its durable (create) and consumes + acks it. */
550
+ export function canonicalizerGrants(space, endpoint) {
551
+ const stream = epjStreamName(space);
552
+ const cfg = canonConsumerConfig(space, endpoint);
553
+ return [consumeCreateRow(stream, cfg), ...consumeBindRows(stream, cfg.durable_name)];
554
+ }
555
+ /** The canonicalizer principal's POOL-ROUTE rows (§13.9 matrix "Work-pool enqueue" +
556
+ * "Work-pool reconciliation probe"): the `epw.<e>.>` enqueue publish (create-per-subject rides
557
+ * the `Nats-Expected-Last-Subject-Sequence: 0` header, §13.6) and the FENCING leader-served
558
+ * `STREAM.MSG.GET` reconciliation read the §13.6 predicate and the enqueue's CAS-loser
559
+ * byte-identity check both require (EPW is `allow_direct=false`, so this is the ONLY read
560
+ * path). The MSG.GET form is BODY-selected (no per-subject confinement in the grant), so these
561
+ * rows are TRUSTED-canonicalizer-only: never on the pool owner (bind-only,
562
+ * {@link poolOwnerBindGrants}), never on any caller, observer, or admin profile. The full
563
+ * canonicalizer aggregate for an endpoint with pool routes is
564
+ * `[...canonicalizerGrants(...), ...canonicalizerWorkGrants(...)]`. */
565
+ export function canonicalizerWorkGrants(space, endpoint) {
566
+ return [
567
+ `${spacePrefix(space)}.epw.${endpointToken(endpoint)}.>`,
568
+ `${JSAPI}.STREAM.MSG.GET.${epwStreamName(space)}`,
569
+ ];
570
+ }
571
+ /** A serving instance's effects rows: BIND-ONLY on the provisioner-pre-created shared `eff_<e>`
572
+ * (INFO/MSG.NEXT/ACK, never create) — instances pull-compete, none owns the durable (§13.9). */
573
+ export function effectsBindGrants(space, endpoint) {
574
+ return consumeBindRows(epfStreamName(space), effectsDurable(endpoint));
575
+ }
576
+ /** A per-kind record-writer principal's EPR rows: owns + consumes + acks its `recw_<space>-<kind>`. */
577
+ export function recordWriterGrants(space, def) {
578
+ const stream = eprStreamName(space);
579
+ const cfg = recordWriterConsumerConfig(space, def);
580
+ return [consumeCreateRow(stream, cfg), ...consumeBindRows(stream, cfg.durable_name)];
581
+ }
582
+ /** The timer-writer principal's EPT_REQ rows: owns + consumes + acks its `timerw_<space>`. */
583
+ export function timerWriterGrants(space) {
584
+ const stream = eptReqStreamName(space);
585
+ const cfg = timerWriterConsumerConfig(space);
586
+ return [consumeCreateRow(stream, cfg), ...consumeBindRows(stream, cfg.durable_name)];
587
+ }
588
+ /** A pool-owning endpoint's EPW rows: BIND-ONLY on the provisioner-pre-created `pool_<e>_<pool>`
589
+ * (INFO/MSG.NEXT/ACK, never create — the bare create form is body-filter-selectable, §13.5/§13.9). */
590
+ export function poolOwnerBindGrants(space, endpoint, pool) {
591
+ return consumeBindRows(epwStreamName(space), poolDurable(endpoint, pool));
592
+ }
593
+ /** The read mediator's BIND-ONLY rows for one caller-scoped reader durable, on the stream the
594
+ * durable lives on (EPF for dec/goal, EPE for eve, `KV_cotal_records_<space>` for rec). */
595
+ export function readerBindGrants(stream, cfg) {
596
+ assertFamilyPair(stream, cfg, "a reader bind grant");
597
+ if (!cfg.durable_name)
598
+ throw new Error("a reader bind grant needs a durable_name");
599
+ return consumeBindRows(stream, cfg.durable_name);
600
+ }
601
+ /** The provisioner's rows for a batch of pre-created durables (§13.9): the exact full-tail
602
+ * CONSUMER.CREATE for every one it pre-creates, plus the matching CONSUMER.DELETE for
603
+ * deprovisioning — and nothing else (it never consumes; owners bind). The create pins each
604
+ * filter, so the provisioner can create ONLY the matrix's durables, not an arbitrary consumer. */
605
+ export function provisionerConsumerGrants(durables) {
606
+ const rows = [];
607
+ for (const d of durables) {
608
+ rows.push(consumeCreateRow(d.stream, d.config), consumeDeleteRow(d.stream, d.config.durable_name));
609
+ }
610
+ return rows;
611
+ }
612
+ /** The ADMISSION MEDIATOR principal's rows (§13.9 matrix "Acceptance obligation", §13.8): the
613
+ * ONE writer of ITS endpoint's `oblig.` subtree (create-only winner + revision-pinned CAS;
614
+ * the target position is a principal wildcard, the endpoint token is LITERAL) plus the
615
+ * terminal-REJECTION publish on its own endpoint's create-only decision subjects, the fencing
616
+ * leader reads (records / EPF / EPW `STREAM.MSG.GET`), and the §13.12 bind-time shape proof
617
+ * (records `STREAM.INFO`). The reply inbox is connection-scoped (`_INBOX_<connId>.>`, never the
618
+ * account-wide default): every JS API call is request/reply, so an account-wide inbox would
619
+ * receive other principals' API replies.
620
+ *
621
+ * NO `CONSUMER.CREATE` on the records stream (SPEC 13.9, site 3 — nats-server#8274). A
622
+ * consumer-create request BODY is not subject-ACL confinable: the extended
623
+ * `CONSUMER.CREATE.<records>.<name>.<oblig filter>` row this profile used to hold still admitted a
624
+ * `durable_name` + PUSH `deliver_subject` body — a durable exporter of the endpoint's whole
625
+ * `oblig.` subtree that SURVIVES this credential's connection and revoke (reproduced live). So the
626
+ * mediator's drain-to-quiescence enumeration runs on the SEALED records scanner
627
+ * ({@link ../../implementations/auth/src/records-scanner.ts openRecordsScanner}), a separate
628
+ * self-minted credential the trusted process never hands out — this profile holds no consumer
629
+ * lifecycle on the records stream at all.
630
+ *
631
+ * D32 residuals, EXPLICIT (accepted only for this trusted per-endpoint profile): (1) the
632
+ * decision publish is payload-blind, so a compromised mediator can forge an ACCEPTANCE within
633
+ * its own endpoint — an escalation to injecting executed work, never merely reject/stall —
634
+ * because rejection-only is not subject-expressible (both decisions MUST share the create-only
635
+ * decision subject for first-wins settlement); it can never forge beyond its endpoint. (2) its
636
+ * own-endpoint `$KV...oblig` subject grant cannot enforce create-only/monotonic CAS: it can
637
+ * overwrite a row to valid `terminal` and hide cleanup debt, or emit DEL/PURGE markers (the
638
+ * latter fail loud as corruption; stream-level erasure is denied). (3) the body-selected
639
+ * `STREAM.MSG.GET` fencing reads expose the records/EPF/EPW streams space-wide, and raw JS API
640
+ * requests carry a caller-selected reply subject, so a compromised mediator can direct fetched
641
+ * API/message bytes onto a foreign rail (confused-deputy injection, not foreign read access).
642
+ * The former consumer-create durable-export reach is CLOSED: enumeration moved to the sealed
643
+ * records scanner and this profile holds no records-stream CREATE. */
644
+ export function admissionMediatorGrants(space, endpoint, connId) {
645
+ const e = endpointToken(endpoint);
646
+ const stream = recordsKvStreamName(space);
647
+ const obligFilter = `$KV.${recordsBucket(space)}.oblig.*.${e}.>`;
648
+ const publish = [
649
+ obligFilter,
650
+ `${spacePrefix(space)}.epf.${e}.dec.>`,
651
+ `${JSAPI}.STREAM.MSG.GET.${stream}`,
652
+ `${JSAPI}.STREAM.MSG.GET.${epfStreamName(space)}`,
653
+ `${JSAPI}.STREAM.MSG.GET.${epwStreamName(space)}`,
654
+ `${JSAPI}.STREAM.INFO.${stream}`,
655
+ `${JSAPI}.INFO`,
656
+ ];
657
+ return { publish, subscribe: [`_INBOX_${assertInboxConnId(connId)}.>`] };
658
+ }
659
+ /** The RETIREMENT CLEANER principal's rows (§13.9 matrix "Terminal pool cleanup", §13.1
660
+ * barrier): minted per (retirement op × endpoint) with the EXACT pools the op intent
661
+ * enumerates — never a pool wildcard, never space-wide EPW rights. Per listed pool: BIND-ONLY
662
+ * on the provisioner-pre-created durable (INFO/MSG.NEXT/ACK, never create/update/delete). Plus
663
+ * the leader-served EPF `STREAM.MSG.GET` its terminal-observe and acceptance re-bind reads
664
+ * require — a STREAM-level grant whose read exposure is space-wide; that residual is EXPLICIT
665
+ * per D32 and accepted only for this trusted, bounded-lived, per-op profile. The cleaner holds
666
+ * NO terminal-publish or lease authority: the op-bounded executor CASes the lease and publishes
667
+ * its derived terminal, and the cleaner re-reads it before ACK. D32 residuals: raw ACK cannot be
668
+ * conditioned on a prior terminal, so compromise can suppress listed-pool work terminal-free;
669
+ * the space-wide EPF `STREAM.MSG.GET` exposes fact content, and its caller-selected reply can
670
+ * inject fetched API/message bytes onto a foreign rail. The reply inbox is
671
+ * connection-scoped (`_INBOX_<connId>.>`, never the account-wide default). NO `epw.>` publish,
672
+ * NO consumer create/update/delete, NO raw stream DELETE. The profile is revoked and its
673
+ * principal cluster-verified-evicted by the barrier BEFORE any frontier records (§13.1). */
674
+ export function retirementCleanerGrants(space, endpoint, pools, connId) {
675
+ if (!Array.isArray(pools) || pools.length === 0)
676
+ throw new Error("a retirement-cleaner grant lists at least one exact pool (SPEC 13.9: the op intent enumerates them; a poolless cleaner is no cleaner)");
677
+ endpointToken(endpoint);
678
+ const publish = [];
679
+ for (const pool of pools) {
680
+ const p = assertPoolToken(pool);
681
+ publish.push(...consumeBindRows(epwStreamName(space), poolDurable(endpoint, p)));
682
+ }
683
+ publish.push(`${JSAPI}.STREAM.MSG.GET.${epfStreamName(space)}`, `${JSAPI}.INFO`);
684
+ return { publish, subscribe: [`_INBOX_${assertInboxConnId(connId)}.>`] };
685
+ }
686
+ /** The endpoint's COMMIT PRINCIPAL rows (§13.9 matrix "Result/receipt/terminal/resume facts" +
687
+ * "Claim / action / checkpoint commits"): the enumerated commit fact families on its OWN
688
+ * endpoint — `goal.*.*.*.*.result` (the goal terminal; the `.bind` leaf under `goal.>` is the
689
+ * canonicalizer's), `eff.>`, `receipt.>`, `wrk.>`, `cp.>` — and **never `dec.>`/`quar.>`**
690
+ * (canonicalizer-only; structurally absent from these rows, not merely unused), plus its own
691
+ * record keys per the §13.7 writer table (`goal.<e>.>`, `cp.<e>.>`, `lease.<e>.>`; the endpoint
692
+ * qualifier is the FIRST qualifier of all three kinds, so the prefix is subject-expressible).
693
+ * Read-back is FENCING and therefore leader-served (§13.9 read service): body-selected
694
+ * `STREAM.MSG.GET` on `EPF_<space>` (create-only CAS emission + idempotent re-commit decisions
695
+ * over exactly its five fact families) and on `KV_cotal_records_<space>` (the terminal-commit's
696
+ * spec read and the epoch/deadline currency reads) — the follower-served `DIRECT.GET` forms are
697
+ * deliberately NOT granted. The reply inbox is connection-scoped (`_INBOX_<connId>.>`, never
698
+ * the account-wide default).
699
+ *
700
+ * D32 residuals, EXPLICIT (accepted only for this trusted per-endpoint profile): (1) every
701
+ * fact publish is payload-blind create-only, so a compromised commit principal can forge an
702
+ * in-endpoint `wrk`/`goal…result` terminal or `cp` resume for work that never ran — an
703
+ * escalation to fabricating completed work within its own endpoint, never beyond it; (2) its
704
+ * raw `$KV` subject grants cannot enforce the per-key CAS/monotonic discipline, so it can
705
+ * overwrite its own endpoint's goal/cp/lease rows (DEL/PURGE markers fail loud as corruption;
706
+ * stream-level erasure is denied by the store shape, §13.12); (3) the two body-selected
707
+ * `STREAM.MSG.GET` fencing reads expose the EPF and records streams space-wide, and a raw JS
708
+ * API request carries a caller-selected reply subject, so compromise can direct fetched
709
+ * API/message bytes onto a foreign rail (confused-deputy injection, not foreign write). */
710
+ export function commitPrincipalGrants(space, endpoint, connId) {
711
+ const e = endpointToken(endpoint);
712
+ const p = spacePrefix(space);
713
+ const records = recordsBucket(space);
714
+ const publish = [
715
+ `${p}.epf.${e}.goal.*.*.*.*.result`,
716
+ `${p}.epf.${e}.eff.>`,
717
+ `${p}.epf.${e}.receipt.>`,
718
+ `${p}.epf.${e}.wrk.>`,
719
+ `${p}.epf.${e}.cp.>`,
720
+ `$KV.${records}.goal.${e}.>`,
721
+ `$KV.${records}.cp.${e}.>`,
722
+ `$KV.${records}.lease.${e}.>`,
723
+ `${JSAPI}.STREAM.MSG.GET.${epfStreamName(space)}`,
724
+ `${JSAPI}.STREAM.MSG.GET.${recordsKvStreamName(space)}`,
725
+ `${JSAPI}.INFO`,
726
+ ];
727
+ return { publish, subscribe: [`_INBOX_${assertInboxConnId(connId)}.>`] };
728
+ }
729
+ /** The CONTRACT PUBLISHER principal's rows (§13.9 matrix "Contract-artifact publication" +
730
+ * the trusted-infra half of "Contract-artifact read"): publish `epc.*` (the digest-hex is ONE
731
+ * subject token; create-only rides `Nats-Expected-Last-Subject-Sequence: 0` at the typed path,
732
+ * §13.7 — the grant cannot express it, the broker CAS enforces it) and the subject-confined
733
+ * follower read-back `DIRECT.GET.EPC_<space>.cotal.<space>.epc.>` (NON-fencing by design:
734
+ * artifacts are content-addressed and verify-on-read is the tamper boundary, §13.7, so a
735
+ * stale replica serves nothing forgeable). NO `STREAM.INFO`: the deny_delete/deny_purge shape
736
+ * proof is the provisioner's (§13.12), not this profile's. The reply inbox is
737
+ * connection-scoped (`_INBOX_<connId>.>`, never the account-wide default).
738
+ *
739
+ * D32 residuals, EXPLICIT: (1) the `epc.*` publish is payload-blind — a compromised publisher
740
+ * can flood NEW digest subjects with garbage artifacts (verify-on-read refuses to SERVE
741
+ * non-canonical or digest-mismatched bytes, and an existing digest subject is CAS-protected,
742
+ * so it can waste storage but never corrupt or replace a published artifact); (2) the raw JS
743
+ * API request carries a caller-selected reply subject (the same confused-deputy injection
744
+ * class as every API-holding profile). */
745
+ export function contractPublisherGrants(space, connId) {
746
+ const publish = [
747
+ `${spacePrefix(space)}.epc.*`,
748
+ `${JSAPI}.DIRECT.GET.${epcStreamName(space)}.${spacePrefix(space)}.epc.>`,
749
+ `${JSAPI}.INFO`,
750
+ ];
751
+ return { publish, subscribe: [`_INBOX_${assertInboxConnId(connId)}.>`] };
752
+ }
753
+ //# sourceMappingURL=endpoint-binding.js.map