From 05952af2a36adc5fa193731aa238d3df00de84ae Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Tue, 30 Jun 2026 14:55:29 +0200 Subject: [PATCH 1/6] Align DID-document generation with did:nostr 0.0.12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/diddoc.js | 27 +++++++++++++++++++++++++-- test/diddoc.test.js | 18 ++++++++++++++---- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/diddoc.js b/src/diddoc.js index abdf6f3..91b09e2 100644 --- a/src/diddoc.js +++ b/src/diddoc.js @@ -5,6 +5,11 @@ // - type: DIDNostr // - Multikey VM with publicKeyMultibase = f + e701 + 02 + // - enhanced: profile (kind 0), follows (kind 3), service/Relay (kind 10002) +// - profile.created_at (kind-0 created_at, Unix seconds; spec #117) +// - follows bounded to a subset; the full signed list is the kind-3 event +// on the relays (spec #104/#125) +// - modified (dcterms:modified, ISO-8601) = max(created_at) over the signed +// parts composed into the document (spec #106) // // Per RFC JavaScriptSolidServer/JavaScriptSolidServer#572 this is intended to // converge with jss's shared `buildDidDocument` once that is extracted as a @@ -13,6 +18,14 @@ const HEX64 = /^[0-9a-f]{64}$/; +// Upper bound on follows inlined into the document. Large lists would bloat a +// cacheable document; the complete signed list is the kind-3 event on the relays. +const FOLLOWS_LIMIT = 500; + +// dcterms:modified serialized as ISO-8601 UTC, no sub-second precision, from a +// Nostr created_at (Unix seconds). +const isoFromUnix = (sec) => new Date(sec * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z'); + export function buildDidDocument(pubkey, { profile, follows, relays } = {}) { const hex = String(pubkey || '').toLowerCase(); if (!HEX64.test(hex)) return null; @@ -39,7 +52,7 @@ export function buildDidDocument(pubkey, { profile, follows, relays } = {}) { const p = {}; for (const k of ['name', 'about', 'picture', 'website', 'nip05', 'lud16']) if (c[k]) p[k] = c[k]; if (c.display_name && !p.name) p.name = c.display_name; - if (profile.created_at) p.timestamp = profile.created_at; + if (profile.created_at) p.created_at = profile.created_at; if (Object.keys(p).length) doc.profile = p; if (Array.isArray(c.alsoKnownAs) && c.alsoKnownAs.length) doc.alsoKnownAs = c.alsoKnownAs; } catch { /* malformed kind-0 content */ } @@ -57,7 +70,7 @@ export function buildDidDocument(pubkey, { profile, follows, relays } = {}) { const f = followHexes .filter((h) => HEX64.test(String(h).toLowerCase())) .map((h) => `did:nostr:${String(h).toLowerCase()}`); - if (f.length) doc.follows = f; + if (f.length) doc.follows = f.slice(0, FOLLOWS_LIMIT); // kind 10002 -> service (Relay) if (Array.isArray(relays?.tags)) { @@ -67,5 +80,15 @@ export function buildDidDocument(pubkey, { profile, follows, relays } = {}) { if (svc.length) doc.service = svc; } + // Subject provenance: max(created_at) over the signed parts actually composed + // into the document, serialized ISO-8601. Representation-only changes are not + // reflected here (those are conveyed by ETag / Last-Modified). + const stamps = [ + doc.profile && profile?.created_at, + doc.follows && follows?.created_at, + doc.service && relays?.created_at, + ].filter(Number.isFinite); + if (stamps.length) doc.modified = isoFromUnix(Math.max(...stamps)); + return doc; } diff --git a/test/diddoc.test.js b/test/diddoc.test.js index faa7f49..d95b6af 100644 --- a/test/diddoc.test.js +++ b/test/diddoc.test.js @@ -15,21 +15,25 @@ test('minimal doc is spec-shaped (cid/v1, Multikey, publicKeyMultibase)', () => assert.equal(d.verificationMethod[0].id, `did:nostr:${PK}#key1`); assert.deepEqual(d.authentication, ['#key1']); assert.deepEqual(d.assertionMethod, ['#key1']); + assert.equal(d.modified, undefined); // no signed parts composed -> no change time }); -test('enhanced: profile (kind 0), follows (kind 3), service (kind 10002)', () => { +test('enhanced: profile (kind 0), follows (kind 3), service (kind 10002), modified', () => { const follow = 'a'.repeat(64); const d = buildDidDocument(PK, { profile: { content: JSON.stringify({ name: 'Alice', nip05: 'alice@example.com' }), created_at: 100 }, - follows: { tags: [['p', follow], ['e', 'ignored']] }, - relays: { tags: [['r', 'wss://relay.example/']] }, + follows: { tags: [['p', follow], ['e', 'ignored']], created_at: 250 }, + relays: { tags: [['r', 'wss://relay.example/']], created_at: 200 }, }); assert.equal(d.profile.name, 'Alice'); assert.equal(d.profile.nip05, 'alice@example.com'); - assert.equal(d.profile.timestamp, 100); + assert.equal(d.profile.created_at, 100); + assert.equal(d.profile.timestamp, undefined); assert.deepEqual(d.follows, [`did:nostr:${follow}`]); assert.equal(d.service[0].type, 'Relay'); assert.equal(d.service[0].serviceEndpoint, 'wss://relay.example/'); + // modified = max(created_at) over composed parts (100, 250, 200), ISO-8601 UTC + assert.equal(d.modified, '1970-01-01T00:04:10Z'); }); test('follows: derived nostr-beacon shape ({follows:[hex,…]}) also resolves', () => { @@ -38,6 +42,12 @@ test('follows: derived nostr-beacon shape ({follows:[hex,…]}) also resolves', assert.deepEqual(d.follows, [`did:nostr:${a}`, `did:nostr:${b}`]); }); +test('follows is bounded; the full signed list stays in the kind-3 event', () => { + const many = Array.from({ length: 600 }, (_, i) => i.toString(16).padStart(64, '0')); + const d = buildDidDocument(PK, { follows: { follows: many } }); + assert.equal(d.follows.length, 500); +}); + test('rejects a non-hex (e.g. npub) identifier', () => { assert.equal(buildDidDocument('npub1xxx'), null); assert.equal(buildDidDocument(''), null); From 7a4fafe1921d4bb348916fcb7bdbe260f3800a4e Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Tue, 30 Jun 2026 15:02:35 +0200 Subject: [PATCH 2/6] Use Number.isFinite for profile.created_at gate (preserve created_at=0) Address Copilot review on #39: the truthiness gate dropped a valid created_at of 0, while the modified computation uses Number.isFinite. Make them consistent. --- src/diddoc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/diddoc.js b/src/diddoc.js index 91b09e2..8b08fcd 100644 --- a/src/diddoc.js +++ b/src/diddoc.js @@ -52,7 +52,7 @@ export function buildDidDocument(pubkey, { profile, follows, relays } = {}) { const p = {}; for (const k of ['name', 'about', 'picture', 'website', 'nip05', 'lud16']) if (c[k]) p[k] = c[k]; if (c.display_name && !p.name) p.name = c.display_name; - if (profile.created_at) p.created_at = profile.created_at; + if (Number.isFinite(profile.created_at)) p.created_at = profile.created_at; if (Object.keys(p).length) doc.profile = p; if (Array.isArray(c.alsoKnownAs) && c.alsoKnownAs.length) doc.alsoKnownAs = c.alsoKnownAs; } catch { /* malformed kind-0 content */ } From 58fc58cd3e2274374f0f7878aeb1f32e5777a8e2 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Tue, 30 Jun 2026 15:08:21 +0200 Subject: [PATCH 3/6] Short-circuit follows collection at FOLLOWS_LIMIT Address Copilot review on #39: collect up to FOLLOWS_LIMIT valid entries and stop, instead of validating/mapping the whole kind-3 list before slicing. --- src/diddoc.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/diddoc.js b/src/diddoc.js index 8b08fcd..9baba60 100644 --- a/src/diddoc.js +++ b/src/diddoc.js @@ -67,10 +67,16 @@ export function buildDidDocument(pubkey, { profile, follows, relays } = {}) { } else if (Array.isArray(follows?.follows)) { followHexes = follows.follows; } - const f = followHexes - .filter((h) => HEX64.test(String(h).toLowerCase())) - .map((h) => `did:nostr:${String(h).toLowerCase()}`); - if (f.length) doc.follows = f.slice(0, FOLLOWS_LIMIT); + // Collect up to FOLLOWS_LIMIT valid entries and stop — avoids validating/mapping + // the entire list (kind-3 events can carry thousands of tags) just to slice it. + const f = []; + for (const h of followHexes) { + const lc = String(h).toLowerCase(); + if (!HEX64.test(lc)) continue; + f.push(`did:nostr:${lc}`); + if (f.length >= FOLLOWS_LIMIT) break; + } + if (f.length) doc.follows = f; // kind 10002 -> service (Relay) if (Array.isArray(relays?.tags)) { From 8199af9450ec5c51accb285902570b89dfbaaad8 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Tue, 30 Jun 2026 15:15:36 +0200 Subject: [PATCH 4/6] Harden modified against corrupt created_at (no throw, no null serialization) Address Copilot review on #39: - isoFromUnix returns null for non-safe-integer or out-of-range timestamps instead of throwing RangeError in the public resolver path. - Filter stamps with Number.isSafeInteger (Nostr created_at is integer seconds) and only set modified when isoFromUnix yields a value (never modified: null). - profile.created_at gate also uses Number.isSafeInteger for consistency. --- src/diddoc.js | 21 +++++++++++++++------ test/diddoc.test.js | 9 +++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/diddoc.js b/src/diddoc.js index 9baba60..caadb77 100644 --- a/src/diddoc.js +++ b/src/diddoc.js @@ -22,9 +22,15 @@ const HEX64 = /^[0-9a-f]{64}$/; // cacheable document; the complete signed list is the kind-3 event on the relays. const FOLLOWS_LIMIT = 500; -// dcterms:modified serialized as ISO-8601 UTC, no sub-second precision, from a -// Nostr created_at (Unix seconds). -const isoFromUnix = (sec) => new Date(sec * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z'); +// dcterms:modified serialized as ISO-8601 UTC (no sub-second precision) from a +// Nostr created_at (Unix seconds). Returns null for anything that is not a sane +// integer timestamp or does not map to a representable date, so the public +// resolver path never throws on a corrupt/hostile created_at. +const isoFromUnix = (sec) => { + if (!Number.isSafeInteger(sec)) return null; + const d = new Date(sec * 1000); + return Number.isNaN(d.getTime()) ? null : d.toISOString().replace(/\.\d{3}Z$/, 'Z'); +}; export function buildDidDocument(pubkey, { profile, follows, relays } = {}) { const hex = String(pubkey || '').toLowerCase(); @@ -52,7 +58,7 @@ export function buildDidDocument(pubkey, { profile, follows, relays } = {}) { const p = {}; for (const k of ['name', 'about', 'picture', 'website', 'nip05', 'lud16']) if (c[k]) p[k] = c[k]; if (c.display_name && !p.name) p.name = c.display_name; - if (Number.isFinite(profile.created_at)) p.created_at = profile.created_at; + if (Number.isSafeInteger(profile.created_at)) p.created_at = profile.created_at; if (Object.keys(p).length) doc.profile = p; if (Array.isArray(c.alsoKnownAs) && c.alsoKnownAs.length) doc.alsoKnownAs = c.alsoKnownAs; } catch { /* malformed kind-0 content */ } @@ -93,8 +99,11 @@ export function buildDidDocument(pubkey, { profile, follows, relays } = {}) { doc.profile && profile?.created_at, doc.follows && follows?.created_at, doc.service && relays?.created_at, - ].filter(Number.isFinite); - if (stamps.length) doc.modified = isoFromUnix(Math.max(...stamps)); + ].filter(Number.isSafeInteger); + if (stamps.length) { + const iso = isoFromUnix(Math.max(...stamps)); + if (iso) doc.modified = iso; + } return doc; } diff --git a/test/diddoc.test.js b/test/diddoc.test.js index d95b6af..5f46c96 100644 --- a/test/diddoc.test.js +++ b/test/diddoc.test.js @@ -48,6 +48,15 @@ test('follows is bounded; the full signed list stays in the kind-3 event', () => assert.equal(d.follows.length, 500); }); +test('out-of-range created_at does not throw and is omitted (no modified)', () => { + const d = buildDidDocument(PK, { + profile: { content: JSON.stringify({ name: 'X' }), created_at: 1e308 }, + }); + assert.equal(d.profile.name, 'X'); + assert.equal(d.profile.created_at, undefined); // not a safe integer -> omitted + assert.equal(d.modified, undefined); // no valid stamp -> no modified +}); + test('rejects a non-hex (e.g. npub) identifier', () => { assert.equal(buildDidDocument('npub1xxx'), null); assert.equal(buildDidDocument(''), null); From fcc33822db80546d0beb6b19095a288147e9b814 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Tue, 30 Jun 2026 15:24:22 +0200 Subject: [PATCH 5/6] Count kind-0 created_at toward modified when it composes via alsoKnownAs Address Copilot review on #39: a kind-0 event can contribute alsoKnownAs with no profile fields, so gating the kind-0 created_at on doc.profile alone could understate modified. Gate on (doc.profile || doc.alsoKnownAs). --- src/diddoc.js | 3 ++- test/diddoc.test.js | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/diddoc.js b/src/diddoc.js index caadb77..b22a53b 100644 --- a/src/diddoc.js +++ b/src/diddoc.js @@ -96,7 +96,8 @@ export function buildDidDocument(pubkey, { profile, follows, relays } = {}) { // into the document, serialized ISO-8601. Representation-only changes are not // reflected here (those are conveyed by ETag / Last-Modified). const stamps = [ - doc.profile && profile?.created_at, + // kind-0 composes the doc via profile and/or alsoKnownAs + (doc.profile || doc.alsoKnownAs) && profile?.created_at, doc.follows && follows?.created_at, doc.service && relays?.created_at, ].filter(Number.isSafeInteger); diff --git a/test/diddoc.test.js b/test/diddoc.test.js index 5f46c96..6f8ee5c 100644 --- a/test/diddoc.test.js +++ b/test/diddoc.test.js @@ -48,6 +48,15 @@ test('follows is bounded; the full signed list stays in the kind-3 event', () => assert.equal(d.follows.length, 500); }); +test('alsoKnownAs-only kind-0 still contributes its created_at to modified', () => { + const d = buildDidDocument(PK, { + profile: { content: JSON.stringify({ alsoKnownAs: ['https://x.example/#me'] }), created_at: 300 }, + }); + assert.equal(d.profile, undefined); // no profile fields composed + assert.deepEqual(d.alsoKnownAs, ['https://x.example/#me']); + assert.equal(d.modified, '1970-01-01T00:05:00Z'); // kind-0 created_at (300) still counts +}); + test('out-of-range created_at does not throw and is omitted (no modified)', () => { const d = buildDidDocument(PK, { profile: { content: JSON.stringify({ name: 'X' }), created_at: 1e308 }, From 0aaa789f1bb0ae2f75c3be2f7ed2b02c1c370e8b Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Tue, 30 Jun 2026 15:25:39 +0200 Subject: [PATCH 6/6] Fix: don't emit a profile that is only a created_at The created_at was added to the profile object before the non-empty check, so a kind-0 with only alsoKnownAs produced doc.profile = {created_at}. Attach created_at only when the kind-0 contributes real profile fields; alsoKnownAs-only kind-0 now yields no profile but still counts toward modified (prior commit's gate). Fixes the test added in fcc3382. --- src/diddoc.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/diddoc.js b/src/diddoc.js index b22a53b..c3bdc7c 100644 --- a/src/diddoc.js +++ b/src/diddoc.js @@ -58,8 +58,12 @@ export function buildDidDocument(pubkey, { profile, follows, relays } = {}) { const p = {}; for (const k of ['name', 'about', 'picture', 'website', 'nip05', 'lud16']) if (c[k]) p[k] = c[k]; if (c.display_name && !p.name) p.name = c.display_name; - if (Number.isSafeInteger(profile.created_at)) p.created_at = profile.created_at; - if (Object.keys(p).length) doc.profile = p; + // created_at is provenance *for the profile*: attach it only when the kind-0 + // actually contributes profile fields (not a bare timestamp object). + if (Object.keys(p).length) { + if (Number.isSafeInteger(profile.created_at)) p.created_at = profile.created_at; + doc.profile = p; + } if (Array.isArray(c.alsoKnownAs) && c.alsoKnownAs.length) doc.alsoKnownAs = c.alsoKnownAs; } catch { /* malformed kind-0 content */ } }