javascript-solid-server 0.0.179 → 0.0.181

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.179",
3
+ "version": "0.0.181",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -7,13 +7,36 @@
7
7
  * 3. Verifying bidirectional link (WebID links back to did:nostr)
8
8
  */
9
9
 
10
+ import { validateExternalUrl } from '../utils/ssrf.js';
11
+ import { extractNostrPubkeysFromProfile } from './nostr-keys.js';
12
+
10
13
  // Default DID resolver endpoint
11
14
  const DEFAULT_DID_RESOLVER = 'https://nostr.social/.well-known/did/nostr';
12
15
 
13
- // Cache for resolved DIDs (pubkey -> webId or null)
16
+ // Cache for resolved DIDs (pubkey -> { webId, timestamp, failureTtl? }).
17
+ //
18
+ // Bounded LRU: pubkeys come from external NIP-98 events, so an
19
+ // attacker can flood the resolver with unique pubkeys and grow the
20
+ // cache without limit if it's an unbounded Map. The Map iteration
21
+ // order IS insertion order, so evicting `cache.keys().next().value`
22
+ // drops the oldest entry — same pattern as src/auth/cid-doc-fetch.js.
23
+ // On every set: re-insert (delete + set) bumps the entry to "newest"
24
+ // so the LRU semantics are preserved across cache hits.
14
25
  const cache = new Map();
15
26
  const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
16
27
  const FAILURE_CACHE_TTL = 60 * 1000; // 1 minute for failed lookups
28
+ const CACHE_MAX_ENTRIES = 10_000; // bound at ~few MB worst case
29
+
30
+ function setCacheEntry(key, entry) {
31
+ // Re-insert to mark as MRU (Map preserves insertion order).
32
+ if (cache.has(key)) cache.delete(key);
33
+ cache.set(key, entry);
34
+ while (cache.size > CACHE_MAX_ENTRIES) {
35
+ const oldest = cache.keys().next().value;
36
+ if (oldest === undefined) break;
37
+ cache.delete(oldest);
38
+ }
39
+ }
17
40
 
18
41
  // Rate-limit repeated error logs (key -> { count, lastLogged })
19
42
  const errorLogTracker = new Map();
@@ -36,65 +59,207 @@ function rateLimitedError(key, message) {
36
59
  errorLogTracker.set(key, { count: 0, lastLogged: now });
37
60
  }
38
61
 
62
+ // Redirect/SSRF/size limits, mirroring src/auth/cid-doc-fetch.js so
63
+ // both the DID-doc resolver and the WebID-backlink verifier apply
64
+ // the same hardening:
65
+ // - manual redirect handling (5 hops max)
66
+ // - SSRF re-validation on EVERY hop (an allowed origin could 30x
67
+ // to a private IP / cloud metadata; default fetch redirect would
68
+ // bypass the initial validateExternalUrl check)
69
+ // - cross-origin redirects refused (open-redirect → arbitrary host)
70
+ // - response size cap before reading the body
71
+ const MAX_REDIRECTS = 5;
72
+ const DEFAULT_FETCH_TIMEOUT_MS = 5000;
73
+ const DEFAULT_MAX_BYTES = 1 * 1024 * 1024; // 1 MB — DID docs / WebID profiles are tiny
74
+
39
75
  /**
40
- * Fetch with timeout
76
+ * Fetch with a timeout, manual redirect following, SSRF re-validation
77
+ * per hop, and a body-size cap. Returns `{ url, status, headers, body }`
78
+ * — `body` is a string (caller decides whether to JSON-parse).
79
+ *
80
+ * Throws on validation, network, redirect, or size failures so the
81
+ * resolver can swallow them uniformly into a null/false return.
82
+ *
83
+ * Exported for tests so the redirect + cross-origin + cap logic can
84
+ * be unit-tested directly with a stubbed validator (the production
85
+ * validator hard-blocks loopback, which is the only thing a unit
86
+ * test can spin up — without injection the redirect tests can't
87
+ * tell the SSRF guard from the redirect guard).
88
+ *
89
+ * @param {object} [opts]
90
+ * @param {Function} [opts._validateUrl] - Test seam. Defaults to the
91
+ * real `validateExternalUrl`. Production callers MUST NOT override.
41
92
  */
42
- async function fetchWithTimeout(url, options = {}, timeout = 5000) {
43
- const controller = new AbortController();
44
- const id = setTimeout(() => controller.abort(), timeout);
45
- try {
46
- const response = await fetch(url, { ...options, signal: controller.signal });
47
- clearTimeout(id);
48
- return response;
49
- } catch (err) {
50
- clearTimeout(id);
51
- throw err;
93
+ export async function fetchWithRedirectGuard(initialUrl, {
94
+ accept,
95
+ timeout = DEFAULT_FETCH_TIMEOUT_MS,
96
+ maxBytes = DEFAULT_MAX_BYTES,
97
+ _validateUrl = validateExternalUrl,
98
+ } = {}) {
99
+ const originalOrigin = new URL(initialUrl).origin;
100
+ let currentUrl = initialUrl;
101
+
102
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
103
+ const isLastAllowedHop = hop === MAX_REDIRECTS;
104
+ const validation = await _validateUrl(currentUrl, {
105
+ requireHttps: process.env.NODE_ENV === 'production',
106
+ blockPrivateIPs: true,
107
+ resolveDNS: true,
108
+ });
109
+ if (!validation.valid) {
110
+ throw new Error(`SSRF protection: ${validation.error}`);
111
+ }
112
+ const controller = new AbortController();
113
+ const timer = setTimeout(() => controller.abort(), timeout);
114
+ let res;
115
+ try {
116
+ res = await fetch(currentUrl, {
117
+ headers: accept ? { Accept: accept } : {},
118
+ redirect: 'manual',
119
+ signal: controller.signal,
120
+ });
121
+ } finally {
122
+ clearTimeout(timer);
123
+ }
124
+ if (res.status >= 300 && res.status < 400) {
125
+ if (isLastAllowedHop) throw new Error(`too many redirects (>${MAX_REDIRECTS})`);
126
+ const loc = res.headers.get('location');
127
+ if (!loc) throw new Error(`redirect ${res.status} without Location`);
128
+ const nextUrl = new URL(loc, currentUrl).toString();
129
+ const nextOrigin = new URL(nextUrl).origin;
130
+ if (nextOrigin !== originalOrigin) {
131
+ throw new Error(`cross-origin redirect refused: ${originalOrigin} → ${nextOrigin}`);
132
+ }
133
+ currentUrl = nextUrl;
134
+ continue;
135
+ }
136
+ // Cap the body before reading. Content-Length pre-check rejects
137
+ // a server that advertises an oversized response; the streaming
138
+ // cap rejects servers that lie about Content-Length.
139
+ const declared = Number(res.headers.get('content-length'));
140
+ if (Number.isFinite(declared) && declared > maxBytes) {
141
+ throw new Error(`response too large (Content-Length=${declared} > ${maxBytes})`);
142
+ }
143
+ const reader = res.body?.getReader?.();
144
+ let body = '';
145
+ if (!reader) {
146
+ body = await res.text();
147
+ if (Buffer.byteLength(body, 'utf8') > maxBytes) {
148
+ throw new Error(`response too large (>${maxBytes} bytes)`);
149
+ }
150
+ } else {
151
+ const chunks = [];
152
+ let total = 0;
153
+ for (;;) {
154
+ const { value, done } = await reader.read();
155
+ if (done) break;
156
+ total += value.byteLength;
157
+ if (total > maxBytes) {
158
+ try { await reader.cancel(); } catch { /* noop */ }
159
+ throw new Error(`response too large (>${maxBytes} bytes)`);
160
+ }
161
+ chunks.push(value);
162
+ }
163
+ body = Buffer.concat(chunks).toString('utf8');
164
+ }
165
+ return { url: currentUrl, status: res.status, headers: res.headers, body };
52
166
  }
167
+ throw new Error('fetch loop exited unexpectedly');
53
168
  }
54
169
 
55
170
  /**
56
- * Resolve did:nostr pubkey to WebID via DID document
171
+ * Resolve did:nostr pubkey to WebID via DID document.
172
+ *
173
+ * Local users are resolved by `resolveDidNostrLocally` in the auth
174
+ * caller (well-known-did-nostr.js exports an in-process function) —
175
+ * this resolver is the cross-pod fallback that fetches an external
176
+ * DID doc, so all fetches run through the SSRF guard.
177
+ *
57
178
  * @param {string} pubkey - 64-char hex Nostr pubkey
58
- * @param {string} resolverUrl - DID resolver base URL
179
+ * @param {string} [resolverUrl] - DID resolver base URL (without the
180
+ * trailing `/<pubkey>.json`). Defaults to the configured
181
+ * DEFAULT_DID_RESOLVER (nostr.social).
59
182
  * @returns {Promise<string|null>} WebID URL or null
60
183
  */
61
184
  export async function resolveDidNostrToWebId(pubkey, resolverUrl = DEFAULT_DID_RESOLVER) {
62
- if (!pubkey || pubkey.length !== 64) {
185
+ // Pubkey is attacker-controlled (it comes off a NIP-98 event)
186
+ // and is interpolated into the resolver URL path and cache key.
187
+ // Length-only validation isn't enough — characters like `/` or
188
+ // `..` would turn this into an arbitrary-path fetch against the
189
+ // resolver origin and produce confusing cache entries. Enforce
190
+ // the documented shape: 64 lowercase hex chars.
191
+ if (typeof pubkey !== 'string' || !/^[0-9a-f]{64}$/i.test(pubkey)) {
63
192
  return null;
64
193
  }
65
-
66
- // Check cache (lazy eviction of expired entries)
67
- const cacheKey = pubkey.toLowerCase();
194
+ pubkey = pubkey.toLowerCase();
195
+
196
+ // Cache key includes the resolver URL because different resolvers
197
+ // can legitimately disagree about the same pubkey (one might have
198
+ // a DID doc, another not; alsoKnownAs values can differ across
199
+ // operator-run resolvers). Keying only on pubkey would let a hit
200
+ // from one resolver leak into a query against another, including
201
+ // a cached `null` mistakenly suppressing a real result.
202
+ const cacheKey = `${resolverUrl}::${pubkey.toLowerCase()}`;
68
203
  const cached = cache.get(cacheKey);
69
204
  if (cached) {
70
205
  const ttl = cached.failureTtl ? FAILURE_CACHE_TTL : CACHE_TTL;
71
206
  if (Date.now() - cached.timestamp < ttl) {
207
+ // Re-insert to bump to MRU. Without this, a frequently-hit
208
+ // entry could still be evicted as "oldest" once the cache is
209
+ // at the cap, defeating the LRU intent.
210
+ setCacheEntry(cacheKey, cached);
72
211
  return cached.webId;
73
212
  }
74
213
  cache.delete(cacheKey);
75
214
  }
76
215
 
77
216
  try {
78
- // Fetch DID document
217
+ // SSRF guard runs inside fetchWithRedirectGuard on EVERY hop —
218
+ // not just the initial URL — so an allowed resolver origin can't
219
+ // 30x-redirect to a private IP / cloud metadata endpoint and
220
+ // bypass the check. Same policy the LWS-CID verifier applies.
79
221
  const didUrl = `${resolverUrl}/${pubkey}.json`;
80
- const didRes = await fetchWithTimeout(didUrl, {
81
- headers: { 'Accept': 'application/did+json, application/json' }
82
- });
83
-
84
- if (!didRes.ok) {
85
- cache.set(cacheKey, { webId: null, timestamp: Date.now() });
222
+ // Two failure classes with different cache TTLs:
223
+ // - Transient: network error, SSRF/redirect refusal, non-2xx,
224
+ // unparseable JSON. These should re-try sooner, so cache
225
+ // with `failureTtl: true` (FAILURE_CACHE_TTL = 1 min).
226
+ // - "No linkage": successful fetch but the DID doc had no
227
+ // alsoKnownAs / profile.webid we could use. That's a valid
228
+ // answer, not a transient blip — cache with the regular
229
+ // CACHE_TTL (5 min) so we don't hammer the resolver.
230
+ let didFetch;
231
+ try {
232
+ didFetch = await fetchWithRedirectGuard(didUrl, {
233
+ accept: 'application/did+json, application/json',
234
+ });
235
+ } catch {
236
+ setCacheEntry(cacheKey, { webId: null, timestamp: Date.now(), failureTtl: true });
237
+ return null;
238
+ }
239
+ if (didFetch.status < 200 || didFetch.status >= 300) {
240
+ setCacheEntry(cacheKey, { webId: null, timestamp: Date.now(), failureTtl: true });
241
+ return null;
242
+ }
243
+ let didDoc;
244
+ try {
245
+ didDoc = JSON.parse(didFetch.body);
246
+ } catch {
247
+ setCacheEntry(cacheKey, { webId: null, timestamp: Date.now(), failureTtl: true });
86
248
  return null;
87
249
  }
88
-
89
- const didDoc = await didRes.json();
90
-
91
250
  // Extract WebID from alsoKnownAs (array) or profile.webid or profile.sameAs
92
251
  let webId = null;
93
252
 
94
253
  if (Array.isArray(didDoc.alsoKnownAs) && didDoc.alsoKnownAs.length > 0) {
95
- // Find first HTTP(S) URL that looks like a WebID
254
+ // Accept BOTH http and https here; the SSRF guard on the
255
+ // backlink fetch will refuse http in production via
256
+ // `requireHttps: NODE_ENV === 'production'`. Filtering to
257
+ // https-only at this layer would mean non-production
258
+ // resolvers can never validate against http WebIDs (test
259
+ // pods, dev fixtures), even when the SSRF layer would have
260
+ // permitted them.
96
261
  webId = didDoc.alsoKnownAs.find(aka =>
97
- typeof aka === 'string' && aka.startsWith('https://'));
262
+ typeof aka === 'string' && /^https?:\/\//.test(aka));
98
263
  }
99
264
 
100
265
  // Fallback to profile fields
@@ -103,81 +268,247 @@ export async function resolveDidNostrToWebId(pubkey, resolverUrl = DEFAULT_DID_R
103
268
  }
104
269
 
105
270
  if (!webId) {
106
- cache.set(cacheKey, { webId: null, timestamp: Date.now() });
271
+ setCacheEntry(cacheKey, { webId: null, timestamp: Date.now() });
107
272
  return null;
108
273
  }
109
274
 
110
- // Verify bidirectional link - WebID must link back to did:nostr
111
- const verified = await verifyWebIdBacklink(webId, pubkey);
112
-
275
+ // Always verify the WebID actually claims this pubkey. The
276
+ // earlier same-origin shortcut was unsafe on multi-tenant
277
+ // pods: same-origin doesn't equal same-control. Mallory who
278
+ // owns `<host>/.well-known/did/nostr/<MallorysPubkey>.json`
279
+ // could publish a DID doc with `alsoKnownAs` pointing at
280
+ // Alice's WebID on the same host, and "same origin" would
281
+ // accept it. The verifier checks the Alice-side profile for
282
+ // a verificationMethod that actually claims this pubkey, so
283
+ // the binding can't be forged from outside Alice's profile.
284
+ let verified;
285
+ try {
286
+ verified = await verifyWebIdBacklink(webId, pubkey);
287
+ } catch (err) {
288
+ if (err instanceof TransientBacklinkError) {
289
+ // Backlink fetch flapped (network / SSRF / redirect / 5xx).
290
+ // Don't pin a 5-minute null — retry sooner.
291
+ setCacheEntry(cacheKey, { webId: null, timestamp: Date.now(), failureTtl: true });
292
+ return null;
293
+ }
294
+ throw err;
295
+ }
113
296
  if (verified) {
114
- cache.set(cacheKey, { webId, timestamp: Date.now() });
297
+ setCacheEntry(cacheKey, { webId, timestamp: Date.now() });
115
298
  return webId;
116
299
  }
117
-
118
- cache.set(cacheKey, { webId: null, timestamp: Date.now() });
300
+ // Verified absence: the WebID profile responded successfully but
301
+ // didn't claim the pubkey. Steady-state answer; cache the full
302
+ // CACHE_TTL so we don't hammer the resolver chain.
303
+ setCacheEntry(cacheKey, { webId: null, timestamp: Date.now() });
119
304
  return null;
120
305
 
121
306
  } catch (err) {
122
307
  // Cache failures with short TTL to avoid hammering a down service
123
- cache.set(cacheKey, { webId: null, timestamp: Date.now(), failureTtl: true });
308
+ setCacheEntry(cacheKey, { webId: null, timestamp: Date.now(), failureTtl: true });
124
309
  rateLimitedError(`did:${pubkey.substring(0, 8)}`, `DID resolution error for ${pubkey}: ${err.message}`);
125
310
  return null;
126
311
  }
127
312
  }
128
313
 
314
+ /** Sentinel error class: backlink fetch failed transiently (network,
315
+ * SSRF refusal, redirect cap, timeout, etc.). Caller should cache
316
+ * with the short failureTtl so a flapping WebID host doesn't pin a
317
+ * null answer for the full 5-minute steady-state TTL. */
318
+ class TransientBacklinkError extends Error {
319
+ constructor(message) { super(message); this.name = 'TransientBacklinkError'; }
320
+ }
321
+
129
322
  /**
130
- * Verify WebID profile links back to did:nostr
323
+ * Verify WebID profile links back to did:nostr.
324
+ *
325
+ * Returns:
326
+ * - `true` — linkage found (CID-VM or owl:sameAs)
327
+ * - `false` — fetched and parsed, but no linkage (verified
328
+ * absence — caller caches with the steady-state TTL)
329
+ * - throws `TransientBacklinkError` — fetch/parse failed
330
+ * (caller catches and caches with the short failureTtl)
331
+ *
131
332
  * @param {string} webId - WebID URL
132
333
  * @param {string} pubkey - Nostr pubkey
133
334
  * @returns {Promise<boolean>}
134
335
  */
135
336
  async function verifyWebIdBacklink(webId, pubkey) {
337
+ const expectedDid = `did:nostr:${pubkey.toLowerCase()}`;
338
+ // The WebID came out of an externally-fetched DID doc, so it's
339
+ // untrusted until verified. fetchWithRedirectGuard re-runs the
340
+ // SSRF check on every redirect hop and refuses cross-origin
341
+ // redirects, so a forged DID doc can't bounce us through an
342
+ // open-redirect into a private IP.
343
+ let backlinkRes;
136
344
  try {
137
- const expectedDid = `did:nostr:${pubkey.toLowerCase()}`;
138
-
139
- // Fetch WebID profile
140
- const res = await fetchWithTimeout(webId, {
141
- headers: { 'Accept': 'application/ld+json, application/json, text/html' }
345
+ backlinkRes = await fetchWithRedirectGuard(webId, {
346
+ accept: 'application/ld+json, application/json, text/html',
142
347
  });
348
+ } catch (err) {
349
+ // Network/SSRF/redirect/size/timeout — transient.
350
+ throw new TransientBacklinkError(`fetch failed: ${err.message}`);
351
+ }
352
+ // Status classification:
353
+ // - 5xx: transient ("try again later")
354
+ // - 408 (request timeout) and 429 (too many requests): also
355
+ // transient — the host couldn't / wouldn't answer right now,
356
+ // not "the linkage is permanently absent"
357
+ // - other 4xx (404, 410, etc.): verified absence — the host
358
+ // answered authoritatively that this resource doesn't exist
359
+ // or is gone, cache as steady-state
360
+ // - 3xx (would only reach here as redirect that resolved):
361
+ // also verified absence (no redirect-to-content arrived)
362
+ if (
363
+ (backlinkRes.status >= 500 && backlinkRes.status < 600) ||
364
+ backlinkRes.status === 408 ||
365
+ backlinkRes.status === 429
366
+ ) {
367
+ throw new TransientBacklinkError(`HTTP ${backlinkRes.status}`);
368
+ }
369
+ if (backlinkRes.status < 200 || backlinkRes.status >= 300) {
370
+ return false;
371
+ }
372
+ const contentType = (backlinkRes.headers.get('content-type') || '');
373
+ const text = backlinkRes.body;
374
+
375
+ // Two acceptable linkage shapes (either is sufficient):
376
+ // 1. CID v1: a verificationMethod containing this Nostr pubkey
377
+ // that is referenced from `authentication`. This is what
378
+ // JSS profiles ship and what the LWS10-CID resource-side
379
+ // verifier checks. Stronger than sameAs because the user
380
+ // is asserting the key, not merely an identity equivalence.
381
+ // 2. owl:sameAs / schema:sameAs to did:nostr:<pubkey>. Older
382
+ // shape; still accepted for compatibility.
383
+ // Pass `backlinkRes.url` (the FINAL post-redirect URL) as the
384
+ // base for absolutizing relative IDs in the profile. Profiles
385
+ // with a relative subject (`"@id": "#me"`) and absolute VM IDs
386
+ // can't otherwise be absolutized correctly by checkCidVmBacklink.
387
+ const checkProfile = (jsonLd) =>
388
+ checkCidVmBacklink(jsonLd, pubkey, backlinkRes.url) ||
389
+ checkSameAsLink(jsonLd, expectedDid);
390
+
391
+ // Handle HTML with JSON-LD data island
392
+ if (contentType.includes('text/html')) {
393
+ const jsonLdMatch = text.match(/<script\s+type=["']application\/ld\+json["']\s*>([\s\S]*?)<\/script>/i);
394
+ if (jsonLdMatch) {
395
+ try {
396
+ return checkProfile(JSON.parse(jsonLdMatch[1]));
397
+ } catch {
398
+ // Parsed bytes but the JSON-LD island was malformed —
399
+ // verified absence (the host responded; the linkage is
400
+ // genuinely not there in a usable form).
401
+ return false;
402
+ }
403
+ }
404
+ return false;
405
+ }
143
406
 
144
- if (!res.ok) {
407
+ // Handle JSON-LD directly
408
+ if (contentType.includes('json')) {
409
+ try {
410
+ return checkProfile(JSON.parse(text));
411
+ } catch {
145
412
  return false;
146
413
  }
414
+ }
147
415
 
148
- const contentType = res.headers.get('content-type') || '';
149
- const text = await res.text();
416
+ return false;
417
+ }
150
418
 
151
- // Handle HTML with JSON-LD data island
152
- if (contentType.includes('text/html')) {
153
- const jsonLdMatch = text.match(/<script\s+type=["']application\/ld\+json["']\s*>([\s\S]*?)<\/script>/i);
154
- if (jsonLdMatch) {
155
- try {
156
- const jsonLd = JSON.parse(jsonLdMatch[1]);
157
- return checkSameAsLink(jsonLd, expectedDid);
158
- } catch {
159
- return false;
160
- }
161
- }
162
- return false;
419
+ /**
420
+ * Does the WebID profile contain a CID v1 `verificationMethod` for
421
+ * the given Nostr pubkey, referenced from `authentication`, with a
422
+ * `controller` consistent with the profile's expected controller set?
423
+ *
424
+ * Mirrors the resource-side verifier's three checks:
425
+ * (1) profile contains a Nostr-shaped VM matching `pubkey` (with
426
+ * BIP-340 even-y validation handled inside
427
+ * extractNostrPubkeysFromProfile)
428
+ * (2) the VM is referenced from `authentication` — a key in
429
+ * `verificationMethod` alone is NOT an auth binding
430
+ * (3) the VM's `controller` is in the profile's expected
431
+ * controller set (the profile-level `controller` field, or
432
+ * the profile subject as the CID-v1 self-control fallback)
433
+ *
434
+ * @param {object} jsonLd - parsed WebID profile
435
+ * @param {string} pubkey - target Nostr x-only pubkey hex
436
+ * @param {string} [docUrl] - URL the profile was fetched from. Used
437
+ * as the base for absolutizing relative IDs when the profile's
438
+ * subject `@id` is itself relative (e.g. `"@id": "#me"`). Without
439
+ * this fallback, mixed-shape profiles (relative subject + absolute
440
+ * VM IDs) would absolutize against an empty base and the
441
+ * authentication-membership check would silently fail.
442
+ */
443
+ function checkCidVmBacklink(jsonLd, pubkey, docUrl) {
444
+ const target = pubkey.toLowerCase();
445
+ const vms = extractNostrPubkeysFromProfile(jsonLd);
446
+ if (vms.length === 0) return false;
447
+
448
+ // Compute the URL base used for absolutizing relative IDs.
449
+ // Preference order:
450
+ // 1. profile['@id']/id when it's an absolute URL
451
+ // 2. the document URL the profile was fetched from
452
+ // 3. empty string (last resort — falls back to raw IDs)
453
+ const subjectRaw = jsonLd?.['@id'] || jsonLd?.id || '';
454
+ const stripHash = (u) => { try { const x = new URL(u); x.hash = ''; return x.toString(); } catch { return ''; } };
455
+ let base = stripHash(subjectRaw);
456
+ if (!base && docUrl) base = stripHash(docUrl);
457
+ // The absolute subject — used for the CID-v1 self-control
458
+ // fallback (controller defaults to the profile subject if no
459
+ // explicit controller is declared).
460
+ const profileSubject = base ? (() => {
461
+ try { const u = new URL(subjectRaw, base); return u.toString(); }
462
+ catch { return base; }
463
+ })() : '';
464
+
465
+ const absolutize = (s) => {
466
+ try { return new URL(s, base).toString(); }
467
+ catch { return s; }
468
+ };
469
+ const collectIds = (val) => {
470
+ const out = [];
471
+ const list = Array.isArray(val) ? val : (val ? [val] : []);
472
+ for (const ent of list) {
473
+ let id;
474
+ if (typeof ent === 'string') id = ent;
475
+ else if (ent && typeof ent === 'object') id = ent['@id'] || ent.id;
476
+ if (id) out.push(absolutize(id));
163
477
  }
478
+ return out;
479
+ };
480
+
481
+ // Authentication-referenced IDs.
482
+ const authIds = new Set(collectIds(jsonLd?.authentication));
483
+
484
+ // Expected controller set. Match the resource-side verifier:
485
+ // declared controllers if any, otherwise fall back to the
486
+ // profile subject (CID v1 self-control).
487
+ const expectedControllers = new Set(collectIds(jsonLd?.controller));
488
+ if (expectedControllers.size === 0 && profileSubject) {
489
+ expectedControllers.add(profileSubject);
490
+ }
164
491
 
165
- // Handle JSON-LD directly
166
- if (contentType.includes('json')) {
167
- try {
168
- const jsonLd = JSON.parse(text);
169
- return checkSameAsLink(jsonLd, expectedDid);
170
- } catch {
171
- return false;
172
- }
492
+ for (const { pubkey: vmPubkey, vm } of vms) {
493
+ if (vmPubkey !== target) continue;
494
+ const vmIdRaw = vm.id || vm['@id'];
495
+ if (typeof vmIdRaw !== 'string') continue;
496
+ const vmId = absolutize(vmIdRaw);
497
+ if (!authIds.has(vmId)) continue;
498
+ // Check (3): VM MUST declare an explicit `controller`, AND
499
+ // that controller must be in expectedControllers. Match the
500
+ // resource-side verifier (src/auth/nostr.js + the well-known
501
+ // indexer) — neither falls back to "origin match means
502
+ // controller match" for a controller-less VM. A VM with no
503
+ // controller is ambiguous and should not authenticate; if
504
+ // the user wanted self-control, they can declare it.
505
+ const vmCtrls = collectIds(vm.controller);
506
+ if (vmCtrls.length === 0) continue;
507
+ for (const c of vmCtrls) {
508
+ if (expectedControllers.has(c)) return true;
173
509
  }
174
-
175
- return false;
176
-
177
- } catch (err) {
178
- rateLimitedError(`backlink:${webId}`, `WebID backlink verification error for ${webId}: ${err.message}`);
179
- return false;
180
510
  }
511
+ return false;
181
512
  }
182
513
 
183
514
  /**
@@ -230,3 +561,16 @@ function checkSameAsLink(jsonLd, expectedDid) {
230
561
  export function clearCache() {
231
562
  cache.clear();
232
563
  }
564
+
565
+ /** @internal — exposed for tests; current cache size after evictions. */
566
+ export function _cacheSizeForTests() {
567
+ return cache.size;
568
+ }
569
+
570
+ /** @internal — exposed for tests; thin wrapper over checkCidVmBacklink. */
571
+ export function _checkCidVmBacklinkForTests(profile, pubkey, docUrl) {
572
+ return checkCidVmBacklink(profile, pubkey, docUrl);
573
+ }
574
+
575
+ /** @internal — exposed for tests; LRU max for assertions. */
576
+ export const _CACHE_MAX_FOR_TESTS = CACHE_MAX_ENTRIES;