Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ Versions are `<stock CSS version>-<n>`: a new stock release starts at `-1`, a
new overlay on the same stock adds one. A tag `v<version>` 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
Expand Down
12 changes: 9 additions & 3 deletions overlay/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 <wbr>
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);
Expand All @@ -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); }
Expand Down
77 changes: 74 additions & 3 deletions overlay/templates/identity/oidc/consent.html.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -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). -%>
<h1 tabindex="-1" autofocus>An app wants to use your pod</h1>
<p class="error" id="error"></p>
<p class="intro">
Expand All @@ -21,6 +23,14 @@
<legend>Choose the WebID — your name on the web — this app will act as</legend>
<ol id="webIdList">
</ol>
<details id="agents" class="agents" hidden>
<summary>Your agents (<span id="agentCount"></span>)</summary>
<p class="hint">
Programs that act for you, such as an assistant connected to your pod.
An app you sign in to should normally get your own WebID, not one of theirs.
</p>
<ol id="agentList"></ol>
</details>
</fieldset>

<fieldset>
Expand Down Expand Up @@ -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';
Expand All @@ -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);
Expand All @@ -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 `<person> acl:delegates <it>` (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
Expand Down
37 changes: 37 additions & 0 deletions test/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// `<card#me> acl:delegates <agent#me>`, 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}> <http://www.w3.org/ns/auth/acl#delegates> <${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
Expand Down
Loading