@cotal-ai/auth 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 (95) hide show
  1. package/dist/admission-mediator.d.ts +319 -0
  2. package/dist/admission-mediator.d.ts.map +1 -0
  3. package/dist/admission-mediator.js +1033 -0
  4. package/dist/admission-mediator.js.map +1 -0
  5. package/dist/auth-admin.d.ts +29 -0
  6. package/dist/auth-admin.d.ts.map +1 -0
  7. package/dist/auth-admin.js +224 -0
  8. package/dist/auth-admin.js.map +1 -0
  9. package/dist/authority-client.d.ts +215 -0
  10. package/dist/authority-client.d.ts.map +1 -0
  11. package/dist/authority-client.js +346 -0
  12. package/dist/authority-client.js.map +1 -0
  13. package/dist/barrier-evict.d.ts +16 -0
  14. package/dist/barrier-evict.d.ts.map +1 -0
  15. package/dist/barrier-evict.js +100 -0
  16. package/dist/barrier-evict.js.map +1 -0
  17. package/dist/connect-reader.d.ts +72 -0
  18. package/dist/connect-reader.d.ts.map +1 -0
  19. package/dist/connect-reader.js +154 -0
  20. package/dist/connect-reader.js.map +1 -0
  21. package/dist/continuity.d.ts.map +1 -1
  22. package/dist/continuity.js +3 -0
  23. package/dist/continuity.js.map +1 -1
  24. package/dist/credential-ledger.d.ts +343 -0
  25. package/dist/credential-ledger.d.ts.map +1 -0
  26. package/dist/credential-ledger.js +732 -0
  27. package/dist/credential-ledger.js.map +1 -0
  28. package/dist/drain-repair.d.ts +95 -0
  29. package/dist/drain-repair.d.ts.map +1 -0
  30. package/dist/drain-repair.js +305 -0
  31. package/dist/drain-repair.js.map +1 -0
  32. package/dist/idp.d.ts +19 -0
  33. package/dist/idp.d.ts.map +1 -1
  34. package/dist/idp.js +17 -0
  35. package/dist/idp.js.map +1 -1
  36. package/dist/index.d.ts +5 -2
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +29 -2
  39. package/dist/index.js.map +1 -1
  40. package/dist/issuer.d.ts +8 -0
  41. package/dist/issuer.d.ts.map +1 -1
  42. package/dist/issuer.js +4 -2
  43. package/dist/issuer.js.map +1 -1
  44. package/dist/ledger-scanner.d.ts +98 -0
  45. package/dist/ledger-scanner.d.ts.map +1 -0
  46. package/dist/ledger-scanner.js +352 -0
  47. package/dist/ledger-scanner.js.map +1 -0
  48. package/dist/ledger.d.ts +7 -0
  49. package/dist/ledger.d.ts.map +1 -1
  50. package/dist/ledger.js +53 -5
  51. package/dist/ledger.js.map +1 -1
  52. package/dist/lifecycle-registry.d.ts +397 -0
  53. package/dist/lifecycle-registry.d.ts.map +1 -0
  54. package/dist/lifecycle-registry.js +819 -0
  55. package/dist/lifecycle-registry.js.map +1 -0
  56. package/dist/permissions.d.ts +5 -1
  57. package/dist/permissions.d.ts.map +1 -1
  58. package/dist/permissions.js +29 -6
  59. package/dist/permissions.js.map +1 -1
  60. package/dist/plane-claim.d.ts +83 -0
  61. package/dist/plane-claim.d.ts.map +1 -0
  62. package/dist/plane-claim.js +290 -0
  63. package/dist/plane-claim.js.map +1 -0
  64. package/dist/provider.d.ts.map +1 -1
  65. package/dist/provider.js +2 -1
  66. package/dist/provider.js.map +1 -1
  67. package/dist/records-scanner.d.ts +78 -0
  68. package/dist/records-scanner.d.ts.map +1 -0
  69. package/dist/records-scanner.js +320 -0
  70. package/dist/records-scanner.js.map +1 -0
  71. package/dist/retirement-barrier.d.ts +217 -0
  72. package/dist/retirement-barrier.d.ts.map +1 -0
  73. package/dist/retirement-barrier.js +624 -0
  74. package/dist/retirement-barrier.js.map +1 -0
  75. package/dist/retirement-cleaner.d.ts +54 -0
  76. package/dist/retirement-cleaner.d.ts.map +1 -0
  77. package/dist/retirement-cleaner.js +158 -0
  78. package/dist/retirement-cleaner.js.map +1 -0
  79. package/dist/root-credential.d.ts +26 -0
  80. package/dist/root-credential.d.ts.map +1 -0
  81. package/dist/root-credential.js +116 -0
  82. package/dist/root-credential.js.map +1 -0
  83. package/dist/service.d.ts +60 -0
  84. package/dist/service.d.ts.map +1 -1
  85. package/dist/service.js +317 -10
  86. package/dist/service.js.map +1 -1
  87. package/dist/session-ledger.d.ts +224 -0
  88. package/dist/session-ledger.d.ts.map +1 -0
  89. package/dist/session-ledger.js +713 -0
  90. package/dist/session-ledger.js.map +1 -0
  91. package/dist/token.d.ts +17 -0
  92. package/dist/token.d.ts.map +1 -1
  93. package/dist/token.js +30 -2
  94. package/dist/token.js.map +1 -1
  95. package/package.json +5 -3
@@ -0,0 +1,713 @@
1
+ import { Kvm } from "@nats-io/kv";
2
+ import { jetstreamManager } from "@nats-io/jetstream";
3
+ import { SignJWT } from "jose";
4
+ import { EpEnvelopeError, epAuthBucket, epsSubject, endpointToken, assertLifecycleToken, parsePrincipalKey, isPrincipalOwnerToken, sessionLedgerKey, assertSessionStateTransition, sweepSessionRow, SESSION_TERMINAL_STATES, } from "@cotal-ai/core";
5
+ import { observeGate, registryStores, readLifecycleMappingLeader, } from "./lifecycle-registry.js";
6
+ import { createRowByteIdempotent, epcredRowKey, markLedgerRowRevoked, parseLedgerRow, } from "./credential-ledger.js";
7
+ import { assertScannerSpace } from "./ledger-scanner.js";
8
+ const enc = new TextEncoder();
9
+ const dec = new TextDecoder();
10
+ const isRec = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
11
+ const uint = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
12
+ /** The store's JetStream handles for the marker-preserving sweep enumeration (a bucket's own
13
+ * `kv.keys()` FILTERS DEL/PURGE, so a tombstone can only be seen through a raw stream read).
14
+ * Module-private, keyed off the branded store. */
15
+ const AUTH_STORES = new WeakMap();
16
+ function assertStore(store) {
17
+ if (!AUTH_STORES.has(store))
18
+ throw new EpEnvelopeError("failed-precondition", `the session auth store was not constructed by openSessionAuthStore(); a hand-assembled {kv, space} never authorizes; the space bond is constructed, not asserted (SPEC 13.12)`);
19
+ }
20
+ /** Open the per-space auth store and PROVE its security-critical shape, not merely that some
21
+ * bucket exists. `Kvm.open` binds lazily, so this forces the bind AND inspects the backing
22
+ * stream config: the store MUST be `allow_direct=false` (every read here is an authority read
23
+ * treated as leader-served by construction; a Direct-Get-capable bucket would let
24
+ * release/close/connect decisions read follower-stale, §13.1), MUST carry NO age eviction
25
+ * (bucket-wide OR per-message TTL — an age-evicted `session.`/gate authority key silently drops
26
+ * a fence, §13.12), and MUST NOT be a mirror/sourced stream (a mirror is a follower copy). A
27
+ * config-drifted bucket fails loud HERE, never at the first authority read. Returns the BRANDED
28
+ * store the hooks/close/sweep seams consume. */
29
+ export async function openSessionAuthStore(nc, space) {
30
+ const bucket = epAuthBucket(space);
31
+ const kv = await new Kvm(nc).open(bucket);
32
+ let cfg;
33
+ try {
34
+ await kv.status();
35
+ cfg = (await (await jetstreamManager(nc)).streams.info(`KV_${bucket}`)).config;
36
+ }
37
+ catch (e) {
38
+ throw new EpEnvelopeError("failed-precondition", `the auth store ${bucket} is not provisioned (run space setup; SPEC 13.12): ${e?.message ?? String(e)}`);
39
+ }
40
+ if (cfg.allow_direct !== false)
41
+ throw new EpEnvelopeError("failed-precondition", `the auth store ${bucket} has allow_direct=${String(cfg.allow_direct)}, not false; every authority read here must be leader-served, a Direct-Get-capable store defeats read-your-writes (§13.1); reprovision`);
42
+ if (typeof cfg.max_age === "number" && cfg.max_age > 0)
43
+ throw new EpEnvelopeError("failed-precondition", `the auth store ${bucket} carries bucket-wide age eviction (max_age ${cfg.max_age}); an age-evicted session/gate authority key silently drops a fence (§13.12); reprovision without MaxAge`);
44
+ if (cfg.mirror !== undefined || (Array.isArray(cfg.sources) && cfg.sources.length > 0))
45
+ throw new EpEnvelopeError("failed-precondition", `the auth store ${bucket} is a mirror/sourced stream; a follower copy cannot serve read-your-writes authority reads (§13.1); bind the primary`);
46
+ // FROZEN before branding (the scanner-handle discipline): the brand keys this exact reference,
47
+ // and the freeze guarantees `kv`/`space` are still the constructed pair when a seam asserts the
48
+ // brand — a post-brand `store.space = "other"` / `store.kv = foreignKv` rebind throws instead of
49
+ // redirecting authority rows to a foreign space over a still-valid brand.
50
+ const store = Object.freeze({ kv, space });
51
+ AUTH_STORES.set(store, true);
52
+ return store;
53
+ }
54
+ // ---- the session ledger over session.<sessionId> rows ------------------------------------------
55
+ function parseRow(raw, key) {
56
+ let o;
57
+ try {
58
+ o = JSON.parse(dec.decode(raw));
59
+ }
60
+ catch {
61
+ throw new EpEnvelopeError("internal", `the session row ${key} is not JSON; garbled trusted-path state never authorizes (SPEC 13.3)`);
62
+ }
63
+ if (!isRec(o))
64
+ throw new EpEnvelopeError("internal", `the session row ${key} is not an object`);
65
+ const allowed = new Set(["sessionId", "endpoint", "serving", "holder", "grantSig", "credCaller", "credServing", "revoked", "state", "exp"]);
66
+ for (const k of Object.keys(o))
67
+ if (!allowed.has(k))
68
+ throw new EpEnvelopeError("internal", `the session row ${key} carries the unknown field "${k}" (closed schema, SPEC 13.3)`);
69
+ const r = o;
70
+ const states = ["issuing", "active", ...SESSION_TERMINAL_STATES];
71
+ if (typeof r.sessionId !== "string" || typeof r.endpoint !== "string" ||
72
+ !isRec(r.serving) || typeof r.serving.instanceId !== "string" || !uint(r.serving.epoch) ||
73
+ !isRec(r.holder) || typeof r.holder.principal !== "string" || typeof r.holder.lifecycleUid !== "string" ||
74
+ typeof r.grantSig !== "string" || r.grantSig.length === 0 ||
75
+ typeof r.credCaller !== "string" || typeof r.credServing !== "string" || r.credCaller === r.credServing ||
76
+ !isRec(r.revoked) || typeof r.revoked.caller !== "boolean" || typeof r.revoked.serving !== "boolean" ||
77
+ typeof r.state !== "string" || !states.includes(r.state) || !uint(r.exp))
78
+ throw new EpEnvelopeError("internal", `the session row ${key} does not validate; garbled trusted-path state never authorizes (SPEC 13.3)`);
79
+ // KEY BINDING: the embedded sessionId MUST equal the `session.<id>` key, so a key-mismatched
80
+ // poison row can never make a transition/sweep/release act on a DIFFERENT session.
81
+ if (key !== sessionLedgerKey(r.sessionId))
82
+ throw new EpEnvelopeError("internal", `the session row at ${key} embeds sessionId "${r.sessionId}" (key ${sessionLedgerKey(r.sessionId)}); a key-mismatched row never authorizes (SPEC 13.6)`);
83
+ // CREDENTIAL-ID BINDING: each id must be the DETERMINISTIC per-party coordinate, so a
84
+ // semantically poisoned row cannot swap the caller/serving rails.
85
+ if (r.credCaller !== callerCredId(r.holder.lifecycleUid, r.sessionId) || r.credServing !== servingCredId(r.endpoint, r.serving.instanceId, r.sessionId))
86
+ throw new EpEnvelopeError("internal", `the session row ${key} names non-deterministic credential ids (caller "${r.credCaller}", serving "${r.credServing}"); a swapped/aliased id never authorizes (SPEC 13.6)`);
87
+ return r;
88
+ }
89
+ const isCasLoss = (e) => {
90
+ const code = e?.api_error?.err_code;
91
+ return code === 10071 || code === 10164 || /wrong last sequence/i.test(e?.message ?? "");
92
+ };
93
+ /** The real-KV {@link SessionLedger}: create-only CAS for the one-use `issuing` row,
94
+ * revision-pinned CAS for every transition, monotonic states enforced on the write path. */
95
+ export function kvSessionLedger(kv) {
96
+ const readEntry = async (sessionId) => {
97
+ const key = sessionLedgerKey(sessionId);
98
+ const entry = await kv.get(key);
99
+ if (!entry)
100
+ return undefined;
101
+ // A DEL/PURGE marker is CORRUPTION, never absence: session rows are terminal-state
102
+ // authority, never deleted (SPEC 13.6/13.12). Collapsing a marker into "no session" would
103
+ // let a deleted row's still-live serving credential silently survive a takeover
104
+ // reconciliation ("nothing to reconcile") and every sweep.
105
+ if (entry.operation !== "PUT")
106
+ throw new EpEnvelopeError("failed-precondition", `the session row ${key} carries a ${entry.operation} marker; a session row is never deleted (corruption, not absence, SPEC 13.12)`);
107
+ return { row: parseRow(entry.value, key), revision: entry.revision };
108
+ };
109
+ return {
110
+ async read(sessionId) {
111
+ return (await readEntry(sessionId))?.row;
112
+ },
113
+ async createIssuing(row) {
114
+ try {
115
+ await kv.create(sessionLedgerKey(row.sessionId), enc.encode(JSON.stringify(row)));
116
+ return "created";
117
+ }
118
+ catch (e) {
119
+ if (isCasLoss(e))
120
+ return "exists";
121
+ throw new EpEnvelopeError("unavailable", `the issuing create for session ${row.sessionId} is ambiguous; redemption fails closed (SPEC 13.6): ${e?.message ?? String(e)}`);
122
+ }
123
+ },
124
+ async finalizeActive(sessionId) {
125
+ const cur = await readEntry(sessionId);
126
+ if (!cur || cur.row.state !== "issuing")
127
+ return false;
128
+ try {
129
+ await kv.update(sessionLedgerKey(sessionId), enc.encode(JSON.stringify({ ...cur.row, state: "active" })), cur.revision);
130
+ return true;
131
+ }
132
+ catch (e) {
133
+ if (isCasLoss(e))
134
+ return false; // a racing close/sweep/barrier won
135
+ throw new EpEnvelopeError("unavailable", `the finalize CAS for session ${sessionId} is ambiguous; the redemption fails closed and the sweep collects (SPEC 13.6): ${e?.message ?? String(e)}`);
136
+ }
137
+ },
138
+ async transitionTerminal(sessionId, to) {
139
+ for (let attempt = 0; attempt < 3; attempt++) {
140
+ const cur = await readEntry(sessionId);
141
+ if (!cur)
142
+ return false;
143
+ if (SESSION_TERMINAL_STATES.includes(cur.row.state))
144
+ return false;
145
+ assertSessionStateTransition(cur.row.state, to);
146
+ try {
147
+ await kv.update(sessionLedgerKey(sessionId), enc.encode(JSON.stringify({ ...cur.row, state: to })), cur.revision);
148
+ return true;
149
+ }
150
+ catch (e) {
151
+ if (isCasLoss(e))
152
+ continue; // re-read: terminal now → false; still live → retry the pin
153
+ throw new EpEnvelopeError("unavailable", `the terminal CAS for session ${sessionId} is ambiguous (SPEC 13.6): ${e?.message ?? String(e)}`);
154
+ }
155
+ }
156
+ throw new EpEnvelopeError("unavailable", `the terminal transition for session ${sessionId} kept losing its pin; retry (SPEC 13.6)`);
157
+ },
158
+ async markRevoked(sessionId, credentialId) {
159
+ for (let attempt = 0; attempt < 3; attempt++) {
160
+ const cur = await readEntry(sessionId);
161
+ if (!cur)
162
+ throw new EpEnvelopeError("failed-precondition", `no session row for ${sessionId}; a revocation mark needs its row (SPEC 13.6)`);
163
+ const which = credentialId === cur.row.credCaller ? "caller" : credentialId === cur.row.credServing ? "serving" : undefined;
164
+ if (which === undefined)
165
+ throw new EpEnvelopeError("failed-precondition", `credential ${credentialId} is not named by session ${sessionId}; marks are by-name (SPEC 13.6)`);
166
+ if (cur.row.revoked[which])
167
+ return;
168
+ try {
169
+ await kv.update(sessionLedgerKey(sessionId), enc.encode(JSON.stringify({ ...cur.row, revoked: { ...cur.row.revoked, [which]: true } })), cur.revision);
170
+ return;
171
+ }
172
+ catch (e) {
173
+ if (isCasLoss(e))
174
+ continue;
175
+ throw new EpEnvelopeError("unavailable", `the revocation mark for ${credentialId} is ambiguous; the sweep retries the unmarked id (SPEC 13.6): ${e?.message ?? String(e)}`);
176
+ }
177
+ }
178
+ throw new EpEnvelopeError("unavailable", `the revocation mark for ${credentialId} kept losing its pin; the sweep retries (SPEC 13.6)`);
179
+ },
180
+ };
181
+ }
182
+ /** The endpoint gate key `epgate.<endpoint>.<instanceId>` (an instanceId is unique ONLY within
183
+ * `(space, endpoint)`, so the key is endpoint-qualified — equal instanceIds under two
184
+ * endpoints never collide on the gate or the credential family, §13.1/§13.6). */
185
+ export const epgateKey = (endpoint, instanceId) => `epgate.${endpointToken(endpoint)}.${assertLifecycleToken(instanceId, "instanceId")}`;
186
+ /** The DETERMINISTIC per-party credential ids: the caller under its holder lifecycle (the
187
+ * `cred.` family), the serving under its (endpoint, instanceId) (the `epcred.` family) — the
188
+ * id encodes the party and prefixes its own ledger key. */
189
+ const callerCredId = (holderUid, sessionId) => `${holderUid}.${sessionId}.c`;
190
+ const servingCredId = (endpoint, instanceId, sessionId) => `${endpointToken(endpoint)}.${instanceId}.${sessionId}.s`;
191
+ /** Route a deterministic session credential id to its NORMATIVE ledger row key: `.c` ids live
192
+ * under the holder's agent family (`cred.<lifecycleUid>.<sessionId>.c`), `.s` ids under the
193
+ * serving endpoint family (`epcred.<endpoint>.<instanceId>.<sessionId>.s`). */
194
+ function credLedgerKey(id) {
195
+ if (id.endsWith(".c"))
196
+ return `cred.${id}`;
197
+ if (id.endsWith(".s"))
198
+ return `epcred.${id}`;
199
+ throw new EpEnvelopeError("failed-precondition", `credential id ${JSON.stringify(id)} names neither session party (…​.c | …​.s); nothing routes (SPEC 13.6)`);
200
+ }
201
+ function parseEndpointGate(raw, key) {
202
+ let o;
203
+ try {
204
+ o = JSON.parse(dec.decode(raw));
205
+ }
206
+ catch {
207
+ throw new EpEnvelopeError("internal", `the endpoint gate ${key} is not JSON (SPEC 13.1)`);
208
+ }
209
+ if (!isRec(o))
210
+ throw new EpEnvelopeError("internal", `the endpoint gate ${key} is not an object`);
211
+ const allowed = new Set(["state", "generation", "processEpoch", "registrationRevision", "nameAuthorityRevision", "principal", "op"]);
212
+ for (const k of Object.keys(o))
213
+ if (!allowed.has(k))
214
+ throw new EpEnvelopeError("internal", `the endpoint gate ${key} carries the unknown field "${k}" (closed schema, SPEC 13.1)`);
215
+ if (!["open", "frozen", "retired"].includes(o.state) || !uint(o.generation) || !uint(o.processEpoch) || !uint(o.registrationRevision) || !uint(o.nameAuthorityRevision))
216
+ throw new EpEnvelopeError("internal", `the endpoint gate ${key} does not validate (SPEC 13.1)`);
217
+ // The serving principal must be a REAL owner-grammar principal (`u_…`/`local` + actor), the
218
+ // same boundary the ledger rows enforce — a dot-form-only check admits `foo.bar`, which
219
+ // stages/finalizes a session whose serving epcred row later refuses to parse, leaving an
220
+ // active session with a poisoned, unenumerable serving half (SPEC 13.1).
221
+ const principal = typeof o.principal === "string" ? parsePrincipalKey(o.principal) : null;
222
+ if (principal === null || !isPrincipalOwnerToken(principal.owner))
223
+ throw new EpEnvelopeError("internal", `the endpoint gate ${key} does not carry a CONNZ-attributable serving principal (owner-grammar owner.actor, SPEC 13.1)`);
224
+ if ((o.state === "frozen" || o.state === "retired") && !isRec(o.op))
225
+ throw new EpEnvelopeError("internal", `the endpoint gate ${key} is ${o.state} without its durable op intent (SPEC 13.1)`);
226
+ if (o.state === "open" && o.op !== undefined)
227
+ throw new EpEnvelopeError("internal", `the endpoint gate ${key} is open but carries an op intent (SPEC 13.1)`);
228
+ if (o.op !== undefined) {
229
+ const op = o.op;
230
+ for (const k of Object.keys(op))
231
+ if (k !== "opId" && k !== "kind" && k !== "successor")
232
+ throw new EpEnvelopeError("internal", `the endpoint gate ${key} op intent carries the unknown field "${k}" (closed schema)`);
233
+ if (typeof op.opId !== "string" || !["activation", "takeover", "registration", "retirement"].includes(op.kind))
234
+ throw new EpEnvelopeError("internal", `the endpoint gate ${key} op intent does not validate (SPEC 13.1)`);
235
+ // The agent gate's STATE x KIND and successor invariants apply to the endpoint family too
236
+ // (SPEC 13.1 per-kind transition sets): a retired gate belongs only to an activation orphan
237
+ // or a retirement, and only takeover/registration may stage a successor summary.
238
+ if (o.state === "retired" && op.kind !== "activation" && op.kind !== "retirement")
239
+ throw new EpEnvelopeError("internal", `the endpoint gate ${key} is retired under a ${op.kind} op; only an activation orphan or a retirement terminalizes (SPEC 13.1); impossible persisted state, refused`);
240
+ if (op.successor !== undefined && (typeof op.successor !== "string" || op.successor.length === 0 || (op.kind !== "takeover" && op.kind !== "registration")))
241
+ throw new EpEnvelopeError("internal", `the endpoint gate ${key} op intent carries an invalid successor (SPEC 13.1: only takeover/registration stage successors, and the summary is a non-empty token)`);
242
+ try {
243
+ assertLifecycleToken(op.opId);
244
+ }
245
+ catch {
246
+ throw new EpEnvelopeError("internal", `the endpoint gate ${key} op intent carries a malformed opId (SPEC 13.1)`);
247
+ }
248
+ }
249
+ return o;
250
+ }
251
+ /** Write an endpoint gate. This is the D14 endpoint-registration stand-in for provisioning and
252
+ * smokes, NOT a production authority surface (production endpoint gates are written only by
253
+ * the registration/takeover machinery with revision-pinned CAS, never an unpinned `put`). It
254
+ * is deliberately NOT re-exported from the package index. */
255
+ export async function writeEndpointGate(kv, endpoint, instanceId, gate) {
256
+ await kv.put(epgateKey(endpoint, instanceId), enc.encode(JSON.stringify(gate)));
257
+ }
258
+ /** Observe the serving instance's endpoint gate (candidate read feeding the pinned touch). A
259
+ * DEL/PURGE marker refuses loudly — a gate is never deleted (corruption, not absence). */
260
+ async function observeEndpointGate(kv, endpoint, instanceId, what) {
261
+ const key = epgateKey(endpoint, instanceId);
262
+ const entry = await kv.get(key);
263
+ if (!entry)
264
+ throw new EpEnvelopeError("permission-denied", `${what} has no endpoint issuance gate (${key}); an unregistered instance mints nothing (SPEC 13.1)`);
265
+ if (entry.operation !== "PUT")
266
+ throw new EpEnvelopeError("failed-precondition", `the endpoint gate ${key} carries a ${entry.operation} marker; a gate is never deleted (corruption, not absence, SPEC 13.12)`);
267
+ const gate = parseEndpointGate(entry.value, key);
268
+ if (gate.state !== "open")
269
+ throw new EpEnvelopeError("permission-denied", `${what}'s endpoint issuance gate is "${gate.state}"; only an open gate mints (a frozen gate is a barrier in flight, a retired one is terminal, SPEC 13.1)`);
270
+ return { pin: { key, revision: entry.revision }, gate };
271
+ }
272
+ /** The durable serve-issuance gate (§13.1) over the durable endpoint families — the real-KV
273
+ * implementation of core's `EpIssuanceGate` seam: core's serve mint (`mintCreds`, profile
274
+ * `endpoint-serve`) fences its release on the `epgate.<endpoint>.<instanceId>` key and stages
275
+ * its per-JWT row under `epcred.<endpoint>.<instanceId>.<credentialId>` — the SAME
276
+ * observe/stage/commit/revoke protocol the in-memory smoke seam models, over the real KV.
277
+ *
278
+ * NOT YET WIRED to a production mint path (fact H3): today its only caller is the session-ledger
279
+ * smoke. Wiring the endpoint-family authority connection that mints serve credentials — with the
280
+ * matching `epgate.>`/`epcred.>` grants, the freeze/enumerate/revoke barrier half, and
281
+ * stale-credential reconnect denial — is the post-D14 daemon-composition slice; the standing
282
+ * writer/barrier connections carry neither `epgate.>` nor `epcred.>` today. This module provides
283
+ * the durable seam so that wiring is a composition, not a rewrite. It closes the recorded
284
+ * D13/D14 durable-KEY gate (the endpoint-qualified families are normative, SPEC 13.9/13.12).
285
+ *
286
+ * Mapping to the NORMATIVE closed row schema (§13.1): the staged row's
287
+ * identity/lineage/state/exp become the CredentialLedgerRow (`lifecycleUid` is the endpoint
288
+ * instance's instanceId). Its gate-coordinate fields
289
+ * (generation/processEpoch/registrationRevision/nameAuthorityRevision) and the holder nkey
290
+ * (`credentialKey`) are NOT persisted on the row: the closed schema refuses unknown fields,
291
+ * the coordinates are pinned by the GATE key the commit CASes (one key, §13.1), and
292
+ * revocation/eviction route by credentialId/holderPrincipal, never nkey. `commit` is the
293
+ * pinned identical-bytes TOUCH at the observed revision — a barrier that moved the gate since
294
+ * observation makes the mint LOSE, the session adapter's exact discipline.
295
+ *
296
+ * Consumes the BRANDED {@link SessionAuthStore} ({@link openSessionAuthStore}), never a raw
297
+ * `{kv, space}` pair: the space bond is constructed, not asserted, so a caller can never pair
298
+ * another bucket's KV with a desired space label and fence/stage against the wrong store. */
299
+ export function kvServeIssuanceGate(store, args) {
300
+ assertStore(store);
301
+ const kv = store.kv;
302
+ const endpoint = endpointToken(args.endpoint);
303
+ const instanceId = assertLifecycleToken(args.instanceId, "instanceId");
304
+ const key = epgateKey(endpoint, instanceId);
305
+ return {
306
+ observe: async () => {
307
+ const entry = await kv.get(key);
308
+ if (!entry)
309
+ return null; // no gate => the mint fails closed (core refuses a null observe)
310
+ if (entry.operation !== "PUT")
311
+ throw new EpEnvelopeError("failed-precondition", `the endpoint gate ${key} carries a ${entry.operation} marker; a gate is never deleted (corruption, not absence, SPEC 13.12)`);
312
+ const gate = parseEndpointGate(entry.value, key);
313
+ return {
314
+ space: store.space, endpoint, lifecycleUid: instanceId,
315
+ // Carry the gate's registered serving principal so the core mint fence can bind the minted
316
+ // owner.actor to it (§13.1:1056-1069: a sibling actor cannot win the gate).
317
+ principal: gate.principal,
318
+ state: gate.state, generation: gate.generation, processEpoch: gate.processEpoch,
319
+ registrationRevision: gate.registrationRevision, nameAuthorityRevision: gate.nameAuthorityRevision,
320
+ revision: entry.revision,
321
+ };
322
+ },
323
+ stage: async (row) => {
324
+ // The staged row must BE this gate's instance — a foreign endpoint/instance row through
325
+ // this adapter is a caller bug, never silently redirected into another family.
326
+ if (row.endpoint !== endpoint || row.lifecycleUid !== instanceId)
327
+ throw new EpEnvelopeError("failed-precondition", `the staged serve row names ${row.endpoint}/${row.lifecycleUid} but this gate serves ${endpoint}/${instanceId}; a row never crosses families (SPEC 13.1)`);
328
+ if (typeof row.exp !== "number")
329
+ throw new EpEnvelopeError("failed-precondition", `the staged serve row for ${endpoint}/${instanceId} carries no expiry; the normative ledger row requires one (SPEC 13.1)`);
330
+ const ledgerRow = {
331
+ credentialId: row.credentialId, holderPrincipal: row.holderPrincipal,
332
+ lifecycleUid: instanceId, endpoint, sourceChain: [...row.sourceChain], state: "active", exp: row.exp,
333
+ };
334
+ const rowKey = epcredRowKey(endpoint, instanceId, row.credentialId);
335
+ // Round-trip the writer's own bytes through the consuming parser BEFORE the create: a
336
+ // row this trusted path would itself refuse to read never lands durably.
337
+ parseLedgerRow(enc.encode(JSON.stringify(ledgerRow)), rowKey);
338
+ await createRowByteIdempotent(kv, rowKey, ledgerRow);
339
+ },
340
+ commit: async (expectedRevision) => {
341
+ const entry = await kv.get(key);
342
+ if (!entry || entry.operation !== "PUT" || entry.revision !== expectedRevision)
343
+ return false;
344
+ if (parseEndpointGate(entry.value, key).state !== "open")
345
+ return false;
346
+ try {
347
+ await kv.update(key, entry.value, expectedRevision);
348
+ return true;
349
+ }
350
+ catch (e) {
351
+ if (isCasLoss(e))
352
+ return false; // a barrier froze/reopened since observation; the mint loses
353
+ throw new EpEnvelopeError("unavailable", `the serve-issuance gate touch for ${key} is ambiguous; the mint fails closed (SPEC 13.1): ${e?.message ?? String(e)}`);
354
+ }
355
+ },
356
+ revoke: async (row) => {
357
+ // `revoke` runs ONLY after a successful `stage` (finalizeServeIssuance's non-win cleanup), so
358
+ // the row MUST exist. Route straight through markLedgerRowRevoked, which is idempotent on an
359
+ // already-revoked row and FAILS LOUD on an absent/DEL row: a vanished never-delete ledger row
360
+ // is corruption, never a "never staged" idempotence case, and must not be hidden (fact M7).
361
+ await markLedgerRowRevoked(kv, epcredRowKey(endpoint, instanceId, row.credentialId));
362
+ },
363
+ };
364
+ }
365
+ const stagePinKey = (sessionId, party) => `stage.session.${sessionId}.${party === "caller" ? "c" : "s"}`;
366
+ function parseStagePin(raw, key) {
367
+ let o;
368
+ try {
369
+ o = JSON.parse(dec.decode(raw));
370
+ }
371
+ catch {
372
+ throw new EpEnvelopeError("internal", `the session stage pin ${key} is not JSON; garbled trusted-path state never authorizes (SPEC 13.3)`);
373
+ }
374
+ if (!isRec(o))
375
+ throw new EpEnvelopeError("internal", `the session stage pin ${key} is not an object`);
376
+ const allowed = new Set(["v", "kind", "sessionId", "party", "kid", "kidThumbprint", "exp"]);
377
+ for (const k of Object.keys(o))
378
+ if (!allowed.has(k))
379
+ throw new EpEnvelopeError("internal", `the session stage pin ${key} carries the unknown field "${k}" (closed schema; a garbled trusted write never authorizes, SPEC 13.3)`);
380
+ const r = o;
381
+ if (r.v !== 1 || r.kind !== "session" || typeof r.sessionId !== "string" || (r.party !== "caller" && r.party !== "serving") ||
382
+ typeof r.kid !== "string" || r.kid.length === 0 || typeof r.kidThumbprint !== "string" || r.kidThumbprint.length === 0 || !uint(r.exp))
383
+ throw new EpEnvelopeError("internal", `the session stage pin ${key} does not validate; a structurally malformed pin never becomes a signed capability (SPEC 13.3)`);
384
+ if (key !== stagePinKey(r.sessionId, r.party))
385
+ throw new EpEnvelopeError("internal", `the session stage pin at ${key} embeds (${r.sessionId}, ${r.party}); a key-mismatched pin never authorizes (SPEC 13.6)`);
386
+ return r;
387
+ }
388
+ /** Build the production {@link SessionRedemptionHooks} over the branded auth store + the
389
+ * sealed D13 registry/reader (both brands enforced — a hand-assembled context never
390
+ * authorizes, at construction for the store/registry and at first use for the reader). */
391
+ export function sessionRedemptionHooks(deps) {
392
+ assertStore(deps.store);
393
+ registryStores(deps.registry); // brand check: throws on a hand-assembled registry
394
+ const { kv, space } = deps.store;
395
+ const { signer, registry, reader } = deps;
396
+ if (registry.space !== space || reader.space !== space)
397
+ throw new EpEnvelopeError("failed-precondition", `the lifecycle registry/reader are bonded to spaces "${registry.space}"/"${reader.space}", not the auth store's "${space}"; cross-space authority never composes (SPEC 13.12)`);
398
+ const ledger = kvSessionLedger(kv);
399
+ const now = deps.now
400
+ ? () => { const t = deps.now(); if (!Number.isSafeInteger(t) || t < 0)
401
+ throw new EpEnvelopeError("failed-precondition", `the session clock returned ${JSON.stringify(t)}, not a non-negative safe integer; a malformed clock never authorizes (SPEC 13.10)`); return t; }
402
+ : undefined;
403
+ // Re-derive the two eps rails from durable session coordinates (space, endpoint, serving
404
+ // epoch, sessionId) — NEVER the stored subject arrays — so a corrupt credential row cannot
405
+ // sign widened/foreign subjects as authority.
406
+ const railSubjects = (row, party) => {
407
+ const inSubj = epsSubject(space, row.endpoint, row.sessionId, row.serving.epoch, "in");
408
+ const outSubj = epsSubject(space, row.endpoint, row.sessionId, row.serving.epoch, "out");
409
+ return party === "caller" ? { pub: [inSubj], sub: [outSubj] } : { pub: [outSubj], sub: [inSubj] };
410
+ };
411
+ const release = async (sessionId, credentialId) => {
412
+ const rowEntry = await kv.get(sessionLedgerKey(sessionId));
413
+ if (!rowEntry || rowEntry.operation !== "PUT")
414
+ throw new EpEnvelopeError("failed-precondition", `no session row for ${sessionId}; nothing releases without its authority row (SPEC 13.6)`);
415
+ const row = parseRow(rowEntry.value, sessionLedgerKey(sessionId));
416
+ if (row.state !== "active")
417
+ 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)`);
418
+ // The NORMATIVE ledger row is the authority a release checks (§13.1: unledgered mints
419
+ // cannot occur — no row, no release; a revoked row never re-releases).
420
+ const ledgerKey = credLedgerKey(credentialId);
421
+ const ledgerEntry = await kv.get(ledgerKey);
422
+ if (!ledgerEntry || ledgerEntry.operation !== "PUT")
423
+ throw new EpEnvelopeError("failed-precondition", `credential ${credentialId} has no ledger row at ${ledgerKey}; release follows the ledger, never invents (SPEC 13.1)`);
424
+ const ledgerRow = parseLedgerRow(ledgerEntry.value, ledgerKey);
425
+ if (!ledgerRow.sourceChain.includes(`session.${sessionId}`) || !ledgerRow.credentialId.startsWith(`${sessionId}.`))
426
+ throw new EpEnvelopeError("internal", `the ledger row ${ledgerKey} does not carry session ${sessionId}'s lineage; a mis-bound credential never authorizes (SPEC 13.1/13.6)`);
427
+ if (ledgerRow.state === "revoked")
428
+ throw new EpEnvelopeError("permission-denied", `credential ${credentialId} is revoked; a revoked half never re-releases (SPEC 13.6)`);
429
+ // The IMPLEMENTATION pins (kid, thumbprint, party) ride the stage.-family pin row.
430
+ const party = credentialId.endsWith(".c") ? "caller" : "serving";
431
+ const pinEntry = await kv.get(stagePinKey(sessionId, party));
432
+ if (!pinEntry || pinEntry.operation !== "PUT")
433
+ throw new EpEnvelopeError("failed-precondition", `credential ${credentialId} has no stage pin at ${stagePinKey(sessionId, party)}; release follows the stage, never invents (SPEC 13.6)`);
434
+ const pin = parseStagePin(pinEntry.value, stagePinKey(sessionId, party));
435
+ const key = signer.resolve(pin.kid);
436
+ if (key === undefined)
437
+ throw new EpEnvelopeError("unavailable", `the signing key ${pin.kid} pinned by credential ${credentialId} is not resolvable; release fails closed rather than re-minting under a different key (SPEC 13.6/13.10)`);
438
+ if (signer.thumbprint(pin.kid) !== pin.kidThumbprint)
439
+ throw new EpEnvelopeError("permission-denied", `the signing key ${pin.kid} now resolves to different key material (thumbprint mismatch); a rebound kid label is not the pinned key, release fails closed (SPEC 13.10)`);
440
+ // DETERMINISTIC mint under the PINNED key: identical payload + EdDSA => identical bytes on
441
+ // every re-release. Subjects RE-DERIVED from the row, never trusted from storage.
442
+ const jwt = await new SignJWT({ act: { kind: "session", sessionId, party, subjects: railSubjects(row, party) } })
443
+ .setProtectedHeader({ alg: "EdDSA", kid: pin.kid })
444
+ .setSubject(party === "caller" ? row.holder.principal : row.endpoint)
445
+ .setExpirationTime(Math.floor(row.exp / 1000))
446
+ .sign(key);
447
+ return { id: credentialId, creds: jwt, exp: row.exp };
448
+ };
449
+ return {
450
+ ledger,
451
+ allocateCredentialIds(grant) {
452
+ // DETERMINISTIC, distinct, bounded, key-recoverable, and ENDPOINT-QUALIFIED on the serving
453
+ // side (an instanceId is unique only within (space, endpoint)): a redemption retry
454
+ // re-allocates the SAME names, and equal instanceIds under two endpoints never collide.
455
+ return {
456
+ credCaller: callerCredId(grant.holder.lifecycleUid, grant.sessionId),
457
+ credServing: servingCredId(grant.endpoint, grant.serving.instanceId, grant.sessionId),
458
+ };
459
+ },
460
+ async holderProcessEpoch(holder) {
461
+ // The holder's CURRENT epoch is the ALIAS HEAD (SPEC 13.1, amended): leader-served,
462
+ // `active`-ONLY currency, and the head must name the PRESENTED holder's own
463
+ // lifecycleUid — a superseded/replaced incarnation's grant yields no epoch and dies.
464
+ const p = parsePrincipalKey(holder.id);
465
+ if (!p)
466
+ throw new EpEnvelopeError("failed-precondition", `holder id ${JSON.stringify(holder.id)} is not a principal dot-form; no head read routes (SPEC 13.1)`);
467
+ const head = await readLifecycleMappingLeader(reader, p.owner, p.actor);
468
+ if (head === undefined || head.mapping.state !== "active" || head.mapping.lifecycleUid !== holder.lifecycleUid)
469
+ return undefined;
470
+ return head.mapping.processEpoch;
471
+ },
472
+ async servingEpoch(endpoint, instanceId) {
473
+ const key = epgateKey(endpoint, instanceId);
474
+ const entry = await kv.get(key);
475
+ if (!entry)
476
+ return undefined;
477
+ if (entry.operation !== "PUT")
478
+ throw new EpEnvelopeError("failed-precondition", `the endpoint gate ${key} carries a ${entry.operation} marker; a gate is never deleted (corruption, not absence, SPEC 13.12)`);
479
+ return parseEndpointGate(entry.value, key).processEpoch;
480
+ },
481
+ async observeHolderGate(holder) {
482
+ // The REAL D13 registry gate (SPEC 13.1): the registry's own observe carries the
483
+ // marker/closed-parse discipline; only an OPEN gate mints.
484
+ const gate = await observeGate(registry, holder.lifecycleUid);
485
+ if (gate === undefined)
486
+ throw new EpEnvelopeError("permission-denied", `holder ${holder.id} has no lifecycle issuance gate (gate.${holder.lifecycleUid}); a never-activated or retired lifecycle mints nothing (SPEC 13.1)`);
487
+ if (gate.row.state !== "open")
488
+ throw new EpEnvelopeError("permission-denied", `holder ${holder.id}'s lifecycle issuance gate is "${gate.row.state}"; only an open gate mints (a frozen gate is a barrier in flight, a retired one is terminal, SPEC 13.1)`);
489
+ return { key: `gate.${holder.lifecycleUid}`, revision: gate.revision };
490
+ },
491
+ async observeServingGate(endpoint, instanceId) {
492
+ return (await observeEndpointGate(kv, endpoint, instanceId, `serving ${endpoint}/${instanceId}`)).pin;
493
+ },
494
+ async stagePair(grant, ids, pins) {
495
+ // Stage the NORMATIVE ledger rows + the implementation stage pins CREATE-ONLY (rows
496
+ // write BEFORE the gate CAS, §13.1 mint protocol; staged rows confer nothing until the
497
+ // session row finalizes `active`), then TOUCH-CAS both gates pinned at their observed
498
+ // revisions: the gate write IS the §13.1 fence — a barrier that moved either gate since
499
+ // observation makes the pinned touch LOSE, and the redemption collects and refuses.
500
+ const tp = signer.thumbprint(signer.current.kid);
501
+ if (tp === undefined)
502
+ throw new EpEnvelopeError("unavailable", `the current signing key ${signer.current.kid} has no resolvable thumbprint; staging fails closed (SPEC 13.10)`);
503
+ // The serving side's evictable principal is the endpoint instance's CONNZ-attributable
504
+ // connection principal, recorded on the endpoint gate (NOT the endpoint name, which CONNZ
505
+ // cannot KICK). The epcred key is built from the `endpoint` field, keeping the KEY identity
506
+ // and the eviction target disjoint (§13.1).
507
+ const { gate: servingGate } = await observeEndpointGate(kv, grant.endpoint, grant.serving.instanceId, `serving ${grant.endpoint}/${grant.serving.instanceId}`);
508
+ const stage = async (id, party) => {
509
+ // The normative §13.1 row, in its party's family (a create loss is a RETRY over this
510
+ // session's own deterministic ids — the one-use already fenced foreign sessions — so
511
+ // byte-identical proceeds and foreign content refuses).
512
+ const ledgerRow = party === "caller"
513
+ ? { credentialId: `${grant.sessionId}.c`, holderPrincipal: grant.holder.id, lifecycleUid: grant.holder.lifecycleUid, sourceChain: [`session.${grant.sessionId}`], state: "active", exp: grant.exp }
514
+ : { credentialId: `${grant.sessionId}.s`, holderPrincipal: servingGate.principal, lifecycleUid: grant.serving.instanceId, endpoint: grant.endpoint, sourceChain: [`session.${grant.sessionId}`], state: "active", exp: grant.exp };
515
+ await createRowByteIdempotent(kv, credLedgerKey(id), ledgerRow);
516
+ const pinRow = { v: 1, kind: "session", sessionId: grant.sessionId, party, kid: signer.current.kid, kidThumbprint: tp, exp: grant.exp };
517
+ await createRowByteIdempotent(kv, stagePinKey(grant.sessionId, party), pinRow);
518
+ };
519
+ await stage(ids.credCaller, "caller");
520
+ await stage(ids.credServing, "serving");
521
+ // Touch each DISTINCT gate key ONCE, in CANONICAL (sorted) order. Deduping is essential:
522
+ // a self-session whose holder and serving name the SAME gate would otherwise touch it
523
+ // twice and the second touch would deterministically lose its own now-bumped pin.
524
+ // Canonical order prevents the crossed-pair livelock (two reciprocal redemptions each
525
+ // winning one gate and losing the other, burning BOTH one-uses with no barrier): both
526
+ // contend the lexicographically-first key first, so exactly one wins and proceeds.
527
+ const byKey = new Map();
528
+ for (const pin of [pins.holder, pins.serving]) {
529
+ const seen = byKey.get(pin.key);
530
+ if (seen !== undefined && seen !== pin.revision)
531
+ throw new EpEnvelopeError("internal", `the same gate ${pin.key} was observed at two revisions (${seen} vs ${pin.revision}); a single observation feeds the pinned touch (SPEC 13.1)`);
532
+ byKey.set(pin.key, pin.revision);
533
+ }
534
+ for (const key of [...byKey.keys()].sort()) {
535
+ const revision = byKey.get(key);
536
+ const entry = await kv.get(key);
537
+ if (!entry || entry.operation !== "PUT" || entry.revision !== revision)
538
+ throw new EpEnvelopeError("permission-denied", `the lifecycle gate ${key} moved since its observation (revision ${entry?.revision ?? "gone"} vs pinned ${revision}); the staged pair loses (SPEC 13.1)`);
539
+ try {
540
+ await kv.update(key, entry.value, revision);
541
+ }
542
+ catch (e) {
543
+ if (isCasLoss(e))
544
+ throw new EpEnvelopeError("permission-denied", `the lifecycle gate ${key} moved during the stage; the pinned write LOSES (SPEC 13.1)`);
545
+ throw new EpEnvelopeError("unavailable", `the gate touch for ${key} is ambiguous; redemption fails closed (SPEC 13.1): ${e?.message ?? String(e)}`);
546
+ }
547
+ }
548
+ },
549
+ releaseCredential: release,
550
+ async revokeCredential(id) {
551
+ // Route to the NORMATIVE ledger row and mark it revoked (monotonic). A NEVER-STAGED id
552
+ // re-revokes successfully (idempotent — the refuse-and-collect path revokes ids whose
553
+ // stage may not have run); an existing row's marker/parse discipline stays loud.
554
+ const key = credLedgerKey(id);
555
+ const entry = await kv.get(key);
556
+ if (!entry)
557
+ return; // a dead (never-staged) id re-revokes successfully
558
+ await markLedgerRowRevoked(kv, key);
559
+ },
560
+ ...(now ? { now } : {}),
561
+ };
562
+ }
563
+ /**
564
+ * The §13.6 authoritative close: verify party membership against the AUTHORITATIVE row (never
565
+ * a caller projection), transition the row terminal, and revoke BOTH credentials by name, each
566
+ * marked on success (the sweep's terminal-row retry backstops any failure). Idempotent for an
567
+ * already-terminal row (`transitioned: false`, the marks still retried).
568
+ */
569
+ export async function closeSession(store, hooks, args) {
570
+ assertStore(store);
571
+ const row = await hooks.ledger.read(args.sessionId);
572
+ if (row === undefined)
573
+ throw new EpEnvelopeError("not-found", `session ${args.sessionId} has no ledger row (SPEC 13.6)`);
574
+ const c = args.closer;
575
+ const member = c.kind === "operator" ||
576
+ (c.kind === "holder" && c.id === row.holder.principal && c.lifecycleUid === row.holder.lifecycleUid) ||
577
+ (c.kind === "serving" && c.endpoint === row.endpoint && c.instanceId === row.serving.instanceId && c.epoch === row.serving.epoch);
578
+ if (!member)
579
+ throw new EpEnvelopeError("permission-denied", `the presenter is not a party to session ${args.sessionId} (close is party- or operator-authenticated against the ledger row, SPEC 13.6)`);
580
+ // A PARTY close (holder/serving) always produces `closed`; only the OPERATOR may name a
581
+ // barrier-specific terminal reason (superseded/retired/expired). A party choosing a barrier
582
+ // reason would let one side stamp a lifecycle-barrier outcome it does not own.
583
+ const to = c.kind === "operator" ? (args.to ?? "closed") : "closed";
584
+ if (c.kind !== "operator" && args.to !== undefined && args.to !== "closed")
585
+ throw new EpEnvelopeError("permission-denied", `a party close of session ${args.sessionId} produces only "closed"; a barrier-specific terminal reason is the operator's / the §13.1 barrier's (SPEC 13.6)`);
586
+ const transitioned = await hooks.ledger.transitionTerminal(args.sessionId, to);
587
+ // Containment: revoke both halves and report whether it COMPLETED. The row transition blocks
588
+ // NEW release/connect, but a mint-time revocation is not verified live eviction (the §13.1
589
+ // barrier's evictPrincipal step, named D13 wiring), so close never silently claims success.
590
+ // CRITICAL race guard: an ABSENT credential row is left UNMARKED, not confirmed — a close
591
+ // racing an in-flight redemption's stage (issuing → close → stage creates the deterministic
592
+ // rows AFTER close) must not durably mark a half "collected" before its row exists, or the
593
+ // sweep would skip the eventually-staged credential. Only a half whose row EXISTED and was
594
+ // revoked is marked; every absent half is the sweep's retry backstop.
595
+ const after = await hooks.ledger.read(args.sessionId);
596
+ let fullyRevoked = true;
597
+ if (after) {
598
+ for (const [id, marked] of [[after.credCaller, after.revoked.caller], [after.credServing, after.revoked.serving]]) {
599
+ if (marked)
600
+ continue;
601
+ try {
602
+ const entry = await store.kv.get(credLedgerKey(id));
603
+ if (!entry || entry.operation !== "PUT") {
604
+ fullyRevoked = false;
605
+ continue;
606
+ } // absent: leave for the sweep, never mark
607
+ await hooks.revokeCredential(id);
608
+ await hooks.ledger.markRevoked(args.sessionId, id);
609
+ }
610
+ catch {
611
+ fullyRevoked = false; // the unmarked id is the sweep's retry backstop; close reports incomplete
612
+ }
613
+ }
614
+ }
615
+ else {
616
+ fullyRevoked = false;
617
+ }
618
+ return { transitioned, fullyRevoked };
619
+ }
620
+ /**
621
+ * The SESSION-PAIR reconciler a §13.1 lifecycle barrier injects (as
622
+ * `credential-ledger`'s `TakeoverDeps.reconcileSessionPair`): when a takeover or handle
623
+ * revocation revokes a `cred.` row whose lineage names `session.<sessionId>`, this tears down
624
+ * BOTH halves of that session so the SERVING half cannot outlive the barrier. It terminalizes
625
+ * `session.<sessionId>` as `superseded` (the barrier's own terminal reason), revokes both
626
+ * ledger rows (the caller `cred.` and the serving `epcred.`), and RETURNS the serving row's
627
+ * CONNZ-attributable holder principal so the barrier can UNION it into its verified-eviction
628
+ * set (SPEC 13.6: both credentials revoked WITH eviction — the row alone leaves an
629
+ * already-connected serving session live). Idempotent: an already-reconciled session still
630
+ * returns its principals (a resumed barrier must still evict); fail-closed if it cannot fully
631
+ * revoke both halves. The barrier only ever names a session it read from a NEVER-DELETED
632
+ * credential row's lineage, and session rows are never deleted either, so TRUE ABSENCE here is
633
+ * corruption, never "nothing to reconcile".
634
+ */
635
+ export async function reconcileSessionForTakeover(store, hooks, sessionId) {
636
+ assertStore(store);
637
+ const row = await hooks.ledger.read(sessionId);
638
+ if (row === undefined)
639
+ throw new EpEnvelopeError("failed-precondition", `session ${sessionId} is named by a live credential lineage but has no ledger row; session rows are never deleted, so a barrier cannot treat this as settled (corruption, SPEC 13.12)`);
640
+ const res = await closeSession(store, hooks, { sessionId, closer: { kind: "operator" }, to: "superseded" });
641
+ if (!res.fullyRevoked)
642
+ throw new EpEnvelopeError("unavailable", `the takeover reconciliation of session ${sessionId} did not fully revoke both halves; the barrier fails closed (SPEC 13.1)`);
643
+ // The serving half's EVICTION TARGET: the epcred row's holderPrincipal (the serving
644
+ // instance's connection principal recorded at gate registration). Read AFTER the close so a
645
+ // just-revoked row still names it; the row parse enforces the principal grammar.
646
+ const servingKey = credLedgerKey(row.credServing);
647
+ const entry = await store.kv.get(servingKey);
648
+ if (entry !== null && entry !== undefined) {
649
+ if (entry.operation !== "PUT")
650
+ throw new EpEnvelopeError("failed-precondition", `the serving credential row ${servingKey} of reconciled session ${sessionId} carries a ${entry.operation} marker; ledger rows are never deleted (corruption, SPEC 13.12)`);
651
+ const serving = parseLedgerRow(entry.value, servingKey);
652
+ return { servingPrincipals: [serving.holderPrincipal] };
653
+ }
654
+ // TRUE ABSENCE of the serving row is legitimate in exactly one shape: a redemption that
655
+ // crashed between its two stage writes never created it, and the sweep's terminal-row retry
656
+ // durably MARKED the never-staged id revoked (the fully-revoked proof). A credential that was
657
+ // never staged was never released, so no connection exists under it — nothing to evict.
658
+ // Anything else is corruption.
659
+ const after = await hooks.ledger.read(sessionId);
660
+ if (after !== undefined && after.revoked.serving)
661
+ return { servingPrincipals: [] };
662
+ throw new EpEnvelopeError("failed-precondition", `the serving credential row ${servingKey} of reconciled session ${sessionId} does not exist and the session row carries no fully-revoked proof for it; ledger rows are never deleted (corruption, SPEC 13.12)`);
663
+ }
664
+ /** Enumerate `session.>` and run core's per-row sweep decision: expiry transitions + the
665
+ * terminal-row unmarked-id revoke retry. Corruption is LOUD but LOCAL: a single malformed row
666
+ * is collected and reported, never allowed to abort containment of the other valid rows (else
667
+ * one poison row would block the whole bucket's expiry/revocation backstop). Returns how many
668
+ * rows this pass acted on plus the keys that failed. */
669
+ export async function sweepSessions(store, hooks, opts, scanner) {
670
+ assertStore(store);
671
+ // The scanner brand/space bond, at THIS public dispatch (not only at registry/mediator install):
672
+ // a hand-assembled `{ scanSessions: async () => [] }` would report a successful sweep while
673
+ // expired session credentials stay active, and a real foreign-space scanner would drive local
674
+ // revoke hooks from foreign rows. Neither ever authorizes an enumeration (SPEC 13.12).
675
+ assertScannerSpace(scanner, store.space);
676
+ if (!Number.isSafeInteger(opts.now) || opts.now < 0)
677
+ throw new EpEnvelopeError("failed-precondition", `the sweep clock ${JSON.stringify(opts.now)} is not a non-negative safe integer; a malformed clock would expire live rows or spare dead ones (SPEC 13.6)`);
678
+ if (opts.marginMs !== undefined && (!Number.isSafeInteger(opts.marginMs) || opts.marginMs < 0))
679
+ throw new EpEnvelopeError("failed-precondition", `the sweep margin ${JSON.stringify(opts.marginMs)} is not a non-negative safe integer (SPEC 13.6)`);
680
+ let acted = 0;
681
+ const failed = [];
682
+ // A bucket's own `kv.keys()`/`kv.watch()` FILTERS DEL/PURGE markers before yielding (the
683
+ // installed @nats-io/kv skips them), so a tombstoned session key would be INVISIBLE to a
684
+ // keys-based sweep and its still-live credential rows would never be reported. The SEALED
685
+ // scanner's `session.>` read PRESERVES the KV-Operation header, so a deletion marker is SEEN and
686
+ // reported as corruption (never silently skipped). It is the SAME sealed scanner (one literal
687
+ // consumer name + lock over the auth stream) the credential-ledger barriers use — the sweep
688
+ // holds NO auth-stream CONSUMER.CREATE of its own (nats-server#8274; ledger-scanner.ts).
689
+ for (const item of await enumerateSessionEntries(scanner)) {
690
+ if (item.op === "DEL" || item.op === "PURGE") {
691
+ failed.push(item.key);
692
+ continue;
693
+ } // a marker is corruption, reported, never invisible
694
+ try {
695
+ const row = parseRow(item.data, item.key);
696
+ if (await sweepSessionRow(row, hooks, opts))
697
+ acted++;
698
+ }
699
+ catch {
700
+ failed.push(item.key); // one poison row never blocks the rest of the bucket's containment
701
+ }
702
+ }
703
+ return { acted, failed };
704
+ }
705
+ /** Point-in-time enumeration of `session.>` through the SEALED auth-ledger scanner's fence-free
706
+ * LastPerSubject read (the credential-ledger enumeration pattern; ledger-scanner.ts). It includes
707
+ * DEL/PURGE markers (the scanner preserves the KV-Operation header), so a tombstoned session key
708
+ * the bucket's own `keys()`/`watch()` would hide is SEEN. The sweep therefore holds no auth-stream
709
+ * `CONSUMER.CREATE` of its own. */
710
+ async function enumerateSessionEntries(scanner) {
711
+ return (await scanner.scanSessions()).map((e) => ({ key: e.key, op: e.op, data: e.data }));
712
+ }
713
+ //# sourceMappingURL=session-ledger.js.map