@kehto/services 0.21.2 → 0.22.1

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/README.md CHANGED
@@ -73,7 +73,10 @@ do not select a handler.
73
73
  `ok: true` means the selected target was ready and the convention was dispatched.
74
74
  The result includes `handled`, `handler`, `windowId`, and `convention`. The
75
75
  target receives the convention and opaque payload through one runtime-attested
76
- `inc.event`; there is no separate `intent.deliver` lifecycle.
76
+ `intent.deliver` envelope with a nested `delivery` object. The host-owned
77
+ `intent.onDelivery` binding buffers arrivals until a handler registers; INC is
78
+ not required. Invocation and result semantics remain on the existing package
79
+ contract pending the separate upstream NAP migration.
77
80
 
78
81
  Paja currently exposes only an exact-contract development simulator, and the
79
82
  playground currently exposes only a verified-manifest catalog builder. Phase
@@ -637,3 +640,13 @@ Generated API module: `docs/api/modules/_kehto_services.html` (run `pnpm docs:ap
637
640
  ## License
638
641
 
639
642
  MIT
643
+
644
+ ## NIP-5D event compatibility
645
+
646
+ Current manifests use a direct artifact `x` hash, plain-text `content`, independent
647
+ `z`/`i` routing declarations, and required `R` / optional `O` domains. Kehto also
648
+ accepts legacy aggregate events through an isolated compatibility adapter.
649
+ Existing `aggregateHash` host/cache/ACL fields carry the verified artifact hash
650
+ for current events; legacy identities keep their original aggregate. Both paths
651
+ verify signatures and bytes before runtime injection and `srcdoc` execution.
652
+ For the schema and removal boundary, see [event migration](https://kehto.github.io/web/docs/migrations/NIP-5D-EVENT-SCHEMA.html).
@@ -14,6 +14,8 @@ import '@kehto/runtime';
14
14
  * random key, `p`-tagged to the server. Responses arrive the same way,
15
15
  * `p`-tagged to the client, and are correlated by the inner JSON-RPC `id`.
16
16
  * - Discovery reads kind-11316 (server) + kind-11317 (tools) announcements.
17
+ * - CEP-22 oversized transfers and CEP-41 open streams ride
18
+ * `notifications/progress` frames addressed by the request progressToken.
17
19
  *
18
20
  * Shipped on a separate entry (`@kehto/services/cvm-nostr-transport`) so the
19
21
  * `nostr-tools` dependency stays out of the core `@kehto/services` bundle.
@@ -84,6 +86,10 @@ interface NostrCvmTransportOptions {
84
86
  name: string;
85
87
  version: string;
86
88
  };
89
+ /** CEP-41: per-stream idle timeout before a keepalive ping is sent. */
90
+ streamIdleTimeoutMs?: number;
91
+ /** CEP-41: probe deadline waiting for a pong after a keepalive ping. */
92
+ streamProbeTimeoutMs?: number;
87
93
  }
88
94
  /**
89
95
  * Create a Nostr-backed ContextVM transport.
@@ -7,15 +7,35 @@ var KIND_GIFT_WRAP_EPHEMERAL = 21059;
7
7
  var KIND_GIFT_WRAP_REGULAR = 1059;
8
8
  var KIND_ANNOUNCE_SERVER = 11316;
9
9
  var KIND_ANNOUNCE_TOOLS = 11317;
10
+ var SUPPORT_ENCRYPTION = "support_encryption";
11
+ var SUPPORT_ENCRYPTION_EPHEMERAL = "support_encryption_ephemeral";
12
+ var SUPPORT_OVERSIZED_TRANSFER = "support_oversized_transfer";
13
+ var SUPPORT_OPEN_STREAM = "support_open_stream";
10
14
  var DEFAULT_TIMEOUT_MS = 3e4;
11
15
  var DEFAULT_DISCOVER_TIMEOUT_MS = 6e3;
12
16
  var MCP_PROTOCOL_VERSION = "2025-11-25";
13
17
  var SEEN_WRAP_LIMIT = 512;
18
+ var DIGEST_PREFIX = "sha256:";
19
+ var MAX_TRANSFER_CHUNKS = 1e4;
20
+ var MAX_TRANSFER_BYTES = 100 * 1024 * 1024;
21
+ var MAX_ACTIVE_TRANSFERS = 8;
22
+ var MAX_TRANSFER_LIFETIME_MS = 12e4;
23
+ var COMPLETION_MODE_RENDER = "render";
24
+ var DEFAULT_STREAM_IDLE_TIMEOUT_MS = 3e4;
25
+ var DEFAULT_STREAM_PROBE_TIMEOUT_MS = 1e4;
26
+ var MAX_STREAM_BUFFERED_CHUNKS = 256;
27
+ var MAX_STREAM_BUFFERED_CODE_UNITS = 8 * 1024 * 1024;
28
+ var MAX_PING_NONCE_BYTES = 64;
14
29
  var correlationCounter = 0;
15
30
  function nextCorrelationId() {
16
31
  correlationCounter += 1;
17
32
  return `cvm-${correlationCounter}-${getPublicKey(generateSecretKey()).slice(0, 8)}`;
18
33
  }
34
+ var streamNonceCounter = 0;
35
+ function nextStreamNonce() {
36
+ streamNonceCounter += 1;
37
+ return `cvm-ping-${streamNonceCounter}-${getPublicKey(generateSecretKey()).slice(0, 8)}`;
38
+ }
19
39
  function randomizedPastTimestamp() {
20
40
  const jitter = Math.floor(Math.random() * 172800);
21
41
  return Math.floor(Date.now() / 1e3) - jitter;
@@ -23,6 +43,19 @@ function randomizedPastTimestamp() {
23
43
  function tagValue(tags, name) {
24
44
  return tags.find((tag) => tag[0] === name)?.[1];
25
45
  }
46
+ function progressTokenKey(token) {
47
+ if (typeof token === "string") return token.length > 0 ? `s:${token}` : null;
48
+ if (typeof token === "number" && Number.isFinite(token)) return `n:${token}`;
49
+ return null;
50
+ }
51
+ function isBoundedInteger(value, min, max) {
52
+ return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
53
+ }
54
+ async function sha256Hex(text) {
55
+ const bytes = new TextEncoder().encode(text);
56
+ const buf = await crypto.subtle.digest("SHA-256", bytes);
57
+ return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
58
+ }
26
59
  function simplePoolAdapter(sp) {
27
60
  return {
28
61
  subscribe(relays, filter, params) {
@@ -47,11 +80,14 @@ function createNostrCvmTransport(options = {}) {
47
80
  const clientSecretKey = options.clientSecretKey ?? generateSecretKey();
48
81
  const clientPubkey = getPublicKey(clientSecretKey);
49
82
  const clientInfo = options.clientInfo ?? { name: "kehto-cvm", version: "1.0.0" };
83
+ const streamIdleTimeoutMs = options.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
84
+ const streamProbeTimeoutMs = options.streamProbeTimeoutMs ?? DEFAULT_STREAM_PROBE_TIMEOUT_MS;
50
85
  const sessions = /* @__PURE__ */ new Map();
51
86
  const pending = /* @__PURE__ */ new Map();
52
87
  const eventHandlers = /* @__PURE__ */ new Set();
53
88
  const relayRefcount = /* @__PURE__ */ new Map();
54
89
  const seenWraps = /* @__PURE__ */ new Set();
90
+ const issuedTokens = /* @__PURE__ */ new Map();
55
91
  let inbound = null;
56
92
  let subscribedRelays = "";
57
93
  function resolveRelays(server) {
@@ -113,23 +149,379 @@ function createNostrCvmTransport(options = {}) {
113
149
  } catch {
114
150
  return;
115
151
  }
152
+ if (mcp.method === "notifications/progress") {
153
+ const params = mcp.params ?? {};
154
+ const cvm = params.cvm;
155
+ if (cvm && typeof cvm.type === "string") {
156
+ if (cvm.type === "oversized-transfer") {
157
+ void handleOversizedFrame(serverPubkey, params, cvm);
158
+ return;
159
+ }
160
+ if (cvm.type === "open-stream") {
161
+ const entry = admitFrame(serverPubkey, params.progressToken);
162
+ if (!entry) return;
163
+ handleOpenStreamFrame(serverPubkey, entry, params, cvm, mcp);
164
+ return;
165
+ }
166
+ }
167
+ }
168
+ routeMessage(serverPubkey, mcp);
169
+ }
170
+ async function publishStreamFrame(entry, stream, frame) {
171
+ const session = sessions.get(entry.serverPubkey);
172
+ if (!session) return;
173
+ stream.localProgress += 1;
174
+ await publishMcp(
175
+ { pubkey: entry.serverPubkey },
176
+ session.relays,
177
+ {
178
+ jsonrpc: "2.0",
179
+ method: "notifications/progress",
180
+ params: {
181
+ progressToken: stream.token,
182
+ progress: stream.localProgress,
183
+ cvm: { type: "open-stream", ...frame }
184
+ }
185
+ }
186
+ );
187
+ }
188
+ function admitFrame(serverPubkey, token) {
189
+ const tokenKey = progressTokenKey(token);
190
+ if (!tokenKey) return null;
191
+ const correlationId = issuedTokens.get(`${serverPubkey}:${tokenKey}`);
192
+ if (!correlationId) return null;
193
+ const entry = pending.get(correlationId);
194
+ if (!entry || entry.serverPubkey !== serverPubkey) return null;
195
+ return entry;
196
+ }
197
+ function releaseTransfer(entry) {
198
+ if (!entry.oversized) return;
199
+ clearTimeout(entry.oversized.expiryTimer);
200
+ entry.oversized = void 0;
201
+ }
202
+ function releaseStream(entry) {
203
+ if (!entry.stream) return;
204
+ clearTimeout(entry.stream.idleTimer);
205
+ if (entry.stream.probeTimer) clearTimeout(entry.stream.probeTimer);
206
+ entry.stream = void 0;
207
+ }
208
+ function endStream(entry) {
209
+ releaseStream(entry);
210
+ entry.streamEnded = true;
211
+ }
212
+ function failStream(entry, reason) {
213
+ const stream = entry.stream;
214
+ if (!stream || entry.streamEnded) return;
215
+ endStream(entry);
216
+ void publishStreamFrame(entry, stream, { frameType: "abort", reason }).catch(() => {
217
+ });
218
+ }
219
+ function resetStreamIdleTimer(entry, stream) {
220
+ clearTimeout(stream.idleTimer);
221
+ stream.idleTimer = setTimeout(() => onStreamIdle(entry), streamIdleTimeoutMs);
222
+ }
223
+ function onStreamIdle(entry) {
224
+ const stream = entry.stream;
225
+ if (!stream || entry.streamEnded) return;
226
+ if (stream.pendingNonce !== null) return;
227
+ const session = sessions.get(entry.serverPubkey);
228
+ if (!session) {
229
+ failStream(entry, "session closed");
230
+ return;
231
+ }
232
+ stream.pendingNonce = nextStreamNonce();
233
+ stream.probeTimer = setTimeout(() => failStream(entry, "probe timeout"), streamProbeTimeoutMs);
234
+ void publishStreamFrame(entry, stream, { frameType: "ping", nonce: stream.pendingNonce }).catch(() => failStream(entry, "ping publication failed"));
235
+ }
236
+ function releaseFrameState(entry) {
237
+ releaseTransfer(entry);
238
+ releaseStream(entry);
239
+ if (entry.tokenKey && issuedTokens.get(entry.tokenKey) === entry.correlationId) {
240
+ issuedTokens.delete(entry.tokenKey);
241
+ }
242
+ entry.tokenKey = void 0;
243
+ }
244
+ function settlePending(correlationId) {
245
+ const entry = pending.get(correlationId);
246
+ if (!entry) return null;
247
+ clearTimeout(entry.timer);
248
+ pending.delete(correlationId);
249
+ releaseFrameState(entry);
250
+ return entry;
251
+ }
252
+ function countActiveTransfers() {
253
+ let active = 0;
254
+ for (const entry of pending.values()) {
255
+ if (entry.oversized) active += 1;
256
+ }
257
+ return active;
258
+ }
259
+ async function handleOversizedFrame(serverPubkey, params, cvm) {
260
+ const entry = admitFrame(serverPubkey, params.progressToken);
261
+ if (!entry) return;
262
+ const progress = Number(params.progress ?? NaN);
263
+ if (!Number.isSafeInteger(progress) || progress < 0) return;
264
+ if (cvm.frameType === "start") {
265
+ const digest = typeof cvm.digest === "string" ? cvm.digest : "";
266
+ const { totalBytes, totalChunks } = cvm;
267
+ if (cvm.completionMode !== COMPLETION_MODE_RENDER) return;
268
+ if (digest === "") return;
269
+ if (!isBoundedInteger(totalChunks, 1, MAX_TRANSFER_CHUNKS)) return;
270
+ if (!isBoundedInteger(totalBytes, 1, MAX_TRANSFER_BYTES)) return;
271
+ if (progress > Number.MAX_SAFE_INTEGER - totalChunks - 1) return;
272
+ if (!entry.oversized && countActiveTransfers() >= MAX_ACTIVE_TRANSFERS) return;
273
+ releaseTransfer(entry);
274
+ entry.oversized = {
275
+ digest,
276
+ totalBytes,
277
+ totalChunks,
278
+ startProgress: progress,
279
+ acceptProgress: null,
280
+ chunks: /* @__PURE__ */ new Map(),
281
+ codeUnitsReceived: 0,
282
+ // Hard expiry: progress frames extend the request deadline but never
283
+ // how long buffers may be held.
284
+ expiryTimer: setTimeout(() => releaseTransfer(entry), MAX_TRANSFER_LIFETIME_MS)
285
+ };
286
+ refreshPendingTimeout(entry);
287
+ return;
288
+ }
289
+ const transfer = entry.oversized;
290
+ if (!transfer) return;
291
+ if (cvm.frameType === "accept") {
292
+ if (progress !== transfer.startProgress + 1) {
293
+ releaseTransfer(entry);
294
+ return;
295
+ }
296
+ transfer.acceptProgress = progress;
297
+ refreshPendingTimeout(entry);
298
+ return;
299
+ }
300
+ if (cvm.frameType === "chunk") {
301
+ if (typeof cvm.data !== "string" || progress <= transfer.startProgress) return;
302
+ if (transfer.chunks.has(progress)) return;
303
+ if (transfer.chunks.size >= transfer.totalChunks) {
304
+ releaseTransfer(entry);
305
+ return;
306
+ }
307
+ transfer.codeUnitsReceived += cvm.data.length;
308
+ if (transfer.codeUnitsReceived > MAX_TRANSFER_BYTES) {
309
+ releaseTransfer(entry);
310
+ return;
311
+ }
312
+ transfer.chunks.set(progress, cvm.data);
313
+ refreshPendingTimeout(entry);
314
+ return;
315
+ }
316
+ if (cvm.frameType === "abort") {
317
+ releaseTransfer(entry);
318
+ return;
319
+ }
320
+ if (cvm.frameType === "end") {
321
+ const message = await assembleTransfer(transfer, progress);
322
+ releaseTransfer(entry);
323
+ if (message) routeMessage(serverPubkey, message);
324
+ return;
325
+ }
326
+ }
327
+ async function assembleTransfer(transfer, endProgress) {
328
+ const { startProgress, acceptProgress, totalChunks } = transfer;
329
+ const hasCompleteRange = (first) => {
330
+ for (let i = 0; i < totalChunks; i++) {
331
+ if (!transfer.chunks.has(first + i)) return false;
332
+ }
333
+ return true;
334
+ };
335
+ const directStart = startProgress + 1;
336
+ const acceptGatedStart = startProgress + 2;
337
+ const firstProgress = hasCompleteRange(directStart) && endProgress === directStart + totalChunks ? directStart : acceptProgress === startProgress + 1 && hasCompleteRange(acceptGatedStart) && endProgress === acceptGatedStart + totalChunks ? acceptGatedStart : null;
338
+ if (firstProgress === null) return null;
339
+ let payload = "";
340
+ for (let i = 0; i < totalChunks; i++) {
341
+ payload += transfer.chunks.get(firstProgress + i);
342
+ }
343
+ if (new TextEncoder().encode(payload).byteLength !== transfer.totalBytes) return null;
344
+ const expected = transfer.digest.startsWith(DIGEST_PREFIX) ? transfer.digest.slice(DIGEST_PREFIX.length) : transfer.digest;
345
+ if (await sha256Hex(payload) !== expected) return null;
346
+ try {
347
+ return JSON.parse(payload);
348
+ } catch {
349
+ return null;
350
+ }
351
+ }
352
+ function handleOpenStreamFrame(serverPubkey, entry, params, cvm, mcp) {
353
+ const progress = Number(params.progress ?? NaN);
354
+ if (!Number.isSafeInteger(progress) || progress < 0) return;
355
+ if (entry.streamEnded) return;
356
+ const stream = entry.stream;
357
+ if (!stream) {
358
+ if (cvm.frameType !== "start") return;
359
+ entry.stream = {
360
+ token: params.progressToken,
361
+ lastProgress: progress,
362
+ localProgress: 0,
363
+ nextChunkIndex: 0,
364
+ outOfOrder: /* @__PURE__ */ new Map(),
365
+ outOfOrderCodeUnits: 0,
366
+ pendingNonce: null,
367
+ idleTimer: setTimeout(() => onStreamIdle(entry), streamIdleTimeoutMs),
368
+ probeTimer: null
369
+ };
370
+ refreshPendingTimeout(entry);
371
+ routeEvent(serverPubkey, mcp);
372
+ return;
373
+ }
374
+ const admitOrderedActivity = () => {
375
+ if (progress <= stream.lastProgress) {
376
+ failStream(entry, "non-monotonic progress");
377
+ return false;
378
+ }
379
+ stream.lastProgress = progress;
380
+ resetStreamIdleTimer(entry, stream);
381
+ refreshPendingTimeout(entry);
382
+ return true;
383
+ };
384
+ switch (cvm.frameType) {
385
+ case "start":
386
+ failStream(entry, "duplicate start");
387
+ return;
388
+ case "accept":
389
+ if (!admitOrderedActivity()) return;
390
+ routeEvent(serverPubkey, mcp);
391
+ return;
392
+ case "chunk": {
393
+ if (typeof cvm.data !== "string" || !isBoundedInteger(cvm.chunkIndex, 0, Number.MAX_SAFE_INTEGER)) {
394
+ failStream(entry, "malformed chunk");
395
+ return;
396
+ }
397
+ const index = cvm.chunkIndex;
398
+ if (index < stream.nextChunkIndex) return;
399
+ if (index > stream.nextChunkIndex) {
400
+ const existing = stream.outOfOrder.get(index);
401
+ if (existing) {
402
+ if (existing.progress !== progress || JSON.stringify(existing.message) !== JSON.stringify(mcp)) {
403
+ failStream(entry, "conflicting duplicate chunk");
404
+ }
405
+ return;
406
+ }
407
+ if (progress <= stream.lastProgress || stream.outOfOrder.size >= MAX_STREAM_BUFFERED_CHUNKS || stream.outOfOrderCodeUnits + cvm.data.length > MAX_STREAM_BUFFERED_CODE_UNITS) {
408
+ failStream(entry, progress <= stream.lastProgress ? "non-monotonic progress" : "gap buffer exhausted");
409
+ return;
410
+ }
411
+ stream.outOfOrder.set(index, { message: mcp, progress, codeUnits: cvm.data.length });
412
+ stream.outOfOrderCodeUnits += cvm.data.length;
413
+ resetStreamIdleTimer(entry, stream);
414
+ refreshPendingTimeout(entry);
415
+ return;
416
+ }
417
+ const ready = [
418
+ { message: mcp, progress, codeUnits: 0 }
419
+ ];
420
+ let nextIndex = index + 1;
421
+ while (stream.outOfOrder.has(nextIndex)) {
422
+ ready.push(stream.outOfOrder.get(nextIndex));
423
+ nextIndex += 1;
424
+ }
425
+ let previousProgress = stream.lastProgress;
426
+ for (const item of ready) {
427
+ if (item.progress <= previousProgress) {
428
+ failStream(entry, "non-monotonic chunk order");
429
+ return;
430
+ }
431
+ previousProgress = item.progress;
432
+ }
433
+ for (let bufferedIndex = index + 1; bufferedIndex < nextIndex; bufferedIndex++) {
434
+ const buffered = stream.outOfOrder.get(bufferedIndex);
435
+ stream.outOfOrder.delete(bufferedIndex);
436
+ stream.outOfOrderCodeUnits -= buffered.codeUnits;
437
+ }
438
+ for (const item of ready) {
439
+ routeEvent(serverPubkey, item.message);
440
+ stream.nextChunkIndex += 1;
441
+ }
442
+ stream.lastProgress = previousProgress;
443
+ resetStreamIdleTimer(entry, stream);
444
+ refreshPendingTimeout(entry);
445
+ return;
446
+ }
447
+ case "ping": {
448
+ if (typeof cvm.nonce !== "string") return;
449
+ if (new TextEncoder().encode(cvm.nonce).byteLength > MAX_PING_NONCE_BYTES) return;
450
+ if (!admitOrderedActivity()) return;
451
+ void publishStreamFrame(entry, stream, { frameType: "pong", nonce: cvm.nonce }).catch(() => {
452
+ failStream(entry, "pong publication failed");
453
+ });
454
+ return;
455
+ }
456
+ case "pong": {
457
+ if (stream.pendingNonce === null || cvm.nonce !== stream.pendingNonce) return;
458
+ if (!admitOrderedActivity()) return;
459
+ stream.pendingNonce = null;
460
+ if (stream.probeTimer) clearTimeout(stream.probeTimer);
461
+ stream.probeTimer = null;
462
+ return;
463
+ }
464
+ case "close": {
465
+ if (cvm.lastChunkIndex !== void 0) {
466
+ const bound = cvm.lastChunkIndex;
467
+ const complete = isBoundedInteger(bound, 0, Number.MAX_SAFE_INTEGER) && stream.nextChunkIndex === bound + 1 && stream.outOfOrder.size === 0;
468
+ if (!complete) {
469
+ failStream(entry, "close with unresolved chunk gaps");
470
+ return;
471
+ }
472
+ }
473
+ if (!admitOrderedActivity()) return;
474
+ routeEvent(serverPubkey, mcp);
475
+ endStream(entry);
476
+ return;
477
+ }
478
+ case "abort":
479
+ if (!admitOrderedActivity()) return;
480
+ routeEvent(serverPubkey, mcp);
481
+ endStream(entry);
482
+ return;
483
+ default:
484
+ return;
485
+ }
486
+ }
487
+ function refreshPendingTimeout(entry) {
488
+ clearTimeout(entry.timer);
489
+ entry.timer = setTimeout(() => {
490
+ const settled = settlePending(entry.correlationId);
491
+ settled?.reject(new Error("relay timeout"));
492
+ }, entry.timeoutMs);
493
+ }
494
+ function routeMessage(serverPubkey, mcp) {
116
495
  const id = mcp.id;
117
496
  if (id != null && pending.has(String(id))) {
118
497
  const entry = pending.get(String(id));
119
498
  if (entry.serverPubkey !== serverPubkey) return;
120
- pending.delete(String(id));
121
- clearTimeout(entry.timer);
122
- entry.resolve({ ...mcp, id: entry.originalId });
499
+ const settled = settlePending(String(id));
500
+ settled?.resolve({ ...mcp, id: entry.originalId });
123
501
  return;
124
502
  }
503
+ routeEvent(serverPubkey, mcp);
504
+ }
505
+ function routeEvent(serverPubkey, mcp) {
125
506
  if (mcp.method !== void 0 && sessions.has(serverPubkey)) {
126
507
  const server = { pubkey: serverPubkey };
127
508
  for (const handler of eventHandlers) handler(server, mcp);
128
509
  }
129
510
  }
130
511
  async function publishMcp(server, relays, message) {
512
+ const capabilityTags = [
513
+ ...encrypt2 ? [[SUPPORT_ENCRYPTION]] : [],
514
+ ...encrypt2 && wrapKind === KIND_GIFT_WRAP_EPHEMERAL ? [[SUPPORT_ENCRYPTION_EPHEMERAL]] : [],
515
+ [SUPPORT_OVERSIZED_TRANSFER],
516
+ [SUPPORT_OPEN_STREAM]
517
+ ];
131
518
  const inner = finalizeEvent(
132
- { kind: KIND_CVM, created_at: Math.floor(Date.now() / 1e3), tags: [["p", server.pubkey]], content: JSON.stringify(message) },
519
+ {
520
+ kind: KIND_CVM,
521
+ created_at: Math.floor(Date.now() / 1e3),
522
+ tags: [["p", server.pubkey], ...capabilityTags],
523
+ content: JSON.stringify(message)
524
+ },
133
525
  clientSecretKey
134
526
  );
135
527
  if (!encrypt2) {
@@ -153,17 +545,52 @@ function createNostrCvmTransport(options = {}) {
153
545
  function sendCorrelated(server, relays, message, timeout) {
154
546
  const correlationId = nextCorrelationId();
155
547
  const originalId = message.id;
156
- const outgoing = { ...message, id: correlationId };
548
+ let outgoing = { ...message, id: correlationId };
549
+ let issuedToken;
550
+ const params = message.params && typeof message.params === "object" ? message.params : void 0;
551
+ const meta = params?._meta && typeof params._meta === "object" ? params._meta : void 0;
552
+ const explicit = meta?.progressToken;
553
+ if (message.method === "tools/call") {
554
+ issuedToken = typeof explicit === "string" || typeof explicit === "number" ? explicit : correlationId;
555
+ outgoing = {
556
+ ...outgoing,
557
+ params: {
558
+ ...params,
559
+ _meta: {
560
+ ...meta,
561
+ progressToken: issuedToken
562
+ }
563
+ }
564
+ };
565
+ } else if (typeof explicit === "string" || typeof explicit === "number") {
566
+ issuedToken = explicit;
567
+ }
568
+ const tokenKey = progressTokenKey(issuedToken);
569
+ const issuedKey = tokenKey ? `${server.pubkey}:${tokenKey}` : null;
570
+ if (issuedKey && issuedTokens.has(issuedKey)) {
571
+ return Promise.reject(new Error("duplicate progress token"));
572
+ }
157
573
  return new Promise((resolve, reject) => {
158
- const timer = setTimeout(() => {
159
- pending.delete(correlationId);
160
- reject(new Error("relay timeout"));
161
- }, timeout);
162
- pending.set(correlationId, { resolve, reject, timer, originalId, serverPubkey: server.pubkey });
574
+ const entry = {
575
+ resolve,
576
+ reject,
577
+ timer: setTimeout(() => {
578
+ const settled = settlePending(correlationId);
579
+ settled?.reject(new Error("relay timeout"));
580
+ }, timeout),
581
+ timeoutMs: timeout,
582
+ originalId,
583
+ serverPubkey: server.pubkey,
584
+ correlationId
585
+ };
586
+ if (issuedKey) {
587
+ entry.tokenKey = issuedKey;
588
+ issuedTokens.set(issuedKey, correlationId);
589
+ }
590
+ pending.set(correlationId, entry);
163
591
  void publishMcp(server, relays, outgoing).catch((err) => {
164
- pending.delete(correlationId);
165
- clearTimeout(timer);
166
- reject(err instanceof Error ? err : new Error("publish failed"));
592
+ const settled = settlePending(correlationId);
593
+ settled?.reject(err instanceof Error ? err : new Error("publish failed"));
167
594
  });
168
595
  });
169
596
  }
@@ -261,6 +688,10 @@ function createNostrCvmTransport(options = {}) {
261
688
  if (!session) return;
262
689
  sessions.delete(server.pubkey);
263
690
  releaseRelays(session.relays);
691
+ for (const [correlationId, entry] of pending) {
692
+ if (entry.serverPubkey !== server.pubkey) continue;
693
+ settlePending(correlationId)?.reject(new Error("server closed"));
694
+ }
264
695
  },
265
696
  onEvent(handler) {
266
697
  eventHandlers.add(handler);
@@ -275,9 +706,12 @@ function createNostrCvmTransport(options = {}) {
275
706
  inbound = null;
276
707
  for (const entry of pending.values()) {
277
708
  clearTimeout(entry.timer);
709
+ releaseTransfer(entry);
710
+ releaseStream(entry);
278
711
  entry.reject(new Error("transport disposed"));
279
712
  }
280
713
  pending.clear();
714
+ issuedTokens.clear();
281
715
  sessions.clear();
282
716
  relayRefcount.clear();
283
717
  eventHandlers.clear();