@johnhenry/browsermesh-transport 0.0.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.
@@ -0,0 +1,543 @@
1
+ /**
2
+ // STATUS: INTEGRATED — wired into ClawserPod lifecycle, proven via E2E testing
3
+ * clawser-mesh-cross-origin.js -- Cross-origin communication bridge.
4
+ *
5
+ * Enables mesh pods running in different browser contexts (iframes,
6
+ * popups, different-origin tabs) to communicate securely using
7
+ * postMessage with origin validation and method allowlisting.
8
+ *
9
+ * No browser-only imports at module level.
10
+ *
11
+ * Run tests:
12
+ * node --import ./web/test/_setup-globals.mjs --test web/test/clawser-mesh-cross-origin.test.mjs
13
+ */
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Trust levels
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export const TRUST_LEVELS = Object.freeze({
20
+ ISOLATED: 'isolated', // No communication allowed
21
+ VERIFIED: 'verified', // Origin verified, limited methods
22
+ TRUSTED: 'trusted', // Full method access
23
+ LINKED: 'linked', // Bidirectional trust
24
+ PINNED: 'pinned', // Pinned trust (like HSTS)
25
+ })
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Wire message types
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export const XO_REQUEST = 'mesh-xo-request'
32
+ export const XO_RESPONSE = 'mesh-xo-response'
33
+ export const XO_HANDSHAKE = 'mesh-xo-handshake'
34
+ export const XO_HANDSHAKE_ACK = 'mesh-xo-handshake-ack'
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // RateLimiter
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /**
41
+ * Rate limiter for cross-origin messages per peer.
42
+ *
43
+ * Tracks message counts in a sliding time window per peerId.
44
+ * Once a peer exceeds `maxPerWindow` messages within `windowMs`,
45
+ * further messages are rejected until the window resets.
46
+ */
47
+ export class RateLimiter {
48
+ #maxPerWindow
49
+ #windowMs
50
+ #counters = new Map() // peerId -> { count, resetAt }
51
+
52
+ /**
53
+ * @param {object} opts
54
+ * @param {number} [opts.maxPerWindow=100] - Max messages per window.
55
+ * @param {number} [opts.windowMs=60000] - Window duration in ms.
56
+ */
57
+ constructor({ maxPerWindow = 100, windowMs = 60000 } = {}) {
58
+ this.#maxPerWindow = maxPerWindow
59
+ this.#windowMs = windowMs
60
+ }
61
+
62
+ /** @returns {number} Configured max per window. */
63
+ get maxPerWindow() { return this.#maxPerWindow }
64
+
65
+ /** @returns {number} Configured window duration in ms. */
66
+ get windowMs() { return this.#windowMs }
67
+
68
+ /**
69
+ * Check if a peer is within rate limits.
70
+ * Does NOT consume a slot -- use `record()` after a successful check.
71
+ * @param {string} peerId
72
+ * @returns {boolean} true if the peer may send another message.
73
+ */
74
+ check(peerId) {
75
+ const now = Date.now()
76
+ const entry = this.#counters.get(peerId)
77
+ if (!entry) return true
78
+ if (now >= entry.resetAt) return true
79
+ return entry.count < this.#maxPerWindow
80
+ }
81
+
82
+ /**
83
+ * Record one message from a peer.
84
+ * Creates a fresh window if the peer has no active window.
85
+ * @param {string} peerId
86
+ */
87
+ record(peerId) {
88
+ const now = Date.now()
89
+ let entry = this.#counters.get(peerId)
90
+ if (!entry || now >= entry.resetAt) {
91
+ entry = { count: 0, resetAt: now + this.#windowMs }
92
+ this.#counters.set(peerId, entry)
93
+ }
94
+ entry.count++
95
+ }
96
+
97
+ /** Reset all counters for every peer. */
98
+ reset() {
99
+ this.#counters.clear()
100
+ }
101
+
102
+ /**
103
+ * Reset the counter for a single peer.
104
+ * @param {string} peerId
105
+ * @returns {boolean} true if the peer had an entry.
106
+ */
107
+ resetPeer(peerId) {
108
+ return this.#counters.delete(peerId)
109
+ }
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // CrossOriginBridge
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /**
117
+ * Cross-origin communication bridge.
118
+ *
119
+ * Manages peer registration, origin validation, method allowlisting,
120
+ * rate limiting and message dispatch.
121
+ *
122
+ * Usage (browser):
123
+ * const bridge = new CrossOriginBridge({ localPodId: 'pod-1' })
124
+ * bridge.registerPeer('pod-2', { origin: 'https://other.example' })
125
+ * bridge.setMethodHandler('ping', () => 'pong')
126
+ * window.addEventListener('message', (e) => bridge.handleMessage(e))
127
+ */
128
+ export class CrossOriginBridge {
129
+ #localPodId
130
+ #peers = new Map() // peerId -> PeerEntry
131
+ #handlers = new Map() // method -> handler(params, fromPodId)
132
+ #rateLimiter
133
+ #onLog
134
+ #pendingRequests = new Map() // requestId -> { resolve, reject, timer }
135
+ #nextId = 1
136
+ #defaultTimeout
137
+
138
+ /**
139
+ * @param {object} opts
140
+ * @param {string} opts.localPodId - This pod's identifier.
141
+ * @param {Function} [opts.onLog] - Logging callback.
142
+ * @param {RateLimiter} [opts.rateLimiter] - Custom rate limiter.
143
+ * @param {number} [opts.defaultTimeout=10000] - Default send timeout ms.
144
+ */
145
+ constructor({ localPodId, onLog, rateLimiter, defaultTimeout = 10000 } = {}) {
146
+ if (!localPodId) throw new Error('localPodId is required')
147
+ this.#localPodId = localPodId
148
+ this.#onLog = onLog || null
149
+ this.#rateLimiter = rateLimiter || new RateLimiter()
150
+ this.#defaultTimeout = defaultTimeout
151
+ }
152
+
153
+ /** @returns {string} The local pod identifier. */
154
+ get localPodId() { return this.#localPodId }
155
+
156
+ /** @returns {number} Number of registered peers. */
157
+ get peerCount() { return this.#peers.size }
158
+
159
+ // -------------------------------------------------------------------------
160
+ // Peer management
161
+ // -------------------------------------------------------------------------
162
+
163
+ /**
164
+ * Register a remote peer for cross-origin communication.
165
+ *
166
+ * @param {string} peerId
167
+ * @param {object} opts
168
+ * @param {string} opts.origin - Expected origin (e.g. 'https://example.com').
169
+ * @param {string} [opts.trust] - Trust level from TRUST_LEVELS. Default: VERIFIED.
170
+ * @param {string[]} [opts.allowedMethods] - Methods this peer may call (VERIFIED only).
171
+ */
172
+ registerPeer(peerId, { origin, trust = TRUST_LEVELS.VERIFIED, allowedMethods = [] } = {}) {
173
+ if (!peerId) throw new Error('peerId is required')
174
+ if (!origin) throw new Error('origin is required')
175
+ if (trust && !Object.values(TRUST_LEVELS).includes(trust)) {
176
+ throw new Error(`Unknown trust level: ${trust}`)
177
+ }
178
+ this.#peers.set(peerId, {
179
+ origin,
180
+ trust,
181
+ allowedMethods: new Set(allowedMethods),
182
+ })
183
+ }
184
+
185
+ /**
186
+ * Update the trust level for an existing peer.
187
+ * @param {string} peerId
188
+ * @param {string} trust - New trust level.
189
+ */
190
+ setTrust(peerId, trust) {
191
+ const peer = this.#peers.get(peerId)
192
+ if (!peer) throw new Error(`Peer "${peerId}" not registered`)
193
+ if (!Object.values(TRUST_LEVELS).includes(trust)) {
194
+ throw new Error(`Unknown trust level: ${trust}`)
195
+ }
196
+ peer.trust = trust
197
+ }
198
+
199
+ /**
200
+ * Remove a registered peer and reject any pending requests to it.
201
+ * @param {string} peerId
202
+ * @returns {boolean} true if the peer existed.
203
+ */
204
+ removePeer(peerId) {
205
+ const existed = this.#peers.delete(peerId)
206
+ // Cancel pending requests to this peer
207
+ for (const [reqId, entry] of this.#pendingRequests) {
208
+ if (entry.peerId === peerId) {
209
+ clearTimeout(entry.timer)
210
+ entry.reject(new Error(`Peer "${peerId}" removed`))
211
+ this.#pendingRequests.delete(reqId)
212
+ }
213
+ }
214
+ return existed
215
+ }
216
+
217
+ /**
218
+ * List all registered peers.
219
+ * @returns {Array<{ peerId, origin, trust, allowedMethods }>}
220
+ */
221
+ listPeers() {
222
+ return [...this.#peers.entries()].map(([peerId, info]) => ({
223
+ peerId,
224
+ origin: info.origin,
225
+ trust: info.trust,
226
+ allowedMethods: [...info.allowedMethods],
227
+ }))
228
+ }
229
+
230
+ /**
231
+ * Get info for a single peer.
232
+ * @param {string} peerId
233
+ * @returns {object|null}
234
+ */
235
+ getPeer(peerId) {
236
+ const info = this.#peers.get(peerId)
237
+ if (!info) return null
238
+ return {
239
+ peerId,
240
+ origin: info.origin,
241
+ trust: info.trust,
242
+ allowedMethods: [...info.allowedMethods],
243
+ }
244
+ }
245
+
246
+ // -------------------------------------------------------------------------
247
+ // Method handlers
248
+ // -------------------------------------------------------------------------
249
+
250
+ /**
251
+ * Register a handler for an incoming method call.
252
+ * @param {string} method
253
+ * @param {Function} handler - (params, fromPodId) => result
254
+ */
255
+ setMethodHandler(method, handler) {
256
+ if (typeof handler !== 'function') throw new Error('handler must be a function')
257
+ this.#handlers.set(method, handler)
258
+ }
259
+
260
+ /**
261
+ * Remove a method handler.
262
+ * @param {string} method
263
+ * @returns {boolean}
264
+ */
265
+ removeMethodHandler(method) {
266
+ return this.#handlers.delete(method)
267
+ }
268
+
269
+ /**
270
+ * List registered method names.
271
+ * @returns {string[]}
272
+ */
273
+ listMethods() {
274
+ return [...this.#handlers.keys()]
275
+ }
276
+
277
+ // -------------------------------------------------------------------------
278
+ // Sending
279
+ // -------------------------------------------------------------------------
280
+
281
+ /**
282
+ * Send a request to a peer. Returns a promise that resolves with the result.
283
+ *
284
+ * @param {string} peerId - Target peer.
285
+ * @param {string} method - Method to invoke.
286
+ * @param {object} [params={}] - Method parameters.
287
+ * @param {object} target - postMessage target (Window, MessagePort, etc.).
288
+ * @param {object} [opts]
289
+ * @param {number} [opts.timeout] - Override default timeout.
290
+ * @returns {Promise<*>}
291
+ */
292
+ async send(peerId, method, params, target, opts = {}) {
293
+ const peer = this.#peers.get(peerId)
294
+ if (!peer) throw new Error(`Peer "${peerId}" not registered`)
295
+ if (peer.trust === TRUST_LEVELS.ISOLATED) {
296
+ throw new Error(`Peer "${peerId}" is isolated`)
297
+ }
298
+
299
+ const requestId = `xo_${this.#nextId++}`
300
+ const message = {
301
+ type: XO_REQUEST,
302
+ requestId,
303
+ fromPodId: this.#localPodId,
304
+ method,
305
+ params: params || {},
306
+ }
307
+
308
+ const timeout = opts.timeout ?? this.#defaultTimeout
309
+
310
+ return new Promise((resolve, reject) => {
311
+ const timer = timeout > 0
312
+ ? setTimeout(() => {
313
+ this.#pendingRequests.delete(requestId)
314
+ reject(new Error(`Request ${requestId} to "${peerId}" timed out`))
315
+ }, timeout)
316
+ : null
317
+
318
+ this.#pendingRequests.set(requestId, { resolve, reject, timer, peerId })
319
+
320
+ if (target && typeof target.postMessage === 'function') {
321
+ target.postMessage(message, peer.origin)
322
+ }
323
+ })
324
+ }
325
+
326
+ // -------------------------------------------------------------------------
327
+ // Receiving
328
+ // -------------------------------------------------------------------------
329
+
330
+ /**
331
+ * Handle an incoming MessageEvent. Validates origin, enforces trust/allowlist,
332
+ * dispatches to handlers, and sends responses.
333
+ *
334
+ * Attach this to `window.addEventListener('message', ...)`.
335
+ *
336
+ * @param {MessageEvent} event
337
+ */
338
+ handleMessage(event) {
339
+ const data = event.data
340
+ if (!data || typeof data !== 'object') return
341
+ if (typeof data.type !== 'string') return
342
+ if (!data.type.startsWith('mesh-xo-')) return
343
+
344
+ const fromPeerId = data.fromPodId
345
+ const peer = fromPeerId ? this.#peers.get(fromPeerId) : null
346
+
347
+ // Origin validation -- reject if the event origin doesn't match the registered origin
348
+ if (peer && event.origin && event.origin !== peer.origin) {
349
+ this.#log(`Origin mismatch for ${fromPeerId}: expected ${peer.origin}, got ${event.origin}`)
350
+ return
351
+ }
352
+
353
+ // Rate limiting
354
+ if (fromPeerId && !this.#rateLimiter.check(fromPeerId)) {
355
+ this.#log(`Rate limit exceeded for ${fromPeerId}`)
356
+ return
357
+ }
358
+ if (fromPeerId) this.#rateLimiter.record(fromPeerId)
359
+
360
+ if (data.type === XO_REQUEST) {
361
+ this.#handleRequest(data, event.source, peer)
362
+ } else if (data.type === XO_RESPONSE) {
363
+ this.#handleResponse(data)
364
+ }
365
+ }
366
+
367
+ // -------------------------------------------------------------------------
368
+ // Internal request/response
369
+ // -------------------------------------------------------------------------
370
+
371
+ #handleRequest(data, source, peer) {
372
+ const { requestId, method, params, fromPodId } = data
373
+
374
+ // ISOLATED peers cannot invoke anything
375
+ if (peer && peer.trust === TRUST_LEVELS.ISOLATED) {
376
+ this.#log(`Blocked request from isolated peer ${fromPodId}`)
377
+ return
378
+ }
379
+
380
+ // For VERIFIED peers, enforce the method allowlist
381
+ if (peer && peer.trust === TRUST_LEVELS.VERIFIED && peer.allowedMethods.size > 0) {
382
+ if (!peer.allowedMethods.has(method)) {
383
+ this.#sendResponse(source, peer, requestId, null, `Method "${method}" not allowed`)
384
+ return
385
+ }
386
+ }
387
+
388
+ // Look up handler
389
+ const handler = this.#handlers.get(method)
390
+ if (!handler) {
391
+ this.#sendResponse(source, peer, requestId, null, `Method "${method}" not found`)
392
+ return
393
+ }
394
+
395
+ // Execute handler (sync or async)
396
+ try {
397
+ const result = handler(params, fromPodId)
398
+ if (result && typeof result.then === 'function') {
399
+ result.then(
400
+ (val) => this.#sendResponse(source, peer, requestId, val, null),
401
+ (err) => this.#sendResponse(source, peer, requestId, null, err.message),
402
+ ).catch(() => {})
403
+ } else {
404
+ this.#sendResponse(source, peer, requestId, result, null)
405
+ }
406
+ } catch (err) {
407
+ this.#sendResponse(source, peer, requestId, null, err.message)
408
+ }
409
+ }
410
+
411
+ #handleResponse(data) {
412
+ const pending = this.#pendingRequests.get(data.requestId)
413
+ if (!pending) return
414
+ this.#pendingRequests.delete(data.requestId)
415
+ if (pending.timer) clearTimeout(pending.timer)
416
+ if (data.error) {
417
+ pending.reject(new Error(data.error))
418
+ } else {
419
+ pending.resolve(data.result)
420
+ }
421
+ }
422
+
423
+ #sendResponse(source, peer, requestId, result, error) {
424
+ if (!source || typeof source.postMessage !== 'function') return
425
+ const msg = {
426
+ type: XO_RESPONSE,
427
+ requestId,
428
+ fromPodId: this.#localPodId,
429
+ result: result ?? null,
430
+ error: error ?? null,
431
+ }
432
+ const origin = peer ? peer.origin : '*'
433
+ source.postMessage(msg, origin)
434
+ }
435
+
436
+ // -------------------------------------------------------------------------
437
+ // Lifecycle
438
+ // -------------------------------------------------------------------------
439
+
440
+ /**
441
+ * Reject all pending requests and clear internal state.
442
+ */
443
+ destroy() {
444
+ for (const [, entry] of this.#pendingRequests) {
445
+ if (entry.timer) clearTimeout(entry.timer)
446
+ entry.reject(new Error('Bridge destroyed'))
447
+ }
448
+ this.#pendingRequests.clear()
449
+ this.#peers.clear()
450
+ this.#handlers.clear()
451
+ this.#rateLimiter.reset()
452
+ }
453
+
454
+ #log(msg) {
455
+ if (this.#onLog) this.#onLog(msg)
456
+ }
457
+ }
458
+
459
+ // ---------------------------------------------------------------------------
460
+ // CrossOriginHandshake
461
+ // ---------------------------------------------------------------------------
462
+
463
+ /**
464
+ * Cross-origin handshake protocol.
465
+ *
466
+ * Establishes initial trust between two browser contexts using a
467
+ * simple challenge-acknowledge exchange over postMessage.
468
+ *
469
+ * Flow:
470
+ * 1. Initiator calls `initiate(targetWindow, origin)`.
471
+ * 2. Target listens for `mesh-xo-handshake` and calls `accept(event)`.
472
+ * 3. Target sends back `mesh-xo-handshake-ack`.
473
+ * 4. Initiator resolves with { peerId, port }.
474
+ */
475
+ export class CrossOriginHandshake {
476
+ /**
477
+ * Initiate a handshake with a target window/iframe.
478
+ *
479
+ * @param {Window} targetWindow - The target context.
480
+ * @param {string} origin - Expected origin of the target.
481
+ * @param {object} [opts]
482
+ * @param {string} [opts.peerId] - Suggested peer ID.
483
+ * @param {number} [opts.timeout] - Timeout in ms (default 5000).
484
+ * @returns {Promise<{ peerId: string, port: MessagePort|null }>}
485
+ */
486
+ static async initiate(targetWindow, origin, opts = {}) {
487
+ const peerId = opts.peerId || `peer_${Date.now().toString(36)}`
488
+ const timeout = opts.timeout ?? 5000
489
+
490
+ return new Promise((resolve, reject) => {
491
+ const timer = setTimeout(() => {
492
+ if (typeof globalThis.removeEventListener === 'function') {
493
+ globalThis.removeEventListener('message', handler)
494
+ }
495
+ reject(new Error('Handshake timeout'))
496
+ }, timeout)
497
+
498
+ const handler = (event) => {
499
+ if (event.origin !== origin) return
500
+ if (!event.data || event.data.type !== XO_HANDSHAKE_ACK) return
501
+ clearTimeout(timer)
502
+ if (typeof globalThis.removeEventListener === 'function') {
503
+ globalThis.removeEventListener('message', handler)
504
+ }
505
+ resolve({
506
+ peerId: event.data.peerId || peerId,
507
+ port: event.data.port || null,
508
+ })
509
+ }
510
+
511
+ if (typeof globalThis.addEventListener === 'function') {
512
+ globalThis.addEventListener('message', handler)
513
+ }
514
+
515
+ targetWindow.postMessage({ type: XO_HANDSHAKE, peerId }, origin)
516
+ })
517
+ }
518
+
519
+ /**
520
+ * Accept a handshake from an incoming message event.
521
+ *
522
+ * @param {MessageEvent} event
523
+ * @param {object} [opts]
524
+ * @param {string} [opts.localPodId] - This side's pod identifier.
525
+ * @returns {Promise<{ peerId: string, port: MessagePort|null }|null>}
526
+ * null if the event is not a handshake request.
527
+ */
528
+ static async accept(event, opts = {}) {
529
+ if (!event.data || event.data.type !== XO_HANDSHAKE) return null
530
+
531
+ const peerId = event.data.peerId || `peer_${Date.now().toString(36)}`
532
+ const ackPeerId = opts.localPodId || `local_${Date.now().toString(36)}`
533
+
534
+ if (event.source && typeof event.source.postMessage === 'function') {
535
+ event.source.postMessage(
536
+ { type: XO_HANDSHAKE_ACK, peerId: ackPeerId },
537
+ event.origin,
538
+ )
539
+ }
540
+
541
+ return { peerId, port: null }
542
+ }
543
+ }