Skip to content

Commit f68e60f

Browse files
Align DID-document generation with did:nostr 0.0.12 (#39)
* 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). * 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. * 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. * 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. * 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). * 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.
1 parent 3a9460d commit f68e60f

2 files changed

Lines changed: 80 additions & 9 deletions

File tree

src/diddoc.js

Lines changed: 48 additions & 5 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,20 @@
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). Returns null for anything that is not a sane
27+
// integer timestamp or does not map to a representable date, so the public
28+
// resolver path never throws on a corrupt/hostile created_at.
29+
const isoFromUnix = (sec) => {
30+
if (!Number.isSafeInteger(sec)) return null;
31+
const d = new Date(sec * 1000);
32+
return Number.isNaN(d.getTime()) ? null : d.toISOString().replace(/\.\d{3}Z$/, 'Z');
33+
};
34+
1635
export function buildDidDocument(pubkey, { profile, follows, relays } = {}) {
1736
const hex = String(pubkey || '').toLowerCase();
1837
if (!HEX64.test(hex)) return null;
@@ -39,8 +58,12 @@ export function buildDidDocument(pubkey, { profile, follows, relays } = {}) {
3958
const p = {};
4059
for (const k of ['name', 'about', 'picture', 'website', 'nip05', 'lud16']) if (c[k]) p[k] = c[k];
4160
if (c.display_name && !p.name) p.name = c.display_name;
42-
if (profile.created_at) p.timestamp = profile.created_at;
43-
if (Object.keys(p).length) doc.profile = p;
61+
// created_at is provenance *for the profile*: attach it only when the kind-0
62+
// actually contributes profile fields (not a bare timestamp object).
63+
if (Object.keys(p).length) {
64+
if (Number.isSafeInteger(profile.created_at)) p.created_at = profile.created_at;
65+
doc.profile = p;
66+
}
4467
if (Array.isArray(c.alsoKnownAs) && c.alsoKnownAs.length) doc.alsoKnownAs = c.alsoKnownAs;
4568
} catch { /* malformed kind-0 content */ }
4669
}
@@ -54,9 +77,15 @@ export function buildDidDocument(pubkey, { profile, follows, relays } = {}) {
5477
} else if (Array.isArray(follows?.follows)) {
5578
followHexes = follows.follows;
5679
}
57-
const f = followHexes
58-
.filter((h) => HEX64.test(String(h).toLowerCase()))
59-
.map((h) => `did:nostr:${String(h).toLowerCase()}`);
80+
// Collect up to FOLLOWS_LIMIT valid entries and stop — avoids validating/mapping
81+
// the entire list (kind-3 events can carry thousands of tags) just to slice it.
82+
const f = [];
83+
for (const h of followHexes) {
84+
const lc = String(h).toLowerCase();
85+
if (!HEX64.test(lc)) continue;
86+
f.push(`did:nostr:${lc}`);
87+
if (f.length >= FOLLOWS_LIMIT) break;
88+
}
6089
if (f.length) doc.follows = f;
6190

6291
// kind 10002 -> service (Relay)
@@ -67,5 +96,19 @@ export function buildDidDocument(pubkey, { profile, follows, relays } = {}) {
6796
if (svc.length) doc.service = svc;
6897
}
6998

99+
// Subject provenance: max(created_at) over the signed parts actually composed
100+
// into the document, serialized ISO-8601. Representation-only changes are not
101+
// reflected here (those are conveyed by ETag / Last-Modified).
102+
const stamps = [
103+
// kind-0 composes the doc via profile and/or alsoKnownAs
104+
(doc.profile || doc.alsoKnownAs) && profile?.created_at,
105+
doc.follows && follows?.created_at,
106+
doc.service && relays?.created_at,
107+
].filter(Number.isSafeInteger);
108+
if (stamps.length) {
109+
const iso = isoFromUnix(Math.max(...stamps));
110+
if (iso) doc.modified = iso;
111+
}
112+
70113
return doc;
71114
}

test/diddoc.test.js

Lines changed: 32 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,30 @@ 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+
51+
test('alsoKnownAs-only kind-0 still contributes its created_at to modified', () => {
52+
const d = buildDidDocument(PK, {
53+
profile: { content: JSON.stringify({ alsoKnownAs: ['https://x.example/#me'] }), created_at: 300 },
54+
});
55+
assert.equal(d.profile, undefined); // no profile fields composed
56+
assert.deepEqual(d.alsoKnownAs, ['https://x.example/#me']);
57+
assert.equal(d.modified, '1970-01-01T00:05:00Z'); // kind-0 created_at (300) still counts
58+
});
59+
60+
test('out-of-range created_at does not throw and is omitted (no modified)', () => {
61+
const d = buildDidDocument(PK, {
62+
profile: { content: JSON.stringify({ name: 'X' }), created_at: 1e308 },
63+
});
64+
assert.equal(d.profile.name, 'X');
65+
assert.equal(d.profile.created_at, undefined); // not a safe integer -> omitted
66+
assert.equal(d.modified, undefined); // no valid stamp -> no modified
67+
});
68+
4169
test('rejects a non-hex (e.g. npub) identifier', () => {
4270
assert.equal(buildDidDocument('npub1xxx'), null);
4371
assert.equal(buildDidDocument(''), null);

0 commit comments

Comments
 (0)