Skip to content

Commit fdfea94

Browse files
beacon v0: did:nostr social-graph indexer (profiles/follows/relays) on Mongo; same schema as nostr-beacon for regression
0 parents  commit fdfea94

8 files changed

Lines changed: 204 additions & 0 deletions

File tree

.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
MONGODB_URI=mongodb://localhost:27017
2+
MONGO_DB=nostr
3+
4+
# Collection names default to the live nostr-beacon values (for regression)
5+
MONGO_COLLECTION=beacon
6+
MONGO_FOLLOWS_COLLECTION=follows
7+
MONGO_RELAYS_COLLECTION=relay_lists
8+
9+
RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net
10+
PORT=3000

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
node_modules/
2+
.env
3+
data/

README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# beacon
2+
3+
A **did:nostr social-graph indexer** — it indexes Nostr **profiles** (kind 0), **follows** (kind 3, the social graph), and **relay lists** (kind 10002) for pubkeys into MongoDB, and serves them over a small read API.
4+
5+
Useful as the identity substrate for **SSO**: "sign in with did:nostr", plus trust / discovery over the follow graph.
6+
7+
This is a clean reimplementation of [nostr-labs/nostr-beacon](https://github.com/nostr-labs/nostr-beacon), keeping the **same Mongo schema to start** (for regression). Per the [RFC](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/572), DID-document *generation* will delegate to jss's `buildDidDocument` rather than maintaining a second, divergent generator (the source of the bugs in nostr-labs/nostr-beacon#3).
8+
9+
## Schema (matches the live nostr-beacon)
10+
11+
Each collection stores the **raw Nostr event**, keyed by `pubkey`, **latest-wins** by `created_at`:
12+
13+
| data | kind | collection |
14+
|---|---|---|
15+
| profiles | 0 | `beacon` |
16+
| follows (social graph) | 3 | `follows` |
17+
| relay lists | 10002 | `relay_lists` |
18+
19+
(Collection names are configurable via env so the same Mongo can be read/written interchangeably.)
20+
21+
## Run
22+
23+
```
24+
npm install
25+
cp .env.example .env # set MONGODB_URI and relays
26+
npm start # indexer + read API
27+
# or run separately:
28+
npm run index # indexer only
29+
npm run serve # read API only
30+
```
31+
32+
## Read API
33+
34+
- `GET /api/profiles` — recent profiles
35+
- `GET /api/profile/:pubkey`
36+
- `GET /api/follows/:pubkey`
37+
- `GET /api/relays/:pubkey`
38+
39+
## Status
40+
41+
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+
43+
## License
44+
45+
MIT

index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Entry point: run the read API and the relay indexer in one process.
2+
import { startServer } from './src/server.js';
3+
import { runIndexer } from './src/indexer.js';
4+
5+
await startServer();
6+
await runIndexer();

package.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "beacon",
3+
"version": "0.0.1",
4+
"type": "module",
5+
"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" },
7+
"scripts": {
8+
"start": "node index.js",
9+
"index": "node -e \"import('./src/indexer.js').then(m => m.runIndexer())\"",
10+
"serve": "node -e \"import('./src/server.js').then(m => m.startServer())\""
11+
},
12+
"dependencies": {
13+
"dotenv": "^16.4.5",
14+
"express": "^4.19.2",
15+
"mongodb": "^6.8.0",
16+
"nostr-tools": "^2.10.0"
17+
},
18+
"license": "MIT"
19+
}

src/db.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// MongoDB layer for the beacon.
2+
//
3+
// Schema matches nostr-beacon (for regression): each collection stores the
4+
// RAW Nostr event, keyed by `pubkey`, latest-wins by `created_at`.
5+
// kind 0 -> profiles (collection: beacon)
6+
// kind 3 -> social graph (collection: follows)
7+
// kind 10002 -> relay lists (collection: relay_lists)
8+
import { MongoClient } from 'mongodb';
9+
import 'dotenv/config';
10+
11+
const uri = process.env.MONGODB_URI || process.env.MONGO_URL || 'mongodb://localhost:27017';
12+
const dbName = process.env.MONGO_DB || process.env.DB_NAME || 'nostr';
13+
14+
// Collection names default to the live nostr-beacon values so the same Mongo
15+
// can be read/written interchangeably for regression.
16+
export const COLLECTIONS = {
17+
0: process.env.MONGO_COLLECTION || 'beacon',
18+
3: process.env.MONGO_FOLLOWS_COLLECTION || 'follows',
19+
10002: process.env.MONGO_RELAYS_COLLECTION || 'relay_lists',
20+
};
21+
22+
let db, client;
23+
24+
export async function connect() {
25+
if (db) return db;
26+
client = new MongoClient(uri);
27+
await client.connect();
28+
db = client.db(dbName);
29+
// non-unique index for query/dedupe perf — does not change the doc shape
30+
for (const name of new Set(Object.values(COLLECTIONS))) {
31+
await db.collection(name).createIndex({ pubkey: 1 });
32+
}
33+
return db;
34+
}
35+
36+
export async function close() {
37+
if (client) await client.close();
38+
db = client = undefined;
39+
}
40+
41+
/** Latest-wins upsert of a raw event into the collection for its kind. */
42+
export async function upsertEvent(event) {
43+
const name = COLLECTIONS[event.kind];
44+
if (!name || !event.pubkey) return false;
45+
const col = (await connect()).collection(name);
46+
const existing = await col.findOne({ pubkey: event.pubkey }, { projection: { created_at: 1 } });
47+
if (existing && existing.created_at >= event.created_at) return false;
48+
await col.updateOne({ pubkey: event.pubkey }, { $set: { ...event } }, { upsert: true });
49+
return true;
50+
}
51+
52+
const one = async (kind, pubkey) => (await connect()).collection(COLLECTIONS[kind]).findOne({ pubkey });
53+
export const getProfile = (pubkey) => one(0, pubkey);
54+
export const getFollows = (pubkey) => one(3, pubkey);
55+
export const getRelays = (pubkey) => one(10002, pubkey);
56+
57+
export async function recentProfiles(limit = 10) {
58+
return (await connect()).collection(COLLECTIONS[0]).find().sort({ created_at: -1 }).limit(limit).toArray();
59+
}

src/indexer.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Relay indexer: subscribe to kind 0 (profile), 3 (follows / social graph),
2+
// and 10002 (relay list), and upsert each raw event into Mongo.
3+
import { SimplePool } from 'nostr-tools/pool';
4+
import { upsertEvent, connect } from './db.js';
5+
import 'dotenv/config';
6+
7+
const RELAYS = (process.env.RELAYS || 'wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net')
8+
.split(',').map((s) => s.trim()).filter(Boolean);
9+
10+
const KINDS = [0, 3, 10002];
11+
12+
export async function runIndexer() {
13+
await connect();
14+
const pool = new SimplePool();
15+
console.log(`[beacon] indexing kinds ${KINDS.join(',')} from ${RELAYS.length} relays`);
16+
17+
const sub = pool.subscribeMany(RELAYS, [{ kinds: KINDS }], {
18+
onevent: async (event) => {
19+
try {
20+
if (await upsertEvent(event)) {
21+
console.log(`[beacon] kind ${event.kind} ${event.pubkey.slice(0, 12)}…`);
22+
}
23+
} catch (e) {
24+
console.error('[beacon] upsert error:', e.message);
25+
}
26+
},
27+
});
28+
29+
const stop = () => { sub.close(); pool.close(RELAYS); };
30+
process.on('SIGINT', () => { stop(); process.exit(0); });
31+
return { pool, sub, stop };
32+
}
33+
34+
if (import.meta.url === `file://${process.argv[1]}`) runIndexer();

src/server.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Thin read API over the indexed social graph.
2+
import express from 'express';
3+
import { getProfile, getFollows, getRelays, recentProfiles, connect } from './db.js';
4+
import 'dotenv/config';
5+
6+
export async function startServer(port = process.env.PORT || 3000) {
7+
await connect();
8+
const app = express();
9+
10+
app.get('/api/profiles', async (_req, res, next) => {
11+
try { res.json(await recentProfiles(10)); } catch (e) { next(e); }
12+
});
13+
app.get('/api/profile/:pubkey', async (req, res, next) => {
14+
try { res.json(await getProfile(req.params.pubkey)); } catch (e) { next(e); }
15+
});
16+
app.get('/api/follows/:pubkey', async (req, res, next) => {
17+
try { res.json(await getFollows(req.params.pubkey)); } catch (e) { next(e); }
18+
});
19+
app.get('/api/relays/:pubkey', async (req, res, next) => {
20+
try { res.json(await getRelays(req.params.pubkey)); } catch (e) { next(e); }
21+
});
22+
23+
app.use((err, _req, res, _next) => res.status(500).json({ error: err.message }));
24+
app.listen(port, () => console.log(`[beacon] read API on :${port}`));
25+
return app;
26+
}
27+
28+
if (import.meta.url === `file://${process.argv[1]}`) startServer();

0 commit comments

Comments
 (0)