@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,409 @@
1
+ import type { SchnorrKeyPair } from '@did-btcr2/keypair';
2
+ import { CompressedSecp256k1PublicKey } from '@did-btcr2/keypair';
3
+
4
+ import { Identifier } from '../../../identifier.js';
5
+ import type { Logger } from '../../logger.js';
6
+ import { CONSOLE_LOGGER } from '../../logger.js';
7
+ import type { BaseMessage } from '../../messages/base.js';
8
+ import type { MessageHandler, Transport } from '../transport.js';
9
+ import { reviveFromWire, signEnvelope, verifyEnvelope } from './envelope.js';
10
+ import { HttpTransportError } from './errors.js';
11
+ import {
12
+ DEFAULT_CLOCK_SKEW_SEC,
13
+ HTTP_ROUTE,
14
+ SSE_EVENT,
15
+ type SignedEnvelope,
16
+ } from './protocol.js';
17
+ import { buildRequestAuth } from './request-auth.js';
18
+ import { parseSseStream } from './sse-stream.js';
19
+
20
+ export interface HttpClientTransportConfig {
21
+ /** Base URL of the aggregation service (e.g. `https://aggregator.example.com/`). */
22
+ baseUrl: string | URL;
23
+ /** Custom `fetch` implementation (tests, Workers, React Native). Defaults to `globalThis.fetch`. */
24
+ fetchImpl?: typeof fetch;
25
+ /** Diagnostic logger. Defaults to {@link CONSOLE_LOGGER}. */
26
+ logger?: Logger;
27
+ /** Reconnect backoff (ms) given attempt count (0-based). */
28
+ reconnectBackoff?: (attempt: number) => number;
29
+ /** Envelope / request-auth clock-skew tolerance in seconds. */
30
+ clockSkewSec?: number;
31
+ }
32
+
33
+ /** Default exponential backoff: 1s, 2s, 4s, ..., capped at 30s, 20% jitter. */
34
+ export function defaultReconnectBackoff(attempt: number): number {
35
+ const base = Math.min(1000 * 2 ** attempt, 30_000);
36
+ const jitter = base * 0.2 * Math.random();
37
+ return Math.floor(base + jitter);
38
+ }
39
+
40
+ interface ActorEntry {
41
+ keys: SchnorrKeyPair;
42
+ handlers: Map<string, MessageHandler>;
43
+ inboxAbort?: AbortController;
44
+ }
45
+
46
+ /**
47
+ * HTTP transport client. Implements the transport-agnostic {@link Transport}
48
+ * interface; the wire is fetch-based SSE for incoming events and fetch-based
49
+ * POST for outgoing messages. All runtime I/O goes through `fetchImpl` so
50
+ * tests can substitute a mock without touching the network.
51
+ */
52
+ export class HttpClientTransport implements Transport {
53
+ readonly name = 'http';
54
+
55
+ readonly #baseUrl: URL;
56
+ readonly #fetch: typeof fetch;
57
+ readonly #logger: Logger;
58
+ readonly #backoff: (attempt: number) => number;
59
+ readonly #clockSkewSec: number;
60
+
61
+ readonly #actors: Map<string, ActorEntry> = new Map();
62
+ readonly #peers: Map<string, Uint8Array> = new Map();
63
+
64
+ #started = false;
65
+ #broadcastAbort?: AbortController;
66
+
67
+ constructor(config: HttpClientTransportConfig) {
68
+ const base = typeof config.baseUrl === 'string' ? new URL(config.baseUrl) : new URL(config.baseUrl.href);
69
+ if(!base.pathname.endsWith('/')) base.pathname += '/';
70
+ this.#baseUrl = base;
71
+ this.#logger = config.logger ?? CONSOLE_LOGGER;
72
+ this.#backoff = config.reconnectBackoff ?? defaultReconnectBackoff;
73
+ this.#clockSkewSec = config.clockSkewSec ?? DEFAULT_CLOCK_SKEW_SEC;
74
+
75
+ const fetchImpl = config.fetchImpl ?? globalThis.fetch;
76
+ if(typeof fetchImpl !== 'function') {
77
+ throw new HttpTransportError(
78
+ 'No fetch implementation available. Pass config.fetchImpl explicitly.',
79
+ 'NO_FETCH_IMPL',
80
+ );
81
+ }
82
+ this.#fetch = fetchImpl;
83
+ }
84
+
85
+ start(): void {
86
+ if(this.#started) return;
87
+ this.#started = true;
88
+
89
+ this.#broadcastAbort = new AbortController();
90
+ this.#runBroadcastLoop(this.#broadcastAbort.signal);
91
+
92
+ for(const [did, entry] of this.#actors) {
93
+ this.#openInbox(did, entry);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Tear down all SSE subscriptions and stop reconnect loops. Not part of the
99
+ * {@link Transport} interface, but needed in tests and whenever a client
100
+ * wants to cleanly disconnect without unregistering every actor.
101
+ *
102
+ * Idempotent. Actors remain registered (re-call {@link start} to resume).
103
+ */
104
+ stop(): void {
105
+ this.#broadcastAbort?.abort();
106
+ this.#broadcastAbort = undefined;
107
+ for(const entry of this.#actors.values()) {
108
+ entry.inboxAbort?.abort();
109
+ entry.inboxAbort = undefined;
110
+ }
111
+ this.#started = false;
112
+ }
113
+
114
+ registerActor(did: string, keys: SchnorrKeyPair): void {
115
+ const existing = this.#actors.get(did);
116
+ if(existing?.inboxAbort) existing.inboxAbort.abort();
117
+
118
+ const entry: ActorEntry = { keys, handlers: new Map() };
119
+ this.#actors.set(did, entry);
120
+ if(this.#started) this.#openInbox(did, entry);
121
+ }
122
+
123
+ unregisterActor(did: string): void {
124
+ const entry = this.#actors.get(did);
125
+ if(!entry) return;
126
+ entry.inboxAbort?.abort();
127
+ this.#actors.delete(did);
128
+ this.#peers.delete(did);
129
+ }
130
+
131
+ getActorPk(did: string): Uint8Array | undefined {
132
+ return this.#actors.get(did)?.keys.publicKey.compressed;
133
+ }
134
+
135
+ registerPeer(did: string, communicationPk: Uint8Array): void {
136
+ try {
137
+ new CompressedSecp256k1PublicKey(communicationPk);
138
+ } catch {
139
+ throw new HttpTransportError(
140
+ `Invalid communication public key for peer ${did}: expected a 33-byte compressed secp256k1 key.`,
141
+ 'INVALID_PEER_KEY',
142
+ { did, keyLength: communicationPk.length },
143
+ );
144
+ }
145
+ this.#peers.set(did, communicationPk);
146
+ }
147
+
148
+ getPeerPk(did: string): Uint8Array | undefined {
149
+ return this.#peers.get(did);
150
+ }
151
+
152
+ registerMessageHandler(actorDid: string, messageType: string, handler: MessageHandler): void {
153
+ const actor = this.#actors.get(actorDid);
154
+ if(!actor) {
155
+ throw new HttpTransportError(
156
+ `Cannot register handler: actor ${actorDid} not registered. Call registerActor() first.`,
157
+ 'UNKNOWN_ACTOR',
158
+ { did: actorDid },
159
+ );
160
+ }
161
+ actor.handlers.set(messageType, handler);
162
+ }
163
+
164
+ unregisterMessageHandler(actorDid: string, messageType: string): void {
165
+ this.#actors.get(actorDid)?.handlers.delete(messageType);
166
+ }
167
+
168
+ async sendMessage(message: BaseMessage, sender: string, recipient?: string): Promise<void> {
169
+ const actor = this.#actors.get(sender);
170
+ if(!actor) {
171
+ throw new HttpTransportError(
172
+ `Unknown sender: ${sender}. Call registerActor() before sending messages.`,
173
+ 'UNKNOWN_SENDER',
174
+ { did: sender },
175
+ );
176
+ }
177
+
178
+ const envelope = signEnvelope(
179
+ message,
180
+ { did: sender, keys: actor.keys },
181
+ { to: recipient },
182
+ );
183
+
184
+ const url = this.#route(HTTP_ROUTE.MESSAGES);
185
+ const res = await this.#fetch(url, {
186
+ method : 'POST',
187
+ headers : { 'content-type': 'application/json' },
188
+ body : JSON.stringify(envelope),
189
+ });
190
+
191
+ if(!res.ok) {
192
+ const body = await safeText(res);
193
+ throw new HttpTransportError(
194
+ `sendMessage failed: HTTP ${res.status}`,
195
+ 'SEND_MESSAGE_HTTP',
196
+ { status: res.status, body: body.slice(0, 256), messageType: message.type },
197
+ );
198
+ }
199
+ }
200
+
201
+ publishRepeating(
202
+ message: BaseMessage,
203
+ sender: string,
204
+ intervalMs: number,
205
+ recipient?: string,
206
+ ): () => void {
207
+ let stopped = false;
208
+ const attempt = (): void => {
209
+ if(stopped) return;
210
+ this.sendMessage(message, sender, recipient).catch((err) => {
211
+ this.#logger.debug('publishRepeating send failed:', err);
212
+ });
213
+ };
214
+ attempt();
215
+ const timer = setInterval(attempt, intervalMs);
216
+ return (): void => {
217
+ if(stopped) return;
218
+ stopped = true;
219
+ clearInterval(timer);
220
+ };
221
+ }
222
+
223
+ #route(template: string): URL {
224
+ // Strip the leading slash so `new URL(rel, base)` is resolved against the
225
+ // base's pathname instead of replacing it.
226
+ return new URL(template.replace(/^\//, ''), this.#baseUrl);
227
+ }
228
+
229
+ #openInbox(did: string, entry: ActorEntry): void {
230
+ const abort = new AbortController();
231
+ entry.inboxAbort = abort;
232
+ this.#runInboxLoop(did, entry, abort.signal);
233
+ }
234
+
235
+ async #runBroadcastLoop(signal: AbortSignal): Promise<void> {
236
+ const url = this.#route(HTTP_ROUTE.ADVERTS);
237
+ let attempt = 0;
238
+ while(!signal.aborted) {
239
+ try {
240
+ const res = await this.#fetch(url, {
241
+ method : 'GET',
242
+ headers : { accept: 'text/event-stream' },
243
+ signal,
244
+ });
245
+ if(!res.ok || !res.body) {
246
+ this.#logger.warn(`Broadcast subscribe failed: HTTP ${res.status}`);
247
+ await sleep(this.#backoff(attempt++), signal);
248
+ continue;
249
+ }
250
+ attempt = 0;
251
+ for await (const ev of parseSseStream(res.body)) {
252
+ if(signal.aborted) return;
253
+ if(ev.event !== SSE_EVENT.ADVERT) continue;
254
+ this.#dispatchBroadcast(ev.data);
255
+ }
256
+ } catch(err) {
257
+ if(signal.aborted) return;
258
+ this.#logger.debug('Broadcast loop error:', err);
259
+ try {
260
+ await sleep(this.#backoff(attempt++), signal);
261
+ } catch {
262
+ return; // sleep was aborted
263
+ }
264
+ }
265
+ }
266
+ }
267
+
268
+ async #runInboxLoop(did: string, entry: ActorEntry, signal: AbortSignal): Promise<void> {
269
+ const url = this.#route(HTTP_ROUTE.ACTOR_INBOX.replace('{did}', encodeURIComponent(did)));
270
+ let attempt = 0;
271
+ while(!signal.aborted) {
272
+ try {
273
+ const auth = buildRequestAuth(did, entry.keys, url.pathname);
274
+ const res = await this.#fetch(url, {
275
+ method : 'GET',
276
+ headers : { accept: 'text/event-stream', authorization: auth },
277
+ signal,
278
+ });
279
+ if(!res.ok || !res.body) {
280
+ this.#logger.warn(`Inbox subscribe failed for ${did}: HTTP ${res.status}`);
281
+ await sleep(this.#backoff(attempt++), signal);
282
+ continue;
283
+ }
284
+ attempt = 0;
285
+ for await (const ev of parseSseStream(res.body)) {
286
+ if(signal.aborted) return;
287
+ if(ev.event !== SSE_EVENT.MESSAGE) continue;
288
+ await this.#dispatchInbox(ev.data, did, entry);
289
+ }
290
+ } catch(err) {
291
+ if(signal.aborted) return;
292
+ this.#logger.debug(`Inbox loop error for ${did}:`, err);
293
+ try {
294
+ await sleep(this.#backoff(attempt++), signal);
295
+ } catch {
296
+ return;
297
+ }
298
+ }
299
+ }
300
+ }
301
+
302
+ #dispatchBroadcast(dataJson: string): void {
303
+ const envelope = parseEnvelope(dataJson, this.#logger);
304
+ if(!envelope) return;
305
+
306
+ const senderPk = this.#resolveSenderPk(envelope.from);
307
+ if(!senderPk) {
308
+ this.#logger.debug(`Broadcast from unresolvable DID: ${envelope.from}`);
309
+ return;
310
+ }
311
+ try {
312
+ verifyEnvelope(envelope, senderPk, { clockSkewSec: this.#clockSkewSec });
313
+ } catch(err) {
314
+ this.#logger.debug('Broadcast envelope verification failed:', err);
315
+ return;
316
+ }
317
+
318
+ const revived = reviveFromWire(envelope.message) as Record<string, unknown>;
319
+ const flat = flattenMessage(revived);
320
+ const messageType = typeof flat.type === 'string' ? flat.type : undefined;
321
+ if(!messageType) return;
322
+
323
+ for(const actor of this.#actors.values()) {
324
+ const handler = actor.handlers.get(messageType);
325
+ if(handler) void Promise.resolve(handler(flat));
326
+ }
327
+ }
328
+
329
+ async #dispatchInbox(dataJson: string, actorDid: string, entry: ActorEntry): Promise<void> {
330
+ const envelope = parseEnvelope(dataJson, this.#logger);
331
+ if(!envelope) return;
332
+
333
+ const senderPk = this.#resolveSenderPk(envelope.from);
334
+ if(!senderPk) {
335
+ this.#logger.debug(`Inbox message from unresolvable DID: ${envelope.from}`);
336
+ return;
337
+ }
338
+ try {
339
+ verifyEnvelope(envelope, senderPk, {
340
+ clockSkewSec : this.#clockSkewSec,
341
+ expectedTo : actorDid,
342
+ });
343
+ } catch(err) {
344
+ this.#logger.debug(`Inbox envelope verification failed for ${actorDid}:`, err);
345
+ return;
346
+ }
347
+
348
+ const revived = reviveFromWire(envelope.message) as Record<string, unknown>;
349
+ const flat = flattenMessage(revived);
350
+ const messageType = typeof flat.type === 'string' ? flat.type : undefined;
351
+ if(!messageType) return;
352
+
353
+ const handler = entry.handlers.get(messageType);
354
+ if(handler) await handler(flat);
355
+ }
356
+
357
+ #resolveSenderPk(did: string): CompressedSecp256k1PublicKey | undefined {
358
+ const peerBytes = this.#peers.get(did);
359
+ if(peerBytes) {
360
+ try { return new CompressedSecp256k1PublicKey(peerBytes); }
361
+ catch { /* fall through to DID decode */ }
362
+ }
363
+ try {
364
+ const components = Identifier.decode(did);
365
+ if(components.idType === 'KEY') {
366
+ return new CompressedSecp256k1PublicKey(components.genesisBytes);
367
+ }
368
+ } catch {
369
+ // Not a decodable did:btcr2 KEY identifier.
370
+ }
371
+ return undefined;
372
+ }
373
+ }
374
+
375
+ function sleep(ms: number, signal: AbortSignal): Promise<void> {
376
+ if(ms <= 0) return Promise.resolve();
377
+ return new Promise((resolve, reject) => {
378
+ const onAbort = (): void => {
379
+ clearTimeout(timer);
380
+ reject(new Error('aborted'));
381
+ };
382
+ const timer = setTimeout(() => {
383
+ signal.removeEventListener('abort', onAbort);
384
+ resolve();
385
+ }, ms);
386
+ if(signal.aborted) onAbort();
387
+ else signal.addEventListener('abort', onAbort, { once: true });
388
+ });
389
+ }
390
+
391
+ async function safeText(res: Response): Promise<string> {
392
+ try { return await res.text(); }
393
+ catch { return ''; }
394
+ }
395
+
396
+ function parseEnvelope(dataJson: string, logger: Logger): SignedEnvelope | undefined {
397
+ try { return JSON.parse(dataJson) as SignedEnvelope; }
398
+ catch(err) {
399
+ logger.debug('SSE event: failed to parse envelope JSON:', err);
400
+ return undefined;
401
+ }
402
+ }
403
+
404
+ function flattenMessage(msg: Record<string, unknown>): Record<string, unknown> {
405
+ if(msg.body && typeof msg.body === 'object') {
406
+ return { ...msg, ...(msg.body as Record<string, unknown>) };
407
+ }
408
+ return msg;
409
+ }
@@ -0,0 +1,204 @@
1
+ import { canonicalHashBytes } from '@did-btcr2/common';
2
+ import type { CompressedSecp256k1PublicKey, SchnorrKeyPair } from '@did-btcr2/keypair';
3
+ import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils';
4
+
5
+ import type { BaseMessage } from '../../messages/base.js';
6
+ import { HttpTransportError } from './errors.js';
7
+ import {
8
+ DEFAULT_CLOCK_SKEW_SEC,
9
+ DEFAULT_NONCE_LEN_BYTES,
10
+ HTTP_ENVELOPE_VERSION,
11
+ type SignedEnvelope,
12
+ } from './protocol.js';
13
+
14
+ /** Any shape acceptable as an envelope payload. `BaseMessage` instances are
15
+ * `toJSON`-normalized before signing so class vs. POJO callers produce the
16
+ * same canonical form. */
17
+ export type EnvelopeMessage = BaseMessage | Record<string, unknown>;
18
+
19
+ export interface SignEnvelopeOptions {
20
+ /** Recipient DID. Omit for broadcasts. */
21
+ to?: string;
22
+ /** Override the random nonce (tests). */
23
+ nonce?: string;
24
+ /** Override the unix-seconds timestamp (tests). */
25
+ timestamp?: number;
26
+ }
27
+
28
+ export interface VerifyEnvelopeOptions {
29
+ /** Reject if `envelope.from` doesn't match. */
30
+ expectedFrom?: string;
31
+ /** Reject if `envelope.to` doesn't match. Pass `undefined` to require a broadcast. */
32
+ expectedTo?: string;
33
+ /** Clock-skew tolerance (seconds). Defaults to {@link DEFAULT_CLOCK_SKEW_SEC}. */
34
+ clockSkewSec?: number;
35
+ /** Clock override (tests). Defaults to `Date.now`. */
36
+ now?: () => number;
37
+ }
38
+
39
+ /**
40
+ * Build a {@link SignedEnvelope} around `message`.
41
+ *
42
+ * Pure function — no I/O beyond `randomBytes` for nonce generation (which
43
+ * uses the platform's cryptographic RNG: `crypto.getRandomValues` in browsers,
44
+ * `node:crypto` in Node). Deterministic when both `nonce` and `timestamp` are
45
+ * supplied via {@link SignEnvelopeOptions}.
46
+ */
47
+ export function signEnvelope(
48
+ message: EnvelopeMessage,
49
+ sender: { did: string; keys: SchnorrKeyPair },
50
+ opts: SignEnvelopeOptions = {},
51
+ ): SignedEnvelope {
52
+ const timestamp = opts.timestamp ?? Math.floor(Date.now() / 1000);
53
+ const nonce = opts.nonce ?? bytesToHex(randomBytes(DEFAULT_NONCE_LEN_BYTES));
54
+ const messageJson = normalizeForWire(normalizeMessage(message)) as Record<string, unknown>;
55
+
56
+ const unsigned: Omit<SignedEnvelope, 'sig'> = {
57
+ v : HTTP_ENVELOPE_VERSION,
58
+ from : sender.did,
59
+ ...(opts.to !== undefined ? { to: opts.to } : {}),
60
+ timestamp,
61
+ nonce,
62
+ message : messageJson,
63
+ };
64
+
65
+ const digest = canonicalHashBytes(unsigned);
66
+ const sig = sender.keys.secretKey.sign(digest, { scheme: 'schnorr' });
67
+
68
+ return { ...unsigned, sig: bytesToHex(sig) };
69
+ }
70
+
71
+ /**
72
+ * Verify a {@link SignedEnvelope} against the sender's compressed secp256k1
73
+ * communication public key. Throws {@link HttpTransportError} on any failure;
74
+ * returns normally on success.
75
+ *
76
+ * Does NOT check nonce uniqueness — replay protection is the caller's
77
+ * responsibility (the server-side transport maintains an LRU cache).
78
+ */
79
+ export function verifyEnvelope(
80
+ envelope: SignedEnvelope,
81
+ senderPk: CompressedSecp256k1PublicKey,
82
+ opts: VerifyEnvelopeOptions = {},
83
+ ): void {
84
+ if(envelope.v !== HTTP_ENVELOPE_VERSION) {
85
+ throw new HttpTransportError(
86
+ `Unsupported envelope version: ${envelope.v}`,
87
+ 'ENVELOPE_VERSION_MISMATCH',
88
+ { version: envelope.v, expected: HTTP_ENVELOPE_VERSION },
89
+ );
90
+ }
91
+
92
+ if(opts.expectedFrom !== undefined && envelope.from !== opts.expectedFrom) {
93
+ throw new HttpTransportError(
94
+ `Envelope from mismatch: expected ${opts.expectedFrom}, got ${envelope.from}`,
95
+ 'ENVELOPE_FROM_MISMATCH',
96
+ { expected: opts.expectedFrom, got: envelope.from },
97
+ );
98
+ }
99
+
100
+ if('expectedTo' in opts && envelope.to !== opts.expectedTo) {
101
+ throw new HttpTransportError(
102
+ `Envelope to mismatch: expected ${opts.expectedTo ?? '<broadcast>'}, got ${envelope.to ?? '<broadcast>'}`,
103
+ 'ENVELOPE_TO_MISMATCH',
104
+ { expected: opts.expectedTo, got: envelope.to },
105
+ );
106
+ }
107
+
108
+ const skewSec = opts.clockSkewSec ?? DEFAULT_CLOCK_SKEW_SEC;
109
+ const nowMs = opts.now ? opts.now() : Date.now();
110
+ const nowSec = Math.floor(nowMs / 1000);
111
+ const diff = Math.abs(nowSec - envelope.timestamp);
112
+ if(diff > skewSec) {
113
+ throw new HttpTransportError(
114
+ `Envelope timestamp out of skew: ${diff}s > ${skewSec}s`,
115
+ 'ENVELOPE_TIMESTAMP_SKEW',
116
+ { diff, skewSec, timestamp: envelope.timestamp, now: nowSec },
117
+ );
118
+ }
119
+
120
+ let sigBytes: Uint8Array;
121
+ try {
122
+ sigBytes = hexToBytes(envelope.sig);
123
+ } catch {
124
+ throw new HttpTransportError(
125
+ 'Envelope signature is not valid hex',
126
+ 'ENVELOPE_SIG_HEX',
127
+ );
128
+ }
129
+ if(sigBytes.length !== 64) {
130
+ throw new HttpTransportError(
131
+ `Invalid signature length: ${sigBytes.length} (expected 64)`,
132
+ 'ENVELOPE_SIG_LENGTH',
133
+ { length: sigBytes.length },
134
+ );
135
+ }
136
+
137
+ const { sig: _sig, ...unsigned } = envelope;
138
+ const digest = canonicalHashBytes(unsigned);
139
+
140
+ const ok = senderPk.verify(sigBytes, digest, { scheme: 'schnorr' });
141
+ if(!ok) {
142
+ throw new HttpTransportError(
143
+ 'Envelope signature verification failed',
144
+ 'ENVELOPE_SIG_INVALID',
145
+ );
146
+ }
147
+ }
148
+
149
+ function normalizeMessage(message: EnvelopeMessage): Record<string, unknown> {
150
+ const maybeToJSON = (message as { toJSON?: () => unknown }).toJSON;
151
+ if(typeof maybeToJSON === 'function') {
152
+ return maybeToJSON.call(message) as Record<string, unknown>;
153
+ }
154
+ return message as Record<string, unknown>;
155
+ }
156
+
157
+ /**
158
+ * Recursively replace `Uint8Array` values with `{ __bytes: hex }` sentinel
159
+ * objects so they survive JSON canonicalization / HTTP body serialization.
160
+ * Pairs with {@link reviveFromWire}.
161
+ *
162
+ * Without this, `JSON.stringify` serializes a `Uint8Array` as an index-keyed
163
+ * object (`{"0":1,"1":2,...}`), which `canonicalize` then re-parses into a
164
+ * plain object — the receiver cannot reconstruct the original bytes even
165
+ * though the signature still verifies.
166
+ */
167
+ export function normalizeForWire(value: unknown): unknown {
168
+ if(value instanceof Uint8Array) {
169
+ return { __bytes: bytesToHex(value) };
170
+ }
171
+ if(Array.isArray(value)) {
172
+ return value.map((v) => normalizeForWire(v));
173
+ }
174
+ if(value && typeof value === 'object') {
175
+ const out: Record<string, unknown> = {};
176
+ for(const [k, v] of Object.entries(value as Record<string, unknown>)) {
177
+ out[k] = normalizeForWire(v);
178
+ }
179
+ return out;
180
+ }
181
+ return value;
182
+ }
183
+
184
+ /**
185
+ * Recursively convert `{ __bytes: hex }` sentinels back into `Uint8Array`
186
+ * values. Call on `envelope.message` *after* successful verification and
187
+ * before handing the payload to a runner's handler.
188
+ */
189
+ export function reviveFromWire(value: unknown): unknown {
190
+ if(value && typeof value === 'object' && !Array.isArray(value)) {
191
+ const rec = value as Record<string, unknown>;
192
+ const keys = Object.keys(rec);
193
+ if(keys.length === 1 && keys[0] === '__bytes' && typeof rec.__bytes === 'string') {
194
+ return hexToBytes(rec.__bytes);
195
+ }
196
+ const out: Record<string, unknown> = {};
197
+ for(const [k, v] of Object.entries(rec)) out[k] = reviveFromWire(v);
198
+ return out;
199
+ }
200
+ if(Array.isArray(value)) {
201
+ return value.map((v) => reviveFromWire(v));
202
+ }
203
+ return value;
204
+ }
@@ -0,0 +1,11 @@
1
+ import { TransportAdapterError } from '../error.js';
2
+
3
+ /**
4
+ * Errors raised by the HTTP transport adapter. Extends {@link TransportAdapterError}
5
+ * so callers can catch HTTP-specific failures narrowly or transport failures broadly.
6
+ */
7
+ export class HttpTransportError extends TransportAdapterError {
8
+ constructor(message: string, type: string = 'HttpTransportError', data?: Record<string, any>) {
9
+ super(message, type, { adapter: 'http', ...(data ?? {}) });
10
+ }
11
+ }
@@ -0,0 +1,53 @@
1
+ export interface StoredEvent {
2
+ /** Monotonic ID assigned at append time. Stable across the buffer's lifetime. */
3
+ id: string;
4
+ /** SSE event name. */
5
+ event: string;
6
+ /** SSE data payload (typically a JSON-stringified {@link SignedEnvelope}). */
7
+ data: string;
8
+ }
9
+
10
+ /**
11
+ * Fixed-capacity FIFO ring buffer of SSE events for a single actor's inbox.
12
+ *
13
+ * When a subscriber (re)connects with a `Last-Event-ID` header, the server
14
+ * uses {@link since} to replay everything that arrived while the subscriber
15
+ * was disconnected. Events older than the replay window (evicted from the
16
+ * ring) are unrecoverable — callers should choose `capacity` based on
17
+ * expected message rate × acceptable reconnect window.
18
+ */
19
+ export class InboxBuffer {
20
+ readonly #capacity: number;
21
+ readonly #entries: StoredEvent[] = [];
22
+ #nextId = 1;
23
+
24
+ constructor(capacity = 100) {
25
+ if(capacity < 1) throw new Error(`InboxBuffer capacity must be >= 1; got ${capacity}`);
26
+ this.#capacity = capacity;
27
+ }
28
+
29
+ /** Append an event. Returns the stored record (including its assigned id). */
30
+ append(event: string, data: string): StoredEvent {
31
+ const stored: StoredEvent = { id: String(this.#nextId++), event, data };
32
+ this.#entries.push(stored);
33
+ if(this.#entries.length > this.#capacity) this.#entries.shift();
34
+ return stored;
35
+ }
36
+
37
+ /**
38
+ * Return stored events with id strictly greater than `lastEventId`. If
39
+ * `lastEventId` is unset or unparseable, returns everything currently
40
+ * retained.
41
+ */
42
+ since(lastEventId?: string): StoredEvent[] {
43
+ if(!lastEventId) return this.#entries.slice();
44
+ const boundary = Number(lastEventId);
45
+ if(!Number.isFinite(boundary)) return this.#entries.slice();
46
+ return this.#entries.filter((e) => Number(e.id) > boundary);
47
+ }
48
+
49
+ /** Currently retained event count. */
50
+ size(): number {
51
+ return this.#entries.length;
52
+ }
53
+ }
@@ -0,0 +1,11 @@
1
+ export * from './errors.js';
2
+ export * from './protocol.js';
3
+ export * from './envelope.js';
4
+ export * from './sse-stream.js';
5
+ export * from './sse-writer.js';
6
+ export * from './request-auth.js';
7
+ export * from './nonce-cache.js';
8
+ export * from './rate-limiter.js';
9
+ export * from './inbox-buffer.js';
10
+ export * from './client.js';
11
+ export * from './server.js';