@cotal-ai/core 0.12.0 → 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 (152) 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 +8 -0
  8. package/dist/auth-provider.d.ts.map +1 -1
  9. package/dist/auth-provider.js.map +1 -1
  10. package/dist/backup.d.ts.map +1 -1
  11. package/dist/backup.js +6 -4
  12. package/dist/backup.js.map +1 -1
  13. package/dist/broker-floor.d.ts +34 -0
  14. package/dist/broker-floor.d.ts.map +1 -0
  15. package/dist/broker-floor.js +39 -0
  16. package/dist/broker-floor.js.map +1 -0
  17. package/dist/canonical.d.ts +24 -0
  18. package/dist/canonical.d.ts.map +1 -0
  19. package/dist/canonical.js +159 -0
  20. package/dist/canonical.js.map +1 -0
  21. package/dist/connector.d.ts +4 -0
  22. package/dist/connector.d.ts.map +1 -1
  23. package/dist/endpoint-action.d.ts +424 -0
  24. package/dist/endpoint-action.d.ts.map +1 -0
  25. package/dist/endpoint-action.js +910 -0
  26. package/dist/endpoint-action.js.map +1 -0
  27. package/dist/endpoint-binding.d.ts +344 -0
  28. package/dist/endpoint-binding.d.ts.map +1 -0
  29. package/dist/endpoint-binding.js +753 -0
  30. package/dist/endpoint-binding.js.map +1 -0
  31. package/dist/endpoint-checkpoint.d.ts +292 -0
  32. package/dist/endpoint-checkpoint.d.ts.map +1 -0
  33. package/dist/endpoint-checkpoint.js +818 -0
  34. package/dist/endpoint-checkpoint.js.map +1 -0
  35. package/dist/endpoint-cluster.d.ts +95 -0
  36. package/dist/endpoint-cluster.d.ts.map +1 -0
  37. package/dist/endpoint-cluster.js +194 -0
  38. package/dist/endpoint-cluster.js.map +1 -0
  39. package/dist/endpoint-contract-store.d.ts +90 -0
  40. package/dist/endpoint-contract-store.d.ts.map +1 -0
  41. package/dist/endpoint-contract-store.js +348 -0
  42. package/dist/endpoint-contract-store.js.map +1 -0
  43. package/dist/endpoint-envelope.d.ts +165 -0
  44. package/dist/endpoint-envelope.d.ts.map +1 -0
  45. package/dist/endpoint-envelope.js +346 -0
  46. package/dist/endpoint-envelope.js.map +1 -0
  47. package/dist/endpoint-grants.d.ts +123 -0
  48. package/dist/endpoint-grants.d.ts.map +1 -0
  49. package/dist/endpoint-grants.js +215 -0
  50. package/dist/endpoint-grants.js.map +1 -0
  51. package/dist/endpoint-guard.d.ts +222 -0
  52. package/dist/endpoint-guard.d.ts.map +1 -0
  53. package/dist/endpoint-guard.js +468 -0
  54. package/dist/endpoint-guard.js.map +1 -0
  55. package/dist/endpoint-handle.d.ts +176 -0
  56. package/dist/endpoint-handle.d.ts.map +1 -0
  57. package/dist/endpoint-handle.js +608 -0
  58. package/dist/endpoint-handle.js.map +1 -0
  59. package/dist/endpoint-journal.d.ts +225 -0
  60. package/dist/endpoint-journal.d.ts.map +1 -0
  61. package/dist/endpoint-journal.js +431 -0
  62. package/dist/endpoint-journal.js.map +1 -0
  63. package/dist/endpoint-receipt.d.ts +225 -0
  64. package/dist/endpoint-receipt.d.ts.map +1 -0
  65. package/dist/endpoint-receipt.js +417 -0
  66. package/dist/endpoint-receipt.js.map +1 -0
  67. package/dist/endpoint-records.d.ts +243 -0
  68. package/dist/endpoint-records.d.ts.map +1 -0
  69. package/dist/endpoint-records.js +591 -0
  70. package/dist/endpoint-records.js.map +1 -0
  71. package/dist/endpoint-serve.d.ts +176 -0
  72. package/dist/endpoint-serve.d.ts.map +1 -0
  73. package/dist/endpoint-serve.js +446 -0
  74. package/dist/endpoint-serve.js.map +1 -0
  75. package/dist/endpoint-service.d.ts +518 -0
  76. package/dist/endpoint-service.d.ts.map +1 -0
  77. package/dist/endpoint-service.js +973 -0
  78. package/dist/endpoint-service.js.map +1 -0
  79. package/dist/endpoint-session.d.ts +398 -0
  80. package/dist/endpoint-session.d.ts.map +1 -0
  81. package/dist/endpoint-session.js +834 -0
  82. package/dist/endpoint-session.js.map +1 -0
  83. package/dist/endpoint-signing.d.ts +105 -0
  84. package/dist/endpoint-signing.d.ts.map +1 -0
  85. package/dist/endpoint-signing.js +190 -0
  86. package/dist/endpoint-signing.js.map +1 -0
  87. package/dist/endpoint-subjects.d.ts +222 -0
  88. package/dist/endpoint-subjects.d.ts.map +1 -0
  89. package/dist/endpoint-subjects.js +381 -0
  90. package/dist/endpoint-subjects.js.map +1 -0
  91. package/dist/endpoint-supervisor.d.ts +19 -0
  92. package/dist/endpoint-supervisor.d.ts.map +1 -0
  93. package/dist/endpoint-supervisor.js +12 -0
  94. package/dist/endpoint-supervisor.js.map +1 -0
  95. package/dist/endpoint-traits.d.ts +225 -0
  96. package/dist/endpoint-traits.d.ts.map +1 -0
  97. package/dist/endpoint-traits.js +469 -0
  98. package/dist/endpoint-traits.js.map +1 -0
  99. package/dist/endpoint-verbs.d.ts +277 -0
  100. package/dist/endpoint-verbs.d.ts.map +1 -0
  101. package/dist/endpoint-verbs.js +576 -0
  102. package/dist/endpoint-verbs.js.map +1 -0
  103. package/dist/endpoint-virtual.d.ts +308 -0
  104. package/dist/endpoint-virtual.d.ts.map +1 -0
  105. package/dist/endpoint-virtual.js +440 -0
  106. package/dist/endpoint-virtual.js.map +1 -0
  107. package/dist/endpoint-work.d.ts +304 -0
  108. package/dist/endpoint-work.d.ts.map +1 -0
  109. package/dist/endpoint-work.js +728 -0
  110. package/dist/endpoint-work.js.map +1 -0
  111. package/dist/endpoint.d.ts +66 -22
  112. package/dist/endpoint.d.ts.map +1 -1
  113. package/dist/endpoint.js +289 -89
  114. package/dist/endpoint.js.map +1 -1
  115. package/dist/evict.d.ts +95 -32
  116. package/dist/evict.d.ts.map +1 -1
  117. package/dist/evict.js +193 -3
  118. package/dist/evict.js.map +1 -1
  119. package/dist/index.d.ts +25 -0
  120. package/dist/index.d.ts.map +1 -1
  121. package/dist/index.js +25 -0
  122. package/dist/index.js.map +1 -1
  123. package/dist/lease.d.ts +4 -0
  124. package/dist/lease.d.ts.map +1 -1
  125. package/dist/lease.js.map +1 -1
  126. package/dist/members.d.ts +4 -4
  127. package/dist/members.d.ts.map +1 -1
  128. package/dist/members.js +12 -12
  129. package/dist/members.js.map +1 -1
  130. package/dist/provision.d.ts +70 -18
  131. package/dist/provision.d.ts.map +1 -1
  132. package/dist/provision.js +194 -33
  133. package/dist/provision.js.map +1 -1
  134. package/dist/safe-pattern.d.ts +33 -0
  135. package/dist/safe-pattern.d.ts.map +1 -0
  136. package/dist/safe-pattern.js +486 -0
  137. package/dist/safe-pattern.js.map +1 -0
  138. package/dist/schema-profile.d.ts +93 -0
  139. package/dist/schema-profile.d.ts.map +1 -0
  140. package/dist/schema-profile.js +284 -0
  141. package/dist/schema-profile.js.map +1 -0
  142. package/dist/streams.d.ts +13 -8
  143. package/dist/streams.d.ts.map +1 -1
  144. package/dist/streams.js +35 -18
  145. package/dist/streams.js.map +1 -1
  146. package/dist/subjects.d.ts +100 -40
  147. package/dist/subjects.d.ts.map +1 -1
  148. package/dist/subjects.js +136 -60
  149. package/dist/subjects.js.map +1 -1
  150. package/dist/types.d.ts +9 -0
  151. package/dist/types.d.ts.map +1 -1
  152. package/package.json +3 -1
@@ -0,0 +1,834 @@
1
+ /**
2
+ * The SESSION (bidirectional stream) composite (SPEC §13.6): D26's cast-ingress + watch-egress
3
+ * composed over dedicated per-session subjects — no new verb, no new transport. The `in`
4
+ * subject is a cast-only rail (caller publishes, serving endpoint subscribes) and `out` is a
5
+ * watch rail (endpoint publishes, caller subscribes); both are epoch-pinned
6
+ * (`eps.<endpoint>.<sessionId>.<epoch>.<in|out>`), CORE-ONLY, never stream-captured.
7
+ *
8
+ * The module owns the transport-thin core:
9
+ *
10
+ * - the SESSION GRANT: a one-use, holder-bound signed artifact (RFC 8785 + Ed25519 via the
11
+ * §13.10 anchor registry, role `sessions`) naming a fresh unguessable `sessionId`, BOTH
12
+ * epoch-pinned rail subjects (carried explicitly AND re-derived at verification — a
13
+ * substituted subject kills the signature or the derivation check), the holder's
14
+ * (principal, lifecycleUid, processEpoch) and the serving (instanceId, epoch). Live
15
+ * authority: an unredeemed grant dies with the caller's restart (§13.1), so redemption
16
+ * fresh-checks the holder epoch.
17
+ * - the SESSION LEDGER ROW contract (`session.<sessionId>` in the auth store, §13.12):
18
+ * `{sessionId, endpoint, serving, holder, both credential ids, per-credential revocation
19
+ * marks, state, exp}` with the monotonic state grammar
20
+ * `issuing → active → closed|expired|superseded|retired` (all terminal). The create-CAS of
21
+ * the `issuing` row IS the one-use redemption; the finalize-CAS `issuing → active`
22
+ * fresh-checks BOTH process epochs and both lifecycle gates and releases the two
23
+ * credentials only on success. A credential is authority ONLY once its row is `active`; an
24
+ * `issuing` row confers nothing (the auth path's connect boundary enforces that; this
25
+ * module pins the contract and the ordering). A crash mid-issue leaves an `issuing` row the
26
+ * expiry sweep collects (revoking BOTH ids by name and tombstoning), never a live
27
+ * half-pair; a redemption racing a close loses its finalize and releases nothing. The
28
+ * revocation marks make the sweep's retry REAL: a revoke that failed leaves its mark
29
+ * unset, and every later sweep pass retries exactly the unmarked halves until both confirm.
30
+ * - the REDEMPTION SEAM ({@link redeemSession}): core owns the ordering, the refusal catalog,
31
+ * and the AUTHORITY EDGES — the redemption is presented by an AUTHENTICATED presenter that
32
+ * must equal the grant's holder exactly (a leaked grant alone releases nothing; §13.10
33
+ * holder-binding is enforced here, not assumed), and the lifecycle fence is the
34
+ * REVISION-PINNED {@link SessionRedemptionHooks.stagePair} write against both parties'
35
+ * OBSERVED issuance gates (a moved gate makes the pinned write LOSE; a read is never a
36
+ * fence, §13.1/§13.9). The trusted auth path (§9/§10, off-broker) wires the real KV
37
+ * create/finalize CAS, the credential mint, and revocation — the same seam pattern as D4's
38
+ * issuance gate.
39
+ * - the RAILS + BOUNDED FLOW WINDOW ({@link openSessionRail}): a tiny framed protocol over
40
+ * OPAQUE data (`{t:"f",seq,data}`), credit-based sliding window sized by the grant
41
+ * (`window`, 1..{@link SESSION_WINDOW_MAX}); a sender whose window is full REFUSES with
42
+ * `resource-exhausted` — in-memory state, NO buffering, never unbounded (§13.6). Credits
43
+ * (`{t:"credit",ack}`) ride the sender's own ingress rail (both rails exist, so control
44
+ * needs no extra grant) and are EXEMPT from the window (control, not data). The receiver
45
+ * acks CONTIGUOUS delivery only, so a dropped frame stalls credits and the sender windows
46
+ * out loudly: the window doubles as the loss detector; there is no retransmit machinery —
47
+ * a broken session is closed and re-established (a durable session is a NEW
48
+ * establishment; the epoch is in the subject, so a restarted instance cannot resume).
49
+ * Credit-loss recovery is three-legged: PIGGYBACKED absolute acks on reverse data, the
50
+ * KEEPALIVE re-emit (a quiet peer gets the absolute watermark re-advertised every idle
51
+ * tick — deliberately including watermarks already advertised, because the receiver cannot
52
+ * observe whether an emitted credit arrived), and the TIMER-DRIVEN stall watchdog (a full
53
+ * window with no ack advance breaks the rail with a detectable fault even if the sender
54
+ * never calls send again).
55
+ * - CLOSE: the in-band `{t:"close"}` frame is an ADVISORY peer signal, never the revocation
56
+ * authority (EPS subjects are captured by nothing) — but it DOES tear down the receiving
57
+ * side's local subscription and timer exactly once (a remote close must never leak local
58
+ * resources). Authoritative close/expiry live on the trusted auth path: an authenticated
59
+ * close op (a session party or the operator names the sessionId; party membership is
60
+ * verified against the ledger row), the auth path's own expiry timer, or either side's
61
+ * §13.1 barrier — each transitions the row terminal and revokes BOTH credentials, so
62
+ * neither side can keep a half-closed session alive and a crashed serving endpoint cannot
63
+ * orphan one (the ledger, not the endpoint, remembers what to revoke).
64
+ */
65
+ import { randomBytes } from "node:crypto";
66
+ import { EpEnvelopeError } from "./endpoint-envelope.js";
67
+ import { canonicalJson } from "./canonical.js";
68
+ import { parsePrincipalKey, assertValidOwnerToken } from "./subjects.js";
69
+ import { epsSubject, assertIdToken, assertLifecycleToken, endpointToken, } from "./endpoint-subjects.js";
70
+ import { verifyArtifactSignature, resolveAnchorForUse, assertAnchorScopeCovers, assertArtifactCurrency, signArtifact, } from "./endpoint-signing.js";
71
+ const isRec = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
72
+ function invalid(what) {
73
+ throw new EpEnvelopeError("contract-invalid", `${what} (SPEC 13.6 session)`);
74
+ }
75
+ // ---- bounds (fail loud past each; a bound reached is a refusal, never a truncation) ----------
76
+ /** Grant validity ceiling — a session grant is LIVE-class authority (§13.6 "expiry per the
77
+ * handle rules"; it is one-use and epoch-bound on both sides, never sturdy). */
78
+ export const SESSION_GRANT_MAX_TTL_MS = 24 * 60 * 60 * 1000;
79
+ export const SESSION_GRANT_MAX_BYTES = 16 * 1024;
80
+ /** The bounded flow window (§13.6: declared in the grant; overflow is `resource-exhausted`). */
81
+ export const SESSION_WINDOW_DEFAULT = 64;
82
+ export const SESSION_WINDOW_MAX = 1024;
83
+ /** §13.6 "fresh unguessable sessionId": at least 22 base64url chars (≥128 bits) — enforced on
84
+ * CALLER-SUPPLIED ids too, so a short guessable id cannot ride in through the mint arg. */
85
+ export const SESSION_ID_MIN_CHARS = 22;
86
+ /** Mint a fresh unguessable sessionId: 32 CSPRNG bytes, base64url (43 chars, within the
87
+ * bounded id-token grammar the subject builder pins). */
88
+ export function mintSessionId() {
89
+ return assertSessionId(randomBytes(32).toString("base64url"));
90
+ }
91
+ function assertSessionId(v) {
92
+ const id = assertIdToken(v, "sessionId");
93
+ if (id.length < SESSION_ID_MIN_CHARS)
94
+ invalid(`sessionId is ${id.length} chars, under the ${SESSION_ID_MIN_CHARS}-char unguessability floor`);
95
+ return id;
96
+ }
97
+ function assertWindow(v) {
98
+ if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 1 || v > SESSION_WINDOW_MAX)
99
+ invalid(`window ${String(v)} is not an integer in 1..${SESSION_WINDOW_MAX}`);
100
+ return v;
101
+ }
102
+ /** The subject grammar's epoch bound (unsigned safe integer), as a value validator — the
103
+ * subject builder re-asserts it when the rails are derived. */
104
+ function assertEpochInt(v, what) {
105
+ if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 0)
106
+ invalid(`${what} ${String(v)} is not an unsigned integer epoch`);
107
+ return v;
108
+ }
109
+ /** `holder.id` is a PRINCIPAL, not a free-form string: the dot-form `<owner>.<actor>`
110
+ * (user-mode) or a bare static/dev actor token (dot-free) — the same grammar the §13.1
111
+ * deprovision target pins. A free-form holder would name a party no authority layer can
112
+ * attribute, so the presenter-equality check downstream would compare garbage to garbage. */
113
+ function assertHolderId(id) {
114
+ if (typeof id !== "string" || id.length === 0 || id.length > 256)
115
+ invalid("holder.id is not a bounded principal string");
116
+ if (parsePrincipalKey(id))
117
+ return id;
118
+ try {
119
+ return assertValidOwnerToken(id);
120
+ }
121
+ catch {
122
+ invalid(`holder.id "${id}" is neither a principal dot-form (<owner>.<actor>) nor a bare static actor token`);
123
+ }
124
+ }
125
+ /** Build + sign a session grant (the serving side's establishment answer). The rail subjects
126
+ * are DERIVED here — the signature pins them; a verifier re-derives and compares. */
127
+ export function mintSessionGrant(args, keyPair) {
128
+ const now = args.now ?? Date.now();
129
+ if (!Number.isSafeInteger(now))
130
+ invalid("now is not an integer");
131
+ if (!Number.isSafeInteger(args.ttlMs) || args.ttlMs <= 0 || args.ttlMs > SESSION_GRANT_MAX_TTL_MS)
132
+ invalid(`ttlMs ${String(args.ttlMs)} is not in (0, ${SESSION_GRANT_MAX_TTL_MS}]`);
133
+ const sessionId = args.sessionId !== undefined ? assertSessionId(args.sessionId) : mintSessionId();
134
+ const endpoint = endpointToken(args.endpoint);
135
+ const serving = {
136
+ instanceId: assertLifecycleToken(args.serving.instanceId, "serving.instanceId"),
137
+ epoch: assertEpochInt(args.serving.epoch, "serving.epoch"),
138
+ };
139
+ const holder = {
140
+ id: assertHolderId(args.holder.id),
141
+ lifecycleUid: assertLifecycleToken(args.holder.lifecycleUid, "holder.lifecycleUid"),
142
+ processEpoch: assertEpochInt(args.holder.processEpoch, "holder.processEpoch"),
143
+ };
144
+ const unsigned = {
145
+ v: 1,
146
+ sessionId,
147
+ space: args.space,
148
+ endpoint,
149
+ subjects: {
150
+ in: epsSubject(args.space, endpoint, sessionId, serving.epoch, "in"),
151
+ out: epsSubject(args.space, endpoint, sessionId, serving.epoch, "out"),
152
+ },
153
+ holder,
154
+ serving,
155
+ window: assertWindow(args.window ?? SESSION_WINDOW_DEFAULT),
156
+ iat: now,
157
+ exp: now + args.ttlMs,
158
+ nonce: randomBytes(18).toString("base64url"),
159
+ issuer: { keyId: args.issuerKeyId },
160
+ };
161
+ return signArtifact(unsigned, keyPair);
162
+ }
163
+ /** Parse + verify a presented session grant, D28-exact: the signature verifies over the EXACT
164
+ * raw artifact (sig absent), the parsed projection is for semantics only; the anchor is
165
+ * resolved FRESH (role `sessions`, scope ceiling covers the endpoint); the rail subjects are
166
+ * re-derived and compared; currency is checked at `now`. Fail-closed everywhere; every nested
167
+ * object is a CLOSED schema. */
168
+ export async function verifySessionGrant(raw, opts) {
169
+ const now = opts.now ?? Date.now();
170
+ // The clock authority is validated at ENTRY, before any anchor or signature work (the same
171
+ // rule verifyHandleChain pins): every currency rule is a numeric comparison, so a
172
+ // NaN/fractional/negative clock would make them all silently false and a stale or
173
+ // forward-dated grant would VERIFY. The shared helper re-checks as a belt.
174
+ if (!Number.isSafeInteger(now) || now < 0)
175
+ throw new EpEnvelopeError("failed-precondition", `now must be a non-negative safe integer; got ${JSON.stringify(now)} (an invalid clock authority never verifies, SPEC 13.10)`);
176
+ if (!isRec(raw))
177
+ invalid("a session grant is not an object");
178
+ // Byte bound BEFORE any structural walk (a canonicalization failure is contract-invalid too:
179
+ // an artifact that cannot canonicalize cannot have been signed). UTF-8 BYTES, not JS chars —
180
+ // a multibyte payload must not slide under the bound on character count.
181
+ let canonical;
182
+ try {
183
+ const { sig: _sig, ...rest } = raw;
184
+ canonical = canonicalJson(rest);
185
+ }
186
+ catch (e) {
187
+ invalid(`session grant does not canonicalize: ${e?.message ?? String(e)}`);
188
+ }
189
+ if (Buffer.byteLength(canonical, "utf8") > SESSION_GRANT_MAX_BYTES)
190
+ invalid(`session grant exceeds ${SESSION_GRANT_MAX_BYTES} bytes`);
191
+ const o = raw;
192
+ const allowed = new Set(["v", "sessionId", "space", "endpoint", "subjects", "holder", "serving", "window", "iat", "nbf", "exp", "nonce", "issuer", "sig"]);
193
+ for (const k of Object.keys(o))
194
+ if (!allowed.has(k))
195
+ invalid(`session grant carries the unknown field "${k}" (closed schema)`);
196
+ if (o.v !== 1)
197
+ invalid(`unknown session grant version ${String(o.v)}`);
198
+ if (typeof o.space !== "string" || o.space.length === 0)
199
+ invalid("space is not a string");
200
+ if (o.space !== opts.space)
201
+ throw new EpEnvelopeError("permission-denied", `session grant is for space "${o.space}", not "${opts.space}" (audience, SPEC 13.6)`);
202
+ const sessionId = assertSessionId(o.sessionId);
203
+ const endpoint = endpointToken(o.endpoint);
204
+ if (!isRec(o.serving))
205
+ invalid("serving is not an object");
206
+ const sv = o.serving;
207
+ for (const k of Object.keys(sv))
208
+ if (k !== "instanceId" && k !== "epoch")
209
+ invalid(`serving carries the unknown field "${k}" (closed schema)`);
210
+ const serving = {
211
+ instanceId: assertLifecycleToken(sv.instanceId, "serving.instanceId"),
212
+ epoch: assertEpochInt(sv.epoch, "serving.epoch"),
213
+ };
214
+ if (!isRec(o.holder))
215
+ invalid("holder is not an object");
216
+ const h = o.holder;
217
+ for (const k of Object.keys(h))
218
+ if (k !== "id" && k !== "lifecycleUid" && k !== "processEpoch")
219
+ invalid(`holder carries the unknown field "${k}" (closed schema)`);
220
+ const holder = {
221
+ id: assertHolderId(h.id),
222
+ lifecycleUid: assertLifecycleToken(h.lifecycleUid, "holder.lifecycleUid"),
223
+ processEpoch: assertEpochInt(h.processEpoch, "holder.processEpoch"),
224
+ };
225
+ if (!isRec(o.subjects))
226
+ invalid("subjects is not an object");
227
+ const subj = o.subjects;
228
+ for (const k of Object.keys(subj))
229
+ if (k !== "in" && k !== "out")
230
+ invalid(`subjects carries the unknown key "${k}"`);
231
+ const expectIn = epsSubject(o.space, endpoint, sessionId, serving.epoch, "in");
232
+ const expectOut = epsSubject(o.space, endpoint, sessionId, serving.epoch, "out");
233
+ if (subj.in !== expectIn || subj.out !== expectOut)
234
+ throw new EpEnvelopeError("permission-denied", "session grant subjects do not match the derived epoch-pinned rails; a substituted subject never verifies (SPEC 13.6)");
235
+ const window = assertWindow(o.window);
236
+ if (typeof o.iat !== "number" || !Number.isSafeInteger(o.iat))
237
+ invalid("iat is not an integer");
238
+ if (o.nbf !== undefined && (typeof o.nbf !== "number" || !Number.isSafeInteger(o.nbf)))
239
+ invalid("nbf is not an integer");
240
+ if (typeof o.exp !== "number" || !Number.isSafeInteger(o.exp))
241
+ invalid("exp is not an integer");
242
+ if (typeof o.nonce !== "string" || !/^[A-Za-z0-9_-]{8,64}$/.test(o.nonce))
243
+ invalid("nonce is not a bounded base64url token");
244
+ if (!isRec(o.issuer))
245
+ invalid("issuer is not an object");
246
+ const iss = o.issuer;
247
+ for (const k of Object.keys(iss))
248
+ if (k !== "keyId")
249
+ invalid(`issuer carries the unknown field "${k}" (closed schema)`);
250
+ if (typeof iss.keyId !== "string")
251
+ invalid("issuer.keyId is not a string");
252
+ // Anchor gate: fresh resolution, role, window-at-signing, revocation-at-verification, and
253
+ // the sessions scope ceiling covers THIS endpoint (an absent dimension is closed).
254
+ const anchor = await resolveAnchorForUse(opts.resolveAnchor, { keyId: iss.keyId, role: "sessions", at: o.iat });
255
+ assertAnchorScopeCovers(anchor, "sessions", endpoint, "the session's endpoint");
256
+ verifyArtifactSignature(o, anchor);
257
+ // Currency LAST (after identity): a forged-but-expired artifact is permission-denied above,
258
+ // never a soft "expired" that leaks verification order. The rules are the SHARED
259
+ // assertArtifactCurrency (SPEC 1778: session expiry follows the handle rules — enforced by
260
+ // calling the same primitive verifyHandleChain calls, not a hand copy that can drift);
261
+ // "post-signature" because identity is established, so the soft codes are safe here.
262
+ assertArtifactCurrency({ iat: o.iat, ...(o.nbf !== undefined ? { nbf: o.nbf } : {}), exp: o.exp }, { now, ceilingMs: SESSION_GRANT_MAX_TTL_MS, what: "session grant", ceilingName: "live", refusals: "post-signature" });
263
+ return {
264
+ v: 1,
265
+ sessionId,
266
+ space: o.space,
267
+ endpoint,
268
+ subjects: { in: expectIn, out: expectOut },
269
+ holder,
270
+ serving,
271
+ window,
272
+ iat: o.iat,
273
+ ...(o.nbf !== undefined ? { nbf: o.nbf } : {}),
274
+ exp: o.exp,
275
+ nonce: o.nonce,
276
+ issuer: { keyId: iss.keyId },
277
+ sig: o.sig,
278
+ };
279
+ }
280
+ // ---- the session ledger row (auth store `session.<sessionId>`, §13.12) -----------------------
281
+ // Runtime-frozen + a private Set the transition/sweep seams consult (the afa715b class: a
282
+ // spliced-out "closed" would otherwise disable the revocation-retry backstop, executed repro).
283
+ export const SESSION_TERMINAL_STATES = Object.freeze(["closed", "expired", "superseded", "retired"]);
284
+ const TERMINAL_STATE_SNAP = new Set(SESSION_TERMINAL_STATES);
285
+ /** The auth-store key (`session.<sessionId>`, §13.12). */
286
+ export function sessionLedgerKey(sessionId) {
287
+ return `session.${assertSessionId(sessionId)}`;
288
+ }
289
+ /** The monotonic state grammar: `issuing → active`, `issuing → terminal` (the sweep collecting
290
+ * a crashed half-issue), `active → terminal`. Terminal states never transition. */
291
+ export function assertSessionStateTransition(from, to) {
292
+ const terminal = TERMINAL_STATE_SNAP.has(from);
293
+ if (terminal)
294
+ throw new EpEnvelopeError("failed-precondition", `session state "${from}" is terminal; states are monotonic (SPEC 13.6)`);
295
+ if (to === "issuing")
296
+ throw new EpEnvelopeError("failed-precondition", `no transition re-enters "issuing" (the create-CAS is the only writer of that state, SPEC 13.6)`);
297
+ if (from === "active" && to === "active")
298
+ throw new EpEnvelopeError("failed-precondition", `session is already active (finalize is one-shot, SPEC 13.6)`);
299
+ }
300
+ /** Bounded credential-ledger id (no usable bytes ride in an id). */
301
+ function assertCredentialId(v, what) {
302
+ if (typeof v !== "string" || v.length === 0 || v.length > 256)
303
+ invalid(`${what} is not a bounded credential id`);
304
+ return v;
305
+ }
306
+ /** Fail-loud validation of what a release hook handed back: the requested id, within the
307
+ * session's life. A hook that returns someone else's credential or one outliving the session
308
+ * violated the seam contract — surface it, never pass it through. */
309
+ function assertReleased(cred, wantId, sessionExp) {
310
+ if (!isRec(cred) || cred.id !== wantId)
311
+ invalid(`the release returned credential "${String(cred?.id)}", not the requested "${wantId}" (hook contract)`);
312
+ if (typeof cred.exp !== "number" || !Number.isSafeInteger(cred.exp) || cred.exp > sessionExp)
313
+ invalid(`released credential exp ${String(cred.exp)} is not an integer within the session exp ${sessionExp} (a credential must not outlive its session)`);
314
+ return cred;
315
+ }
316
+ async function refuseAndCollect(hooks, sessionId, ids, to, err) {
317
+ // Best-effort containment before the refusal surfaces: the row (when ours) goes terminal and
318
+ // both staged credentials are revoked by name, each marked on success. Failures here must
319
+ // not mask the refusal — the sweep's terminal-row retry (driven by the unmarked ids) is the
320
+ // durable backstop for exactly this window.
321
+ try {
322
+ await hooks.ledger.transitionTerminal(sessionId, to);
323
+ }
324
+ catch {
325
+ /* sweep backstop */
326
+ }
327
+ if (ids) {
328
+ for (const id of [ids.credCaller, ids.credServing]) {
329
+ try {
330
+ await hooks.revokeCredential(id);
331
+ await hooks.ledger.markRevoked(sessionId, id);
332
+ }
333
+ catch {
334
+ /* the unmarked id is retried by the sweep */
335
+ }
336
+ }
337
+ }
338
+ throw err;
339
+ }
340
+ /**
341
+ * Redeem a VERIFIED session grant (§13.6 finalize-CAS ordering), presented by an AUTHENTICATED
342
+ * presenter. The panel-locked order — no half-issued session is ever usable, a redemption
343
+ * racing a close loses its finalize and releases nothing:
344
+ *
345
+ * 0. the presenter must equal the grant's holder EXACTLY (identity before anything —
346
+ * possession of a leaked grant releases nothing, §13.10 holder-binding);
347
+ * 1. allocate both credential ids (no bytes; bounded, distinct);
348
+ * 2. create-CAS the `issuing` row naming BOTH ids (the one-use — a duplicate loses here,
349
+ * EXCEPT the authenticated holder retrying an active row after a lost response, which
350
+ * re-releases the SAME holder credential);
351
+ * 3. observe both lifecycle issuance gates (leader-served) and stage both credential rows
352
+ * REVISION-PINNED to them (the lifecycle FENCE: a moved gate makes the pinned write lose);
353
+ * 4. fresh-check both process epochs (leader-served reads) AND grant expiry;
354
+ * 5. finalize-CAS `issuing → active` (a racing close/barrier wins here);
355
+ * 6. release ONLY the HOLDER's credential (the serving side retrieves its own separately).
356
+ *
357
+ * The caller passes the output of {@link verifySessionGrant} (signature/anchor/currency already
358
+ * enforced there) plus the presenter its OWN authenticated context established. Returns the
359
+ * HOLDER's credential alone — {@link retrieveServingCredential} delivers the serving side's, so
360
+ * no private material crosses between the two parties. A release failure AFTER finalize leaves
361
+ * the row `active` and throws: the authenticated holder retries this same call and lands on the
362
+ * re-release path (release is idempotent for the row's life), so a transient release outage is
363
+ * recoverable without a half-session.
364
+ */
365
+ export async function redeemSession(grant, presenter, hooks) {
366
+ // (0) Identity first: the authenticated presenter IS the holder, exactly.
367
+ if (typeof presenter?.id !== "string" || typeof presenter?.lifecycleUid !== "string")
368
+ invalid("presenter is not an authenticated principal projection");
369
+ if (presenter.id !== grant.holder.id || presenter.lifecycleUid !== grant.holder.lifecycleUid)
370
+ throw new EpEnvelopeError("permission-denied", `session grant is holder-bound to ${grant.holder.id} (uid ${grant.holder.lifecycleUid}); the presenter is ${presenter.id} (uid ${presenter.lifecycleUid}); a grant is not a bearer artifact (SPEC 13.6/13.10)`);
371
+ const now = hooks.now?.() ?? Date.now();
372
+ if (now > grant.exp)
373
+ throw new EpEnvelopeError("expired", `session grant expired at ${grant.exp} (now ${now})`);
374
+ // Cheap fail-fast currency (authoritative re-check happens at the finalize below — these
375
+ // only avoid burning the one-use and staging for an already-dead pair).
376
+ const preHolder = await hooks.holderProcessEpoch(grant.holder);
377
+ if (preHolder !== grant.holder.processEpoch)
378
+ throw new EpEnvelopeError("expired", `holder process epoch ${String(preHolder)} is not the grant's ${grant.holder.processEpoch}; an unredeemed grant does not survive the caller's restart (SPEC 13.1/13.6)`);
379
+ const preServing = await hooks.servingEpoch(grant.endpoint, grant.serving.instanceId);
380
+ if (preServing !== grant.serving.epoch)
381
+ throw new EpEnvelopeError("expired", `serving epoch ${String(preServing)} is not the grant's ${grant.serving.epoch}; the session dies with the serving instance's epoch (SPEC 13.6)`);
382
+ // (1) Allocate both ids FIRST so the issuing row names both from its very first write — a crash
383
+ // after the create leaves a row that names exactly what the sweep revokes. Bounded + distinct:
384
+ // aliased ids would collapse the two parties' revocation into one name.
385
+ const ids = await hooks.allocateCredentialIds(grant);
386
+ assertCredentialId(ids.credCaller, "credCaller");
387
+ assertCredentialId(ids.credServing, "credServing");
388
+ if (ids.credCaller === ids.credServing)
389
+ invalid(`allocated credential ids alias ("${ids.credCaller}"); the two parties' ids must be distinct`);
390
+ // (2) The one-use: create-CAS the issuing row.
391
+ const row = {
392
+ sessionId: grant.sessionId,
393
+ endpoint: grant.endpoint,
394
+ serving: grant.serving,
395
+ holder: { principal: grant.holder.id, lifecycleUid: grant.holder.lifecycleUid },
396
+ grantSig: grant.sig,
397
+ credCaller: ids.credCaller,
398
+ credServing: ids.credServing,
399
+ revoked: { caller: false, serving: false },
400
+ state: "issuing",
401
+ exp: grant.exp,
402
+ };
403
+ const created = await hooks.ledger.createIssuing(row);
404
+ if (created === "exists") {
405
+ // The one-use is burned — with ONE authenticated exception: the holder whose redemption
406
+ // response was lost AFTER finalize retries the same call, and release is idempotent for
407
+ // the row's life (same bytes, no re-mint), so the retry re-releases the SAME credential.
408
+ // The ids allocated above were never staged and carry no bytes — orphans by design.
409
+ const existing = await hooks.ledger.read(grant.sessionId);
410
+ // The retry re-releases ONLY for an EXACT replay of the grant that WON the one-use: the
411
+ // presenter must be the holder AND the presenting grant's SIGNATURE must equal the winner's
412
+ // (the signature is the full verified-artifact identity — window, holder processEpoch,
413
+ // iat/nbf, nonce, issuer, everything signed — so a DIFFERENT signed grant reusing only the
414
+ // sessionId + a coordinate subset can never re-release the winner's credential, even with a
415
+ // larger flow window, SPEC 13.6/13.10).
416
+ if (existing !== undefined &&
417
+ existing.state === "active" &&
418
+ existing.holder.principal === presenter.id &&
419
+ existing.holder.lifecycleUid === presenter.lifecycleUid &&
420
+ existing.grantSig === grant.sig) {
421
+ // Re-release the WINNER's stored credential id (never the freshly allocated `ids`, which
422
+ // the create just lost): the ids allocated for this racing attempt carry no bytes.
423
+ return assertReleased(await hooks.releaseCredential(grant.sessionId, existing.credCaller), existing.credCaller, existing.exp);
424
+ }
425
+ throw new EpEnvelopeError("permission-denied", `session ${grant.sessionId} is already redeemed; the issuing create-CAS is the one-use, and only an exact replay of the winning grant re-releases (SPEC 13.6)`);
426
+ }
427
+ // (3) The LIFECYCLE FENCE: observe both issuance gates (leader-served), then stage both
428
+ // credential rows PINNED to those observations. A gate that moved — or is gone — means a
429
+ // barrier retired a party: the pinned write LOSES and nothing was ever usable.
430
+ try {
431
+ const pins = {
432
+ holder: await hooks.observeHolderGate(grant.holder),
433
+ serving: await hooks.observeServingGate(grant.endpoint, grant.serving.instanceId),
434
+ };
435
+ await hooks.stagePair(grant, ids, pins);
436
+ }
437
+ catch (e) {
438
+ return refuseAndCollect(hooks, grant.sessionId, ids, "retired", e instanceof EpEnvelopeError ? e : new EpEnvelopeError("permission-denied", `session ${grant.sessionId} credential staging lost the lifecycle gate (a barrier retired a party during redemption, SPEC 13.1/13.6): ${e?.message ?? String(e)}`));
439
+ }
440
+ // (4) FINALIZE fresh checks: leader-served epoch reads (the lifecycle gates were fenced by
441
+ // the pinned stage above, and a barrier that wins AFTER the stage finds both rows indexed),
442
+ // plus expiry AT the finalize — a slow stage must not activate a grant that died meanwhile.
443
+ const holderEpoch = await hooks.holderProcessEpoch(grant.holder);
444
+ if (holderEpoch !== grant.holder.processEpoch)
445
+ return refuseAndCollect(hooks, grant.sessionId, ids, "retired", new EpEnvelopeError("expired", `holder process epoch moved to ${String(holderEpoch)} during redemption; finalize fresh-checks the holder (SPEC 13.6)`));
446
+ const servingNow = await hooks.servingEpoch(grant.endpoint, grant.serving.instanceId);
447
+ if (servingNow !== grant.serving.epoch)
448
+ return refuseAndCollect(hooks, grant.sessionId, ids, "superseded", new EpEnvelopeError("expired", `serving epoch moved to ${String(servingNow)} during redemption; finalize fresh-checks the serving instance (SPEC 13.6)`));
449
+ const atFinalize = hooks.now?.() ?? Date.now();
450
+ if (atFinalize > grant.exp)
451
+ return refuseAndCollect(hooks, grant.sessionId, ids, "expired", new EpEnvelopeError("expired", `session grant expired at ${grant.exp} during redemption (now ${atFinalize}); finalize re-checks currency (SPEC 13.6)`));
452
+ // (5) The finalize CAS — a racing close/barrier wins here.
453
+ const finalized = await hooks.ledger.finalizeActive(grant.sessionId);
454
+ if (!finalized) {
455
+ for (const id of [ids.credCaller, ids.credServing]) {
456
+ try {
457
+ await hooks.revokeCredential(id);
458
+ await hooks.ledger.markRevoked(grant.sessionId, id);
459
+ }
460
+ catch {
461
+ /* sweep backstop (terminal-row retry) */
462
+ }
463
+ }
464
+ throw new EpEnvelopeError("conflict", `session ${grant.sessionId} finalize lost: the row left "issuing" during redemption (a racing close wins; nothing is released, SPEC 13.6)`);
465
+ }
466
+ // (6) Release ONLY the holder's credential (the serving side retrieves its own separately).
467
+ // A throw here leaves the row active: the authenticated holder retries and lands on the
468
+ // re-release path above — recoverable, never a half-session.
469
+ return assertReleased(await hooks.releaseCredential(grant.sessionId, ids.credCaller), ids.credCaller, grant.exp);
470
+ }
471
+ /** The serving instance retrieves ITS OWN credential after the session is `active`, through its
472
+ * own authenticated path (never the holder's redemption response — no private material crosses
473
+ * between the two parties, §13.6 per-party release). The presenter is the AUTHENTICATED serving
474
+ * identity (endpoint + instanceId + epoch, established by the auth path's own context); the row
475
+ * is read AUTHORITATIVELY from the ledger, never accepted as a caller-supplied projection.
476
+ * Release is idempotent for the row's life (lost-response retry), behind the exact identity. */
477
+ export async function retrieveServingCredential(sessionId, presenter, hooks) {
478
+ const row = await hooks.ledger.read(sessionId);
479
+ if (row === undefined)
480
+ throw new EpEnvelopeError("not-found", `session ${sessionId} has no ledger row (SPEC 13.6)`);
481
+ if (row.endpoint !== presenter.endpoint || row.serving.instanceId !== presenter.instanceId || row.serving.epoch !== presenter.epoch)
482
+ throw new EpEnvelopeError("permission-denied", `session ${sessionId} is pinned to serving ${row.endpoint}/${row.serving.instanceId}@${row.serving.epoch}, not the presenting ${presenter.endpoint}/${presenter.instanceId}@${presenter.epoch} (per-party release, SPEC 13.6)`);
483
+ if (row.state !== "active")
484
+ throw new EpEnvelopeError("failed-precondition", `session ${sessionId} is "${row.state}", not active; a credential is authority only once its row is active (SPEC 13.6)`);
485
+ return assertReleased(await hooks.releaseCredential(sessionId, row.credServing), row.credServing, row.exp);
486
+ }
487
+ /** The expiry sweep's per-row decision (the auth path enumerates `session.>` and calls this):
488
+ * an `issuing` or `active` row past its `exp` (plus the caller's margin) transitions
489
+ * `expired` and BOTH credential ids are revoked by name, each MARKED on success. A TERMINAL
490
+ * row with an UNMARKED id is retried — that retry (not a comment) is what makes every
491
+ * swallowed revoke failure in this module safe: the mark is set only by a revoke that
492
+ * succeeded, so half a pair can never quietly outlive its session. Returns whether this pass
493
+ * did work. Fully-collected terminal rows are never touched (retention: rows live at least
494
+ * max session exp + a recovery margin, §13.6). */
495
+ export async function sweepSessionRow(row, hooks, opts) {
496
+ const revokePending = async () => {
497
+ const pending = [];
498
+ if (!row.revoked.caller)
499
+ pending.push(row.credCaller);
500
+ if (!row.revoked.serving)
501
+ pending.push(row.credServing);
502
+ for (const id of pending) {
503
+ try {
504
+ await hooks.revokeCredential(id);
505
+ await hooks.ledger.markRevoked(row.sessionId, id);
506
+ }
507
+ catch {
508
+ /* the mark stays unset — the NEXT sweep pass retries exactly this id */
509
+ }
510
+ }
511
+ };
512
+ if (TERMINAL_STATE_SNAP.has(row.state)) {
513
+ if (row.revoked.caller && row.revoked.serving)
514
+ return false; // fully collected
515
+ await revokePending();
516
+ return true;
517
+ }
518
+ if (opts.now <= row.exp + (opts.marginMs ?? 0))
519
+ return false;
520
+ const moved = await hooks.ledger.transitionTerminal(row.sessionId, "expired");
521
+ if (!moved)
522
+ return false; // raced another terminator; its marks (or the next pass) finish the revokes
523
+ await revokePending();
524
+ return true;
525
+ }
526
+ export function encodeSessionFrame(frame) {
527
+ return new TextEncoder().encode(JSON.stringify(frame));
528
+ }
529
+ /** Fail-loud frame parse (closed schema): a garbled frame is a PROTOCOL error the rail
530
+ * surfaces via `onProtocolError` — never silently skipped, never a crash. */
531
+ export function parseSessionFrame(bytes) {
532
+ let raw;
533
+ try {
534
+ raw = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
535
+ }
536
+ catch {
537
+ invalid("session frame is not UTF-8 JSON");
538
+ }
539
+ if (!isRec(raw))
540
+ invalid("session frame is not an object");
541
+ const o = raw;
542
+ if (o.t === "f") {
543
+ for (const k of Object.keys(o))
544
+ if (k !== "t" && k !== "seq" && k !== "data" && k !== "ack")
545
+ invalid(`data frame carries unknown field "${k}"`);
546
+ if (typeof o.seq !== "number" || !Number.isSafeInteger(o.seq) || o.seq < 1)
547
+ invalid("data frame seq is not a positive integer");
548
+ if (!("data" in o))
549
+ invalid("data frame carries no data");
550
+ if (o.ack !== undefined && (typeof o.ack !== "number" || !Number.isSafeInteger(o.ack) || o.ack < 0))
551
+ invalid("data frame ack is not a non-negative integer");
552
+ return { t: "f", seq: o.seq, data: o.data, ...(o.ack !== undefined ? { ack: o.ack } : {}) };
553
+ }
554
+ if (o.t === "credit") {
555
+ for (const k of Object.keys(o))
556
+ if (k !== "t" && k !== "ack")
557
+ invalid(`credit frame carries unknown field "${k}"`);
558
+ if (typeof o.ack !== "number" || !Number.isSafeInteger(o.ack) || o.ack < 0)
559
+ invalid("credit frame ack is not a non-negative integer");
560
+ return { t: "credit", ack: o.ack };
561
+ }
562
+ if (o.t === "close") {
563
+ for (const k of Object.keys(o))
564
+ if (k !== "t")
565
+ invalid(`close frame carries unknown field "${k}"`);
566
+ return { t: "close" };
567
+ }
568
+ invalid(`unknown session frame type ${String(o.t)}`);
569
+ }
570
+ /**
571
+ * Open one side of an established session over its two core rails. The credentials the
572
+ * redemption released confine each side to exactly its pub/sub pair; this helper only speaks
573
+ * the framed protocol and enforces the bounded window — it grants nothing.
574
+ *
575
+ * FLOW CONTROL (panel-locked): the data window is bounded and per-direction; control frames
576
+ * (`credit`, `close`) are EXEMPT (a full window never blocks the credits that reopen it).
577
+ * RECEIVE-side acceptance is serialized and the (possibly async) handler AWAITED — credit
578
+ * emits only for frames the application actually accepted — and the pending-frame queue is
579
+ * bounded by the same window (`flood` past it), so neither side ever buffers unboundedly.
580
+ * Credits carry an ABSOLUTE cumulative watermark, PIGGYBACKED on reverse data frames, so a lost
581
+ * dedicated credit self-heals on the next reverse traffic; ANY deeper loss (including loss of
582
+ * already-emitted threshold credits) recovers on the KEEPALIVE re-emit; sustained loss or a
583
+ * dead peer surfaces the TIMER-driven `stall` fault (never a silent hang, even for a sender
584
+ * that stopped calling send). A dropped DATA frame is unrecoverable at this transport (EPS is
585
+ * at-most-once, core-only) and shows as a seq gap the app reacts to — reliability layers
586
+ * inside `data` or uses the journal/checkpoint composites.
587
+ */
588
+ export function openSessionRail(opts) {
589
+ const { grant, role } = opts;
590
+ const window = assertWindow(grant.window);
591
+ const maxPayload = opts.maxPayloadBytes ?? 1024 * 1024;
592
+ const idleCreditMs = opts.idleCreditMs ?? 1000;
593
+ const stallTimeoutMs = opts.stallTimeoutMs ?? 30_000;
594
+ const now = opts.now ?? (() => Date.now());
595
+ const setIntervalFn = opts.setIntervalFn ?? ((fn, ms) => setInterval(fn, ms));
596
+ const clearIntervalFn = opts.clearIntervalFn ?? ((h) => clearInterval(h));
597
+ const egressDir = role === "caller" ? "in" : "out";
598
+ const ingressDir = role === "caller" ? "out" : "in";
599
+ const egress = epsSubject(grant.space, grant.endpoint, grant.sessionId, grant.serving.epoch, egressDir);
600
+ const ingress = epsSubject(grant.space, grant.endpoint, grant.sessionId, grant.serving.epoch, ingressDir);
601
+ let seq = 0; // last sent
602
+ let ackedThrough = 0; // peer's contiguous ack for OUR egress (absolute)
603
+ let windowFullSince = 0; // when the window became full with no ack advance (0 = not blocked)
604
+ let expected = 1; // next ingress data seq we can deliver contiguously
605
+ let deliveredSinceCredit = 0;
606
+ let dataSinceIdleTick = false; // did ingress data arrive since the last idle tick?
607
+ let delivered = 0;
608
+ let closed = false;
609
+ let broken = false;
610
+ let tornDown = false;
611
+ let idleTimer;
612
+ let sub;
613
+ const creditEvery = Math.max(1, Math.ceil(window / 2));
614
+ // EXACTLY-ONCE local cleanup, whoever triggers it (local close, PEER close, or a protocol
615
+ // fault): a remote peer must never be able to leave this side holding a dangling
616
+ // subscription + interval per session (a remotely triggerable resource leak).
617
+ const teardown = () => {
618
+ if (tornDown)
619
+ return;
620
+ tornDown = true;
621
+ if (idleTimer)
622
+ clearIntervalFn(idleTimer);
623
+ sub?.unsubscribe();
624
+ };
625
+ const protocolError = (reason, detail) => {
626
+ broken = true;
627
+ teardown(); // a broken rail holds no resources
628
+ opts.onProtocolError?.(reason, detail);
629
+ };
630
+ // Absorb an absolute watermark from a credit frame OR a piggybacked data ack. Monotonic — a
631
+ // stale/duplicated advertisement never narrows the window; an ack past what we ever sent is a
632
+ // protocol violation (fail-loud, never silently widen).
633
+ const applyAck = (ack) => {
634
+ if (ack > seq) {
635
+ protocolError("credit-overrun", { ack, sent: seq });
636
+ return;
637
+ }
638
+ if (ack > ackedThrough) {
639
+ ackedThrough = ack;
640
+ windowFullSince = 0; // progress — reset the stall watchdog
641
+ }
642
+ };
643
+ const emitCredit = () => {
644
+ try {
645
+ opts.nc.publish(egress, encodeSessionFrame({ t: "credit", ack: expected - 1 }));
646
+ }
647
+ catch (e) {
648
+ protocolError("publish", e?.message ?? String(e));
649
+ }
650
+ };
651
+ // SERIALIZED data acceptance: the application accepts FIRST and may be ASYNC — NATS does not
652
+ // serialize callback promises, so the callback only enqueues and this single drain loop runs
653
+ // one handler at a time in seq order. The watermark advances and credit emits only after the
654
+ // handler RESOLVES (credit == the receiver's buffer actually freed: the §13.6 back-pressure
655
+ // semantic), so an async rejection refuses the frame exactly like a synchronous throw. The
656
+ // HEAD frame stays queued while its handler runs, so the window bound below counts it; a
657
+ // handler that resolves into a rail that closed or broke meanwhile advances NOTHING.
658
+ const ingressQueue = [];
659
+ let draining = false;
660
+ const drain = async () => {
661
+ if (draining)
662
+ return;
663
+ draining = true;
664
+ try {
665
+ while (!closed && !broken && ingressQueue.length > 0) {
666
+ const head = ingressQueue[0];
667
+ try {
668
+ await opts.onData(head.data, head.seq);
669
+ }
670
+ catch (e) {
671
+ // A rejection landing in a rail that closed or broke DURING the await reports
672
+ // NOTHING: the rail is already terminal (its fault, if any, was already surfaced),
673
+ // and a second protocolError would double-fault a dead rail.
674
+ if (closed || broken)
675
+ return;
676
+ protocolError("handler", e?.message ?? String(e));
677
+ return;
678
+ }
679
+ if (closed || broken)
680
+ return;
681
+ ingressQueue.shift();
682
+ expected++;
683
+ delivered++;
684
+ deliveredSinceCredit++;
685
+ if (deliveredSinceCredit >= creditEvery) {
686
+ deliveredSinceCredit = 0;
687
+ emitCredit();
688
+ }
689
+ }
690
+ }
691
+ finally {
692
+ draining = false;
693
+ }
694
+ };
695
+ sub = opts.nc.subscribe(ingress, {
696
+ callback: (err, msg) => {
697
+ if (closed || broken)
698
+ return;
699
+ if (err) {
700
+ protocolError("subscription", err.message);
701
+ return;
702
+ }
703
+ let frame;
704
+ try {
705
+ frame = parseSessionFrame(msg.data);
706
+ }
707
+ catch (e) {
708
+ protocolError("garbled-frame", e.message);
709
+ return;
710
+ }
711
+ if (frame.t === "credit") {
712
+ applyAck(frame.ack);
713
+ return;
714
+ }
715
+ if (frame.t === "close") {
716
+ closed = true;
717
+ teardown();
718
+ opts.onClose?.();
719
+ return;
720
+ }
721
+ // Data. Its piggybacked ack refreshes OUR credit first (self-heals a lost dedicated
722
+ // credit) — and an OVERRUNNING piggyback breaks the rail BEFORE the frame's data can
723
+ // reach the application: a protocol-invalid frame must have no application effect.
724
+ if (frame.ack !== undefined) {
725
+ applyAck(frame.ack);
726
+ if (broken)
727
+ return;
728
+ }
729
+ dataSinceIdleTick = true;
730
+ // Contiguity is judged against the queue's tail (the head may still be in its handler):
731
+ // a peer sending in order while an earlier handler is pending is NOT a gap.
732
+ const nextIngress = expected + ingressQueue.length;
733
+ if (frame.seq < nextIngress)
734
+ return; // duplicate — idempotent drop
735
+ if (frame.seq > nextIngress) {
736
+ protocolError("gap", { expected: nextIngress, got: frame.seq });
737
+ return;
738
+ }
739
+ // The ingress queue is bounded by the grant WINDOW (an honest peer can never have more
740
+ // unacknowledged frames in flight): a peer that ignores flow control while a handler is
741
+ // pending cannot pile promises here — the rail breaks instead (§13.6: never unbounded).
742
+ if (ingressQueue.length >= window) {
743
+ protocolError("flood", { queued: ingressQueue.length, window });
744
+ return;
745
+ }
746
+ ingressQueue.push({ seq: frame.seq, data: frame.data });
747
+ void drain();
748
+ },
749
+ });
750
+ // One tick drives BOTH recovery legs:
751
+ // - the KEEPALIVE credit re-emit: while this side has delivered anything and the peer went
752
+ // quiet, re-advertise the absolute watermark — deliberately NOT gated on what was already
753
+ // advertised (see idleCreditMs docs: the double-credit-loss counterexample).
754
+ // - the STALL WATCHDOG: a window that stays full past stallTimeoutMs with no ack advance
755
+ // breaks the rail with a DETECTABLE fault even if the sender never calls send() again.
756
+ if (idleCreditMs > 0 || stallTimeoutMs > 0) {
757
+ idleTimer = setIntervalFn(() => {
758
+ if (closed || broken)
759
+ return;
760
+ if (stallTimeoutMs > 0 && windowFullSince !== 0) {
761
+ const blockedMs = now() - windowFullSince;
762
+ if (blockedMs > stallTimeoutMs) {
763
+ protocolError("stall", { window, ackedThrough, sent: seq, blockedMs });
764
+ return;
765
+ }
766
+ }
767
+ if (idleCreditMs > 0 && !dataSinceIdleTick && expected > 1)
768
+ emitCredit();
769
+ dataSinceIdleTick = false;
770
+ }, idleCreditMs > 0 ? idleCreditMs : 1000);
771
+ idleTimer.unref?.();
772
+ }
773
+ return {
774
+ send(data) {
775
+ if (closed || broken)
776
+ throw new EpEnvelopeError("failed-precondition", "session rail is closed/broken; establish a new session (SPEC 13.6)");
777
+ if (seq >= Number.MAX_SAFE_INTEGER - 1) {
778
+ protocolError("seq-exhausted", { seq });
779
+ throw new EpEnvelopeError("failed-precondition", "session rail exhausted its sequence space; establish a new session (SPEC 13.6)");
780
+ }
781
+ if (seq - ackedThrough >= window) {
782
+ // The window is full. The timer is the primary stall detector; this path double-checks
783
+ // (belt for a caller running with timers disabled) and otherwise refuses TRANSIENTLY.
784
+ const t = now();
785
+ if (windowFullSince === 0)
786
+ windowFullSince = t;
787
+ else if (stallTimeoutMs > 0 && t - windowFullSince > stallTimeoutMs) {
788
+ protocolError("stall", { window, ackedThrough, sent: seq, blockedMs: t - windowFullSince });
789
+ throw new EpEnvelopeError("failed-precondition", `session rail stalled: the window stayed full ${t - windowFullSince}ms with no credit; the peer is unreachable, re-establish (SPEC 13.6)`);
790
+ }
791
+ throw new EpEnvelopeError("resource-exhausted", `session window is full (${window} unacknowledged frames); the flow window is bounded and nothing buffers (SPEC 13.6)`);
792
+ }
793
+ // Piggyback our absolute reverse-rail watermark so a lost dedicated credit self-heals.
794
+ const ack = expected - 1;
795
+ const frame = encodeSessionFrame({ t: "f", seq: seq + 1, data, ...(ack > 0 ? { ack } : {}) });
796
+ if (frame.byteLength > maxPayload)
797
+ throw new EpEnvelopeError("contract-invalid", `session frame is ${frame.byteLength} bytes, over the ${maxPayload}-byte payload ceiling`);
798
+ // Publish BEFORE advancing: a synchronous publish failure must not consume the seq (the
799
+ // peer would otherwise see a permanent gap from a frame that never left this process,
800
+ // and the local stats would count it in flight). A failed publish breaks the rail.
801
+ try {
802
+ opts.nc.publish(egress, frame);
803
+ }
804
+ catch (e) {
805
+ protocolError("publish", e?.message ?? String(e));
806
+ throw new EpEnvelopeError("failed-precondition", `session rail publish failed; the rail is broken, re-establish (SPEC 13.6): ${e?.message ?? String(e)}`);
807
+ }
808
+ seq++;
809
+ // Arm the watchdog the moment the window BECOMES full: a sender that now only waits
810
+ // (never calling send again) is still covered by the timer-driven stall check.
811
+ if (seq - ackedThrough >= window && windowFullSince === 0)
812
+ windowFullSince = now();
813
+ return seq;
814
+ },
815
+ close() {
816
+ if (closed) {
817
+ teardown(); // idempotent; also covers close-after-broken
818
+ return;
819
+ }
820
+ closed = true;
821
+ try {
822
+ opts.nc.publish(egress, encodeSessionFrame({ t: "close" }));
823
+ }
824
+ catch {
825
+ /* advisory only — the ledger is the authority */
826
+ }
827
+ teardown();
828
+ },
829
+ stats() {
830
+ return { sent: seq, ackedThrough, delivered, inFlight: seq - ackedThrough };
831
+ },
832
+ };
833
+ }
834
+ //# sourceMappingURL=endpoint-session.js.map