Skip to content

fix(well-known-did-nostr): probe profile/card.jsonld for root-path WebIDs — closes #451#546

Merged
melvincarvalho merged 2 commits into
gh-pagesfrom
issue-451-root-path-webid-profile
Jun 10, 2026
Merged

fix(well-known-did-nostr): probe profile/card.jsonld for root-path WebIDs — closes #451#546
melvincarvalho merged 2 commits into
gh-pagesfrom
issue-451-root-path-webid-profile

Conversation

@melvincarvalho

Copy link
Copy Markdown
Contributor

Closes #451.

Bug

A root-path WebID like https://melvin.solid.social/#me has pathname /, so profilePathCandidates() derives an empty pathnameRel and every candidate resolves to a directory (dataRoot itself, or the pod dir via the subdomain gate). The indexer then logs NO_CANDIDATE_MATCHED … not-a-regular-file on every TTL rebuild and never publishes the account's DID document — exactly the warning spam reported on solid.social.

Fix (option 2 from the issue)

When the pathname is a pod root ('' or trailing /), additionally probe the conventional profile document location under each directory candidate:

WebID shape New candidate
https://example.com/#me (root pod) <dataRoot>/profile/card.jsonld
https://melvin.solid.social/#me + podName melvin (subdomain) <dataRoot>/melvin/profile/card.jsonld
https://example.com/alice/#me (path-mode pod root, trailing slash) <dataRoot>/alice/profile/card.jsonld

The trailing-slash case is the same bug class as the issue's root-path case and is covered by the same isPodRoot check — one extra condition, included deliberately.

Deliberate narrowing vs. the issue's sketch

The issue suggested probing profile/card.jsonld, profile/card, and profile/card.ttl. Only profile/card.jsonld is probed: the rebuild loop parses every candidate with JSON.parse (well-known-did-nostr.js:181), so Turtle-shaped files could never match — probing them would only add dead candidates and failure-log noise. The code comment documents this.

Safety

  • Cross-account: unchanged — the rebuild loop accepts a candidate only when the document's declared @id equals account.webId exactly (:186-187), so probing a directory that happens to belong to another account cannot mis-bind.
  • Subdomain gate: applies to the fallback candidate exactly as to the primary (host's first DNS label must match podName); a regression test locks this.
  • Non-root WebIDs: byte-identical candidate lists, locked by a deepStrictEqual test.
  • Containment: fallback candidates flow through the same consider() → inside-dataRoot check as all others.

Why not options 1/3 (fix the account record / migration script)

Once indexing handles root-path WebIDs, the data is valid as-is — /#me is a legitimate WebID shape, not corruption. Operators who prefer canonical profile/card.jsonld#me WebIDs can still update records manually, but nothing requires it.

Tests

5 new cases in the existing profilePathCandidates — deployment-shape coverage (#411) block, including the exact melvin.solid.social scenario from the issue. Full suite: 925/925 passing.

…bIDs (#451)

profilePathCandidates() derives candidates from the WebID's pathname.
A root-path WebID like https://melvin.solid.social/#me has pathname
'/', so pathnameRel is '' and every candidate resolves to a DIRECTORY
(dataRoot itself, or the pod dir via the subdomain gate) — the indexer
then logs NO_CANDIDATE_MATCHED / not-a-regular-file on every TTL
rebuild and never publishes the account's DID document.

Fix is option 2 from the issue: when the pathname is a pod root
('' or trailing '/'), additionally probe the conventional profile
document location, profile/card.jsonld, under each directory
candidate. The trailing-slash sibling (e.g.
https://example.com/alice/#me, pathname '/alice/') is the same bug
class and is covered by the same isPodRoot check.

Deliberate narrowing vs the issue's sketch: only profile/card.jsonld
is probed — not profile/card or profile/card.ttl — because the
rebuild loop parses candidates with JSON.parse, so Turtle-shaped
files could never match; probing them would only add dead candidates
and failure-log noise.

Cross-account safety: unchanged. The rebuild loop accepts a candidate
only when its declared @id equals account.webId exactly, and the
subdomain gate (host's first DNS label must match podName) applies to
the fallback candidate the same as the primary.

Non-root WebIDs produce byte-identical candidate lists (locked by a
deepStrictEqual regression test).

Tests: 5 new cases in the existing #411 describe block — root pod,
the exact melvin.solid.social subdomain scenario from the issue,
trailing-slash path mode, gate preservation under non-matching
podName, and the no-change guarantee for document-shaped WebIDs.

Full suite: 925/925 passing.

Closes #451.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes well-known-did-nostr profile lookup for root-path / pod-root WebIDs (e.g. https://example.com/#me and https://example.com/alice/#me) by adding a fallback probe to the conventional profile/card.jsonld location under otherwise-directory candidates, preventing repeated “not-a-regular-file” rebuild warnings and enabling DID document publication for those accounts.

Changes:

  • Extend profilePathCandidates() to add profile/card.jsonld fallback candidates when the WebID path resolves to a directory ('' or trailing /).
  • Add regression tests covering root-path WebIDs, subdomain-mode gating behavior, and a “no change for non-root WebIDs” invariant.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
src/idp/well-known-did-nostr.js Adds root-path/pod-root fallback candidate generation for profile lookup.
test/well-known-did-nostr.test.js Adds test coverage for root-path and trailing-slash WebID shapes plus subdomain gating/invariants.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/idp/well-known-did-nostr.js Outdated
Comment on lines 363 to 379
const isPodRoot = pathnameRel === '' || pathnameRel.endsWith('/');
if (isPodRoot) {
// path.resolve skips empty segments, so pathnameRel === '' lands
// on `<dataRoot>/profile/card.jsonld` (root pod) as intended.
consider(pathnameRel, 'profile/card.jsonld');
}
// Subdomain mode: only when the WebID host's first DNS label
// matches the account's podName (case-insensitive — DNS is).
if (typeof podName === 'string' && podName.length > 0) {
const host = url.hostname.toLowerCase();
const expected = podName.toLowerCase() + '.';
if (host.startsWith(expected)) {
consider(podName, pathnameRel);
if (isPodRoot) {
consider(podName, pathnameRel, 'profile/card.jsonld');
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — confirmed real and fixed in bb89e47, taking exactly the suggested shape. The relative-subject premise checks out: "@id": "#me" is a supported shape (see the collectAuthenticationIds docstring and the absolutize-against-account-webId logic at well-known-did-nostr.js:223), so the root pod's profile could indeed pass the @id check for a subdomain account and bind the wrong pubkeys.

The gate is now computed up front, and the root-level fallback is emitted only when the subdomain gate does NOT match — when it matches, the pod-dir fallback is the only candidate added, since no legitimate deployment serves a subdomain account's profile from the dataRoot root. New regression test (root-path WebID in subdomain mode does NOT probe the root pod profile) locks the suppression in.

Worth noting: the same relative-@id acceptance also applies to the pre-existing path-mode candidate for document-shaped subdomain WebIDs (<dataRoot>/profile/card.jsonld is probed first for https://test.solid.social/profile/card.jsonld#me — see the #411 test at line 784). That window predates this PR and requires a hand-edited root profile (JSS-generated profiles declare absolute subjects), so I've left it out of scope here — happy to file a follow-up issue if it's worth tracking.

926/926 passing.

…te matches

Copilot found a real cross-account window in the new fallback: for a
subdomain root-path WebID (https://melvin.solid.social/#me, podName
melvin), the root-level fallback emitted <dataRoot>/profile/card.jsonld
— the ROOT pod's document — BEFORE the pod-specific candidate. The
rebuild loop's @id check absolutizes a relative subject ("@id": "#me",
a supported shape, see collectAuthenticationIds) against the PROBING
account's WebID, so the root pod's profile could pass the check and
bind the root pod's pubkeys to the subdomain account.

Fix per the review suggestion: compute the subdomain gate up front and
emit the root-level fallback only when the gate does NOT match. When
it matches, the pod-dir fallback is the only correct location — no
legitimate deployment serves a subdomain account's profile from the
dataRoot root.

New regression test asserts <dataRoot>/profile/card.jsonld is NOT a
candidate for the subdomain root-path case.

Full suite: 926/926 passing.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@melvincarvalho
melvincarvalho merged commit 15ac645 into gh-pages Jun 10, 2026
2 checks passed
@melvincarvalho
melvincarvalho deleted the issue-451-root-path-webid-profile branch June 10, 2026 13:50
melvincarvalho added a commit that referenced this pull request Jun 10, 2026
Bundles everything merged since 0.0.205 was published to npm
(2026-06-07):

- #539 fix(idp): handleSchnorrComplete recovers from
  interactionFinished throw — missing _interaction cookie now gets a
  clean 400 instead of a hung socket / gateway 504 (closes #412)
- #540 refactor(idp): sweep the hijack-then-throw defensive pattern
  across all 5 interaction handlers via a shared
  finishInteractionDefensively helper (foundation for #526)
- #542 fix: align runtime port fallbacks with the canonical 4443
  default — one source of truth in config.defaults (closes #477)
- #543 feat: configurable bodyLimit via createServer({ bodyLimit }),
  --body-limit, and JSS_BODY_LIMIT — large git pushes no longer 413
  at the hard-coded 10 MiB cap (closes #474)
- #546 fix(well-known-did-nostr): probe profile/card.jsonld for
  root-path WebIDs like https://melvin.solid.social/#me, with a
  cross-account guard on the subdomain fallback (closes #451)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

well-known-did-nostr: root-path WebID (/#me) fails profile lookup

2 participants