@cotal-ai/core 0.12.0 → 0.13.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/acls.d.ts +31 -7
- package/dist/acls.d.ts.map +1 -1
- package/dist/acls.js +105 -17
- package/dist/acls.js.map +1 -1
- package/dist/agent-file.js +1 -1
- package/dist/agent-file.js.map +1 -1
- package/dist/auth-provider.d.ts +8 -0
- package/dist/auth-provider.d.ts.map +1 -1
- package/dist/auth-provider.js.map +1 -1
- package/dist/backup.d.ts.map +1 -1
- package/dist/backup.js +6 -4
- package/dist/backup.js.map +1 -1
- package/dist/broker-floor.d.ts +34 -0
- package/dist/broker-floor.d.ts.map +1 -0
- package/dist/broker-floor.js +39 -0
- package/dist/broker-floor.js.map +1 -0
- package/dist/canonical.d.ts +24 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +159 -0
- package/dist/canonical.js.map +1 -0
- package/dist/connector.d.ts +4 -0
- package/dist/connector.d.ts.map +1 -1
- package/dist/endpoint-action.d.ts +424 -0
- package/dist/endpoint-action.d.ts.map +1 -0
- package/dist/endpoint-action.js +910 -0
- package/dist/endpoint-action.js.map +1 -0
- package/dist/endpoint-binding.d.ts +344 -0
- package/dist/endpoint-binding.d.ts.map +1 -0
- package/dist/endpoint-binding.js +753 -0
- package/dist/endpoint-binding.js.map +1 -0
- package/dist/endpoint-checkpoint.d.ts +292 -0
- package/dist/endpoint-checkpoint.d.ts.map +1 -0
- package/dist/endpoint-checkpoint.js +818 -0
- package/dist/endpoint-checkpoint.js.map +1 -0
- package/dist/endpoint-cluster.d.ts +95 -0
- package/dist/endpoint-cluster.d.ts.map +1 -0
- package/dist/endpoint-cluster.js +194 -0
- package/dist/endpoint-cluster.js.map +1 -0
- package/dist/endpoint-contract-store.d.ts +90 -0
- package/dist/endpoint-contract-store.d.ts.map +1 -0
- package/dist/endpoint-contract-store.js +348 -0
- package/dist/endpoint-contract-store.js.map +1 -0
- package/dist/endpoint-envelope.d.ts +165 -0
- package/dist/endpoint-envelope.d.ts.map +1 -0
- package/dist/endpoint-envelope.js +346 -0
- package/dist/endpoint-envelope.js.map +1 -0
- package/dist/endpoint-grants.d.ts +123 -0
- package/dist/endpoint-grants.d.ts.map +1 -0
- package/dist/endpoint-grants.js +215 -0
- package/dist/endpoint-grants.js.map +1 -0
- package/dist/endpoint-guard.d.ts +222 -0
- package/dist/endpoint-guard.d.ts.map +1 -0
- package/dist/endpoint-guard.js +468 -0
- package/dist/endpoint-guard.js.map +1 -0
- package/dist/endpoint-handle.d.ts +176 -0
- package/dist/endpoint-handle.d.ts.map +1 -0
- package/dist/endpoint-handle.js +608 -0
- package/dist/endpoint-handle.js.map +1 -0
- package/dist/endpoint-journal.d.ts +225 -0
- package/dist/endpoint-journal.d.ts.map +1 -0
- package/dist/endpoint-journal.js +431 -0
- package/dist/endpoint-journal.js.map +1 -0
- package/dist/endpoint-receipt.d.ts +225 -0
- package/dist/endpoint-receipt.d.ts.map +1 -0
- package/dist/endpoint-receipt.js +417 -0
- package/dist/endpoint-receipt.js.map +1 -0
- package/dist/endpoint-records.d.ts +243 -0
- package/dist/endpoint-records.d.ts.map +1 -0
- package/dist/endpoint-records.js +591 -0
- package/dist/endpoint-records.js.map +1 -0
- package/dist/endpoint-serve.d.ts +176 -0
- package/dist/endpoint-serve.d.ts.map +1 -0
- package/dist/endpoint-serve.js +446 -0
- package/dist/endpoint-serve.js.map +1 -0
- package/dist/endpoint-service.d.ts +518 -0
- package/dist/endpoint-service.d.ts.map +1 -0
- package/dist/endpoint-service.js +973 -0
- package/dist/endpoint-service.js.map +1 -0
- package/dist/endpoint-session.d.ts +398 -0
- package/dist/endpoint-session.d.ts.map +1 -0
- package/dist/endpoint-session.js +834 -0
- package/dist/endpoint-session.js.map +1 -0
- package/dist/endpoint-signing.d.ts +105 -0
- package/dist/endpoint-signing.d.ts.map +1 -0
- package/dist/endpoint-signing.js +190 -0
- package/dist/endpoint-signing.js.map +1 -0
- package/dist/endpoint-subjects.d.ts +222 -0
- package/dist/endpoint-subjects.d.ts.map +1 -0
- package/dist/endpoint-subjects.js +381 -0
- package/dist/endpoint-subjects.js.map +1 -0
- package/dist/endpoint-supervisor.d.ts +19 -0
- package/dist/endpoint-supervisor.d.ts.map +1 -0
- package/dist/endpoint-supervisor.js +12 -0
- package/dist/endpoint-supervisor.js.map +1 -0
- package/dist/endpoint-traits.d.ts +225 -0
- package/dist/endpoint-traits.d.ts.map +1 -0
- package/dist/endpoint-traits.js +469 -0
- package/dist/endpoint-traits.js.map +1 -0
- package/dist/endpoint-verbs.d.ts +277 -0
- package/dist/endpoint-verbs.d.ts.map +1 -0
- package/dist/endpoint-verbs.js +576 -0
- package/dist/endpoint-verbs.js.map +1 -0
- package/dist/endpoint-virtual.d.ts +308 -0
- package/dist/endpoint-virtual.d.ts.map +1 -0
- package/dist/endpoint-virtual.js +440 -0
- package/dist/endpoint-virtual.js.map +1 -0
- package/dist/endpoint-work.d.ts +304 -0
- package/dist/endpoint-work.d.ts.map +1 -0
- package/dist/endpoint-work.js +728 -0
- package/dist/endpoint-work.js.map +1 -0
- package/dist/endpoint.d.ts +66 -22
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +289 -89
- package/dist/endpoint.js.map +1 -1
- package/dist/evict.d.ts +95 -32
- package/dist/evict.d.ts.map +1 -1
- package/dist/evict.js +193 -3
- package/dist/evict.js.map +1 -1
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -1
- package/dist/lease.d.ts +4 -0
- package/dist/lease.d.ts.map +1 -1
- package/dist/lease.js.map +1 -1
- package/dist/members.d.ts +4 -4
- package/dist/members.d.ts.map +1 -1
- package/dist/members.js +12 -12
- package/dist/members.js.map +1 -1
- package/dist/provision.d.ts +70 -18
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +194 -33
- package/dist/provision.js.map +1 -1
- package/dist/safe-pattern.d.ts +33 -0
- package/dist/safe-pattern.d.ts.map +1 -0
- package/dist/safe-pattern.js +486 -0
- package/dist/safe-pattern.js.map +1 -0
- package/dist/schema-profile.d.ts +93 -0
- package/dist/schema-profile.d.ts.map +1 -0
- package/dist/schema-profile.js +284 -0
- package/dist/schema-profile.js.map +1 -0
- package/dist/streams.d.ts +13 -8
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +35 -18
- package/dist/streams.js.map +1 -1
- package/dist/subjects.d.ts +100 -40
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +136 -60
- package/dist/subjects.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -1
|
@@ -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
|