Skip to content

Commit dc21537

Browse files
Merge pull request #12 from JavaScriptSolidServer/issue-11-relaylists-hose
Firehose phase 3: relay-lists hose (kind 10002) — retires the last legacy kind
2 parents dc3bc86 + 87f3dd9 commit dc21537

8 files changed

Lines changed: 179 additions & 69 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,9 @@ The indexer is being reworked into composable **hoses** (`src/hoses/`), each own
4545

4646
- **Profiles** (kind 0) — verifies + stores the raw event latest-wins, owns its indexes incl. the `content_text` search index.
4747
- **Follows** (kind 3) — verifies + stores the derived social-graph shape `{ pubkey, follows:[hex…], count }` (so in-degree works), and harvests relay URLs from legacy kind-3 content into the `relays` directory.
48+
- **Relay lists** (kind 10002) — verifies + stores the raw event latest-wins (the shape the DID document's `service` entries are built from), and harvests the `r`-tag URLs into the `relays` directory (the canonical relay-URL source).
4849

49-
Relay lists (kind 10002) still use the raw upsert path until their phase.
50+
Every kind is now hose-owned; the legacy raw-upsert fallback is empty.
5051

5152
## License
5253

src/hoses/follows.js

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77
// relay map some clients still put in kind-3 `content`, seeding the `relays`
88
// directory (discovery only — it never overwrites health metrics).
99
import { verifySignature } from './event.js';
10+
import { harvestRelays, ensureRelayDirectoryIndex } from './relays.js';
1011
import { COLLECTIONS } from '../db.js';
1112

1213
const KIND = 3;
1314
const HEX64 = /^[0-9a-f]{64}$/;
14-
const RELAY_DIRECTORY = process.env.MONGO_RELAY_DIRECTORY_COLLECTION || 'relays';
1515

1616
/** Validate a kind-3 event. Unlike kind 0, content is optional / free-form. */
1717
export function verifyEvent(e) {
@@ -33,26 +33,13 @@ export function parseFollows(event) {
3333
return [...out];
3434
}
3535

36-
// Trailing slash on bare origins (so `wss://r.com` and `wss://r.com/` dedupe);
37-
// keep an explicit path as-is. Non-ws(s) or unparseable URLs are dropped.
38-
export function canonicalizeRelayUrl(url) {
39-
try {
40-
const u = new URL(url);
41-
if (u.protocol !== 'wss:' && u.protocol !== 'ws:') return null;
42-
if (u.pathname === '' || u.pathname === '/') return `${u.protocol}//${u.host}/`;
43-
return url;
44-
} catch { return null; }
45-
}
46-
4736
/** Relay URLs from the legacy NIP-65-ish relay map in kind-3 content. */
4837
export function relayUrlsFrom(event) {
4938
if (!event?.content) return [];
5039
let map;
5140
try { map = JSON.parse(event.content); } catch { return []; }
5241
if (!map || typeof map !== 'object') return [];
53-
const out = new Set();
54-
for (const k of Object.keys(map)) { const u = canonicalizeRelayUrl(k); if (u) out.add(u); }
55-
return [...out];
42+
return Object.keys(map);
5643
}
5744

5845
export default {
@@ -65,7 +52,7 @@ export default {
6552
await col.createIndex({ pubkey: 1 }).catch(ok);
6653
await col.createIndex({ follows: 1 }).catch(ok); // reverse lookup / in-degree
6754
await col.createIndex({ created_at: -1 }).catch(ok);
68-
await db.collection(RELAY_DIRECTORY).createIndex({ relay: 1 }).catch(ok);
55+
await ensureRelayDirectoryIndex(db);
6956
},
7057

7158
/** Verify, latest-wins upsert of the derived shape, then harvest relays. */
@@ -80,15 +67,7 @@ export default {
8067
{ pubkey: event.pubkey, follows, created_at: event.created_at, count: follows.length },
8168
{ upsert: true },
8269
);
83-
// Discovery side-effect: seed newly-seen relay URLs without touching any
84-
// existing health fields. Best-effort — never fail ingest on it.
85-
const urls = relayUrlsFrom(event);
86-
if (urls.length) {
87-
await db.collection(RELAY_DIRECTORY).bulkWrite(
88-
urls.map((relay) => ({ updateOne: { filter: { relay }, update: { $setOnInsert: { relay } }, upsert: true } })),
89-
{ ordered: false },
90-
).catch(() => {});
91-
}
70+
await harvestRelays(db, relayUrlsFrom(event)); // discovery; best-effort
9271
return true;
9372
},
9473
};

src/hoses/relaylists.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// The relay-lists hose — kind 10002 (NIP-65 relay list metadata).
2+
//
3+
// Stores the raw event latest-wins in `relay_lists` (the shape diddoc reads to
4+
// build the DID document's `service`/`Relay` entries). As a side-effect it
5+
// harvests the `r`-tag URLs into the `relays` directory — the canonical source
6+
// of relay URLs, which the relay-health prober (phase 4) checks.
7+
import { verifySignature } from './event.js';
8+
import { harvestRelays, ensureRelayDirectoryIndex } from './relays.js';
9+
import { COLLECTIONS } from '../db.js';
10+
11+
const KIND = 10002;
12+
13+
/** Validate a kind-10002 event. Content is free-form; relays live in `r` tags. */
14+
export function verifyEvent(e) {
15+
if (!e || e.kind !== KIND) return false;
16+
return verifySignature(e);
17+
}
18+
19+
/** Relay URLs from `r` tags (NIP-65). */
20+
export function relayUrlsFrom(event) {
21+
if (!Array.isArray(event?.tags)) return [];
22+
return event.tags.filter((t) => t?.[0] === 'r' && typeof t[1] === 'string').map((t) => t[1]);
23+
}
24+
25+
export default {
26+
name: 'relaylists',
27+
kinds: [KIND],
28+
29+
async ensureIndexes(db) {
30+
const ok = (e) => { if (e?.code !== 85 && e?.code !== 86) throw e; };
31+
const col = db.collection(COLLECTIONS[KIND]);
32+
await col.createIndex({ pubkey: 1 }).catch(ok);
33+
await col.createIndex({ created_at: -1 }).catch(ok);
34+
await ensureRelayDirectoryIndex(db);
35+
},
36+
37+
/** Verify, latest-wins upsert of the raw event, then harvest its relays. */
38+
async ingest(event, db) {
39+
if (!verifyEvent(event)) return false;
40+
const col = db.collection(COLLECTIONS[KIND]);
41+
const existing = await col.findOne({ pubkey: event.pubkey }, { projection: { created_at: 1 } });
42+
if (existing && existing.created_at >= event.created_at) return false;
43+
await col.updateOne({ pubkey: event.pubkey }, { $set: { ...event } }, { upsert: true });
44+
await harvestRelays(db, relayUrlsFrom(event)); // discovery; best-effort
45+
return true;
46+
},
47+
};

src/hoses/relays.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Shared relay-directory helpers, used by any hose that discovers relay URLs
2+
// (follows harvests them from legacy kind-3 content; relay-lists from kind-3…
3+
// sorry, kind-10002 `r` tags). Discovery only seeds the URL — it never touches
4+
// the health metrics the relay-health prober owns.
5+
const RELAY_DIRECTORY = process.env.MONGO_RELAY_DIRECTORY_COLLECTION || 'relays';
6+
7+
// Trailing slash on bare origins (so `wss://r.com` and `wss://r.com/` dedupe);
8+
// keep an explicit path as-is. Non-ws(s) or unparseable URLs return null.
9+
export function canonicalizeRelayUrl(url) {
10+
try {
11+
const u = new URL(url);
12+
if (u.protocol !== 'wss:' && u.protocol !== 'ws:') return null;
13+
if (u.pathname === '' || u.pathname === '/') return `${u.protocol}//${u.host}/`;
14+
return url;
15+
} catch { return null; }
16+
}
17+
18+
export async function ensureRelayDirectoryIndex(db) {
19+
await db.collection(RELAY_DIRECTORY).createIndex({ relay: 1 })
20+
.catch((e) => { if (e?.code !== 85 && e?.code !== 86) throw e; });
21+
}
22+
23+
/**
24+
* Best-effort discovery: seed newly-seen relay URLs into the directory without
25+
* touching any existing fields ($setOnInsert). Canonicalizes + dedupes first.
26+
* Never throws — discovery must never fail an ingest. Returns the count seeded.
27+
*/
28+
export async function harvestRelays(db, urls) {
29+
const uniq = [...new Set((urls || []).map(canonicalizeRelayUrl).filter(Boolean))];
30+
if (!uniq.length) return 0;
31+
await db.collection(RELAY_DIRECTORY).bulkWrite(
32+
uniq.map((relay) => ({ updateOne: { filter: { relay }, update: { $setOnInsert: { relay } }, upsert: true } })),
33+
{ ordered: false },
34+
).catch(() => {});
35+
return uniq.length;
36+
}

src/indexer.js

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,33 +9,32 @@
99
import { upsertEvent, connect } from './db.js';
1010
import profilesHose from './hoses/profiles.js';
1111
import followsHose from './hoses/follows.js';
12+
import relaylistsHose from './hoses/relaylists.js';
1213
import 'dotenv/config';
1314

1415
const RELAYS = (process.env.RELAYS || 'wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net')
1516
.split(',').map((s) => s.trim()).filter(Boolean);
1617

1718
// All hoses that exist (grows each phase). `planIngest` selects which run.
18-
const ALL_HOSES = [profilesHose, followsHose];
19-
20-
// Kinds not yet migrated to a hose: 10002 (relay lists). They use the legacy
21-
// raw-upsert path, on by default for local parity. Set INDEX_LEGACY_KINDS=0 to
22-
// turn it off so a single-hose deploy never writes collections still owned by
23-
// the legacy firehose processes.
24-
const LEGACY_KINDS = [10002];
19+
// Every event kind now has a hose, so there are no legacy kinds left — the
20+
// raw-upsert fallback and its INDEX_LEGACY_KINDS switch remain only as a safety
21+
// net for any kind a future phase adds before its hose lands.
22+
const ALL_HOSES = [profilesHose, followsHose, relaylistsHose];
23+
const LEGACY_KINDS = [];
2524

2625
/**
27-
* Resolve the ingest plan from the registered hoses + env switches. Pure (env
28-
* passed in) so it's unit-testable. Returns the active hoses, a kind→hose
29-
* lookup, the union of kinds to subscribe to, and the legacy-fallback state.
26+
* Resolve the ingest plan from the registered hoses + env switches. Pure (all
27+
* inputs passed in) so it's unit-testable. Returns the active hoses, a
28+
* kind→hose lookup, the union of kinds to subscribe to, and the legacy state.
3029
*/
31-
export function planIngest(allHoses = ALL_HOSES, env = process.env) {
30+
export function planIngest(allHoses = ALL_HOSES, env = process.env, legacyKindsAll = LEGACY_KINDS) {
3231
const want = (env.HOSES || allHoses.map((h) => h.name).join(','))
3332
.split(',').map((s) => s.trim()).filter(Boolean);
3433
const hoses = allHoses.filter((h) => want.includes(h.name));
3534
const unknown = want.filter((n) => !allHoses.some((h) => h.name === n));
3635
const legacy = env.INDEX_LEGACY_KINDS !== '0' && env.INDEX_LEGACY_KINDS !== 'false';
3736
const hoseFor = (kind) => hoses.find((h) => h.kinds.includes(kind));
38-
const legacyKinds = legacy ? LEGACY_KINDS.filter((k) => !hoseFor(k)) : [];
37+
const legacyKinds = legacy ? legacyKindsAll.filter((k) => !hoseFor(k)) : [];
3938
const kinds = [...new Set([...hoses.flatMap((h) => h.kinds), ...legacyKinds])];
4039
return { hoses, hoseFor, kinds, legacy, legacyKinds, unknown };
4140
}

test/follows-hose.test.js

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import assert from 'node:assert/strict';
44
import { schnorr } from '@noble/curves/secp256k1.js';
55
import { sha256 } from '@noble/hashes/sha2.js';
66
import { hexToBytes, bytesToHex } from '@noble/hashes/utils.js';
7-
import { verifyEvent, parseFollows, relayUrlsFrom, canonicalizeRelayUrl } from '../src/hoses/follows.js';
7+
import { verifyEvent, parseFollows, relayUrlsFrom } from '../src/hoses/follows.js';
8+
import { canonicalizeRelayUrl } from '../src/hoses/relays.js';
89

910
const enc = new TextEncoder();
1011
const PRIV = hexToBytes('0000000000000000000000000000000000000000000000000000000000000003');
@@ -60,17 +61,13 @@ test('canonicalizeRelayUrl: trailing slash on origins, path preserved, junk drop
6061
assert.equal(canonicalizeRelayUrl('garbage'), null);
6162
});
6263

63-
test('relayUrlsFrom: parses the legacy relay map, canonicalizes + dedupes', () => {
64+
test('relayUrlsFrom: returns the raw relay-map keys (canon/dedup is harvestRelays\' job)', () => {
6465
const content = JSON.stringify({
6566
'wss://relay.example.com': { read: true, write: true },
66-
'wss://relay.example.com/': { read: true, write: false }, // dedupes with above
6767
'wss://other.example.com/inbox': { read: true, write: true },
68-
'https://bad.example.com': {}, // dropped (not ws/wss)
6968
});
70-
assert.deepEqual(
71-
relayUrlsFrom(signed({ content })).sort(),
72-
['wss://other.example.com/inbox', 'wss://relay.example.com/'],
73-
);
69+
assert.deepEqual(relayUrlsFrom(signed({ content })),
70+
['wss://relay.example.com', 'wss://other.example.com/inbox']);
7471
});
7572

7673
test('relayUrlsFrom: empty / non-JSON content -> empty', () => {

test/indexer-plan.test.js

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,60 @@
11
// The HOSES / INDEX_LEGACY_KINDS switch: planIngest selects which hoses run
2-
// and which kinds get subscribed. Pure function — test with fake hoses.
2+
// and which kinds get subscribed. Pure function — test with fake hoses and an
3+
// explicit legacy-kind set (so these stay stable as real hoses are added).
34
import test from 'node:test';
45
import assert from 'node:assert/strict';
56
import { planIngest } from '../src/indexer.js';
67

7-
// Fakes — the real LEGACY_KINDS is [10002] (kinds 0 and 3 are now hose-owned).
88
const profiles = { name: 'profiles', kinds: [0] };
99
const follows = { name: 'follows', kinds: [3] };
10-
const ONE = [profiles]; // only profiles registered
11-
const TWO = [profiles, follows]; // the real registry
10+
const ALL = [profiles, follows];
11+
const LEGACY = [10002]; // a kind with no hose yet, for these scenarios
1212

1313
const sorted = (a) => [...a].sort((x, y) => x - y);
1414

1515
test('default (no env): all registered hoses on + legacy kinds', () => {
16-
const p = planIngest(ONE, {});
17-
assert.deepEqual(p.hoses.map((h) => h.name), ['profiles']);
16+
const p = planIngest(ALL, {}, LEGACY);
17+
assert.deepEqual(p.hoses.map((h) => h.name), ['profiles', 'follows']);
1818
assert.equal(p.legacy, true);
19-
assert.deepEqual(sorted(p.kinds), [0, 10002]); // 10002 is the only legacy kind
19+
assert.deepEqual(sorted(p.kinds), [0, 3, 10002]);
2020
assert.deepEqual(p.unknown, []);
2121
});
2222

2323
test('empty HOSES string falls back to the default (all on)', () => {
24-
assert.deepEqual(sorted(planIngest(ONE, { HOSES: '' }).kinds), [0, 10002]);
24+
assert.deepEqual(sorted(planIngest(ALL, { HOSES: '' }, LEGACY).kinds), [0, 3, 10002]);
25+
});
26+
27+
test('HOSES selects a subset', () => {
28+
const p = planIngest(ALL, { HOSES: 'profiles' }, LEGACY);
29+
assert.deepEqual(p.hoses.map((h) => h.name), ['profiles']);
30+
assert.deepEqual(sorted(p.kinds), [0, 10002]); // profiles + legacy
2531
});
2632

2733
test('INDEX_LEGACY_KINDS=0 → only enabled hoses\' kinds, no legacy', () => {
28-
const p = planIngest(ONE, { HOSES: 'profiles', INDEX_LEGACY_KINDS: '0' });
34+
const p = planIngest(ALL, { HOSES: 'profiles', INDEX_LEGACY_KINDS: '0' }, LEGACY);
2935
assert.equal(p.legacy, false);
3036
assert.deepEqual(p.kinds, [0]);
3137
});
3238

3339
test('INDEX_LEGACY_KINDS=false is also off', () => {
34-
assert.equal(planIngest(ONE, { INDEX_LEGACY_KINDS: 'false' }).legacy, false);
40+
assert.equal(planIngest(ALL, { INDEX_LEGACY_KINDS: 'false' }, LEGACY).legacy, false);
3541
});
3642

3743
test('unknown hose names are reported and ignored', () => {
38-
const p = planIngest(ONE, { HOSES: 'profiles,nope' });
44+
const p = planIngest(ALL, { HOSES: 'profiles,nope' }, LEGACY);
3945
assert.deepEqual(p.hoses.map((h) => h.name), ['profiles']);
4046
assert.deepEqual(p.unknown, ['nope']);
4147
});
4248

43-
test('selecting only an unknown hose leaves no hose kinds (legacy still applies)', () => {
44-
const p = planIngest(ONE, { HOSES: 'nope' });
45-
assert.deepEqual(p.hoses, []);
46-
assert.deepEqual(sorted(p.kinds), [10002]); // legacy fallback only
47-
});
48-
49-
test('the real two-hose registry: profiles + follows own 0 and 3, legacy = 10002', () => {
50-
const p = planIngest(TWO, {}); // follows owns kind 3
51-
assert.deepEqual(p.hoses.map((h) => h.name), ['profiles', 'follows']);
49+
test('a hose that owns a legacy kind removes it from the legacy set', () => {
50+
// follows owns kind 3 — if 3 were also "legacy" it must not double-subscribe.
51+
const p = planIngest(ALL, {}, [3, 10002]);
5252
assert.deepEqual(p.legacyKinds, [10002]);
5353
assert.deepEqual(sorted(p.kinds), [0, 3, 10002]);
5454
});
5555

56-
test('single-hose deploy: HOSES=follows + no legacy → just kind 3', () => {
57-
const p = planIngest(TWO, { HOSES: 'follows', INDEX_LEGACY_KINDS: '0' });
58-
assert.deepEqual(p.kinds, [3]);
56+
test('no legacy kinds (the real phase-3 state): just the hose kinds', () => {
57+
const p = planIngest(ALL, {}, []);
58+
assert.deepEqual(p.legacyKinds, []);
59+
assert.deepEqual(sorted(p.kinds), [0, 3]);
5960
});

test/relaylists-hose.test.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Relay-lists hose: signature verification + r-tag extraction.
2+
import test from 'node:test';
3+
import assert from 'node:assert/strict';
4+
import { schnorr } from '@noble/curves/secp256k1.js';
5+
import { sha256 } from '@noble/hashes/sha2.js';
6+
import { hexToBytes, bytesToHex } from '@noble/hashes/utils.js';
7+
import { verifyEvent, relayUrlsFrom } from '../src/hoses/relaylists.js';
8+
9+
const enc = new TextEncoder();
10+
const PRIV = hexToBytes('0000000000000000000000000000000000000000000000000000000000000004');
11+
const PUBKEY = bytesToHex(schnorr.getPublicKey(PRIV));
12+
13+
function signed({ tags = [], content = '', created_at = 1000, kind = 10002 } = {}) {
14+
const base = { pubkey: PUBKEY, created_at, kind, tags, content };
15+
const id = bytesToHex(sha256(enc.encode(JSON.stringify([0, base.pubkey, base.created_at, base.kind, base.tags, base.content]))));
16+
const sig = bytesToHex(schnorr.sign(hexToBytes(id), PRIV));
17+
return { ...base, id, sig };
18+
}
19+
20+
test('accepts a validly-signed kind-10002 event', () => {
21+
assert.equal(verifyEvent(signed({ tags: [['r', 'wss://relay.example.com']] })), true);
22+
});
23+
24+
test('rejects the wrong kind', () => {
25+
assert.equal(verifyEvent(signed({ kind: 3 })), false);
26+
});
27+
28+
test('rejects a tampered event', () => {
29+
const e = signed({ tags: [['r', 'wss://relay.example.com']] });
30+
e.tags = [['r', 'wss://evil.example.com']]; // changed after signing
31+
assert.equal(verifyEvent(e), false);
32+
});
33+
34+
test('rejects malformed input without throwing', () => {
35+
for (const bad of [null, undefined, {}, 42, 'x']) assert.equal(verifyEvent(bad), false);
36+
});
37+
38+
test('relayUrlsFrom: pulls r-tag URLs, ignores other tags', () => {
39+
const e = signed({ tags: [
40+
['r', 'wss://relay.example.com'],
41+
['r', 'wss://other.example.com/', 'read'], // marker is fine
42+
['L', 'pink.momostr'], // non-r tag ignored
43+
['r'], // malformed skipped
44+
] });
45+
assert.deepEqual(relayUrlsFrom(e), ['wss://relay.example.com', 'wss://other.example.com/']);
46+
});
47+
48+
test('relayUrlsFrom: no tags -> empty', () => {
49+
assert.deepEqual(relayUrlsFrom(signed()), []);
50+
});

0 commit comments

Comments
 (0)