Skip to content

Commit bff24f2

Browse files
* Index subdomain-mode pods (JavaScriptSolidServer#411) The well-known did:nostr indexer was silently dropping every subdomain-mode pod. The original `profilePathFromWebId` derived the on-disk path from `webIdUrl.pathname` only, which works for path-mode named pods (`example.com/alice/...` → `<dataRoot>/alice/...`) and root pods (`example.com/profile/...` → `<dataRoot>/profile/...`) but loses the subdomain in the third case: WebID: https://alice.example.com/profile/card.jsonld#me pathname: /profile/card.jsonld derived: <dataRoot>/profile/card.jsonld actual: <dataRoot>/alice/profile/card.jsonld ← profile lives here On solid.social (subdomain-mode), every subdomain user fell through to the typed-username fallback because the local index never found their profile. "Sign in with Schnorr" with no typing just didn't work. Fix: - New `profilePathCandidates(dataRoot, webId)` returns the ORDERED list of candidate filesystem paths to probe: 1. `<dataRoot>/<pathname>` (path mode + root pod) 2. `<dataRoot>/<host-first-label>/<pathname>` (subdomain mode) Subdomain candidate only emitted for hosts with ≥2 DNS labels. Containment-checked: every candidate stays under dataRootAbs. - rebuildPubkeyIndex now PROBES candidates and accepts the first one whose `@id` matches `account.webId`. Multiple candidates can exist on disk simultaneously (root pod + named pods coexisting; subdomain pod with a coincidentally- named path-mode dir), so committing to the first candidate-that-stats was a bug — could pick someone else's profile, fail the @id check, log "no match," skip the actual matching candidate. Now: stat → size cap → parse → @id check, fall through to next candidate on any failure. - Removed the unused `findProfilePathOnDisk` helper. Tests: - Six new unit tests on `profilePathCandidates` covering path mode, root pod, subdomain mode, single-label hosts, unparseable input, and containment across all of them. - One new integration test that synthesizes a subdomain-mode pod (profile at `<TEST_DATA_DIR>/sub/profile/card.jsonld`, webId at `http://sub.example.test/profile/...`) and asserts the well-known endpoint resolves it. The test exercises the "first candidate exists but belongs to a different account" case because the prior test in the same suite leaves a root-pod profile at `<TEST_DATA_DIR>/profile/card.jsonld` — the subdomain account's path-mode candidate would hit it, fail the @id check, and the loop must fall through to the subdomain candidate. (This caught the original findProfilePathOnDisk bug during development.) Test count: 752 → 767 in full suite (+ 7 unit + 1 integration + 7 from the same test file's organic growth across the run). * Address copilot pass 1 on JavaScriptSolidServer#413 — gate subdomain candidate on podName Real concern with my pass-0 implementation: `profilePathCandidates` emitted the "host first label as pod dir" candidate for ANY host with ≥2 DNS labels. For a root-pod WebID (`https://example.com/profile/card.jsonld`) on a normal domain, that produced `<dataRoot>/example/profile/card.jsonld` as a secondary candidate — which could be a totally unrelated account's pod dir. The downstream `@id` check rejected mis-indexing, so it was never exploitable, but: - extra wasted disk probes per non-subdomain account - confusing log entries blaming the wrong path on rebuild miss - defense rested entirely on @id check, no precision in candidate generation itself Fix: add an optional `podName` parameter. The subdomain candidate is only emitted when `<podName>.` is the WebID host's actual first label (case-insensitive — DNS is). `rebuildPubkeyIndex` passes `account.podName` since it has the account record in hand. For root pods (podName='me' from src/server.js's single-user seed, host is the bare baseDomain), the gate fails and only the path-mode/root candidate is emitted. For a true subdomain pod (podName='alice', host='alice.example.com'), the gate matches and we emit `<dataRoot>/alice/profile/card.jsonld` as the secondary candidate. Path mode (no subdomain in host) still gets just the path-mode candidate. Tests: - "subdomain-mode pod: emits ... when host first label matches podName" (renamed; existing behavior, now explicit about the matching condition) - NEW "does NOT emit a subdomain candidate when podName is omitted" — backward-safe behavior for any other caller - NEW "does NOT emit a subdomain candidate when podName does not match the host first label" — the case Copilot flagged: root-pod on example.com with podName='me' must NOT produce `<dataRoot>/example/profile/...` - "single-label host" test now passes podName explicitly to confirm gating is consistent - Containment loop now includes podName per case Test count: 767 → 769 in full suite. * Address copilot pass 2 on JavaScriptSolidServer#413 — precise per-candidate diagnostics Two findings, both about losing operator-visible information: 1. The fallback `err = { code: 'ENOENT', message: 'no candidate matched account.webId' }` was misleading. The actual reason could be: - profile genuinely missing (ENOENT) - oversized file - JSON parse failure - `@id` mismatch (most common when subdomain config is wrong) - candidate rejected by containment Hard-coding ENOENT erased that signal. Fix: rebuild loop now accumulates per-candidate failure reasons in a list, and the fallback log emits ALL of them joined with ` | `, prefixed with the actual code: - `<path>: ENOENT` / stat-error / not-a-regular-file - `<path>: oversized (NN > MAX)` - `<path>: parse-error (msg)` - `<path>: @id-mismatch (declared=...)` - `<path>: outside-dataRoot` Top-level err code changed to `NO_CANDIDATE_MATCHED` so it's distinguishable from a single ENOENT. 2. `profilePathCandidates` was silently dropping out-of-dataRoot candidates. Pre-JavaScriptSolidServer#411, `profilePathFromWebId` logged "resolves outside dataRoot" loudly. Operators lost that signal — traversal/misconfig and "profile not on disk" looked identical in the failure log. Fix: function now returns `{ paths, skipped }`. `paths` is the containment-passed candidates (caller probes these); `skipped` is `[{ path, reason }]` for any rejected candidate (today only "outside-dataRoot"). The rebuild loop folds `skipped` into its diagnostics list so containment rejections show up in the per-account failure log alongside ENOENT/parse/mismatch reasons. Test changes: - All `profilePathCandidates` callers updated for the `{ paths, skipped }` shape (destructuring `paths`). - The "returns []" test became "returns empty paths" with the new shape. - New "returns the shape so the caller can surface diagnostics" test confirms the API contract: paths and skipped are both arrays, normal URL input produces empty skipped. Test count: 769 → 770 in full suite (+1 shape test, -0 — the shape rewrite consolidated the `[].length === 0` assertion into the new shape test). * Address copilot pass 3 on JavaScriptSolidServer#413 Four findings, all real. 1. Oversize-profile branch was emitting a direct `console.error`, bypassing the per-account rate limit in `logProfileFailure`. On every TTL rebuild, an oversized profile would re-log; if the same account had a later candidate that matched, the error would also be misleading. Now folded into the same `reasons` accumulator used for ENOENT/parse/mismatch — the rate-limited `logProfileFailure` only fires when NO candidate matches, and the oversize hit is part of the diagnostics in `err.message`. 2. `logProfileFailure(accountId, profilePath, err)` formats logs as `(profile=${profilePath})`. We were passing a multi- candidate summary string (`<path>: ENOENT | <path>: ...`) as the `profilePath` argument, which made the log field misleading and unsearchable. Now passes the placeholder `(multiple candidates)` for the path and puts the full summary in `err.message`. Operators searching for `profile=/some/path` get back the actual paths; the summary travels with the err. 3. The subdomain-mode integration test had an implicit dependency on test ordering. The "first candidate exists but belongs to a different account" path was only exercised because a prior test left a root-pod profile at `<TEST_DATA_DIR>/profile/card.jsonld`. Reordering tests would have silently lost that coverage. Now self-contained: the test creates its OWN decoy profile (with an unrelated key and webId) at the path-mode candidate location, runs the subdomain assertion, then cleans up both fixtures. 4. The new `profilePathCandidates` unit tests hard-coded POSIX paths like `/srv/jss/data/alice/...`, which would fail on Windows (different separator + drive root). DATA_ROOT is now `path.resolve('/srv/jss/data')` and every expected value is built via `path.join(DATA_ROOT, ...)`. Portable. Test count: same 770; the subdomain test became self-contained and added two fs writes + cleanup but no new test cases. * Address copilot pass 4 on JavaScriptSolidServer#413 — snapshot/restore decoy fixture One finding, real test-isolation gap. The pass-3 subdomain-mode test created a decoy profile at `<TEST_DATA_DIR>/profile/card.jsonld` and then unconditionally deleted it during cleanup. That same path is also used by the earlier "indexes root-level pods" test, which leaves a real root-pod profile + an account entry in `_webid_index.json`. Running the subdomain test would silently wipe the root-pod fixture, and any later TTL rebuild would log "no candidate matched" for the orphaned root-pod account — order-dependent, brittle, and noisy. Fix: - Snapshot the decoy file's content (if any) BEFORE writing over it. ENOENT on snapshot means "didn't exist; remove on cleanup"; any other error rethrows. - On cleanup, restore the snapshotted content (if there was one) or delete the file (if it was created by this test only). - Also remove `subProfilePath` (the subdomain candidate file this test wrote) and prune the now-empty `sub/profile/` + `sub/` dirs (best-effort — failures swallowed). Test count: same 770; the test stays correct + now isolates its filesystem side effects regardless of execution order. * Address copilot pass 5 on JavaScriptSolidServer#413 — wrap fixtures in try/finally One finding, real test-isolation gap. The pass-4 subdomain-mode test snapshotted/restored the decoy profile but the cleanup ran in the happy path only. A failing fetch or assertion would skip the cleanup block and leave: - the decoy profile overwritten on disk - subProfilePath created - `_webid_index.json` carrying a synthetic account entry - `subdomain-pod-test-account.json` left in accountsDir turning the whole suite into an order-dependent mess on the next run. Fix: wrap the fixture mutations + assertions in `try`, with ALL restoration in `finally`. The snapshot read happens BEFORE the try block (it's a read; if it throws non-ENOENT we want to fail before mutating anything). Each cleanup step is itself wrapped in a swallow-errors try so one failed restoration doesn't prevent the others (best effort). The decoy snapshot/restore semantics are preserved: - file existed before → restore the original content - file didn't exist → delete Test count: same 770; the test now isolates correctly even when an assertion throws mid-run. * Address copilot pass 6 on JavaScriptSolidServer#413 — dead code + deprecated API Two findings, both small. 1. `profilePath` in rebuildPubkeyIndex was assigned when a candidate matched but never read afterward — the only downstream consumers care about `profile` (the parsed JSON) and `mtimeMs` (used in the index entry). Pre-restructure it was the path used for the next steps; after pass-2's accept-or-fall-through loop the variable became orphaned. Removed the declaration and the assignment. 2. `fs.rmdir` (node:fs/promises) emits a deprecation warning in modern Node — it's slated for removal. Switched to `fs-extra`'s `fs.remove` which: - is already in use elsewhere in this test - handles both files and (recursively) directories - is a no-op on missing paths (matches our best-effort cleanup semantics) Replaces `await fs.rmdir(<dir>)` with `await fs.remove(<dir>)` for the two parent-dir cleanup steps. Test count: same 770.
1 parent 92b40bf commit bff24f2

2 files changed

Lines changed: 350 additions & 30 deletions

File tree

src/idp/well-known-did-nostr.js

Lines changed: 127 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -138,45 +138,85 @@ async function rebuildPubkeyIndex() {
138138
continue;
139139
}
140140
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;
144156
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+
}
153169
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})`);
158177
continue;
159178
}
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;
168212
}
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:
173214
// (2) VM's controller MUST be in the profile's expected controller
174215
// set (declared `controller`, with @id fallback)
175216
// (3) VM MUST be referenced from `authentication` — a key in
176217
// verificationMethod alone (no auth membership) shouldn't be
177218
// 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
180220
const expectedControllers = collectControllerIds(profile, profileSubject);
181221
if (expectedControllers.size === 0) continue;
182222
// Pass the already-validated absolute subject as the base. Without
@@ -260,6 +300,64 @@ export function profilePathFromWebId(dataRoot, webId, accountId = 'unknown') {
260300
return resolved;
261301
}
262302

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+
263361
function collectControllerIds(source, baseUrl) {
264362
const out = new Set();
265363
const c = source?.controller;

0 commit comments

Comments
 (0)