Skip to content

Commit fe5495a

Browse files
sirtimidclaude
andcommitted
feat(ocap-kernel): bound relay hints in OCAP URLs and relay pool size
Cap relay hints embedded in OCAP URLs to 3 (MAX_URL_RELAY_HINTS) with priority selection (bootstrap first, then most recently seen). Bound the relay pool to 20 entries (MAX_KNOWN_RELAYS) with eviction of oldest non-bootstrap entries. This prevents unbounded URL growth as kernels exchange relay hints across the network. Closes #889 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8a1b6d0 commit fe5495a

5 files changed

Lines changed: 336 additions & 48 deletions

File tree

packages/ocap-kernel/src/remotes/kernel/remote-comms.test.ts

Lines changed: 156 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,31 @@ import {
88
initRemoteIdentity,
99
initRemoteComms,
1010
parseOcapURL,
11+
MAX_URL_RELAY_HINTS,
12+
MAX_KNOWN_RELAYS,
1113
} from './remote-comms.ts';
1214
import { createMockRemotesFactory } from '../../../test/remotes-mocks.ts';
1315
import { makeMapKernelDatabase } from '../../../test/storage.ts';
14-
import type { KernelStore } from '../../store/index.ts';
16+
import type { KernelStore, RelayEntry } from '../../store/index.ts';
1517
import { makeKernelStore } from '../../store/index.ts';
1618
import type { KRef, PlatformServices } from '../../types.ts';
1719
import { mnemonicToSeed } from '../../utils/bip39.ts';
1820
import type { RemoteMessageHandler } from '../types.ts';
1921

22+
/**
23+
* Build learned (non-bootstrap) relay entries from address strings.
24+
*
25+
* @param addrs - Relay multiaddr strings.
26+
* @param lastSeen - Epoch ms for lastSeen (default 100).
27+
* @returns Relay entries with isBootstrap: false.
28+
*/
29+
function makeLearnedRelayEntries(
30+
addrs: string[],
31+
lastSeen = 100,
32+
): RelayEntry[] {
33+
return addrs.map((addr) => ({ addr, lastSeen, isBootstrap: false }));
34+
}
35+
2036
describe('remote-comms', () => {
2137
let mockKernelStore: KernelStore;
2238
let mockPlatformServices: PlatformServices;
@@ -80,11 +96,11 @@ describe('remote-comms', () => {
8096
expect(remoteComms.getPeerId()).toBe(peerId);
8197

8298
const ocapURL = await remoteComms.issueOcapURL('ko1' as KRef);
83-
const { oid } = parseOcapURL(ocapURL);
84-
const knownRelays = mockKernelStore.getKnownRelays();
85-
expect(Array.isArray(knownRelays)).toBe(true);
86-
expect(knownRelays.length).toBeGreaterThan(0);
87-
expect(knownRelays).toStrictEqual(testRelays);
99+
const { oid, hints } = parseOcapURL(ocapURL);
100+
const knownRelayAddresses = mockKernelStore.getKnownRelayAddresses();
101+
expect(knownRelayAddresses).toStrictEqual(testRelays);
102+
// URL should embed the relays (within MAX_URL_RELAY_HINTS cap)
103+
expect(hints).toStrictEqual(testRelays);
88104
const referenceURL = `ocap:${oid}@${peerId},${testRelays.join(',')}`;
89105
expect(ocapURL).toBe(referenceURL);
90106

@@ -157,12 +173,12 @@ describe('remote-comms', () => {
157173
);
158174
});
159175

160-
it('uses getKnownRelays when options.relays is empty', async () => {
176+
it('uses stored relays when options.relays is empty', async () => {
161177
const storedRelays = [
162178
'/dns4/stored-relay1.example/tcp/443/wss/p2p/relay1',
163179
'/dns4/stored-relay2.example/tcp/443/wss/p2p/relay2',
164180
];
165-
mockKernelStore.setKnownRelays(storedRelays);
181+
mockKernelStore.setRelayEntries(makeLearnedRelayEntries(storedRelays));
166182
await initRemoteComms(
167183
mockKernelStore,
168184
mockPlatformServices,
@@ -339,20 +355,24 @@ describe('remote-comms', () => {
339355
mockRemoteMessageHandler,
340356
{ relays: testRelays },
341357
);
342-
expect(mockKernelStore.getKnownRelays()).toStrictEqual(testRelays);
358+
expect(mockKernelStore.getKnownRelayAddresses()).toStrictEqual(
359+
testRelays,
360+
);
343361
});
344362

345363
it('does not save relays to KV store when empty', async () => {
346364
const storedRelays = ['/dns4/stored-relay.example/tcp/443/wss/p2p/relay'];
347-
mockKernelStore.setKnownRelays(storedRelays);
365+
mockKernelStore.setRelayEntries(makeLearnedRelayEntries(storedRelays));
348366
await initRemoteComms(
349367
mockKernelStore,
350368
mockPlatformServices,
351369
mockRemoteMessageHandler,
352370
{}, // empty relays
353371
);
354372
// Should not overwrite existing relays
355-
expect(mockKernelStore.getKnownRelays()).toStrictEqual(storedRelays);
373+
expect(mockKernelStore.getKnownRelayAddresses()).toStrictEqual(
374+
storedRelays,
375+
);
356376
});
357377
});
358378

@@ -444,7 +464,7 @@ describe('remote-comms', () => {
444464
'/dns4/relay1.example/tcp/443/wss/p2p-circuit', // duplicate
445465
]);
446466

447-
const stored = mockKernelStore.getKnownRelays();
467+
const stored = mockKernelStore.getKnownRelayAddresses();
448468
expect(stored).toStrictEqual([
449469
'/dns4/relay1.example/tcp/443/wss/p2p-circuit',
450470
'/dns4/relay2.example/tcp/443/wss/p2p-circuit',
@@ -475,10 +495,132 @@ describe('remote-comms', () => {
475495

476496
identity.addKnownRelays([]);
477497

478-
const stored = mockKernelStore.getKnownRelays();
498+
const stored = mockKernelStore.getKnownRelayAddresses();
479499
expect(stored).toStrictEqual(initialRelays);
480500
});
481501

502+
it('issueOcapURL caps relay hints to MAX_URL_RELAY_HINTS', async () => {
503+
const relays = Array.from(
504+
{ length: MAX_URL_RELAY_HINTS + 3 },
505+
(_, i) => `/dns4/relay${i}.example/tcp/443/wss/p2p-circuit`,
506+
);
507+
const { identity } = await initRemoteIdentity(mockKernelStore, {
508+
relays,
509+
});
510+
511+
const ocapURL = await identity.issueOcapURL('ko1' as KRef);
512+
const { hints } = parseOcapURL(ocapURL);
513+
expect(hints).toHaveLength(MAX_URL_RELAY_HINTS);
514+
});
515+
516+
it('issueOcapURL prefers bootstrap relays over learned relays', async () => {
517+
const bootstrapRelays = [
518+
'/dns4/bootstrap1.example/tcp/443/wss/p2p-circuit',
519+
'/dns4/bootstrap2.example/tcp/443/wss/p2p-circuit',
520+
];
521+
const { identity } = await initRemoteIdentity(mockKernelStore, {
522+
relays: bootstrapRelays,
523+
});
524+
525+
// Add many learned relays (more recent lastSeen)
526+
const learnedRelays = Array.from(
527+
{ length: 5 },
528+
(_, i) => `/dns4/learned${i}.example/tcp/443/wss/p2p-circuit`,
529+
);
530+
identity.addKnownRelays(learnedRelays);
531+
532+
const ocapURL = await identity.issueOcapURL('ko1' as KRef);
533+
const { hints } = parseOcapURL(ocapURL);
534+
expect(hints).toHaveLength(MAX_URL_RELAY_HINTS);
535+
// Bootstrap relays should be included ahead of learned relays
536+
for (const bootstrap of bootstrapRelays) {
537+
expect(hints).toContain(bootstrap);
538+
}
539+
});
540+
541+
it('addKnownRelays enforces MAX_KNOWN_RELAYS pool cap', async () => {
542+
const { identity } = await initRemoteIdentity(mockKernelStore);
543+
544+
// Add more relays than the cap
545+
const relays = Array.from(
546+
{ length: MAX_KNOWN_RELAYS + 5 },
547+
(_, i) => `/dns4/relay${i}.example/tcp/443/wss/p2p-circuit`,
548+
);
549+
identity.addKnownRelays(relays);
550+
551+
const entries = mockKernelStore.getRelayEntries();
552+
expect(entries).toHaveLength(MAX_KNOWN_RELAYS);
553+
});
554+
555+
it('addKnownRelays evicts oldest non-bootstrap relays when pool is full', async () => {
556+
const bootstrapRelays = [
557+
'/dns4/bootstrap.example/tcp/443/wss/p2p-circuit',
558+
];
559+
const { identity } = await initRemoteIdentity(mockKernelStore, {
560+
relays: bootstrapRelays,
561+
});
562+
563+
// Fill pool to the cap
564+
const fillerRelays = Array.from(
565+
{ length: MAX_KNOWN_RELAYS },
566+
(_, i) => `/dns4/relay${i}.example/tcp/443/wss/p2p-circuit`,
567+
);
568+
identity.addKnownRelays(fillerRelays);
569+
570+
const entries = mockKernelStore.getRelayEntries();
571+
expect(entries).toHaveLength(MAX_KNOWN_RELAYS);
572+
// Bootstrap relay must survive eviction
573+
expect(entries.some((entry) => entry.addr === bootstrapRelays[0])).toBe(
574+
true,
575+
);
576+
});
577+
578+
it('addKnownRelays updates lastSeen on re-observed relays', async () => {
579+
const { identity } = await initRemoteIdentity(mockKernelStore);
580+
581+
identity.addKnownRelays(['/dns4/relay.example/tcp/443/wss/p2p-circuit']);
582+
const firstSeen = mockKernelStore.getRelayEntries()[0]?.lastSeen ?? 0;
583+
584+
// SES lockdown prevents mocking Date.now; use a real delay instead
585+
await new Promise((resolve) => {
586+
setTimeout(resolve, 50);
587+
});
588+
identity.addKnownRelays(['/dns4/relay.example/tcp/443/wss/p2p-circuit']);
589+
const secondSeen = mockKernelStore.getRelayEntries()[0]?.lastSeen ?? 0;
590+
591+
expect(secondSeen).toBeGreaterThan(firstSeen);
592+
});
593+
594+
it('init marks bootstrap relays and preserves learned relays', async () => {
595+
// Pre-seed a learned relay
596+
mockKernelStore.setRelayEntries([
597+
{
598+
addr: '/dns4/learned.example/tcp/443/wss/p2p-circuit',
599+
lastSeen: 100,
600+
isBootstrap: false,
601+
},
602+
]);
603+
604+
const bootstrapRelays = [
605+
'/dns4/bootstrap.example/tcp/443/wss/p2p-circuit',
606+
];
607+
await initRemoteIdentity(mockKernelStore, {
608+
relays: bootstrapRelays,
609+
});
610+
611+
const entries = mockKernelStore.getRelayEntries();
612+
expect(entries).toHaveLength(2);
613+
expect(
614+
entries.find((entry) => entry.addr === bootstrapRelays[0])?.isBootstrap,
615+
).toBe(true);
616+
expect(
617+
entries.find(
618+
(entry) =>
619+
entry.addr === '/dns4/learned.example/tcp/443/wss/p2p-circuit',
620+
)?.isBootstrap,
621+
).toBe(false);
622+
});
623+
482624
it('throws with mnemonic when identity already exists', async () => {
483625
mockKernelStore.setRemoteIdentityValue('peerId', 'existing-peer-id');
484626
mockKernelStore.setRemoteIdentityValue(
@@ -690,7 +832,7 @@ describe('remote-comms', () => {
690832
'/dns4/stored-relay1.example/tcp/443/wss/p2p/relay1',
691833
'/dns4/stored-relay2.example/tcp/443/wss/p2p/relay2',
692834
];
693-
mockKernelStore.setKnownRelays(storedRelays);
835+
mockKernelStore.setRelayEntries(makeLearnedRelayEntries(storedRelays));
694836
const remoteComms = await initRemoteComms(
695837
mockKernelStore,
696838
mockPlatformServices,

packages/ocap-kernel/src/remotes/kernel/remote-comms.ts

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ export type OcapURLParts = {
2424
hints: string[];
2525
};
2626

27+
/** Maximum number of relay hints embedded in a single OCAP URL. */
28+
export const MAX_URL_RELAY_HINTS = 3;
29+
30+
/** Maximum number of relay entries stored in the kernel's relay pool. */
31+
export const MAX_KNOWN_RELAYS = 20;
32+
2733
/**
2834
* Break down an ocap URL string into its constituent parts.
2935
*
@@ -109,7 +115,30 @@ export async function initRemoteIdentity(
109115
const relays = options?.relays ?? [];
110116
const mnemonic = options?.mnemonic;
111117
if (relays.length > 0) {
112-
kernelStore.setKnownRelays(relays);
118+
const now = Date.now();
119+
const bootstrapSet = new Set(relays);
120+
// Merge with existing entries: mark bootstrap relays, preserve learned ones
121+
const existing = kernelStore.getRelayEntries();
122+
const byAddr = new Map(existing.map((entry) => [entry.addr, entry]));
123+
for (const addr of relays) {
124+
byAddr.set(addr, { addr, lastSeen: now, isBootstrap: true });
125+
}
126+
// Clear bootstrap flag on entries no longer in the current bootstrap set
127+
for (const entry of byAddr.values()) {
128+
if (entry.isBootstrap && !bootstrapSet.has(entry.addr)) {
129+
entry.isBootstrap = false;
130+
}
131+
}
132+
let merged = [...byAddr.values()];
133+
// Enforce pool cap: keep all bootstrap, then newest non-bootstrap
134+
if (merged.length > MAX_KNOWN_RELAYS) {
135+
const bootstrap = merged.filter((entry) => entry.isBootstrap);
136+
const nonBootstrap = merged
137+
.filter((entry) => !entry.isBootstrap)
138+
.sort((a, b) => b.lastSeen - a.lastSeen);
139+
merged = [...bootstrap, ...nonBootstrap].slice(0, MAX_KNOWN_RELAYS);
140+
}
141+
kernelStore.setRelayEntries(merged);
113142
}
114143

115144
/* eslint-disable no-param-reassign */
@@ -171,34 +200,69 @@ export async function initRemoteIdentity(
171200
const encodedKref = encoder.encode(paddedKref);
172201
const rawOid = await cipher.encrypt(encodedKref, ocapURLKey);
173202
const oid = base58btc.encode(rawOid);
174-
const currentRelays = kernelStore.getKnownRelays();
175-
const relaySuffix =
176-
currentRelays.length > 0 ? `,${currentRelays.join(',')}` : '';
203+
const entries = kernelStore.getRelayEntries();
204+
// Select top relays: bootstrap first, then most recently seen
205+
const sorted = [...entries].sort((a, b) => {
206+
if (a.isBootstrap !== b.isBootstrap) {
207+
return a.isBootstrap ? -1 : 1;
208+
}
209+
return b.lastSeen - a.lastSeen;
210+
});
211+
const selected = sorted
212+
.slice(0, MAX_URL_RELAY_HINTS)
213+
.map((entry) => entry.addr);
214+
const relaySuffix = selected.length > 0 ? `,${selected.join(',')}` : '';
177215
const ocapURL = `ocap:${oid}@${peerId}${relaySuffix}`;
178216
return ocapURL;
179217
}
180218

181219
/**
182-
* Add relay addresses to the kernel's known relay pool, deduplicating.
220+
* Add relay addresses to the kernel's known relay pool.
221+
* Deduplicates, updates lastSeen on re-observation, and enforces
222+
* {@link MAX_KNOWN_RELAYS} by evicting the oldest non-bootstrap entries.
183223
*
184224
* @param newRelays - Relay multiaddrs to add.
185225
*/
186226
function addKnownRelays(newRelays: string[]): void {
187227
if (newRelays.length === 0) {
188228
return;
189229
}
190-
const existing = kernelStore.getKnownRelays();
191-
const merged = new Set(existing);
230+
const now = Date.now();
231+
const existing = kernelStore.getRelayEntries();
232+
const byAddr = new Map(existing.map((entry) => [entry.addr, entry]));
192233
let changed = false;
193-
for (const relay of newRelays) {
194-
if (!merged.has(relay)) {
195-
merged.add(relay);
234+
235+
for (const addr of newRelays) {
236+
const entry = byAddr.get(addr);
237+
if (entry) {
238+
// Update lastSeen on re-observation (create new object to avoid
239+
// mutating potentially-hardened deserialized entries)
240+
if (entry.lastSeen !== now) {
241+
byAddr.set(addr, { ...entry, lastSeen: now });
242+
changed = true;
243+
}
244+
} else {
245+
byAddr.set(addr, { addr, lastSeen: now, isBootstrap: false });
196246
changed = true;
197247
}
198248
}
199-
if (changed) {
200-
kernelStore.setKnownRelays([...merged]);
249+
250+
if (!changed) {
251+
return;
201252
}
253+
254+
let entries = [...byAddr.values()];
255+
256+
// Enforce pool cap by evicting oldest non-bootstrap entries
257+
if (entries.length > MAX_KNOWN_RELAYS) {
258+
const bootstrap = entries.filter((entry) => entry.isBootstrap);
259+
const nonBootstrap = entries
260+
.filter((entry) => !entry.isBootstrap)
261+
.sort((a, b) => b.lastSeen - a.lastSeen);
262+
entries = [...bootstrap, ...nonBootstrap].slice(0, MAX_KNOWN_RELAYS);
263+
}
264+
265+
kernelStore.setRelayEntries(entries);
202266
}
203267

204268
/**
@@ -231,7 +295,7 @@ export async function initRemoteIdentity(
231295
return {
232296
identity: { getPeerId, issueOcapURL, redeemLocalOcapURL, addKnownRelays },
233297
keySeed,
234-
knownRelays: kernelStore.getKnownRelays(),
298+
knownRelays: kernelStore.getKnownRelayAddresses(),
235299
};
236300
}
237301

0 commit comments

Comments
 (0)