@robota-sdk/agent-transport-webrtc 3.0.0-beta.100

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 (84) hide show
  1. package/CHANGELOG.md +694 -0
  2. package/LICENSE +661 -0
  3. package/README.md +111 -0
  4. package/dist/node/index.cjs +1 -0
  5. package/dist/node/index.d.cts +1650 -0
  6. package/dist/node/index.d.cts.map +1 -0
  7. package/dist/node/index.d.ts +1650 -0
  8. package/dist/node/index.d.ts.map +1 -0
  9. package/dist/node/index.js +2 -0
  10. package/dist/node/index.js.map +1 -0
  11. package/package.json +93 -0
  12. package/src/__tests__/admission-secure-by-default.test.ts +63 -0
  13. package/src/__tests__/admission-steps.test.ts +46 -0
  14. package/src/__tests__/device-mesh-relay.test.ts +441 -0
  15. package/src/__tests__/device-mesh.test.ts +682 -0
  16. package/src/__tests__/discovering-mesh-relay.test.ts +234 -0
  17. package/src/__tests__/enrollment-link.test.ts +103 -0
  18. package/src/__tests__/fake-datachannel.ts +83 -0
  19. package/src/__tests__/handoff-grant-gate.test.ts +403 -0
  20. package/src/__tests__/local-peer-proof-gate.test.ts +215 -0
  21. package/src/__tests__/mesh-admission-contract.test.ts +38 -0
  22. package/src/__tests__/mesh-discovery.test.ts +820 -0
  23. package/src/__tests__/mesh-fixtures.ts +148 -0
  24. package/src/__tests__/mesh-internet.test.ts +942 -0
  25. package/src/__tests__/mesh-turn-relay.test.ts +161 -0
  26. package/src/__tests__/negotiated-certificate.test.ts +76 -0
  27. package/src/__tests__/nostr-source-condition.test.ts +27 -0
  28. package/src/__tests__/operator-approval-gate.test.ts +308 -0
  29. package/src/__tests__/outbound-delivery-carrier.test.ts +155 -0
  30. package/src/__tests__/pairing-e2e.test.ts +237 -0
  31. package/src/__tests__/pairing-gate-e3.test.ts +194 -0
  32. package/src/__tests__/pairing-gate-e4.test.ts +110 -0
  33. package/src/__tests__/pairing-gate.test.ts +296 -0
  34. package/src/__tests__/pairing-relay-in-the-middle.test.ts +225 -0
  35. package/src/__tests__/rtc-channel-close.test.ts +52 -0
  36. package/src/__tests__/rtc-peer-candidates.test.ts +61 -0
  37. package/src/__tests__/session-attachment-app-view.test.ts +66 -0
  38. package/src/__tests__/session-attachment-budget.test.ts +136 -0
  39. package/src/__tests__/turn-server-bind.test.ts +59 -0
  40. package/src/__tests__/turn-server.test.ts +335 -0
  41. package/src/__tests__/turn-test-client.ts +256 -0
  42. package/src/__tests__/webrtc-implementation.test.ts +83 -0
  43. package/src/__tests__/webrtc-transport.test.ts +349 -0
  44. package/src/__tests__/ws-mesh-relay-client.test.ts +95 -0
  45. package/src/__tests__/ws-signaling-client.test.ts +108 -0
  46. package/src/admission-steps.ts +55 -0
  47. package/src/channel-delivery.ts +44 -0
  48. package/src/datachannel-loader.ts +86 -0
  49. package/src/device-mesh-node.ts +1019 -0
  50. package/src/discovering-mesh-relay.ts +675 -0
  51. package/src/enrollment-link.ts +465 -0
  52. package/src/handoff-grant-gate.ts +186 -0
  53. package/src/index.ts +139 -0
  54. package/src/local-peer-proof.ts +135 -0
  55. package/src/mainline-dht-store.ts +174 -0
  56. package/src/mesh-dht.ts +446 -0
  57. package/src/mesh-discovery.ts +87 -0
  58. package/src/mesh-item-store.ts +287 -0
  59. package/src/mesh-lan-listener.ts +193 -0
  60. package/src/mesh-mdns.ts +406 -0
  61. package/src/mesh-peer-link.ts +600 -0
  62. package/src/mesh-public-relays.ts +25 -0
  63. package/src/mesh-records.ts +357 -0
  64. package/src/mesh-relay.ts +98 -0
  65. package/src/mesh-signal.ts +116 -0
  66. package/src/mesh-turn-relay.ts +238 -0
  67. package/src/negotiated-certificate.ts +72 -0
  68. package/src/nostr-mesh-relay.ts +391 -0
  69. package/src/pairing-channel-lifecycle.ts +31 -0
  70. package/src/pairing-controllers.ts +81 -0
  71. package/src/pairing-frames.ts +34 -0
  72. package/src/pairing-gate-options.ts +133 -0
  73. package/src/pairing-gate.ts +397 -0
  74. package/src/rtc-peer.ts +429 -0
  75. package/src/session-attachment.ts +96 -0
  76. package/src/signaling.ts +62 -0
  77. package/src/stun-message.ts +341 -0
  78. package/src/transport-lifecycle-error.ts +11 -0
  79. package/src/turn-server.ts +1028 -0
  80. package/src/webrtc-delivery-lifecycle.ts +54 -0
  81. package/src/webrtc-transport-options.ts +87 -0
  82. package/src/webrtc-transport.ts +368 -0
  83. package/src/ws-mesh-relay-client.ts +117 -0
  84. package/src/ws-signaling-client.ts +139 -0
@@ -0,0 +1,942 @@
1
+ import { createIdentityContext } from '@robota-sdk/agent-remote-pairing';
2
+ const testIdentity = createIdentityContext('test-product');
3
+ /**
4
+ * Beyond the local network: rendezvous records on public item stores (the Mainline DHT, pkarr
5
+ * relays) and live signaling over public Nostr relays — all through in-process fakes; nothing here
6
+ * reaches the public network.
7
+ */
8
+ import {
9
+ RENDEZVOUS_EPOCH_MS,
10
+ derivePairRendezvous,
11
+ issueDeviceRevocationList,
12
+ rendezvousEpoch,
13
+ startDeviceHandshake,
14
+ } from '@robota-sdk/agent-remote-pairing';
15
+ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
16
+
17
+ import { DeviceMeshNode, type IDeviceMeshLink } from '../device-mesh-node.js';
18
+ import { DiscoveringMeshRelay } from '../discovering-mesh-relay.js';
19
+ import { startMainlineDhtStore, type IMainlineDht } from '../mainline-dht-store.js';
20
+ import { MeshDht } from '../mesh-dht.js';
21
+ import {
22
+ createInMemoryItemNetwork,
23
+ createPkarrRelayStore,
24
+ decodePkarrPacket,
25
+ encodePkarrPacket,
26
+ type IInMemoryItemNetwork,
27
+ type IRendezvousItemStore,
28
+ } from '../mesh-item-store.js';
29
+ import { startMeshLanListener } from '../mesh-lan-listener.js';
30
+ import { MeshPeerLink } from '../mesh-peer-link.js';
31
+ import {
32
+ HINTS_PADDED_BYTES,
33
+ bep44SignedBytes,
34
+ ed25519FromSeed,
35
+ itemAddress,
36
+ publishJitter,
37
+ signMutableItem,
38
+ } from '../mesh-records.js';
39
+ import { createInMemoryMeshRelayHub, type IMeshRelay } from '../mesh-relay.js';
40
+ import {
41
+ NostrMeshRelay,
42
+ createInMemoryNostrHub,
43
+ type IInMemoryNostrHub,
44
+ type INostrEvent,
45
+ type INostrRelayPool,
46
+ } from '../nostr-mesh-relay.js';
47
+ import { RtcPeer } from '../rtc-peer.js';
48
+ import {
49
+ ALL_CAPABILITIES,
50
+ buildMeshWorld,
51
+ type IMeshTestDevice,
52
+ type IMeshTestWorld,
53
+ } from './mesh-fixtures.js';
54
+
55
+ import type { IMeshPeerRoute } from '../mesh-discovery.js';
56
+
57
+ const NOW = Date.now();
58
+ const EPOCH = rendezvousEpoch(NOW);
59
+ const LOCAL = '127.0.0.1';
60
+ /** TEST-NET-1 (RFC 5737): an address on another network that never answers. */
61
+ const UNREACHABLE = '192.0.2.1';
62
+
63
+ let world: IMeshTestWorld;
64
+ let stranger: IMeshTestWorld;
65
+ const cleanups: (() => unknown)[] = [];
66
+
67
+ beforeAll(async () => {
68
+ world = await buildMeshWorld(NOW);
69
+ stranger = await buildMeshWorld(NOW);
70
+ });
71
+
72
+ afterEach(async () => {
73
+ vi.useRealTimers();
74
+ for (const cleanup of cleanups.splice(0).reverse()) await cleanup();
75
+ });
76
+
77
+ async function routeOf(
78
+ own: IMeshTestDevice,
79
+ peer: IMeshTestDevice,
80
+ of: IMeshTestWorld = world,
81
+ ): Promise<IMeshPeerRoute> {
82
+ const rendezvous = await derivePairRendezvous(testIdentity, {
83
+ ownKaPrivateKey: own.ka.privateKey,
84
+ own: own.cert,
85
+ peerDeviceId: peer.cert.deviceId,
86
+ lists: of.identity(own),
87
+ });
88
+ return { deviceId: peer.cert.deviceId, ...(await rendezvous.relayInbox()), rendezvous };
89
+ }
90
+
91
+ const hex = (bytes: Uint8Array): string => Buffer.from(bytes).toString('hex');
92
+ const never = (): AbortSignal => new AbortController().signal;
93
+
94
+ function dhtOn(
95
+ network: IInMemoryItemNetwork,
96
+ options: Partial<ConstructorParameters<typeof MeshDht>[0]> = {},
97
+ ): MeshDht {
98
+ const dht = new MeshDht({
99
+ stores: [network.store()],
100
+ addresses: () => [LOCAL],
101
+ maxPublishJitterMs: 0,
102
+ ...options,
103
+ });
104
+ cleanups.push(() => dht.close());
105
+ return dht;
106
+ }
107
+
108
+ /**
109
+ * Every string that would tell whose a record is, as it would appear in plaintext. Short device
110
+ * names are left out: random ciphertext contains them by chance.
111
+ */
112
+ function identifying(): string[] {
113
+ const out = ['agent', 'ConversationAgent'];
114
+ for (const device of [world.low, world.high, world.third]) {
115
+ out.push(device.cert.deviceId, device.cert.userId);
116
+ }
117
+ return out;
118
+ }
119
+
120
+ describe('rendezvous records', () => {
121
+ it('are opaque, fixed-size, and differ per direction and per epoch', async () => {
122
+ const network = createInMemoryItemNetwork();
123
+ let now = NOW;
124
+ const lowDht = dhtOn(network, {
125
+ now: () => now,
126
+ lists: () => ({ revocation: world.identity(world.low).revocation }),
127
+ });
128
+ const highDht = dhtOn(network, { now: () => now });
129
+ const lowToHigh = await routeOf(world.low, world.high);
130
+ const highToLow = await routeOf(world.high, world.low);
131
+
132
+ await lowDht.advertise([lowToHigh], 4242);
133
+ await highDht.advertise([highToLow], 4343);
134
+ await vi.waitFor(() => expect(network.items()).toHaveLength(3));
135
+ now += RENDEZVOUS_EPOCH_MS;
136
+ await lowDht.advertise([lowToHigh], 4242);
137
+ await vi.waitFor(() => expect(network.items()).toHaveLength(5));
138
+
139
+ const items = network.items();
140
+ // No key or salt repeats: each direction and each epoch has its own.
141
+ expect(new Set(items.map((i) => hex(i.k))).size).toBe(items.length);
142
+ expect(new Set(items.map((i) => hex(i.salt!))).size).toBe(items.length);
143
+ // Every field as the bytes it carries, where a plaintext value would read as itself.
144
+ const plain = items
145
+ .map((i) => [i.k, i.salt!, i.v].map((b) => Buffer.from(b).toString('latin1')).join('\n'))
146
+ .join('\n');
147
+ // And the key and salt as hex too, where an id or a topic written as hex would show.
148
+ const wire = `${plain}\n${items.map((i) => `${hex(i.k)}\n${hex(i.salt!)}`).join('\n')}`;
149
+ for (const value of identifying()) expect(wire).not.toContain(value);
150
+ for (const route of [lowToHigh, highToLow]) {
151
+ expect(wire).not.toContain(route.inbound);
152
+ expect(wire).not.toContain(route.outbound);
153
+ }
154
+ expect(plain).not.toContain(LOCAL);
155
+ // The port as the hints encode it. Only the bytes are searched: four digits turn up in random
156
+ // hex by chance, and a port written into a field shows in its bytes.
157
+ expect(plain).not.toContain('4242');
158
+ // Hints are one size whatever they hold.
159
+ const hintSizes = new Set(items.filter((i) => i.v.length < 700).map((i) => i.v.length));
160
+ expect([...hintSizes]).toEqual([HINTS_PADDED_BYTES + 28]);
161
+ });
162
+
163
+ it('are found by the peer at this epoch and the adjacent ones, and not by anyone else', async () => {
164
+ const network = createInMemoryItemNetwork();
165
+ const highDht = dhtOn(network, { now: () => NOW });
166
+ await highDht.advertise([await routeOf(world.high, world.low)], 4343);
167
+ await vi.waitFor(() => expect(network.items().length).toBeGreaterThan(0));
168
+ const toHigh = await routeOf(world.low, world.high);
169
+
170
+ for (const shift of [-1, 0, 1]) {
171
+ const lowDht = dhtOn(network, { now: () => NOW + shift * RENDEZVOUS_EPOCH_MS });
172
+ await expect(lowDht.candidates(toHigh, never())).resolves.toEqual([
173
+ { host: LOCAL, port: 4343 },
174
+ ]);
175
+ }
176
+ const later = dhtOn(network, { now: () => NOW + 2 * RENDEZVOUS_EPOCH_MS });
177
+ await expect(later.candidates(toHigh, never())).resolves.toEqual([]);
178
+ // The other direction, and another pair, find nothing.
179
+ const highItself = dhtOn(network, { now: () => NOW });
180
+ await expect(
181
+ highItself.candidates(await routeOf(world.high, world.low), never()),
182
+ ).resolves.toEqual([]);
183
+ const third = dhtOn(network, { now: () => NOW });
184
+ await expect(
185
+ third.candidates(await routeOf(world.third, world.high), never()),
186
+ ).resolves.toEqual([]);
187
+ });
188
+
189
+ it("carry the device's relay endpoints to each paired device, and to no one else", async () => {
190
+ const network = createInMemoryItemNetwork();
191
+ const relayEndpoint = { host: '198.51.100.7', port: 3478 };
192
+ const highDht = dhtOn(network, { now: () => NOW, relayEndpoints: () => [relayEndpoint] });
193
+ await highDht.advertise([await routeOf(world.high, world.low)], 4343);
194
+ await vi.waitFor(() => expect(network.items().length).toBeGreaterThan(0));
195
+ // Hints are one size whether or not they carry a relay.
196
+ expect(new Set(network.items().map((i) => i.v.length))).toEqual(
197
+ new Set([HINTS_PADDED_BYTES + 28]),
198
+ );
199
+
200
+ const lowDht = dhtOn(network, { now: () => NOW });
201
+ const found = await lowDht.relayAdverts([await routeOf(world.low, world.high)], never());
202
+ expect([...found.entries()]).toEqual([[world.high.cert.deviceId, [relayEndpoint]]]);
203
+ // The relay's address is not an address of its signaling endpoint.
204
+ await expect(lowDht.candidates(await routeOf(world.low, world.high), never())).resolves.toEqual(
205
+ [{ host: LOCAL, port: 4343 }],
206
+ );
207
+
208
+ // A device high published nothing for learns of no relay.
209
+ const thirdDht = dhtOn(network, { now: () => NOW });
210
+ expect(
211
+ (await thirdDht.relayAdverts([await routeOf(world.third, world.high)], never())).size,
212
+ ).toBe(0);
213
+ });
214
+
215
+ it('a relay lookup is not repeated on every attempt: found or not, the answer is kept a while', async () => {
216
+ const network = createInMemoryItemNetwork();
217
+ const inner = network.store();
218
+ let reads = 0;
219
+ const counting: IRendezvousItemStore = {
220
+ put: (...args) => inner.put(...args),
221
+ get: (...args) => {
222
+ reads += 1;
223
+ return inner.get(...args);
224
+ },
225
+ close: () => inner.close(),
226
+ };
227
+ let now = NOW;
228
+ const lowDht = dhtOn(network, { now: () => now, stores: [counting] });
229
+ const toHigh = [await routeOf(world.low, world.high)];
230
+
231
+ expect((await lowDht.relayAdverts(toHigh, never())).size).toBe(0);
232
+ const afterFirst = reads;
233
+ expect(afterFirst).toBeGreaterThan(0);
234
+ expect((await lowDht.relayAdverts(toHigh, never())).size).toBe(0);
235
+ expect(reads).toBe(afterFirst);
236
+
237
+ // "No relay" is checked again after a while: the peer may have started one since.
238
+ const highDht = dhtOn(network, {
239
+ now: () => now,
240
+ relayEndpoints: () => [{ host: '198.51.100.7', port: 3478 }],
241
+ });
242
+ await highDht.advertise([await routeOf(world.high, world.low)], 4343);
243
+ await vi.waitFor(() => expect(network.items().length).toBeGreaterThan(0));
244
+ now += 3 * 60 * 1000;
245
+ expect((await lowDht.relayAdverts(toHigh, never())).size).toBe(1);
246
+ const afterFound = reads;
247
+ expect((await lowDht.relayAdverts(toHigh, never())).size).toBe(1);
248
+ expect(reads).toBe(afterFound);
249
+ });
250
+
251
+ it('a tampered record, one signed by another key, or another pair’s record is rejected', async () => {
252
+ const network = createInMemoryItemNetwork();
253
+ const highDht = dhtOn(network, { now: () => NOW });
254
+ const thirdDht = dhtOn(network, { now: () => NOW });
255
+ await highDht.advertise([await routeOf(world.high, world.low)], 4343);
256
+ await thirdDht.advertise([await routeOf(world.third, world.low)], 4444);
257
+ await vi.waitFor(() => expect(network.items()).toHaveLength(2));
258
+ const toHigh = await routeOf(world.low, world.high);
259
+ const lowDht = dhtOn(network, { now: () => NOW });
260
+ const address = await itemAddress(toHigh.rendezvous, 'hints', 'inbound', EPOCH);
261
+ const genuine = network.items().find((i) => hex(i.k) === hex(address.key.publicKey))!;
262
+ const thirds = network.items().find((i) => i !== genuine)!;
263
+
264
+ // A byte of the value flipped by a node.
265
+ network.serve((item) => {
266
+ const v = new Uint8Array(item.v);
267
+ v[v.length - 1]! ^= 1;
268
+ return { ...item, v };
269
+ });
270
+ await expect(lowDht.candidates(toHigh, never())).resolves.toEqual([]);
271
+
272
+ // A node that re-signs a value under its own key.
273
+ const foreign = await ed25519FromSeed(new Uint8Array(32).fill(7));
274
+ const forged = await signMutableItem(foreign, genuine.salt, genuine.seq, genuine.v);
275
+ network.serve(() => forged);
276
+ await expect(lowDht.candidates(toHigh, never())).resolves.toEqual([]);
277
+
278
+ // Another pair's valid record served in its place.
279
+ network.serve(() => thirds);
280
+ await expect(lowDht.candidates(toHigh, never())).resolves.toEqual([]);
281
+
282
+ network.serve((item) => item);
283
+ await expect(lowDht.candidates(toHigh, never())).resolves.toEqual([
284
+ { host: LOCAL, port: 4343 },
285
+ ]);
286
+ });
287
+
288
+ it('publish times are jittered per pair, within the bound', async () => {
289
+ const realSleep = setTimeout;
290
+ const settle = (): Promise<void> => new Promise((resolve) => realSleep(resolve, 50));
291
+ vi.useFakeTimers({ now: NOW });
292
+ const network = createInMemoryItemNetwork();
293
+ const store = network.store();
294
+ const at: number[] = [];
295
+ const spy: IRendezvousItemStore = {
296
+ put: (address, value, now) => {
297
+ at.push(Date.now() - NOW);
298
+ return store.put(address, value, now);
299
+ },
300
+ get: store.get,
301
+ close: () => undefined,
302
+ };
303
+ const draws = [0.25, 0.9];
304
+ const dht = new MeshDht({
305
+ stores: [spy],
306
+ addresses: () => [LOCAL],
307
+ maxPublishJitterMs: 10_000,
308
+ random: () => draws.shift() ?? 0,
309
+ });
310
+ cleanups.push(() => dht.close());
311
+ await dht.advertise(
312
+ [await routeOf(world.low, world.high), await routeOf(world.low, world.third)],
313
+ 4242,
314
+ );
315
+ await vi.advanceTimersByTimeAsync(2_499);
316
+ await settle();
317
+ expect(at).toEqual([]);
318
+ await vi.advanceTimersByTimeAsync(1);
319
+ await settle();
320
+ expect(at).toEqual([2_500]);
321
+ await vi.advanceTimersByTimeAsync(6_499);
322
+ await settle();
323
+ expect(at).toEqual([2_500]);
324
+ await vi.advanceTimersByTimeAsync(1);
325
+ await settle();
326
+ expect(at).toEqual([2_500, 9_000]);
327
+
328
+ expect(publishJitter(10_000, () => 0)).toBe(0);
329
+ expect(publishJitter(10_000, () => 0.9999)).toBeLessThanOrEqual(10_000);
330
+ expect(publishJitter(10_000, () => 5)).toBe(10_000);
331
+ expect(publishJitter(10_000, () => -1)).toBe(0);
332
+ expect(publishJitter(10_000, () => Number.NaN)).toBe(0);
333
+ });
334
+
335
+ it('hand on the newest device lists a peer published, for the freshness lookup', async () => {
336
+ const network = createInMemoryItemNetwork();
337
+ const newer = await world.revoking(world.third);
338
+ const highDht = dhtOn(network, {
339
+ now: () => NOW,
340
+ lists: () => ({ revocation: newer }),
341
+ });
342
+ await highDht.advertise([await routeOf(world.high, world.low)], 4343);
343
+ await vi.waitFor(() => expect(network.items()).toHaveLength(2));
344
+ const lowDht = dhtOn(network, { now: () => NOW });
345
+ await lowDht.advertise([await routeOf(world.low, world.high)], 4242);
346
+
347
+ await expect(lowDht.latestLists(never())).resolves.toEqual({
348
+ revocation: [JSON.parse(JSON.stringify(newer))],
349
+ });
350
+ });
351
+
352
+ it('hand on every list candidate newest first, and a list too large for one record in chunks', async () => {
353
+ const network = createInMemoryItemNetwork();
354
+ const many = await issueDeviceRevocationList(testIdentity, {
355
+ signingKey: world.signingKey,
356
+ seq: 12,
357
+ issuedAt: NOW,
358
+ revokedDeviceIds: Array.from({ length: 40 }, (_, i) =>
359
+ Buffer.alloc(32, i + 1).toString('base64url'),
360
+ ),
361
+ });
362
+ const forged = { ...many, seq: Number.MAX_SAFE_INTEGER, revokedDeviceIds: [] };
363
+ const highDht = dhtOn(network, { now: () => NOW, lists: () => ({ revocation: many }) });
364
+ const thirdDht = dhtOn(network, { now: () => NOW, lists: () => ({ revocation: forged }) });
365
+ await highDht.advertise([await routeOf(world.high, world.low)], 4343);
366
+ await thirdDht.advertise([await routeOf(world.third, world.low)], 4444);
367
+ // Hints and one list chunk from third; hints and several list chunks from high.
368
+ await vi.waitFor(() => expect(network.items().length).toBeGreaterThan(5));
369
+ const lowDht = dhtOn(network, { now: () => NOW });
370
+ await lowDht.advertise(
371
+ [await routeOf(world.low, world.high), await routeOf(world.low, world.third)],
372
+ 4242,
373
+ );
374
+
375
+ const found = await lowDht.latestLists(never());
376
+ expect(found?.revocation).toEqual([
377
+ JSON.parse(JSON.stringify(forged)),
378
+ JSON.parse(JSON.stringify(many)),
379
+ ]);
380
+ // Every chunk is one fixed size.
381
+ expect(
382
+ new Set(
383
+ network
384
+ .items()
385
+ .filter((i) => i.v.length > 700)
386
+ .map((i) => i.v.length),
387
+ ).size,
388
+ ).toBe(1);
389
+ });
390
+
391
+ it('a paired device that publishes many lists cannot crowd out the one another device published', async () => {
392
+ const networks = Array.from({ length: 6 }, () => createInMemoryItemNetwork());
393
+ const genuine = await world.revoking(world.third);
394
+ const toLowFromHigh = await routeOf(world.high, world.low);
395
+ const toLowFromThird = await routeOf(world.third, world.low);
396
+ const highDht = new MeshDht({
397
+ stores: networks.map((n) => n.store()),
398
+ addresses: () => [LOCAL],
399
+ maxPublishJitterMs: 0,
400
+ now: () => NOW,
401
+ lists: () => ({ revocation: genuine }),
402
+ });
403
+ cleanups.push(() => highDht.close());
404
+ await highDht.advertise([toLowFromHigh], 4343);
405
+ // `third` publishes a different list claiming a higher seq to every store, in every epoch read.
406
+ let forged = 0;
407
+ for (const skew of [-1, 0, 1]) {
408
+ for (const network of networks) {
409
+ forged += 1;
410
+ const claim = { ...genuine, seq: Number.MAX_SAFE_INTEGER - forged };
411
+ const dht = dhtOn(network, {
412
+ now: () => NOW + skew * RENDEZVOUS_EPOCH_MS,
413
+ lists: () => ({ revocation: claim }),
414
+ });
415
+ await dht.advertise([toLowFromThird], 4444);
416
+ }
417
+ }
418
+ // Hints and one list chunk per store from high, and per store and epoch from third.
419
+ await vi.waitFor(() =>
420
+ expect(networks.reduce((n, net) => n + net.items().length, 0)).toBe(12 + 36),
421
+ );
422
+ const lowDht = new MeshDht({
423
+ stores: networks.map((n) => n.store()),
424
+ addresses: () => [LOCAL],
425
+ maxPublishJitterMs: 0,
426
+ now: () => NOW,
427
+ });
428
+ cleanups.push(() => lowDht.close());
429
+ await lowDht.advertise(
430
+ [await routeOf(world.low, world.high), await routeOf(world.low, world.third)],
431
+ 4242,
432
+ );
433
+
434
+ const found = await lowDht.latestLists(never());
435
+ expect(found?.revocation).toContainEqual(JSON.parse(JSON.stringify(genuine)));
436
+ });
437
+
438
+ it('a read that finds the chunks of two versions of a list yields no list', async () => {
439
+ const ids = (fill: number): string[] =>
440
+ Array.from({ length: 40 }, (_, i) => Buffer.alloc(32, fill + i).toString('base64url'));
441
+ const older = await issueDeviceRevocationList(testIdentity, {
442
+ signingKey: world.signingKey,
443
+ seq: 12,
444
+ issuedAt: NOW,
445
+ revokedDeviceIds: ids(1),
446
+ });
447
+ const newer = await issueDeviceRevocationList(testIdentity, {
448
+ signingKey: world.signingKey,
449
+ seq: 13,
450
+ issuedAt: NOW,
451
+ revokedDeviceIds: ids(101),
452
+ });
453
+ const toLow = await routeOf(world.high, world.low);
454
+ const before = createInMemoryItemNetwork();
455
+ const after = createInMemoryItemNetwork();
456
+ await dhtOn(before, { now: () => NOW, lists: () => ({ revocation: older }) }).advertise(
457
+ [toLow],
458
+ 4343,
459
+ );
460
+ await dhtOn(after, { now: () => NOW, lists: () => ({ revocation: newer }) }).advertise(
461
+ [toLow],
462
+ 4343,
463
+ );
464
+ await vi.waitFor(() => {
465
+ expect(before.items().length).toBeGreaterThan(2);
466
+ expect(after.items()).toHaveLength(before.items().length);
467
+ });
468
+ // The first chunk is already the newer version's; the others are still the older one's.
469
+ const first = await itemAddress(toLow.rendezvous, 'revocation', 'outbound', EPOCH, 0);
470
+ const firstKey = hex(first.key.publicKey);
471
+ before.serve((item) =>
472
+ hex(item.k) === firstKey ? after.items().find((i) => hex(i.k) === firstKey) : item,
473
+ );
474
+ const lowDht = dhtOn(before, { now: () => NOW });
475
+ await lowDht.advertise([await routeOf(world.low, world.high)], 4242);
476
+
477
+ await expect(lowDht.latestLists(never())).resolves.toBeUndefined();
478
+ });
479
+ });
480
+
481
+ describe('pkarr relays', () => {
482
+ it('carry the salt-less form of a record, and a relay cannot forge or tamper with one', async () => {
483
+ const held = new Map<string, Uint8Array>();
484
+ let tamper = false;
485
+ const relay = (async (input: string | URL | Request, init?: RequestInit) => {
486
+ const url = String(input);
487
+ if (init?.method === 'PUT') {
488
+ held.set(url, new Uint8Array(init.body as ArrayBuffer));
489
+ return new Response(null, { status: 204 });
490
+ }
491
+ const body = held.get(url);
492
+ if (body === undefined) return new Response(null, { status: 404 });
493
+ const served = new Uint8Array(body);
494
+ if (tamper) served[served.length - 1]! ^= 1;
495
+ return new Response(served, { status: 200 });
496
+ }) as typeof fetch;
497
+ const store = createPkarrRelayStore({
498
+ relays: ['https://one.example', 'https://two.example/'],
499
+ fetch: relay,
500
+ });
501
+ const route = await routeOf(world.low, world.high);
502
+ const address = await itemAddress(route.rendezvous, 'hints', 'outbound', EPOCH);
503
+ const value = new Uint8Array(668).fill(9);
504
+ await store.put(address, value, NOW);
505
+
506
+ expect(held.size).toBe(2);
507
+ const body = [...held.values()][0]!;
508
+ const packet = body.subarray(72);
509
+ const seq = Number(new DataView(body.buffer, 64, 8).getBigUint64(0));
510
+ expect(seq).toBe(NOW * 1000);
511
+ expect(body.length).toBeLessThanOrEqual(64 + 8 + 1000);
512
+ expect(decodePkarrPacket(packet)).toEqual(value);
513
+ expect(await store.get(address.key.publicKey, address.salt, never())).toEqual(value);
514
+ // The signature covers exactly the salt-less item.
515
+ expect(hex(await address.key.sign(bep44SignedBytes(undefined, seq, packet)))).toBe(
516
+ hex(body.subarray(0, 64)),
517
+ );
518
+
519
+ tamper = true;
520
+ await expect(store.get(address.key.publicKey, address.salt, never())).resolves.toBeUndefined();
521
+ const other = await ed25519FromSeed(new Uint8Array(32).fill(3));
522
+ tamper = false;
523
+ await expect(store.get(other.publicKey, address.salt, never())).resolves.toBeUndefined();
524
+ expect(decodePkarrPacket(encodePkarrPacket(other.publicKey, new Uint8Array(0)))).toEqual(
525
+ new Uint8Array(0),
526
+ );
527
+ });
528
+ });
529
+
530
+ describe('the Mainline DHT store', () => {
531
+ it('puts a salted BEP 44 item and rejects what a node tampered with', async () => {
532
+ let stored: { k: Buffer; salt: Buffer; seq: number; v: Buffer; sig: Buffer } | undefined;
533
+ let tamper = false;
534
+ const fake: IMainlineDht = {
535
+ put: (item, callback) => {
536
+ stored = item;
537
+ callback(null, Buffer.alloc(20), 8);
538
+ },
539
+ get: (_target, _options, callback) => {
540
+ if (stored === undefined) return callback(null, null);
541
+ const v = Buffer.from(stored.v);
542
+ if (tamper) v[0]! ^= 1;
543
+ callback(null, { ...stored, v });
544
+ },
545
+ on: () => undefined,
546
+ destroy: () => undefined,
547
+ };
548
+ const store = await startMainlineDhtStore({ createDht: () => fake });
549
+ const route = await routeOf(world.low, world.high);
550
+ const address = await itemAddress(route.rendezvous, 'hints', 'outbound', EPOCH);
551
+ const value = new Uint8Array(100).fill(5);
552
+ await store.put(address, value, NOW);
553
+ expect(hex(stored!.salt)).toBe(hex(address.salt));
554
+ expect(stored!.seq).toBe(Math.floor(NOW / 1000));
555
+ await expect(store.get(address.key.publicKey, address.salt, never())).resolves.toEqual(
556
+ new Uint8Array(value),
557
+ );
558
+ tamper = true;
559
+ await expect(store.get(address.key.publicKey, address.salt, never())).resolves.toBeUndefined();
560
+ store.close();
561
+ });
562
+ });
563
+
564
+ describe('Nostr signaling', () => {
565
+ function nostrOn(hub: IInMemoryNostrHub, now: () => number = () => Date.now()): NostrMeshRelay {
566
+ const relay = new NostrMeshRelay({ pool: hub.pool(), now });
567
+ cleanups.push(() => relay.close());
568
+ return relay;
569
+ }
570
+
571
+ it('events carry only ciphertext under keys and kinds that rotate per direction and epoch', async () => {
572
+ const hub = createInMemoryNostrHub();
573
+ let now = NOW;
574
+ const low = nostrOn(hub, () => now);
575
+ const high = nostrOn(hub, () => now);
576
+ const toHigh = await routeOf(world.low, world.high);
577
+ const toLow = await routeOf(world.high, world.low);
578
+ low.declarePeers([toHigh]);
579
+ high.declarePeers([toLow]);
580
+ await high.whenIdle();
581
+ const received: [string, unknown][] = [];
582
+ high.onMessage((topic, data) => received.push([topic, data]));
583
+ const signal = { v: 1, kind: 'hello', from: 'SECRETINSTANCEIDxxxxxx' };
584
+
585
+ low.send(toHigh.outbound, signal);
586
+ await vi.waitFor(() => expect(received).toEqual([[toLow.inbound, signal]]));
587
+ high.send(toLow.outbound, signal);
588
+ await vi.waitFor(() => expect(hub.events).toHaveLength(2));
589
+ now += RENDEZVOUS_EPOCH_MS;
590
+ low.declarePeers([toHigh]);
591
+ high.declarePeers([toLow]);
592
+ await high.whenIdle();
593
+ low.send(toHigh.outbound, signal);
594
+ await vi.waitFor(() => expect(received).toHaveLength(2));
595
+
596
+ const events = hub.events;
597
+ expect(new Set(events.map((e) => e.pubkey)).size).toBe(3);
598
+ for (const event of events) {
599
+ expect(event.kind).toBeGreaterThanOrEqual(20_000);
600
+ expect(event.kind).toBeLessThan(30_000);
601
+ expect(event.tags).toEqual([]);
602
+ const plain = Buffer.from(event.content, 'base64').toString('latin1');
603
+ expect(plain).not.toContain('SECRETINSTANCEID');
604
+ expect(plain).not.toContain('hello');
605
+ }
606
+ const wire = JSON.stringify(events);
607
+ for (const value of identifying()) expect(wire).not.toContain(value);
608
+ for (const topic of [toHigh.inbound, toHigh.outbound]) expect(wire).not.toContain(topic);
609
+ });
610
+
611
+ it('drops forged, tampered, replayed, stale and duplicate events', async () => {
612
+ const hub = createInMemoryNostrHub();
613
+ const low = nostrOn(hub);
614
+ const high = nostrOn(hub);
615
+ const toHigh = await routeOf(world.low, world.high);
616
+ low.declarePeers([toHigh]);
617
+ high.declarePeers([await routeOf(world.high, world.low)]);
618
+ await high.whenIdle();
619
+ const received: unknown[] = [];
620
+ high.onMessage((_topic, data) => received.push(data));
621
+ low.send(toHigh.outbound, { n: 1 });
622
+ await vi.waitFor(() => expect(received).toHaveLength(1));
623
+ const genuine = hub.events[0]!;
624
+
625
+ // The same event again (another relay, or a replay).
626
+ hub.inject(genuine);
627
+ // Content changed: the id and signature no longer match.
628
+ hub.inject({ ...genuine, content: Buffer.from('forged').toString('base64') });
629
+ // A stale event, re-signed by the pair's key: still too old.
630
+ const secretKey = await toHigh.rendezvous.signingSeed('outbound', EPOCH, 'signal');
631
+ const { finalizeEvent } = await import('nostr-tools/pure');
632
+ hub.inject(
633
+ finalizeEvent(
634
+ {
635
+ kind: genuine.kind,
636
+ created_at: genuine.created_at - 3_600,
637
+ tags: [],
638
+ content: genuine.content,
639
+ },
640
+ secretKey,
641
+ ),
642
+ );
643
+ // A valid event by a key that is not the pair's.
644
+ hub.inject(
645
+ finalizeEvent(
646
+ { kind: genuine.kind, created_at: genuine.created_at, tags: [], content: genuine.content },
647
+ new Uint8Array(32).fill(1),
648
+ ),
649
+ );
650
+ await new Promise((resolve) => setTimeout(resolve, 50));
651
+ expect(received).toEqual([{ n: 1 }]);
652
+ });
653
+ });
654
+
655
+ // ── End to end, two devices, through in-process fakes ──────────────────────────────────────────
656
+
657
+ interface IInternetDevice {
658
+ readonly node: DeviceMeshNode;
659
+ readonly relay: DiscoveringMeshRelay;
660
+ readonly dht: MeshDht;
661
+ readonly sent: string[];
662
+ }
663
+
664
+ async function internetDevice(
665
+ device: IMeshTestDevice,
666
+ options: {
667
+ readonly network: IInMemoryItemNetwork;
668
+ readonly nostr?: INostrRelayPool;
669
+ readonly relay?: IMeshRelay;
670
+ /** What this device publishes as its addresses. */
671
+ readonly addresses: readonly string[];
672
+ readonly dht?: Partial<ConstructorParameters<typeof MeshDht>[0]>;
673
+ readonly freshness?: boolean;
674
+ readonly identity?: ReturnType<IMeshTestWorld['identity']>;
675
+ readonly admissionTimeoutMs?: number;
676
+ },
677
+ ): Promise<IInternetDevice> {
678
+ const listener = await startMeshLanListener({ host: LOCAL });
679
+ const dht = new MeshDht({
680
+ stores: [options.network.store()],
681
+ addresses: () => options.addresses,
682
+ maxPublishJitterMs: 0,
683
+ ...options.dht,
684
+ });
685
+ const nostr =
686
+ options.nostr !== undefined ? new NostrMeshRelay({ pool: options.nostr }) : undefined;
687
+ const sent: string[] = [];
688
+ const relay = new DiscoveringMeshRelay({
689
+ ...(options.relay !== undefined ? { relay: options.relay } : {}),
690
+ ...(nostr !== undefined ? { signaling: [nostr] } : {}),
691
+ listener,
692
+ sources: [dht],
693
+ advertisers: [dht],
694
+ probeTimeoutMs: 500,
695
+ ...(options.admissionTimeoutMs !== undefined
696
+ ? { admissionTimeoutMs: options.admissionTimeoutMs }
697
+ : {}),
698
+ });
699
+ const node = new DeviceMeshNode({cryptoContext: testIdentity,
700
+ identity: options.identity ?? world.identity(device),
701
+ sessionDescriptor: device.session,
702
+ localPolicy: ALL_CAPABILITIES,
703
+ relay,
704
+ connectTimeoutMs: 15_000,
705
+ ...(options.freshness === true
706
+ ? { fetchLatestLists: (signal) => dht.latestLists(signal) }
707
+ : {}),
708
+ });
709
+ cleanups.push(
710
+ () => node.stop(),
711
+ () => relay.close(),
712
+ () => nostr?.close(),
713
+ );
714
+ return { node, relay, dht, sent };
715
+ }
716
+
717
+ function nextMessage(link: IDeviceMeshLink): Promise<string> {
718
+ return new Promise((resolve) => {
719
+ const off = link.onMessage((body) => {
720
+ off();
721
+ resolve(body);
722
+ });
723
+ });
724
+ }
725
+
726
+ describe('two devices beyond the local network', () => {
727
+ it('find each other in DHT records and connect directly', async () => {
728
+ const network = createInMemoryItemNetwork();
729
+ const high = await internetDevice(world.high, { network, addresses: [LOCAL] });
730
+ await high.node.start();
731
+ await vi.waitFor(() => expect(network.items().length).toBeGreaterThan(0));
732
+ const low = await internetDevice(world.low, { network, addresses: [LOCAL] });
733
+ await low.node.start();
734
+
735
+ const [atLow, atHigh] = await Promise.all([
736
+ low.node.connect(world.high.cert.deviceId),
737
+ high.node.connect(world.low.cert.deviceId),
738
+ ]);
739
+ expect(atLow.admission.deviceId).toBe(world.high.cert.deviceId);
740
+ const received = nextMessage(atHigh);
741
+ atLow.send('found through the DHT');
742
+ await expect(received).resolves.toBe('found through the DHT');
743
+ }, 40_000);
744
+
745
+ it('on two networks whose addresses do not reach each other, Nostr carries the signaling', async () => {
746
+ const network = createInMemoryItemNetwork();
747
+ const hub = createInMemoryNostrHub();
748
+ const high = await internetDevice(world.high, {
749
+ network,
750
+ nostr: hub.pool(),
751
+ addresses: [UNREACHABLE],
752
+ });
753
+ const low = await internetDevice(world.low, {
754
+ network,
755
+ nostr: hub.pool(),
756
+ addresses: [UNREACHABLE],
757
+ });
758
+ await Promise.all([low.node.start(), high.node.start()]);
759
+
760
+ const [atLow, atHigh] = await Promise.all([
761
+ low.node.connect(world.high.cert.deviceId, 30_000),
762
+ high.node.connect(world.low.cert.deviceId, 30_000),
763
+ ]);
764
+ const received = nextMessage(atHigh);
765
+ atLow.send('signaled over Nostr');
766
+ await expect(received).resolves.toBe('signaled over Nostr');
767
+
768
+ // What the relays saw names no device and none of the pair's relay inbox topics.
769
+ const wire = JSON.stringify(hub.events);
770
+ expect(hub.events.length).toBeGreaterThan(2);
771
+ for (const value of identifying()) expect(wire).not.toContain(value);
772
+ const route = await routeOf(world.low, world.high);
773
+ expect(wire).not.toContain(route.inbound);
774
+ expect(wire).not.toContain(route.outbound);
775
+ }, 60_000);
776
+
777
+ it('Nostr relays that drop signals are set aside for the self-hosted relay', async () => {
778
+ const network = createInMemoryItemNetwork();
779
+ const silent: INostrRelayPool = {
780
+ publish: () => Promise.resolve(true),
781
+ subscribe: () => ({ close: () => undefined }),
782
+ close: () => undefined,
783
+ };
784
+ const hub = createInMemoryMeshRelayHub();
785
+ const options = {
786
+ network,
787
+ nostr: silent,
788
+ addresses: [UNREACHABLE],
789
+ admissionTimeoutMs: 1_500,
790
+ } as const;
791
+ const high = await internetDevice(world.high, { ...options, relay: hub.connect() });
792
+ const low = await internetDevice(world.low, { ...options, relay: hub.connect() });
793
+ await Promise.all([low.node.start(), high.node.start()]);
794
+
795
+ await expect
796
+ .poll(
797
+ async () => {
798
+ const [a] = await Promise.allSettled([
799
+ low.node.connect(world.high.cert.deviceId, 4_000),
800
+ high.node.connect(world.low.cert.deviceId, 4_000),
801
+ ]);
802
+ return a.status;
803
+ },
804
+ { timeout: 40_000, interval: 200 },
805
+ )
806
+ .toBe('fulfilled');
807
+ }, 60_000);
808
+
809
+ it('a discovery result grants nothing: a stranger holding the pair’s public keys is refused', async () => {
810
+ // The strongest discovery attacker: it has the pair's Nostr keys (as if a relay could compute
811
+ // them) and answers as the peer, with a device of another roster.
812
+ const network = createInMemoryItemNetwork();
813
+ const hub = createInMemoryNostrHub();
814
+ const low = await internetDevice(world.low, {
815
+ network,
816
+ nostr: hub.pool(),
817
+ addresses: [UNREACHABLE],
818
+ });
819
+ const attacker = new NostrMeshRelay({ pool: hub.pool() });
820
+ cleanups.push(() => attacker.close());
821
+ const asHigh = await routeOf(world.high, world.low);
822
+ attacker.declarePeers([asHigh]);
823
+ const instance = 'AAAAAAAAAAAAAAAAAAAAAA';
824
+ const impostor = stranger.high;
825
+ let rogue: MeshPeerLink | undefined;
826
+ attacker.onMessage((_topic, data) => {
827
+ const signal = data as { kind: string; from: string; cid: string; sdp?: string };
828
+ if (signal.kind === 'hello') {
829
+ attacker.send(asHigh.outbound, { v: 1, kind: 'hello', from: instance });
830
+ } else if (signal.kind === 'offer') {
831
+ rogue = new MeshPeerLink({
832
+ role: 'answerer',
833
+ createPeer: () => new RtcPeer(),
834
+ sendSignal: (s) =>
835
+ attacker.send(asHigh.outbound, {
836
+ v: 1,
837
+ from: instance,
838
+ to: signal.from,
839
+ cid: signal.cid,
840
+ ...s,
841
+ }),
842
+ startHandshake: (binding) =>
843
+ startDeviceHandshake(testIdentity, {
844
+ role: 'responder',
845
+ identity: stranger.identity(impostor),
846
+ sessionDescriptor: impostor.session,
847
+ localFingerprint: binding.localFingerprint,
848
+ remoteFingerprint: binding.remoteFingerprint,
849
+ locality: 'another-host',
850
+ localPolicy: ALL_CAPABILITIES,
851
+ send: binding.send,
852
+ }),
853
+ connectTimeoutMs: 15_000,
854
+ onAdmitted: () => undefined,
855
+ onEnded: () => undefined,
856
+ });
857
+ cleanups.push(() => rogue?.close());
858
+ rogue.onSignal({ kind: 'offer', sdp: signal.sdp! });
859
+ } else if (signal.kind === 'ice') {
860
+ rogue?.onSignal({ kind: 'ice', candidate: (data as { candidate: never }).candidate });
861
+ }
862
+ });
863
+ const admitted: IDeviceMeshLink[] = [];
864
+ const refusals: string[] = [];
865
+ low.node.onLink((link) => admitted.push(link));
866
+ low.node.onRefusal((refusal) => refusals.push(refusal.end));
867
+ await low.node.start();
868
+ const attempt = low.node.connect(world.high.cert.deviceId, 25_000).then(
869
+ () => 'admitted',
870
+ () => 'refused',
871
+ );
872
+
873
+ await expect.poll(() => refusals.length, { timeout: 30_000 }).toBeGreaterThan(0);
874
+ expect(rogue).toBeDefined();
875
+ await expect(attempt).resolves.toBe('refused');
876
+ expect(admitted).toEqual([]);
877
+ }, 60_000);
878
+
879
+ it('a revocation published by another device is seen before admission, and the revoked device is refused even when it publishes a forged newer list', async () => {
880
+ const network = createInMemoryItemNetwork();
881
+ const hub = createInMemoryNostrHub();
882
+ const revokingThird = await world.revoking(world.third);
883
+ // high holds the newer list and hands it on; low has not seen it.
884
+ const high = await internetDevice(world.high, {
885
+ network,
886
+ addresses: [UNREACHABLE],
887
+ identity: world.identity(world.high, { revocation: revokingThird }),
888
+ dht: { lists: () => ({ revocation: revokingThird }) },
889
+ });
890
+ await high.node.start();
891
+ await vi.waitFor(() =>
892
+ expect(network.items().some((i) => i.v.length > HINTS_PADDED_BYTES + 28)).toBe(true),
893
+ );
894
+ const low = await internetDevice(world.low, {
895
+ network,
896
+ nostr: hub.pool(),
897
+ addresses: [UNREACHABLE],
898
+ freshness: true,
899
+ });
900
+ // The device being revoked publishes a forged "newer" list to hide the real one.
901
+ const forged = { ...revokingThird, seq: Number.MAX_SAFE_INTEGER, revokedDeviceIds: [] };
902
+ const third = await internetDevice(world.third, {
903
+ network,
904
+ nostr: hub.pool(),
905
+ addresses: [UNREACHABLE],
906
+ dht: { lists: () => ({ revocation: forged }) },
907
+ });
908
+ const admitted: IDeviceMeshLink[] = [];
909
+ low.node.onLink((link) => admitted.push(link));
910
+ await Promise.all([low.node.start(), third.node.start()]);
911
+
912
+ const results = await Promise.allSettled([
913
+ low.node.connect(world.third.cert.deviceId, 25_000),
914
+ third.node.connect(world.low.cert.deviceId, 25_000),
915
+ ]);
916
+ expect(results.map((r) => r.status)).toEqual(['rejected', 'rejected']);
917
+ expect(admitted).toEqual([]);
918
+ // low adopted the newer list, and dropped the revoked device.
919
+ expect(low.node.roleFor(world.third.cert.deviceId)).toBeUndefined();
920
+ }, 60_000);
921
+
922
+ it('without the freshness lookup the same device would have been admitted', async () => {
923
+ const network = createInMemoryItemNetwork();
924
+ const hub = createInMemoryNostrHub();
925
+ const low = await internetDevice(world.low, {
926
+ network,
927
+ nostr: hub.pool(),
928
+ addresses: [UNREACHABLE],
929
+ });
930
+ const third = await internetDevice(world.third, {
931
+ network,
932
+ nostr: hub.pool(),
933
+ addresses: [UNREACHABLE],
934
+ });
935
+ await Promise.all([low.node.start(), third.node.start()]);
936
+ const [atLow] = await Promise.all([
937
+ low.node.connect(world.third.cert.deviceId, 25_000),
938
+ third.node.connect(world.low.cert.deviceId, 25_000),
939
+ ]);
940
+ expect(atLow.admission.deviceId).toBe(world.third.cert.deviceId);
941
+ }, 60_000);
942
+ });