Skip to content

Commit 05952af

Browse files
Align DID-document generation with did:nostr 0.0.12
Three conformance fixes surfaced by diffing the live nostr.social output against the 0.0.12 spec: - profile.timestamp -> profile.created_at (nostrcg/did-nostr#117), using the kind-0 created_at (now a defined context term, nostr:created_at). - Emit "modified" (dcterms:modified, ISO-8601) = max(created_at) over the signed parts composed into the document (nostrcg/did-nostr#106). Minimal docs (no signed parts) carry none. - Bound inlined follows to a subset (500) rather than serving the entire list — one live doc was inlining 4760 follows. The complete signed list is the kind-3 event on the relays (nostrcg/did-nostr#104 / #125). diddoc unit tests updated and passing (created_at, modified, bounded follows).
1 parent 3a9460d commit 05952af

2 files changed

Lines changed: 39 additions & 6 deletions

File tree

src/diddoc.js

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
// - type: DIDNostr
66
// - Multikey VM with publicKeyMultibase = f + e701 + 02 + <x-only hex>
77
// - enhanced: profile (kind 0), follows (kind 3), service/Relay (kind 10002)
8+
// - profile.created_at (kind-0 created_at, Unix seconds; spec #117)
9+
// - follows bounded to a subset; the full signed list is the kind-3 event
10+
// on the relays (spec #104/#125)
11+
// - modified (dcterms:modified, ISO-8601) = max(created_at) over the signed
12+
// parts composed into the document (spec #106)
813
//
914
// Per RFC JavaScriptSolidServer/JavaScriptSolidServer#572 this is intended to
1015
// converge with jss's shared `buildDidDocument` once that is extracted as a
@@ -13,6 +18,14 @@
1318

1419
const HEX64 = /^[0-9a-f]{64}$/;
1520

21+
// Upper bound on follows inlined into the document. Large lists would bloat a
22+
// cacheable document; the complete signed list is the kind-3 event on the relays.
23+
const FOLLOWS_LIMIT = 500;
24+
25+
// dcterms:modified serialized as ISO-8601 UTC, no sub-second precision, from a
26+
// Nostr created_at (Unix seconds).
27+
const isoFromUnix = (sec) => new Date(sec * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
28+
1629
export function buildDidDocument(pubkey, { profile, follows, relays } = {}) {
1730
const hex = String(pubkey || '').toLowerCase();
1831
if (!HEX64.test(hex)) return null;
@@ -39,7 +52,7 @@ export function buildDidDocument(pubkey, { profile, follows, relays } = {}) {
3952
const p = {};
4053
for (const k of ['name', 'about', 'picture', 'website', 'nip05', 'lud16']) if (c[k]) p[k] = c[k];
4154
if (c.display_name && !p.name) p.name = c.display_name;
42-
if (profile.created_at) p.timestamp = profile.created_at;
55+
if (profile.created_at) p.created_at = profile.created_at;
4356
if (Object.keys(p).length) doc.profile = p;
4457
if (Array.isArray(c.alsoKnownAs) && c.alsoKnownAs.length) doc.alsoKnownAs = c.alsoKnownAs;
4558
} catch { /* malformed kind-0 content */ }
@@ -57,7 +70,7 @@ export function buildDidDocument(pubkey, { profile, follows, relays } = {}) {
5770
const f = followHexes
5871
.filter((h) => HEX64.test(String(h).toLowerCase()))
5972
.map((h) => `did:nostr:${String(h).toLowerCase()}`);
60-
if (f.length) doc.follows = f;
73+
if (f.length) doc.follows = f.slice(0, FOLLOWS_LIMIT);
6174

6275
// kind 10002 -> service (Relay)
6376
if (Array.isArray(relays?.tags)) {
@@ -67,5 +80,15 @@ export function buildDidDocument(pubkey, { profile, follows, relays } = {}) {
6780
if (svc.length) doc.service = svc;
6881
}
6982

83+
// Subject provenance: max(created_at) over the signed parts actually composed
84+
// into the document, serialized ISO-8601. Representation-only changes are not
85+
// reflected here (those are conveyed by ETag / Last-Modified).
86+
const stamps = [
87+
doc.profile && profile?.created_at,
88+
doc.follows && follows?.created_at,
89+
doc.service && relays?.created_at,
90+
].filter(Number.isFinite);
91+
if (stamps.length) doc.modified = isoFromUnix(Math.max(...stamps));
92+
7093
return doc;
7194
}

test/diddoc.test.js

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,25 @@ test('minimal doc is spec-shaped (cid/v1, Multikey, publicKeyMultibase)', () =>
1515
assert.equal(d.verificationMethod[0].id, `did:nostr:${PK}#key1`);
1616
assert.deepEqual(d.authentication, ['#key1']);
1717
assert.deepEqual(d.assertionMethod, ['#key1']);
18+
assert.equal(d.modified, undefined); // no signed parts composed -> no change time
1819
});
1920

20-
test('enhanced: profile (kind 0), follows (kind 3), service (kind 10002)', () => {
21+
test('enhanced: profile (kind 0), follows (kind 3), service (kind 10002), modified', () => {
2122
const follow = 'a'.repeat(64);
2223
const d = buildDidDocument(PK, {
2324
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/']] },
25+
follows: { tags: [['p', follow], ['e', 'ignored']], created_at: 250 },
26+
relays: { tags: [['r', 'wss://relay.example/']], created_at: 200 },
2627
});
2728
assert.equal(d.profile.name, 'Alice');
2829
assert.equal(d.profile.nip05, 'alice@example.com');
29-
assert.equal(d.profile.timestamp, 100);
30+
assert.equal(d.profile.created_at, 100);
31+
assert.equal(d.profile.timestamp, undefined);
3032
assert.deepEqual(d.follows, [`did:nostr:${follow}`]);
3133
assert.equal(d.service[0].type, 'Relay');
3234
assert.equal(d.service[0].serviceEndpoint, 'wss://relay.example/');
35+
// modified = max(created_at) over composed parts (100, 250, 200), ISO-8601 UTC
36+
assert.equal(d.modified, '1970-01-01T00:04:10Z');
3337
});
3438

3539
test('follows: derived nostr-beacon shape ({follows:[hex,…]}) also resolves', () => {
@@ -38,6 +42,12 @@ test('follows: derived nostr-beacon shape ({follows:[hex,…]}) also resolves',
3842
assert.deepEqual(d.follows, [`did:nostr:${a}`, `did:nostr:${b}`]);
3943
});
4044

45+
test('follows is bounded; the full signed list stays in the kind-3 event', () => {
46+
const many = Array.from({ length: 600 }, (_, i) => i.toString(16).padStart(64, '0'));
47+
const d = buildDidDocument(PK, { follows: { follows: many } });
48+
assert.equal(d.follows.length, 500);
49+
});
50+
4151
test('rejects a non-hex (e.g. npub) identifier', () => {
4252
assert.equal(buildDidDocument('npub1xxx'), null);
4353
assert.equal(buildDidDocument(''), null);

0 commit comments

Comments
 (0)