From ccfd02a74597142e9e8867eb835124a4182e69b9 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 09:38:36 +0000 Subject: [PATCH] fix: read a member's descriptor from the page's own rel=openwebring link A site that is a path on a shared host (a blog under /~name/) cannot put a file at that host's /.well-known/, which is why the spec's second discovery route is a on the page. The check only tried the origin, so the first active member of any ring (Chovy's Blog, which links to the Profullstack ring and points at its own openwebring.json) came back with made_by unstated. The origin's well-known file is still tried first; when it yields no descriptor, the page's pointer is followed, resolved against the page, attribute order not assumed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XYae2mH3khdwiXUVzcVMDw --- apps/web/test/openwebring.test.js | 24 ++++++++++++++++++ packages/ingest/src/webring.js | 42 ++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/apps/web/test/openwebring.test.js b/apps/web/test/openwebring.test.js index 70c3211..4610d74 100644 --- a/apps/web/test/openwebring.test.js +++ b/apps/web/test/openwebring.test.js @@ -254,6 +254,30 @@ test('a descriptor naming the ring counts as linking back', () => { assert.equal(descriptorUrlFor('not a url'), null); }); +test('a member on a shared host points at its descriptor from the page, and the check follows it', async () => { + // A blog under /~name/ cannot put a file at the host's /.well-known/, so its + // page says where the file is (the spec's second discovery route). The + // href is relative and the rel is one of several. + const site = 'https://host.example/~name/blog/'; + const page = `next`; + const asked = []; + const fetchText = async (url, opts) => { + asked.push(url); + if (url === site) return page; + if (url === 'https://host.example/~name/blog/openwebring.json') return JSON.stringify({ made_by: 'both', disclosure: 'ai-assisted', rings: [] }); + return null; + }; + const found = await checkRingMember({ base: BASE, ringSlug: 'physics', memberUrl: site, fetchText }); + assert.deepEqual(found, { status: 'active', linked: 'page', reachable: true, madeBy: 'both', disclosure: 'ai-assisted', descriptorUrl: 'https://host.example/~name/blog/openwebring.json' }); + assert.deepEqual(asked, [site, 'https://host.example/.well-known/openwebring.json', 'https://host.example/~name/blog/openwebring.json'], 'the origin first, then the page\'s own pointer'); + + // No pointer and no well-known file: nothing is fetched a third time. + const bare = []; + const none = await checkRingMember({ base: BASE, ringSlug: 'physics', memberUrl: site, fetchText: async (url) => { bare.push(url); return url === site ? '

plain

' : null; } }); + assert.equal(none.madeBy, null); + assert.equal(bare.length, 2); +}); + test('checking a member asks two URLs and decides from both', async () => { const page = `next`; const descriptor = JSON.stringify({ made_by: 'both', disclosure: 'ai-assisted', rings: [] }); diff --git a/packages/ingest/src/webring.js b/packages/ingest/src/webring.js index 2469756..8dd263f 100644 --- a/packages/ingest/src/webring.js +++ b/packages/ingest/src/webring.js @@ -80,6 +80,35 @@ export function linksToRing(html, base, slug) { * @param {string} siteUrl * @returns {string|null} */ +/** + * The descriptor a page points at with , + * resolved against the page, or null. The spec's second discovery route: + * a member whose site is a path on a shared host (a user directory, a blog + * under /~name/) cannot put a file at that host's /.well-known/, so the + * page says where the file is instead. Attribute order is not assumed. + * + * @param {string|null|undefined} html + * @param {string} pageUrl + * @returns {string|null} + */ +export function descriptorLinkFrom(html, pageUrl) { + if (typeof html !== 'string') return null; + const tags = html.match(/]*>/gi) ?? []; + for (const tag of tags) { + const rel = /\brel\s*=\s*["']([^"']*)["']/i.exec(tag)?.[1]?.toLowerCase() ?? ''; + if (!/\bopenwebring\b/.test(rel)) continue; + const href = /\bhref\s*=\s*["']([^"']*)["']/i.exec(tag)?.[1]?.trim(); + if (!href) continue; + try { + const u = new URL(href, pageUrl); + if (/^https?:$/.test(u.protocol)) return u.toString(); + } catch { + // A href that is not a URL is no descriptor. + } + } + return null; +} + export function descriptorUrlFor(siteUrl) { try { const u = new URL(siteUrl); @@ -223,12 +252,23 @@ export async function checkRingMember({ base, ringSlug, memberUrl, fetchText }) const html = await fetchText(memberUrl, { accept: 'text/html' }).catch(() => null); const linkedFromPage = linksToRing(html, base, ringSlug); - const descriptorUrl = descriptorUrlFor(memberUrl); + // The well-known file on the origin first; when there is none, the file + // the page itself points at. Both are the member's own words; a page on a + // shared host has only the second. + let descriptorUrl = descriptorUrlFor(memberUrl); let descriptor = null; if (descriptorUrl) { const text = await fetchText(descriptorUrl, { accept: 'application/json' }).catch(() => null); descriptor = parseRingDescriptor(text); } + if (!descriptor) { + const linked = descriptorLinkFrom(html, memberUrl); + if (linked && linked !== descriptorUrl) { + const text = await fetchText(linked, { accept: 'application/json' }).catch(() => null); + descriptor = parseRingDescriptor(text); + if (descriptor) descriptorUrl = linked; + } + } const namedInDescriptor = descriptorNamesRing(descriptor, base, ringSlug); return {