javascript-solid-server 0.0.180 → 0.0.182

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.
@@ -410,7 +410,8 @@
410
410
  "Bash(/usr/bin/chromium-browser --headless --no-sandbox --disable-gpu --hide-scrollbars --window-size=480,800 --screenshot=/tmp/consent-preview.png file:///tmp/consent-preview.html)",
411
411
  "Bash(cp /tmp/consent-preview.html ~/consent-preview.html)",
412
412
  "Read(//home/melvin/**)",
413
- "Bash(/usr/bin/chromium-browser --headless --no-sandbox --disable-gpu --hide-scrollbars --window-size=520,900 --screenshot=consent.png file:///home/melvin/consent-preview.html)"
413
+ "Bash(/usr/bin/chromium-browser --headless --no-sandbox --disable-gpu --hide-scrollbars --window-size=520,900 --screenshot=consent.png file:///home/melvin/consent-preview.html)",
414
+ "Bash(sed -i '0,/\"version\": \"0.0.181\"/{s/\"version\": \"0.0.181\"/\"version\": \"0.0.182\"/}' package-lock.json)"
414
415
  ]
415
416
  }
416
417
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.180",
3
+ "version": "0.0.182",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -28,6 +28,14 @@ const LIVE_RELOAD_SCRIPT = `<script>(function(){var ws=new WebSocket((location.p
28
28
  // where a cached data variant was served on top-level navigation (#315).
29
29
  const RDF_CACHE_CONTROL = 'private, no-cache, must-revalidate';
30
30
 
31
+ // Detects when the request's Accept header explicitly names a JSON
32
+ // media type. Used by the container/index.html branches of GET and HEAD
33
+ // to decide whether to surface the embedded JSON-LD data island —
34
+ // without this guard, selectContentType's `*/*` arm would divert plain
35
+ // browser requests into the RDF branch (#409). Hoisted so GET and HEAD
36
+ // can't drift apart silently.
37
+ const EXPLICIT_JSON_RE = /\b(application\/ld\+json|application\/json)\b/i;
38
+
31
39
  /**
32
40
  * Inject live reload script into HTML content
33
41
  */
@@ -161,7 +169,16 @@ export async function handleGet(request, reply) {
161
169
  const wantsTurtle = negotiated === RDF_TYPES.TURTLE
162
170
  || negotiated === RDF_TYPES.N3
163
171
  || negotiated === 'application/n-triples';
164
- const wantsJsonLd = negotiated === RDF_TYPES.JSON_LD;
172
+ // Only treat as JSON-LD when Accept *explicitly* asks for JSON.
173
+ // selectContentType doesn't recognize text/html or
174
+ // application/xhtml+xml, so for a browser Accept like
175
+ // `text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8`
176
+ // it walks past those unsupported types and lands on `*/*`, which
177
+ // returns JSON-LD — diverting plain browser GETs into the RDF
178
+ // branch and serving the embedded data island instead of the
179
+ // index.html body. Mirrors the HEAD-handler logic below (#409).
180
+ const explicitJson = EXPLICIT_JSON_RE.test(acceptHeader);
181
+ const wantsJsonLd = negotiated === RDF_TYPES.JSON_LD && explicitJson;
165
182
 
166
183
  if (wantsTurtle || wantsJsonLd) {
167
184
  // Extract JSON-LD from HTML data island
@@ -601,7 +618,7 @@ export async function handleHead(request, reply) {
601
618
  // For an index.html container, only override to JSON-LD if the
602
619
  // Accept header explicitly asked for JSON; otherwise fall back
603
620
  // to text/html so HEAD matches the index.html that GET serves.
604
- const explicitJson = /\b(application\/ld\+json|application\/json)\b/i.test(acceptHeader);
621
+ const explicitJson = EXPLICIT_JSON_RE.test(acceptHeader);
605
622
  contentType = (indexExists && !explicitJson) ? 'text/html' : 'application/ld+json';
606
623
  } else {
607
624
  contentType = indexExists ? 'text/html' : 'application/ld+json';
@@ -138,45 +138,85 @@ async function rebuildPubkeyIndex() {
138
138
  continue;
139
139
  }
140
140
  if (!account?.webId) continue;
141
- const profilePath = profilePathFromWebId(dataRoot, account.webId, accountId);
142
- if (!profilePath) continue;
143
- let profile;
141
+ // Probe candidate paths in order until one ALSO passes the @id
142
+ // check. Multiple candidates can exist on disk simultaneously
143
+ // (e.g. root pod and named pods coexisting under the same
144
+ // dataRoot, or a subdomain pod with a coincidentally-named
145
+ // path-mode dir). The path-mode candidate may exist but belong
146
+ // to a different account — keep going until we find one whose
147
+ // declared `@id` matches account.webId.
148
+ const { paths: candidates, skipped: containmentSkipped } =
149
+ profilePathCandidates(dataRoot, account.webId, account.podName);
150
+ // Track per-candidate failure reasons so operators get a precise
151
+ // diagnostic when nothing matches — distinguishing
152
+ // "profile genuinely missing" from "@id mismatch" from "oversized"
153
+ // from "containment rejected".
154
+ const reasons = containmentSkipped.map(s => `${s.path}: ${s.reason}`);
155
+ let profile = null;
144
156
  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.
157
+ for (const candidate of candidates) {
158
+ let stat;
159
+ try {
160
+ stat = await fs.stat(candidate);
161
+ } catch (err) {
162
+ reasons.push(`${candidate}: ${err.code || 'stat-error'}`);
163
+ continue;
164
+ }
165
+ if (!stat.isFile()) {
166
+ reasons.push(`${candidate}: not-a-regular-file`);
167
+ continue;
168
+ }
153
169
  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
- );
170
+ // Funnel through the rate-limited per-account logger
171
+ // (when no candidate matches at all). Direct console.error
172
+ // would spam logs every TTL rebuild for any account whose
173
+ // profile is oversized at one candidate but matches at
174
+ // another — and on every rebuild for genuinely-oversized
175
+ // accounts.
176
+ reasons.push(`${candidate}: oversized (${stat.size} > ${MAX_PROFILE_BYTES})`);
158
177
  continue;
159
178
  }
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
179
+ let parsed;
180
+ try {
181
+ parsed = JSON.parse(await fs.readFile(candidate, 'utf8'));
182
+ } catch (err) {
183
+ reasons.push(`${candidate}: parse-error (${err.message})`);
184
+ continue;
185
+ }
186
+ const declaredSubject = absolutize(parsed?.['@id'] || parsed?.id, stripHashIfAny(account.webId));
187
+ if (declaredSubject !== account.webId) {
188
+ reasons.push(`${candidate}: @id-mismatch (declared=${declaredSubject || '(none)'})`);
189
+ continue;
190
+ }
191
+ profile = parsed;
192
+ mtimeMs = stat.mtimeMs;
193
+ break;
194
+ }
195
+ if (!profile) {
196
+ // Nothing on disk matched. Surface the precise per-candidate
197
+ // failure reasons so operators can distinguish ENOENT (profile
198
+ // genuinely missing) from @id mismatch (wrong subdomain config?)
199
+ // from containment rejection (malformed webId path) without
200
+ // having to grep the file system.
201
+ //
202
+ // Keep the `path` argument to logProfileFailure path-shaped so
203
+ // downstream log readers don't get a giant summary string in
204
+ // the `profile=...` field. Per-candidate detail goes into
205
+ // `err.message` as a single line.
206
+ const summary = reasons.length ? reasons.join(' | ') : '(no candidates)';
207
+ logProfileFailure(accountId, '(multiple candidates)', {
208
+ code: 'NO_CANDIDATE_MATCHED',
209
+ message: `no candidate profile matched ${account.webId} — tried: ${summary}`,
210
+ });
211
+ continue;
168
212
  }
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)
213
+ // CID semantics (continued) — match the resource-side checks:
173
214
  // (2) VM's controller MUST be in the profile's expected controller
174
215
  // set (declared `controller`, with @id fallback)
175
216
  // (3) VM MUST be referenced from `authentication` — a key in
176
217
  // verificationMethod alone (no auth membership) shouldn't be
177
218
  // published as authentic
178
- const profileSubject = absolutize(profile?.['@id'] || profile?.id, stripHashIfAny(account.webId));
179
- if (!profileSubject || profileSubject !== account.webId) continue;
219
+ const profileSubject = account.webId; // already validated above
180
220
  const expectedControllers = collectControllerIds(profile, profileSubject);
181
221
  if (expectedControllers.size === 0) continue;
182
222
  // Pass the already-validated absolute subject as the base. Without
@@ -260,6 +300,64 @@ export function profilePathFromWebId(dataRoot, webId, accountId = 'unknown') {
260
300
  return resolved;
261
301
  }
262
302
 
303
+ /**
304
+ * Build the candidate filesystem paths to probe for a given WebID,
305
+ * covering the deployment shapes JSS supports:
306
+ *
307
+ * 1. Path-mode named pod (host=`example.com`, path=`/alice/profile/card.jsonld`)
308
+ * → `<dataRoot>/alice/profile/card.jsonld`
309
+ * 2. Root pod (single-user) (host=`example.com`, path=`/profile/card.jsonld`)
310
+ * → `<dataRoot>/profile/card.jsonld`
311
+ * 3. Subdomain-mode pod (host=`alice.example.com`, path=`/profile/card.jsonld`)
312
+ * → `<dataRoot>/alice/profile/card.jsonld`
313
+ *
314
+ * The subdomain candidate (3) is gated on `podName` matching the
315
+ * WebID host's first DNS label — without that gate, a root-pod
316
+ * WebID (`example.com`) would also emit `<dataRoot>/example/...`,
317
+ * which could be a different account's pod dir.
318
+ *
319
+ * Returns `{ paths, skipped }`:
320
+ * - `paths` — absolute, containment-passed candidates to probe
321
+ * in order
322
+ * - `skipped` — diagnostic entries for paths rejected at this
323
+ * stage (today only "outside-dataRoot"). Surfaced through the
324
+ * rebuild loop's failure log so operators can distinguish
325
+ * traversal / misconfig from "profile not on disk."
326
+ *
327
+ * @internal exported for tests
328
+ */
329
+ export function profilePathCandidates(dataRoot, webId, podName = null) {
330
+ if (typeof webId !== 'string') return { paths: [], skipped: [] };
331
+ let url;
332
+ try { url = new URL(webId); } catch { return { paths: [], skipped: [] }; }
333
+ const pathnameRel = url.pathname.replace(/^\/+/, '');
334
+ const dataRootAbs = path.resolve(dataRoot);
335
+ const insideRoot = (p) => p === dataRootAbs || p.startsWith(dataRootAbs + path.sep);
336
+ const paths = [];
337
+ const skipped = [];
338
+ const consider = (...parts) => {
339
+ const r = path.resolve(dataRootAbs, ...parts);
340
+ if (!insideRoot(r)) {
341
+ skipped.push({ path: r, reason: 'outside-dataRoot' });
342
+ return;
343
+ }
344
+ if (!paths.includes(r)) paths.push(r);
345
+ };
346
+ // Path-mode named pod OR root pod.
347
+ consider(pathnameRel);
348
+ // Subdomain mode: only when the WebID host's first DNS label
349
+ // matches the account's podName (case-insensitive — DNS is).
350
+ if (typeof podName === 'string' && podName.length > 0) {
351
+ const host = url.hostname.toLowerCase();
352
+ const expected = podName.toLowerCase() + '.';
353
+ if (host.startsWith(expected)) {
354
+ consider(podName, pathnameRel);
355
+ }
356
+ }
357
+ return { paths, skipped };
358
+ }
359
+
360
+
263
361
  function collectControllerIds(source, baseUrl) {
264
362
  const out = new Set();
265
363
  const c = source?.controller;
@@ -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
  });
@@ -14,7 +14,11 @@ import fs from 'fs-extra';
14
14
  import { createServer as createNetServer } from 'net';
15
15
  import { generateSecretKey, getPublicKey } from '../src/nostr/event.js';
16
16
  import { createServer } from '../src/server.js';
17
- import { _resetIndexForTests, profilePathFromWebId } from '../src/idp/well-known-did-nostr.js';
17
+ import {
18
+ _resetIndexForTests,
19
+ profilePathFromWebId,
20
+ profilePathCandidates,
21
+ } from '../src/idp/well-known-did-nostr.js';
18
22
  import { extractNostrPubkeysFromProfile } from '../src/auth/nostr.js';
19
23
 
20
24
  const TEST_HOST = '127.0.0.1';
@@ -301,6 +305,123 @@ describe('GET /.well-known/did/nostr/:pubkey (#407)', () => {
301
305
  assert.strictEqual(doc.alsoKnownAs[0], rootWebId);
302
306
  });
303
307
 
308
+ it('indexes subdomain-mode pods (#411): /<host-first-label>/profile/...', async () => {
309
+ // Subdomain layout: WebID host = `<podname>.<basedomain>` and
310
+ // the profile lives at `<DATA_ROOT>/<podname>/profile/card.jsonld`
311
+ // (NOT at `<DATA_ROOT>/profile/card.jsonld`). Pre-#411 the
312
+ // indexer derived the path from `webIdUrl.pathname` only,
313
+ // dropped the subdomain, and ENOENT-skipped every subdomain
314
+ // pod silently — so the entire `Sign in with Schnorr` zero-
315
+ // typing UX fell through to the typed-username fallback on
316
+ // every subdomain-mode deployment (e.g. solid.social).
317
+ //
318
+ // This test ALSO exercises the "first candidate exists but
319
+ // belongs to a different account" path: we write a coexisting
320
+ // root-pod profile at `<TEST_DATA_DIR>/profile/card.jsonld`
321
+ // for an UNRELATED account (different webId, different VM).
322
+ // The subdomain account's path-mode candidate hits that file,
323
+ // fails the @id check, and the loop must fall through to the
324
+ // subdomain candidate. Self-contained — doesn't depend on
325
+ // earlier tests' fixtures or test-execution order.
326
+ //
327
+ // Snapshot any pre-existing file at the decoy path so the
328
+ // earlier "indexes root-level pods" test's fixture (or any
329
+ // future fixture sharing that path) can be restored after
330
+ // this test runs. Without this snapshot, deleting the decoy
331
+ // unconditionally would silently wipe legitimate state.
332
+ const decoyPk = getPublicKey(generateSecretKey()); // unrelated key
333
+ const decoyProfilePath = path.join(TEST_DATA_DIR, 'profile', 'card.jsonld');
334
+ const decoyWebId = `${baseUrl}/profile/card.jsonld#decoy`;
335
+ const sk = generateSecretKey();
336
+ const subPk = getPublicKey(sk);
337
+ const subWebId = 'http://sub.example.test/profile/card.jsonld#me';
338
+ const subProfilePath = path.join(TEST_DATA_DIR, 'sub', 'profile', 'card.jsonld');
339
+ const VM_ID = 'http://sub.example.test/profile/card.jsonld#k';
340
+ const accountsDir = path.join(TEST_DATA_DIR, '.idp', 'accounts');
341
+ const indexPath = path.join(accountsDir, '_webid_index.json');
342
+ const accountId = 'subdomain-pod-test-account';
343
+
344
+ // Snapshot any pre-existing file at the decoy path so a prior
345
+ // fixture (e.g. the "indexes root-level pods" test's profile
346
+ // at the same location) can be restored at cleanup. ENOENT
347
+ // means "didn't exist; remove on cleanup."
348
+ let decoySnapshot = null;
349
+ try {
350
+ decoySnapshot = await fs.readFile(decoyProfilePath, 'utf8');
351
+ } catch (e) {
352
+ if (e.code !== 'ENOENT') throw e;
353
+ }
354
+
355
+ // Mutate fixtures inside try; cleanup runs regardless of
356
+ // whether assertions throw, so a future regression doesn't
357
+ // leak filesystem state and turn the suite order-dependent.
358
+ try {
359
+ await fs.ensureDir(path.dirname(decoyProfilePath));
360
+ await fs.writeJson(decoyProfilePath, {
361
+ '@context': 'https://www.w3.org/ns/solid/v1',
362
+ '@id': decoyWebId,
363
+ verificationMethod: [{
364
+ id: `${decoyWebId.replace('#decoy', '')}#k`,
365
+ type: 'Multikey',
366
+ controller: decoyWebId,
367
+ publicKeyMultibase: fformMultikey(decoyPk),
368
+ }],
369
+ authentication: [`${decoyWebId.replace('#decoy', '')}#k`],
370
+ }, { spaces: 2 });
371
+
372
+ await fs.ensureDir(path.dirname(subProfilePath));
373
+ await fs.writeJson(subProfilePath, {
374
+ '@context': 'https://www.w3.org/ns/solid/v1',
375
+ '@id': subWebId,
376
+ verificationMethod: [{
377
+ id: VM_ID,
378
+ type: 'Multikey',
379
+ controller: subWebId,
380
+ publicKeyMultibase: fformMultikey(subPk),
381
+ }],
382
+ authentication: [VM_ID],
383
+ }, { spaces: 2 });
384
+
385
+ const idx = await fs.readJson(indexPath);
386
+ idx[subWebId] = accountId;
387
+ await fs.writeJson(indexPath, idx, { spaces: 2 });
388
+ await fs.writeJson(path.join(accountsDir, `${accountId}.json`), {
389
+ id: accountId,
390
+ podName: 'sub',
391
+ webId: subWebId,
392
+ email: 'sub@example.test',
393
+ }, { spaces: 2 });
394
+
395
+ const r = await fetch(`${baseUrl}/.well-known/did/nostr/${subPk}.json`);
396
+ assert.strictEqual(r.status, 200, 'subdomain-mode pod must be findable');
397
+ const doc = await r.json();
398
+ assert.strictEqual(doc.id, `did:nostr:${subPk}`);
399
+ assert.strictEqual(doc.alsoKnownAs[0], subWebId);
400
+ } finally {
401
+ // Restore decoy (or delete if it was created by this test).
402
+ if (decoySnapshot !== null) {
403
+ try { await fs.writeFile(decoyProfilePath, decoySnapshot, 'utf8'); }
404
+ catch { /* best effort */ }
405
+ } else {
406
+ try { await fs.remove(decoyProfilePath); } catch { /* best effort */ }
407
+ }
408
+ // Subdomain fixture file + empty parent dirs. fs-extra's
409
+ // `remove` handles both files and (recursively) dirs and
410
+ // is a no-op on missing paths — replaces the deprecated
411
+ // node `fs.rmdir`.
412
+ try { await fs.remove(subProfilePath); } catch { /* best effort */ }
413
+ try { await fs.remove(path.dirname(subProfilePath)); } catch { /* best effort */ }
414
+ try { await fs.remove(path.dirname(path.dirname(subProfilePath))); } catch { /* best effort */ }
415
+ // Index entry + account record.
416
+ try {
417
+ const idx = await fs.readJson(indexPath);
418
+ delete idx[subWebId];
419
+ await fs.writeJson(indexPath, idx, { spaces: 2 });
420
+ } catch { /* best effort */ }
421
+ try { await fs.remove(path.join(accountsDir, `${accountId}.json`)); } catch { /* best effort */ }
422
+ }
423
+ });
424
+
304
425
  // No `it()` here — path containment is now exercised directly
305
426
  // by unit tests on `profilePathFromWebId` below. The previous
306
427
  // integration-style test couldn't actually trigger the
@@ -629,3 +750,104 @@ describe('profilePathFromWebId — DATA_ROOT containment', () => {
629
750
  }
630
751
  });
631
752
  });
753
+
754
+ describe('profilePathCandidates — deployment-shape coverage (#411)', () => {
755
+ // The original `profilePathFromWebId` only emitted ONE candidate
756
+ // (`<dataRoot><pathname>`), which broke subdomain-mode pods on
757
+ // solid.social: account `b0b1707f-...` with WebID
758
+ // `https://test.solid.social/profile/card.jsonld#me` lives on disk
759
+ // at `<dataRoot>/test/profile/card.jsonld`, but the indexer was
760
+ // looking at `<dataRoot>/profile/card.jsonld` and ENOENT-ing.
761
+ //
762
+ // `profilePathCandidates` returns the full ordered list. Tests
763
+ // cover all three deployment shapes JSS supports.
764
+ //
765
+ // DATA_ROOT is path.resolve()d so the assertions below build
766
+ // expected values via path.join — works on Windows (different
767
+ // separator/root) the same as on POSIX.
768
+ const DATA_ROOT = path.resolve('/srv/jss/data');
769
+
770
+ it('path-mode named pod: <dataRoot>/<pod>/profile/card.jsonld', () => {
771
+ const { paths } = profilePathCandidates(DATA_ROOT, 'https://example.com/alice/profile/card.jsonld#me');
772
+ const expected = path.join(DATA_ROOT, 'alice', 'profile', 'card.jsonld');
773
+ assert.ok(paths.includes(expected),
774
+ `expected ${expected}; got ${paths.join(', ')}`);
775
+ });
776
+
777
+ it('root pod: <dataRoot>/profile/card.jsonld', () => {
778
+ const { paths } = profilePathCandidates(DATA_ROOT, 'https://example.com/profile/card.jsonld#me');
779
+ const expected = path.join(DATA_ROOT, 'profile', 'card.jsonld');
780
+ assert.ok(paths.includes(expected),
781
+ `expected ${expected}; got ${paths.join(', ')}`);
782
+ });
783
+
784
+ it('subdomain-mode pod: emits <dataRoot>/<podName>/profile/... when host first label matches podName', () => {
785
+ const { paths } = profilePathCandidates(DATA_ROOT, 'https://test.solid.social/profile/card.jsonld#me', 'test');
786
+ const pathMode = path.join(DATA_ROOT, 'profile', 'card.jsonld');
787
+ const subdomain = path.join(DATA_ROOT, 'test', 'profile', 'card.jsonld');
788
+ assert.ok(paths.includes(pathMode),
789
+ `expected path-mode candidate ${pathMode}; got ${paths.join(', ')}`);
790
+ assert.ok(paths.includes(subdomain),
791
+ `expected subdomain candidate ${subdomain}; got ${paths.join(', ')}`);
792
+ });
793
+
794
+ it('does NOT emit a subdomain candidate when podName is omitted', () => {
795
+ const { paths } = profilePathCandidates(DATA_ROOT, 'https://test.solid.social/profile/card.jsonld#me');
796
+ assert.deepStrictEqual(paths, [path.join(DATA_ROOT, 'profile', 'card.jsonld')]);
797
+ });
798
+
799
+ it('does NOT emit a subdomain candidate when podName does not match the host first label', () => {
800
+ const { paths } = profilePathCandidates(DATA_ROOT, 'https://example.com/profile/card.jsonld#me', 'me');
801
+ assert.deepStrictEqual(paths, [path.join(DATA_ROOT, 'profile', 'card.jsonld')]);
802
+ });
803
+
804
+ it('does NOT emit a subdomain candidate for a single-label host', () => {
805
+ const { paths } = profilePathCandidates(DATA_ROOT, 'http://localhost/profile/card.jsonld#me', 'localhost');
806
+ assert.deepStrictEqual(paths, [path.join(DATA_ROOT, 'profile', 'card.jsonld')]);
807
+ });
808
+
809
+ it('returns empty paths for an unparseable webId', () => {
810
+ assert.deepStrictEqual(profilePathCandidates(DATA_ROOT, 'not a url'), { paths: [], skipped: [] });
811
+ assert.deepStrictEqual(profilePathCandidates(DATA_ROOT, null), { paths: [], skipped: [] });
812
+ });
813
+
814
+ it('every candidate stays inside dataRootAbs', () => {
815
+ const cases = [
816
+ ['https://example.com/alice/profile/card.jsonld#me', 'alice'],
817
+ ['https://alice.example.com/profile/card.jsonld#me', 'alice'],
818
+ ['https://h/../../../etc/passwd', null],
819
+ ['https://h.com/../../../etc/passwd', 'h'],
820
+ ];
821
+ for (const [w, podName] of cases) {
822
+ const { paths } = profilePathCandidates(DATA_ROOT, w, podName);
823
+ for (const c of paths) {
824
+ assert.ok(c === DATA_ROOT || c.startsWith(DATA_ROOT + path.sep),
825
+ `${w} (podName=${podName}) → ${c} escaped DATA_ROOT`);
826
+ }
827
+ }
828
+ });
829
+
830
+ it('returns the `{ paths, skipped }` shape so the caller can surface diagnostics', () => {
831
+ // Restructure of pass-2: the function returns BOTH the
832
+ // containment-passed paths AND a `skipped` list of rejected
833
+ // candidates with reasons. rebuildPubkeyIndex folds `skipped`
834
+ // into its per-account failure log so operators can
835
+ // distinguish "traversal/misconfig" from "profile not on disk."
836
+ //
837
+ // Through normal URL-parsed input the `skipped` list stays
838
+ // empty (URL normalization prevents traversal in pathname,
839
+ // and the podName-gated subdomain candidate rejects mismatches
840
+ // before path-resolve ever runs). The field exists as
841
+ // defense-in-depth for any future caller that bypasses URL
842
+ // parsing or feeds an externally-derived podName, AND so the
843
+ // rebuild loop's failure log has a hook to surface
844
+ // containment rejections instead of dropping them silently.
845
+ const result = profilePathCandidates(DATA_ROOT,
846
+ 'https://alice.example.com/profile/card.jsonld#me', 'alice');
847
+ assert.ok(Array.isArray(result.paths), 'paths must be an array');
848
+ assert.ok(Array.isArray(result.skipped), 'skipped must be an array');
849
+ assert.ok(result.paths.length > 0, 'happy-path must yield paths');
850
+ assert.deepStrictEqual(result.skipped, [],
851
+ 'normal URL-parsed input must produce no skipped entries');
852
+ });
853
+ });