diff --git a/src/diddoc.js b/src/diddoc.js index abdf6f3..c3bdc7c 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,20 @@ 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). 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(); if (!HEX64.test(hex)) return null; @@ -39,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 (profile.created_at) p.timestamp = 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 */ } } @@ -54,9 +77,15 @@ 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()}`); + // 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) @@ -67,5 +96,19 @@ 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 = [ + // 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); + 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 faa7f49..6f8ee5c 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,30 @@ 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('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 }, + }); + 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);