@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.
- package/dist/admission-mediator.d.ts +319 -0
- package/dist/admission-mediator.d.ts.map +1 -0
- package/dist/admission-mediator.js +1033 -0
- package/dist/admission-mediator.js.map +1 -0
- package/dist/auth-admin.d.ts +29 -0
- package/dist/auth-admin.d.ts.map +1 -0
- package/dist/auth-admin.js +224 -0
- package/dist/auth-admin.js.map +1 -0
- package/dist/authority-client.d.ts +215 -0
- package/dist/authority-client.d.ts.map +1 -0
- package/dist/authority-client.js +346 -0
- package/dist/authority-client.js.map +1 -0
- package/dist/barrier-evict.d.ts +16 -0
- package/dist/barrier-evict.d.ts.map +1 -0
- package/dist/barrier-evict.js +100 -0
- package/dist/barrier-evict.js.map +1 -0
- package/dist/connect-reader.d.ts +72 -0
- package/dist/connect-reader.d.ts.map +1 -0
- package/dist/connect-reader.js +154 -0
- package/dist/connect-reader.js.map +1 -0
- package/dist/continuity.d.ts.map +1 -1
- package/dist/continuity.js +3 -0
- package/dist/continuity.js.map +1 -1
- package/dist/credential-ledger.d.ts +343 -0
- package/dist/credential-ledger.d.ts.map +1 -0
- package/dist/credential-ledger.js +732 -0
- package/dist/credential-ledger.js.map +1 -0
- package/dist/drain-repair.d.ts +95 -0
- package/dist/drain-repair.d.ts.map +1 -0
- package/dist/drain-repair.js +305 -0
- package/dist/drain-repair.js.map +1 -0
- package/dist/idp.d.ts +19 -0
- package/dist/idp.d.ts.map +1 -1
- package/dist/idp.js +17 -0
- package/dist/idp.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -2
- package/dist/index.js.map +1 -1
- package/dist/issuer.d.ts +8 -0
- package/dist/issuer.d.ts.map +1 -1
- package/dist/issuer.js +4 -2
- package/dist/issuer.js.map +1 -1
- package/dist/ledger-scanner.d.ts +98 -0
- package/dist/ledger-scanner.d.ts.map +1 -0
- package/dist/ledger-scanner.js +352 -0
- package/dist/ledger-scanner.js.map +1 -0
- package/dist/ledger.d.ts +7 -0
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +53 -5
- package/dist/ledger.js.map +1 -1
- package/dist/lifecycle-registry.d.ts +397 -0
- package/dist/lifecycle-registry.d.ts.map +1 -0
- package/dist/lifecycle-registry.js +819 -0
- package/dist/lifecycle-registry.js.map +1 -0
- package/dist/permissions.d.ts +5 -1
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +29 -6
- package/dist/permissions.js.map +1 -1
- package/dist/plane-claim.d.ts +83 -0
- package/dist/plane-claim.d.ts.map +1 -0
- package/dist/plane-claim.js +290 -0
- package/dist/plane-claim.js.map +1 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +2 -1
- package/dist/provider.js.map +1 -1
- package/dist/records-scanner.d.ts +78 -0
- package/dist/records-scanner.d.ts.map +1 -0
- package/dist/records-scanner.js +320 -0
- package/dist/records-scanner.js.map +1 -0
- package/dist/retirement-barrier.d.ts +217 -0
- package/dist/retirement-barrier.d.ts.map +1 -0
- package/dist/retirement-barrier.js +624 -0
- package/dist/retirement-barrier.js.map +1 -0
- package/dist/retirement-cleaner.d.ts +54 -0
- package/dist/retirement-cleaner.d.ts.map +1 -0
- package/dist/retirement-cleaner.js +158 -0
- package/dist/retirement-cleaner.js.map +1 -0
- package/dist/root-credential.d.ts +26 -0
- package/dist/root-credential.d.ts.map +1 -0
- package/dist/root-credential.js +116 -0
- package/dist/root-credential.js.map +1 -0
- package/dist/service.d.ts +60 -0
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +317 -10
- package/dist/service.js.map +1 -1
- package/dist/session-ledger.d.ts +224 -0
- package/dist/session-ledger.d.ts.map +1 -0
- package/dist/session-ledger.js +713 -0
- package/dist/session-ledger.js.map +1 -0
- package/dist/token.d.ts +17 -0
- package/dist/token.d.ts.map +1 -1
- package/dist/token.js +30 -2
- package/dist/token.js.map +1 -1
- 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
|