@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.
- 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,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
|