/** * The rights matrix, end to end, on the REAL local ActivityPods stack: three * fresh users per run — Bob (organizer), Alice (another user), Carol (a third * user) — plus anonymous, every feature exercised with positive AND negative * access assertions. This script IS the acceptance gate for public data, * discovery and participation: a human no longer validates them one by one. * * HOW IT DRIVES. The browser where UI truth matters (Bob and Alice sign in * through the pod's login + consent; Bob creates the event and TWO meeting * points through the app; Alice finds the event in Découvrir, opens it, joins * and leaves both meeting points by tapping; Carol, a non-contact, reads the * counts her own view renders). Direct HTTP with each user's own token for the * access-control lines (faster, and precise about statuses). Carol installs the * app programmatically (the smoke's registration call), then signs in. Setup * asserts each user's registered Event container answers 200: a sign-in that * never installed the app fails AT setup, not as a 400 twenty lines later. * * DEFENSIVE ONLY (user decision 2026-10-13). No line sends a forged or spoofed * activity, nor writes spoofed data, to the live stack. Such checks are * MANUAL-CHECK lines naming where they are covered (backend unit tests). * * THE REPORT. Every line is a named assertion with expected vs actual: * PASS / FAIL — hard lines; any FAIL makes the exit code non-zero. * KNOWN-DEVIATION — a tracked, decided deviation from the domain * doctrine (attendee lists are world-readable). * KNOWN-DEFECT — a tracked platform defect (anonymous PATCH on an * attendees collection is accepted). * MANUAL-CHECK — not exercised live (see DEFENSIVE ONLY above). * Tracked lines never fail the run, but print a loud NOTICE the day their * behaviour changes, so they get promoted to hard assertions. * * NEGATIVE LINES pass only on an explicit refusal: 401 or 403 for an anonymous * request, 403 for an authenticated one. A 404, a 5xx or an unreachable host * (status 0) is never a refusal. Each negative line that could be passed by a * broken probe has a CONTROL line on the opposite state (Alice reads her own * 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. * * ORIGIN. As for `e2e-login-flow.ts`: the OIDC redirect_uri is registered for * `http://localhost:4000/login` only, so the app MUST be served at APP_URL. */ import { writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join as joinPath } from 'node:path'; import { chromium, type Browser, type Page } from 'playwright'; import { attendeesCollectionUri, linkId, postOutboxActivity, recordUri, type ApodsSession, type ApodsStackConfig, type JsonRecord, } from '../src/shared/support/activitypodsStack'; import { Matrix, arrayOf, benignUiError, bodyText, brief, createEventUi, createMeetingPointUi, driveLogin, http, installApp, installedContainer, itemUris, loginWithRetry, need, parseJson, poll, profileUriOf, refused, sleep, splitPageErrors, preferencesFileOf, wire, type CheckOutcome, type DriveContext, } from './e2e-rights-matrix-support'; const strip = (u: string): string => u.replace(/\/+$/, ''); const BACKEND = strip(process.env.BACKEND_URL || 'http://localhost:3003'); const APP = strip(process.env.APP_URL || 'http://localhost:4000'); const POD_FRONT = strip(process.env.POD_FRONTEND_URL || 'http://localhost:5000'); const config: ApodsStackConfig = { podProviderUrl: strip(process.env.POD_PROVIDER_URL || 'http://localhost:3000'), backendUrl: BACKEND, }; const TS = Date.now(); const PASS = 'Test1test'; const USERS = { bob: `rmbob${TS}`, alice: `rmalice${TS}`, carol: `rmcarol${TS}` }; const TITLE_E = `Matrice droits ${TS}`; 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'; const ANON = [401, 403]; const AUTHN = [403]; const m = new Matrix(); const ui: DriveContext = { app: APP, podFront: POD_FRONT, hops: [], errors: [], consoleErrors: [], pageErrors: [], deadline: Date.now() + 15 * 60 * 1000 }; /** Everything the lines share; each field is set by the line that establishes it. */ 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; alicePage?: Page; carolPage?: Page; /** Set only by the line that SAW Alice's WebID land in the collection. */ joined: { M?: boolean; M2?: boolean }; before?: { participants: number; mp: number; mp2: number }; snapshot?: { E: string; M: string }; } = { joined: {}, preferences: new Map() }; // ── Small readers ───────────────────────────────────────────────────────── async function catalogIds(): Promise<{ ids: string[]; items: JsonRecord[]; raw: string }> { const r = await http('GET', `${BACKEND}/catalog`, { accept: 'application/json' }); if (r.status !== 200) throw new Error(`GET /catalog → ${brief(r)}`); const body = parseJson(r.text); const items = arrayOf(body?.['orderedItems'] ?? body?.['items'] ?? body?.['as:orderedItems']) .filter((x): x is JsonRecord => typeof x === 'object' && x !== null); return { ids: items.map(i => recordUri(i)).filter((u): u is string => !!u), items, raw: r.text }; } async function attendees(uri: string, token?: string): Promise<{ status: number; items: string[]; brief: string }> { const r = await http('GET', uri, { token }); return { status: r.status, items: r.status === 200 ? itemUris(parseJson(r.text)) : [], brief: brief(r) }; } const ctxLink = (rec: JsonRecord): string | undefined => linkId(rec['as:context'] ?? rec['context']); /** * The card of one meeting point on the event detail page: the deepest element * holding both its title and a button (ancestors precede descendants in * document order, so `.last()` is the card itself). */ const mpCard = (page: Page, title: string) => page.locator('div').filter({ has: page.getByText(title, { exact: true }) }).filter({ has: page.getByRole('button') }).last(); const countIn = (text: string): number | undefined => { const k = /👥\s*(\d+)\s+inscrits?/i.exec(text); return k ? Number(k[1]) : undefined; }; /** UI counts on the event detail page: header "PARTICIPANTS (n)" and each meeting point's "👥 k inscrit(s)". */ async function detailCounts(page: Page): Promise<{ participants?: number; mp?: number; mp2?: number; text: string }> { const text = await bodyText(page); const p = /participants \((\d+)\)/i.exec(text); const card = async (title: string): Promise => countIn(await mpCard(page, title).innerText({ timeout: 2000 }).catch(() => '')); return { participants: p ? Number(p[1]) : undefined, mp: await card(TITLE_M), mp2: await card(TITLE_M2), text }; } const showCounts = (c: { participants?: number; mp?: number; mp2?: number }): string => `PARTICIPANTS (${c.participants}), M ${c.mp} inscrit, M2 ${c.mp2} inscrit`; /** * The summed counter of E read from the data: every meeting point the catalog * links to E, each attendees collection read anonymously. A read that does not * answer 200 is an ERROR, never "0 items". */ async function summedCounter(): Promise<{ mps: string[]; per: Map; sum: number }> { const E = need(S.E, 'E'); const mps = (await catalogIds()).items.filter(i => ctxLink(i) === E); const per = new Map(); let sum = 0; for (const mp of mps) { const uri = need(recordUri(mp), 'a meeting point id in the catalog'); const a = await attendees(need(attendeesCollectionUri(mp), `attendees of ${uri}`)); if (a.status !== 200) throw new Error(`attendees of ${uri} → ${a.brief} (a failed read is never counted as 0)`); per.set(uri, a.items); sum += a.items.length; } return { mps: mps.map(i => recordUri(i) ?? '?'), per, sum }; } const PUT_BODY = (id: string): string => JSON.stringify({ '@context': PLATFORM_CONTEXT, id, type: 'Event', name: `HIJACKED ${TS}` }); const POST_BODY = JSON.stringify({ '@context': PLATFORM_CONTEXT, type: 'Note', content: `HIJACKED ${TS}` }); const PATCH_BODY = (id: string): string => `INSERT DATA { <${id}> "HIJACKED ${TS}" . }`; /** A write attempt that must be refused with one of `allowed`. */ function refusedWrite( id: string, name: string, method: 'PUT' | 'POST' | 'PATCH' | 'DELETE', who: 'anonymous' | 'alice', target: () => string, group: string, opts: { body?: string } = {}, ): Promise { const allowed = who === 'anonymous' ? ANON : AUTHN; return m.hard(id, group, name, `refused ${allowed.join('/')}`, async () => { const uri = target(); const token = who === 'alice' ? need(S.alice, 'Alice session').token : undefined; const r = method === 'PUT' ? await http('PUT', uri, { token, body: opts.body ?? PUT_BODY(uri), contentType: 'application/ld+json' }) : method === 'POST' ? await http('POST', uri, { token, body: opts.body ?? POST_BODY, contentType: 'application/ld+json' }) : method === 'PATCH' ? await http('PATCH', uri, { token, body: opts.body ?? PATCH_BODY(uri), contentType: 'application/sparql-update' }) : await http('DELETE', uri, { token }); return { ok: refused(r.status, allowed), actual: brief(r) }; }); } /** * POST on a NON-container resource is rejected before any rights check, for * every agent — the owner included (VERIFIED 2026-10-12: Bob's own POST on E → * 400 with JSON-LD, 500 with Turtle; E unchanged). It is not a write path, so * the matrix's POST-on-E/M lines are INFORMATIONAL (orchestrator decision * 2026-10-12): each shows its status beside the owner's own POST on E. The * POST requests still fire before `write.unchanged`, which fails on any byte * they would change. The rights intent is asserted by the strict * container-POST lines and the PUT/PATCH/DELETE lines. */ const POST_ON_RESOURCE_NAME = (who: string, target: string): string => `${who} POST on ${target} — method-not-applicable: POST on a non-container resource → 400 for every agent, owner included; ` + 'the POST rights intent is asserted by write.*-POST-container (anonymous/Alice refused, owner 201) and the PUT/PATCH/DELETE lines'; /** Fire a POST on a non-container resource and keep its status for the informational line. */ async function postOnResource(who: 'anonymous' | 'alice', target: () => string): Promise { try { const uri = target(); const token = who === 'alice' ? need(S.alice, 'Alice session').token : undefined; const r = await http('POST', uri, { token, body: POST_BODY, contentType: 'application/ld+json' }); return String(r.status); } catch (e) { return `not observed: ${String(e).slice(0, 120)}`; } } // ── The matrix ──────────────────────────────────────────────────────────── /** A UI sign-in line: the drive's own verdict, never the absence of the picker. */ function signIn(id: string, who: 'bob' | 'alice' | 'carol', label: string, page: Page, onOk: () => void): Promise { return m.hard(id, 'setup', `${label} signs in to the app: pod login → consent (if not yet installed) → app shell`, 'left the app for the pod, came back, bottom navigation shown, no picker', async () => { need(S[who], `${label} account`); const r = await driveLogin(page, who, USERS[who], PASS, ui); if (r.ok) onOk(); return { ok: r.ok, actual: r.detail }; }); } /** The install assertion: the registered Event container answers 200 for this user. */ function installed(id: string, who: 'bob' | 'alice' | 'carol', label: string): Promise { return m.hard(id, 'setup', `${label}'s pod has the app installed`, 'her/his as/event container answers 200 (else: the sign-in never installed the app)', async (): Promise => installedContainer(need(S[who], `${label} account`))); } /** Find a meeting point of E in Bob's container by its title; polled (the write lands asynchronously). */ async function findMeetingPoint(title: string): Promise { const bob = need(S.bob, 'Bob account'); const E = need(S.E, 'event E'); return poll(async () => { const c = parseJson((await http('GET', `${bob.storage}/as/event`, { token: bob.token })).text); for (const entry of arrayOf(c?.['ldp:contains'] ?? c?.['contains'])) { const uri = linkId(entry); if (!uri || uri === E) continue; const rec = parseJson((await http('GET', uri, { token: bob.token })).text); if (rec && ctxLink(rec) === E && JSON.stringify(rec).includes(title)) return uri; } return undefined; }, v => !!v, 20000); } async function attachedCollection(M: string): Promise { const bob = need(S.bob, 'Bob account'); return poll(async () => attendeesCollectionUri(parseJson((await http('GET', M, { token: bob.token })).text) ?? {}), v => !!v, 20000); } async function setup(browser: Browser): Promise { const G = 'setup'; await m.hard('setup.bob-account', G, `Bob (${USERS.bob}) signs up / in on the pod`, 'session with outbox + storage', async () => { S.bob = await loginWithRetry(config, USERS.bob, PASS); return { ok: true, actual: S.bob.webId }; }); await m.hard('setup.alice-account', G, `Alice (${USERS.alice}) signs up / in on the pod`, 'session with outbox + storage', async () => { S.alice = await loginWithRetry(config, USERS.alice, PASS); return { ok: true, actual: S.alice.webId }; }); await m.hard('setup.carol-account', G, `Carol (${USERS.carol}) signs up / in on the pod`, 'session with outbox + storage', async () => { S.carol = await loginWithRetry(config, USERS.carol, PASS); return { ok: true, actual: S.carol.webId }; }); await m.hard('setup.carol-install', G, 'Carol installs the app (programmatic registration)', 'registered; her as/event container answers 200', async () => installApp(config, need(S.carol, 'Carol session'))); // Bob: consent through the UI, then the event and its two meeting points through the app. const bobCtx = await browser.newContext(); const bobPage = await bobCtx.newPage(); wire(bobPage, 'bob', ui); await signIn('setup.bob-consent', 'bob', 'Bob', bobPage, () => {}); await installed('setup.bob-installed', 'bob', 'Bob'); await m.hard('setup.bob-event', G, `Bob creates event E "${TITLE_E}" through the app`, 'lands on E detail; E is in Bob\'s pod with that name', async () => { const bob = need(S.bob, 'Bob account'); const r = await createEventUi(bobPage, APP, TITLE_E); const E = need(r.eventUri, `an event route after creation (landed on ${r.finalUrl})`); const rec = parseJson((await http('GET', E, { token: bob.token })).text); const name = rec?.['name']; S.E = E; return { ok: E.startsWith(`${bob.storage}/`) && name === TITLE_E, actual: `${E} name=${JSON.stringify(name)} to=${JSON.stringify(rec?.['to'])}` }; }); await m.hard('setup.bob-meeting-point', G, `Bob proposes meeting point M "${TITLE_M}" on E through the app`, 'M in Bob\'s as/event container with as:context = E', async () => { need(S.E, 'event E'); const shown = await createMeetingPointUi(bobPage, TITLE_M); S.M = await findMeetingPoint(TITLE_M); return { ok: shown && !!S.M, actual: `ui shows it: ${shown}; M = ${S.M ?? '(not found)'}` }; }); await m.hard('setup.attendees-collection', G, 'M carries its apods:attendees collection (attached by the backend)', 'collection URI on M', async () => { S.A = await attachedCollection(need(S.M, 'meeting point M')); return { ok: !!S.A, actual: S.A ?? '(none after 20 s)' }; }); await m.hard('setup.bob-meeting-point-2', G, `Bob proposes a SECOND meeting point M2 "${TITLE_M2}" on E through the app`, 'M2 in Bob\'s as/event container with as:context = E, distinct from M', async () => { need(S.E, 'event E'); const shown = await createMeetingPointUi(bobPage, TITLE_M2); S.M2 = await findMeetingPoint(TITLE_M2); return { ok: shown && !!S.M2 && S.M2 !== S.M, actual: `ui shows it: ${shown}; M2 = ${S.M2 ?? '(not found)'}` }; }); await m.hard('setup.attendees-collection-2', G, 'M2 carries its own apods:attendees collection', 'collection URI on M2, distinct from M\'s', async () => { S.A2 = await attachedCollection(need(S.M2, 'meeting point M2')); return { ok: !!S.A2 && S.A2 !== S.A, actual: S.A2 ?? '(none after 20 s)' }; }); await bobCtx.close(); // Bob, direct HTTP with his token: one event WITHOUT public intent (no `to`), // and its control twin WITH it — same path, opposite intent. await m.hard('setup.bob-private-event', G, `Bob POSTs event P "${TITLE_P}" into his as/event container, NO \`to\``, '201 + Location', async () => { 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: TITLE_P, startTime: '2026-12-21T18:00:00Z', endTime: '2026-12-21T22:00:00Z' }), }); S.P = r.headers.get('Location') ?? undefined; return { ok: (r.status === 201 || r.status === 200) && !!S.P, actual: `HTTP ${r.status} → ${S.P ?? '(no Location)'}` }; }); await m.hard('setup.bob-public-twin', G, `CONTROL: Bob POSTs twin T "${TITLE_T}" the same way, WITH \`to: as:Public\``, '201 + Location', async () => { 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: TITLE_T, startTime: '2026-12-21T18:00:00Z', endTime: '2026-12-21T22:00:00Z', to: AS_PUBLIC }), }); S.T = r.headers.get('Location') ?? undefined; return { ok: (r.status === 201 || r.status === 200) && !!S.T, actual: `HTTP ${r.status} → ${S.T ?? '(no Location)'}` }; }); // Alice: consent through the UI; her page carries discovery, join and leave. const alicePage = await (await browser.newContext()).newPage(); wire(alicePage, 'alice', ui); await signIn('setup.alice-consent', 'alice', 'Alice', alicePage, () => { S.alicePage = alicePage; }); await installed('setup.alice-installed', 'alice', 'Alice'); // Carol: already installed programmatically; she signs in to see her own (non-contact) view. const carolPage = await (await browser.newContext()).newPage(); wire(carolPage, 'carol', ui); await signIn('setup.carol-sign-in', 'carol', 'Carol', carolPage, () => { S.carolPage = carolPage; }); S.bobProfile = S.bob ? await profileUriOf(S.bob.webId) : undefined; S.aliceProfile = S.alice ? await profileUriOf(S.alice.webId) : undefined; S.carolProfile = S.carol ? await profileUriOf(S.carol.webId) : undefined; for (const who of ['bob', 'alice', 'carol'] as const) { const session = S[who]; const pref = session ? await preferencesFileOf(session) : undefined; if (pref) S.preferences.set(who, pref); } } async function publicReads(): Promise { const G = 'public reads (positive)'; const read = (id: string, name: string, token: () => string | undefined, target: () => string, extra?: (rec: JsonRecord | undefined) => string) => m.hard(id, G, name, '200', async () => { const r = await http('GET', target(), { token: token() }); return { ok: r.status === 200, actual: `HTTP ${r.status}${extra ? ` ${extra(parseJson(r.text))}` : ''}` }; }); await read('read.anon-E', 'anonymous GET E', () => undefined, () => need(S.E, 'event E')); await read('read.anon-M', 'anonymous GET M', () => undefined, () => need(S.M, 'meeting point M')); await read('read.alice-E', 'Alice GET E (her token)', () => need(S.alice, 'Alice').token, () => need(S.E, 'event E')); await read('read.alice-M', 'Alice GET M (her token)', () => need(S.alice, 'Alice').token, () => need(S.M, 'meeting point M')); await m.hard('read.alice-attendees', G, "Alice GET M's attendees (her token)", '200 with an `items` key (count = items.length; no totalItems on this platform)', async () => { const r = await http('GET', need(S.A, 'attendees collection'), { token: need(S.alice, 'Alice').token }); const rec = parseJson(r.text); const hasItemsKey = !!rec && ('items' in rec || 'as:items' in rec); return { ok: r.status === 200 && hasItemsKey, actual: `HTTP ${r.status} items.length=${itemUris(rec).length} (items key present: ${hasItemsKey})` }; }); } async function writesRefused(): Promise { const G = 'writes (negative)'; const bobRead = async (uri: string): Promise => { const r = await http('GET', uri, { token: need(S.bob, 'Bob').token }); if (r.status !== 200) throw new Error(`Bob GET ${uri} → ${brief(r)}`); return r.text; }; try { S.snapshot = { E: await bobRead(need(S.E, 'E')), M: await bobRead(need(S.M, 'M')) }; } catch { /* the unchanged line below FAILs with the missing snapshot */ } const E = (): string => need(S.E, 'event E'); const M = (): string => need(S.M, 'meeting point M'); await refusedWrite('write.anon-PUT-E', 'anonymous PUT on E', 'PUT', 'anonymous', E, G); const postOn: Record = {}; postOn['anon-E'] = await postOnResource('anonymous', E); await refusedWrite('write.anon-PATCH-E', 'anonymous PATCH on E', 'PATCH', 'anonymous', E, G); await refusedWrite('write.anon-PUT-M', 'anonymous PUT on M', 'PUT', 'anonymous', M, G); postOn['anon-M'] = await postOnResource('anonymous', M); await refusedWrite('write.anon-PATCH-M', 'anonymous PATCH on M', 'PATCH', 'anonymous', M, G); await refusedWrite('write.alice-PUT-E', 'Alice PUT on E', 'PUT', 'alice', E, G); postOn['alice-E'] = await postOnResource('alice', E); await refusedWrite('write.alice-PATCH-E', 'Alice PATCH on E', 'PATCH', 'alice', E, G); await refusedWrite('write.alice-PUT-M', 'Alice PUT on M', 'PUT', 'alice', M, G); postOn['alice-M'] = await postOnResource('alice', M); await refusedWrite('write.alice-PATCH-M', 'Alice PATCH on M', 'PATCH', 'alice', M, G); await m.hard('write.unchanged', G, 'E and M are byte-identical after every refused write (read by Bob)', 'identical to the pre-write snapshot', async () => { const snap = need(S.snapshot, 'pre-write snapshot of E and M'); const e = await bobRead(E()); const mm = await bobRead(M()); return { ok: e === snap.E && mm === snap.M, actual: `E ${e === snap.E ? 'unchanged' : 'CHANGED'}, M ${mm === snap.M ? 'unchanged' : 'CHANGED'}${e.includes('HIJACKED') || mm.includes('HIJACKED') ? ' (HIJACKED marker present)' : ''}` }; }); const container = (): string => `${need(S.bob, 'Bob').storage}/as/event`; const hijack = JSON.stringify({ '@context': PLATFORM_CONTEXT, type: 'Event', name: `HIJACKED ${TS}` }); await refusedWrite('write.anon-POST-container', "anonymous POST into Bob's as/event container (owner control: setup.bob-private-event → 201)", 'POST', 'anonymous', container, G, { body: hijack }); await refusedWrite('write.alice-POST-container', "Alice POST into Bob's as/event container (owner control: setup.bob-private-event → 201)", 'POST', 'alice', container, G, { body: hijack }); let ownerStatus = 'not observed'; m.info('write.owner-POST-E', G, 'evidence for the POST-on-resource lines: Bob (owner) POST on his own E', await (async () => { const r = await http('POST', need(S.E, 'E'), { token: need(S.bob, 'Bob').token, body: POST_BODY, contentType: 'application/ld+json' }).catch(() => undefined); if (r) ownerStatus = String(r.status); return r ? `Bob POST on E → ${brief(r)}` : 'not observed'; })().catch(e => `not observed: ${String(e)}`)); const posts: Array<[string, string, string, string]> = [ ['write.anon-POST-E', 'anon-E', 'anonymous', 'E'], ['write.anon-POST-M', 'anon-M', 'anonymous', 'M'], ['write.alice-POST-E', 'alice-E', 'Alice', 'E'], ['write.alice-POST-M', 'alice-M', 'Alice', 'M'], ]; for (const [id, key, who, target] of posts) { const status = postOn[key] ?? 'not observed'; const premiseHolds = status === ownerStatus && status === '400'; m.info(id, G, POST_ON_RESOURCE_NAME(who, target), `${who} POST on ${target} → ${status}; owner control: Bob POST on E → ${ownerStatus}`, premiseHolds ? undefined : 'method-not-applicable no longer holds as observed (status differs from the owner\'s, or is not 400) — re-evaluate whether POST on a resource became a write path'); } } async function profilePrivacy(): Promise { const G = 'profile privacy (negative, with control)'; await m.hard('profile.alice-own', G, 'CONTROL: Alice GET her own profile document (her token)', '200 (the URI is right; refusals below are WAC)', async () => { const r = await http('GET', need(S.aliceProfile, "Alice's profile URI (actor `url`)"), { token: need(S.alice, 'Alice').token }); return { ok: r.status === 200, actual: `HTTP ${r.status} ${S.aliceProfile}` }; }); await m.hard('profile.alice-reads-bob', G, "Alice GET Bob's profile document (her token)", 'refused 403', async () => { const r = await http('GET', need(S.bobProfile, "Bob's profile URI"), { token: need(S.alice, 'Alice').token }); return { ok: refused(r.status, AUTHN), actual: `${brief(r).slice(0, 60)} ${S.bobProfile}` }; }); await m.hard('profile.bob-reads-alice-before-join', G, "Bob GET Alice's profile document BEFORE any join (his token)", 'refused 403 (nobody is a contact at start)', async () => { const r = await http('GET', need(S.aliceProfile, "Alice's profile URI"), { token: need(S.bob, 'Bob').token }); return { ok: refused(r.status, AUTHN), actual: brief(r).slice(0, 60) }; }); await m.hard('profile.carol-reads-alice', G, "Carol GET Alice's profile document (her token)", 'refused 403', async () => { const r = await http('GET', need(S.aliceProfile, "Alice's profile URI"), { token: need(S.carol, 'Carol').token }); return { ok: refused(r.status, AUTHN), actual: brief(r).slice(0, 60) }; }); await m.hard('profile.anon-reads-alice', G, "anonymous GET Alice's profile document", 'refused 401/403', async () => { const r = await http('GET', need(S.aliceProfile, "Alice's profile URI")); return { ok: refused(r.status, ANON), actual: brief(r).slice(0, 60) }; }); } async function catalogAndIntent(): Promise { const G = 'catalog & public intent'; const listed = (id: string, name: string, target: () => string) => m.hard(id, G, name, 'listed in GET /catalog within 30 s', async () => { const uri = target(); const c = await poll(() => catalogIds(), v => v.ids.includes(uri), 30000); return { ok: c.ids.includes(uri), actual: `${c.ids.includes(uri) ? 'listed' : 'NOT listed'} (catalog has ${c.ids.length} items)` }; }); await listed('catalog.E-listed', 'E (created through the app) is listed', () => need(S.E, 'E')); await listed('catalog.M-listed', 'M (created through the app) is listed', () => need(S.M, 'M')); await listed('catalog.M2-listed', 'M2 (created through the app) is listed', () => need(S.M2, 'M2')); await m.hard('catalog.M-context', G, "M's catalog item links E through as:context", 'as:context = E', async () => { const c = await catalogIds(); const item = c.items.find(i => recordUri(i) === need(S.M, 'M')); return { ok: !!item && ctxLink(item) === need(S.E, 'E'), actual: `as:context = ${item ? ctxLink(item) : '(M not in catalog)'}` }; }); await listed('catalog.T-listed', 'CONTROL: twin T (direct POST, WITH public intent) is listed', () => need(S.T, 'twin T')); await m.hard('read.anon-T', G, 'CONTROL: anonymous GET twin T', '200', async () => { const r = await http('GET', need(S.T, 'twin T')); return { ok: r.status === 200, actual: `HTTP ${r.status}` }; }); await m.hard('private.bob-reads-P', G, 'CONTROL: Bob GET P (his token) — P exists', '200', async () => { const r = await http('GET', need(S.P, 'private P'), { token: need(S.bob, 'Bob').token }); return { ok: r.status === 200, actual: `HTTP ${r.status}` }; }); await m.hard('private.anon-P', G, 'anonymous GET P (no public intent)', 'refused 401/403', async () => { const r = await http('GET', need(S.P, 'private P')); return { ok: refused(r.status, ANON), actual: brief(r).slice(0, 80) }; }); await m.hard('private.P-not-in-catalog', G, 'P is never listed in GET /catalog', 'absent once its later twin T is listed, and 5 s after', async () => { const P = need(S.P, 'private P'); const T = need(S.T, 'twin T'); const settled = await poll(() => catalogIds(), v => v.ids.includes(T), 30000); if (!settled.ids.includes(T)) throw new Error('the window never settled: twin T was not listed either'); await sleep(5000); const c = await catalogIds(); return { ok: !c.ids.includes(P) && !c.raw.includes(TITLE_P), actual: c.ids.includes(P) ? 'LISTED' : c.raw.includes(TITLE_P) ? 'title present in catalog body' : 'absent' }; }); } async function discoveryUi(): Promise { const G = 'discovery (Alice, UI)'; let text = ''; await m.hard('discover.E-listed', G, "Alice's Découvrir (/events) lists Bob's E (title match)", `"${TITLE_E}" shown`, async () => { const page = need(S.alicePage, "Alice's signed-in page"); await page.goto(`${APP}/events`, { waitUntil: 'domcontentloaded' }); text = await poll(() => bodyText(page), t => t.includes(TITLE_E) && t.includes(TITLE_T), 30000, 1000); return { ok: text.includes(TITLE_E), actual: text.includes(TITLE_E) ? 'shown' : `absent; page: ${text.slice(0, 160)}` }; }); await m.hard('discover.T-listed', G, "CONTROL: Alice's Découvrir lists the public twin T", `"${TITLE_T}" shown`, async () => ({ ok: text.includes(TITLE_T), actual: text.includes(TITLE_T) ? 'shown' : 'absent' })); await m.hard('discover.P-absent', G, "Alice's Découvrir never shows Bob's private P", `"${TITLE_P}" absent (same render that shows E and T)`, async () => { if (!text.includes(TITLE_E) || !text.includes(TITLE_T)) throw new Error('prerequisite missing: a Découvrir render showing E and T'); return { ok: !text.includes(TITLE_P), actual: text.includes(TITLE_P) ? 'SHOWN' : 'absent' }; }); try { const c = await catalogIds(); const ours = new Set([S.E, S.M, S.T].filter(Boolean)); m.info('discover.fresh-user-empty', G, 'a brand-new user\'s Découvrir is empty of others\' events (sanity)', `not applicable on a shared stack: the catalog already holds ${c.ids.filter(i => !ours.has(i)).length} items from other users`); } catch (e) { m.info('discover.fresh-user-empty', G, 'a brand-new user\'s Découvrir is empty of others\' events (sanity)', `catalog unreadable: ${String(e)}`); } } /** Tap a meeting point's toggle on the detail page and wait for its card to show `after`. */ async function tapMeetingPoint(page: Page, title: string, before: RegExp, after: RegExp): Promise { const card = mpCard(page, title); await card.getByRole('button', { name: before }).click({ timeout: 10000 }); const ok = await card.getByRole('button', { name: after }).waitFor({ state: 'visible', timeout: 15000 }).then(() => true, () => false); return { ok, actual: ok ? `card shows ${after.source}` : `no ${after.source} on the card within 15 s` }; } /** Poll a collection (Alice's token) until it does / does not hold Alice; any non-200 read keeps the line failing. */ async function aliceIn(collection: string, wanted: boolean): Promise<{ ok: boolean; status: number; items: string[] }> { const alice = need(S.alice, 'Alice'); const a = await poll(() => attendees(collection, alice.token), v => v.status === 200 && v.items.includes(alice.webId) === wanted, 30000); return { ok: a.status === 200 && a.items.includes(alice.webId) === wanted, status: a.status, items: a.items }; } async function join(): Promise { const G = 'join (Alice, UI)'; await m.hard('join.open-E', G, 'Alice taps E in Découvrir → its detail renders', 'detail route of E, title shown, M and M2 listed', async () => { const page = need(S.alicePage, "Alice's signed-in page"); const E = need(S.E, 'E'); await page.getByText(TITLE_E, { exact: true }).first().click({ timeout: 10000 }); const t = await poll(() => bodyText(page), x => x.includes(TITLE_E) && x.includes(TITLE_M) && x.includes(TITLE_M2) && /👥\s*\d+\s+inscrit/i.test(x), 30000); const onRoute = decodeURIComponent(new URL(page.url()).pathname).endsWith(`/events/${E}`); return { ok: onRoute && t.includes(TITLE_E) && t.includes(TITLE_M) && t.includes(TITLE_M2), actual: `route ok: ${onRoute}; title: ${t.includes(TITLE_E)}; M: ${t.includes(TITLE_M)}; M2: ${t.includes(TITLE_M2)}${t.includes('non trouvé') ? '; "non trouvé" shown' : ''}` }; }); await m.hard('join.before', G, "Before joining, Alice's view counts nobody (fresh event)", 'PARTICIPANTS (0), M 0 inscrit, M2 0 inscrit', async () => { const c = await poll(() => detailCounts(need(S.alicePage, "Alice's page")), v => v.participants !== undefined && v.mp !== undefined && v.mp2 !== undefined, 15000); if (c.participants !== undefined && c.mp !== undefined && c.mp2 !== undefined) S.before = { participants: c.participants, mp: c.mp, mp2: c.mp2 }; return { ok: c.participants === 0 && c.mp === 0 && c.mp2 === 0, actual: showCounts(c) }; }); await m.hard('join.tap', G, "Alice taps S'inscrire on M", "M's card turns ✓ Inscrit", async () => tapMeetingPoint(need(S.alicePage, "Alice's page"), TITLE_M, /^S'inscrire$/, /✓ Inscrit/)); await m.hard('join.collection', G, "Alice's WebID appears in M's attendees items", 'items ∋ Alice within 30 s', async () => { const r = await aliceIn(need(S.A, 'attendees of M'), true); if (r.ok) S.joined.M = true; return { ok: r.ok, actual: `HTTP ${r.status} items=${JSON.stringify(r.items)}` }; }); await m.hard('join.ui-counts', G, "Alice's view increments by 1 (event header and M; M2 unchanged)", 'PARTICIPANTS (1), M 1 inscrit, M2 0 inscrit', async () => { need(S.joined.M, 'the join on M landed (join.collection)'); const page = need(S.alicePage, "Alice's page"); const c = await poll(() => detailCounts(page), v => v.participants === 1 && v.mp === 1 && v.mp2 === 0, 20000); return { ok: c.participants === 1 && c.mp === 1 && c.mp2 === 0, actual: showCounts(c) }; }); await m.hard('join.tap-2', G, "Alice ALSO taps S'inscrire on M2 (one person in both meeting points)", "M2's card turns ✓ Inscrit", async () => tapMeetingPoint(need(S.alicePage, "Alice's page"), TITLE_M2, /^S'inscrire$/, /✓ Inscrit/)); await m.hard('join.collection-2', G, "Alice's WebID appears in M2's attendees items", 'items ∋ Alice within 30 s', async () => { const r = await aliceIn(need(S.A2, 'attendees of M2'), true); if (r.ok) S.joined.M2 = true; return { ok: r.ok, actual: `HTTP ${r.status} items=${JSON.stringify(r.items)}` }; }); await m.hard('join.ui-counts-2', G, "Alice's view: the event counts her in each meeting point", 'PARTICIPANTS (2), M 1 inscrit, M2 1 inscrit', async () => { need(S.joined.M && S.joined.M2, 'both joins landed (join.collection, join.collection-2)'); const page = need(S.alicePage, "Alice's page"); const c = await poll(() => detailCounts(page), v => v.participants === 2 && v.mp === 1 && v.mp2 === 1, 20000); return { ok: c.participants === 2 && c.mp === 1 && c.mp2 === 1, actual: showCounts(c) }; }); await m.hard('join.bob-resolves-alice', G, "Bob can resolve Alice's name after her join (Bob's token on her profile)", "200 — the join grants the organizer contact-read", async () => { need(S.joined.M, 'the join on M landed (join.collection)'); const bob = need(S.bob, 'Bob'); const r = await poll(() => http('GET', need(S.aliceProfile, "Alice's profile URI"), { token: bob.token }), v => v.status === 200, 20000, 2000); return { ok: r.status === 200, actual: `${brief(r).slice(0, 120)} (polled 20 s)`, evidence: `GET ${S.aliceProfile} with Bob's token` }; }, { finding: "PRODUCT-FINDING: joining a meeting point does not grant its organizer read access on the joiner's profile — the organizer cannot resolve who joined" }); await m.hard('join.carol-cannot-resolve-alice', G, "Carol cannot resolve Alice's name after the join (Carol's token on her profile)", 'refused 403', async () => { need(S.joined.M, 'the join on M landed (join.collection)'); const r = await http('GET', need(S.aliceProfile, "Alice's profile URI"), { token: need(S.carol, 'Carol').token }); return { ok: refused(r.status, AUTHN), actual: brief(r).slice(0, 80) }; }); } async function counterSemantics(): Promise { const G = 'counter semantics'; await m.hard('counter.sum', G, "E's counter is the SUM of its meeting points' item counts — Alice in both M and M2 counts twice (sum 2; max, first or distinct would give 1)", "data: M=[Alice], M2=[Alice], sum 2; Alice's view PARTICIPANTS (2)", async () => { need(S.joined.M && S.joined.M2, 'both joins landed (join.collection, join.collection-2)'); const alice = need(S.alice, 'Alice').webId; const d = await summedCounter(); const inM = d.per.get(need(S.M, 'M'))?.includes(alice) ?? false; const inM2 = d.per.get(need(S.M2, 'M2'))?.includes(alice) ?? false; const ui = await poll(() => detailCounts(need(S.alicePage, "Alice's page")), v => v.participants === 2, 15000); const parts = [...d.per].map(([u, it]) => `${u.slice(-12)}:${it.length}`).join(', '); return { ok: d.mps.length === 2 && inM && inM2 && d.sum === 2 && ui.participants === 2, actual: `MPs=${d.mps.length} [${parts}] Alice in M: ${inM}, in M2: ${inM2}; sum=${d.sum}; Alice's UI PARTICIPANTS (${ui.participants})` }; }); await m.hard('counter.non-contact-total', G, "A non-contact (Carol) is shown the TOTAL in her own view, not the count of profiles she can resolve", "Carol's view of E: PARTICIPANTS (2), M 1 inscrit, M2 1 inscrit — while Alice's profile is refused to her (resolvable: 0)", async () => { need(S.joined.M && S.joined.M2, 'both joins landed (join.collection, join.collection-2)'); const page = need(S.carolPage, "Carol's signed-in page"); await page.goto(`${APP}/events/${encodeURIComponent(need(S.E, 'E'))}`, { waitUntil: 'domcontentloaded' }); const c = await poll(() => detailCounts(page), v => v.participants === 2 && v.mp === 1 && v.mp2 === 1, 30000); const p = await http('GET', need(S.aliceProfile, "Alice's profile"), { token: need(S.carol, 'Carol').token }); const anonymousRows = (c.text.match(/Participant inconnu/g) ?? []).length; return { ok: c.participants === 2 && c.mp === 1 && c.mp2 === 1 && refused(p.status, AUTHN), actual: `Carol's UI: ${showCounts(c)}; anonymous rows shown: ${anonymousRows}; Alice's profile for Carol → HTTP ${p.status}` }; }); } async function isolation(): Promise { const G = 'isolation (negative)'; await m.hard('isolation.alice-own-pod', G, "Alice's own as/event container (her token) does not contain Bob's E or M — cross-user visibility comes only through the catalog", '200, neither E nor M in it', async () => { const alice = need(S.alice, 'Alice'); const r = await http('GET', `${alice.storage}/as/event`, { token: alice.token }); const rec = parseJson(r.text); const members = arrayOf(rec?.['ldp:contains'] ?? rec?.['contains']).map(linkId).filter((u): u is string => !!u); const E = need(S.E, 'E'); const M = need(S.M, 'M'); const leaked = r.text.includes(E) || r.text.includes(M); return { ok: r.status === 200 && !!rec && !leaked, actual: `HTTP ${r.status}; ${members.length} member(s); E/M ${leaked ? 'PRESENT' : 'absent'}` }; }); } async function catalogDeletion(): Promise { const G = 'catalog deletion'; m.manual('manual.forged-delete', G, "A user posting to HER outbox a Delete that embeds a Tombstone of another user's event must not de-index it", 'NOT exercised live (defensive only, user decision 2026-10-13: no forged activity is sent). Covered by backend unit tests — ' + 'T05.h catalog.service ownership check (object must lie under the emitting outbox\'s pod) and T05.i verifiedEmit (activity and object re-read from the pod) — ' + 'plus a manual check recorded in the handoff.'); await m.hard('catalog.owner-delete-deindexes', G, 'Bob deletes his own public twin T → T leaves /catalog', 'owner DELETE 2xx, then T absent from /catalog within 30 s', async () => { const T = need(S.T, 'twin T'); const del = await http('DELETE', T, { token: need(S.bob, 'Bob').token }); const c = await poll(() => catalogIds(), v => !v.ids.includes(T), 30000); return { ok: del.status >= 200 && del.status < 300 && !c.ids.includes(T), actual: `DELETE → HTTP ${del.status}; T ${c.ids.includes(T) ? 'still listed' : 'de-indexed'}` }; }); } async function leave(): Promise { const G = 'leave (Alice, UI)'; await m.hard('leave.tap', G, 'Alice taps ✓ Inscrit on M (leave)', "M's card turns back to S'inscrire", async () => { need(S.joined.M, 'the join on M landed (join.collection) — a leave without a join proves nothing'); return tapMeetingPoint(need(S.alicePage, "Alice's page"), TITLE_M, /✓ Inscrit/, /^S'inscrire$/); }); await m.hard('leave.collection', G, "Alice's WebID disappears from M's attendees items", 'items ∌ Alice within 30 s (read 200)', async () => { need(S.joined.M, 'the join on M landed (join.collection)'); const r = await aliceIn(need(S.A, 'attendees of M'), false); return { ok: r.ok, actual: `HTTP ${r.status} items=${JSON.stringify(r.items)}` }; }); await m.hard('leave.ui-counts', G, "Alice's view decrements: she is now counted in M2 only", 'PARTICIPANTS (1), M 0 inscrit, M2 1 inscrit', async () => { need(S.joined.M && S.joined.M2, 'both joins landed'); const page = need(S.alicePage, "Alice's page"); const c = await poll(() => detailCounts(page), v => v.participants === 1 && v.mp === 0 && v.mp2 === 1, 20000); return { ok: c.participants === 1 && c.mp === 0 && c.mp2 === 1, actual: showCounts(c) }; }); await m.hard('leave.tap-2', G, 'Alice taps ✓ Inscrit on M2 (leave)', "M2's card turns back to S'inscrire", async () => { need(S.joined.M2, 'the join on M2 landed (join.collection-2) — a leave without a join proves nothing'); return tapMeetingPoint(need(S.alicePage, "Alice's page"), TITLE_M2, /✓ Inscrit/, /^S'inscrire$/); }); await m.hard('leave.collection-2', G, "Alice's WebID disappears from M2's attendees items", 'items ∌ Alice within 30 s (read 200)', async () => { need(S.joined.M2, 'the join on M2 landed (join.collection-2)'); const r = await aliceIn(need(S.A2, 'attendees of M2'), false); return { ok: r.ok, actual: `HTTP ${r.status} items=${JSON.stringify(r.items)}` }; }); await m.hard('leave.ui-counts-2', G, "Alice's view is back to its pre-join counts", 'PARTICIPANTS (0), M 0 inscrit, M2 0 inscrit (= join.before)', async () => { need(S.joined.M && S.joined.M2, 'both joins landed'); const before = need(S.before, 'pre-join counts'); const page = need(S.alicePage, "Alice's page"); const same = (v: { participants?: number; mp?: number; mp2?: number }): boolean => v.participants === before.participants && v.mp === before.mp && v.mp2 === before.mp2; const c = await poll(() => detailCounts(page), same, 20000); return { ok: same(c), actual: showCounts(c) }; }); await m.hard('leave.sum', G, "E's summed counter is back to its pre-join value (every collection read 200)", 'sum of M and M2 items = 0', async () => { need(S.joined.M && S.joined.M2, 'both joins landed'); const d = await summedCounter(); return { ok: d.mps.length === 2 && d.sum === need(S.before, 'pre-join counts').participants, actual: `MPs=${d.mps.length} sum=${d.sum}` }; }); } async function deletesRefused(): Promise { const G = 'deletes (negative)'; await refusedWrite('delete.anon-E', 'anonymous DELETE E', 'DELETE', 'anonymous', () => need(S.E, 'E'), G); await refusedWrite('delete.alice-E', 'Alice DELETE E', 'DELETE', 'alice', () => need(S.E, 'E'), G); await refusedWrite('delete.alice-M', 'Alice DELETE M', 'DELETE', 'alice', () => need(S.M, 'M'), G); await refusedWrite('delete.alice-attendees', "Alice DELETE M's attendees collection", 'DELETE', 'alice', () => need(S.A, 'attendees'), G); await refusedWrite('delete.alice-bob-profile', "Alice DELETE Bob's profile document", 'DELETE', 'alice', () => need(S.bobProfile, "Bob's profile"), G); await m.hard('delete.still-there', G, "E, M, M's attendees and Bob's profile all still exist (read by Bob)", 'all 200', async () => { const bob = need(S.bob, 'Bob'); const uris = [need(S.E, 'E'), need(S.M, 'M'), need(S.A, 'attendees'), need(S.bobProfile, "Bob's profile")]; const st = await Promise.all(uris.map(u => http('GET', u, { token: bob.token }).then(r => r.status))); return { ok: st.every(s => s === 200), actual: st.join(' / ') }; }); } async function tracked(): Promise { const G = 'tracked platform behaviours (non-fatal)'; await m.known('known-deviation', 'deviation.anon-attendees-read', G, "anonymous GET M's attendees (doctrine: participation is PROTECTED; platform default: world-readable)", 'doctrine: refused; tracked as world-readable (user decision 2026-10-12)', async () => { const r = await http('GET', need(S.A, 'attendees')); const n = r.status === 200 ? itemUris(parseJson(r.text)).length : 0; if (r.status === 200) return { ok: true, actual: `HTTP 200 — world-readable (items.length=${n}; members are WebIDs, which embed usernames)` }; if (refused(r.status, ANON)) return { ok: true, actual: `HTTP ${r.status} — refused`, notice: 'the attendee list is no longer world-readable: the deviation is gone — promote this line to a hard negative assertion' }; return { ok: false, actual: brief(r), notice: 'neither world-readable nor an explicit refusal — investigate before trusting this line' }; }); await m.known('known-defect', 'defect.anon-attendees-patch', G, "anonymous PATCH on M's attendees collection (its only foaf:Agent rule is acl:Read)", 'should be refused 401/403; tracked platform defect: accepted (204)', async () => { // DEFENSIVE ONLY: the probe is a triple on a test predicate, which the // collection accepts and silently drops (VERIFIED 2026-10-12: 204, nothing // written) — no member is spoofed. The status alone is the tripwire: a WAC // refusal would answer 401/403. That an anonymous `as:items` insert LANDS // was verified once on 2026-10-12 and is not re-exercised live. const A = need(S.A, 'attendees'); const bob = need(S.bob, 'Bob'); const marker = `anon-patch-tripwire-${TS}`; const before = itemUris(parseJson((await http('GET', A, { token: bob.token })).text)); const r = await http('PATCH', A, { body: `INSERT DATA { <${A}> "${marker}" . }`, contentType: 'application/sparql-update' }); const afterRead = await http('GET', A, { token: bob.token }); const after = itemUris(parseJson(afterRead.text)); const wrote = afterRead.text.includes(marker) || after.length !== before.length; const actual = `HTTP ${r.status}; collection ${wrote ? 'CHANGED' : 'unchanged'} (items ${before.length} → ${after.length})`; if (r.status >= 200 && r.status < 300 && !wrote) return { ok: true, actual }; if (refused(r.status, ANON)) return { ok: true, actual, notice: 'anonymous PATCH on an attendees collection is now REFUSED — the platform defect looks fixed: promote this line to a hard negative assertion (refused 401/403)' }; return { ok: false, actual, notice: `behaviour changed (HTTP ${r.status}, collection ${wrote ? 'changed' : 'unchanged'}) — neither the tracked acceptance nor a refusal: investigate, then decide whether to promote` }; }); } /** * 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 { const G = 'backend public surface (anonymous GET, status codes only)'; // A path is under the backend's base URL; a full URL is taken as is. const status = async (target: string): Promise => (await http('GET', /^https?:\/\//.test(target) ? target : `${BACKEND}${target}`)).status; // The /.well-known endpoints sit at the backend ORIGIN's root whatever its base path (`/api` on Coolify, backend/services/base-path.js). const atOriginRoot = (path: string): string => `${new URL(BACKEND).origin}${path}`; 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,}$/, '/'); 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/, /interop/access-need-group/, /interop/access-need/, /.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)'), atOriginRoot('/.well-known/context.jsonld'), atOriginRoot('/.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 = atOriginRoot(`/.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/ 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 { 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 = {}): 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 { 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 => (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 => { 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 { const G = 'UI drives'; // Expected refusals are exact URLs: this run's profile documents, and each // user's OWN preferences file. Any other ≥400 is unexpected. const profiles = new Set([S.bobProfile, S.aliceProfile, S.carolProfile].filter((u): u is string => !!u)); const expected = { profiles, ownPreferences: S.preferences }; const unexpected = ui.errors.filter(e => benignUiError(e, expected) === null); const seen = new Map(); for (const e of ui.errors) { const label = benignUiError(e, expected); if (label) seen.set(label, (seen.get(label) ?? 0) + 1); } await m.hard('ui.no-unexpected-4xx', G, 'No unexpected ≥400 response during the Bob, Alice and Carol UI drives', 'none beyond: push-channel creation 403, profile-document 403 (this run\'s profiles), own preferences-file 403', async () => { if (profiles.size < 3 || S.preferences.size < 3) throw new Error('prerequisite missing: the three profile and preferences-file URIs (to tell an expected 403 from any other)'); return { ok: unexpected.length === 0, actual: unexpected.length === 0 ? `none (expected seen: ${[...seen].map(([k, n]) => `${k} ×${n}`).join(', ') || 'none'})` : unexpected.map(e => `[${e.who}] ${e.status} ${e.method} ${e.url} ${e.body.slice(0, 80)}`).join(' | '), }; }); const pe = splitPageErrors(ui.pageErrors, ui.errors); await m.hard('ui.no-page-errors', G, 'No unexpected uncaught page error during the UI drives (app and pod pages)', 'none beyond the live provider\'s "Forbidden" from subscribe, one per refused push channel of the same user', async () => ({ ok: pe.unexpected.length === 0, actual: pe.unexpected.length === 0 ? `none (expected: ${pe.expected.length} × "Forbidden" from the live provider's subscribe)` : pe.unexpected.map(e => `[${e.who}] at ${e.url}: ${e.message} | ${e.stack.split('\n').slice(1, 3).join(' ').trim()}`).join(' | '), })); m.info('ui.expected-page-errors', G, 'Uncaught "Forbidden" from the live provider\'s subscribe when the pod refuses its push channel (SDK side — to report upstream, never worked around here)', `${pe.expected.length} seen (${[...new Set(pe.expected.map(e => e.who))].join(', ') || 'none'})`); } (async () => { console.error(`[matrix] run ${TS}: bob=${USERS.bob} alice=${USERS.alice} carol=${USERS.carol}`); // No --disable-web-security: the app must work under the browser's real // 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(); await profilePrivacy(); await catalogAndIntent(); await discoveryUi(); await join(); await counterSemantics(); await isolation(); await catalogDeletion(); 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, 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 => { m.print(`ABORTED: ${String(err).slice(0, 300)}`); process.exit(1); });