Skip to content

Commit 6b07496

Browse files
beacon: firehose phase 2 — follows hose (kind 3) + relay harvesting
- src/hoses/event.js: extract the shared structural + schnorr-signature check (eventId, verifySignature); profiles hose refactored to use it. - src/hoses/follows.js (kind 3): verify signature, derive the social-graph shape { pubkey, follows:[hex…], created_at, count } from p tags (64-hex, lowercased, deduped), latest-wins upsert (the legacy followshose blindly replaced — an out-of-order older list could clobber a newer one). Owns its indexes (pubkey, follows, created_at). Harvests relay URLs from the legacy relay map in kind-3 content into the relays directory via $setOnInsert (discovery only — never overwrites health metrics; best-effort, never fails ingest). - src/indexer.js: register the follows hose; LEGACY_KINDS drops to [10002]. The switch removes kind 3 from the legacy set automatically. - tests: new follows-hose tests (verify/parse/harvest/canonicalize); plan tests updated for the now-hose-owned kind 3. Fixes in-degree for kind-3 events the legacy raw-upsert path wrote without a top-level follows array. npm test 34/34. Closes #9
1 parent 6751e6d commit 6b07496

7 files changed

Lines changed: 233 additions & 38 deletions

File tree

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,12 @@ npm run serve # read API only
4141

4242
v0 — MVP: indexer + read API + the regression-compatible schema, plus the DID-document resolution endpoint (`/.well-known/did/nostr/:pubkey.json` via jss's `buildDidDocument`) and a `/relays` health directory.
4343

44-
The indexer is being reworked into composable **hoses** (`src/hoses/`). Phase 1 is the **profiles hose** (kind 0): it schnorr-verifies each event before storing it, so a relay can't inject a forged `did:nostr` profile, and owns its Mongo indexes (incl. the `content_text` search index). Follows (kind 3) and relay lists (kind 10002) still use the raw upsert path until their phases.
44+
The indexer is being reworked into composable **hoses** (`src/hoses/`), each owning a set of event kinds, all sharing one schnorr signature check (`src/hoses/event.js`) so a relay can't inject forged data. Which hoses run is a switch — the `HOSES` env (comma list of names; default: all).
45+
46+
- **Profiles** (kind 0) — verifies + stores the raw event latest-wins, owns its indexes incl. the `content_text` search index.
47+
- **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+
49+
Relay lists (kind 10002) still use the raw upsert path until their phase.
4550

4651
## License
4752

src/hoses/event.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Shared Nostr event verification used by every hose.
2+
//
3+
// A hose layers kind- and content-specific rules on top of this: the checks
4+
// here are common to all events — well-formed pubkey/created_at/sig, an `id`
5+
// that matches the canonical serialization, and a valid schnorr signature.
6+
import { schnorr } from '@noble/curves/secp256k1.js';
7+
import { sha256 } from '@noble/hashes/sha2.js';
8+
import { hexToBytes, bytesToHex } from '@noble/hashes/utils.js';
9+
10+
const HEX64 = /^[0-9a-f]{64}$/;
11+
const HEX128 = /^[0-9a-f]{128}$/;
12+
const enc = new TextEncoder();
13+
14+
// NIP-01 event id: sha256 of [0, pubkey, created_at, kind, tags, content].
15+
export function eventId(e) {
16+
const serial = JSON.stringify([0, e.pubkey, e.created_at, e.kind, e.tags || [], e.content ?? '']);
17+
return bytesToHex(sha256(enc.encode(serial)));
18+
}
19+
20+
/**
21+
* Structural + cryptographic validation common to every event. Verifies the
22+
* pubkey/created_at/sig are well-formed, the claimed `id` (if present) matches
23+
* the content, and the schnorr signature checks out. Does NOT inspect `kind`
24+
* or content semantics — callers add those.
25+
*/
26+
export function verifySignature(e) {
27+
if (!e || typeof e.pubkey !== 'string' || !HEX64.test(e.pubkey)) return false;
28+
if (!Number.isFinite(e.created_at)) return false;
29+
if (typeof e.sig !== 'string' || !HEX128.test(e.sig)) return false;
30+
const id = eventId(e);
31+
if (e.id && e.id !== id) return false; // claimed id must match the content
32+
try { return schnorr.verify(hexToBytes(e.sig), hexToBytes(id), hexToBytes(e.pubkey)); }
33+
catch { return false; }
34+
}

src/hoses/follows.js

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// The follows hose — kind 3 (contact list / social graph).
2+
//
3+
// Stores the *derived* nostr-beacon shape `{ pubkey, follows: [hex…],
4+
// created_at, count }` (not the raw event): the in-degree query
5+
// (`followerCount`) and `enrichCounts` both rely on the top-level `follows`
6+
// array + `count`. As a side-effect it harvests relay URLs from the legacy
7+
// relay map some clients still put in kind-3 `content`, seeding the `relays`
8+
// directory (discovery only — it never overwrites health metrics).
9+
import { verifySignature } from './event.js';
10+
import { COLLECTIONS } from '../db.js';
11+
12+
const KIND = 3;
13+
const HEX64 = /^[0-9a-f]{64}$/;
14+
const RELAY_DIRECTORY = process.env.MONGO_RELAY_DIRECTORY_COLLECTION || 'relays';
15+
16+
/** Validate a kind-3 event. Unlike kind 0, content is optional / free-form. */
17+
export function verifyEvent(e) {
18+
if (!e || e.kind !== KIND) return false;
19+
return verifySignature(e);
20+
}
21+
22+
/** Followed pubkeys from `p` tags — 64-hex only, lowercased, deduped. */
23+
export function parseFollows(event) {
24+
const out = new Set();
25+
if (Array.isArray(event?.tags)) {
26+
for (const t of event.tags) {
27+
if (t?.[0] === 'p' && typeof t[1] === 'string') {
28+
const hex = t[1].toLowerCase();
29+
if (HEX64.test(hex)) out.add(hex);
30+
}
31+
}
32+
}
33+
return [...out];
34+
}
35+
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+
47+
/** Relay URLs from the legacy NIP-65-ish relay map in kind-3 content. */
48+
export function relayUrlsFrom(event) {
49+
if (!event?.content) return [];
50+
let map;
51+
try { map = JSON.parse(event.content); } catch { return []; }
52+
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];
56+
}
57+
58+
export default {
59+
name: 'follows',
60+
kinds: [KIND],
61+
62+
async ensureIndexes(db) {
63+
const ok = (e) => { if (e?.code !== 85 && e?.code !== 86) throw e; };
64+
const col = db.collection(COLLECTIONS[KIND]);
65+
await col.createIndex({ pubkey: 1 }).catch(ok);
66+
await col.createIndex({ follows: 1 }).catch(ok); // reverse lookup / in-degree
67+
await col.createIndex({ created_at: -1 }).catch(ok);
68+
await db.collection(RELAY_DIRECTORY).createIndex({ relay: 1 }).catch(ok);
69+
},
70+
71+
/** Verify, latest-wins upsert of the derived shape, then harvest relays. */
72+
async ingest(event, db) {
73+
if (!verifyEvent(event)) return false;
74+
const col = db.collection(COLLECTIONS[KIND]);
75+
const existing = await col.findOne({ pubkey: event.pubkey }, { projection: { created_at: 1 } });
76+
if (existing && existing.created_at >= event.created_at) return false;
77+
const follows = parseFollows(event);
78+
await col.replaceOne(
79+
{ pubkey: event.pubkey },
80+
{ pubkey: event.pubkey, follows, created_at: event.created_at, count: follows.length },
81+
{ upsert: true },
82+
);
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+
}
92+
return true;
93+
},
94+
};

src/hoses/profiles.js

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,39 +10,20 @@
1010
// verifies each event's schnorr signature before trusting it — beacon is an
1111
// identity/SSO substrate, so a malicious relay must not be able to inject a
1212
// forged did:nostr profile.
13-
import { schnorr } from '@noble/curves/secp256k1.js';
14-
import { sha256 } from '@noble/hashes/sha2.js';
15-
import { hexToBytes, bytesToHex } from '@noble/hashes/utils.js';
13+
import { verifySignature } from './event.js';
1614
import { COLLECTIONS } from '../db.js';
1715

1816
const KIND = 0;
19-
const HEX64 = /^[0-9a-f]{64}$/;
20-
const HEX128 = /^[0-9a-f]{128}$/;
21-
const enc = new TextEncoder();
22-
23-
// NIP-01 event id: sha256 of the canonical serialization
24-
// [0, pubkey, created_at, kind, tags, content].
25-
function eventId(e) {
26-
const serial = JSON.stringify([0, e.pubkey, e.created_at, e.kind, e.tags || [], e.content ?? '']);
27-
return bytesToHex(sha256(enc.encode(serial)));
28-
}
2917

3018
/**
31-
* Structural + cryptographic validation of a raw kind-0 event.
32-
* Rejects wrong kind, malformed fields, non-JSON content, a tampered `id`,
33-
* and any event whose schnorr signature doesn't verify against its pubkey.
19+
* Validate a raw kind-0 event: right kind, JSON-parseable content (empty is
20+
* allowed, treated as {}), plus the shared structural + schnorr-signature
21+
* check. Rejects forged or tampered profiles so a relay can't inject one.
3422
*/
3523
export function verifyEvent(e) {
3624
if (!e || e.kind !== KIND) return false;
37-
if (typeof e.pubkey !== 'string' || !HEX64.test(e.pubkey)) return false;
38-
if (!Number.isFinite(e.created_at)) return false;
39-
if (typeof e.sig !== 'string' || !HEX128.test(e.sig)) return false;
40-
// kind-0 content is a JSON object; empty string is allowed (treated as {}).
4125
if (e.content) { try { JSON.parse(e.content); } catch { return false; } }
42-
const id = eventId(e);
43-
if (e.id && e.id !== id) return false; // claimed id must match the content
44-
try { return schnorr.verify(hexToBytes(e.sig), hexToBytes(id), hexToBytes(e.pubkey)); }
45-
catch { return false; }
26+
return verifySignature(e);
4627
}
4728

4829
export default {

src/indexer.js

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,20 @@
88
// double-writing against the legacy firehose processes during the migration.
99
import { upsertEvent, connect } from './db.js';
1010
import profilesHose from './hoses/profiles.js';
11+
import followsHose from './hoses/follows.js';
1112
import 'dotenv/config';
1213

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

1617
// All hoses that exist (grows each phase). `planIngest` selects which run.
17-
const ALL_HOSES = [profilesHose];
18+
const ALL_HOSES = [profilesHose, followsHose];
1819

19-
// Kinds not yet migrated to a hose: 3 (follows), 10002 (relay lists). They use
20-
// the legacy raw-upsert path, on by default for local parity. Set
21-
// INDEX_LEGACY_KINDS=0 to turn it off so a single-hose deploy never writes
22-
// collections still owned by the legacy firehose/followshose processes.
23-
const LEGACY_KINDS = [3, 10002];
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];
2425

2526
/**
2627
* Resolve the ingest plan from the registered hoses + env switches. Pure (env

test/follows-hose.test.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Follows hose: signature verification + p-tag derivation + relay harvesting.
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, parseFollows, relayUrlsFrom, canonicalizeRelayUrl } from '../src/hoses/follows.js';
8+
9+
const enc = new TextEncoder();
10+
const PRIV = hexToBytes('0000000000000000000000000000000000000000000000000000000000000003');
11+
const PUBKEY = bytesToHex(schnorr.getPublicKey(PRIV));
12+
const PK = (n) => String(n).padStart(64, '0'); // a fake 64-hex pubkey
13+
14+
function signed({ tags = [], content = '', created_at = 1000, kind = 3 } = {}) {
15+
const base = { pubkey: PUBKEY, created_at, kind, tags, content };
16+
const id = bytesToHex(sha256(enc.encode(JSON.stringify([0, base.pubkey, base.created_at, base.kind, base.tags, base.content]))));
17+
const sig = bytesToHex(schnorr.sign(hexToBytes(id), PRIV));
18+
return { ...base, id, sig };
19+
}
20+
21+
test('accepts a validly-signed kind-3 event (empty content ok)', () => {
22+
assert.equal(verifyEvent(signed()), true);
23+
});
24+
25+
test('rejects the wrong kind', () => {
26+
assert.equal(verifyEvent(signed({ kind: 0 })), false);
27+
});
28+
29+
test('rejects a tampered event', () => {
30+
const e = signed({ tags: [['p', PK(1)]] });
31+
e.tags = [['p', PK(2)]]; // change after signing
32+
assert.equal(verifyEvent(e), false);
33+
});
34+
35+
test('rejects malformed input without throwing', () => {
36+
for (const bad of [null, undefined, {}, 42, 'x']) assert.equal(verifyEvent(bad), false);
37+
});
38+
39+
test('parseFollows: p tags only, 64-hex, lowercased, deduped', () => {
40+
const e = signed({ tags: [
41+
['p', PK('a').toUpperCase()], // uppercased -> lowercased
42+
['p', PK('a')], // duplicate -> deduped
43+
['p', PK('b')],
44+
['e', PK('c')], // non-p tag -> ignored
45+
['p', 'not-hex'], // invalid -> dropped
46+
['p'], // malformed -> skipped
47+
] });
48+
assert.deepEqual(parseFollows(e), [PK('a'), PK('b')]);
49+
});
50+
51+
test('parseFollows: no tags -> empty', () => {
52+
assert.deepEqual(parseFollows(signed()), []);
53+
});
54+
55+
test('canonicalizeRelayUrl: trailing slash on origins, path preserved, junk dropped', () => {
56+
assert.equal(canonicalizeRelayUrl('wss://relay.example.com'), 'wss://relay.example.com/');
57+
assert.equal(canonicalizeRelayUrl('wss://relay.example.com/'), 'wss://relay.example.com/');
58+
assert.equal(canonicalizeRelayUrl('wss://relay.example.com/inbox'), 'wss://relay.example.com/inbox');
59+
assert.equal(canonicalizeRelayUrl('https://not-a-relay.com'), null);
60+
assert.equal(canonicalizeRelayUrl('garbage'), null);
61+
});
62+
63+
test('relayUrlsFrom: parses the legacy relay map, canonicalizes + dedupes', () => {
64+
const content = JSON.stringify({
65+
'wss://relay.example.com': { read: true, write: true },
66+
'wss://relay.example.com/': { read: true, write: false }, // dedupes with above
67+
'wss://other.example.com/inbox': { read: true, write: true },
68+
'https://bad.example.com': {}, // dropped (not ws/wss)
69+
});
70+
assert.deepEqual(
71+
relayUrlsFrom(signed({ content })).sort(),
72+
['wss://other.example.com/inbox', 'wss://relay.example.com/'],
73+
);
74+
});
75+
76+
test('relayUrlsFrom: empty / non-JSON content -> empty', () => {
77+
assert.deepEqual(relayUrlsFrom(signed({ content: '' })), []);
78+
assert.deepEqual(relayUrlsFrom(signed({ content: 'not json' })), []);
79+
});

test/indexer-plan.test.js

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,24 @@ import test from 'node:test';
44
import assert from 'node:assert/strict';
55
import { planIngest } from '../src/indexer.js';
66

7+
// Fakes — the real LEGACY_KINDS is [10002] (kinds 0 and 3 are now hose-owned).
78
const profiles = { name: 'profiles', kinds: [0] };
89
const follows = { name: 'follows', kinds: [3] };
9-
const ONE = [profiles]; // mirrors today's registry
10-
const TWO = [profiles, follows]; // a future phase
10+
const ONE = [profiles]; // only profiles registered
11+
const TWO = [profiles, follows]; // the real registry
1112

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

1415
test('default (no env): all registered hoses on + legacy kinds', () => {
1516
const p = planIngest(ONE, {});
1617
assert.deepEqual(p.hoses.map((h) => h.name), ['profiles']);
1718
assert.equal(p.legacy, true);
18-
assert.deepEqual(sorted(p.kinds), [0, 3, 10002]);
19+
assert.deepEqual(sorted(p.kinds), [0, 10002]); // 10002 is the only legacy kind
1920
assert.deepEqual(p.unknown, []);
2021
});
2122

2223
test('empty HOSES string falls back to the default (all on)', () => {
23-
assert.deepEqual(sorted(planIngest(ONE, { HOSES: '' }).kinds), [0, 3, 10002]);
24+
assert.deepEqual(sorted(planIngest(ONE, { HOSES: '' }).kinds), [0, 10002]);
2425
});
2526

2627
test('INDEX_LEGACY_KINDS=0 → only enabled hoses\' kinds, no legacy', () => {
@@ -42,13 +43,13 @@ test('unknown hose names are reported and ignored', () => {
4243
test('selecting only an unknown hose leaves no hose kinds (legacy still applies)', () => {
4344
const p = planIngest(ONE, { HOSES: 'nope' });
4445
assert.deepEqual(p.hoses, []);
45-
assert.deepEqual(sorted(p.kinds), [3, 10002]); // legacy fallback only
46+
assert.deepEqual(sorted(p.kinds), [10002]); // legacy fallback only
4647
});
4748

48-
test('a hose that owns a legacy kind removes it from the legacy set (no double-subscribe)', () => {
49+
test('the real two-hose registry: profiles + follows own 0 and 3, legacy = 10002', () => {
4950
const p = planIngest(TWO, {}); // follows owns kind 3
5051
assert.deepEqual(p.hoses.map((h) => h.name), ['profiles', 'follows']);
51-
assert.deepEqual(p.legacyKinds, [10002]); // 3 now owned by the follows hose
52+
assert.deepEqual(p.legacyKinds, [10002]);
5253
assert.deepEqual(sorted(p.kinds), [0, 3, 10002]);
5354
});
5455

0 commit comments

Comments
 (0)