Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
27 changes: 25 additions & 2 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,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');

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardened in 8199af9isoFromUnix now returns null for non-safe-integer or out-of-range timestamps (no RangeError in the resolver path), stamps are filtered with Number.isSafeInteger, and modified is only set when a valid ISO value is produced (never modified: null). Added a test for the out-of-range case.


export function buildDidDocument(pubkey, { profile, follows, relays } = {}) {
const hex = String(pubkey || '').toLowerCase();
if (!HEX64.test(hex)) return null;
Expand All @@ -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 (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 */ }
Expand All @@ -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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 58fc58c — replaced the filter/map-then-slice with a loop that collects up to FOLLOWS_LIMIT valid entries and breaks, so large kind-3 lists aren't fully processed just to be truncated.


// kind 10002 -> service (Relay)
if (Array.isArray(relays?.tags)) {
Expand All @@ -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));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardened in 8199af9isoFromUnix now returns null for non-safe-integer or out-of-range timestamps (no RangeError in the resolver path), stamps are filtered with Number.isSafeInteger, and modified is only set when a valid ISO value is produced (never modified: null). Added a test for the out-of-range case.


return doc;
}
18 changes: 14 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,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);
Expand Down