One sign-in drive for every e2e, the public surface and visibility in the rights matrix, and the stack gates in validate

e2e-login-flow and e2e-revocation now share the matrix's driveLogin, which counts a sign-in only once the app is really reached and the app install is asserted; their own loops could report "shell reached" while still on the picker page. The rights matrix gains a public-surface group (allow-listed endpoints answer 200, non-public paths 404, status codes only) and a visibility group (an event with a meeting point and a participant switched public and private by its owner: event, meeting point, attendees collection and catalog listing follow). validate.ts probes the stack and runs test:e2e-rights and test:e2e-revocation as layers, reported SKIPPED when the stack is down and never counted as passed.
This commit is contained in:
Sylvain Duchesne
2026-10-13 18:11:34 +02:00
parent 88f23b7023
commit 9381c206df
5 changed files with 437 additions and 179 deletions
+178 -1
View File
@@ -36,6 +36,20 @@
* profile; a twin event WITH public intent is readable and listed; Bob reads
* Alice's profile only after her join; the owner's own container POST lands).
*
* BACKEND PUBLIC SURFACE (T05.i allow-list). Anonymous GETs only, status codes
* only — never a body (before 88f23b7, `/key` served the app's private key):
* every allow-listed endpoint answers 200 (the control), a fixed list of
* non-public paths answers 404, the gate's refusal for every caller.
*
* VISIBILITY FOLLOWS THE OWNER (T05.i). An event V with a meeting point VM and
* a participant (Carol joins through her own outbox, as the app does) is
* switched private → public → private by Bob PATCHing `to as:Public` on his
* own two resources — a legitimate owner update (no UI for it yet). At every
* state: anonymous reads of V, VM and both attendees collections, the catalog
* listing, and Carol's membership (only the public read moves). For a
* collection the pod's refusal is 404 (VERIFIED 2026-10-13); it counts as one
* only next to Bob's 200 on the same URI at the same moment.
*
* Lines are independent: a failure is recorded and the run continues; a line
* whose prerequisite is missing FAILs with the name of what is missing.
*
@@ -50,6 +64,7 @@ import { chromium, type Browser, type Page } from 'playwright';
import {
attendeesCollectionUri,
linkId,
postOutboxActivity,
recordUri,
type ApodsSession,
type ApodsStackConfig,
@@ -99,6 +114,8 @@ const TITLE_M = `Rdv matrice ${TS}`;
const TITLE_M2 = `Rdv second ${TS}`;
const TITLE_P = `Prive matrice ${TS}`;
const TITLE_T = `Public jumeau ${TS}`;
const TITLE_V = `Visibilite matrice ${TS}`;
const TITLE_VM = `Rdv visibilite ${TS}`;
const PLATFORM_CONTEXT = 'https://activitypods.org/context.json';
const AS_PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
@@ -113,6 +130,10 @@ const S: {
bob?: ApodsSession; alice?: ApodsSession; carol?: ApodsSession;
/** M and its attendees A are the first meeting point; M2 / A2 the second. */
E?: string; M?: string; A?: string; M2?: string; A2?: string; P?: string; T?: string;
/** The visibility group's event, its meeting point, and their attendees collections. */
V?: string; VM?: string; AV?: string; AVM?: string;
/** Set only by the line that SAW Carol's WebID land in VM's collection. */
carolInVM?: boolean;
bobProfile?: string; aliceProfile?: string; carolProfile?: string;
/** Each user's own preferences file, keyed by the UI drive's tag (bob/alice/carol). */
preferences: Map<string, string>;
@@ -747,6 +768,160 @@ async function tracked(): Promise<void> {
});
}
/**
* The backend's public surface: the allow-list of `backend/services/public-surface.js`
* (T05.i). Anonymous GETs, STATUS CODES ONLY — no body is ever printed or kept.
*/
async function publicSurface(): Promise<void> {
const G = 'backend public surface (anonymous GET, status codes only)';
const status = async (path: string): Promise<number> => (await http('GET', `${BACKEND}${path}`)).status;
const pathOf = (uri: string | undefined, what: string): string => {
const u = need(uri, what);
if (!u.startsWith(`${BACKEND}/`)) throw new Error(`${what} is not on the backend (${u})`);
return u.slice(BACKEND.length);
};
const shown = (path: string): string => path.replace(/\/[A-Za-z0-9_-]{16,}$/, '/<id>');
await m.hard('surface.public-200', G,
'CONTROL: every allow-listed public endpoint answers an anonymous GET',
'200 each: /app, /as/actor/app, /catalog, /public-key/<id>, /interop/access-need-group/<id>, /interop/access-need/<id>, /.well-known/context.jsonld, /.well-known/nodeinfo, /nodeinfo/2.1',
async () => {
const actor = parseJson((await http('GET', `${BACKEND}/as/actor/app`)).text);
const app = parseJson((await http('GET', `${BACKEND}/app`)).text);
const groupPath = pathOf(linkId(app?.['interop:hasAccessNeedGroup']), 'the access-need group (GET /app)');
const group = parseJson((await http('GET', `${BACKEND}${groupPath}`)).text);
const paths = [
'/app', '/as/actor/app', '/catalog',
pathOf(linkId(actor?.['publicKey']), 'the app actor\'s public key (GET /as/actor/app)'),
groupPath,
pathOf(linkId(arrayOf(group?.['interop:hasAccessNeed'])[0]), 'an access need (the access-need group)'),
'/.well-known/context.jsonld', '/.well-known/nodeinfo', '/nodeinfo/2.1',
];
const codes = await Promise.all(paths.map(status));
return { ok: codes.every(c => c === 200), actual: paths.map((p, i) => `${shown(p)} ${codes[i]}`).join(', ') };
});
const webfinger = `/.well-known/webfinger?resource=${encodeURIComponent(`acct:app@${new URL(BACKEND).host}`)}`;
const refusedPaths = [
'/', '/key', '/sparql', '/sparql?query=ASK%7B%7D', '/settings', '/proxy', '/_acl', '/.webhooks',
'/interop/application-registration', '/interop/access-grant', '/interop/data-grant',
'/as/actor/app/outbox', '/as/actor/app/followers', webfinger,
];
await m.hard('surface.non-public-404', G,
'anonymous GET on non-public backend paths (private keys, SPARQL, listings, registrations and grants, the app actor\'s collections, webfinger)',
'404 each — the gate\'s refusal for every caller (control: surface.public-200 on the same host)', async () => {
const codes = await Promise.all(refusedPaths.map(status));
return { ok: codes.every(c => c === 404), actual: refusedPaths.map((p, i) => `${p} ${codes[i]}`).join(', ') };
});
m.manual('manual.surface-writes', G,
'writes on the backend outside its two delivery endpoints are refused (404), and a crafted delivery to POST /as/actor/app/inbox or /.webhooks/<id> is never trusted',
'NOT exercised live (read-only sweeps only, user decision 2026-10-13). Covered by backend unit tests — '
+ 'public-surface.test.js (every write outside the two delivery endpoints refused; the LDP catch-all behind the gate) and '
+ 'emitted-activity.test.js (activity and object re-read from the pod before any decision).');
}
/** Bob's view of a resource's attendees collection link, polled (the backend attaches it asynchronously). */
async function collectionOf(uri: string): Promise<string | undefined> {
const bob = need(S.bob, 'Bob account');
return poll(async () => attendeesCollectionUri(parseJson((await http('GET', uri, { token: bob.token })).text) ?? {}), v => !!v, 30000);
}
/** Bob POSTs an event the way Festipod creates one (container POST, `to: as:Public`). */
async function postPublicEvent(name: string, extra: Record<string, unknown> = {}): Promise<{ uri?: string; status: number }> {
const bob = need(S.bob, 'Bob account');
const r = await http('POST', `${bob.storage}/as/event`, {
token: bob.token, contentType: 'application/ld+json',
body: JSON.stringify({ '@context': PLATFORM_CONTEXT, type: 'Event', name, startTime: '2026-12-22T18:00:00Z', endTime: '2026-12-22T22:00:00Z', to: AS_PUBLIC, ...extra }),
});
return { uri: r.headers.get('Location') ?? undefined, status: r.status };
}
async function visibility(): Promise<void> {
const G = 'visibility follows the owner (V + VM + participant)';
await m.hard('visibility.setup-V', G, `Bob creates event V "${TITLE_V}" and its meeting point VM "${TITLE_VM}" as Festipod does (container POST, \`to: as:Public\`)`, '201 + Location for each', async () => {
const v = await postPublicEvent(TITLE_V);
S.V = v.uri;
const vm = S.V ? await postPublicEvent(TITLE_VM, { 'as:context': S.V }) : { status: 0, uri: undefined };
S.VM = vm.uri;
return { ok: !!S.V && !!S.VM, actual: `V: HTTP ${v.status} → ${S.V ?? '(no Location)'}; VM: HTTP ${vm.status} → ${S.VM ?? '(no Location)'}` };
});
await m.hard('visibility.setup-collections', G, 'V and VM each carry their apods:attendees collection', 'two distinct collection URIs within 30 s', async () => {
S.AV = await collectionOf(need(S.V, 'event V'));
S.AVM = await collectionOf(need(S.VM, 'meeting point VM'));
return { ok: !!S.AV && !!S.AVM && S.AV !== S.AVM, actual: `V: ${S.AV ?? '(none)'}; VM: ${S.AVM ?? '(none)'}` };
});
await m.hard('visibility.setup-join', G, "Carol joins VM through her own outbox (Join addressed to Bob, as the app does)", "Carol's WebID in VM's attendees items within 30 s (read by Bob)", async () => {
const carol = need(S.carol, 'Carol account');
const bob = need(S.bob, 'Bob account');
const AVM = need(S.AVM, "VM's attendees collection");
await postOutboxActivity(carol, 'Join', need(S.VM, 'meeting point VM'), bob.webId);
const a = await poll(() => attendees(AVM, bob.token), v => v.status === 200 && v.items.includes(carol.webId), 30000);
S.carolInVM = a.status === 200 && a.items.includes(carol.webId);
return { ok: S.carolInVM, actual: `HTTP ${a.status} items=${JSON.stringify(a.items)}` };
});
const AS_TO = 'https://www.w3.org/ns/activitystreams#to';
const setPublic = async (uri: string, on: boolean): Promise<number> =>
(await http('PATCH', uri, { token: need(S.bob, 'Bob account').token, contentType: 'application/sparql-update', body: `${on ? 'INSERT' : 'DELETE'} DATA { <${uri}> <${AS_TO}> <${AS_PUBLIC}> . }` })).status;
type Read = { label: string; uri: string; anon: number; bob?: number };
/** One observation of the four resources: anonymous status, plus Bob's for the collections (the 404 control). */
const observe = async (): Promise<Read[]> => {
const bob = need(S.bob, 'Bob account');
const targets: Array<[string, string, boolean]> = [
['V', need(S.V, 'event V'), false], ['VM', need(S.VM, 'meeting point VM'), false],
["VM's collection", need(S.AVM, "VM's attendees collection"), true], ["V's collection", need(S.AV, "V's attendees collection"), true],
];
return Promise.all(targets.map(async ([label, uri, isCollection]) => ({
label, uri,
anon: (await http('GET', uri)).status,
bob: isCollection ? (await http('GET', uri, { token: bob.token })).status : undefined,
})));
};
const readable = (r: Read): boolean => r.anon === 200;
/** A refusal: 401/403; for a collection also the pod's 404, but only while Bob reads the same URI 200. */
const closed = (r: Read): boolean => refused(r.anon, ANON) || (r.bob !== undefined && r.anon === 404 && r.bob === 200);
const show = (rs: Read[]): string => rs.map(r => `${r.label} anon ${r.anon}${r.bob !== undefined ? ` (Bob ${r.bob})` : ''}`).join(', ');
const states: Array<{ id: string; label: string; on: boolean; toggle: boolean }> = [
{ id: 'created-public', label: 'as created (public)', on: true, toggle: false },
{ id: 'private-1', label: 'switched private', on: false, toggle: true },
{ id: 'public-again', label: 'switched public again', on: true, toggle: true },
{ id: 'private-2', label: 'switched private again', on: false, toggle: true },
];
for (const st of states) {
if (st.toggle) {
await m.hard(`visibility.${st.id}-update`, G, `Bob ${st.on ? 'adds' : 'removes'} \`to as:Public\` on V and on VM (PATCH, his own token)`, '2xx for both', async () => {
const a = await setPublic(need(S.V, 'event V'), st.on);
const b = await setPublic(need(S.VM, 'meeting point VM'), st.on);
return { ok: a >= 200 && a < 300 && b >= 200 && b < 300, actual: `V PATCH ${a}, VM PATCH ${b}` };
});
}
await m.hard(`visibility.${st.id}-reads`, G,
`${st.label}: anonymous reads of V, VM, VM's collection, V's collection`,
st.on ? '200 for all four within 45 s' : 'V and VM refused 401/403; each collection refused (401/403, or the pod\'s 404 while Bob reads it 200) within 45 s',
async () => {
const want = st.on ? readable : closed;
const rs = await poll(observe, v => v.every(want), 45000, 1500);
return { ok: rs.every(want), actual: show(rs) };
});
await m.hard(`visibility.${st.id}-catalog`, G, `${st.label}: V and VM ${st.on ? 'listed in' : 'absent from'} GET /catalog`, `${st.on ? 'both listed' : 'neither listed'} within 45 s`, async () => {
const V = need(S.V, 'event V');
const VM = need(S.VM, 'meeting point VM');
const c = await poll(() => catalogIds(), v => v.ids.includes(V) === st.on && v.ids.includes(VM) === st.on, 45000, 1500);
return { ok: c.ids.includes(V) === st.on && c.ids.includes(VM) === st.on, actual: `V ${c.ids.includes(V) ? 'listed' : 'absent'}, VM ${c.ids.includes(VM) ? 'listed' : 'absent'}` };
});
await m.hard(`visibility.${st.id}-member`, G, `${st.label}: Carol is still a member of VM (only the public read moved)`, "Carol's WebID in VM's items (read by Bob, 200)", async () => {
need(S.carolInVM, 'the join on VM landed (visibility.setup-join)');
const carol = need(S.carol, 'Carol account');
const a = await attendees(need(S.AVM, "VM's attendees collection"), need(S.bob, 'Bob account').token);
return { ok: a.status === 200 && a.items.includes(carol.webId), actual: `HTTP ${a.status} items=${JSON.stringify(a.items)}` };
});
}
m.manual('manual.visibility-crafted', G,
'a webhook or activity CLAIMING a visibility change (not made on the pod) never grants, revokes, lists or de-lists',
'NOT exercised live (no forged activity, user decision 2026-10-13). Covered by backend unit tests — emitted-activity.test.js and '
+ 'public-read.service.test.js / catalog.service.test.js (decisions taken on the object re-read from the pod, under its per-object lock).');
}
async function uiHygiene(): Promise<void> {
const G = 'UI drives';
// Expected refusals are exact URLs: this run's profile documents, and each
@@ -787,6 +962,7 @@ async function uiHygiene(): Promise<void> {
// cross-origin rules, as it does for a user.
const browser = await chromium.launch({ headless: true });
try {
await publicSurface();
await setup(browser);
await publicReads();
await writesRefused();
@@ -800,12 +976,13 @@ async function uiHygiene(): Promise<void> {
await leave();
await deletesRefused();
await tracked();
await visibility();
} finally {
await uiHygiene();
await browser.close().catch(() => {});
}
const file = joinPath(tmpdir(), `rights-matrix-${TS}.json`);
writeFileSync(file, JSON.stringify({ run: TS, users: USERS, ids: { E: S.E, M: S.M, A: S.A, M2: S.M2, A2: S.A2, P: S.P, T: S.T }, lines: m.lines, hops: ui.hops, uiErrors: ui.errors, pageErrors: ui.pageErrors, consoleErrors: ui.consoleErrors }, null, 2));
writeFileSync(file, JSON.stringify({ run: TS, users: USERS, ids: { E: S.E, M: S.M, A: S.A, M2: S.M2, A2: S.A2, P: S.P, T: S.T, V: S.V, VM: S.VM, AV: S.AV, AVM: S.AVM }, lines: m.lines, hops: ui.hops, uiErrors: ui.errors, pageErrors: ui.pageErrors, consoleErrors: ui.consoleErrors }, null, 2));
m.print(`run ${TS} (bob=${USERS.bob}, alice=${USERS.alice}, carol=${USERS.carol}) — JSON: ${file}`);
process.exit(m.hardFailures.length === 0 ? 0 : 1);
})().catch(err => {