Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 48 additions & 5 deletions src/diddoc.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
// - type: DIDNostr
// - Multikey VM with publicKeyMultibase = f + e701 + 02 + <x-only hex>
// - 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
Expand All @@ -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;
Expand All @@ -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 */ }
}
Expand All @@ -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)
Expand All @@ -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;
}
36 changes: 32 additions & 4 deletions test/diddoc.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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);
Expand Down