@did-btcr2/method 0.28.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/README.md +13 -5
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/browser.js +34125 -44647
  4. package/dist/browser.mjs +26409 -36931
  5. package/dist/cjs/index.js +2869 -679
  6. package/dist/esm/core/aggregation/beacon-strategy.js +62 -0
  7. package/dist/esm/core/aggregation/beacon-strategy.js.map +1 -0
  8. package/dist/esm/core/aggregation/cohort.js +31 -8
  9. package/dist/esm/core/aggregation/cohort.js.map +1 -1
  10. package/dist/esm/core/aggregation/logger.js +15 -0
  11. package/dist/esm/core/aggregation/logger.js.map +1 -0
  12. package/dist/esm/core/aggregation/messages/base.js +12 -1
  13. package/dist/esm/core/aggregation/messages/base.js.map +1 -1
  14. package/dist/esm/core/aggregation/messages/bodies.js +90 -0
  15. package/dist/esm/core/aggregation/messages/bodies.js.map +1 -0
  16. package/dist/esm/core/aggregation/messages/factories.js.map +1 -1
  17. package/dist/esm/core/aggregation/messages/index.js +1 -0
  18. package/dist/esm/core/aggregation/messages/index.js.map +1 -1
  19. package/dist/esm/core/aggregation/participant.js +39 -46
  20. package/dist/esm/core/aggregation/participant.js.map +1 -1
  21. package/dist/esm/core/aggregation/runner/participant-runner.js +33 -7
  22. package/dist/esm/core/aggregation/runner/participant-runner.js.map +1 -1
  23. package/dist/esm/core/aggregation/runner/service-runner.js +198 -19
  24. package/dist/esm/core/aggregation/runner/service-runner.js.map +1 -1
  25. package/dist/esm/core/aggregation/service.js +143 -15
  26. package/dist/esm/core/aggregation/service.js.map +1 -1
  27. package/dist/esm/core/aggregation/signing-session.js +44 -5
  28. package/dist/esm/core/aggregation/signing-session.js.map +1 -1
  29. package/dist/esm/core/aggregation/transport/didcomm.js +9 -0
  30. package/dist/esm/core/aggregation/transport/didcomm.js.map +1 -1
  31. package/dist/esm/core/aggregation/transport/factory.js +15 -6
  32. package/dist/esm/core/aggregation/transport/factory.js.map +1 -1
  33. package/dist/esm/core/aggregation/transport/http/client.js +350 -0
  34. package/dist/esm/core/aggregation/transport/http/client.js.map +1 -0
  35. package/dist/esm/core/aggregation/transport/http/envelope.js +126 -0
  36. package/dist/esm/core/aggregation/transport/http/envelope.js.map +1 -0
  37. package/dist/esm/core/aggregation/transport/http/errors.js +11 -0
  38. package/dist/esm/core/aggregation/transport/http/errors.js.map +1 -0
  39. package/dist/esm/core/aggregation/transport/http/inbox-buffer.js +45 -0
  40. package/dist/esm/core/aggregation/transport/http/inbox-buffer.js.map +1 -0
  41. package/dist/esm/core/aggregation/transport/http/index.js +12 -0
  42. package/dist/esm/core/aggregation/transport/http/index.js.map +1 -0
  43. package/dist/esm/core/aggregation/transport/http/nonce-cache.js +38 -0
  44. package/dist/esm/core/aggregation/transport/http/nonce-cache.js.map +1 -0
  45. package/dist/esm/core/aggregation/transport/http/protocol.js +28 -0
  46. package/dist/esm/core/aggregation/transport/http/protocol.js.map +1 -0
  47. package/dist/esm/core/aggregation/transport/http/rate-limiter.js +45 -0
  48. package/dist/esm/core/aggregation/transport/http/rate-limiter.js.map +1 -0
  49. package/dist/esm/core/aggregation/transport/http/request-auth.js +100 -0
  50. package/dist/esm/core/aggregation/transport/http/request-auth.js.map +1 -0
  51. package/dist/esm/core/aggregation/transport/http/server.js +481 -0
  52. package/dist/esm/core/aggregation/transport/http/server.js.map +1 -0
  53. package/dist/esm/core/aggregation/transport/http/sse-stream.js +110 -0
  54. package/dist/esm/core/aggregation/transport/http/sse-stream.js.map +1 -0
  55. package/dist/esm/core/aggregation/transport/http/sse-writer.js +25 -0
  56. package/dist/esm/core/aggregation/transport/http/sse-writer.js.map +1 -0
  57. package/dist/esm/core/aggregation/transport/index.js +1 -0
  58. package/dist/esm/core/aggregation/transport/index.js.map +1 -1
  59. package/dist/esm/core/aggregation/transport/nostr.js +245 -16
  60. package/dist/esm/core/aggregation/transport/nostr.js.map +1 -1
  61. package/dist/esm/core/beacon/beacon.js +295 -63
  62. package/dist/esm/core/beacon/beacon.js.map +1 -1
  63. package/dist/esm/core/beacon/cas-beacon.js +3 -3
  64. package/dist/esm/core/beacon/cas-beacon.js.map +1 -1
  65. package/dist/esm/core/beacon/singleton-beacon.js +3 -3
  66. package/dist/esm/core/beacon/singleton-beacon.js.map +1 -1
  67. package/dist/esm/core/beacon/smt-beacon.js +3 -3
  68. package/dist/esm/core/beacon/smt-beacon.js.map +1 -1
  69. package/dist/esm/core/beacon/utils.js +14 -9
  70. package/dist/esm/core/beacon/utils.js.map +1 -1
  71. package/dist/esm/core/updater.js +63 -55
  72. package/dist/esm/core/updater.js.map +1 -1
  73. package/dist/esm/did-btcr2.js +0 -4
  74. package/dist/esm/did-btcr2.js.map +1 -1
  75. package/dist/esm/index.js +2 -0
  76. package/dist/esm/index.js.map +1 -1
  77. package/dist/esm/utils/did-document.js +2 -2
  78. package/dist/esm/utils/did-document.js.map +1 -1
  79. package/dist/types/core/aggregation/beacon-strategy.d.ts +52 -0
  80. package/dist/types/core/aggregation/beacon-strategy.d.ts.map +1 -0
  81. package/dist/types/core/aggregation/cohort.d.ts +20 -3
  82. package/dist/types/core/aggregation/cohort.d.ts.map +1 -1
  83. package/dist/types/core/aggregation/logger.d.ts +22 -0
  84. package/dist/types/core/aggregation/logger.d.ts.map +1 -0
  85. package/dist/types/core/aggregation/messages/base.d.ts +13 -1
  86. package/dist/types/core/aggregation/messages/base.d.ts.map +1 -1
  87. package/dist/types/core/aggregation/messages/bodies.d.ts +130 -0
  88. package/dist/types/core/aggregation/messages/bodies.d.ts.map +1 -0
  89. package/dist/types/core/aggregation/messages/factories.d.ts +1 -0
  90. package/dist/types/core/aggregation/messages/factories.d.ts.map +1 -1
  91. package/dist/types/core/aggregation/messages/index.d.ts +1 -0
  92. package/dist/types/core/aggregation/messages/index.d.ts.map +1 -1
  93. package/dist/types/core/aggregation/participant.d.ts +2 -0
  94. package/dist/types/core/aggregation/participant.d.ts.map +1 -1
  95. package/dist/types/core/aggregation/runner/events.d.ts +32 -6
  96. package/dist/types/core/aggregation/runner/events.d.ts.map +1 -1
  97. package/dist/types/core/aggregation/runner/participant-runner.d.ts +7 -5
  98. package/dist/types/core/aggregation/runner/participant-runner.d.ts.map +1 -1
  99. package/dist/types/core/aggregation/runner/service-runner.d.ts +33 -3
  100. package/dist/types/core/aggregation/runner/service-runner.d.ts.map +1 -1
  101. package/dist/types/core/aggregation/service.d.ts +33 -2
  102. package/dist/types/core/aggregation/service.d.ts.map +1 -1
  103. package/dist/types/core/aggregation/signing-session.d.ts +5 -1
  104. package/dist/types/core/aggregation/signing-session.d.ts.map +1 -1
  105. package/dist/types/core/aggregation/transport/didcomm.d.ts +3 -0
  106. package/dist/types/core/aggregation/transport/didcomm.d.ts.map +1 -1
  107. package/dist/types/core/aggregation/transport/factory.d.ts +22 -7
  108. package/dist/types/core/aggregation/transport/factory.d.ts.map +1 -1
  109. package/dist/types/core/aggregation/transport/http/client.d.ts +48 -0
  110. package/dist/types/core/aggregation/transport/http/client.d.ts.map +1 -0
  111. package/dist/types/core/aggregation/transport/http/envelope.d.ts +64 -0
  112. package/dist/types/core/aggregation/transport/http/envelope.d.ts.map +1 -0
  113. package/dist/types/core/aggregation/transport/http/errors.d.ts +9 -0
  114. package/dist/types/core/aggregation/transport/http/errors.d.ts.map +1 -0
  115. package/dist/types/core/aggregation/transport/http/inbox-buffer.d.ts +32 -0
  116. package/dist/types/core/aggregation/transport/http/inbox-buffer.d.ts.map +1 -0
  117. package/dist/types/core/aggregation/transport/http/index.d.ts +12 -0
  118. package/dist/types/core/aggregation/transport/http/index.d.ts.map +1 -0
  119. package/dist/types/core/aggregation/transport/http/nonce-cache.d.ts +26 -0
  120. package/dist/types/core/aggregation/transport/http/nonce-cache.d.ts.map +1 -0
  121. package/dist/types/core/aggregation/transport/http/protocol.d.ts +53 -0
  122. package/dist/types/core/aggregation/transport/http/protocol.d.ts.map +1 -0
  123. package/dist/types/core/aggregation/transport/http/rate-limiter.d.ts +41 -0
  124. package/dist/types/core/aggregation/transport/http/rate-limiter.d.ts.map +1 -0
  125. package/dist/types/core/aggregation/transport/http/request-auth.d.ts +50 -0
  126. package/dist/types/core/aggregation/transport/http/request-auth.d.ts.map +1 -0
  127. package/dist/types/core/aggregation/transport/http/server.d.ts +110 -0
  128. package/dist/types/core/aggregation/transport/http/server.d.ts.map +1 -0
  129. package/dist/types/core/aggregation/transport/http/sse-stream.d.ts +34 -0
  130. package/dist/types/core/aggregation/transport/http/sse-stream.d.ts.map +1 -0
  131. package/dist/types/core/aggregation/transport/http/sse-writer.d.ts +12 -0
  132. package/dist/types/core/aggregation/transport/http/sse-writer.d.ts.map +1 -0
  133. package/dist/types/core/aggregation/transport/index.d.ts +1 -0
  134. package/dist/types/core/aggregation/transport/index.d.ts.map +1 -1
  135. package/dist/types/core/aggregation/transport/nostr.d.ts +99 -1
  136. package/dist/types/core/aggregation/transport/nostr.d.ts.map +1 -1
  137. package/dist/types/core/aggregation/transport/transport.d.ts +26 -1
  138. package/dist/types/core/aggregation/transport/transport.d.ts.map +1 -1
  139. package/dist/types/core/beacon/beacon.d.ts +149 -22
  140. package/dist/types/core/beacon/beacon.d.ts.map +1 -1
  141. package/dist/types/core/beacon/cas-beacon.d.ts +3 -3
  142. package/dist/types/core/beacon/cas-beacon.d.ts.map +1 -1
  143. package/dist/types/core/beacon/singleton-beacon.d.ts +3 -3
  144. package/dist/types/core/beacon/singleton-beacon.d.ts.map +1 -1
  145. package/dist/types/core/beacon/smt-beacon.d.ts +3 -3
  146. package/dist/types/core/beacon/smt-beacon.d.ts.map +1 -1
  147. package/dist/types/core/beacon/utils.d.ts +2 -2
  148. package/dist/types/core/beacon/utils.d.ts.map +1 -1
  149. package/dist/types/core/updater.d.ts +27 -12
  150. package/dist/types/core/updater.d.ts.map +1 -1
  151. package/dist/types/did-btcr2.d.ts.map +1 -1
  152. package/dist/types/index.d.ts +2 -0
  153. package/dist/types/index.d.ts.map +1 -1
  154. package/package.json +5 -7
  155. package/src/core/aggregation/beacon-strategy.ts +123 -0
  156. package/src/core/aggregation/cohort.ts +34 -8
  157. package/src/core/aggregation/logger.ts +33 -0
  158. package/src/core/aggregation/messages/base.ts +20 -5
  159. package/src/core/aggregation/messages/bodies.ts +223 -0
  160. package/src/core/aggregation/messages/factories.ts +1 -0
  161. package/src/core/aggregation/messages/index.ts +1 -0
  162. package/src/core/aggregation/participant.ts +40 -46
  163. package/src/core/aggregation/runner/events.ts +27 -3
  164. package/src/core/aggregation/runner/participant-runner.ts +41 -7
  165. package/src/core/aggregation/runner/service-runner.ts +227 -19
  166. package/src/core/aggregation/service.ts +189 -20
  167. package/src/core/aggregation/signing-session.ts +65 -7
  168. package/src/core/aggregation/transport/didcomm.ts +17 -0
  169. package/src/core/aggregation/transport/factory.ts +48 -12
  170. package/src/core/aggregation/transport/http/client.ts +409 -0
  171. package/src/core/aggregation/transport/http/envelope.ts +204 -0
  172. package/src/core/aggregation/transport/http/errors.ts +11 -0
  173. package/src/core/aggregation/transport/http/inbox-buffer.ts +53 -0
  174. package/src/core/aggregation/transport/http/index.ts +11 -0
  175. package/src/core/aggregation/transport/http/nonce-cache.ts +43 -0
  176. package/src/core/aggregation/transport/http/protocol.ts +57 -0
  177. package/src/core/aggregation/transport/http/rate-limiter.ts +75 -0
  178. package/src/core/aggregation/transport/http/request-auth.ts +164 -0
  179. package/src/core/aggregation/transport/http/server.ts +615 -0
  180. package/src/core/aggregation/transport/http/sse-stream.ts +121 -0
  181. package/src/core/aggregation/transport/http/sse-writer.ts +23 -0
  182. package/src/core/aggregation/transport/index.ts +1 -0
  183. package/src/core/aggregation/transport/nostr.ts +266 -23
  184. package/src/core/aggregation/transport/transport.ts +34 -1
  185. package/src/core/beacon/beacon.ts +411 -79
  186. package/src/core/beacon/cas-beacon.ts +4 -4
  187. package/src/core/beacon/singleton-beacon.ts +4 -4
  188. package/src/core/beacon/smt-beacon.ts +4 -4
  189. package/src/core/beacon/utils.ts +16 -11
  190. package/src/core/updater.ts +113 -67
  191. package/src/did-btcr2.ts +0 -5
  192. package/src/index.ts +2 -0
  193. package/src/utils/did-document.ts +2 -2
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Parsed Server-Sent Events record.
3
+ *
4
+ * Events without a `data` field are never yielded (per the SSE spec — only a
5
+ * blank line that follows at least one `data:` line dispatches an event).
6
+ */
7
+ export interface SseEvent {
8
+ /** Optional event name (from `event:` field). Defaults to "message" if omitted. */
9
+ event?: string;
10
+ /** Accumulated data payload (multiple `data:` lines joined with `\n`). */
11
+ data: string;
12
+ /** Last-Event-ID value for reconnect resumption. */
13
+ id?: string;
14
+ /** Retry delay hint in milliseconds. */
15
+ retry?: number;
16
+ }
17
+
18
+ /**
19
+ * Parse an SSE stream into an async iterable of {@link SseEvent} records.
20
+ *
21
+ * The parser follows the HTML Living Standard ({@link https://html.spec.whatwg.org/multipage/server-sent-events.html})
22
+ * closely enough for our needs: LF and CRLF line terminators, multi-line
23
+ * `data` fields, `event` / `id` / `retry` fields, and `:`-prefixed comments.
24
+ * CR-only line terminators are not supported (every mainstream SSE
25
+ * implementation emits LF or CRLF).
26
+ *
27
+ * Pure, runtime-agnostic — works anywhere `ReadableStream<Uint8Array>` and
28
+ * `TextDecoder` exist (browsers and Node 22+).
29
+ *
30
+ * The caller owns stream lifecycle: cancellation should be effected via an
31
+ * `AbortController` on the producing `fetch`, which propagates as a read
32
+ * error and cleanly unwinds this generator's `finally`.
33
+ */
34
+ export async function* parseSseStream(
35
+ readable: ReadableStream<Uint8Array>,
36
+ ): AsyncGenerator<SseEvent, void, void> {
37
+ const decoder = new TextDecoder('utf-8');
38
+ const reader = readable.getReader();
39
+
40
+ let buffer = '';
41
+ let pending: { event?: string; data?: string; id?: string; retry?: number } = {};
42
+
43
+ const dispatchPending = (): SseEvent | null => {
44
+ if(pending.data === undefined) {
45
+ pending = {};
46
+ return null;
47
+ }
48
+ const ev: SseEvent = { data: pending.data };
49
+ if(pending.event !== undefined) ev.event = pending.event;
50
+ if(pending.id !== undefined) ev.id = pending.id;
51
+ if(pending.retry !== undefined) ev.retry = pending.retry;
52
+ pending = {};
53
+ return ev;
54
+ };
55
+
56
+ const processLine = (line: string): void => {
57
+ if(line.startsWith(':')) return; // comment
58
+
59
+ const colon = line.indexOf(':');
60
+ const field = colon === -1 ? line : line.slice(0, colon);
61
+ let value = colon === -1 ? '' : line.slice(colon + 1);
62
+ if(value.startsWith(' ')) value = value.slice(1);
63
+
64
+ switch(field) {
65
+ case 'data':
66
+ pending.data = pending.data === undefined ? value : `${pending.data}\n${value}`;
67
+ break;
68
+ case 'event':
69
+ pending.event = value;
70
+ break;
71
+ case 'id':
72
+ // Per spec: ignore ids containing NUL.
73
+ if(!value.includes('\0')) pending.id = value;
74
+ break;
75
+ case 'retry': {
76
+ const n = Number(value);
77
+ if(Number.isInteger(n) && n >= 0) pending.retry = n;
78
+ break;
79
+ }
80
+ // Other fields (including unknown names) are ignored per the spec.
81
+ }
82
+ };
83
+
84
+ try {
85
+ for(;;) {
86
+ const { value, done } = await reader.read();
87
+ if(done) {
88
+ // Flush any bytes the decoder is still holding.
89
+ buffer += decoder.decode();
90
+ if(buffer.length > 0) {
91
+ const line = buffer.endsWith('\r') ? buffer.slice(0, -1) : buffer;
92
+ if(line.length > 0) processLine(line);
93
+ buffer = '';
94
+ }
95
+ const tail = dispatchPending();
96
+ if(tail) yield tail;
97
+ return;
98
+ }
99
+
100
+ buffer += decoder.decode(value, { stream: true });
101
+
102
+ // Drain as many complete lines as are available.
103
+ let lineEnd = buffer.indexOf('\n');
104
+ while(lineEnd !== -1) {
105
+ let line = buffer.slice(0, lineEnd);
106
+ if(line.endsWith('\r')) line = line.slice(0, -1);
107
+ buffer = buffer.slice(lineEnd + 1);
108
+
109
+ if(line.length === 0) {
110
+ const ev = dispatchPending();
111
+ if(ev) yield ev;
112
+ } else {
113
+ processLine(line);
114
+ }
115
+ lineEnd = buffer.indexOf('\n');
116
+ }
117
+ }
118
+ } finally {
119
+ try { reader.releaseLock(); } catch { /* already released */ }
120
+ }
121
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Format an SSE event frame. Pairs with {@link parseSseStream}.
3
+ *
4
+ * Multi-line `data` is split across multiple `data:` lines per the SSE spec —
5
+ * each embedded `\n` becomes its own line, and the parser rejoins them.
6
+ *
7
+ * The returned string includes a trailing blank line (the dispatch marker).
8
+ */
9
+ export function formatSseEvent(event: string, data: string, id?: string): string {
10
+ const lines: string[] = [];
11
+ if(id !== undefined) lines.push(`id: ${id}`);
12
+ lines.push(`event: ${event}`);
13
+ for(const part of data.split('\n')) lines.push(`data: ${part}`);
14
+ lines.push('');
15
+ lines.push('');
16
+ return lines.join('\n');
17
+ }
18
+
19
+ /** SSE comment frame (server keepalive). Lines starting with `:` are ignored by compliant parsers. */
20
+ export function formatSseComment(comment: string): string {
21
+ const safe = comment.replace(/\n/g, ' ');
22
+ return `: ${safe}\n\n`;
23
+ }
@@ -3,3 +3,4 @@ export * from './error.js';
3
3
  export * from './factory.js';
4
4
  export * from './nostr.js';
5
5
  export * from './didcomm.js';
6
+ export * from './http/index.js';
@@ -2,9 +2,12 @@ import type { Did } from '@did-btcr2/common';
2
2
  import type { SchnorrKeyPair } from '@did-btcr2/keypair';
3
3
  import { CompressedSecp256k1PublicKey } from '@did-btcr2/keypair';
4
4
  import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
5
+ import type { SubCloser } from 'nostr-tools/abstract-pool';
5
6
  import type { Event, EventTemplate} from 'nostr-tools';
6
7
  import { finalizeEvent, nip44 } from 'nostr-tools';
7
8
  import { SimplePool } from 'nostr-tools/pool';
9
+ import type { Logger } from '../logger.js';
10
+ import { CONSOLE_LOGGER } from '../logger.js';
8
11
  import type { BaseMessage } from '../messages/base.js';
9
12
  import { COHORT_ADVERT } from '../messages/constants.js';
10
13
  import { isAggregationMessageType, isKeygenMessageType, isSignMessageType, isUpdateMessageType } from '../messages/guards.js';
@@ -24,12 +27,32 @@ export const DEFAULT_NOSTR_RELAYS = [
24
27
 
25
28
  export interface NostrTransportConfig {
26
29
  relays?: string[];
30
+ /**
31
+ * Optional logger for transport-level diagnostics (publish/subscribe events,
32
+ * relay rejections, parse failures). Defaults to {@link CONSOLE_LOGGER}.
33
+ */
34
+ logger?: Logger;
35
+ /**
36
+ * How far back (in milliseconds) to set the `since` filter on the broadcast
37
+ * (COHORT_ADVERT) subscription. Some public relays do NOT replay historical
38
+ * events to late subscribers when the filter has no `since`, so the advert
39
+ * gets lost if the subscription lands after the publish. A short lookback
40
+ * window nudges those relays into delivering recent adverts. Set to 0 to
41
+ * disable the filter entirely (unbounded history). Defaults to
42
+ * {@link DEFAULT_BROADCAST_LOOKBACK_MS} (5 minutes).
43
+ */
44
+ broadcastLookbackMs?: number;
27
45
  }
28
46
 
47
+ /** Default `since` lookback for broadcast (COHORT_ADVERT) subscriptions: 5 minutes. */
48
+ export const DEFAULT_BROADCAST_LOOKBACK_MS = 5 * 60 * 1000;
49
+
29
50
  /** Internal registration for a single actor sharing this transport. */
30
51
  interface ActorEntry {
31
52
  keys: SchnorrKeyPair;
32
53
  handlers: Map<string, MessageHandler>;
54
+ /** Relay-pool subscriptions opened for this actor. Closed on unregisterActor(). */
55
+ subscriptions: SubCloser[];
33
56
  }
34
57
 
35
58
  /**
@@ -56,9 +79,13 @@ export class NostrTransport implements Transport {
56
79
  #actors: Map<string, ActorEntry> = new Map();
57
80
  #peerRegistry: Map<string, Uint8Array> = new Map();
58
81
  #started = false;
82
+ #logger: Logger;
83
+ #broadcastLookbackMs: number;
59
84
 
60
85
  constructor(config?: NostrTransportConfig) {
61
86
  this.#relays = config?.relays ?? DEFAULT_NOSTR_RELAYS;
87
+ this.#logger = config?.logger ?? CONSOLE_LOGGER;
88
+ this.#broadcastLookbackMs = config?.broadcastLookbackMs ?? DEFAULT_BROADCAST_LOOKBACK_MS;
62
89
  }
63
90
 
64
91
  /**
@@ -70,11 +97,12 @@ export class NostrTransport implements Transport {
70
97
  * @example
71
98
  * const transport = new NostrTransport();
72
99
  * const keys = SchnorrKeyPair.generate();
73
- * transport.registerActor('did:btcr2:...', keys);
100
+ * const did = DidBtcr2.create(keys.publicKey.compressed, { idType: 'KEY', network: 'mutinynet' });
101
+ * transport.registerActor(did, keys);
74
102
  * transport.start();
75
103
  */
76
- public registerActor(did: string, keys: SchnorrKeyPair): void {
77
- const entry: ActorEntry = { keys, handlers: new Map() };
104
+ registerActor(did: string, keys: SchnorrKeyPair): void {
105
+ const entry: ActorEntry = { keys, handlers: new Map(), subscriptions: [] };
78
106
  this.#actors.set(did, entry);
79
107
 
80
108
  // If already started, create a directed subscription for this actor
@@ -83,11 +111,57 @@ export class NostrTransport implements Transport {
83
111
  }
84
112
  }
85
113
 
86
- public getActorPk(did: string): Uint8Array | undefined {
114
+ /**
115
+ * Detach an actor: drop its handlers, close its relay subscriptions, and remove its keys + peer
116
+ * mapping. Idempotent.
117
+ * @param {string} did - The DID of the actor to unregister.
118
+ * @returns {void}
119
+ * @throws {TransportAdapterError} If the transport is not started or if the pool is unavailable.
120
+ * @example
121
+ * transport.unregisterActor(did);
122
+ */
123
+ unregisterActor(did: string): void {
124
+ const entry = this.#actors.get(did);
125
+ if(!entry) return;
126
+ for(const sub of entry.subscriptions) {
127
+ try { sub.close(); } catch(err) { this.#logger.debug(`Error closing subscription for ${did}:`, err); }
128
+ }
129
+ entry.subscriptions.length = 0;
130
+ entry.handlers.clear();
131
+ this.#actors.delete(did);
132
+ this.#peerRegistry.delete(did);
133
+ }
134
+
135
+ /**
136
+ * Remove a single (actor, messageType) handler. Idempotent.
137
+ * @param {string} actorDid - The DID of the actor.
138
+ * @param {string} messageType - The type of message to unregister the handler for.
139
+ * @returns {void}
140
+ * @example
141
+ * transport.unregisterMessageHandler(actorDid, messageType);
142
+ */
143
+ unregisterMessageHandler(actorDid: string, messageType: string): void {
144
+ const actor = this.#actors.get(actorDid);
145
+ if(!actor) return;
146
+ actor.handlers.delete(messageType);
147
+ }
148
+
149
+ /**
150
+ * Gets the public key for a registered actor by their DID.
151
+ * @param {string} did - The DID of the registered actor to get the public key for.
152
+ * @returns {Uint8Array | undefined} The compressed public key bytes for the actor's DID, or
153
+ * undefined if the DID is not registered.
154
+ */
155
+ getActorPk(did: string): Uint8Array | undefined {
87
156
  return this.#actors.get(did)?.keys.publicKey.compressed;
88
157
  }
89
158
 
90
- public registerPeer(did: string, communicationPk: Uint8Array): void {
159
+ /**
160
+ * Registers a peer's communication public key for encrypted messages.
161
+ * @param {string} did - The DID of the peer to register.
162
+ * @param {Uint8Array} communicationPk - The compressed secp256k1 public key bytes for the peer.
163
+ */
164
+ registerPeer(did: string, communicationPk: Uint8Array): void {
91
165
  try {
92
166
  new CompressedSecp256k1PublicKey(communicationPk);
93
167
  } catch {
@@ -99,11 +173,27 @@ export class NostrTransport implements Transport {
99
173
  this.#peerRegistry.set(did, communicationPk);
100
174
  }
101
175
 
102
- public getPeerPk(did: string): Uint8Array | undefined {
176
+ /**
177
+ * Gets the registered communication public key for a peer by their DID.
178
+ * @param {string} did - The DID of the peer to get the communication public key for.
179
+ * @returns {Uint8Array | undefined} The compressed secp256k1 public key bytes for the peer, or
180
+ * undefined if the peer is not registered.
181
+ */
182
+ getPeerPk(did: string): Uint8Array | undefined {
103
183
  return this.#peerRegistry.get(did);
104
184
  }
105
185
 
106
- public registerMessageHandler(actorDid: string, messageType: string, handler: MessageHandler): void {
186
+ /**
187
+ * Registers a message handler function for a specific actor and message type. The handler will be called
188
+ * when a message of the specified type is received for the actor's DID. The transport must have been
189
+ * started for handlers to be invoked. If the transport is already started, the handler will be registered
190
+ * immediately; otherwise, it will be registered when the transport starts and the actor's subscription is created.
191
+ * @param {string} actorDid - The DID of the actor to register the message handler for.
192
+ * @param {string} messageType - The type of message to handle.
193
+ * @param {MessageHandler} handler - The function to handle incoming messages of the specified type.
194
+ * @throws {TransportAdapterError} If the actor DID is not registered or if the handler is invalid.
195
+ */
196
+ registerMessageHandler(actorDid: string, messageType: string, handler: MessageHandler): void {
107
197
  const actor = this.#actors.get(actorDid);
108
198
  if(!actor) {
109
199
  throw new TransportAdapterError(
@@ -114,16 +204,41 @@ export class NostrTransport implements Transport {
114
204
  actor.handlers.set(messageType, handler);
115
205
  }
116
206
 
117
- public start(): NostrTransport {
207
+ /**
208
+ * Starts the transport by connecting to the configured Nostr relays and setting up subscriptions
209
+ * for all registered actors. This method must be called after registering actors via registerActor()
210
+ * and before sending or receiving messages. The transport will subscribe to broadcast events (kind 1)
211
+ * for cohort adverts and directed events (kinds 1 and 1059) for each registered actor based on their
212
+ * public keys. Incoming events are processed and dispatched to the appropriate handlers based on
213
+ * message type. If the transport is already started, this method has no effect.
214
+ * @returns {NostrTransport}
215
+ */
216
+ start(): NostrTransport {
118
217
  if(this.#started) return this;
119
218
  this.#started = true;
120
219
 
121
220
  this.pool = new SimplePool();
122
- const since = Math.floor(Date.now() / 1000);
123
221
 
124
- // Broadcast subscription: kind 1 COHORT_ADVERT events (all actors receive these)
125
- this.pool.subscribeMany(this.#relays, { kinds: [1], '#t': [COHORT_ADVERT], since }, {
126
- onclose : (reasons: string[]) => console.debug('Nostr broadcast subscription closed', reasons),
222
+ // Broadcast subscription: kind 1 COHORT_ADVERT events (all actors receive
223
+ // these). Duplicate adverts are idempotent: AggregationParticipant stores
224
+ // discovered cohorts in a Map keyed by cohortId.
225
+ //
226
+ // The `since` filter is a workaround for relays that don't backfill
227
+ // historical events to late subscribers (observed on nos.lol and
228
+ // relay.snort.social). Without it, an advert published before a
229
+ // participant's subscription lands is lost on those relays. The default
230
+ // 5-minute window is generous enough to cover network + subscription
231
+ // setup delays while still excluding ancient traffic. Set
232
+ // broadcastLookbackMs to 0 to disable.
233
+ const broadcastFilter: { kinds: number[]; '#t': string[]; since?: number } = {
234
+ kinds : [1],
235
+ '#t' : [COHORT_ADVERT],
236
+ };
237
+ if(this.#broadcastLookbackMs > 0) {
238
+ broadcastFilter.since = Math.floor((Date.now() - this.#broadcastLookbackMs) / 1000);
239
+ }
240
+ this.pool.subscribeMany(this.#relays, broadcastFilter, {
241
+ onclose : (reasons: string[]) => this.#logger.debug('Nostr broadcast subscription closed', reasons),
127
242
  onevent : this.#handleBroadcastEvent.bind(this),
128
243
  });
129
244
 
@@ -132,11 +247,23 @@ export class NostrTransport implements Transport {
132
247
  this.#subscribeDirected(did, entry);
133
248
  }
134
249
 
135
- console.info(`NostrTransport started, listening on ${this.#relays.length} relay(s)`);
250
+ this.#logger.info(`NostrTransport started, listening on ${this.#relays.length} relay(s)`);
136
251
  return this;
137
252
  }
138
253
 
139
- public async sendMessage(message: BaseMessage, sender: Did, to?: Did): Promise<void> {
254
+ /**
255
+ * Sends a message by publishing a Nostr event to the configured relays. The message is serialized
256
+ * as JSON and included in the event content.
257
+ * @param {BaseMessage} message - The aggregation message to send. Must include a valid `type` property.
258
+ * @param {Did} sender - The DID of the registered actor sending the message. Must have been
259
+ * registered via registerActor().
260
+ * @param {Did} [to] - Optional recipient DID for directed messages. Required for encrypted message
261
+ * types. If provided, must have been registered via registerPeer().
262
+ * @returns {Promise<void>} Resolves when the message has been published to the relays. Note that
263
+ * publication is best-effort: the method will resolve as long as at least one relay accepts the
264
+ * event, even if others reject it.
265
+ */
266
+ async sendMessage(message: BaseMessage, sender: Did, to?: Did): Promise<void> {
140
267
  const type = message.type;
141
268
 
142
269
  if(!type) {
@@ -178,7 +305,7 @@ export class NostrTransport implements Transport {
178
305
  tags,
179
306
  content : JSON.stringify(message, NostrTransport.#jsonReplacer),
180
307
  } as EventTemplate, senderKeys.secretKey.bytes);
181
- console.debug(`Publishing kind 1 [${type}]`);
308
+ this.#logger.debug(`Publishing kind 1 [${type}]`);
182
309
  await this.#publishToRelays(event);
183
310
  return;
184
311
  }
@@ -211,31 +338,91 @@ export class NostrTransport implements Transport {
211
338
  tags,
212
339
  content,
213
340
  } as EventTemplate, senderKeys.secretKey.bytes);
214
- console.debug(`Publishing kind 1059 [${type}]`);
341
+ this.#logger.debug(`Publishing kind 1059 [${type}]`);
215
342
  await this.#publishToRelays(event);
216
343
  return;
217
344
  }
218
345
 
219
- console.warn(`Unsupported message type: ${type}`);
346
+ this.#logger.warn(`Unsupported message type: ${type}`);
220
347
  }
221
348
 
349
+ /**
350
+ * Publish the message once immediately and then re-publish on an interval
351
+ * until the returned stop function is invoked.
352
+ *
353
+ * Useful for broadcast messages (COHORT_ADVERT) on relays that don't
354
+ * backfill historical events to late subscribers: republishing gives late
355
+ * joiners a window to discover the message without requiring protocol
356
+ * changes. Relay rate-limit / publish failures inside the interval are
357
+ * caught and logged rather than propagated — the caller should stop the
358
+ * repeater once the protocol condition is satisfied.
359
+ */
360
+ publishRepeating(message: BaseMessage, sender: Did, intervalMs: number, recipient?: Did): () => void {
361
+ let stopped = false;
362
+ // Fire the first publish eagerly; any error surfaces as a rejected
363
+ // promise that we swallow to avoid unhandled rejections — the caller can
364
+ // observe delivery via receive-side handlers.
365
+ void this.sendMessage(message, sender, recipient).catch((err) => {
366
+ this.#logger.debug('publishRepeating first send failed:', err);
367
+ });
368
+ const timer = setInterval(() => {
369
+ if(stopped) return;
370
+ void this.sendMessage(message, sender, recipient).catch((err) => {
371
+ this.#logger.debug('publishRepeating retry failed:', err);
372
+ });
373
+ }, intervalMs);
374
+ return () => {
375
+ if(stopped) return;
376
+ stopped = true;
377
+ clearInterval(timer);
378
+ };
379
+ }
380
+
381
+ /**
382
+ * Creates a directed subscription for the given actor, filtering for messages that match the
383
+ * actor's public key. Messages received on this subscription are dispatched to the actor's
384
+ * registered handlers based on message type.
385
+ * @param {string} did - The DID of the actor to create the subscription for.
386
+ * @param {ActorEntry} entry - The actor's registration entry containing keys and handlers.
387
+ * @returns {void}
388
+ * @throws {TransportAdapterError} If the transport is not started or if the pool is unavailable.
389
+ */
222
390
  #subscribeDirected(did: string, entry: ActorEntry): void {
223
391
  if(!this.pool) return;
224
392
 
225
393
  const pkHex = bytesToHex(entry.keys.publicKey.x);
226
- const since = Math.floor(Date.now() / 1000);
227
394
 
228
- this.pool.subscribeMany(this.#relays, { kinds: [1, 1059], '#p': [pkHex], since }, {
229
- onclose : (reasons: string[]) => console.debug(`Nostr directed subscription closed for ${did}`, reasons),
395
+ // No `since` filter: directed messages must be retrievable on reconnect /
396
+ // crash-recovery. Out-of-phase messages are silently dropped by the state
397
+ // machines (AggregationService, AggregationParticipant), so replayed stale
398
+ // messages are harmless.
399
+ const sub = this.pool.subscribeMany(this.#relays, { kinds: [1, 1059], '#p': [pkHex] }, {
400
+ onclose : (reasons: string[]) => this.#logger.debug(`Nostr directed subscription closed for ${did}`, reasons),
230
401
  onevent : this.#makeActorEventHandler(did),
231
402
  });
403
+ entry.subscriptions.push(sub);
232
404
  }
233
405
 
406
+ /**
407
+ * Creates an event handler function for a specific actor that processes incoming events, decrypts
408
+ * if necessary, and dispatches messages to the actor's registered handlers based on message type.
409
+ * @param {string} actorDid - The DID of the actor to create the event handler for.
410
+ * @returns {(event: Event) => Promise<void>} An asynchronous event handler function that
411
+ * processes incoming events for the specified actor.
412
+ */
234
413
  #makeActorEventHandler(actorDid: string): (event: Event) => Promise<void> {
235
414
  return async (event: Event) => {
236
415
  const actor = this.#actors.get(actorDid);
237
416
  if(!actor) return;
238
417
 
418
+ // Relay self-echo: sendMessage() adds the sender's own pubkey to the
419
+ // event's `p` tags (so recipients can reply). The directed subscription
420
+ // filter `{'#p': [actor_pk]}` therefore matches every event this actor
421
+ // publishes. Skip — we don't need to process our own outgoing events,
422
+ // and attempting to NIP-44-decrypt them fails with "invalid MAC" because
423
+ // the content was encrypted for the recipient, not self.
424
+ if(event.pubkey === bytesToHex(actor.keys.publicKey.x)) return;
425
+
239
426
  let message: Record<string, unknown>;
240
427
 
241
428
  try {
@@ -252,7 +439,7 @@ export class NostrTransport implements Transport {
252
439
  return;
253
440
  }
254
441
  } catch(err) {
255
- console.debug(`Failed to parse event ${event.id} for ${actorDid}:`, err);
442
+ this.#logger.debug(`Failed to parse event ${event.id} for ${actorDid}:`, err);
256
443
  return;
257
444
  }
258
445
 
@@ -260,6 +447,16 @@ export class NostrTransport implements Transport {
260
447
  };
261
448
  }
262
449
 
450
+ /**
451
+ * Handles incoming broadcast events (kind 1) by parsing the event content, validating it as an
452
+ * aggregation message, and dispatching it to all registered actors that have handlers for the
453
+ * message type. This is used for COHORT_ADVERT messages that need to be received by all actors
454
+ * regardless of DID.
455
+ * @param {Event} event - The Nostr event to handle, expected to be a kind 1 broadcast containing
456
+ * a COHORT_ADVERT message. The event content is parsed and dispatched to all registered actors
457
+ * that have handlers for the
458
+ * @returns
459
+ */
263
460
  async #handleBroadcastEvent(event: Event): Promise<void> {
264
461
  if(event.kind !== 1) return;
265
462
 
@@ -267,7 +464,7 @@ export class NostrTransport implements Transport {
267
464
  try {
268
465
  message = JSON.parse(event.content, NostrTransport.#jsonReviver);
269
466
  } catch(err) {
270
- console.debug(`Failed to parse broadcast event ${event.id}:`, err);
467
+ this.#logger.debug(`Failed to parse broadcast event ${event.id}:`, err);
271
468
  return;
272
469
  }
273
470
 
@@ -285,6 +482,18 @@ export class NostrTransport implements Transport {
285
482
  }
286
483
  }
287
484
 
485
+ /**
486
+ * Dispatches a parsed message to the appropriate handler of a registered actor based on message type.
487
+ * The message is expected to have already been parsed from the Nostr event content and validated as
488
+ * an aggregation message. If the message has a body, its properties are merged into the top-level
489
+ * message object for easier handler access. The message is then dispatched to the handler registered
490
+ * for its type, if one exists.
491
+ * @param {Record<string, unknown>} message - The message object parsed from a Nostr event, expected to
492
+ * @param {ActorEntry} actor - The registered actor entry containing keys and handlers to dispatch the message to.
493
+ * @returns {void}
494
+ * @throws {TransportAdapterError} If the message type is unsupported or if no handler is registered
495
+ * for the message type.
496
+ */
288
497
  #dispatchMessage(message: Record<string, unknown>, actor: ActorEntry): void {
289
498
  if(message.body && typeof message.body === 'object') {
290
499
  message = { ...message, ...(message.body as Record<string, unknown>) };
@@ -297,6 +506,16 @@ export class NostrTransport implements Transport {
297
506
  if(handler) handler(message);
298
507
  }
299
508
 
509
+ /**
510
+ * Publishes a Nostr event to the configured relays and handles the results. The method waits for all
511
+ * relay promises to settle and checks how many accepted or rejected the event. If all relays reject the event,
512
+ * an error is thrown. Otherwise, the method completes successfully even if some relays rejected the event,
513
+ * as long as at least one relay accepted it. Relay rejections are logged for debugging purposes.
514
+ * @param {Event} event - The Nostr event to publish to the configured relays. The event should already
515
+ * @returns {Promise<void>} A promise that resolves if the event was accepted by at least one relay, or rejects
516
+ * with a TransportAdapterError if all relays rejected the event.
517
+ * @throws {TransportAdapterError} If the pool is not initialized or if all relays reject the event.
518
+ */
300
519
  async #publishToRelays(event: Event): Promise<void> {
301
520
  const relayPromises = this.pool?.publish(this.#relays, event);
302
521
  if(!relayPromises?.length) return;
@@ -306,7 +525,7 @@ export class NostrTransport implements Transport {
306
525
  const rejected = results.filter(r => r.status === 'rejected');
307
526
 
308
527
  for(const r of rejected) {
309
- console.debug(`Relay rejected event ${event.id}: ${(r as PromiseRejectedResult).reason}`);
528
+ this.#logger.debug(`Relay rejected event ${event.id}: ${(r as PromiseRejectedResult).reason}`);
310
529
  }
311
530
 
312
531
  if(accepted === 0) {
@@ -317,6 +536,18 @@ export class NostrTransport implements Transport {
317
536
  }
318
537
  }
319
538
 
539
+ /**
540
+ * Custom JSON replacer to handle serialization of Uint8Array values as hex strings in message
541
+ * content. This allows messages containing binary data (e.g. public keys, signatures) to be correctly
542
+ * serialized to JSON for Nostr event content. The replacer checks if a value is a Uint8Array and, if so,
543
+ * converts it to a hex string wrapped in an object with a __bytes property. The corresponding reviver
544
+ * can then convert this back to a Uint8Array when parsing the message content from the event.
545
+ * @param {string} _key - The key of the property being processed.
546
+ * @param {unknown} value - The value to check if the message type is valid.
547
+ * @returns {unknown} The transformed value for JSON serialization. If the value is a Uint8Array,
548
+ * it returns an object with a __bytes property containing the hex string. Otherwise, it returns
549
+ * the value unchanged.
550
+ */
320
551
  static #jsonReplacer(_key: string, value: unknown): unknown {
321
552
  if(value instanceof Uint8Array) {
322
553
  return { __bytes: bytesToHex(value) };
@@ -324,6 +555,18 @@ export class NostrTransport implements Transport {
324
555
  return value;
325
556
  }
326
557
 
558
+ /**
559
+ * Custom JSON reviver to handle deserialization of hex strings back into Uint8Array values in message
560
+ * content. This complements the custom replacer used during serialization, allowing messages that contain
561
+ * binary data (e.g. public keys, signatures) to be correctly reconstructed when parsing JSON from
562
+ * Nostr event content. The reviver checks if a value is an object with a __bytes property and,
563
+ * if so, converts the hex string back into a Uint8Array. Otherwise, it returns the value unchanged.
564
+ * @param {string} _key - The key of the property being processed.
565
+ * @param {unknown} value - The value to check if it is an object containing a __bytes property for
566
+ * hex string conversion.
567
+ * @returns {unknown} The transformed value for JSON deserialization. If the value is an object
568
+ * with a __bytes property, it returns a Uint8Array. Otherwise, it returns the value unchanged.
569
+ */
327
570
  static #jsonReviver(_key: string, value: unknown): unknown {
328
571
  if(value && typeof value === 'object' && '__bytes' in (value as Record<string, unknown>)) {
329
572
  return hexToBytes((value as { __bytes: string }).__bytes);
@@ -5,7 +5,7 @@ export type SyncMessageHandler = (msg: any) => void;
5
5
  export type AsyncMessageHandler = (msg: any) => Promise<void>;
6
6
  export type MessageHandler = SyncMessageHandler | AsyncMessageHandler;
7
7
 
8
- export type TransportType = 'nostr' | 'didcomm';
8
+ export type TransportType = 'nostr' | 'didcomm' | 'http';
9
9
 
10
10
  /**
11
11
  * Multi-actor message transport.
@@ -41,6 +41,39 @@ export interface Transport {
41
41
  /** Register a message handler scoped to a specific actor. */
42
42
  registerMessageHandler(actorDid: string, messageType: string, handler: MessageHandler): void;
43
43
 
44
+ /** Remove a previously-registered handler. No-op if not registered. */
45
+ unregisterMessageHandler(actorDid: string, messageType: string): void;
46
+
47
+ /**
48
+ * Detach an actor: unregister all its handlers, drop its keys, and close any
49
+ * transport-level subscriptions created for it. No-op if the actor is not
50
+ * registered.
51
+ */
52
+ unregisterActor(did: string): void;
53
+
44
54
  /** Send a message. The transport looks up sender to resolve signing keys. */
45
55
  sendMessage(message: BaseMessage, sender: string, recipient?: string): Promise<void>;
56
+
57
+ /**
58
+ * Publish the message once immediately and then repeat it on a fixed
59
+ * interval. Returns a stop function the caller MUST invoke when the repeat
60
+ * is no longer needed (e.g. once the protocol state that required the
61
+ * message is satisfied).
62
+ *
63
+ * Useful for broadcasts on transports that don't reliably backfill
64
+ * historical events to late subscribers (many Nostr relays) — republishing
65
+ * gives late joiners a window in which to discover the message. The first
66
+ * publish is synchronous-ish (fired before the method returns).
67
+ *
68
+ * Callers specify `recipient` only for directed messages; for broadcasts
69
+ * it is omitted.
70
+ *
71
+ * @returns A stop function. Idempotent — safe to call more than once.
72
+ */
73
+ publishRepeating(
74
+ message: BaseMessage,
75
+ sender: string,
76
+ intervalMs: number,
77
+ recipient?: string,
78
+ ): () => void;
46
79
  }