@cotal-ai/auth 0.90.0 → 0.91.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/authority-client.d.ts.map +1 -1
- package/dist/authority-client.js +4 -1
- package/dist/authority-client.js.map +1 -1
- package/dist/chat-parent-pin.d.ts +43 -0
- package/dist/chat-parent-pin.d.ts.map +1 -0
- package/dist/chat-parent-pin.js +115 -0
- package/dist/chat-parent-pin.js.map +1 -0
- package/dist/chat-session-authority.d.ts +120 -0
- package/dist/chat-session-authority.d.ts.map +1 -0
- package/dist/chat-session-authority.js +924 -0
- package/dist/chat-session-authority.js.map +1 -0
- package/dist/chat-session-row.d.ts +117 -0
- package/dist/chat-session-row.d.ts.map +1 -0
- package/dist/chat-session-row.js +358 -0
- package/dist/chat-session-row.js.map +1 -0
- package/dist/chat-session-store.d.ts +126 -0
- package/dist/chat-session-store.d.ts.map +1 -0
- package/dist/chat-session-store.js +392 -0
- package/dist/chat-session-store.js.map +1 -0
- package/dist/commands.d.ts +3 -1
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +497 -122
- package/dist/commands.js.map +1 -1
- package/dist/continuity.js +1 -1
- package/dist/continuity.js.map +1 -1
- package/dist/daemon-flags.d.ts +42 -0
- package/dist/daemon-flags.d.ts.map +1 -0
- package/dist/daemon-flags.js +12 -0
- package/dist/daemon-flags.js.map +1 -0
- package/dist/handle-inspect.d.ts +101 -0
- package/dist/handle-inspect.d.ts.map +1 -0
- package/dist/handle-inspect.js +50 -0
- package/dist/handle-inspect.js.map +1 -0
- package/dist/handle-verification.d.ts +75 -0
- package/dist/handle-verification.d.ts.map +1 -0
- package/dist/handle-verification.js +135 -0
- package/dist/handle-verification.js.map +1 -0
- package/dist/idp.d.ts +23 -1
- package/dist/idp.d.ts.map +1 -1
- package/dist/idp.js +54 -8
- package/dist/idp.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +58 -5
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +126 -6
- package/dist/ledger.js.map +1 -1
- package/dist/lifecycle-registry.d.ts +1 -70
- package/dist/lifecycle-registry.d.ts.map +1 -1
- package/dist/lifecycle-registry.js +4 -35
- package/dist/lifecycle-registry.js.map +1 -1
- package/dist/login.d.ts +9 -0
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +53 -8
- package/dist/login.js.map +1 -1
- package/dist/permissions.js +4 -0
- package/dist/permissions.js.map +1 -1
- package/dist/provider.d.ts +23 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +244 -44
- package/dist/provider.js.map +1 -1
- package/dist/service-adapter.d.ts +6 -0
- package/dist/service-adapter.d.ts.map +1 -0
- package/dist/service-adapter.js +120 -0
- package/dist/service-adapter.js.map +1 -0
- package/dist/service.d.ts +61 -2
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +399 -84
- package/dist/service.js.map +1 -1
- package/dist/store.d.ts +10 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +25 -8
- package/dist/store.js.map +1 -1
- package/dist/token.d.ts +1 -1
- package/dist/token.d.ts.map +1 -1
- package/dist/token.js +4 -1
- package/dist/token.js.map +1 -1
- package/package.json +11 -7
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The auth service as a CHAT-ONLY minting authority for user-mode chat sessions (SPEC 13.1
|
|
3
|
+
* amendment). One module owns the four authority decisions over the closed chat records
|
|
4
|
+
* (`chat-session-row.ts`, `chat-session-store.ts`, `chat-parent-pin.ts`):
|
|
5
|
+
*
|
|
6
|
+
* - ADMISSION: derive the owner from the IdP proof (the caller), draw the session actor and its
|
|
7
|
+
* lifecycle UID, pin the human's own `cli` grant, and confine the session to its read/post lists
|
|
8
|
+
* and TTL. The writes run in the normative order a1 to a10: quota, holder index, UID reservation,
|
|
9
|
+
* UID bind, `pending` row with the pin re-check, `provisioning`, activation and footprint, the
|
|
10
|
+
* read-back, `active` index, `active` row. Only then does the caller answer. A retry with the
|
|
11
|
+
* same holder converges on the same UID; a retired holder never admits again.
|
|
12
|
+
* - EXCHANGE: a holder proof plus a current IdP proof buys one short bearer of an ACTIVE session.
|
|
13
|
+
* The session's own head and root credential, the parent pin and the holder index are checked
|
|
14
|
+
* before AND after signing, and a mismatch discards the signed bytes.
|
|
15
|
+
* - CONNECT: the callout's own checks for a chat principal (the row, the parent pin, the holder
|
|
16
|
+
* index), run before signing and again inside the native issuance recorder after signing.
|
|
17
|
+
* - RETIREMENT and the bounded SWEEP: an active session retires through the existing barrier,
|
|
18
|
+
* then its index, its row and its quota slot. A paused or abandoned admission is fenced first
|
|
19
|
+
* (S1), its UID driven to a terminal gate (S2), and its slot released last (S3). A slot is
|
|
20
|
+
* released only after the UID's gate reads back terminal (or no UID was ever bound). A pass
|
|
21
|
+
* counts its own reads, never waits on a retirement, and runs at most four at once.
|
|
22
|
+
*
|
|
23
|
+
* Nothing here adds a credential profile. The leaf is exactly core's `permissionsFor("agent", ...)`
|
|
24
|
+
* with the session's lists and no role, capability, endpoint capability, issued pair or serve grant.
|
|
25
|
+
*/
|
|
26
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
27
|
+
import { EpEnvelopeError, assertDerivedOwnerToken, assertLifecycleToken, isConcreteChannel } from "@cotal-ai/core";
|
|
28
|
+
import { CHAT_LABEL_MAX, CHAT_SESSION_MAX_TTL_MS, advanceChatSessionRow, assertChatActor, assertChatList, assertHolderHash, chatSessionAliasPresent, compactChatSessionRow, createPendingChatSessionRow, listChatSessionRowNames, parseChatSessionRowFilename, readChatSessionRow, } from "./chat-session-row.js";
|
|
29
|
+
import { openChatAuthorityStore, parseChatHolderKey, parseChatOwnerKey, } from "./chat-session-store.js";
|
|
30
|
+
import { CHAT_PARENT_ACTOR, assertChatListsContained, assertChatParentPinCurrent, buildChatParentPin, chatParentPinDigest, readChatParentHeadLeader, readChatParentRow, } from "./chat-parent-pin.js";
|
|
31
|
+
import { credRowKey, parseLedgerRow, stageIntentKey } from "./credential-ledger.js";
|
|
32
|
+
import { findActorUnified, findInteractiveActor } from "./ledger.js";
|
|
33
|
+
import { createGateFrozen, observeGate, readLifecycleHeadForOperation, registryStores, reserveLifecycleUid, resumeActivation, retireGate, } from "./lifecycle-registry.js";
|
|
34
|
+
import { ensureRootCredential } from "./root-credential.js";
|
|
35
|
+
import { serializedFor } from "./serialized.js";
|
|
36
|
+
import { MAX_TOKEN_TTL_SEC } from "./token.js";
|
|
37
|
+
/** At most this many own candidate reads per sweep pass (D). */
|
|
38
|
+
export const CHAT_SWEEP_READS = 256;
|
|
39
|
+
/** At most this many sweep retirements in flight at once (C). */
|
|
40
|
+
export const CHAT_SWEEP_RETIREMENTS = 4;
|
|
41
|
+
/** A retired row's body is compacted to its tombstone this long after the session's expiry. */
|
|
42
|
+
export const CHAT_ROW_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
43
|
+
/** The minimum quiet period after a reservation before the sweep acts on it. It is
|
|
44
|
+
* never the evidence: every sweep action is gated on durable state, and a holder whose admission
|
|
45
|
+
* or retirement is visibly in flight in this process is skipped. */
|
|
46
|
+
export const CHAT_SWEEP_QUIET_MS = 60 * 1000;
|
|
47
|
+
/** The retirement barrier's operation for one chat lifecycle. */
|
|
48
|
+
export function chatRetireOpId(lifecycleUid) {
|
|
49
|
+
return createHash("sha256").update(`chat-retire:${assertLifecycleToken(lifecycleUid)}`).digest("hex").slice(0, 26);
|
|
50
|
+
}
|
|
51
|
+
/** The sweep's own activation operation for one holder (S2 `opS`): it births and retires
|
|
52
|
+
* the gate of a bound UID that never got one. */
|
|
53
|
+
export function chatSweepOpId(owner, holderHash) {
|
|
54
|
+
return createHash("sha256").update(`chat-sweep:${assertDerivedOwnerToken(owner)}.${assertHolderHash(holderHash)}`).digest("hex").slice(0, 26);
|
|
55
|
+
}
|
|
56
|
+
/** The holder hash a proof must match: lowercase SHA-256 hex over the proof's UTF-8 bytes. */
|
|
57
|
+
export function chatHolderHash(holderProof) {
|
|
58
|
+
return createHash("sha256").update(holderProof, "utf8").digest("hex");
|
|
59
|
+
}
|
|
60
|
+
/** The bearer window (exchange step 5): the TTL is the least of the bearer cap, the IdP proof's
|
|
61
|
+
* remaining life and the session's remaining life; `notAfter` is the absolute ceiling the issuer
|
|
62
|
+
* re-reads at signing. Undefined means nothing may be signed. All values are unix seconds except
|
|
63
|
+
* `expiresAtMs`. */
|
|
64
|
+
export function chatBearerWindow(nowSec, idpExpSec, expiresAtMs) {
|
|
65
|
+
const notAfter = Math.min(Math.floor(idpExpSec), Math.floor(expiresAtMs / 1000));
|
|
66
|
+
const ttlSec = Math.min(MAX_TOKEN_TTL_SEC, notAfter - nowSec);
|
|
67
|
+
return ttlSec > 0 ? { ttlSec, notAfter } : undefined;
|
|
68
|
+
}
|
|
69
|
+
const CHAT_HOLDER_CHAINS = new Map();
|
|
70
|
+
const isCode = (e, code) => e instanceof EpEnvelopeError && e.code === code;
|
|
71
|
+
/** The same refusal with the effect outcome it lacked; an outcome already stated is kept. */
|
|
72
|
+
const withOutcome = (e, outcome) => e instanceof EpEnvelopeError && e.outcome === undefined ? new EpEnvelopeError(e.code, e.message, e.details, outcome) : e;
|
|
73
|
+
const reason = (e) => (e instanceof Error ? e.message : String(e));
|
|
74
|
+
const sameLists = (a, b) => JSON.stringify(a.read) === JSON.stringify(b.read) && JSON.stringify(a.post) === JSON.stringify(b.post);
|
|
75
|
+
/** The one refusal an unknown session, a wrong holder or a foreign owner gets, so the exchange and
|
|
76
|
+
* retire routes never reveal which of them it was. */
|
|
77
|
+
const holderDenied = () => new EpEnvelopeError("permission-denied", "no chat session matches this holder proof for the signed-in account; open a new session");
|
|
78
|
+
function holderMatches(holderProof, holderHash) {
|
|
79
|
+
if (typeof holderProof !== "string" || holderProof.length === 0 || holderProof.length > 512)
|
|
80
|
+
return false;
|
|
81
|
+
const presented = Buffer.from(chatHolderHash(holderProof), "hex");
|
|
82
|
+
const stored = Buffer.from(holderHash, "hex");
|
|
83
|
+
return presented.length === stored.length && timingSafeEqual(presented, stored);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Build the session authority over the plane's mint-writer registry. The chat KV records are read
|
|
87
|
+
* and written on that registry's auth store, which is leader-served by shape.
|
|
88
|
+
*/
|
|
89
|
+
export function openChatSessionAuthority(deps) {
|
|
90
|
+
const { space, dir, registry } = deps;
|
|
91
|
+
const { authKv, jsm } = registryStores(registry);
|
|
92
|
+
const store = openChatAuthorityStore(authKv, jsm, space);
|
|
93
|
+
/** The sweep's owner-quota scan position, in memory only: the durable cursor is the closed
|
|
94
|
+
* `{rowName, holderSeq}`. After a restart the owner scan starts again from the first owner. Owners
|
|
95
|
+
* get at least a third of each pass's reads, so a cycle completes unless restarts come faster than
|
|
96
|
+
* one cycle. Until it does, an entry with no index (an admission paused after a1) keeps its quota
|
|
97
|
+
* slot; it holds no authority. */
|
|
98
|
+
let ownerSeq = 0;
|
|
99
|
+
const parentHead = (owner) => readChatParentHeadLeader(jsm, space, owner);
|
|
100
|
+
/** The in-process serialization key of one holder: its admissions, its closes and its sweep fence
|
|
101
|
+
* run one at a time here. */
|
|
102
|
+
const holderKey = (owner, holderHash) => `${space}:${owner}.${holderHash}`;
|
|
103
|
+
/** Preconditions 2 to 4: the parent row, its head leader-read and ensured, the pin, containment.
|
|
104
|
+
* An absent or unstamped parent head is ensured first, so the pin is never taken over a head the
|
|
105
|
+
* human's next ordinary exchange would move. */
|
|
106
|
+
const currentParentPin = async (owner, lists) => {
|
|
107
|
+
const row = readChatParentRow(dir, owner);
|
|
108
|
+
if (typeof row.lifecycleUid !== "string" || row.lifecycleUid === "")
|
|
109
|
+
throw new EpEnvelopeError("permission-denied", `the "${CHAT_PARENT_ACTOR}" grant for owner ${owner} carries no lifecycleUid; re-grant it before opening a chat session`);
|
|
110
|
+
assertChatListsContained(row, lists);
|
|
111
|
+
let head = await parentHead(owner);
|
|
112
|
+
if (head === undefined || head.mapping.currentCredentialId === undefined) {
|
|
113
|
+
await ensureRootCredential(registry, { owner, actor: CHAT_PARENT_ACTOR, lifecycleUid: row.lifecycleUid, managerInstance: deps.managerInstance });
|
|
114
|
+
head = await parentHead(owner);
|
|
115
|
+
}
|
|
116
|
+
const pin = buildChatParentPin(row, head);
|
|
117
|
+
return { pin, digest: chatParentPinDigest(pin) };
|
|
118
|
+
};
|
|
119
|
+
/** The pin CHECK on fresh reads (a5 re-check, exchange pre and post, connect). */
|
|
120
|
+
const assertPinCurrent = async (owner, pin, lists) => {
|
|
121
|
+
assertChatParentPinCurrent(pin, findInteractiveActor(dir, owner, CHAT_PARENT_ACTOR), await parentHead(owner), lists);
|
|
122
|
+
};
|
|
123
|
+
/** A leader read of the holder index that must be `active` at the UID. */
|
|
124
|
+
const assertIndexActive = async (owner, holderHash, lifecycleUid) => {
|
|
125
|
+
const idx = await store.readHolder(owner, holderHash);
|
|
126
|
+
if (idx === undefined || idx.value.state !== "active" || idx.value.lifecycleUid !== lifecycleUid)
|
|
127
|
+
throw new EpEnvelopeError("permission-denied", `the chat session at ${lifecycleUid} is not active in its holder index (${idx === undefined ? "absent" : `${idx.value.state} at ${String(idx.value.lifecycleUid)}`}); open a new session`);
|
|
128
|
+
};
|
|
129
|
+
/** A full (not tombstoned) active, unexpired row at the principal. */
|
|
130
|
+
const activeRow = (owner, actor) => {
|
|
131
|
+
const rec = readChatSessionRow(dir, owner, actor);
|
|
132
|
+
if (rec === undefined || rec.form !== "row")
|
|
133
|
+
throw new EpEnvelopeError("permission-denied", `chat session "${owner}.${actor}" is not (or no longer) admitted`);
|
|
134
|
+
if (rec.row.state !== "active")
|
|
135
|
+
throw new EpEnvelopeError("permission-denied", `chat session "${owner}.${actor}" is ${rec.row.state}, not active; a pending or retired session authorizes nothing`);
|
|
136
|
+
if (rec.row.expiresAt <= deps.now())
|
|
137
|
+
throw new EpEnvelopeError("permission-denied", `chat session "${owner}.${actor}" expired at ${new Date(rec.row.expiresAt).toISOString()}; open a new session`);
|
|
138
|
+
return rec.row;
|
|
139
|
+
};
|
|
140
|
+
/** Revision-pinned holder-index CAS. A loss converges when a sibling made the same move. */
|
|
141
|
+
const casIndex = async (owner, holderHash, from, to) => {
|
|
142
|
+
try {
|
|
143
|
+
return await store.casHolder(owner, holderHash, from, to);
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
if (!isCode(e, "conflict"))
|
|
147
|
+
throw e;
|
|
148
|
+
const now = await store.readHolder(owner, holderHash);
|
|
149
|
+
if (now !== undefined && now.value.state === to.state && now.value.lifecycleUid === to.lifecycleUid)
|
|
150
|
+
return now;
|
|
151
|
+
throw new EpEnvelopeError("conflict", `the chat holder index moved to ${now === undefined ? "absent" : now.value.state} while this admission advanced it; retry with the same holder`, undefined, "unknown");
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
/** Release this holder's quota entry, pinned on a fresh read. The caller has already read the
|
|
155
|
+
* index back as retired and the UID's gate back as terminal (or the UID as null). */
|
|
156
|
+
const releaseQuota = async (owner, holderHash) => {
|
|
157
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
158
|
+
const q = await store.readQuota(owner);
|
|
159
|
+
if (q === undefined)
|
|
160
|
+
return;
|
|
161
|
+
try {
|
|
162
|
+
await store.releaseQuotaEntry(owner, holderHash, q);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
if (!isCode(e, "conflict"))
|
|
167
|
+
throw e;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
throw new EpEnvelopeError("unavailable", `the chat quota record for owner ${owner} kept moving; the slot is released by a later sweep`, undefined, "unknown");
|
|
171
|
+
};
|
|
172
|
+
/** Move a present, not yet retired row to `retired`. A tombstone is already retired. A row that
|
|
173
|
+
* became active records `retired`; a pending one records `notStarted`. */
|
|
174
|
+
const retireRow = (owner, actor, lifecycleUid) => {
|
|
175
|
+
const rec = readChatSessionRow(dir, owner, actor);
|
|
176
|
+
if (rec === undefined || rec.row.state === "retired")
|
|
177
|
+
return;
|
|
178
|
+
if (rec.row.lifecycleUid !== lifecycleUid)
|
|
179
|
+
throw new EpEnvelopeError("internal", `the chat row "${owner}.${actor}" names lifecycle ${rec.row.lifecycleUid}, not its holder index's ${lifecycleUid}`);
|
|
180
|
+
const retireOutcome = rec.row.state === "active" ? "retired" : "notStarted";
|
|
181
|
+
advanceChatSessionRow(dir, { owner, actor, lifecycleUid }, { state: "retired", retiredAt: deps.now(), retireOutcome });
|
|
182
|
+
};
|
|
183
|
+
/** Read back that the holder index is retired, or throw. */
|
|
184
|
+
const assertIndexRetired = async (owner, holderHash) => {
|
|
185
|
+
const back = await store.readHolder(owner, holderHash);
|
|
186
|
+
if (back === undefined || back.value.state !== "retired")
|
|
187
|
+
throw new EpEnvelopeError("unavailable", `the chat holder index for owner ${owner} did not read back retired`, undefined, "unknown");
|
|
188
|
+
return back;
|
|
189
|
+
};
|
|
190
|
+
/** Move a non-retired index to retired (S1 for an existing index, step 2 of a retirement). */
|
|
191
|
+
const retireIndex = async (owner, holderHash, from, by) => {
|
|
192
|
+
if (from.value.state !== "retired") {
|
|
193
|
+
try {
|
|
194
|
+
await store.casHolder(owner, holderHash, from, { ...from.value, state: "retired", retiredBy: by });
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
// A loss means another writer moved it; only a retired result is acceptable here.
|
|
198
|
+
if (!isCode(e, "conflict"))
|
|
199
|
+
throw e;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return assertIndexRetired(owner, holderHash);
|
|
203
|
+
};
|
|
204
|
+
/**
|
|
205
|
+
* S2: drive the UID's issuance gate to a terminal row, on fresh reads each round.
|
|
206
|
+
* No gate: born frozen under the sweep's own `opS`, then retired under it (a loss re-observes).
|
|
207
|
+
* Frozen by an activation op (an admission's, or `opS` adopted by one): that op's saga is resumed,
|
|
208
|
+
* which terminalizes it when its head never won and reopens it when it did. Open: the retirement
|
|
209
|
+
* barrier under `opR`. Frozen by `opR`: the barrier resumes. Returns `undefined` once the gate
|
|
210
|
+
* reads back retired, else the pending reason.
|
|
211
|
+
*/
|
|
212
|
+
const terminalizeUid = async (owner, actor, holderHash, lifecycleUid) => {
|
|
213
|
+
const opS = chatSweepOpId(owner, holderHash);
|
|
214
|
+
const opR = chatRetireOpId(lifecycleUid);
|
|
215
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
216
|
+
const gate = await observeGate(registry, lifecycleUid);
|
|
217
|
+
if (gate === undefined) {
|
|
218
|
+
try {
|
|
219
|
+
const born = await createGateFrozen(registry, { lifecycleUid, op: { opId: opS, kind: "activation" } });
|
|
220
|
+
await retireGate(registry, { lifecycleUid, revision: born.revision, opId: opS });
|
|
221
|
+
}
|
|
222
|
+
catch (e) {
|
|
223
|
+
if (!isCode(e, "conflict") && !isCode(e, "failed-precondition") && !isCode(e, "permission-denied"))
|
|
224
|
+
return `retirement pending: the UID ${lifecycleUid} could not be fenced (${reason(e)})`;
|
|
225
|
+
}
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (gate.row.state === "retired")
|
|
229
|
+
return undefined;
|
|
230
|
+
if (gate.row.state === "frozen") {
|
|
231
|
+
const op = gate.row.op;
|
|
232
|
+
if (op.kind === "activation") {
|
|
233
|
+
await resumeActivation(registry, { owner, actor, lifecycleUid, opId: op.opId });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (op.kind === "retirement" && op.opId === opR) {
|
|
237
|
+
try {
|
|
238
|
+
await deps.retireLifecycle({ owner, actor, lifecycleUid, opId: opR });
|
|
239
|
+
}
|
|
240
|
+
catch (e) {
|
|
241
|
+
return `retirement pending: ${reason(e)}`;
|
|
242
|
+
}
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
return `retirement pending: the issuance gate for ${lifecycleUid} is frozen by a ${op.kind} operation ${op.opId}`;
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
await deps.retireLifecycle({ owner, actor, lifecycleUid, opId: opR });
|
|
249
|
+
}
|
|
250
|
+
catch (e) {
|
|
251
|
+
return `retirement pending: ${reason(e)}`;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return `retirement pending: the issuance gate for ${lifecycleUid} kept moving`;
|
|
255
|
+
};
|
|
256
|
+
/** Clear the lifecycle's durables and ACL row. A failure is logged: compaction re-runs it. */
|
|
257
|
+
const deprovisionBestEffort = async (row) => {
|
|
258
|
+
try {
|
|
259
|
+
await deps.deprovisionFootprint({ owner: row.owner, actor: row.actor, lifecycleUid: row.lifecycleUid, memberChannels: row.read.filter(isConcreteChannel) });
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
catch (e) {
|
|
263
|
+
deps.log(`auth-service: chat session ${row.owner}.${row.actor} at ${row.lifecycleUid}: footprint removal deferred to compaction (${reason(e)})`);
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
/**
|
|
268
|
+
* Settle one holder to terminal. An ACTIVE index retires in order: the barrier, the index, the
|
|
269
|
+
* row, the quota slot. Any other index is fenced first (S1), then its UID is terminalized (S2),
|
|
270
|
+
* then the row, then the slot (S3). Returns the closed outcome; a blocked barrier is `failed` with
|
|
271
|
+
* its pending reason and nothing after it runs.
|
|
272
|
+
*/
|
|
273
|
+
const settleHolder = async (owner, holderHash, by) => {
|
|
274
|
+
let idx = await store.readHolder(owner, holderHash);
|
|
275
|
+
if (idx === undefined)
|
|
276
|
+
return { outcome: "notStarted" };
|
|
277
|
+
const { actor } = idx.value;
|
|
278
|
+
const wasRetired = idx.value.state === "retired";
|
|
279
|
+
if (idx.value.state === "active") {
|
|
280
|
+
const uid = idx.value.lifecycleUid;
|
|
281
|
+
const pending = await terminalizeUid(owner, actor, holderHash, uid);
|
|
282
|
+
if (pending !== undefined)
|
|
283
|
+
return { outcome: "failed", pending };
|
|
284
|
+
await deprovisionBestEffort({ owner, actor, lifecycleUid: uid, read: idx.value.ceiling.read });
|
|
285
|
+
idx = await retireIndex(owner, holderHash, idx, by);
|
|
286
|
+
retireRow(owner, actor, uid);
|
|
287
|
+
await releaseQuota(owner, holderHash);
|
|
288
|
+
return { outcome: "retired" };
|
|
289
|
+
}
|
|
290
|
+
idx = await retireIndex(owner, holderHash, idx, by);
|
|
291
|
+
const uid = idx.value.lifecycleUid;
|
|
292
|
+
if (uid !== null) {
|
|
293
|
+
const pending = await terminalizeUid(owner, actor, holderHash, uid);
|
|
294
|
+
if (pending !== undefined)
|
|
295
|
+
return { outcome: "failed", pending };
|
|
296
|
+
await deprovisionBestEffort({ owner, actor, lifecycleUid: uid, read: idx.value.ceiling.read });
|
|
297
|
+
retireRow(owner, actor, uid);
|
|
298
|
+
}
|
|
299
|
+
await releaseQuota(owner, holderHash);
|
|
300
|
+
return { outcome: wasRetired ? "alreadyRetired" : uid === null ? "notStarted" : "retired" };
|
|
301
|
+
};
|
|
302
|
+
/** {@link settleHolder} in line with this process's admissions and closes of the same holder. */
|
|
303
|
+
const settleInLine = (owner, holderHash, by) => serializedFor(CHAT_HOLDER_CHAINS, holderKey(owner, holderHash), () => settleHolder(owner, holderHash, by));
|
|
304
|
+
/** a8: read back the activation (the footprint read-back ran inside a7). */
|
|
305
|
+
const readBackActivation = async (owner, actor, lifecycleUid, pinDigest) => {
|
|
306
|
+
const fail = (what) => {
|
|
307
|
+
throw new EpEnvelopeError("unavailable", `chat admission read-back: ${what}; retry with the same holder`, undefined, "unknown");
|
|
308
|
+
};
|
|
309
|
+
const head = await readLifecycleHeadForOperation(registry, owner, actor);
|
|
310
|
+
if (head === undefined || head.mapping.state !== "active" || head.mapping.lifecycleUid !== lifecycleUid)
|
|
311
|
+
fail(`the session head is not active at ${lifecycleUid}`);
|
|
312
|
+
const credentialId = head.mapping.currentCredentialId;
|
|
313
|
+
if (credentialId === undefined)
|
|
314
|
+
fail("the session head carries no root credential stamp");
|
|
315
|
+
const gate = await observeGate(registry, lifecycleUid);
|
|
316
|
+
if (gate === undefined || gate.row.state !== "open")
|
|
317
|
+
fail(`the issuance gate for ${lifecycleUid} is not open`);
|
|
318
|
+
const entry = await authKv.get(credRowKey(lifecycleUid, credentialId));
|
|
319
|
+
if (!entry || entry.operation !== "PUT")
|
|
320
|
+
fail("the root credential row is absent");
|
|
321
|
+
const cred = parseLedgerRow(entry.value, credRowKey(lifecycleUid, credentialId));
|
|
322
|
+
if (cred.state !== "active" || cred.holderPrincipal !== `${owner}.${actor}` || cred.lifecycleUid !== lifecycleUid)
|
|
323
|
+
fail("the root credential row is not active for this session");
|
|
324
|
+
const rec = readChatSessionRow(dir, owner, actor);
|
|
325
|
+
if (rec === undefined || rec.form !== "row" || rec.row.state !== "pending" || rec.row.lifecycleUid !== lifecycleUid ||
|
|
326
|
+
chatParentPinDigest(rec.row.parentPin) !== pinDigest)
|
|
327
|
+
fail("the pending row does not match the admission");
|
|
328
|
+
};
|
|
329
|
+
/** The session's own head still active at its UID with this root stamp, and the row still active. */
|
|
330
|
+
const assertOwnHeadCurrent = async (owner, actor, lifecycleUid, credentialId) => {
|
|
331
|
+
const head = await readLifecycleHeadForOperation(registry, owner, actor);
|
|
332
|
+
if (head === undefined || head.mapping.state !== "active" || head.mapping.lifecycleUid !== lifecycleUid || head.mapping.currentCredentialId !== credentialId)
|
|
333
|
+
throw new EpEnvelopeError("permission-denied", `the chat session head is no longer active at ${lifecycleUid} with root credential ${credentialId}`);
|
|
334
|
+
const entry = await authKv.get(credRowKey(lifecycleUid, credentialId));
|
|
335
|
+
const cred = entry && entry.operation === "PUT" ? parseLedgerRow(entry.value, credRowKey(lifecycleUid, credentialId)) : undefined;
|
|
336
|
+
if (cred === undefined || cred.state !== "active" || cred.holderPrincipal !== `${owner}.${actor}` || cred.lifecycleUid !== lifecycleUid)
|
|
337
|
+
throw new EpEnvelopeError("permission-denied", `the chat session's root credential ${credentialId} is no longer active`);
|
|
338
|
+
};
|
|
339
|
+
/** a10: `pending → active`, read back. An already active row at the UID is the same success. */
|
|
340
|
+
const activateRow = (owner, actor, lifecycleUid) => {
|
|
341
|
+
const rec = readChatSessionRow(dir, owner, actor);
|
|
342
|
+
if (rec === undefined || rec.form !== "row" || rec.row.lifecycleUid !== lifecycleUid)
|
|
343
|
+
throw new EpEnvelopeError("internal", `the chat row "${owner}.${actor}" is missing or names another lifecycle at activation`);
|
|
344
|
+
if (rec.row.state === "active")
|
|
345
|
+
return;
|
|
346
|
+
if (rec.row.state === "retired")
|
|
347
|
+
throw new EpEnvelopeError("permission-denied", `chat session "${owner}.${actor}" was retired before it became active; open a new session`);
|
|
348
|
+
advanceChatSessionRow(dir, { owner, actor, lifecycleUid }, { state: "active" });
|
|
349
|
+
};
|
|
350
|
+
/** Fence a holder whose admission cannot continue (the a5 pin re-check failed): index and row to
|
|
351
|
+
* retired, nothing deleted. The sweep later terminalizes the UID and releases the slot. */
|
|
352
|
+
const rollback = async (owner, holderHash, idx) => {
|
|
353
|
+
const back = await retireIndex(owner, holderHash, idx, "rollback");
|
|
354
|
+
if (back.value.lifecycleUid !== null)
|
|
355
|
+
retireRow(owner, back.value.actor, back.value.lifecycleUid);
|
|
356
|
+
};
|
|
357
|
+
/** Continue (or resume) one admission from its holder index, per its state. */
|
|
358
|
+
const converge = async (req, pin, found) => {
|
|
359
|
+
const { owner, holderHash, read, post } = req;
|
|
360
|
+
let idx = found;
|
|
361
|
+
if (idx.value.state === "retired")
|
|
362
|
+
throw new EpEnvelopeError("permission-denied", "this holder's chat session has ended; a retired holder never admits again - open with a fresh holder", undefined, "not-executed");
|
|
363
|
+
if (!sameLists(idx.value.ceiling, { read, post }) || idx.value.expiresAt !== idx.value.reservedAt + req.ttlMs)
|
|
364
|
+
throw new EpEnvelopeError("conflict", "this holder already reserved a chat session with a different ceiling or lifetime; a retry must repeat the original request", undefined, "not-executed");
|
|
365
|
+
const actor = idx.value.actor;
|
|
366
|
+
const expiresAt = Math.floor(idx.value.expiresAt / 1000);
|
|
367
|
+
if (idx.value.state === "active") {
|
|
368
|
+
// An active session is returned only unexpired and under an unchanged parent. A moved parent
|
|
369
|
+
// ends it at its next exchange or connect; its retirement is the barrier's, never a rollback.
|
|
370
|
+
if (idx.value.expiresAt <= deps.now())
|
|
371
|
+
throw new EpEnvelopeError("failed-precondition", "this holder's chat session has expired; open with a fresh holder", undefined, "not-executed");
|
|
372
|
+
if (idx.value.parentPinDigest !== pin.digest)
|
|
373
|
+
throw new EpEnvelopeError("permission-denied", "the signed-in grant moved after this chat session was admitted; it has ended - open a new session", undefined, "not-executed");
|
|
374
|
+
const uid = idx.value.lifecycleUid;
|
|
375
|
+
activateRow(owner, actor, uid);
|
|
376
|
+
return { actor, lifecycleUid: uid, expiresAt };
|
|
377
|
+
}
|
|
378
|
+
if (idx.value.parentPinDigest !== pin.digest) {
|
|
379
|
+
await rollback(owner, holderHash, idx);
|
|
380
|
+
throw new EpEnvelopeError("permission-denied", "the signed-in grant moved after this chat session was reserved; the reservation was ended - open a new session", undefined, "executed");
|
|
381
|
+
}
|
|
382
|
+
// a3 + a4: reserve a UID and bind it once. A lost bind converges on the sibling's UID (ours
|
|
383
|
+
// stays burned and unbound).
|
|
384
|
+
if (idx.value.lifecycleUid === null) {
|
|
385
|
+
const drawn = await reserveLifecycleUid(registry, { owner, actor, mintedBy: deps.managerInstance });
|
|
386
|
+
try {
|
|
387
|
+
idx = await store.casHolder(owner, holderHash, idx, { ...idx.value, lifecycleUid: drawn });
|
|
388
|
+
}
|
|
389
|
+
catch (e) {
|
|
390
|
+
if (!isCode(e, "conflict"))
|
|
391
|
+
throw e;
|
|
392
|
+
const now = await store.readHolder(owner, holderHash);
|
|
393
|
+
if (now === undefined || now.value.state === "retired" || now.value.lifecycleUid === null)
|
|
394
|
+
throw new EpEnvelopeError("conflict", "the chat holder index was fenced or moved while its UID was bound; retry with the same holder", undefined, "unknown");
|
|
395
|
+
idx = now;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const uid = idx.value.lifecycleUid;
|
|
399
|
+
// a5: the pending row, then the pin re-check on fresh reads.
|
|
400
|
+
const row = {
|
|
401
|
+
owner, actor, lifecycleUid: uid, holderHash,
|
|
402
|
+
allowSubscribe: read, allowPublish: post, scope: [],
|
|
403
|
+
...(req.label !== undefined ? { label: req.label } : {}),
|
|
404
|
+
admittedAt: idx.value.reservedAt, expiresAt: idx.value.expiresAt,
|
|
405
|
+
parentPin: pin.pin, state: "pending",
|
|
406
|
+
};
|
|
407
|
+
const made = createPendingChatSessionRow(dir, row);
|
|
408
|
+
if (!made.created) {
|
|
409
|
+
const ex = made.existing;
|
|
410
|
+
if (ex.form !== "row" || ex.row.state === "retired")
|
|
411
|
+
throw new EpEnvelopeError("permission-denied", "this holder's chat session row is retired; open with a fresh holder");
|
|
412
|
+
if (ex.row.lifecycleUid !== uid || ex.row.holderHash !== holderHash || !sameLists({ read: ex.row.allowSubscribe, post: ex.row.allowPublish }, { read, post }) ||
|
|
413
|
+
ex.row.label !== req.label || chatParentPinDigest(ex.row.parentPin) !== pin.digest)
|
|
414
|
+
throw new EpEnvelopeError("conflict", "the chat row at this session does not match the retried request; retry must repeat the original request");
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
await assertPinCurrent(owner, pin.pin, { read, post });
|
|
418
|
+
}
|
|
419
|
+
catch (e) {
|
|
420
|
+
await rollback(owner, holderHash, idx);
|
|
421
|
+
throw withOutcome(e, "executed");
|
|
422
|
+
}
|
|
423
|
+
// a6
|
|
424
|
+
if (idx.value.state === "reserved")
|
|
425
|
+
idx = await casIndex(owner, holderHash, idx, { ...idx.value, state: "provisioning" });
|
|
426
|
+
// a7: the activation saga at the UID and the root credential, then the footprint.
|
|
427
|
+
await ensureRootCredential(registry, { owner, actor, lifecycleUid: uid, managerInstance: deps.managerInstance });
|
|
428
|
+
await deps.provisionFootprint({ owner, actor, lifecycleUid: uid, read });
|
|
429
|
+
// a8
|
|
430
|
+
await readBackActivation(owner, actor, uid, pin.digest);
|
|
431
|
+
// a9, read back
|
|
432
|
+
idx = await casIndex(owner, holderHash, idx, { ...idx.value, state: "active" });
|
|
433
|
+
const back = await store.readHolder(owner, holderHash);
|
|
434
|
+
if (back === undefined || back.value.state !== "active" || back.value.lifecycleUid !== uid)
|
|
435
|
+
throw new EpEnvelopeError("unavailable", "the chat holder index did not read back active; retry with the same holder", undefined, "unknown");
|
|
436
|
+
// a10
|
|
437
|
+
activateRow(owner, actor, uid);
|
|
438
|
+
return { actor, lifecycleUid: uid, expiresAt };
|
|
439
|
+
};
|
|
440
|
+
const admitOnce = async (req) => {
|
|
441
|
+
const { owner, holderHash, read, post } = req;
|
|
442
|
+
let pin;
|
|
443
|
+
try {
|
|
444
|
+
pin = await currentParentPin(owner, { read, post });
|
|
445
|
+
}
|
|
446
|
+
catch (e) {
|
|
447
|
+
throw withOutcome(e, "not-executed");
|
|
448
|
+
}
|
|
449
|
+
// A present index selects the retry path before anything touches the quota.
|
|
450
|
+
const existing = await store.readHolder(owner, holderHash);
|
|
451
|
+
if (existing !== undefined)
|
|
452
|
+
return converge(req, pin, existing);
|
|
453
|
+
// a1: a refusal here is before any write; an unknown acknowledgement says so itself.
|
|
454
|
+
let q;
|
|
455
|
+
try {
|
|
456
|
+
q = await store.reserveQuota(owner, holderHash, deps.now());
|
|
457
|
+
}
|
|
458
|
+
catch (e) {
|
|
459
|
+
throw withOutcome(e, "not-executed");
|
|
460
|
+
}
|
|
461
|
+
// a2
|
|
462
|
+
const fresh = {
|
|
463
|
+
v: 1, state: "reserved", actor: q.actor, lifecycleUid: null,
|
|
464
|
+
reservedAt: q.reservedAt, expiresAt: q.reservedAt + req.ttlMs,
|
|
465
|
+
ceiling: { read, post }, parentPinDigest: pin.digest,
|
|
466
|
+
};
|
|
467
|
+
const created = await store.createHolderReserved(owner, holderHash, fresh);
|
|
468
|
+
if (created.created)
|
|
469
|
+
return converge(req, pin, { value: fresh, revision: created.revision });
|
|
470
|
+
// The loser shares the winner's quota entry and never touches it.
|
|
471
|
+
const winner = await store.readHolder(owner, holderHash);
|
|
472
|
+
if (winner === undefined)
|
|
473
|
+
throw new EpEnvelopeError("unavailable", "the chat holder index create lost, but no index reads back; retry with the same holder", undefined, "unknown");
|
|
474
|
+
return converge(req, pin, winner);
|
|
475
|
+
};
|
|
476
|
+
// ---- retirements in flight, and the sweep ---------------------------------------------------
|
|
477
|
+
/** Chat retirements running on this instance, keyed by holder: at most
|
|
478
|
+
* {@link CHAT_SWEEP_RETIREMENTS}, closes and sweep alike. */
|
|
479
|
+
const settling = new Map();
|
|
480
|
+
const slotWaiters = [];
|
|
481
|
+
/** Holders whose last retirement here did not complete. Each pass restarts them first, up to the
|
|
482
|
+
* free slots. After a restart the service's boot resume carries every owed barrier intent, and
|
|
483
|
+
* the holder's quota entry, released last, keeps it discoverable by the owner scan. */
|
|
484
|
+
const owed = new Map();
|
|
485
|
+
const runSettle = (owner, holderHash, by) => {
|
|
486
|
+
const key = holderKey(owner, holderHash);
|
|
487
|
+
const run = settleInLine(owner, holderHash, by)
|
|
488
|
+
.then((r) => {
|
|
489
|
+
if (r.outcome === "failed")
|
|
490
|
+
owed.set(key, { owner, holderHash });
|
|
491
|
+
else
|
|
492
|
+
owed.delete(key);
|
|
493
|
+
return r;
|
|
494
|
+
}, (e) => {
|
|
495
|
+
owed.set(key, { owner, holderHash });
|
|
496
|
+
throw e;
|
|
497
|
+
})
|
|
498
|
+
.finally(() => {
|
|
499
|
+
settling.delete(key);
|
|
500
|
+
slotWaiters.shift()?.();
|
|
501
|
+
});
|
|
502
|
+
settling.set(key, run);
|
|
503
|
+
return run;
|
|
504
|
+
};
|
|
505
|
+
/** The sweep's start of one retirement. It never waits: false when every slot is taken. */
|
|
506
|
+
const trySettle = (owner, holderHash) => {
|
|
507
|
+
if (settling.has(holderKey(owner, holderHash)))
|
|
508
|
+
return true;
|
|
509
|
+
if (settling.size >= CHAT_SWEEP_RETIREMENTS)
|
|
510
|
+
return false;
|
|
511
|
+
void runSettle(owner, holderHash, "sweep").then((r) => { if (r.outcome === "failed")
|
|
512
|
+
deps.log(`auth-service: chat holder ${owner}/${holderHash.slice(0, 12)}: ${r.pending}`); }, (e) => deps.log(`auth-service: chat holder ${owner}/${holderHash.slice(0, 12)}: retirement did not complete (${reason(e)}); a later pass retries it`));
|
|
513
|
+
return true;
|
|
514
|
+
};
|
|
515
|
+
/** A close joins the holder's running retirement, or waits for a free slot. */
|
|
516
|
+
const settleForClose = async (owner, holderHash) => {
|
|
517
|
+
for (;;) {
|
|
518
|
+
const running = settling.get(holderKey(owner, holderHash));
|
|
519
|
+
if (running !== undefined)
|
|
520
|
+
return running;
|
|
521
|
+
if (settling.size < CHAT_SWEEP_RETIREMENTS)
|
|
522
|
+
return runSettle(owner, holderHash, "close");
|
|
523
|
+
await new Promise((wake) => slotWaiters.push(wake));
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
/** A holder whose admission or retirement is running in this process is not a sweep candidate. */
|
|
527
|
+
const visiblyInFlight = (owner, holderHash) => CHAT_HOLDER_CHAINS.has(holderKey(owner, holderHash)) || settling.has(holderKey(owner, holderHash));
|
|
528
|
+
const take = (b, n = 1) => {
|
|
529
|
+
if (b.reads + n > b.cap)
|
|
530
|
+
return false;
|
|
531
|
+
b.reads += n;
|
|
532
|
+
return true;
|
|
533
|
+
};
|
|
534
|
+
/** S1 for a reserved or provisioning index past its quiet period: CAS it `retired` at its read
|
|
535
|
+
* revision, keeping its UID, then read it back. False when the admission moved it first; it is
|
|
536
|
+
* classified again next pass and nothing is released this round. */
|
|
537
|
+
const fenceIndex = (owner, holderHash, idx) => serializedFor(CHAT_HOLDER_CHAINS, holderKey(owner, holderHash), async () => {
|
|
538
|
+
try {
|
|
539
|
+
await store.casHolder(owner, holderHash, idx, { ...idx.value, state: "retired", retiredBy: "sweep" });
|
|
540
|
+
}
|
|
541
|
+
catch (e) {
|
|
542
|
+
if (!isCode(e, "conflict"))
|
|
543
|
+
throw e;
|
|
544
|
+
}
|
|
545
|
+
const back = await store.readHolder(owner, holderHash);
|
|
546
|
+
return back !== undefined && back.value.state === "retired";
|
|
547
|
+
});
|
|
548
|
+
/**
|
|
549
|
+
* Classify a holder whose index is not retired, and act. `done` lets the
|
|
550
|
+
* cursor pass it: nothing was needed, or its durable step reads back (an expired session's barrier
|
|
551
|
+
* intent, a fenced index), or its retirement already failed here with a recorded reason and is
|
|
552
|
+
* retried from the owed list and the quota entry. `blocked` keeps the cursor before it.
|
|
553
|
+
*/
|
|
554
|
+
const classify = async (owner, holderHash, idx, b) => {
|
|
555
|
+
const v = idx.value;
|
|
556
|
+
const now = deps.now();
|
|
557
|
+
if (v.state === "active") {
|
|
558
|
+
if (v.expiresAt > now)
|
|
559
|
+
return "done";
|
|
560
|
+
if (owed.has(holderKey(owner, holderHash))) {
|
|
561
|
+
trySettle(owner, holderHash);
|
|
562
|
+
return "done";
|
|
563
|
+
}
|
|
564
|
+
if (!take(b))
|
|
565
|
+
return "blocked";
|
|
566
|
+
const intent = await authKv.get(stageIntentKey(chatRetireOpId(v.lifecycleUid)));
|
|
567
|
+
trySettle(owner, holderHash);
|
|
568
|
+
return intent !== null && intent.operation === "PUT" ? "done" : "blocked";
|
|
569
|
+
}
|
|
570
|
+
if (now - v.reservedAt < CHAT_SWEEP_QUIET_MS)
|
|
571
|
+
return "done";
|
|
572
|
+
if (!take(b))
|
|
573
|
+
return "blocked";
|
|
574
|
+
if (!(await fenceIndex(owner, holderHash, idx)))
|
|
575
|
+
return "blocked";
|
|
576
|
+
// S2 and S3 run in the background; the retired index and the quota entry carry them.
|
|
577
|
+
trySettle(owner, holderHash);
|
|
578
|
+
return "done";
|
|
579
|
+
};
|
|
580
|
+
/** One row: compact a retired row past retention (after the UID-exact footprint removal runs
|
|
581
|
+
* again), settle a row lagging its retired index, or classify its holder. */
|
|
582
|
+
const sweepRow = async (name, b) => {
|
|
583
|
+
const { owner, actor } = parseChatSessionRowFilename(name);
|
|
584
|
+
if (!take(b))
|
|
585
|
+
return "blocked";
|
|
586
|
+
const rec = readChatSessionRow(dir, owner, actor);
|
|
587
|
+
if (rec === undefined || rec.form === "tombstone")
|
|
588
|
+
return "done";
|
|
589
|
+
const row = rec.row;
|
|
590
|
+
if (row.state === "retired") {
|
|
591
|
+
if (deps.now() < row.expiresAt + CHAT_ROW_RETENTION_MS)
|
|
592
|
+
return "done";
|
|
593
|
+
// At most CHAT_SWEEP_RETIREMENTS compactions per pass, each after the footprint removal ran
|
|
594
|
+
// again. A deferred or failed one keeps its body for a later wrap; it never stalls the cursor.
|
|
595
|
+
if (b.compactions >= CHAT_SWEEP_RETIREMENTS)
|
|
596
|
+
return "done";
|
|
597
|
+
b.compactions++;
|
|
598
|
+
if (await deprovisionBestEffort({ owner, actor, lifecycleUid: row.lifecycleUid, read: row.allowSubscribe }))
|
|
599
|
+
compactChatSessionRow(dir, { owner, actor, lifecycleUid: row.lifecycleUid });
|
|
600
|
+
return "done";
|
|
601
|
+
}
|
|
602
|
+
if (visiblyInFlight(owner, row.holderHash))
|
|
603
|
+
return "done";
|
|
604
|
+
if (!take(b))
|
|
605
|
+
return "blocked";
|
|
606
|
+
const idx = await store.readHolder(owner, row.holderHash);
|
|
607
|
+
if (idx === undefined) {
|
|
608
|
+
deps.log(`auth-service: chat session ${owner}.${actor} has a row but no holder index; it authorizes nothing and is left for inspection`);
|
|
609
|
+
return "done";
|
|
610
|
+
}
|
|
611
|
+
if (idx.value.state === "retired") {
|
|
612
|
+
trySettle(owner, row.holderHash);
|
|
613
|
+
return "done";
|
|
614
|
+
}
|
|
615
|
+
return classify(owner, row.holderHash, idx, b);
|
|
616
|
+
};
|
|
617
|
+
/** One holder index from the scan: a retired index is settled only while its quota entry remains. */
|
|
618
|
+
const sweepHolder = async (owner, holderHash, b) => {
|
|
619
|
+
if (visiblyInFlight(owner, holderHash))
|
|
620
|
+
return "done";
|
|
621
|
+
if (!take(b))
|
|
622
|
+
return "blocked";
|
|
623
|
+
const idx = await store.readHolder(owner, holderHash);
|
|
624
|
+
if (idx === undefined)
|
|
625
|
+
return "done";
|
|
626
|
+
if (idx.value.state !== "retired")
|
|
627
|
+
return classify(owner, holderHash, idx, b);
|
|
628
|
+
if (!take(b))
|
|
629
|
+
return "blocked";
|
|
630
|
+
const q = await store.readQuota(owner);
|
|
631
|
+
if (q?.value.live.some((e) => e.holderHash === holderHash) === true)
|
|
632
|
+
trySettle(owner, holderHash);
|
|
633
|
+
return "done";
|
|
634
|
+
};
|
|
635
|
+
/** One owner's quota entries, each against an exact read of its index. */
|
|
636
|
+
const sweepOwner = async (owner, b) => {
|
|
637
|
+
if (!take(b))
|
|
638
|
+
return "blocked";
|
|
639
|
+
const q = await store.readQuota(owner);
|
|
640
|
+
if (q === undefined)
|
|
641
|
+
return "done";
|
|
642
|
+
for (const entry of q.value.live) {
|
|
643
|
+
if (visiblyInFlight(owner, entry.holderHash))
|
|
644
|
+
continue;
|
|
645
|
+
if (!take(b))
|
|
646
|
+
return "blocked";
|
|
647
|
+
const idx = await store.readHolder(owner, entry.holderHash);
|
|
648
|
+
if (idx === undefined) {
|
|
649
|
+
if (deps.now() - entry.reservedAt < CHAT_SWEEP_QUIET_MS)
|
|
650
|
+
continue;
|
|
651
|
+
// S1 for an absent index: born retired with a null UID, so a paused admission's a2 loses.
|
|
652
|
+
const fenced = await serializedFor(CHAT_HOLDER_CHAINS, holderKey(owner, entry.holderHash), () => store.createHolderFence(owner, entry.holderHash, entry));
|
|
653
|
+
if (fenced.created)
|
|
654
|
+
trySettle(owner, entry.holderHash); // S2 is empty at a null UID; S3 releases the slot
|
|
655
|
+
continue; // a lost create means an admission made it first: the next pass classifies it
|
|
656
|
+
}
|
|
657
|
+
if (idx.value.actor !== entry.actor) {
|
|
658
|
+
if (!take(b))
|
|
659
|
+
return "blocked";
|
|
660
|
+
const fresh = await store.readQuota(owner);
|
|
661
|
+
if (fresh !== undefined) {
|
|
662
|
+
try {
|
|
663
|
+
await store.rewriteQuotaEntryActor(owner, entry.holderHash, idx.value.actor, fresh);
|
|
664
|
+
}
|
|
665
|
+
catch (e) {
|
|
666
|
+
if (!isCode(e, "conflict"))
|
|
667
|
+
throw e;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
if (idx.value.state === "retired") {
|
|
673
|
+
trySettle(owner, entry.holderHash);
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
if ((await classify(owner, entry.holderHash, idx, b)) === "blocked")
|
|
677
|
+
return "blocked";
|
|
678
|
+
}
|
|
679
|
+
return "done";
|
|
680
|
+
};
|
|
681
|
+
/** A leader-served discovery page whose scan messages are counted as made: `limit` reads are
|
|
682
|
+
* reserved, and the unused ones (a page that ended early stops on one empty read) returned. */
|
|
683
|
+
const countedPage = async (family, afterSeq, b) => {
|
|
684
|
+
const limit = Math.floor((b.cap - b.reads) / 2);
|
|
685
|
+
if (limit < 1)
|
|
686
|
+
return { page: [], complete: false };
|
|
687
|
+
take(b, limit);
|
|
688
|
+
const page = await store.scanPage(family, afterSeq, limit);
|
|
689
|
+
const used = page.length < limit ? page.length + 1 : page.length;
|
|
690
|
+
b.reads -= limit - used;
|
|
691
|
+
return { page, complete: page.length < limit };
|
|
692
|
+
};
|
|
693
|
+
/**
|
|
694
|
+
* One pass. Owed retirements restart first. Then rows, holder indexes and owner
|
|
695
|
+
* quotas are classified, each family against its share of {@link CHAT_SWEEP_READS} own reads: a
|
|
696
|
+
* third of what is left for rows, half of the rest for holders, the remainder for owners. A pass
|
|
697
|
+
* never waits on a retirement; it starts one only while a slot is free. Each family's position
|
|
698
|
+
* passes only a contiguous run of `done` candidates, and wraps once a page reaches the end. The
|
|
699
|
+
* row and holder positions are written in one revision-pinned CAS; a loser continues from the
|
|
700
|
+
* winner's position next pass. The owner position lives in memory.
|
|
701
|
+
*/
|
|
702
|
+
const sweepPass = async () => {
|
|
703
|
+
for (const { owner, holderHash } of [...owed.values()])
|
|
704
|
+
if (!trySettle(owner, holderHash))
|
|
705
|
+
break;
|
|
706
|
+
const b = { reads: 0, cap: CHAT_SWEEP_READS, compactions: 0 };
|
|
707
|
+
if (!take(b))
|
|
708
|
+
return;
|
|
709
|
+
const observed = await store.readCursor();
|
|
710
|
+
const cursor = observed?.value ?? { v: 1, rowName: "", holderSeq: 0 };
|
|
711
|
+
b.cap = b.reads + Math.floor((CHAT_SWEEP_READS - b.reads) / 3);
|
|
712
|
+
let rowName = cursor.rowName;
|
|
713
|
+
if (take(b)) {
|
|
714
|
+
const asked = Math.max(1, b.cap - b.reads);
|
|
715
|
+
const names = listChatSessionRowNames(dir, cursor.rowName, asked);
|
|
716
|
+
let blocked = false;
|
|
717
|
+
for (const name of names) {
|
|
718
|
+
if ((await sweepRow(name, b)) === "blocked") {
|
|
719
|
+
blocked = true;
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
rowName = name;
|
|
723
|
+
}
|
|
724
|
+
// Fewer names than asked, all done: the end of the row space was reached.
|
|
725
|
+
if (!blocked && names.length < asked)
|
|
726
|
+
rowName = "";
|
|
727
|
+
}
|
|
728
|
+
b.cap = b.reads + Math.floor((CHAT_SWEEP_READS - b.reads) / 2);
|
|
729
|
+
let holderSeq = cursor.holderSeq;
|
|
730
|
+
{
|
|
731
|
+
const { page, complete } = await countedPage("holder", cursor.holderSeq, b);
|
|
732
|
+
const seen = new Set();
|
|
733
|
+
let blocked = false;
|
|
734
|
+
for (const { key, seq } of page) {
|
|
735
|
+
if (!seen.has(key)) {
|
|
736
|
+
seen.add(key);
|
|
737
|
+
const { owner, holderHash } = parseChatHolderKey(key);
|
|
738
|
+
if ((await sweepHolder(owner, holderHash, b)) === "blocked") {
|
|
739
|
+
blocked = true;
|
|
740
|
+
break;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
holderSeq = seq;
|
|
744
|
+
}
|
|
745
|
+
if (complete && !blocked)
|
|
746
|
+
holderSeq = 0;
|
|
747
|
+
}
|
|
748
|
+
try {
|
|
749
|
+
await store.casCursor(observed, { v: 1, rowName, holderSeq });
|
|
750
|
+
}
|
|
751
|
+
catch (e) {
|
|
752
|
+
if (!isCode(e, "conflict"))
|
|
753
|
+
throw e;
|
|
754
|
+
}
|
|
755
|
+
b.cap = CHAT_SWEEP_READS;
|
|
756
|
+
const { page, complete } = await countedPage("owner", ownerSeq, b);
|
|
757
|
+
const seen = new Set();
|
|
758
|
+
let blocked = false;
|
|
759
|
+
for (const { key, seq } of page) {
|
|
760
|
+
if (!seen.has(key)) {
|
|
761
|
+
seen.add(key);
|
|
762
|
+
if ((await sweepOwner(parseChatOwnerKey(key), b)) === "blocked") {
|
|
763
|
+
blocked = true;
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
ownerSeq = seq;
|
|
768
|
+
}
|
|
769
|
+
if (complete && !blocked)
|
|
770
|
+
ownerSeq = 0;
|
|
771
|
+
};
|
|
772
|
+
/** Exchange steps 1 to 6; the route writes the response, step 7. */
|
|
773
|
+
const exchangeOnce = async ({ owner: rawOwner, actor, holderProof, idpExp, issuer }) => {
|
|
774
|
+
const owner = assertDerivedOwnerToken(rawOwner);
|
|
775
|
+
try {
|
|
776
|
+
assertChatActor(actor);
|
|
777
|
+
}
|
|
778
|
+
catch {
|
|
779
|
+
throw holderDenied();
|
|
780
|
+
}
|
|
781
|
+
// 1. Holder proof and owner: the row is read at the IdP proof's own owner.
|
|
782
|
+
const rec = readChatSessionRow(dir, owner, actor);
|
|
783
|
+
if (rec === undefined || !holderMatches(holderProof, rec.row.holderHash))
|
|
784
|
+
throw holderDenied();
|
|
785
|
+
// 2. State checks.
|
|
786
|
+
const row = activeRow(owner, actor);
|
|
787
|
+
await assertIndexActive(owner, row.holderHash, row.lifecycleUid);
|
|
788
|
+
// 3. Parent pin pre-check and containment.
|
|
789
|
+
const lists = { read: row.allowSubscribe, post: row.allowPublish };
|
|
790
|
+
await assertPinCurrent(owner, row.parentPin, lists);
|
|
791
|
+
// 4. The session's own root credential.
|
|
792
|
+
const credentialId = await ensureRootCredential(registry, { owner, actor, lifecycleUid: row.lifecycleUid, managerInstance: deps.managerInstance });
|
|
793
|
+
// 5. Sign in memory.
|
|
794
|
+
const window = chatBearerWindow(Math.floor(deps.now() / 1000), idpExp, row.expiresAt);
|
|
795
|
+
if (window === undefined)
|
|
796
|
+
throw new EpEnvelopeError("permission-denied", "the IdP proof or the chat session has no remaining life; nothing was signed");
|
|
797
|
+
const token = await issuer.issue({ owner, space, actor, scope: [], lifecycleUid: row.lifecycleUid, credentialId, ttlSec: window.ttlSec, notAfter: window.notAfter });
|
|
798
|
+
// 6. Post-sign re-check: own row and head, the index, the parent pin. A mismatch discards the
|
|
799
|
+
// signed bytes; the root credential stays.
|
|
800
|
+
try {
|
|
801
|
+
activeRow(owner, actor);
|
|
802
|
+
await assertOwnHeadCurrent(owner, actor, row.lifecycleUid, credentialId);
|
|
803
|
+
await assertIndexActive(owner, row.holderHash, row.lifecycleUid);
|
|
804
|
+
await assertPinCurrent(owner, row.parentPin, lists);
|
|
805
|
+
}
|
|
806
|
+
catch (e) {
|
|
807
|
+
throw new EpEnvelopeError("permission-denied", `the chat session changed while its bearer was signed; the bearer was discarded (${reason(e)})`);
|
|
808
|
+
}
|
|
809
|
+
const payload = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
|
|
810
|
+
if (typeof payload.exp !== "number")
|
|
811
|
+
throw new EpEnvelopeError("internal", "the minted chat bearer carries no exp");
|
|
812
|
+
return { token, exp: payload.exp };
|
|
813
|
+
};
|
|
814
|
+
return {
|
|
815
|
+
// Every refusal the routes answer states its effect: a validation or pre-write refusal is
|
|
816
|
+
// `not-executed`, and one raised after this request may have written is `unknown` unless the
|
|
817
|
+
// step that raised it said otherwise.
|
|
818
|
+
admit: async (raw) => {
|
|
819
|
+
let req;
|
|
820
|
+
try {
|
|
821
|
+
const owner = assertDerivedOwnerToken(raw.owner);
|
|
822
|
+
const holderHash = assertHolderHash(raw.holderHash);
|
|
823
|
+
if (raw.label !== undefined && (typeof raw.label !== "string" || raw.label.length === 0 || raw.label.length > CHAT_LABEL_MAX))
|
|
824
|
+
throw new EpEnvelopeError("bad-request", `a chat label is 1 to ${CHAT_LABEL_MAX} characters`);
|
|
825
|
+
const read = [...assertChatList(raw.read, "read")];
|
|
826
|
+
const post = [...assertChatList(raw.post, "post")];
|
|
827
|
+
if (!Number.isSafeInteger(raw.ttlSec) || raw.ttlSec <= 0)
|
|
828
|
+
throw new EpEnvelopeError("bad-request", "ttlSec must be a positive integer of seconds");
|
|
829
|
+
const ttlMs = raw.ttlSec * 1000;
|
|
830
|
+
if (ttlMs > CHAT_SESSION_MAX_TTL_MS)
|
|
831
|
+
throw new EpEnvelopeError("bad-request", `a chat session lasts at most ${CHAT_SESSION_MAX_TTL_MS / 1000} seconds and is never extended; ask for less`);
|
|
832
|
+
req = { owner, holderHash, read, post, ttlMs, ...(raw.label !== undefined ? { label: raw.label } : {}) };
|
|
833
|
+
}
|
|
834
|
+
catch (e) {
|
|
835
|
+
throw withOutcome(e, "not-executed");
|
|
836
|
+
}
|
|
837
|
+
// Same-holder admissions in this process run one at a time; each re-reads the durable state.
|
|
838
|
+
const r = req;
|
|
839
|
+
try {
|
|
840
|
+
return await serializedFor(CHAT_HOLDER_CHAINS, holderKey(r.owner, r.holderHash), () => admitOnce(r));
|
|
841
|
+
}
|
|
842
|
+
catch (e) {
|
|
843
|
+
throw withOutcome(e, "unknown");
|
|
844
|
+
}
|
|
845
|
+
},
|
|
846
|
+
// No bearer leaves on any refusal, so each one is `not-executed` for the caller. The session's
|
|
847
|
+
// own root credential ensured in step 4 is idempotent and stays.
|
|
848
|
+
exchange: async (args) => {
|
|
849
|
+
try {
|
|
850
|
+
return await exchangeOnce(args);
|
|
851
|
+
}
|
|
852
|
+
catch (e) {
|
|
853
|
+
throw withOutcome(e, "not-executed");
|
|
854
|
+
}
|
|
855
|
+
},
|
|
856
|
+
isChatPrincipal: (owner, actor) => chatSessionAliasPresent(dir, owner, actor),
|
|
857
|
+
authorizeConnect: async (t) => {
|
|
858
|
+
const owner = assertDerivedOwnerToken(t.owner);
|
|
859
|
+
const actor = t.act.actor;
|
|
860
|
+
// The three-space read: a chat alias also present as a grant is a broken ledger and denies.
|
|
861
|
+
findActorUnified(dir, owner, actor);
|
|
862
|
+
if (t.act.view !== undefined)
|
|
863
|
+
throw new EpEnvelopeError("permission-denied", "a chat session connects only as its own agent leaf, never with a view");
|
|
864
|
+
if (t.scope.length !== 0 || (t.act.scope ?? []).length !== 0)
|
|
865
|
+
throw new EpEnvelopeError("permission-denied", "a chat session bearer carries no capability scope");
|
|
866
|
+
const row = activeRow(owner, actor);
|
|
867
|
+
if (t.act.lifecycleUid !== row.lifecycleUid)
|
|
868
|
+
throw new EpEnvelopeError("permission-denied", `bearer lifecycle ${String(t.act.lifecycleUid)} is not this chat session's ${row.lifecycleUid}`);
|
|
869
|
+
// The connect pin check: the parent head at the pinned UID and revision, the digest, containment.
|
|
870
|
+
await assertPinCurrent(owner, row.parentPin, { read: row.allowSubscribe, post: row.allowPublish });
|
|
871
|
+
await assertIndexActive(owner, row.holderHash, row.lifecycleUid);
|
|
872
|
+
},
|
|
873
|
+
retire: async ({ owner: rawOwner, actor, lifecycleUid, holderProof }) => {
|
|
874
|
+
let holderHash;
|
|
875
|
+
try {
|
|
876
|
+
const owner = assertDerivedOwnerToken(rawOwner);
|
|
877
|
+
try {
|
|
878
|
+
assertChatActor(actor);
|
|
879
|
+
assertLifecycleToken(lifecycleUid);
|
|
880
|
+
}
|
|
881
|
+
catch {
|
|
882
|
+
throw holderDenied();
|
|
883
|
+
}
|
|
884
|
+
const rec = readChatSessionRow(dir, owner, actor);
|
|
885
|
+
if (rec === undefined || !holderMatches(holderProof, rec.row.holderHash) || rec.row.lifecycleUid !== lifecycleUid)
|
|
886
|
+
throw holderDenied();
|
|
887
|
+
if (rec.row.state === "retired")
|
|
888
|
+
return { outcome: "alreadyRetired" };
|
|
889
|
+
holderHash = rec.row.holderHash;
|
|
890
|
+
}
|
|
891
|
+
catch (e) {
|
|
892
|
+
throw withOutcome(e, "not-executed");
|
|
893
|
+
}
|
|
894
|
+
try {
|
|
895
|
+
return await settleForClose(assertDerivedOwnerToken(rawOwner), holderHash);
|
|
896
|
+
}
|
|
897
|
+
catch (e) {
|
|
898
|
+
throw withOutcome(e, "unknown");
|
|
899
|
+
}
|
|
900
|
+
},
|
|
901
|
+
sweepPass,
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* The callout's ACL resolver, chat-aware. A chat principal resolves from its closed row only: the
|
|
906
|
+
* session's read and post lists, its UID, and an empty capability set, so core mints exactly the
|
|
907
|
+
* agent leaf with no role. Every other principal resolves through `base` unchanged.
|
|
908
|
+
*/
|
|
909
|
+
export function chatAwareAclResolver(dir, base) {
|
|
910
|
+
return (t) => {
|
|
911
|
+
if (!chatSessionAliasPresent(dir, t.owner, t.act.actor))
|
|
912
|
+
return base(t);
|
|
913
|
+
findActorUnified(dir, t.owner, t.act.actor);
|
|
914
|
+
if (t.act.view !== undefined)
|
|
915
|
+
throw new Error("callout permissions: a chat session mints only its agent leaf, never a view");
|
|
916
|
+
const rec = readChatSessionRow(dir, t.owner, t.act.actor);
|
|
917
|
+
if (rec === undefined || rec.form !== "row" || rec.row.state !== "active")
|
|
918
|
+
throw new Error(`callout permissions: chat session "${t.owner}.${t.act.actor}" is not active`);
|
|
919
|
+
if (t.act.lifecycleUid !== rec.row.lifecycleUid)
|
|
920
|
+
throw new Error("callout permissions: the bearer's lifecycle is not the chat session's");
|
|
921
|
+
return { allowSubscribe: [...rec.row.allowSubscribe], allowPublish: [...rec.row.allowPublish], lifecycleUid: rec.row.lifecycleUid, scope: [] };
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
//# sourceMappingURL=chat-session-authority.js.map
|