Skip to content

Commit e144439

Browse files
add did:nostr resolution endpoint (.well-known/did/nostr/:pubkey.json) via conformant builder + tests
1 parent d4710fa commit e144439

3 files changed

Lines changed: 127 additions & 0 deletions

File tree

src/diddoc.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Build a spec-conformant did:nostr DID document from indexed events.
2+
//
3+
// Implements the current spec (https://nostrcg.github.io/did-nostr/):
4+
// - @context: cid/v1 + nostr/context (cf. nostrcg/did-nostr#90/#91)
5+
// - type: DIDNostr
6+
// - Multikey VM with publicKeyMultibase = f + e701 + 02 + <x-only hex>
7+
// - enhanced: profile (kind 0), follows (kind 3), service/Relay (kind 10002)
8+
//
9+
// Per RFC JavaScriptSolidServer/JavaScriptSolidServer#572 this is intended to
10+
// converge with jss's shared `buildDidDocument` once that is extracted as a
11+
// reusable module; until then beacon builds the conformant doc directly so it
12+
// is correct now (and avoids the divergent-generator bugs of nostr-labs/nostr-beacon#3).
13+
14+
const HEX64 = /^[0-9a-f]{64}$/;
15+
16+
export function buildDidDocument(pubkey, { profile, follows, relays } = {}) {
17+
const hex = String(pubkey || '').toLowerCase();
18+
if (!HEX64.test(hex)) return null;
19+
const did = `did:nostr:${hex}`;
20+
21+
const doc = {
22+
'@context': ['https://www.w3.org/ns/cid/v1', 'https://w3id.org/nostr/context'],
23+
id: did,
24+
type: 'DIDNostr',
25+
verificationMethod: [{
26+
id: `${did}#key1`,
27+
type: 'Multikey',
28+
controller: did,
29+
publicKeyMultibase: `fe70102${hex}`,
30+
}],
31+
authentication: ['#key1'],
32+
assertionMethod: ['#key1'],
33+
};
34+
35+
// kind 0 -> profile (+ alsoKnownAs)
36+
if (profile?.content) {
37+
try {
38+
const c = JSON.parse(profile.content);
39+
const p = {};
40+
for (const k of ['name', 'about', 'picture', 'website', 'nip05', 'lud16']) if (c[k]) p[k] = c[k];
41+
if (c.display_name && !p.name) p.name = c.display_name;
42+
if (profile.created_at) p.timestamp = profile.created_at;
43+
if (Object.keys(p).length) doc.profile = p;
44+
if (Array.isArray(c.alsoKnownAs) && c.alsoKnownAs.length) doc.alsoKnownAs = c.alsoKnownAs;
45+
} catch { /* malformed kind-0 content */ }
46+
}
47+
48+
// kind 3 -> follows (did:nostr of each p-tag)
49+
if (Array.isArray(follows?.tags)) {
50+
const f = follows.tags
51+
.filter((t) => t[0] === 'p' && HEX64.test(String(t[1]).toLowerCase()))
52+
.map((t) => `did:nostr:${String(t[1]).toLowerCase()}`);
53+
if (f.length) doc.follows = f;
54+
}
55+
56+
// kind 10002 -> service (Relay)
57+
if (Array.isArray(relays?.tags)) {
58+
const svc = relays.tags
59+
.filter((t) => t[0] === 'r' && t[1])
60+
.map((t, i) => ({ id: `${did}#relay${i + 1}`, type: 'Relay', serviceEndpoint: t[1] }));
61+
if (svc.length) doc.service = svc;
62+
}
63+
64+
return doc;
65+
}

src/server.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Thin read API over the indexed social graph.
22
import express from 'express';
33
import { getProfile, getFollows, getRelays, recentProfiles, connect } from './db.js';
4+
import { buildDidDocument } from './diddoc.js';
45
import 'dotenv/config';
56

67
export async function startServer(port = process.env.PORT || 3000) {
@@ -20,6 +21,29 @@ export async function startServer(port = process.env.PORT || 3000) {
2021
try { res.json(await getRelays(req.params.pubkey)); } catch (e) { next(e); }
2122
});
2223

24+
// did:nostr resolution — build the conformant DID document from the index
25+
const resolve = async (pubkey) => {
26+
const [profile, follows, relays] = await Promise.all([
27+
getProfile(pubkey), getFollows(pubkey), getRelays(pubkey),
28+
]);
29+
return buildDidDocument(pubkey, { profile, follows, relays });
30+
};
31+
app.get('/.well-known/did/nostr/:pubkey.json', async (req, res, next) => {
32+
try {
33+
const doc = await resolve(req.params.pubkey);
34+
if (!doc) return res.status(400).json({ error: 'invalid did:nostr pubkey (expect 64-hex)' });
35+
res.setHeader('Content-Type', 'application/did+json');
36+
res.json(doc);
37+
} catch (e) { next(e); }
38+
});
39+
app.get('/api/did/:pubkey', async (req, res, next) => {
40+
try {
41+
const doc = await resolve(req.params.pubkey);
42+
if (!doc) return res.status(400).json({ error: 'invalid did:nostr pubkey (expect 64-hex)' });
43+
res.json(doc);
44+
} catch (e) { next(e); }
45+
});
46+
2347
app.use((err, _req, res, _next) => res.status(500).json({ error: err.message }));
2448
app.listen(port, () => console.log(`[beacon] read API on :${port}`));
2549
return app;

test/diddoc.test.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Unit tests for the conformant did:nostr DID-document builder (no Mongo needed).
2+
import { test } from 'node:test';
3+
import assert from 'node:assert/strict';
4+
import { buildDidDocument } from '../src/diddoc.js';
5+
6+
const PK = '124c0fa99407182ece5a24fad9b7f6674902fc422843d3128d38a0afbee0fdd2';
7+
8+
test('minimal doc is spec-shaped (cid/v1, Multikey, publicKeyMultibase)', () => {
9+
const d = buildDidDocument(PK);
10+
assert.deepEqual(d['@context'], ['https://www.w3.org/ns/cid/v1', 'https://w3id.org/nostr/context']);
11+
assert.equal(d.id, `did:nostr:${PK}`);
12+
assert.equal(d.type, 'DIDNostr');
13+
assert.equal(d.verificationMethod[0].type, 'Multikey');
14+
assert.equal(d.verificationMethod[0].publicKeyMultibase, `fe70102${PK}`);
15+
assert.equal(d.verificationMethod[0].id, `did:nostr:${PK}#key1`);
16+
assert.deepEqual(d.authentication, ['#key1']);
17+
assert.deepEqual(d.assertionMethod, ['#key1']);
18+
});
19+
20+
test('enhanced: profile (kind 0), follows (kind 3), service (kind 10002)', () => {
21+
const follow = 'a'.repeat(64);
22+
const d = buildDidDocument(PK, {
23+
profile: { content: JSON.stringify({ name: 'Alice', nip05: 'alice@example.com' }), created_at: 100 },
24+
follows: { tags: [['p', follow], ['e', 'ignored']] },
25+
relays: { tags: [['r', 'wss://relay.example/']] },
26+
});
27+
assert.equal(d.profile.name, 'Alice');
28+
assert.equal(d.profile.nip05, 'alice@example.com');
29+
assert.equal(d.profile.timestamp, 100);
30+
assert.deepEqual(d.follows, [`did:nostr:${follow}`]);
31+
assert.equal(d.service[0].type, 'Relay');
32+
assert.equal(d.service[0].serviceEndpoint, 'wss://relay.example/');
33+
});
34+
35+
test('rejects a non-hex (e.g. npub) identifier', () => {
36+
assert.equal(buildDidDocument('npub1xxx'), null);
37+
assert.equal(buildDidDocument(''), null);
38+
});

0 commit comments

Comments
 (0)