@cello-protocol/client 0.0.21 → 0.0.22
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/client-send-helpers.d.ts +25 -0
- package/dist/client-send-helpers.d.ts.map +1 -0
- package/dist/client-send-helpers.js +118 -0
- package/dist/client-send-helpers.js.map +1 -0
- package/dist/client-startup.d.ts +74 -0
- package/dist/client-startup.d.ts.map +1 -0
- package/dist/client-startup.js +337 -0
- package/dist/client-startup.js.map +1 -0
- package/dist/client-wiring.d.ts +120 -0
- package/dist/client-wiring.d.ts.map +1 -0
- package/dist/client-wiring.js +289 -0
- package/dist/client-wiring.js.map +1 -0
- package/dist/client.d.ts +29 -169
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +222 -5396
- package/dist/client.js.map +1 -1
- package/dist/connection-inbound-handler.d.ts +47 -0
- package/dist/connection-inbound-handler.d.ts.map +1 -0
- package/dist/connection-inbound-handler.js +325 -0
- package/dist/connection-inbound-handler.js.map +1 -0
- package/dist/connection-manager.d.ts +191 -0
- package/dist/connection-manager.d.ts.map +1 -0
- package/dist/connection-manager.js +692 -0
- package/dist/connection-manager.js.map +1 -0
- package/dist/frame-dispatch.d.ts +28 -0
- package/dist/frame-dispatch.d.ts.map +1 -0
- package/dist/frame-dispatch.js +118 -0
- package/dist/frame-dispatch.js.map +1 -0
- package/dist/registration-manager.d.ts +54 -0
- package/dist/registration-manager.d.ts.map +1 -0
- package/dist/registration-manager.js +248 -0
- package/dist/registration-manager.js.map +1 -0
- package/dist/relay-stream-manager.d.ts +136 -0
- package/dist/relay-stream-manager.d.ts.map +1 -0
- package/dist/relay-stream-manager.js +834 -0
- package/dist/relay-stream-manager.js.map +1 -0
- package/dist/seal-manager.d.ts +133 -0
- package/dist/seal-manager.d.ts.map +1 -0
- package/dist/seal-manager.js +803 -0
- package/dist/seal-manager.js.map +1 -0
- package/dist/session-assignment-parser.d.ts +33 -0
- package/dist/session-assignment-parser.d.ts.map +1 -0
- package/dist/session-assignment-parser.js +149 -0
- package/dist/session-assignment-parser.js.map +1 -0
- package/dist/session-manager.d.ts +132 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/session-manager.js +605 -0
- package/dist/session-manager.js.map +1 -0
- package/dist/signaling-manager.d.ts +85 -0
- package/dist/signaling-manager.d.ts.map +1 -0
- package/dist/signaling-manager.js +597 -0
- package/dist/signaling-manager.js.map +1 -0
- package/package.json +3 -3
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConnectionManager — CONNREQ-002, CONNREQ-003
|
|
3
|
+
*
|
|
4
|
+
* Extracted from CelloClientImpl. Owns all connection domain state:
|
|
5
|
+
* connections, connectionsByPeer, profileUncheckedPeers, connectionPolicy,
|
|
6
|
+
* pending resolvers, review queue, etc.
|
|
7
|
+
*/
|
|
8
|
+
import { Encoder } from "cbor-x";
|
|
9
|
+
import * as lp from "it-length-prefixed";
|
|
10
|
+
import { handleInboundConnectionRequest as doHandleInboundConnectionRequest, handleDisclosureResponse as doHandleDisclosureResponse, } from "./connection-inbound-handler.js";
|
|
11
|
+
const CBOR_ENC = new Encoder({ tagUint8Array: false });
|
|
12
|
+
export class ConnectionManager {
|
|
13
|
+
#ctx;
|
|
14
|
+
// Connection state owned by this manager
|
|
15
|
+
#connections = new Map();
|
|
16
|
+
#connectionsByPeer = new Map();
|
|
17
|
+
#profileUncheckedPeers = new Set();
|
|
18
|
+
#connectionPolicy;
|
|
19
|
+
#onConnectionEstablishedHandler;
|
|
20
|
+
#onDisclosureRequestedHandler;
|
|
21
|
+
#onConnectionPendingReview;
|
|
22
|
+
// Request tracking state owned by this manager
|
|
23
|
+
#pendingConnectionRequestResolvers = new Map();
|
|
24
|
+
#pendingAwaitConnectionRequestResolvers = [];
|
|
25
|
+
#pendingDisclosureResolvers = new Map();
|
|
26
|
+
#pendingInboundRequests = new Map();
|
|
27
|
+
#pendingReviewQueue = [];
|
|
28
|
+
#decidedRequests = new Set();
|
|
29
|
+
constructor(ctx, opts) {
|
|
30
|
+
this.#ctx = ctx;
|
|
31
|
+
this.#connectionPolicy = opts?.connectionPolicy;
|
|
32
|
+
this.#onConnectionPendingReview = opts?.onConnectionPendingReview;
|
|
33
|
+
}
|
|
34
|
+
// ─── Public state accessors ──────────────────────────────────────────────────
|
|
35
|
+
get pendingConnectionRequestResolverCount() {
|
|
36
|
+
return this.#pendingConnectionRequestResolvers.size;
|
|
37
|
+
}
|
|
38
|
+
listConnections() {
|
|
39
|
+
return [...this.#connections.values()];
|
|
40
|
+
}
|
|
41
|
+
hasConnection(counterpartyPubkeyHex) {
|
|
42
|
+
return this.#connectionsByPeer.get(counterpartyPubkeyHex) ?? null;
|
|
43
|
+
}
|
|
44
|
+
getConnectionIdForPeer(pubkeyHex) {
|
|
45
|
+
return this.#connectionsByPeer.get(pubkeyHex);
|
|
46
|
+
}
|
|
47
|
+
hasConnectionPolicy() {
|
|
48
|
+
return this.#connectionPolicy !== undefined;
|
|
49
|
+
}
|
|
50
|
+
setPolicy(policy) {
|
|
51
|
+
this.#connectionPolicy = policy;
|
|
52
|
+
}
|
|
53
|
+
getPolicy() {
|
|
54
|
+
return this.#connectionPolicy ?? { mode: "open", review_mode: "deterministic", requirements: [] };
|
|
55
|
+
}
|
|
56
|
+
onConnectionEstablished(handler) {
|
|
57
|
+
this.#onConnectionEstablishedHandler = handler;
|
|
58
|
+
}
|
|
59
|
+
onDisclosureRequested(handler) {
|
|
60
|
+
this.#onDisclosureRequestedHandler = handler;
|
|
61
|
+
}
|
|
62
|
+
setOnConnectionPendingReview(handler) {
|
|
63
|
+
this.#onConnectionPendingReview = handler;
|
|
64
|
+
}
|
|
65
|
+
/** Called during loadPersistedState to restore connection records. */
|
|
66
|
+
addConnection(id, record) {
|
|
67
|
+
this.#connections.set(id, record);
|
|
68
|
+
}
|
|
69
|
+
addConnectionByPeer(pubkey, id) {
|
|
70
|
+
this.#connectionsByPeer.set(pubkey, id);
|
|
71
|
+
}
|
|
72
|
+
addProfileUncheckedPeer(pubkey) {
|
|
73
|
+
this.#profileUncheckedPeers.add(pubkey);
|
|
74
|
+
}
|
|
75
|
+
// ─── Public API methods ─────────────────────────────────────────────────────
|
|
76
|
+
async acceptConnection(connectionRequestId) {
|
|
77
|
+
const pending = this.#pendingInboundRequests.get(connectionRequestId);
|
|
78
|
+
if (!pending) {
|
|
79
|
+
if (this.#decidedRequests.has(connectionRequestId)) {
|
|
80
|
+
return { error: { reason: "already_decided" } };
|
|
81
|
+
}
|
|
82
|
+
return { error: { reason: "no_pending_request" } };
|
|
83
|
+
}
|
|
84
|
+
if (this.#decidedRequests.has(connectionRequestId)) {
|
|
85
|
+
return { error: { reason: "already_decided" } };
|
|
86
|
+
}
|
|
87
|
+
// Mark as decided before sending to prevent races
|
|
88
|
+
this.#decidedRequests.add(connectionRequestId);
|
|
89
|
+
this.#pendingInboundRequests.delete(connectionRequestId);
|
|
90
|
+
// CRIT-2: persist decision
|
|
91
|
+
if (this.#ctx.persistence) {
|
|
92
|
+
void this.#ctx.persistence.decidePendingConnectionRequest(connectionRequestId, "accepted");
|
|
93
|
+
}
|
|
94
|
+
const stream = this.#ctx.getPersistentSignalingStream();
|
|
95
|
+
if (!stream) {
|
|
96
|
+
// Stream gone — still mark as decided
|
|
97
|
+
return { error: { reason: "no_pending_request" } };
|
|
98
|
+
}
|
|
99
|
+
const responseFrame = CBOR_ENC.encode({
|
|
100
|
+
type: "connection_response",
|
|
101
|
+
connection_request_id: connectionRequestId,
|
|
102
|
+
verdict: "accept",
|
|
103
|
+
});
|
|
104
|
+
try {
|
|
105
|
+
stream.send(lp.encode.single(responseFrame));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return { error: { reason: "no_pending_request" } };
|
|
109
|
+
}
|
|
110
|
+
// Wait for connection_established to arrive via the signaling reader loop.
|
|
111
|
+
const deadline = Date.now() + this.#ctx.connectionTimeoutMs;
|
|
112
|
+
while (Date.now() < deadline) {
|
|
113
|
+
const connectionId = this.#connectionsByPeer.get(pending.from_pubkey);
|
|
114
|
+
if (connectionId) {
|
|
115
|
+
return { accepted: true, connection_id: connectionId };
|
|
116
|
+
}
|
|
117
|
+
const remaining = deadline - Date.now();
|
|
118
|
+
if (remaining <= 0)
|
|
119
|
+
break;
|
|
120
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(20, remaining)));
|
|
121
|
+
}
|
|
122
|
+
return { error: { reason: "no_pending_request" } };
|
|
123
|
+
}
|
|
124
|
+
async rejectConnection(connectionRequestId, reason) {
|
|
125
|
+
const pending = this.#pendingInboundRequests.get(connectionRequestId);
|
|
126
|
+
if (!pending) {
|
|
127
|
+
if (this.#decidedRequests.has(connectionRequestId)) {
|
|
128
|
+
return { error: { reason: "already_decided" } };
|
|
129
|
+
}
|
|
130
|
+
return { error: { reason: "no_pending_request" } };
|
|
131
|
+
}
|
|
132
|
+
if (this.#decidedRequests.has(connectionRequestId)) {
|
|
133
|
+
return { error: { reason: "already_decided" } };
|
|
134
|
+
}
|
|
135
|
+
// Mark as decided
|
|
136
|
+
this.#decidedRequests.add(connectionRequestId);
|
|
137
|
+
this.#pendingInboundRequests.delete(connectionRequestId);
|
|
138
|
+
// CRIT-2: persist decision
|
|
139
|
+
if (this.#ctx.persistence) {
|
|
140
|
+
void this.#ctx.persistence.decidePendingConnectionRequest(connectionRequestId, "rejected");
|
|
141
|
+
}
|
|
142
|
+
const stream = this.#ctx.getPersistentSignalingStream();
|
|
143
|
+
if (!stream) {
|
|
144
|
+
return { rejected: true };
|
|
145
|
+
}
|
|
146
|
+
const payload = {
|
|
147
|
+
type: "connection_response",
|
|
148
|
+
connection_request_id: connectionRequestId,
|
|
149
|
+
verdict: "reject",
|
|
150
|
+
};
|
|
151
|
+
if (reason !== undefined)
|
|
152
|
+
payload["reason"] = reason;
|
|
153
|
+
const responseFrame = CBOR_ENC.encode(payload);
|
|
154
|
+
try {
|
|
155
|
+
stream.send(lp.encode.single(responseFrame));
|
|
156
|
+
}
|
|
157
|
+
catch { /* stream closed */ }
|
|
158
|
+
return { rejected: true };
|
|
159
|
+
}
|
|
160
|
+
async requestMoreDisclosure(connectionRequestId, requestedItems) {
|
|
161
|
+
const pending = this.#pendingInboundRequests.get(connectionRequestId);
|
|
162
|
+
if (!pending) {
|
|
163
|
+
if (this.#decidedRequests.has(connectionRequestId)) {
|
|
164
|
+
return { error: { reason: "already_decided" } };
|
|
165
|
+
}
|
|
166
|
+
return { error: { reason: "no_pending_request" } };
|
|
167
|
+
}
|
|
168
|
+
if (this.#decidedRequests.has(connectionRequestId)) {
|
|
169
|
+
return { error: { reason: "already_decided" } };
|
|
170
|
+
}
|
|
171
|
+
if (pending.round >= 2) {
|
|
172
|
+
return { error: { reason: "max_rounds_reached" } };
|
|
173
|
+
}
|
|
174
|
+
// Advance to Round 2
|
|
175
|
+
pending.round = 2;
|
|
176
|
+
// CRIT-2: persist disclosure decision
|
|
177
|
+
if (this.#ctx.persistence) {
|
|
178
|
+
void this.#ctx.persistence.decidePendingConnectionRequest(connectionRequestId, "more_disclosure");
|
|
179
|
+
}
|
|
180
|
+
const stream = this.#ctx.getPersistentSignalingStream();
|
|
181
|
+
if (!stream) {
|
|
182
|
+
return { error: { reason: "no_pending_request" } };
|
|
183
|
+
}
|
|
184
|
+
const disclosureFrame = CBOR_ENC.encode({
|
|
185
|
+
type: "disclosure_request",
|
|
186
|
+
connection_request_id: connectionRequestId,
|
|
187
|
+
requested_items: requestedItems,
|
|
188
|
+
});
|
|
189
|
+
try {
|
|
190
|
+
stream.send(lp.encode.single(disclosureFrame));
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return { error: { reason: "no_pending_request" } };
|
|
194
|
+
}
|
|
195
|
+
return { request_sent: true };
|
|
196
|
+
}
|
|
197
|
+
async awaitConnectionRequest(timeoutMs = 30_000) {
|
|
198
|
+
// Fast path: if items already queued, return immediately (no Promise overhead)
|
|
199
|
+
if (this.#pendingReviewQueue.length > 0) {
|
|
200
|
+
const item = this.#pendingReviewQueue.shift();
|
|
201
|
+
return {
|
|
202
|
+
type: "pending_review",
|
|
203
|
+
connection_request_id: item.connection_request_id,
|
|
204
|
+
from_pubkey: item.from_pubkey,
|
|
205
|
+
report: item.report,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
// CONNREQ-003: multi-slot await via Promise queue.
|
|
209
|
+
let resolveItem;
|
|
210
|
+
const itemPromise = new Promise((resolve) => {
|
|
211
|
+
resolveItem = resolve;
|
|
212
|
+
});
|
|
213
|
+
this.#pendingAwaitConnectionRequestResolvers.push(resolveItem);
|
|
214
|
+
const result = { item: null };
|
|
215
|
+
let timedOut = false;
|
|
216
|
+
await Promise.race([
|
|
217
|
+
itemPromise.then((i) => { result.item = i; }),
|
|
218
|
+
new Promise((resolve) => setTimeout(() => { timedOut = true; resolve(); }, timeoutMs)),
|
|
219
|
+
]);
|
|
220
|
+
// On timeout: remove our resolver from the queue if it hasn't been consumed yet
|
|
221
|
+
if (timedOut) {
|
|
222
|
+
const idx = this.#pendingAwaitConnectionRequestResolvers.indexOf(resolveItem);
|
|
223
|
+
if (idx !== -1) {
|
|
224
|
+
this.#pendingAwaitConnectionRequestResolvers.splice(idx, 1);
|
|
225
|
+
}
|
|
226
|
+
return { type: "timeout" };
|
|
227
|
+
}
|
|
228
|
+
const item = result.item;
|
|
229
|
+
if (!item) {
|
|
230
|
+
return { type: "timeout" };
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
type: "pending_review",
|
|
234
|
+
connection_request_id: item.connection_request_id,
|
|
235
|
+
from_pubkey: item.from_pubkey,
|
|
236
|
+
report: item.report,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
async cello_request_connection(opts) {
|
|
240
|
+
const targetPubkeyHex = opts.target_pubkey;
|
|
241
|
+
// CONNREQ-003 AC-002: reject duplicate concurrent requests to same target immediately.
|
|
242
|
+
if (this.#pendingConnectionRequestResolvers.has(targetPubkeyHex)) {
|
|
243
|
+
this.#ctx.logger.warn("connection.request.duplicate", { targetPubkeyHex });
|
|
244
|
+
return { result: "error", reason: "connection_request_in_flight" };
|
|
245
|
+
}
|
|
246
|
+
// CONNREQ-003: Reserve this target's slot in the map BEFORE any async work.
|
|
247
|
+
let resolveOutcome;
|
|
248
|
+
const outcomePromise = new Promise((resolve) => {
|
|
249
|
+
resolveOutcome = resolve;
|
|
250
|
+
});
|
|
251
|
+
this.#pendingConnectionRequestResolvers.set(targetPubkeyHex, resolveOutcome);
|
|
252
|
+
// AC-008 (DX-001): per-stage timeouts.
|
|
253
|
+
const dialTimeoutMs = opts.dialTimeoutMs ?? this.#ctx.connectionTimeoutMs;
|
|
254
|
+
const sendTimeoutMs = opts.sendTimeoutMs ?? this.#ctx.connectionTimeoutMs;
|
|
255
|
+
const waitTimeoutMs = opts.waitTimeoutMs ?? this.#ctx.connectionTimeoutMs;
|
|
256
|
+
// Stage 1 — dial: Ensure the persistent signaling stream is open within dialTimeoutMs.
|
|
257
|
+
if (!this.#ctx.getPersistentSignalingStream()) {
|
|
258
|
+
let dialTimedOut = false;
|
|
259
|
+
let opened = false;
|
|
260
|
+
await Promise.race([
|
|
261
|
+
this.#ctx.openPersistentSignalingStream().then((result) => { opened = result; }),
|
|
262
|
+
new Promise((resolve) => setTimeout(() => { dialTimedOut = true; resolve(); }, dialTimeoutMs)),
|
|
263
|
+
]);
|
|
264
|
+
if (dialTimedOut || !opened) {
|
|
265
|
+
this.#pendingConnectionRequestResolvers.delete(targetPubkeyHex);
|
|
266
|
+
this.#ctx.logger.warn("client.connection.request.stage.timeout", { stage: "dial", timeoutMs: dialTimeoutMs, targetPubkeyOrAgentId: targetPubkeyHex });
|
|
267
|
+
return { result: "timeout", stage: "dial" };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Mint a correlationId for this outbound connection request flow.
|
|
271
|
+
const correlationId = `connreq-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
|
|
272
|
+
this.#ctx.logger.info("connection.request.sent", { targetPubkeyHex, correlationId });
|
|
273
|
+
// Build the frame
|
|
274
|
+
const frameBytes = CBOR_ENC.encode({
|
|
275
|
+
type: "connection_request",
|
|
276
|
+
target_pubkey: targetPubkeyHex,
|
|
277
|
+
package_cbor: opts.package_cbor,
|
|
278
|
+
});
|
|
279
|
+
// Stage 2 — send: Deliver the frame within sendTimeoutMs.
|
|
280
|
+
let sendTimedOut = false;
|
|
281
|
+
let sendError = false;
|
|
282
|
+
await Promise.race([
|
|
283
|
+
new Promise((resolve) => {
|
|
284
|
+
try {
|
|
285
|
+
this.#ctx.getPersistentSignalingStream().send(lp.encode.single(frameBytes));
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
sendError = true;
|
|
289
|
+
}
|
|
290
|
+
resolve();
|
|
291
|
+
}),
|
|
292
|
+
new Promise((resolve) => setTimeout(() => { sendTimedOut = true; resolve(); }, sendTimeoutMs)),
|
|
293
|
+
]);
|
|
294
|
+
if (sendTimedOut) {
|
|
295
|
+
this.#pendingConnectionRequestResolvers.delete(targetPubkeyHex);
|
|
296
|
+
this.#ctx.logger.warn("client.connection.request.stage.timeout", { stage: "send", timeoutMs: sendTimeoutMs, targetPubkeyOrAgentId: targetPubkeyHex });
|
|
297
|
+
return { result: "timeout", stage: "send" };
|
|
298
|
+
}
|
|
299
|
+
if (sendError) {
|
|
300
|
+
this.#pendingConnectionRequestResolvers.delete(targetPubkeyHex);
|
|
301
|
+
return { result: "error", reason: "directory_unreachable" };
|
|
302
|
+
}
|
|
303
|
+
// Stage 3 — wait: Race outcome vs waitTimeoutMs.
|
|
304
|
+
let frame = null;
|
|
305
|
+
let timedOut = false;
|
|
306
|
+
await Promise.race([
|
|
307
|
+
outcomePromise.then((f) => { frame = f; }),
|
|
308
|
+
new Promise((resolve) => setTimeout(() => { timedOut = true; resolve(); }, waitTimeoutMs)),
|
|
309
|
+
]);
|
|
310
|
+
// Clean up this target's resolver slot on timeout
|
|
311
|
+
if (this.#pendingConnectionRequestResolvers.get(targetPubkeyHex) === resolveOutcome) {
|
|
312
|
+
this.#pendingConnectionRequestResolvers.delete(targetPubkeyHex);
|
|
313
|
+
}
|
|
314
|
+
if (timedOut || !frame) {
|
|
315
|
+
this.#ctx.logger.warn("client.connection.request.stage.timeout", { stage: "wait", timeoutMs: waitTimeoutMs, targetPubkeyOrAgentId: targetPubkeyHex });
|
|
316
|
+
return { result: "timeout", stage: "wait" };
|
|
317
|
+
}
|
|
318
|
+
const type = frame["type"];
|
|
319
|
+
if (type === "connection_established") {
|
|
320
|
+
const connectionId = frame["connection_id"];
|
|
321
|
+
const counterpartyPubkey = frame["counterparty_pubkey"];
|
|
322
|
+
// Store connection record locally
|
|
323
|
+
const record = {
|
|
324
|
+
connection_id: connectionId,
|
|
325
|
+
counterparty_pubkey: counterpartyPubkey,
|
|
326
|
+
counterparty_primary_pubkey: "",
|
|
327
|
+
counterparty_ml_dsa_pubkey: "",
|
|
328
|
+
established_at: Date.now(),
|
|
329
|
+
status: "active",
|
|
330
|
+
};
|
|
331
|
+
this.#connections.set(connectionId, record);
|
|
332
|
+
this.#connectionsByPeer.set(counterpartyPubkey, connectionId);
|
|
333
|
+
// PERSIST-024: persist connection to DB
|
|
334
|
+
if (this.#ctx.persistence) {
|
|
335
|
+
void this.#ctx.persistence.persistConnection({
|
|
336
|
+
connectionId,
|
|
337
|
+
counterpartyPubkey,
|
|
338
|
+
establishedAt: record.established_at,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
this.#ctx.logger.info("connection.established", {
|
|
342
|
+
connectionId,
|
|
343
|
+
counterpartyPubkeyHex: counterpartyPubkey,
|
|
344
|
+
correlationId,
|
|
345
|
+
});
|
|
346
|
+
return { result: "established", connection_id: connectionId };
|
|
347
|
+
}
|
|
348
|
+
if (type === "connection_rejected") {
|
|
349
|
+
return { result: "rejected", reason: frame["reason"] ?? "rejected" };
|
|
350
|
+
}
|
|
351
|
+
if (type === "connection_insufficient") {
|
|
352
|
+
return { result: "insufficient", unmet_requirements: frame["unmet_requirements"] ?? [] };
|
|
353
|
+
}
|
|
354
|
+
if (type === "connection_request_error") {
|
|
355
|
+
const reason = frame["reason"] ?? "unknown";
|
|
356
|
+
// already_connected: hydrate the existing connection so the caller can proceed.
|
|
357
|
+
if (reason === "already_connected" && frame["connection_id"]) {
|
|
358
|
+
const connectionId = frame["connection_id"];
|
|
359
|
+
if (!this.#connections.has(connectionId)) {
|
|
360
|
+
const record = {
|
|
361
|
+
connection_id: connectionId,
|
|
362
|
+
counterparty_pubkey: targetPubkeyHex,
|
|
363
|
+
counterparty_primary_pubkey: "",
|
|
364
|
+
counterparty_ml_dsa_pubkey: "",
|
|
365
|
+
established_at: Date.now(),
|
|
366
|
+
status: "active",
|
|
367
|
+
};
|
|
368
|
+
this.#connections.set(connectionId, record);
|
|
369
|
+
this.#connectionsByPeer.set(targetPubkeyHex, connectionId);
|
|
370
|
+
// PERSIST-024: persist connection to DB
|
|
371
|
+
if (this.#ctx.persistence) {
|
|
372
|
+
void this.#ctx.persistence.persistConnection({
|
|
373
|
+
connectionId,
|
|
374
|
+
counterpartyPubkey: targetPubkeyHex,
|
|
375
|
+
establishedAt: record.established_at,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
this.#ctx.logger.info("connection.established", {
|
|
380
|
+
connectionId,
|
|
381
|
+
counterpartyPubkeyHex: targetPubkeyHex,
|
|
382
|
+
correlationId,
|
|
383
|
+
});
|
|
384
|
+
return { result: "established", connection_id: connectionId };
|
|
385
|
+
}
|
|
386
|
+
this.#ctx.logger.error("connection.request.failed", { targetPubkeyHex, reason, correlationId });
|
|
387
|
+
return { result: "error", reason };
|
|
388
|
+
}
|
|
389
|
+
if (type === "disclosure_request_inbound") {
|
|
390
|
+
return {
|
|
391
|
+
result: "disclosure_requested",
|
|
392
|
+
connection_request_id: frame["connection_request_id"],
|
|
393
|
+
requested_items: frame["requested_items"] ?? [],
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
return { result: "error", reason: "unknown" };
|
|
397
|
+
}
|
|
398
|
+
async cello_respond_to_disclosure_request(opts) {
|
|
399
|
+
const stream = this.#ctx.getPersistentSignalingStream();
|
|
400
|
+
if (!stream) {
|
|
401
|
+
return { result: "error", reason: "directory_unreachable" };
|
|
402
|
+
}
|
|
403
|
+
const frameBytes = CBOR_ENC.encode({
|
|
404
|
+
type: "disclosure_response",
|
|
405
|
+
connection_request_id: opts.connection_request_id,
|
|
406
|
+
package_cbor: opts.package_cbor,
|
|
407
|
+
});
|
|
408
|
+
let resolveOutcome;
|
|
409
|
+
const outcomePromise = new Promise((resolve) => {
|
|
410
|
+
resolveOutcome = resolve;
|
|
411
|
+
});
|
|
412
|
+
this.#pendingDisclosureResolvers.set(opts.connection_request_id, resolveOutcome);
|
|
413
|
+
try {
|
|
414
|
+
stream.send(lp.encode.single(frameBytes));
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
this.#pendingDisclosureResolvers.delete(opts.connection_request_id);
|
|
418
|
+
return { result: "error", reason: "directory_unreachable" };
|
|
419
|
+
}
|
|
420
|
+
let frame = null;
|
|
421
|
+
let timedOut = false;
|
|
422
|
+
await Promise.race([
|
|
423
|
+
outcomePromise.then((f) => { frame = f; }),
|
|
424
|
+
new Promise((resolve) => setTimeout(() => { timedOut = true; resolve(); }, this.#ctx.connectionTimeoutMs)),
|
|
425
|
+
]);
|
|
426
|
+
this.#pendingDisclosureResolvers.delete(opts.connection_request_id);
|
|
427
|
+
if (timedOut || !frame) {
|
|
428
|
+
return { result: "timeout" };
|
|
429
|
+
}
|
|
430
|
+
const type = frame["type"];
|
|
431
|
+
if (type === "connection_established") {
|
|
432
|
+
const connectionId = frame["connection_id"];
|
|
433
|
+
const counterpartyPubkey = frame["counterparty_pubkey"];
|
|
434
|
+
const establishedAt = Date.now();
|
|
435
|
+
const record = {
|
|
436
|
+
connection_id: connectionId,
|
|
437
|
+
counterparty_pubkey: counterpartyPubkey,
|
|
438
|
+
counterparty_primary_pubkey: "",
|
|
439
|
+
counterparty_ml_dsa_pubkey: "",
|
|
440
|
+
established_at: establishedAt,
|
|
441
|
+
status: "active",
|
|
442
|
+
};
|
|
443
|
+
this.#connections.set(connectionId, record);
|
|
444
|
+
this.#connectionsByPeer.set(counterpartyPubkey, connectionId);
|
|
445
|
+
if (this.#ctx.persistence) {
|
|
446
|
+
void this.#ctx.persistence.persistConnection({
|
|
447
|
+
connectionId,
|
|
448
|
+
counterpartyPubkey,
|
|
449
|
+
establishedAt,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
return { result: "established", connection_id: connectionId };
|
|
453
|
+
}
|
|
454
|
+
if (type === "connection_rejected") {
|
|
455
|
+
return { result: "rejected", reason: frame["reason"] ?? "rejected" };
|
|
456
|
+
}
|
|
457
|
+
if (type === "connection_insufficient") {
|
|
458
|
+
return { result: "insufficient", unmet_requirements: frame["unmet_requirements"] ?? [] };
|
|
459
|
+
}
|
|
460
|
+
return { result: "error", reason: "unknown" };
|
|
461
|
+
}
|
|
462
|
+
async cello_request_more_disclosure(opts) {
|
|
463
|
+
const pending = this.#pendingInboundRequests.get(opts.connection_request_id);
|
|
464
|
+
if (!pending) {
|
|
465
|
+
return { error: "max_rounds_reached" }; // no such request or already completed
|
|
466
|
+
}
|
|
467
|
+
if (pending.round >= 2) {
|
|
468
|
+
return { error: "max_rounds_reached" };
|
|
469
|
+
}
|
|
470
|
+
// Advance to Round 2 state
|
|
471
|
+
pending.round = 2;
|
|
472
|
+
const stream = this.#ctx.getPersistentSignalingStream();
|
|
473
|
+
if (!stream) {
|
|
474
|
+
return { error: "max_rounds_reached" }; // stream gone
|
|
475
|
+
}
|
|
476
|
+
const frameBytes = CBOR_ENC.encode({
|
|
477
|
+
type: "disclosure_request",
|
|
478
|
+
connection_request_id: opts.connection_request_id,
|
|
479
|
+
requested_items: opts.requested_items,
|
|
480
|
+
});
|
|
481
|
+
try {
|
|
482
|
+
stream.send(lp.encode.single(frameBytes));
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
return { error: "max_rounds_reached" };
|
|
486
|
+
}
|
|
487
|
+
return { ok: true };
|
|
488
|
+
}
|
|
489
|
+
// ─── State restoration (called by loadPersistedState) ────────────────────────
|
|
490
|
+
/**
|
|
491
|
+
* Restore a decided request ID from persisted state.
|
|
492
|
+
* Called during loadPersistedState to restore the #decidedRequests set.
|
|
493
|
+
*/
|
|
494
|
+
restoreDecidedRequest(requestId) {
|
|
495
|
+
this.#decidedRequests.add(requestId);
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Restore a pending inbound request from persisted state.
|
|
499
|
+
* Called during loadPersistedState to restore the #pendingInboundRequests map.
|
|
500
|
+
*/
|
|
501
|
+
restorePendingInboundRequest(opts) {
|
|
502
|
+
this.#pendingInboundRequests.set(opts.connection_request_id, {
|
|
503
|
+
connection_request_id: opts.connection_request_id,
|
|
504
|
+
from_pubkey: opts.from_pubkey,
|
|
505
|
+
package_cbor: opts.package_cbor,
|
|
506
|
+
round: opts.round,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Restore a review queue item from persisted state.
|
|
511
|
+
* Called during loadPersistedState to restore the #pendingReviewQueue.
|
|
512
|
+
*/
|
|
513
|
+
restoreReviewQueueItem(item) {
|
|
514
|
+
this.#pendingReviewQueue.push(item);
|
|
515
|
+
}
|
|
516
|
+
// ─── TEST-ONLY escape hatches ───────────────────────────────────────────────
|
|
517
|
+
_injectPendingConnectionRequest(opts) {
|
|
518
|
+
this.#pendingInboundRequests.set(opts.connection_request_id, {
|
|
519
|
+
connection_request_id: opts.connection_request_id,
|
|
520
|
+
from_pubkey: opts.from_pubkey,
|
|
521
|
+
package_cbor: opts.package_cbor,
|
|
522
|
+
round: opts.round,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
_injectConnectionFrame(frame) {
|
|
526
|
+
const type = frame["type"];
|
|
527
|
+
if (type === "connection_established") {
|
|
528
|
+
const counterpartyPubkey = frame["counterparty_pubkey"];
|
|
529
|
+
const resolve = this.#pendingConnectionRequestResolvers.get(counterpartyPubkey);
|
|
530
|
+
if (resolve) {
|
|
531
|
+
this.#pendingConnectionRequestResolvers.delete(counterpartyPubkey);
|
|
532
|
+
resolve(frame);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
else if (type === "disclosure_request_inbound") {
|
|
536
|
+
const targetPubkeyForDisclosure = frame["from_pubkey"];
|
|
537
|
+
const resolve = this.#pendingConnectionRequestResolvers.get(targetPubkeyForDisclosure);
|
|
538
|
+
if (resolve) {
|
|
539
|
+
this.#pendingConnectionRequestResolvers.delete(targetPubkeyForDisclosure);
|
|
540
|
+
resolve(frame);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
// connection_rejected, connection_insufficient, connection_request_error
|
|
545
|
+
const targetPubkeyForError = frame["target_pubkey"];
|
|
546
|
+
if (targetPubkeyForError && this.#pendingConnectionRequestResolvers.has(targetPubkeyForError)) {
|
|
547
|
+
const resolve = this.#pendingConnectionRequestResolvers.get(targetPubkeyForError);
|
|
548
|
+
this.#pendingConnectionRequestResolvers.delete(targetPubkeyForError);
|
|
549
|
+
resolve(frame);
|
|
550
|
+
}
|
|
551
|
+
else if (this.#pendingConnectionRequestResolvers.size === 1) {
|
|
552
|
+
const [singleKey, singleResolve] = this.#pendingConnectionRequestResolvers.entries().next().value;
|
|
553
|
+
this.#pendingConnectionRequestResolvers.delete(singleKey);
|
|
554
|
+
singleResolve(frame);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
// ─── Signaling reader routing (called by CelloClientImpl signaling reader) ──
|
|
559
|
+
/**
|
|
560
|
+
* Route connection outcome frames from the signaling reader.
|
|
561
|
+
* Called by CelloClientImpl#runPersistentSignalingReader when a connection-related frame arrives.
|
|
562
|
+
*/
|
|
563
|
+
routeConnectionFrame(frame) {
|
|
564
|
+
const type = frame["type"];
|
|
565
|
+
if (type === "connection_established") {
|
|
566
|
+
// Store connection record locally (applies to both sender A and target B)
|
|
567
|
+
const connectionId = frame["connection_id"];
|
|
568
|
+
const counterpartyPubkey = frame["counterparty_pubkey"];
|
|
569
|
+
if (connectionId && counterpartyPubkey) {
|
|
570
|
+
if (!this.#connections.has(connectionId)) {
|
|
571
|
+
const record = {
|
|
572
|
+
connection_id: connectionId,
|
|
573
|
+
counterparty_pubkey: counterpartyPubkey,
|
|
574
|
+
counterparty_primary_pubkey: "",
|
|
575
|
+
counterparty_ml_dsa_pubkey: "",
|
|
576
|
+
established_at: Date.now(),
|
|
577
|
+
status: "active",
|
|
578
|
+
};
|
|
579
|
+
if (this.#profileUncheckedPeers.has(counterpartyPubkey)) {
|
|
580
|
+
record.profile_unchecked = true;
|
|
581
|
+
this.#profileUncheckedPeers.delete(counterpartyPubkey);
|
|
582
|
+
}
|
|
583
|
+
this.#connections.set(connectionId, record);
|
|
584
|
+
this.#connectionsByPeer.set(counterpartyPubkey, connectionId);
|
|
585
|
+
// PERSIST-024: persist connection to DB
|
|
586
|
+
if (this.#ctx.persistence) {
|
|
587
|
+
void this.#ctx.persistence.persistConnection({
|
|
588
|
+
connectionId,
|
|
589
|
+
counterpartyPubkey,
|
|
590
|
+
establishedAt: record.established_at,
|
|
591
|
+
profileUnchecked: record.profile_unchecked,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
// Fire onConnectionEstablished handler (both A and B)
|
|
596
|
+
if (this.#onConnectionEstablishedHandler) {
|
|
597
|
+
this.#onConnectionEstablishedHandler({ type: "connection_established", counterparty_pubkey: counterpartyPubkey, connection_id: connectionId });
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// CONNREQ-003: Route to the resolver for this specific target (keyed by counterparty pubkey).
|
|
601
|
+
const resolve = this.#pendingConnectionRequestResolvers.get(counterpartyPubkey);
|
|
602
|
+
if (resolve) {
|
|
603
|
+
this.#pendingConnectionRequestResolvers.delete(counterpartyPubkey);
|
|
604
|
+
resolve(frame);
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
// Round 2: route to disclosure resolver if pending (no in-flight connection_request)
|
|
608
|
+
for (const [id, disclosureResolve] of this.#pendingDisclosureResolvers) {
|
|
609
|
+
this.#pendingDisclosureResolvers.delete(id);
|
|
610
|
+
disclosureResolve(frame);
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
else if (type === "disclosure_request_inbound") {
|
|
616
|
+
// CONNREQ-002 Round 2: target requests more disclosure → fire onDisclosureRequested
|
|
617
|
+
if (this.#onDisclosureRequestedHandler) {
|
|
618
|
+
this.#onDisclosureRequestedHandler({
|
|
619
|
+
type: "disclosure_request_inbound",
|
|
620
|
+
from_pubkey: frame["from_pubkey"],
|
|
621
|
+
connection_request_id: frame["connection_request_id"],
|
|
622
|
+
requested_items: frame["requested_items"] ?? [],
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
// CONNREQ-003: disclosure_request_inbound carries from_pubkey (= target).
|
|
626
|
+
const targetPubkeyForDisclosure = frame["from_pubkey"];
|
|
627
|
+
const resolve = this.#pendingConnectionRequestResolvers.get(targetPubkeyForDisclosure);
|
|
628
|
+
if (resolve) {
|
|
629
|
+
this.#pendingConnectionRequestResolvers.delete(targetPubkeyForDisclosure);
|
|
630
|
+
resolve(frame);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
else {
|
|
634
|
+
// connection_rejected, connection_insufficient, connection_request_error
|
|
635
|
+
const targetPubkeyForError = frame["target_pubkey"];
|
|
636
|
+
if (targetPubkeyForError && this.#pendingConnectionRequestResolvers.has(targetPubkeyForError)) {
|
|
637
|
+
const resolve = this.#pendingConnectionRequestResolvers.get(targetPubkeyForError);
|
|
638
|
+
this.#pendingConnectionRequestResolvers.delete(targetPubkeyForError);
|
|
639
|
+
resolve(frame);
|
|
640
|
+
}
|
|
641
|
+
else if (this.#pendingConnectionRequestResolvers.size === 1) {
|
|
642
|
+
// Fallback: if exactly one request is in flight and frame has no target_pubkey,
|
|
643
|
+
// route to that single resolver (backward-compatible with pre-CONNREQ-003 directory).
|
|
644
|
+
const [singleKey, singleResolve] = this.#pendingConnectionRequestResolvers.entries().next().value;
|
|
645
|
+
this.#pendingConnectionRequestResolvers.delete(singleKey);
|
|
646
|
+
singleResolve(frame);
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
// Round 2: route to disclosure resolver if pending
|
|
650
|
+
for (const [id, disclosureResolve] of this.#pendingDisclosureResolvers) {
|
|
651
|
+
this.#pendingDisclosureResolvers.delete(id);
|
|
652
|
+
disclosureResolve(frame);
|
|
653
|
+
break;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Unblock all pending connection request resolvers when the signaling stream closes.
|
|
660
|
+
* CONNREQ-003 AC-005.
|
|
661
|
+
*/
|
|
662
|
+
unblockAllOnStreamClose() {
|
|
663
|
+
for (const [targetPubkey, pendingResolve] of this.#pendingConnectionRequestResolvers) {
|
|
664
|
+
this.#pendingConnectionRequestResolvers.delete(targetPubkey);
|
|
665
|
+
pendingResolve({ type: "connection_request_error", reason: "directory_unreachable", target_pubkey: targetPubkey });
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
// ─── Inbound connection request handling ────────────────────────────────────
|
|
669
|
+
async handleInboundConnectionRequest(frame) {
|
|
670
|
+
return doHandleInboundConnectionRequest(this.#inboundDeps(), frame);
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Handle a disclosure_response_inbound frame (B-side, Round 2).
|
|
674
|
+
* Re-evaluates the updated package and sends final connection_response.
|
|
675
|
+
*/
|
|
676
|
+
async handleDisclosureResponse(frame) {
|
|
677
|
+
return doHandleDisclosureResponse(this.#inboundDeps(), frame);
|
|
678
|
+
}
|
|
679
|
+
/** Build the InboundHandlerDeps snapshot used by both inbound handlers. */
|
|
680
|
+
#inboundDeps() {
|
|
681
|
+
return {
|
|
682
|
+
ctx: this.#ctx,
|
|
683
|
+
connectionPolicy: this.#connectionPolicy,
|
|
684
|
+
pendingInboundRequests: this.#pendingInboundRequests,
|
|
685
|
+
pendingAwaitConnectionRequestResolvers: this.#pendingAwaitConnectionRequestResolvers,
|
|
686
|
+
pendingReviewQueue: this.#pendingReviewQueue,
|
|
687
|
+
profileUncheckedPeers: this.#profileUncheckedPeers,
|
|
688
|
+
onConnectionPendingReview: this.#onConnectionPendingReview,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
//# sourceMappingURL=connection-manager.js.map
|