diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d424ef..9401251 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ Versions are `-`: a new stock release starts at `-1`, a new overlay on the same stock adds one. A tag `v` publishes both images (`.github/workflows/build.yml`). +## Unreleased + +- **Consent keeps the account's agents apart** (#25). A WebID that another + WebID of the account declares as its agent (`acl:delegates` in its public + profile, as the MCP connector writes it) moves to a closed "Your agents" + list under the person's own WebIDs; it is not removed. Only this server's + profiles are read, at most 12, 3 s in all, without cookies; a profile that + cannot be read moves nothing. Smoke: an agent linked to the test account is + listed apart; with the card unreadable, nothing moves. + ## 7.3.0-1 — 2026-11-03 Stock CSS 7.3.0 (#30). An instance updates the image; nothing to change in diff --git a/overlay/main.css b/overlay/main.css index 80e8109..ff4fff5 100644 --- a/overlay/main.css +++ b/overlay/main.css @@ -273,8 +273,9 @@ li.radio > ol { margin: 8px 0 0 32px; } server's host, /profile/, #me; ext = a host that is not this server; insecure = an http:// scheme, kept, in danger. Lines break only at the after each "/", never inside a name. */ -#webIdList { gap: 8px; } -#webIdList li.radio > label { +#webIdList, #agentList { gap: 8px; } +#webIdList li.radio > label, +#agentList li.radio > label { align-items: center; padding: 10px 12px; border: 1px solid var(--border-control); @@ -285,10 +286,15 @@ li.radio > ol { margin: 8px 0 0 32px; } line-height: 1.5; overflow-wrap: break-word; } -#webIdList li.radio > label:has(input:checked) { +#webIdList li.radio > label:has(input:checked), +#agentList li.radio > label:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); } +/* The account's agents (issue #25): closed under the person's own WebIDs. */ +details.agents { margin-top: 16px; } +details.agents summary { cursor: pointer; padding: 10px 0; color: var(--text-secondary); } +details.agents p.hint { margin: 4px 0 12px; } .webid { flex: 1; min-width: 0; } .webid-key { color: var(--text-primary); font-weight: 500; } .webid-dim { color: var(--text-tertiary); } diff --git a/overlay/templates/identity/oidc/consent.html.ejs b/overlay/templates/identity/oidc/consent.html.ejs index e070053..f9b6b34 100644 --- a/overlay/templates/identity/oidc/consent.html.ejs +++ b/overlay/templates/identity/oidc/consent.html.ejs @@ -4,7 +4,9 @@ cannot take autofocus); the WebID list is sorted, labelled for reading and pre-selects the WebID last authorized in this browser; client name and ID are inserted as text, not markup (stock inserts the app's own metadata as HTML). - Added: #client-origin, where the app runs (solid-kit ADR 010, point 6). -%> + Added: #client-origin, where the app runs (solid-kit ADR 010, point 6); + #agents, a closed "Your agents" list for the WebIDs another listed WebID + declares as its agent (acl:delegates, read from the public profiles). -%>

An app wants to use your pod

@@ -21,6 +23,14 @@ Choose the WebID — your name on the web — this app will act as

+
@@ -69,7 +79,10 @@ try { last = localStorage.getItem(LAST_WEBID_KEY); } catch { /* storage blocked */ } const preselected = webIds.length === 1 ? webIds[0] : (webIds.includes(last) ? last : null); - // List WebIDs on page, grouped by pod, the pod's own card first + // List WebIDs on page, grouped by pod, the pod's own card first; the + // account's agents apart, in a closed list (issue #25). + const agents = await findAgents(webIds); + const agentList = document.getElementById('agentList'); for (const webId of sortWebIds(webIds)) { const li = document.createElement('li'); li.className = 'radio'; @@ -81,7 +94,14 @@ input.checked = webId === preselected; label.append(input, webIdLabel(webId)); li.append(label); - webIdList.append(li); + (agents.has(webId) ? agentList : webIdList).append(li); + } + if (agents.size > 0) { + const box = document.getElementById('agents'); + document.getElementById('agentCount').textContent = agents.size; + box.hidden = false; + // Last time this browser chose an agent: show it, never a hidden choice. + box.open = agents.has(preselected); } const { client } = await fetchJson(controls.oidc.consent); @@ -94,6 +114,57 @@ addPostListener(() => consent(controls)); })(); + // Which listed WebIDs are agents: a WebID is an agent when another listed + // WebID's public profile says ` acl:delegates ` (the MCP + // connector writes that in the person's card for each agent it makes). An + // agent has no pod of its own and acts for the person, so signing in to an + // app *as* it is almost never what someone means; it is kept, not removed + // (a test or an admin may want it). + // Bounded and cautious: + // - only this server's WebIDs are read: another server is not told that + // someone is signing in here; + // - at most AGENT_FETCH_MAX profiles, AGENT_FETCH_MS in all, without + // cookies; whatever is not read in time counts as "not an agent"; + // - a WebID is only ever moved on what a profile *says*, never because a + // fetch failed: a failure leaves the list as it was before. + // N-Triples, one triple per line, so no RDF library in the page; CSS + // converts the profile to it. The WebIDs are still not parsed for logic: + // the host test only decides whom to ask, and the URIs are compared whole. + const AGENT_FETCH_MAX = 12; + const AGENT_FETCH_MS = 3000; + const DELEGATES = 'http://www.w3.org/ns/auth/acl#delegates'; + async function findAgents(webIds) { + const agents = new Set(); + if (webIds.length < 2) return agents; + const listed = new Set(webIds); + const ours = (webId) => { + try { + const { host } = new URL(webId); + return host === location.host || host.endsWith(`.${location.host}`); + } catch { return false; } + }; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), AGENT_FETCH_MS); + const read = async(webId) => { + try { + // fetch() drops the #fragment: this is the profile document. + const res = await fetch(webId, { headers: { accept: 'application/n-triples' }, credentials: 'omit', signal: controller.signal }); + if (!res.ok) return; + for (const line of (await res.text()).split('\n')) { + const triple = /^<([^>]*)>\s+<([^>]*)>\s+<([^>]*)>\s*\.\s*$/.exec(line.trim()); + if (triple && triple[1] === webId && triple[2] === DELEGATES && triple[3] !== webId && listed.has(triple[3])) { + agents.add(triple[3]); + } + } + } catch { /* unreadable, timed out: says nothing */ } + }; + await Promise.all(webIds.filter(ours).slice(0, AGENT_FETCH_MAX).map(read)); + clearTimeout(timer); + // Never leave the person without a WebID in plain sight. + if (agents.size === webIds.length) agents.clear(); + return agents; + } + // The WebID as a person reads it among six or seven: the pod and the // document stand out (key), this server's host, /profile/ and #me are muted // (dim), another server's host stays strong (ext), an http:// scheme stays diff --git a/test/smoke.mjs b/test/smoke.mjs index 71609db..958726c 100644 --- a/test/smoke.mjs +++ b/test/smoke.mjs @@ -168,6 +168,43 @@ const again = await desk.newPage(); await toConsent(again); check((await again.$eval("#webIdList input:checked", (i) => i.value).catch(() => null)) === chosen, "consent: the last WebID chosen is pre-selected"); +// The account's agents apart (issue #25). An agent WebID in the account's own +// pod links without an ownership check (CSS: the account made that pod). The +// person's card is what says it is an agent: the connector writes +// ` acl:delegates `, here added to the served card by the +// browser, as this run has no Solid session to write the card with. +const card = `${podUrl("nicolas")}profile/card#me`; +const agentId = `${podUrl("nicolas")}profile/agent#me`; +const accountControls = (await (await page.request.get(`${base}/.account/`, { headers: { accept: "application/json" } })).json()).controls; +const linked = await page.request.post(accountControls.account.webId, { data: { webId: agentId } }); +check(linked.ok(), `consent: an agent WebID links to the account (${linked.status()})`); +const delegating = async (route) => { + const res = await route.fetch(); + const body = (await res.text()) + `\n<${card}> <${agentId}> .\n`; + await route.fulfill({ response: res, body }); +}; +const withAgent = await desk.newPage(); +await withAgent.route(card.replace(/#.*/, ""), delegating); +await toConsent(withAgent); +const placed = await withAgent.evaluate((a) => ({ + inMain: [...document.querySelectorAll("#webIdList input")].some((i) => i.value === a), + inAgents: [...document.querySelectorAll("#agentList input")].some((i) => i.value === a), + open: document.getElementById("agents").open, + hidden: document.getElementById("agents").hidden, +}), agentId); +check(placed.inAgents && !placed.inMain && !placed.hidden && !placed.open, `consent: an agent is listed apart, closed (${JSON.stringify(placed)})`); +check(await withAgent.$$eval("#webIdList input", (is, c) => is.some((i) => i.value === c), card), "consent: the person's own card stays in the main list"); +await withAgent.click("#agents summary"); +await shot(withAgent, "06c-consent-agents"); +await withAgent.close(); +// No profile readable: nothing is moved, the agent is listed as before. +const blind = await desk.newPage(); +await blind.route(card.replace(/#.*/, ""), (route) => route.abort()); +await toConsent(blind); +check(await blind.$$eval("#webIdList input", (is, a) => is.some((i) => i.value === a), agentId) && (await blind.$eval("#agents", (d) => d.hidden)), + "consent: an unreadable card hides nothing"); +await blind.close(); + // An app with a Client ID Document: the server fetched it and sends the sign-in // only to its redirect URIs, so the page names the document's host. Served // here, on the machine running the smoke run; the server must reach it, which