Skip to content

Commit 6751e6d

Browse files
Merge pull request #8 from JavaScriptSolidServer/issue-7-profiles-hose
Firehose phase 1: validated profiles hose (kind 0) + extensible hose seam
2 parents d279a04 + f4240f9 commit 6751e6d

8 files changed

Lines changed: 290 additions & 12 deletions

File tree

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,11 @@ MONGO_RELAY_DIRECTORY_COLLECTION=relays
1010

1111
RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net
1212
PORT=3000
13+
14+
# Firehose switch: which hoses run (comma list of names; default: all).
15+
# e.g. HOSES=profiles to run only the profiles hose during a phased rollout.
16+
# HOSES=profiles
17+
# Legacy raw-upsert path for kinds not yet migrated to a hose (3=follows,
18+
# 10002=relay lists). On by default; set to 0 to run a single hose without
19+
# writing collections still owned by the legacy firehose processes.
20+
# INDEX_LEGACY_KINDS=1

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ npm run serve # read API only
3939

4040
## Status
4141

42-
v0 — MVP: indexer + read API + the regression-compatible schema. DID-document resolution endpoint (`/.well-known/did/nostr/:pubkey.json` via jss's `buildDidDocument`) is the next step, per the RFC.
42+
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.
43+
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.
4345

4446
## License
4547

package-lock.json

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,18 @@
33
"version": "0.0.1",
44
"type": "module",
55
"description": "did:nostr social-graph indexer (profiles + follows + relay lists) on MongoDB. Same schema as nostr-beacon for regression; DID-doc generation delegates to jss buildDidDocument per RFC.",
6-
"bin": { "beacon": "index.js" },
6+
"bin": {
7+
"beacon": "index.js"
8+
},
79
"scripts": {
810
"test": "node --test",
911
"start": "node index.js",
1012
"index": "node -e \"import('./src/indexer.js').then(m => m.runIndexer())\"",
1113
"serve": "node -e \"import('./src/server.js').then(m => m.startServer())\""
1214
},
1315
"dependencies": {
16+
"@noble/curves": "^2.2.0",
17+
"@noble/hashes": "^2.2.0",
1418
"dotenv": "^16.4.5",
1519
"express": "^4.19.2",
1620
"mongodb": "^6.8.0"

src/hoses/profiles.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// The profiles hose — first concrete "hose" on the firehose indexer.
2+
//
3+
// A hose is a small, self-contained ingest module: it declares the event
4+
// `kinds` it wants, ensures its own Mongo indexes, and ingests one raw event
5+
// at a time. The indexer subscribes to the union of all hoses' kinds and
6+
// dispatches each event to the matching hose. Later phases add sibling hoses
7+
// (follows, relay lists, relay health) without touching the indexer's core.
8+
//
9+
// This hose handles kind 0 (profile metadata). Unlike a blind upsert, it
10+
// verifies each event's schnorr signature before trusting it — beacon is an
11+
// identity/SSO substrate, so a malicious relay must not be able to inject a
12+
// 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';
16+
import { COLLECTIONS } from '../db.js';
17+
18+
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+
}
29+
30+
/**
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.
34+
*/
35+
export function verifyEvent(e) {
36+
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 {}).
41+
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; }
46+
}
47+
48+
export default {
49+
name: 'profiles',
50+
kinds: [KIND],
51+
52+
// Own the collection's indexes so a fresh deploy is fully functional:
53+
// pubkey (lookup/dedupe), created_at (recency), and the text index that
54+
// /api/search relies on. Tolerate a pre-existing index spec (codes 85/86).
55+
async ensureIndexes(db) {
56+
const col = db.collection(COLLECTIONS[KIND]);
57+
const ok = (e) => { if (e?.code !== 85 && e?.code !== 86) throw e; };
58+
await col.createIndex({ pubkey: 1 }).catch(ok);
59+
await col.createIndex({ created_at: -1 }).catch(ok);
60+
await col.createIndex({ content: 'text' }, { name: 'content_text' }).catch(ok);
61+
},
62+
63+
/** Verify, then latest-wins upsert of the raw event. Returns true if stored. */
64+
async ingest(event, db) {
65+
if (!verifyEvent(event)) return false;
66+
const col = db.collection(COLLECTIONS[KIND]);
67+
const existing = await col.findOne({ pubkey: event.pubkey }, { projection: { created_at: 1 } });
68+
if (existing && existing.created_at >= event.created_at) return false;
69+
await col.updateOne({ pubkey: event.pubkey }, { $set: { ...event } }, { upsert: true });
70+
return true;
71+
},
72+
};

src/indexer.js

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,52 @@
22
// Nostr is a simple protocol over WS: send ["REQ", subId, filter], receive
33
// ["EVENT", subId, event]. Node 24 has a built-in global WebSocket.
44
//
5-
// Subscribes to kind 0 (profile), 3 (follows / social graph), 10002 (relay
6-
// list) and upserts each raw event into Mongo.
5+
// Ingestion is composed of "hoses" (see src/hoses/), each owning a set of
6+
// event kinds. Which hoses run is a switch — the `HOSES` env (comma list of
7+
// names; default: all registered) — so a deploy can run a single hose without
8+
// double-writing against the legacy firehose processes during the migration.
79
import { upsertEvent, connect } from './db.js';
10+
import profilesHose from './hoses/profiles.js';
811
import 'dotenv/config';
912

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

13-
const KINDS = [0, 3, 10002];
16+
// All hoses that exist (grows each phase). `planIngest` selects which run.
17+
const ALL_HOSES = [profilesHose];
18+
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];
24+
25+
/**
26+
* Resolve the ingest plan from the registered hoses + env switches. Pure (env
27+
* passed in) so it's unit-testable. Returns the active hoses, a kind→hose
28+
* lookup, the union of kinds to subscribe to, and the legacy-fallback state.
29+
*/
30+
export function planIngest(allHoses = ALL_HOSES, env = process.env) {
31+
const want = (env.HOSES || allHoses.map((h) => h.name).join(','))
32+
.split(',').map((s) => s.trim()).filter(Boolean);
33+
const hoses = allHoses.filter((h) => want.includes(h.name));
34+
const unknown = want.filter((n) => !allHoses.some((h) => h.name === n));
35+
const legacy = env.INDEX_LEGACY_KINDS !== '0' && env.INDEX_LEGACY_KINDS !== 'false';
36+
const hoseFor = (kind) => hoses.find((h) => h.kinds.includes(kind));
37+
const legacyKinds = legacy ? LEGACY_KINDS.filter((k) => !hoseFor(k)) : [];
38+
const kinds = [...new Set([...hoses.flatMap((h) => h.kinds), ...legacyKinds])];
39+
return { hoses, hoseFor, kinds, legacy, legacyKinds, unknown };
40+
}
41+
1442
const SUB_ID = 'beacon';
1543
const RECONNECT_MS = 3000;
1644

17-
function connectRelay(url, onEvent) {
45+
function connectRelay(url, kinds, onEvent) {
1846
let ws, closed = false, timer;
1947
const open = () => {
2048
if (closed) return;
2149
ws = new WebSocket(url);
22-
ws.addEventListener('open', () => ws.send(JSON.stringify(['REQ', SUB_ID, { kinds: KINDS }])));
50+
ws.addEventListener('open', () => ws.send(JSON.stringify(['REQ', SUB_ID, { kinds }])));
2351
ws.addEventListener('message', (m) => {
2452
try {
2553
const msg = JSON.parse(typeof m.data === 'string' ? m.data : m.data.toString());
@@ -34,16 +62,25 @@ function connectRelay(url, onEvent) {
3462
}
3563

3664
export async function runIndexer() {
37-
await connect();
38-
console.log(`[beacon] indexing kinds ${KINDS.join(',')} from ${RELAYS.length} relays`);
65+
const db = await connect();
66+
const { hoses, hoseFor, kinds, legacy, unknown } = planIngest();
67+
if (unknown.length) console.warn(`[beacon] unknown hose(s) in HOSES, ignored: ${unknown.join(', ')}`);
68+
if (!kinds.length) { console.warn('[beacon] no hoses enabled and no legacy kinds — nothing to index'); return { stop() {} }; }
69+
for (const h of hoses) await h.ensureIndexes(db);
70+
console.log(`[beacon] hoses: [${hoses.map((h) => h.name).join(', ') || 'none'}] legacy kinds: ${legacy ? 'on' : 'off'} subscribing kinds ${kinds.join(',')} from ${RELAYS.length} relays`);
3971
const onEvent = async (event) => {
4072
try {
41-
if (await upsertEvent(event)) console.log(`[beacon] kind ${event.kind} ${String(event.pubkey).slice(0, 12)}…`);
73+
const hose = hoseFor(event.kind);
74+
let stored;
75+
if (hose) stored = await hose.ingest(event, db);
76+
else if (legacy) stored = await upsertEvent(event);
77+
else return; // not an enabled kind
78+
if (stored) console.log(`[beacon] kind ${event.kind} ${String(event.pubkey).slice(0, 12)}…`);
4279
} catch (e) {
43-
console.error('[beacon] upsert error:', e.message);
80+
console.error('[beacon] ingest error:', e.message);
4481
}
4582
};
46-
const stoppers = RELAYS.map((url) => connectRelay(url, onEvent));
83+
const stoppers = RELAYS.map((url) => connectRelay(url, kinds, onEvent));
4784
const stop = () => stoppers.forEach((s) => s());
4885
process.on('SIGINT', () => { stop(); process.exit(0); });
4986
process.on('SIGTERM', () => { stop(); process.exit(0); });

test/indexer-plan.test.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// The HOSES / INDEX_LEGACY_KINDS switch: planIngest selects which hoses run
2+
// and which kinds get subscribed. Pure function — test with fake hoses.
3+
import test from 'node:test';
4+
import assert from 'node:assert/strict';
5+
import { planIngest } from '../src/indexer.js';
6+
7+
const profiles = { name: 'profiles', kinds: [0] };
8+
const follows = { name: 'follows', kinds: [3] };
9+
const ONE = [profiles]; // mirrors today's registry
10+
const TWO = [profiles, follows]; // a future phase
11+
12+
const sorted = (a) => [...a].sort((x, y) => x - y);
13+
14+
test('default (no env): all registered hoses on + legacy kinds', () => {
15+
const p = planIngest(ONE, {});
16+
assert.deepEqual(p.hoses.map((h) => h.name), ['profiles']);
17+
assert.equal(p.legacy, true);
18+
assert.deepEqual(sorted(p.kinds), [0, 3, 10002]);
19+
assert.deepEqual(p.unknown, []);
20+
});
21+
22+
test('empty HOSES string falls back to the default (all on)', () => {
23+
assert.deepEqual(sorted(planIngest(ONE, { HOSES: '' }).kinds), [0, 3, 10002]);
24+
});
25+
26+
test('INDEX_LEGACY_KINDS=0 → only enabled hoses\' kinds, no legacy', () => {
27+
const p = planIngest(ONE, { HOSES: 'profiles', INDEX_LEGACY_KINDS: '0' });
28+
assert.equal(p.legacy, false);
29+
assert.deepEqual(p.kinds, [0]);
30+
});
31+
32+
test('INDEX_LEGACY_KINDS=false is also off', () => {
33+
assert.equal(planIngest(ONE, { INDEX_LEGACY_KINDS: 'false' }).legacy, false);
34+
});
35+
36+
test('unknown hose names are reported and ignored', () => {
37+
const p = planIngest(ONE, { HOSES: 'profiles,nope' });
38+
assert.deepEqual(p.hoses.map((h) => h.name), ['profiles']);
39+
assert.deepEqual(p.unknown, ['nope']);
40+
});
41+
42+
test('selecting only an unknown hose leaves no hose kinds (legacy still applies)', () => {
43+
const p = planIngest(ONE, { HOSES: 'nope' });
44+
assert.deepEqual(p.hoses, []);
45+
assert.deepEqual(sorted(p.kinds), [3, 10002]); // legacy fallback only
46+
});
47+
48+
test('a hose that owns a legacy kind removes it from the legacy set (no double-subscribe)', () => {
49+
const p = planIngest(TWO, {}); // follows owns kind 3
50+
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(sorted(p.kinds), [0, 3, 10002]);
53+
});
54+
55+
test('single-hose deploy: HOSES=follows + no legacy → just kind 3', () => {
56+
const p = planIngest(TWO, { HOSES: 'follows', INDEX_LEGACY_KINDS: '0' });
57+
assert.deepEqual(p.kinds, [3]);
58+
});

test/profiles-hose.test.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Profiles hose: validation + signature verification.
2+
// We sign real kind-0 events with a known key, then assert accept/reject.
3+
import test from 'node:test';
4+
import assert from 'node:assert/strict';
5+
import { schnorr } from '@noble/curves/secp256k1.js';
6+
import { sha256 } from '@noble/hashes/sha2.js';
7+
import { hexToBytes, bytesToHex } from '@noble/hashes/utils.js';
8+
import { verifyEvent } from '../src/hoses/profiles.js';
9+
10+
const enc = new TextEncoder();
11+
const PRIV = hexToBytes('0000000000000000000000000000000000000000000000000000000000000001');
12+
const PUBKEY = bytesToHex(schnorr.getPublicKey(PRIV));
13+
14+
// Build a properly-signed kind-0 event.
15+
function signed({ content = '{"name":"alice"}', created_at = 1000, kind = 0 } = {}) {
16+
const base = { pubkey: PUBKEY, created_at, kind, tags: [], content };
17+
const id = bytesToHex(sha256(enc.encode(JSON.stringify([0, base.pubkey, base.created_at, base.kind, base.tags, base.content]))));
18+
const sig = bytesToHex(schnorr.sign(hexToBytes(id), PRIV));
19+
return { ...base, id, sig };
20+
}
21+
22+
test('accepts a validly-signed kind-0 event', () => {
23+
assert.equal(verifyEvent(signed()), true);
24+
});
25+
26+
test('accepts empty content (treated as {})', () => {
27+
assert.equal(verifyEvent(signed({ content: '' })), true);
28+
});
29+
30+
test('rejects tampered content (sig no longer matches)', () => {
31+
const e = signed();
32+
e.content = '{"name":"mallory"}';
33+
assert.equal(verifyEvent(e), false);
34+
});
35+
36+
test('rejects a tampered id', () => {
37+
const e = signed();
38+
e.id = 'ff' + e.id.slice(2);
39+
assert.equal(verifyEvent(e), false);
40+
});
41+
42+
test('rejects a forged/garbage signature', () => {
43+
const e = signed();
44+
e.sig = 'f'.repeat(128);
45+
assert.equal(verifyEvent(e), false);
46+
});
47+
48+
test('rejects non-hex pubkey', () => {
49+
const e = signed();
50+
e.pubkey = 'npub1xxx';
51+
assert.equal(verifyEvent(e), false);
52+
});
53+
54+
test('rejects the wrong kind', () => {
55+
assert.equal(verifyEvent(signed({ kind: 3 })), false);
56+
});
57+
58+
test('rejects non-JSON content', () => {
59+
// Re-sign so the sig is valid but content is not JSON — must still reject.
60+
const e = signed({ content: 'not json{' });
61+
assert.equal(verifyEvent(e), false);
62+
});
63+
64+
test('rejects malformed input without throwing', () => {
65+
for (const bad of [null, undefined, {}, { kind: 0 }, 42, 'x']) {
66+
assert.equal(verifyEvent(bad), false);
67+
}
68+
});

0 commit comments

Comments
 (0)