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.
@@ -0,0 +1,458 @@
1
+ /**
2
+ * did:nostr HTTP resolution endpoint.
3
+ *
4
+ * Implements the well-known path from the did:nostr spec:
5
+ *
6
+ * GET /.well-known/did/nostr/<pubkey>.json
7
+ * GET /.well-known/did/nostr/<pubkey>.jsonld
8
+ * GET /.well-known/did/nostr/<pubkey>
9
+ *
10
+ * For any local account whose WebID profile declares this Nostr pubkey
11
+ * as a CID `verificationMethod` referenced from `authentication`, JSS
12
+ * generates a DID document on the fly with `alsoKnownAs: [<webId>]`.
13
+ * Other resolvers (nostr.social, nostr.rocks, JSS's own
14
+ * `src/auth/did-nostr.js`) can then fetch the DID doc from this pod
15
+ * and follow the WebID linkage — making the pod its own
16
+ * authoritative DID resolver for its accounts.
17
+ *
18
+ * Closes the "type your username" UX hack on the IdP login page
19
+ * (#403 / #405): the existing did-nostr resolver finds local users
20
+ * via this endpoint without any user-typed hint.
21
+ */
22
+
23
+ import path from 'path';
24
+ import fs from 'fs-extra';
25
+ import { findById } from './accounts.js';
26
+ import { extractNostrPubkeysFromProfile } from '../auth/nostr-keys.js';
27
+
28
+ // In-memory pubkey → resolved-account-record index. Built lazily
29
+ // from disk; rebuilt when the TTL expires. Real production wants
30
+ // a write-path hook on LDP PUT/PATCH so updates are immediate;
31
+ // that's filed as a follow-up.
32
+ //
33
+ // Each entry stores `{ accountId, webId, mtimeMs }` so the hot
34
+ // path (NIP-98 auth via resolveDidNostrLocally + every DID-doc
35
+ // request) can answer without re-reading the account JSON from
36
+ // disk. accountId is kept for log diagnostics; webId is what
37
+ // the resolver actually needs.
38
+ let pubkeyIndex = null; // Map<pubkeyHex, { accountId, webId, mtimeMs }>
39
+ let indexBuiltAt = 0;
40
+ let rebuildInFlight = null; // Promise — in-flight rebuild dedup
41
+ const INDEX_TTL_MS = 5 * 60 * 1000;
42
+
43
+ // Rate-limit "profile unreadable" log spam. A single broken profile
44
+ // shouldn't flood logs every 5 minutes (every TTL rebuild) — but the
45
+ // first occurrence per rebuild cycle MUST be logged so operators can
46
+ // debug "why isn't my pubkey publishing?" without grepping silence.
47
+ const PROFILE_LOG_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
48
+ const profileLogTracker = new Map(); // accountId -> last logged ms
49
+ function logProfileFailure(accountId, profilePath, err) {
50
+ const now = Date.now();
51
+ const last = profileLogTracker.get(accountId) || 0;
52
+ if (now - last < PROFILE_LOG_INTERVAL_MS) return;
53
+ profileLogTracker.set(accountId, now);
54
+ // Trim the tracker so it can't grow without bound.
55
+ if (profileLogTracker.size > 10_000) {
56
+ const oldest = profileLogTracker.keys().next().value;
57
+ if (oldest !== undefined) profileLogTracker.delete(oldest);
58
+ }
59
+ console.error(
60
+ `well-known-did-nostr: skipping account ${accountId} ` +
61
+ `(profile=${profilePath}): ${err.code || err.name || 'error'} ${err.message}`,
62
+ );
63
+ }
64
+ // Size cap on per-account profile reads. WebID profiles are tiny —
65
+ // 64 KB is generous and matches the bound the LDP layer would impose
66
+ // for any sane profile. A user shouldn't be able to make the indexer
67
+ // allocate megabytes by writing a giant profile, especially since
68
+ // rebuilds can be triggered by attacker-driven NIP-98 traffic once
69
+ // the TTL expires.
70
+ const MAX_PROFILE_BYTES = 64 * 1024;
71
+
72
+ /** @internal — exposed for tests */
73
+ export function _resetIndexForTests() {
74
+ pubkeyIndex = null;
75
+ indexBuiltAt = 0;
76
+ rebuildInFlight = null;
77
+ profileLogTracker.clear();
78
+ }
79
+
80
+ // Match the layout in src/idp/accounts.js — accounts live under
81
+ // <DATA_ROOT>/.idp/accounts. Computed lazily so DATA_ROOT changes
82
+ // (test setup, env override) are picked up.
83
+ function getAccountsDir() {
84
+ const dataRoot = process.env.DATA_ROOT || './data';
85
+ return path.join(dataRoot, '.idp', 'accounts');
86
+ }
87
+ function getWebIdIndexPath() {
88
+ return path.join(getAccountsDir(), '_webid_index.json');
89
+ }
90
+
91
+ /**
92
+ * Read a JSON file. Returns null in two cases (with different
93
+ * semantics, kept the same return shape for caller simplicity):
94
+ *
95
+ * - ENOENT — silently null. The index file legitimately doesn't
96
+ * exist on a fresh deployment with no accounts yet.
97
+ * - Any other error (parse error, permission denied, etc.) — null
98
+ * PLUS a loud console.error so operational issues surface in logs
99
+ * instead of silently disabling DID-doc publishing.
100
+ */
101
+ async function readJsonOrEmpty(file) {
102
+ try {
103
+ return await fs.readJson(file);
104
+ } catch (err) {
105
+ if (err.code === 'ENOENT') return null;
106
+ console.error(`well-known-did-nostr: failed to read ${file}: ${err.message}`);
107
+ return null;
108
+ }
109
+ }
110
+
111
+ async function rebuildPubkeyIndex() {
112
+ const idx = new Map();
113
+ const dataRoot = process.env.DATA_ROOT || './data';
114
+ const webIdIndex = await readJsonOrEmpty(getWebIdIndexPath());
115
+ if (!webIdIndex) {
116
+ pubkeyIndex = idx;
117
+ indexBuiltAt = Date.now();
118
+ return;
119
+ }
120
+ // Track pubkeys that appear under more than one account so we can
121
+ // EXCLUDE them rather than silently picking one. An ambiguous binding
122
+ // would make resolution depend on insertion order and be hard to
123
+ // diagnose; better to refuse and log loudly.
124
+ const seenAccounts = new Map(); // pubkey -> Set<accountId>
125
+ for (const [, accountId] of Object.entries(webIdIndex)) {
126
+ // Wrap each account read so one corrupt/unreadable account file
127
+ // can't take down resolution for everyone — the index would just
128
+ // skip that account and a single 500 wouldn't cascade across the
129
+ // whole pod's NIP-98 traffic.
130
+ let account;
131
+ try {
132
+ account = await findById(accountId);
133
+ } catch (err) {
134
+ console.error(
135
+ `well-known-did-nostr: skipping account ${accountId} ` +
136
+ `(read failed: ${err.message})`,
137
+ );
138
+ continue;
139
+ }
140
+ if (!account?.webId) continue;
141
+ const profilePath = profilePathFromWebId(dataRoot, account.webId, accountId);
142
+ if (!profilePath) continue;
143
+ let profile;
144
+ let mtimeMs = 0;
145
+ try {
146
+ const stat = await fs.stat(profilePath);
147
+ mtimeMs = stat.mtimeMs;
148
+ // Size cap to bound per-rebuild memory/CPU. A user can write
149
+ // their own profile, and TTL-expired rebuilds can be triggered
150
+ // by attacker-driven NIP-98 traffic — without this an
151
+ // adversarially-large profile could pin the event loop on
152
+ // JSON.parse during the rebuild loop.
153
+ if (stat.size > MAX_PROFILE_BYTES) {
154
+ console.error(
155
+ `well-known-did-nostr: skipping account ${accountId} ` +
156
+ `— profile size ${stat.size} > ${MAX_PROFILE_BYTES} bytes`,
157
+ );
158
+ continue;
159
+ }
160
+ const text = await fs.readFile(profilePath, 'utf8');
161
+ profile = JSON.parse(text);
162
+ } catch (err) {
163
+ // Log so operators can debug "why isn't my pubkey publishing?".
164
+ // Rate-limited per account so a single perpetually-broken
165
+ // profile can't flood logs every TTL cycle.
166
+ logProfileFailure(accountId, profilePath, err);
167
+ continue; // unreadable / malformed — skip
168
+ }
169
+ // CID semantics — match the resource-side checks:
170
+ // (1) profile's @id MUST match the account's webId (no fragment-
171
+ // swapping attack via a stored profile that claims to be
172
+ // someone else)
173
+ // (2) VM's controller MUST be in the profile's expected controller
174
+ // set (declared `controller`, with @id fallback)
175
+ // (3) VM MUST be referenced from `authentication` — a key in
176
+ // verificationMethod alone (no auth membership) shouldn't be
177
+ // published as authentic
178
+ const profileSubject = absolutize(profile?.['@id'] || profile?.id, stripHashIfAny(account.webId));
179
+ if (!profileSubject || profileSubject !== account.webId) continue;
180
+ const expectedControllers = collectControllerIds(profile, profileSubject);
181
+ if (expectedControllers.size === 0) continue;
182
+ // Pass the already-validated absolute subject as the base. Without
183
+ // this, profiles with a relative subject (e.g. `"@id": "#me"`)
184
+ // would absolutize their `authentication` entries against an
185
+ // unusable base, leaving the IDs relative — and then the
186
+ // `authIds.has(vmId)` check below would never match even when the
187
+ // VM is actually authenticated.
188
+ const authIds = collectAuthenticationIds(profile, stripHashIfAny(profileSubject));
189
+
190
+ for (const { pubkey, vm } of extractNostrPubkeysFromProfile(profile)) {
191
+ const vmId = absolutize(vm.id || vm['@id'], stripHashIfAny(profileSubject));
192
+ if (!vmId || !authIds.has(vmId)) continue;
193
+ const vmCtrls = collectControllerIds({ controller: vm.controller }, profileSubject);
194
+ let controllerOk = false;
195
+ for (const c of vmCtrls) {
196
+ if (expectedControllers.has(c)) { controllerOk = true; break; }
197
+ }
198
+ if (!controllerOk) continue;
199
+
200
+ // Duplicate-pubkey detection: track every account that claims
201
+ // it; resolve at the end of the scan.
202
+ if (!seenAccounts.has(pubkey)) seenAccounts.set(pubkey, new Set());
203
+ seenAccounts.get(pubkey).add(accountId);
204
+ // Cache the resolved webId in the index so the lookup hot
205
+ // path doesn't have to re-read the account JSON.
206
+ if (!idx.has(pubkey)) idx.set(pubkey, { accountId, webId: account.webId, mtimeMs });
207
+ }
208
+ }
209
+ // Drop ambiguous pubkeys and warn loudly.
210
+ for (const [pubkey, accountIds] of seenAccounts) {
211
+ if (accountIds.size > 1) {
212
+ console.error(
213
+ `well-known-did-nostr: pubkey ${pubkey} claimed by ` +
214
+ `${accountIds.size} accounts (${[...accountIds].join(', ')}) — ` +
215
+ `omitting from index to avoid ambiguous resolution`,
216
+ );
217
+ idx.delete(pubkey);
218
+ }
219
+ }
220
+ pubkeyIndex = idx;
221
+ indexBuiltAt = Date.now();
222
+ }
223
+
224
+ /**
225
+ * Derive the on-disk profile path from a WebID (and validate
226
+ * containment in DATA_ROOT). Returns the absolute filesystem path
227
+ * or `null` if the WebID is unparseable / would escape dataRoot.
228
+ *
229
+ * Why a separate function: WHATWG URL parsing already strips most
230
+ * `..` traversal at the URL layer, but the path-resolve containment
231
+ * check is defense-in-depth for any future caller that bypasses
232
+ * URL parsing (string manipulation, alternate parser, etc.). Lives
233
+ * in its own function so the containment branch is unit-testable
234
+ * with raw inputs that DON'T go through `new URL()`.
235
+ *
236
+ * @internal exported for tests
237
+ */
238
+ export function profilePathFromWebId(dataRoot, webId, accountId = 'unknown') {
239
+ if (typeof webId !== 'string') return null;
240
+ let pathname;
241
+ try {
242
+ pathname = new URL(webId).pathname;
243
+ } catch {
244
+ return null;
245
+ }
246
+ // Strip leading `/` so it's treated as a relative segment, then
247
+ // resolve and assert the result is at-or-under dataRootAbs. An
248
+ // account record whose webId path resolves outside dataRoot is
249
+ // never indexed.
250
+ const relPath = pathname.replace(/^\/+/, '');
251
+ const dataRootAbs = path.resolve(dataRoot);
252
+ const resolved = path.resolve(dataRootAbs, relPath);
253
+ if (resolved !== dataRootAbs && !resolved.startsWith(dataRootAbs + path.sep)) {
254
+ console.error(
255
+ `well-known-did-nostr: account ${accountId} webId ${webId} ` +
256
+ `resolves outside dataRoot (${resolved}) — skipping`,
257
+ );
258
+ return null;
259
+ }
260
+ return resolved;
261
+ }
262
+
263
+ function collectControllerIds(source, baseUrl) {
264
+ const out = new Set();
265
+ const c = source?.controller;
266
+ const list = Array.isArray(c) ? c : (c ? [c] : []);
267
+ for (const ent of list) {
268
+ let id;
269
+ if (typeof ent === 'string') id = ent;
270
+ else if (ent && typeof ent === 'object') id = ent['@id'] || ent.id;
271
+ if (id) out.add(absolutize(id, baseUrl));
272
+ }
273
+ // Fallback to @id when no explicit controller (CID v1 self-control).
274
+ if (out.size === 0 && source && (source['@id'] || source.id)) {
275
+ out.add(absolutize(source['@id'] || source.id, baseUrl));
276
+ }
277
+ return out;
278
+ }
279
+
280
+ /**
281
+ * Resolve a profile's `authentication` entries to a Set of absolute
282
+ * IDs. Caller MUST pass an already-absolute base URL — re-deriving
283
+ * the base from `profile['@id']` here would fail when the profile
284
+ * subject is relative (e.g. `"@id": "#me"`), leaving the resulting
285
+ * IDs relative and silently breaking the auth-membership check.
286
+ */
287
+ function collectAuthenticationIds(profile, baseUrl) {
288
+ const out = new Set();
289
+ const auth = profile?.authentication;
290
+ const list = Array.isArray(auth) ? auth : (auth ? [auth] : []);
291
+ for (const ent of list) {
292
+ let id;
293
+ if (typeof ent === 'string') id = ent;
294
+ else if (ent && typeof ent === 'object') id = ent['@id'] || ent.id;
295
+ if (id) out.add(absolutize(id, baseUrl));
296
+ }
297
+ return out;
298
+ }
299
+
300
+ function absolutize(u, base) {
301
+ if (!u) return u;
302
+ try { return new URL(u, base).toString(); } catch { return u; }
303
+ }
304
+
305
+ function stripHashIfAny(u) {
306
+ if (typeof u !== 'string') return u;
307
+ try { const url = new URL(u); url.hash = ''; return url.toString(); }
308
+ catch { return u; }
309
+ }
310
+
311
+ async function findAccountByNostrPubkey(pubkeyHex) {
312
+ const lower = pubkeyHex.toLowerCase();
313
+ if (!pubkeyIndex || (Date.now() - indexBuiltAt) > INDEX_TTL_MS) {
314
+ // Dedup concurrent rebuilds: under a burst of requests that all
315
+ // arrive after the TTL expires, only ONE rebuild runs and every
316
+ // other caller awaits its promise. Without this, N concurrent
317
+ // requests would each do a full disk scan + parse pass, with
318
+ // N-1 of them throwing away their result.
319
+ if (!rebuildInFlight) {
320
+ rebuildInFlight = rebuildPubkeyIndex().finally(() => {
321
+ rebuildInFlight = null;
322
+ });
323
+ }
324
+ await rebuildInFlight;
325
+ }
326
+ const entry = pubkeyIndex.get(lower);
327
+ if (!entry) return null;
328
+ // The webId is now stored on the index entry — no per-request
329
+ // findById disk read needed. NIP-98 auth (via
330
+ // resolveDidNostrLocally) and DID-doc generation hit this on
331
+ // every request, so dropping the I/O matters.
332
+ return { account: { webId: entry.webId }, mtimeMs: entry.mtimeMs };
333
+ }
334
+
335
+ /**
336
+ * In-process local DID resolution: given a Nostr pubkey, return the
337
+ * matching account's WebID without any network fetch. Lets the
338
+ * verifyNostrAuth resolver chain prefer local users via direct
339
+ * function call instead of a same-host HTTP loop, removing both the
340
+ * latency and the SSRF surface that came with feeding request-
341
+ * controlled host headers into a `fetch()`.
342
+ *
343
+ * Returns null for non-local pubkeys (caller falls back to the
344
+ * external HTTP resolver, with SSRF protection).
345
+ */
346
+ export async function resolveDidNostrLocally(pubkeyHex) {
347
+ if (typeof pubkeyHex !== 'string' || !/^[0-9a-f]{64}$/i.test(pubkeyHex)) return null;
348
+ const found = await findAccountByNostrPubkey(pubkeyHex.toLowerCase());
349
+ return found?.account?.webId || null;
350
+ }
351
+
352
+ /**
353
+ * Build a CID-shaped DID document for a Nostr pubkey + account pair.
354
+ *
355
+ * Uses the spec example's vocabulary (Multikey + publicKeyMultibase)
356
+ * for max interop with our own resolver and the W3C VC track. The
357
+ * Multikey value is computed deterministically from the pubkey via
358
+ * the f-form recipe (multibase `f` + multicodec `e701` + parity byte
359
+ * `02` + 32-byte xonly hex) — the same shape the doctor's B.2 emits.
360
+ */
361
+ function buildDidDocument({ pubkey, webId }) {
362
+ const did = `did:nostr:${pubkey.toLowerCase()}`;
363
+ const multikey = `f` + `e701` + `02` + pubkey.toLowerCase();
364
+ const vmId = `${did}#key1`;
365
+ return {
366
+ '@context': ['https://w3id.org/did', 'https://w3id.org/nostr/context'],
367
+ 'id': did,
368
+ 'type': 'DIDNostr',
369
+ 'alsoKnownAs': [webId],
370
+ 'verificationMethod': [{
371
+ 'id': vmId,
372
+ 'type': 'Multikey',
373
+ 'controller': did,
374
+ 'publicKeyMultibase': multikey,
375
+ }],
376
+ 'authentication': [vmId],
377
+ 'assertionMethod': [vmId],
378
+ };
379
+ }
380
+
381
+ /**
382
+ * Fastify handler for GET /.well-known/did/nostr/:pubkeyAndExt
383
+ *
384
+ * The :pubkeyAndExt parameter accepts `<pubkey>`, `<pubkey>.json`, or
385
+ * `<pubkey>.jsonld`; the body is the same DID doc either way. The
386
+ * spec specifies `.json` as the canonical path, so that's the
387
+ * primary; the others are friendly aliases.
388
+ *
389
+ * The data root is read from `process.env.DATA_ROOT` (matching
390
+ * `accounts.js`). We don't accept a parameter for it because the
391
+ * account-index path is derived from the same env elsewhere — taking
392
+ * a parameter would create two sources of truth and be misleading.
393
+ */
394
+ export function buildWellKnownDidNostrHandler() {
395
+ return async function handleWellKnownDidNostr(request, reply) {
396
+ const raw = String(request.params.pubkeyAndExt || '');
397
+ const ext = raw.endsWith('.jsonld') ? '.jsonld'
398
+ : raw.endsWith('.json') ? '.json'
399
+ : '';
400
+ const pubkey = (ext ? raw.slice(0, -ext.length) : raw).toLowerCase();
401
+ // Per-status header policy (so success and failure responses are
402
+ // both predictable to clients/CDNs):
403
+ // 200 Cache-Control: max-age=3600 — DID doc seldom changes
404
+ // 404 Cache-Control: max-age=60 — short TTL so a newly added
405
+ // key surfaces quickly
406
+ // 400 Cache-Control: no-store — request was malformed; never cache
407
+ // Nostr-Timestamp is set on EVERY response (including errors) per
408
+ // the did:nostr spec recommendation that clients can correlate the
409
+ // resolver's clock with the answer they got. Last-Modified only
410
+ // makes sense for 200 (it tracks the underlying profile mtime);
411
+ // for errors we omit it because there's no underlying resource.
412
+ const nowEpoch = Math.floor(Date.now() / 1000);
413
+ if (!/^[0-9a-f]{64}$/.test(pubkey)) {
414
+ return reply.code(400)
415
+ .header('Content-Type', 'application/json')
416
+ .header('Cache-Control', 'no-store')
417
+ .header('Nostr-Timestamp', String(nowEpoch))
418
+ .send({ error: 'pubkey must be 64 hex chars' });
419
+ }
420
+ const found = await findAccountByNostrPubkey(pubkey);
421
+ if (!found?.account) {
422
+ return reply.code(404)
423
+ .header('Cache-Control', 'max-age=60')
424
+ .header('Content-Type', 'application/json')
425
+ .header('Nostr-Timestamp', String(nowEpoch))
426
+ .send({ error: 'no local account claims this pubkey' });
427
+ }
428
+ const { account, mtimeMs } = found;
429
+ if (!account.webId) {
430
+ // Defensive — every account has a webId, but if one slips through,
431
+ // the DID doc would be useless without alsoKnownAs.
432
+ return reply.code(404)
433
+ .header('Cache-Control', 'max-age=60')
434
+ .header('Content-Type', 'application/json')
435
+ .header('Nostr-Timestamp', String(nowEpoch))
436
+ .send({ error: 'account has no webId' });
437
+ }
438
+
439
+ const didDoc = buildDidDocument({ pubkey, webId: account.webId });
440
+ const contentType = ext === '.jsonld'
441
+ ? 'application/did+ld+json; charset=utf-8'
442
+ : 'application/did+json; charset=utf-8';
443
+ // Two distinct timestamp semantics, two distinct headers:
444
+ // - Nostr-Timestamp: the resolver's clock at answer time (uniform
445
+ // across 200/404/400 — clients use it to correlate the resolver
446
+ // clock with their own, regardless of cache hits).
447
+ // - Last-Modified: when the underlying mapping (the user's
448
+ // profile file) actually changed — only meaningful for 200,
449
+ // so clients/CDNs can do conditional GET against the source.
450
+ const lastModifiedDate = mtimeMs > 0 ? new Date(mtimeMs) : new Date(indexBuiltAt);
451
+ return reply
452
+ .header('Content-Type', contentType)
453
+ .header('Cache-Control', 'max-age=3600')
454
+ .header('Nostr-Timestamp', String(nowEpoch))
455
+ .header('Last-Modified', lastModifiedDate.toUTCString())
456
+ .send(didDoc);
457
+ };
458
+ }
package/src/server.js CHANGED
@@ -11,6 +11,10 @@ import { authorize, handleUnauthorized } from './auth/middleware.js';
11
11
  import { notificationsPlugin } from './notifications/index.js';
12
12
  import { startFileWatcher } from './notifications/events.js';
13
13
  import { idpPlugin } from './idp/index.js';
14
+ // well-known-did-nostr is loaded lazily inside the idpEnabled branch
15
+ // below so non-IdP deployments don't pull in the IdP accounts module
16
+ // (bcryptjs etc.) just to register Fastify routes. The same lazy-load
17
+ // pattern is used in src/auth/nostr.js for the NIP-98 verifier.
14
18
  import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js';
15
19
  import { handleCorsProxy, isCorsProxyRequest, setProxyCorsHeaders } from './handlers/cors-proxy.js';
16
20
  import { AccessMode } from './wac/parser.js';
@@ -651,6 +655,68 @@ export function createServer(options = {}) {
651
655
  }
652
656
  };
653
657
 
658
+ // /.well-known/did/nostr/<pubkey>(.json|.jsonld)? — did:nostr HTTP
659
+ // resolution for accounts on this pod (#407). Registered before the
660
+ // LDP wildcard so it actually matches; without this the
661
+ // dynamic-segment + .json suffix gets swallowed by the wildcard
662
+ // GET /* handler below and never reaches our route.
663
+ // The 405 method blocks for /.well-known/did/nostr/* must be
664
+ // registered REGARDLESS of idpEnabled. The global auth preHandler
665
+ // unconditionally skips WAC for any /.well-known/* request (that's
666
+ // the spec-mandated public namespace), so without these blocks the
667
+ // wildcard write handlers (PUT/POST/PATCH/DELETE /*) would still
668
+ // accept unauthenticated writes under this namespace on non-IdP
669
+ // deployments — anyone could PUT a file at
670
+ // /.well-known/did/nostr/whatever.json. The GET/HEAD generation
671
+ // (which actually serves DID docs) stays IdP-only since it reads
672
+ // the IdP accounts index.
673
+ const methodNotAllowed = async (request, reply) => reply.code(405)
674
+ .header('Allow', 'GET, HEAD, OPTIONS')
675
+ .send({ error: 'Method Not Allowed' });
676
+ // OPTIONS must report the SAME `Allow` set as the 405s. Without
677
+ // an explicit handler the request falls through to the wildcard
678
+ // `OPTIONS /*` which advertises GET, HEAD, PUT, DELETE, PATCH,
679
+ // POST — wrong for this namespace and confusing to CORS
680
+ // preflights. We also set the full CORS header set (origin,
681
+ // allowed-methods restricted to read-only, allowed-headers,
682
+ // credentials, max-age) so browser preflights to this endpoint
683
+ // succeed; bare 204 with only `Allow` would fail CORS.
684
+ const optionsForReadOnlyNamespace = async (request, reply) => {
685
+ const cors = getCorsHeaders(request.headers.origin);
686
+ cors['Access-Control-Allow-Methods'] = 'GET, HEAD, OPTIONS';
687
+ return reply.code(204)
688
+ .header('Allow', 'GET, HEAD, OPTIONS')
689
+ .headers(cors)
690
+ .send();
691
+ };
692
+ for (const pat of [
693
+ '/.well-known/did/nostr',
694
+ '/.well-known/did/nostr/',
695
+ '/.well-known/did/nostr/:pubkeyAndExt',
696
+ '/.well-known/did/nostr/*',
697
+ ]) {
698
+ fastify.put(pat, methodNotAllowed);
699
+ fastify.post(pat, methodNotAllowed);
700
+ fastify.patch(pat, methodNotAllowed);
701
+ fastify.delete(pat, methodNotAllowed);
702
+ fastify.options(pat, optionsForReadOnlyNamespace);
703
+ }
704
+ if (idpEnabled) {
705
+ // Async plugin registration so the dynamic import lives in here,
706
+ // not at module top level. Non-IdP deployments never enter this
707
+ // branch and never pull in the IdP accounts module.
708
+ fastify.register(async (instance) => {
709
+ const { buildWellKnownDidNostrHandler } = await import('./idp/well-known-did-nostr.js');
710
+ const wellKnownDidNostr = buildWellKnownDidNostrHandler();
711
+ instance.get('/.well-known/did/nostr/:pubkeyAndExt', wellKnownDidNostr);
712
+ // HEAD shares the GET implementation so headers (Content-Type,
713
+ // Cache-Control, Last-Modified, etc.) match. Without this the
714
+ // request falls through to the wildcard HEAD /* below and the
715
+ // LDP layer returns 404 because there's no on-disk file.
716
+ instance.head('/.well-known/did/nostr/:pubkeyAndExt', wellKnownDidNostr);
717
+ });
718
+ }
719
+
654
720
  // LDP routes - using wildcard routing
655
721
  // Read operations - no rate limit (handled by bodyLimit)
656
722
  fastify.get('/*', handleGet);
@@ -677,4 +677,86 @@ describe('Content Negotiation — q-weights and HEAD/GET parity (#325)', () => {
677
677
  assert.strictEqual(ct(authed), ct(anon));
678
678
  });
679
679
  });
680
+
681
+ describe('container with index.html — browser Accept (#409)', () => {
682
+ // Regression: a container that has an index.html with a *valid*
683
+ // <script type="application/ld+json"> data island used to return that
684
+ // data island as application/ld+json to plain browser GETs.
685
+ // selectContentType iterates the Accept list — for a browser sending
686
+ // `Accept: text/html, ..., */*;q=0.8` it sees text/html (and other
687
+ // HTML-ish types) but doesn't recognize any of them, then hits the
688
+ // `*/*` arm and returns JSON-LD, so the user-visible page silently
689
+ // flipped to JSON.
690
+ const HTML_WITH_JSONLD = '<!DOCTYPE html><html><head><title>Home</title>'
691
+ + '<script type="application/ld+json">'
692
+ + JSON.stringify({ '@context': { foaf: 'http://xmlns.com/foaf/0.1/' }, '@id': '#me', 'foaf:name': 'Carol' })
693
+ + '</script></head><body><h1>hello</h1></body></html>';
694
+
695
+ before(async () => {
696
+ // Container with an index.html containing a parseable JSON-LD island.
697
+ await request('/qwtest/public/page/', { method: 'PUT', auth: 'qwtest' });
698
+ await request('/qwtest/public/page/index.html', {
699
+ method: 'PUT',
700
+ headers: { 'Content-Type': 'text/html' },
701
+ body: HTML_WITH_JSONLD,
702
+ auth: 'qwtest'
703
+ });
704
+ });
705
+
706
+ it('browser Accept (text/html with */*;q=0.8) → text/html, not JSON-LD', async () => {
707
+ const res = await request('/qwtest/public/page/', {
708
+ headers: { Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' }
709
+ });
710
+ assertStatus(res, 200);
711
+ assert.strictEqual(ct(res), 'text/html',
712
+ 'browser GET on a container with index.html must return the HTML body, not the embedded data island');
713
+ const body = await res.text();
714
+ assert.ok(body.includes('<h1>hello</h1>'), 'response should be the index.html body');
715
+ });
716
+
717
+ it('plain Accept: text/html → text/html', async () => {
718
+ const res = await request('/qwtest/public/page/', { headers: { Accept: 'text/html' } });
719
+ assertStatus(res, 200);
720
+ assert.strictEqual(ct(res), 'text/html');
721
+ });
722
+
723
+ it('explicit Accept: application/ld+json → JSON-LD from data island still works', async () => {
724
+ const res = await request('/qwtest/public/page/', {
725
+ headers: { Accept: 'application/ld+json' }
726
+ });
727
+ assertStatus(res, 200);
728
+ assert.strictEqual(ct(res), 'application/ld+json');
729
+ const body = await res.json();
730
+ assert.strictEqual(body['foaf:name'], 'Carol',
731
+ 'should still extract the data island when JSON-LD is explicitly asked for');
732
+ });
733
+
734
+ it('explicit Accept: text/turtle → Turtle from data island still works', async () => {
735
+ const res = await request('/qwtest/public/page/', { headers: { Accept: 'text/turtle' } });
736
+ assertStatus(res, 200);
737
+ assert.strictEqual(ct(res), 'text/turtle');
738
+ const body = await res.text();
739
+ assert.ok(body.includes('Carol'), 'turtle output should contain the data island content');
740
+ });
741
+
742
+ // The original bug was specifically GET vs HEAD divergence — the HEAD
743
+ // handler already had the explicitJson guard, GET didn't. Pin the
744
+ // parity here so any future drift between the two branches fails.
745
+ const parityCases = [
746
+ ['browser Accept', { Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' }],
747
+ ['Accept: text/html', { Accept: 'text/html' }],
748
+ ['Accept: application/ld+json', { Accept: 'application/ld+json' }],
749
+ ['Accept: text/turtle', { Accept: 'text/turtle' }]
750
+ ];
751
+ for (const [label, headers] of parityCases) {
752
+ it(`HEAD === GET content-type — ${label}`, async () => {
753
+ const get = await request('/qwtest/public/page/', { headers });
754
+ const head = await request('/qwtest/public/page/', { method: 'HEAD', headers });
755
+ assert.strictEqual(get.status, 200);
756
+ assert.strictEqual(head.status, 200);
757
+ assert.strictEqual(ct(head), ct(get),
758
+ `HEAD ct (${ct(head)}) must equal GET ct (${ct(get)}) for ${label}`);
759
+ });
760
+ }
761
+ });
680
762
  });