1910 lines
134 KiB
TypeScript
1910 lines
134 KiB
TypeScript
/**
|
||
* 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 events and their meeting
|
||
* points through the app; Alice finds the event in Découvrir, opens it, joins
|
||
* and leaves its meeting points by tapping; Carol creates her own event and
|
||
* meeting point through her app and Bob joins it by tapping (the forged-contact
|
||
* group) — so EVERY participation goes the way the app does it: the app writes
|
||
* the participant's secret on their profile with their app-session token (the
|
||
* data provider's update), then posts the Join to their own outbox, addressed
|
||
* to the app actor alone (decision_2026-10-29_participation-privacy-mechanism,
|
||
* decision_2026-11-03_participation-delivered-to-the-app). Carol (a
|
||
* non-contact) and Bob (the creator) read the event through their own app.
|
||
* 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.
|
||
*
|
||
* EACH ACTOR OBTAINS ITS INPUTS. Every value an actor uses is read through the
|
||
* system under its own session: a participant computes their member URN from
|
||
* the secret their own profile carries, and Alice finds her participations
|
||
* collection on her own profile; every reader of her participations other than
|
||
* herself — Bob once her contact, Carol as the target of her request and then
|
||
* as her contact, Bob again once removed — finds her profile through her public
|
||
* actor document, reads it under their own session, and follows the collection
|
||
* it links; the attendee lists are reached from the public meeting points.
|
||
* What the script carries across actors is only what circulates publicly
|
||
* (event, meeting point and list URIs) — and, on NEGATIVE lines only, a private
|
||
* URI handed OUT OF BAND on purpose (said in the line's name), to prove the pod
|
||
* refuses it even to a reader who has it.
|
||
*
|
||
* ATTACKS ARE ALLOWED, BREAKING THE DEV ENVIRONMENT IS NOT (user rule
|
||
* 2026-10-30, concept bdd-testing, rule_tests-never-break-the-dev-environment).
|
||
* A line may forge or spoof activities as long as it only touches the
|
||
* throwaway accounts this run creates and deletes. The forged-contact group
|
||
* runs the attack decision_2026-10-29 names, the way an attacker would: Carol,
|
||
* from her own outbox and under her own session, posts an Accept of a contact
|
||
* Offer Bob never sent. Checks not built live yet are MANUAL-CHECK lines naming
|
||
* where they are covered (backend unit tests).
|
||
*
|
||
* THE CONTACT ORDER. Bob is NOT Alice's contact through her first joins and
|
||
* leave (non-contact lines: refusals, anonymous notifications, « Participant
|
||
* inconnu »); the contact pair is then made through the platform's contact
|
||
* flow, AFTER those joins — so the positive lines prove that a future contact
|
||
* sees the past. Carol stays Alice's non-contact until the last two groups.
|
||
* First the forged contact, while the pages are still open: Carol asks Bob to
|
||
* become a contact (which shares her profile with him), forges his acceptance,
|
||
* and — only once that forged state holds — Bob joins Carol's meeting point
|
||
* through his app. Then the contact changes, HTTP only, after every UI drive:
|
||
* Alice asks Carol to become a contact (Carol, the request's TARGET, already
|
||
* reads Alice's participations before accepting — the platform puts a request's
|
||
* target in the sender's /contacts group — user decision 2026-10-29), Carol
|
||
* accepts, then Alice removes Bob through the platform's removal — Bob STILL
|
||
* reads (the platform leaves a removed contact in the group: tracked defect),
|
||
* Carol still reads.
|
||
*
|
||
* WHO MAY READ A PARTICIPATIONS COLLECTION is the owner's `/contacts` WAC group,
|
||
* through ONE `acl:agentGroup` grant on its own Read rule — NOT per-contact
|
||
* `acl:agent` grants, and NOT the forgeable `apods:contacts` LIST (decision_
|
||
* 2026-10-29). NOTIFICATIONS are therefore ALWAYS anonymous: the only membership
|
||
* signal the app could read, the list, is forgeable, so naming is never trusted
|
||
* (every notify line is anonymous, on Join and on Leave, contact or not).
|
||
*
|
||
* 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 model behaviour (the world-readable
|
||
* attendee list: its members are member URNs, a
|
||
* count for everyone, identities for no one).
|
||
* KNOWN-DEFECT — a tracked platform defect (an anonymous PATCH on
|
||
* an attendees collection is accepted; a removed
|
||
* contact stays in the /contacts group; a forged
|
||
* Accept adds its sender to the apods:contacts list).
|
||
* MANUAL-CHECK — not exercised live (see ONE FORGED ACTIVITY above).
|
||
* INFO — informational; also a hard line SKIPPED because the
|
||
* state it needs could not be produced (the forged
|
||
* contact), printed with a loud NOTICE — never a PASS.
|
||
* 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 — except the pod's 404 on a COLLECTION, and
|
||
* only next to the owner's 200 on the same URI at the same moment. Each
|
||
* negative line that could be passed by a broken probe has a CONTROL line on
|
||
* the opposite state (Alice reads her own profile and her own Join; a twin
|
||
* event WITH public intent is readable and listed; Bob reads Alice's profile
|
||
* and collection once her contact; 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
|
||
* (both created through Bob's app) and a participant (Alice joins VM through
|
||
* hers) 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 Alice's membership (only the public read moves).
|
||
*
|
||
* 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 — the
|
||
* one exception being the forged-contact lines, SKIPPED (INFO + NOTICE) when
|
||
* the forged state itself could not be produced.
|
||
*
|
||
* ORIGIN. As for `e2e-login-flow.ts`: the OIDC redirect_uri is registered for
|
||
* the app origin `/login` (default http://localhost:4005) 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 {
|
||
appActorUri,
|
||
attendeesCollectionUri,
|
||
linkId,
|
||
MEMBER_URN_PREFIX,
|
||
memberUrnOf,
|
||
participationSecretOf,
|
||
participationsCollectionOf,
|
||
postOutboxActivity,
|
||
recordUri,
|
||
asRecord,
|
||
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 || process.env.VITE_BACKEND_URL || 'http://localhost:3005');
|
||
const APP = strip(process.env.APP_URL || `http://localhost:${process.env.PORT || 4005}`);
|
||
const POD_FRONT = strip(process.env.POD_FRONTEND_URL || 'http://localhost:5000');
|
||
const config: ApodsStackConfig = {
|
||
podProviderUrl: strip(process.env.POD_PROVIDER_URL || process.env.VITE_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 TITLE_CE = `Evenement de Carol ${TS}`;
|
||
const TITLE_CM = `Rdv de Carol ${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 };
|
||
|
||
/** One request a page sent to the pod — its writes, and its reads of the user's own profile. */
|
||
interface PodRequest { seq: number; method: string; url: string; token?: string; body: string; status?: number; response?: string }
|
||
|
||
/** 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;
|
||
/** Carol's event, her meeting point and its attendees collection (the forged-contact group). */
|
||
CE?: string; CM?: string; ACM?: string;
|
||
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>;
|
||
bobPage?: Page; alicePage?: Page; carolPage?: Page;
|
||
/** Set only by the line that SAW Alice's member URN land in the list. */
|
||
joined: { M?: boolean; M2?: boolean; VM?: boolean };
|
||
/** Set only by the line that SAW the Bob ↔ Alice contact pair established. */
|
||
contacts?: boolean;
|
||
/** Alice's Join on M, as SHE read it in her own outbox (handed out of band on the negative lines). */
|
||
aliceJoinM?: string;
|
||
/** What Alice's app sent to her pod, in order, recorded off her page. */
|
||
aliceTraffic: PodRequest[];
|
||
before?: { participants: number; mp: number; mp2: number };
|
||
snapshot?: { E: string; M: string };
|
||
} = { joined: {}, preferences: new Map(), aliceTraffic: [] };
|
||
|
||
/** Record what `page` sends to the pod: every write, and every read of `profileUri()` with its response body. */
|
||
function recordPodTraffic(page: Page, log: PodRequest[], profileUri: () => string | undefined): void {
|
||
let seq = 0;
|
||
page.on('request', req => {
|
||
const url = req.url();
|
||
if (!url.startsWith(`${config.podProviderUrl}/`)) return;
|
||
const profileRead = req.method() === 'GET' && url === profileUri();
|
||
if (req.method() === 'GET' && !profileRead) return;
|
||
const entry: PodRequest = { seq: seq++, method: req.method(), url, token: req.headers()['authorization']?.replace(/^Bearer /, ''), body: (req.postData() ?? '').slice(0, 4000) };
|
||
log.push(entry);
|
||
void req.response().then(async r => {
|
||
if (!r) return;
|
||
entry.status = r.status();
|
||
if (profileRead) entry.response = (await r.text().catch(() => '')).slice(0, 4000);
|
||
}).catch(() => {});
|
||
});
|
||
}
|
||
|
||
|
||
// ── 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<number | undefined> =>
|
||
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 event-level counter of E read from the data: every meeting point the
|
||
* catalog links to E, each attendees collection read anonymously, counted as
|
||
* the DISTINCT members across them. The members are member URNs, one per
|
||
* (person, EVENT) (decision_2026-10-29_participation-privacy-mechanism), so
|
||
* this anonymous read yields the number of distinct persons. A read that does
|
||
* not answer 200 is an ERROR, never "0 items".
|
||
*/
|
||
async function eventCounter(): Promise<{ mps: string[]; per: Map<string, string[]>; distinct: number }> {
|
||
const E = need(S.E, 'E');
|
||
const mps = (await catalogIds()).items.filter(i => ctxLink(i) === E);
|
||
const per = new Map<string, string[]>();
|
||
const attendeesIds = new Set<string>();
|
||
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);
|
||
for (const id of a.items) attendeesIds.add(id);
|
||
}
|
||
return { mps: mps.map(i => recordUri(i) ?? '?'), per, distinct: attendeesIds.size };
|
||
}
|
||
|
||
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}> <https://www.w3.org/ns/activitystreams#name> "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<boolean> {
|
||
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<string> {
|
||
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<boolean> {
|
||
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<boolean> {
|
||
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<CheckOutcome> =>
|
||
installedContainer(need(S[who], `${label} account`)));
|
||
}
|
||
|
||
/** Find a meeting point of `eventUri` in its creator's container (Bob's by default) by its title; polled (the write lands asynchronously). */
|
||
async function findMeetingPoint(title: string, eventUri: string, creator: ApodsSession = need(S.bob, 'Bob account')): Promise<string | undefined> {
|
||
return poll(async () => {
|
||
const c = parseJson((await http('GET', `${creator.storage}/as/event`, { token: creator.token })).text);
|
||
for (const entry of arrayOf(c?.['ldp:contains'] ?? c?.['contains'])) {
|
||
const uri = linkId(entry);
|
||
if (!uri || uri === eventUri) continue;
|
||
const rec = parseJson((await http('GET', uri, { token: creator.token })).text);
|
||
if (rec && ctxLink(rec) === eventUri && JSON.stringify(rec).includes(title)) return uri;
|
||
}
|
||
return undefined;
|
||
}, v => !!v, 20000);
|
||
}
|
||
|
||
|
||
/** The attendees collection a meeting point names, read by its creator (Bob by default); polled (the backend attaches it asynchronously). */
|
||
async function attachedCollection(M: string, creator: ApodsSession = need(S.bob, 'Bob account')): Promise<string | undefined> {
|
||
return poll(async () => attendeesCollectionUri(parseJson((await http('GET', M, { token: creator.token })).text) ?? {}), v => !!v, 20000);
|
||
}
|
||
|
||
async function setup(browser: Browser): Promise<void> {
|
||
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')));
|
||
// The profile documents (public actor documents' `url`): Alice's is needed BEFORE her sign-in,
|
||
// to record what her app reads and writes there.
|
||
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;
|
||
|
||
// Bob: consent through the UI, then the event and its two meeting points through the app. His
|
||
// page stays open: it is the creator's view later (before and after he becomes Alice's contact).
|
||
const bobPage = await (await browser.newContext()).newPage();
|
||
wire(bobPage, 'bob', ui);
|
||
await signIn('setup.bob-consent', 'bob', 'Bob', bobPage, () => { S.bobPage = 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, need(S.E, 'event E'));
|
||
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, need(S.E, 'event E'));
|
||
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)' };
|
||
});
|
||
|
||
// 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 — and what her app
|
||
// sends to her pod is recorded (the secret's write path, assumption a).
|
||
const alicePage = await (await browser.newContext()).newPage();
|
||
wire(alicePage, 'alice', ui);
|
||
recordPodTraffic(alicePage, S.aliceTraffic, () => S.aliceProfile);
|
||
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; });
|
||
|
||
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<void> {
|
||
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<void> {
|
||
const G = 'writes (negative)';
|
||
const bobRead = async (uri: string): Promise<string> => {
|
||
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<string, string> = {};
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<CheckOutcome> {
|
||
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 until it does / does not hold `ref`; any non-200 read keeps the line failing. */
|
||
async function refIn(collection: string, ref: string, wanted: boolean, token?: string): Promise<{ ok: boolean; status: number; items: string[] }> {
|
||
const a = await poll(() => attendees(collection, token), v => v.status === 200 && v.items.includes(ref) === wanted, 30000);
|
||
return { ok: a.status === 200 && a.items.includes(ref) === wanted, status: a.status, items: a.items };
|
||
}
|
||
|
||
// ── Participation readers: each actor reads under its OWN session ────────────
|
||
|
||
const typeIs = (rec: JsonRecord | undefined, type: string): boolean =>
|
||
arrayOf(rec?.['type'] ?? rec?.['@type']).some(t => t === type || t === `as:${type}` || t === `https://www.w3.org/ns/activitystreams#${type}`);
|
||
|
||
/**
|
||
* Every item an ActivityStreams collection shows its reader: the inline items, or
|
||
* the pages from `first` on (bounded) — items are dereferenced with the same
|
||
* token when they come as bare URIs. A root that does not answer 200 yields its
|
||
* status and no items (never "empty").
|
||
*/
|
||
async function collectionItems(uri: string, token?: string): Promise<{ status: number; items: JsonRecord[]; rootText: string }> {
|
||
const r = await http('GET', uri, { token });
|
||
if (r.status !== 200) return { status: r.status, items: [], rootText: r.text };
|
||
const root = parseJson(r.text);
|
||
let raw = arrayOf(root?.['orderedItems'] ?? root?.['items']);
|
||
let next = raw.length === 0 ? linkId(root?.['first']) : undefined;
|
||
for (let page = 0; next && page < 20; page++) {
|
||
const p = parseJson((await http('GET', next, { token })).text);
|
||
const its = arrayOf(p?.['orderedItems'] ?? p?.['items']);
|
||
raw = raw.concat(its);
|
||
next = its.length > 0 ? linkId(p?.['next']) : undefined;
|
||
}
|
||
const items: JsonRecord[] = [];
|
||
for (const item of raw) {
|
||
const rec = asRecord(item);
|
||
if (rec && (rec['type'] !== undefined || rec['@type'] !== undefined)) { items.push(rec); continue; }
|
||
const id = linkId(item);
|
||
const fetched = id ? parseJson((await http('GET', id, { token })).text) : undefined;
|
||
if (fetched) items.push(fetched);
|
||
}
|
||
return { status: 200, items, rootText: r.text };
|
||
}
|
||
|
||
/** A session's OWN profile document URI (the `url` of its actor document, read with its token). */
|
||
async function ownProfileUri(session: ApodsSession): Promise<string> {
|
||
return need(linkId(parseJson((await http('GET', session.webId, { token: session.token })).text)?.['url']), `the profile ${session.webId} links (url)`);
|
||
}
|
||
|
||
/** A session's OWN profile record, read with its token. */
|
||
async function ownProfile(session: ApodsSession): Promise<JsonRecord | undefined> {
|
||
return parseJson((await http('GET', await ownProfileUri(session), { token: session.token })).text);
|
||
}
|
||
|
||
/**
|
||
* The member URN a person computes for HERSELF at an event — from the secret her
|
||
* own profile carries (read with her token). Nobody hands it to her.
|
||
*/
|
||
async function ownMemberUrn(session: ApodsSession, eventUri: string): Promise<string> {
|
||
const secret = need(participationSecretOf(await ownProfile(session)), `the participation secret on ${session.webId}'s own profile`);
|
||
return memberUrnOf(session.webId, eventUri, secret);
|
||
}
|
||
|
||
/** A session's OWN participations collection, from its own profile (undefined while the profile links none). */
|
||
async function ownParticipations(session: ApodsSession): Promise<string | undefined> {
|
||
return participationsCollectionOf(await ownProfile(session));
|
||
}
|
||
|
||
/** An actor document's link under `apods:<name>` (CURIE or full IRI). */
|
||
const apodsLink = (rec: JsonRecord | undefined, name: string): string | undefined =>
|
||
linkId(rec?.[`apods:${name}`]) ?? linkId(rec?.[`http://activitypods.org/ns/core#${name}`]);
|
||
|
||
/** A session's OWN `apods:contacts` collection URI, read with its token. */
|
||
async function ownContactsUri(session: ApodsSession): Promise<string> {
|
||
const actor = parseJson((await http('GET', session.webId, { token: session.token })).text);
|
||
return need(apodsLink(actor, 'contacts'), `the apods:contacts of ${session.webId}`);
|
||
}
|
||
|
||
/** A session's OWN `apods:contacts` items — the bilateral list — read with its token (an unreadable list throws). */
|
||
async function ownContacts(session: ApodsSession): Promise<string[]> {
|
||
const uri = await ownContactsUri(session);
|
||
const r = await http('GET', uri, { token: session.token });
|
||
if (r.status !== 200) throw new Error(`GET ${uri} (own token) → ${brief(r)}`);
|
||
return itemUris(parseJson(r.text));
|
||
}
|
||
|
||
/** The `/contacts` WAC group URI derived from an `apods:contacts` collection (pod's getUriFromCollectionUri). */
|
||
function contactsGroupUri(contactsUri: string): string {
|
||
const u = new URL(contactsUri);
|
||
return `${u.origin}/_groups${u.pathname}`;
|
||
}
|
||
|
||
/**
|
||
* The ACL of a session's OWN participations collection, read with its token: the
|
||
* agents its own Read rule names (the owner left out — the pod may name her as the
|
||
* fallback of an otherwise empty rule set — and the app actor left out, reported
|
||
* apart: the pod grants it read on the collection it created), the groups its own Read names, and every
|
||
* agent class ANY rule names, inherited ones included. `onlyGroup(groupUri)` holds
|
||
* when the own Read grants read to exactly that one `/contacts` WAC group, with no
|
||
* per-contact `acl:agent` and no agent class (foaf:Agent, acl:AuthenticatedAgent)
|
||
* anywhere — the shape decision_2026-10-29 prescribes.
|
||
*/
|
||
async function participationsAcl(owner: ApodsSession): Promise<{ status: number; graph: JsonRecord[]; agents: string[]; ownGroups: string[]; groups: string[]; classes: string[]; onlyGroup: (groupUri: string) => boolean; summary: string }> {
|
||
const uri = need(await ownParticipations(owner), 'her participations collection');
|
||
const r = await http('GET', `${new URL(uri).origin}/_acl${new URL(uri).pathname}`, { token: owner.token });
|
||
const graph = r.status === 200 ? arrayOf(parseJson(r.text)?.['@graph']).map(asRecord).filter((x): x is JsonRecord => !!x) : [];
|
||
const values = (rule: JsonRecord, name: string): string[] =>
|
||
arrayOf(rule[`acl:${name}`] ?? rule[`http://www.w3.org/ns/auth/acl#${name}`]).map(v => linkId(v)).filter((v): v is string => !!v);
|
||
const ownRead = graph.filter(rule => String(rule['@id'] ?? '').endsWith('#Read'));
|
||
// The app actor holds its own read on a collection it created (the pod grants it at creation, as
|
||
// it grants the app #Control): it is no contact, so it is reported, not counted as a per-contact agent.
|
||
const appActor = `${BACKEND}/as/actor/app`;
|
||
const ownReadAgents = [...new Set(ownRead.flatMap(rule => values(rule, 'agent')))];
|
||
const appReads = ownReadAgents.includes(appActor);
|
||
const agents = ownReadAgents.filter(a => a !== owner.webId && a !== appActor);
|
||
const ownGroups = [...new Set(ownRead.flatMap(rule => values(rule, 'agentGroup')))];
|
||
const groups = [...new Set(graph.flatMap(rule => values(rule, 'agentGroup')))];
|
||
const classes = [...new Set(graph.flatMap(rule => values(rule, 'agentClass')))];
|
||
const onlyGroup = (groupUri: string): boolean =>
|
||
r.status === 200 && agents.length === 0 && classes.length === 0 && ownGroups.length === 1 && ownGroups[0] === groupUri;
|
||
return { status: r.status, graph, agents, ownGroups, groups, classes, onlyGroup, summary: `ACL HTTP ${r.status}; ${graph.length} rule(s); own Read per-contact agents ${JSON.stringify(agents)} (app actor reads: ${appReads}); own Read groups ${JSON.stringify(ownGroups)}; agent classes ${JSON.stringify(classes)}` };
|
||
}
|
||
|
||
/** The newest `type` activity naming `object` in a session's OWN outbox, read with its token. */
|
||
async function ownActivity(session: ApodsSession, type: 'Join' | 'Leave', object: string): Promise<JsonRecord | undefined> {
|
||
const { items } = await collectionItems(session.outbox, session.token);
|
||
return items.find(a => typeIs(a, type) && linkId(a['object']) === object);
|
||
}
|
||
|
||
/** The addressees an activity names, as `field=uri` — a participation activity names the app actor alone, in `to`. */
|
||
const ADDRESSING = ['to', 'cc', 'bto', 'bcc', 'audience', 'as:to', 'as:cc', 'as:bto', 'as:bcc', 'as:audience'];
|
||
const addressingOf = (activity: JsonRecord): string[] =>
|
||
ADDRESSING.flatMap(k => arrayOf(activity[k]).map(v => `${k.replace(/^as:/, '')}=${linkId(v) ?? JSON.stringify(v)}`));
|
||
const addressedToTheAppAlone = (activity: JsonRecord): boolean => {
|
||
const addressees = addressingOf(activity);
|
||
return addressees.length === 1 && addressees[0] === `to=${BACKEND}/as/actor/app`;
|
||
};
|
||
|
||
/**
|
||
* The pod's refusal of a COLLECTION: 401/403, or its 404 — which counts as one
|
||
* only while the owner reads the same URI 200 at the same moment (VERIFIED
|
||
* 2026-10-13 on attendee lists, 2026-10-29 on a participations collection).
|
||
*/
|
||
async function collectionRefusal(uri: string, token: string | undefined, owner: ApodsSession, allowed: number[]): Promise<{ ok: boolean; actual: string }> {
|
||
const [r, o] = await Promise.all([http('GET', uri, { token }), http('GET', uri, { token: owner.token })]);
|
||
const ok = refused(r.status, allowed) || (r.status === 404 && o.status === 200);
|
||
return { ok, actual: `${brief(r).slice(0, 70)} (owner reads it: HTTP ${o.status})` };
|
||
}
|
||
|
||
/** One notification as the recipient reads it in their own inbox. */
|
||
interface Notice { uri?: string; text: string }
|
||
|
||
/**
|
||
* The notifications about `contextUri` in a session's OWN inbox (read with its
|
||
* token): the app's `pod-notifications.send` delivers an `apods:Notification`
|
||
* Note whose `context` is the meeting point.
|
||
*/
|
||
async function notificationsAbout(session: ApodsSession, contextUri: string): Promise<{ status: number; notices: Notice[] }> {
|
||
const actor = parseJson((await http('GET', session.webId, { token: session.token })).text);
|
||
const inbox = need(linkId(actor?.['inbox']), `the inbox of ${session.webId}`);
|
||
const { status, items } = await collectionItems(inbox, session.token);
|
||
const notices = items
|
||
.filter(n => typeIs(n, 'Note') && linkId(n['context']) === contextUri)
|
||
.map(n => ({ uri: recordUri(n), text: JSON.stringify([n['name'], n['nameMap'], n['content'], n['contentMap']]) }));
|
||
return { status, notices };
|
||
}
|
||
const JOIN_WORDING = /joined|is attending|a rejoint|participe à/i;
|
||
const LEAVE_WORDING = /\bleft\b|no longer attending|a quitté|ne participe plus/i;
|
||
|
||
/**
|
||
* A notification line: within 30 s, the creator's inbox (read by the creator —
|
||
* Bob by default) holds a `change` notification about `contextUri`, naming the
|
||
* participant (Alice by default; the display name, which is the username here)
|
||
* iff `named`. An anonymous one may name neither the display name nor the WebID.
|
||
*/
|
||
async function notifiedLine(
|
||
contextUri: string, change: 'join' | 'leave', named: boolean,
|
||
who: { creator: ApodsSession; participant: ApodsSession; name: string } = { creator: need(S.bob, 'Bob (the creator)'), participant: need(S.alice, 'Alice'), name: USERS.alice },
|
||
): Promise<CheckOutcome> {
|
||
const { creator, participant, name } = who;
|
||
const wording = change === 'join' ? JOIN_WORDING : LEAVE_WORDING;
|
||
const naming = (n: Notice): boolean => n.text.includes(name) || n.text.includes(participant.webId);
|
||
const match = (n: Notice): boolean => wording.test(n.text) && (named ? n.text.includes(name) : !naming(n));
|
||
const r = await poll(() => notificationsAbout(creator, contextUri), v => v.notices.some(match), 30000, 1500);
|
||
const wrongKind = r.notices.filter(n => wording.test(n.text) && !match(n));
|
||
return {
|
||
ok: r.status === 200 && r.notices.some(match) && wrongKind.length === 0,
|
||
actual: `inbox HTTP ${r.status}; ${r.notices.length} notification(s) about it: ${r.notices.map(n => n.text.slice(0, 120)).join(' | ') || 'none'}${wrongKind.length ? ` — ${wrongKind.length} of the wrong kind (${named ? 'anonymous' : `NAMING ${name}`})` : ''}`,
|
||
};
|
||
}
|
||
|
||
/** The claims of a JWT bearer (undefined when it is not one). */
|
||
function jwtClaims(token: string | undefined): JsonRecord | undefined {
|
||
const part = token?.split('.')[1];
|
||
if (!part) return undefined;
|
||
try {
|
||
return parseJson(Buffer.from(part, 'base64url').toString('utf8'));
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
/** How many "Participant inconnu" rows a detail page shows. */
|
||
const unknownRows = (text: string): number => (text.match(/Participant inconnu/g) ?? []).length;
|
||
|
||
// ── Join (Alice, through the app; Bob NOT her contact) ───────────────────────
|
||
|
||
async function join(): Promise<void> {
|
||
const G = 'join (Alice, through the app — Bob not her contact)';
|
||
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/));
|
||
|
||
// THE SECRET, WRITTEN BY THE APP (assumption a): the request is read off Alice's page.
|
||
await m.hard('join.secret-app-write', G,
|
||
"Alice's app wrote her participation secret through the data provider's update — a PUT on her profile with her APP-SESSION token — BEFORE it posted the Join",
|
||
'a PUT on her profile from her page, 2xx; the bearer is not her pod-login token and names the app; the body carries a 64-hex http://festipod.org/participationSecret; the outbox Join POST comes after it',
|
||
async () => {
|
||
const alice = need(S.alice, 'Alice');
|
||
const profile = need(S.aliceProfile, "Alice's profile URI");
|
||
await poll(async () => S.aliceTraffic.find(r => r.method === 'POST' && r.url === alice.outbox), v => !!v, 15000);
|
||
const put = S.aliceTraffic.find(r => r.method === 'PUT' && r.url === profile);
|
||
const post = S.aliceTraffic.find(r => r.method === 'POST' && r.url === alice.outbox && r.body.includes('"Join"'));
|
||
if (!put) return { ok: false, actual: `no PUT on ${profile} from Alice's page — pod writes seen: ${S.aliceTraffic.filter(r => r.method !== 'GET').map(r => `${r.method} ${r.url}`).join(', ') || 'none'}` };
|
||
const claims = jwtClaims(put.token);
|
||
const appActor = `${BACKEND}/as/actor/app`;
|
||
const claimNamingApp = Object.entries(claims ?? {}).find(([, v]) => JSON.stringify(v).includes(appActor))?.[0];
|
||
const appToken = !!put.token && put.token !== alice.token && claimNamingApp !== undefined;
|
||
const sent = participationSecretOf(parseJson(put.body));
|
||
const ordered = !!post && put.seq < post.seq;
|
||
const status = put.status ?? 0;
|
||
return {
|
||
ok: status >= 200 && status < 300 && appToken && sent !== undefined && ordered,
|
||
actual: `PUT → HTTP ${status}; app-session token: ${appToken} (claim naming the app: ${claimNamingApp ?? 'none'}; webid claim: ${String(claims?.['webid'] ?? '?')}); secret in the body: ${sent !== undefined}; PUT before the Join POST: ${ordered}`,
|
||
};
|
||
});
|
||
await m.hard('join.secret-app-readback', G,
|
||
"the pod returns the secret to the app's token, and to its owner — the key it answers under is reported",
|
||
"an app-token GET of her profile issued AFTER the PUT carries the very secret sent; Alice's own read agrees",
|
||
async () => {
|
||
const alice = need(S.alice, 'Alice');
|
||
const profile = need(S.aliceProfile, "Alice's profile URI");
|
||
const put = need(S.aliceTraffic.find(r => r.method === 'PUT' && r.url === profile), 'the app\'s PUT on her profile (join.secret-app-write)');
|
||
const sent = need(participationSecretOf(parseJson(put.body)), 'the secret the PUT sent');
|
||
const reads = S.aliceTraffic.filter(r => r.method === 'GET' && r.url === profile && r.seq > put.seq && r.token !== alice.token);
|
||
const back = reads.map(r => parseJson(r.response ?? '')).find(rec => participationSecretOf(rec) === sent);
|
||
const key = back ? Object.keys(back).find(k => k.includes('participationSecret')) : undefined;
|
||
const own = participationSecretOf(await ownProfile(alice));
|
||
return { ok: !!back && own === sent, actual: `${reads.length} app-token read(s) after the PUT; secret returned: ${!!back} under the key ${JSON.stringify(key)}; her own read: ${own === sent ? 'same secret' : own ? 'ANOTHER secret' : 'none'}` };
|
||
});
|
||
|
||
// THE JOIN: in her own outbox, addressed to the app actor alone; only she and the app read it.
|
||
await m.hard('join.activity-shape', G, "Alice reads her own outbox: the Join names M and is addressed to the app actor ALONE (not public, not the creator)", 'one Join, actor Alice, object M, to = the app actor, no other addressee', async () => {
|
||
const alice = need(S.alice, 'Alice');
|
||
const M = need(S.M, 'M');
|
||
const act = await poll(() => ownActivity(alice, 'Join', M), v => !!v, 15000);
|
||
if (!act) return { ok: false, actual: `no Join naming M in her outbox` };
|
||
S.aliceJoinM = recordUri(act);
|
||
return { ok: linkId(act['actor']) === alice.webId && addressedToTheAppAlone(act) && !!S.aliceJoinM, actual: `${S.aliceJoinM}: actor ${linkId(act['actor'])}, object ${linkId(act['object'])}, addressees: ${addressingOf(act).join(',') || 'none'}` };
|
||
});
|
||
await m.hard('join.activity-author-reads', G, 'CONTROL: Alice (the author) GETs her Join', '200', async () => {
|
||
const r = await http('GET', need(S.aliceJoinM, "Alice's Join (join.activity-shape)"), { token: need(S.alice, 'Alice').token });
|
||
return { ok: r.status === 200, actual: `HTTP ${r.status}` };
|
||
});
|
||
await m.hard('join.activity-anon-refused', G, "anonymous GET on Alice's Join (its URI handed out of band)", 'refused 401/403', async () => {
|
||
const r = await http('GET', need(S.aliceJoinM, "Alice's Join"));
|
||
return { ok: refused(r.status, ANON), actual: brief(r).slice(0, 80) };
|
||
});
|
||
await m.hard('join.activity-creator-refused', G, "Bob — the meeting point's creator, not her contact — GETs Alice's Join (its URI handed out of band)", 'refused 403', async () => {
|
||
const r = await http('GET', need(S.aliceJoinM, "Alice's Join"), { token: need(S.bob, 'Bob').token });
|
||
return { ok: refused(r.status, AUTHN), actual: brief(r).slice(0, 80) };
|
||
});
|
||
await m.hard('join.activity-carol-refused', G, "Carol (not her contact) GETs Alice's Join (its URI handed out of band)", 'refused 403', async () => {
|
||
const r = await http('GET', need(S.aliceJoinM, "Alice's Join"), { token: need(S.carol, 'Carol').token });
|
||
return { ok: refused(r.status, AUTHN), actual: brief(r).slice(0, 80) };
|
||
});
|
||
await m.hard('join.activity-anon-listing', G, "the ANONYMOUS listing of Alice's outbox (every page) shows neither the Join nor M", 'Join absent, M named by no listed activity', async () => {
|
||
const alice = need(S.alice, 'Alice');
|
||
const listed = await collectionItems(alice.outbox);
|
||
const join = need(S.aliceJoinM, "Alice's Join");
|
||
const hasJoin = listed.items.some(a => recordUri(a) === join);
|
||
const namesM = listed.items.some(a => JSON.stringify(a).includes(need(S.M, 'M')));
|
||
m.info('join.outbox-root-residual', G, "accepted residual: the anonymous outbox ROOT carries the newest activity's URI in its pagination link (never its content)",
|
||
`the root names the Join's URI: ${listed.rootText.includes(join)}`);
|
||
return { ok: listed.status === 200 && !hasJoin && !namesM, actual: `HTTP ${listed.status}; ${listed.items.length} activit(ies) listed; Join ${hasJoin ? 'LISTED' : 'absent'}; M ${namesM ? 'NAMED' : 'not named'}` };
|
||
});
|
||
|
||
// THE LIST: a member URN, never a WebID.
|
||
await m.hard('join.collection', G, "M's attendee list (anonymous read) holds Alice's member URN — the one she computes from her own secret — and no WebID", 'items = [urn:festipod:userref:<sha256(Alice E secret)>] within 30 s', async () => {
|
||
const alice = need(S.alice, 'Alice');
|
||
const urn = await ownMemberUrn(alice, need(S.E, 'E'));
|
||
const r = await refIn(need(S.A, 'attendees of M'), urn, true);
|
||
const webIds = [S.bob?.webId, alice.webId, S.carol?.webId].filter((u): u is string => !!u);
|
||
const clean = r.items.every(i => i.startsWith(MEMBER_URN_PREFIX)) && !r.items.some(i => webIds.includes(i));
|
||
if (r.ok && clean) S.joined.M = true;
|
||
return { ok: r.ok && clean && r.items.length === 1, actual: `HTTP ${r.status} items=${JSON.stringify(r.items)}; her own URN ${urn.slice(0, 34)}…; only URNs, no WebID: ${clean}` };
|
||
});
|
||
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) };
|
||
});
|
||
|
||
// THE PARTICIPATIONS COLLECTION (assumptions b, d).
|
||
await m.hard('join.participations-created', G, "Alice's profile now links her participations collection (created by the backend at her first Join), in her pod; she reads it listing M", 'link on her profile (her read), the collection in her pod, 200, items ∋ M within 30 s', async () => {
|
||
const alice = need(S.alice, 'Alice');
|
||
const uri = await poll(() => ownParticipations(alice), v => !!v, 30000);
|
||
if (!uri) return { ok: false, actual: 'her profile links no participations collection after 30 s' };
|
||
const inPod = uri.startsWith(`${new URL(alice.webId).origin}${new URL(alice.webId).pathname}/`);
|
||
const r = await refIn(uri, need(S.M, 'M'), true, alice.token);
|
||
return { ok: inPod && r.ok, actual: `${uri} (in her pod: ${inPod}); HTTP ${r.status} items=${JSON.stringify(r.items)}` };
|
||
});
|
||
await m.hard('join.participations-acl', G, "the collection's ACL (read by Alice): no public read anywhere — the pod's own world-readable rule was removed — and read is granted to exactly her /contacts WAC group, with no foaf:Agent and no per-contact acl:agent", 'own Read names exactly her /contacts group; no foaf:Agent / acl:AuthenticatedAgent on any rule; no per-contact acl:agent', async () => {
|
||
const alice = need(S.alice, 'Alice');
|
||
const group = contactsGroupUri(await ownContactsUri(alice));
|
||
const a = await participationsAcl(alice);
|
||
const appControl = a.graph.some(r => JSON.stringify(r['acl:agent'] ?? '').includes(`${BACKEND}/as/actor/app`) && JSON.stringify(r['acl:mode'] ?? '').includes('Control') && String(r['@id'] ?? '').startsWith('#'));
|
||
m.info('join.participations-app-control', G, 'assumption (b): the app holds acl:Control on the collection it created (its OWN rule, which is what lets it remove the public read)', `own #Control naming the app actor: ${appControl}`);
|
||
return { ok: a.onlyGroup(group), actual: `${a.summary}; expected group ${group}` };
|
||
});
|
||
const participationsRefused = (id: string, name: string, who: () => string | undefined, allowed: number[]) =>
|
||
m.hard(id, G, name, `refused (${allowed.join('/')}, or the pod's 404 while Alice reads it 200)`, async () => {
|
||
const alice = need(S.alice, 'Alice');
|
||
const uri = need(await ownParticipations(alice), 'her participations collection');
|
||
return collectionRefusal(uri, who(), alice, allowed);
|
||
});
|
||
await participationsRefused('join.participations-anon-refused', "anonymous GET on Alice's participations collection (its URI handed out of band: the profile that links it is refused)", () => undefined, ANON);
|
||
await participationsRefused('join.participations-creator-refused', "Bob — the creator, NOT her contact — GETs her participations collection (URI handed out of band)", () => need(S.bob, 'Bob').token, AUTHN);
|
||
await participationsRefused('join.participations-carol-refused', "Carol (not her contact) GETs her participations collection (URI handed out of band)", () => need(S.carol, 'Carol').token, AUTHN);
|
||
|
||
// THE SECRET'S AUDIENCE: her profile — refused to every non-contact.
|
||
const secretRefused = (id: string, name: string, token: () => string | undefined, allowed: number[]) =>
|
||
m.hard(id, G, name, `refused ${allowed.join('/')}`, async () => {
|
||
const r = await http('GET', need(S.aliceProfile, "Alice's profile URI (her public actor document's url)"), { token: token() });
|
||
return { ok: refused(r.status, allowed) && !r.text.includes('participationSecret'), actual: brief(r).slice(0, 80) };
|
||
});
|
||
await secretRefused('join.secret-anon-refused', "anonymous GET on Alice's profile — her participation secret's only home", () => undefined, ANON);
|
||
await secretRefused('join.secret-creator-refused', "Bob (the creator, not her contact) GETs Alice's profile — joining grants the creator nothing", () => need(S.bob, 'Bob').token, AUTHN);
|
||
await secretRefused('join.secret-carol-refused', "Carol (not her contact) GETs Alice's profile", () => need(S.carol, 'Carol').token, AUTHN);
|
||
await m.hard('join.alice-resolves-bob', G, "after her join, Alice reads Bob's (the creator's) profile — joining grants no read in either direction", 'refused 403', async () => {
|
||
need(S.joined.M, 'the join on M landed (join.collection)');
|
||
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, 80) };
|
||
});
|
||
|
||
// ONE PERSON, TWO MEETING POINTS OF ONE EVENT.
|
||
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, "M2's attendee list (anonymous read) holds the SAME member URN as M's — one person, one URN per event", "items = [her URN at E] within 30 s, identical to M's", async () => {
|
||
const urn = await ownMemberUrn(need(S.alice, 'Alice'), need(S.E, 'E'));
|
||
const r = await refIn(need(S.A2, 'attendees of M2'), urn, true);
|
||
const inM = (await attendees(need(S.A, 'attendees of M'))).items.includes(urn);
|
||
if (r.ok) S.joined.M2 = true;
|
||
return { ok: r.ok && inM && r.items.length === 1, actual: `HTTP ${r.status} items=${JSON.stringify(r.items)}; the same URN in M's list: ${inM}` };
|
||
});
|
||
await m.hard('join.ui-counts-2', G, "Alice's view: the event counts her ONCE although she is in both meeting points", 'PARTICIPANTS (1), 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 === 1 && v.mp === 1 && v.mp2 === 1, 20000);
|
||
return { ok: c.participants === 1 && c.mp === 1 && c.mp2 === 1, actual: showCounts(c) };
|
||
});
|
||
await m.hard('join.participations-2', G, 'her participations collection lists both meeting points (her read)', 'items ∋ M and M2 within 30 s', async () => {
|
||
const alice = need(S.alice, 'Alice');
|
||
const uri = need(await ownParticipations(alice), 'her participations collection');
|
||
const r = await refIn(uri, need(S.M2, 'M2'), true, alice.token);
|
||
return { ok: r.ok && r.items.includes(need(S.M, 'M')), actual: `HTTP ${r.status} items=${JSON.stringify(r.items)}` };
|
||
});
|
||
|
||
// THE CREATOR'S NOTIFICATIONS: anonymous — he is not her contact.
|
||
await m.hard('join.notify-anonymous', G, "Bob (the creator, not her contact) is notified of each Join in his inbox (his session) — ANONYMOUSLY", 'a join notification about M naming neither her name nor her WebID, within 30 s', async () =>
|
||
notifiedLine(need(S.M, 'M'), 'join', false));
|
||
await m.hard('join.notify-anonymous-2', G, 'the same for her Join on M2', 'an anonymous join notification about M2 within 30 s', async () =>
|
||
notifiedLine(need(S.M2, 'M2'), 'join', false));
|
||
}
|
||
|
||
async function counterSemantics(): Promise<void> {
|
||
const G = 'counter semantics & recognition without contact';
|
||
await m.hard('counter.distinct', G,
|
||
"E's counter is its count of DISTINCT member URNs, publicly computable: Alice in M and M2 is ONE URN in both lists",
|
||
'anonymous: M=[u], M2=[u], distinct 1 — u being the URN Alice computes for herself at E', async () => {
|
||
need(S.joined.M && S.joined.M2, 'both joins landed (join.collection, join.collection-2)');
|
||
const urn = await ownMemberUrn(need(S.alice, 'Alice'), need(S.E, 'E'));
|
||
const d = await eventCounter();
|
||
const parts = [...d.per].map(([u, it]) => `${u.slice(-12)}:${it.length}`).join(', ');
|
||
const both = [need(S.M, 'M'), need(S.M2, 'M2')].every(mp => d.per.get(mp)?.length === 1 && d.per.get(mp)?.[0] === urn);
|
||
return { ok: d.mps.length === 2 && both && d.distinct === 1, actual: `MPs=${d.mps.length} [${parts}]; both lists hold exactly her URN: ${both}; distinct=${d.distinct}` };
|
||
});
|
||
const anonymousView = (id: string, who: 'carol' | 'bob', label: string) =>
|
||
m.hard(id, G,
|
||
`${label} views E: the public totals, and the participant as « Participant inconnu » — never her name`,
|
||
'PARTICIPANTS (1), M 1 inscrit, M2 1 inscrit; one « Participant inconnu » row; Alice\'s name absent', async () => {
|
||
need(S.joined.M && S.joined.M2, 'both joins landed');
|
||
const page = need(who === 'carol' ? S.carolPage : S.bobPage, `${label}'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 === 1 && v.mp === 1 && v.mp2 === 1 && unknownRows(v.text) === 1, 30000);
|
||
return { ok: c.participants === 1 && c.mp === 1 && c.mp2 === 1 && unknownRows(c.text) === 1 && !c.text.includes(USERS.alice), actual: `${showCounts(c)}; « Participant inconnu » rows: ${unknownRows(c.text)}; Alice's name ${c.text.includes(USERS.alice) ? 'SHOWN' : 'absent'}` };
|
||
});
|
||
await anonymousView('recognition.carol-anonymous', 'carol', 'Carol (not her contact)');
|
||
await anonymousView('recognition.creator-anonymous', 'bob', 'Bob (the creator, not yet her contact)');
|
||
}
|
||
|
||
|
||
async function isolation(): Promise<void> {
|
||
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<void> {
|
||
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 yet. 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<void> {
|
||
const G = 'leave M (Alice, through the app — Bob not her contact)';
|
||
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');
|
||
const page = need(S.alicePage, "Alice's page");
|
||
await page.goto(`${APP}/events/${encodeURIComponent(need(S.E, 'E'))}`, { waitUntil: 'domcontentloaded' });
|
||
await mpCard(page, TITLE_M).getByRole('button', { name: /✓ Inscrit/ }).waitFor({ timeout: 20000 }).catch(() => {});
|
||
return tapMeetingPoint(page, TITLE_M, /✓ Inscrit/, /^S'inscrire$/);
|
||
});
|
||
await m.hard('leave.activity-shape', G, "Alice reads her own outbox: the Leave names M and is addressed to the app actor alone", 'a Leave, object M, to = the app actor, no other addressee', async () => {
|
||
const act = await poll(() => ownActivity(need(S.alice, 'Alice'), 'Leave', need(S.M, 'M')), v => !!v, 15000);
|
||
return { ok: !!act && addressedToTheAppAlone(act), actual: act ? `${recordUri(act)}: addressees: ${addressingOf(act).join(',') || 'none'}` : 'no Leave naming M in her outbox' };
|
||
});
|
||
await m.hard('leave.collection', G, "Alice's member URN disappears from M's attendee list", 'items ∌ her URN within 30 s (read 200)', async () => {
|
||
need(S.joined.M, 'the join on M landed (join.collection)');
|
||
const r = await refIn(need(S.A, 'attendees of M'), await ownMemberUrn(need(S.alice, 'Alice'), need(S.E, 'E')), false);
|
||
return { ok: r.ok, actual: `HTTP ${r.status} items=${JSON.stringify(r.items)}` };
|
||
});
|
||
await m.hard('leave.participations', G, 'M leaves her participations collection; M2 stays (her read)', 'items ∌ M, ∋ M2 within 30 s', async () => {
|
||
const alice = need(S.alice, 'Alice');
|
||
const uri = need(await ownParticipations(alice), 'her participations collection');
|
||
const r = await refIn(uri, need(S.M, 'M'), false, alice.token);
|
||
return { ok: r.ok && r.items.includes(need(S.M2, 'M2')), 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.notify-anonymous', G, 'Bob (the creator, not her contact) is notified of the Leave in his inbox — ANONYMOUSLY', 'a leave notification about M naming neither her name nor her WebID, within 30 s', async () =>
|
||
notifiedLine(need(S.M, 'M'), 'leave', false));
|
||
}
|
||
|
||
/**
|
||
* F8 — the positive half of the identity rule, through the platform's own
|
||
* contact flow (each act is the user's own outbox post, exactly what the pod
|
||
* frontend posts): Bob asks, Alice accepts. Runs after every NON-contact line:
|
||
* from here on Bob is Alice's contact — a contact made AFTER her joins.
|
||
*/
|
||
async function contactChannel(): Promise<void> {
|
||
const G = 'contact channel (the positive half, F8)';
|
||
const actorLink = (rec: JsonRecord | undefined, pred: string) =>
|
||
linkId(rec?.[pred]) ?? linkId(rec?.[`http://activitypods.org/ns/core#${pred.slice('apods:'.length)}`]);
|
||
|
||
// Bob asks Alice to become a contact: the platform's Offer (Add of a Profile),
|
||
// posted to his own outbox — the same shape the app's addFriend posts.
|
||
await m.hard('contact.request-posted', G, 'Bob posts a contact request to Alice (Offer{Add{Profile}}, his own outbox, addressed to her)', '2xx from his pod', async () => {
|
||
const bob = need(S.bob, 'Bob');
|
||
const alice = need(S.alice, 'Alice');
|
||
const r = await http('POST', bob.outbox, {
|
||
token: bob.token, contentType: 'application/ld+json',
|
||
body: JSON.stringify({
|
||
'@context': PLATFORM_CONTEXT, type: 'Offer', actor: bob.webId,
|
||
object: { type: 'Add', object: { id: need(S.bobProfile, "Bob's profile URI"), type: 'Profile' } },
|
||
target: alice.webId, to: alice.webId,
|
||
}),
|
||
});
|
||
return { ok: r.status >= 200 && r.status < 300, actual: `HTTP ${r.status}` };
|
||
});
|
||
|
||
await m.hard('contact.accepted', G, "Alice accepts the request (Accept naming the stored Offer, her own outbox) — both contact lists name each other", 'mutual apods:contacts within 30 s', async () => {
|
||
const bob = need(S.bob, 'Bob');
|
||
const alice = need(S.alice, 'Alice');
|
||
const aliceActor = parseJson((await http('GET', alice.webId, { token: alice.token })).text);
|
||
const requestsUri = actorLink(aliceActor, 'apods:contactRequests');
|
||
if (!requestsUri) return { ok: false, actual: "Alice's actor document links no contact-requests collection" };
|
||
const offer = await poll(async () => {
|
||
const rec = parseJson((await http('GET', requestsUri, { token: alice.token })).text);
|
||
return arrayOf(rec?.['items'] ?? rec?.['as:items']).find(item => linkId(asRecord(item)?.['actor']) === bob.webId);
|
||
}, v => !!v, 30000);
|
||
const offerId = linkId(asRecord(offer));
|
||
if (!offerId) return { ok: false, actual: "Bob's Offer never appeared in Alice's contact-requests" };
|
||
const acc = await http('POST', alice.outbox, {
|
||
token: alice.token, contentType: 'application/ld+json',
|
||
body: JSON.stringify({ '@context': PLATFORM_CONTEXT, type: 'Accept', actor: alice.webId, object: offerId, to: bob.webId }),
|
||
});
|
||
if (acc.status < 200 || acc.status >= 300) return { ok: false, actual: `Accept post → HTTP ${acc.status}` };
|
||
const aliceContacts = actorLink(aliceActor, 'apods:contacts');
|
||
const bobActor = parseJson((await http('GET', bob.webId, { token: bob.token })).text);
|
||
const bobContacts = actorLink(bobActor, 'apods:contacts');
|
||
if (!aliceContacts || !bobContacts) return { ok: false, actual: 'an actor document links no apods:contacts collection' };
|
||
const mutual = await poll(async () => {
|
||
const a = itemUris(parseJson((await http('GET', aliceContacts, { token: alice.token })).text));
|
||
const b = itemUris(parseJson((await http('GET', bobContacts, { token: bob.token })).text));
|
||
return a.includes(bob.webId) && b.includes(alice.webId);
|
||
}, v => v, 30000);
|
||
if (mutual) S.contacts = true;
|
||
return { ok: mutual, actual: `mutual apods:contacts: ${mutual}` };
|
||
});
|
||
}
|
||
|
||
/**
|
||
* What the contact made AFTER the joins sees — « future contacts see the past »:
|
||
* Bob, now Alice's contact, reads her profile (its URI from her public actor
|
||
* document), her secret on it, her participations collection linked from it —
|
||
* all under his own session — and his app recognises her.
|
||
*/
|
||
async function afterContact(): Promise<void> {
|
||
const G = 'a contact made AFTER the joins (Bob)';
|
||
const aliceProfileAsBob = async (): Promise<{ status: number; rec?: JsonRecord }> => {
|
||
const uri = need(await profileUriOf(need(S.alice, 'Alice').webId), "the profile Alice's public actor document links");
|
||
const r = await poll(() => http('GET', uri, { token: need(S.bob, 'Bob').token }), v => v.status === 200, 30000);
|
||
return { status: r.status, rec: parseJson(r.text) };
|
||
};
|
||
await m.hard('contact.secret-readable', G, "Bob reads Alice's profile (his session) — it carries her participation secret", '200 with a 64-hex http://festipod.org/participationSecret', async () => {
|
||
need(S.contacts, 'the contact pair (contact.accepted)');
|
||
const p = await aliceProfileAsBob();
|
||
return { ok: p.status === 200 && participationSecretOf(p.rec) !== undefined, actual: `HTTP ${p.status}; secret present: ${participationSecretOf(p.rec) !== undefined}` };
|
||
});
|
||
await m.hard('contact.urn-computable', G, "with the secret HE read, Bob computes Alice's URN at E and finds it in M2's list (not in M's: she left)", "his computed URN ∈ M2's items, ∉ M's (anonymous reads)", async () => {
|
||
need(S.contacts, 'the contact pair (contact.accepted)');
|
||
const secret = need(participationSecretOf((await aliceProfileAsBob()).rec), "the secret on Alice's profile, read by Bob");
|
||
const urn = memberUrnOf(need(S.alice, 'Alice').webId, need(S.E, 'E'), secret);
|
||
const inM2 = (await attendees(need(S.A2, 'attendees of M2'))).items.includes(urn);
|
||
const inM = (await attendees(need(S.A, 'attendees of M'))).items.includes(urn);
|
||
return { ok: inM2 && !inM, actual: `in M2: ${inM2}; in M: ${inM}` };
|
||
});
|
||
await m.hard('contact.participations-readable', G, "Bob reads Alice's participations collection, linked from her profile — including the Join she made BEFORE he became her contact", "200, items ∋ M2 (joined before the contact), ∌ M (left)", async () => {
|
||
need(S.contacts, 'the contact pair (contact.accepted)');
|
||
const uri = need(participationsCollectionOf((await aliceProfileAsBob()).rec), "the participations link on Alice's profile, read by Bob");
|
||
const r = await poll(() => http('GET', uri, { token: need(S.bob, 'Bob').token }), v => v.status === 200, 30000);
|
||
const items = itemUris(parseJson(r.text));
|
||
return { ok: r.status === 200 && items.includes(need(S.M2, 'M2')) && !items.includes(need(S.M, 'M')), actual: `HTTP ${r.status} items=${JSON.stringify(items)}` };
|
||
});
|
||
await m.hard('contact.carol-still-refused', G, "Carol, still NOT Alice's contact, stays refused her profile and her participations collection (URI handed out of band)", 'profile refused 403; collection refused (403, or the pod\'s 404 while Alice reads it 200)', async () => {
|
||
const alice = need(S.alice, 'Alice');
|
||
const carol = need(S.carol, 'Carol');
|
||
const profile = await http('GET', need(S.aliceProfile, "Alice's profile"), { token: carol.token });
|
||
const coll = await collectionRefusal(need(await ownParticipations(alice), 'her participations collection'), carol.token, alice, AUTHN);
|
||
return { ok: refused(profile.status, AUTHN) && coll.ok, actual: `profile ${brief(profile).slice(0, 40)}; collection ${coll.actual}` };
|
||
});
|
||
try {
|
||
const join2 = await ownActivity(need(S.alice, 'Alice'), 'Join', need(S.M2, 'M2'));
|
||
const r = join2 ? await http('GET', need(recordUri(join2), 'her Join on M2'), { token: need(S.bob, 'Bob').token }) : undefined;
|
||
m.info('contact.join-activity', G, "Bob, now her contact, GETs Alice's Join on M2 (URI handed out of band) — the decision: a non-public activity is readable by its author and recipients only",
|
||
r ? `HTTP ${r.status}` : 'her Join on M2 not found in her outbox');
|
||
} catch (e) {
|
||
m.info('contact.join-activity', G, "Bob, now her contact, GETs Alice's Join on M2", `not observed: ${String(e).slice(0, 120)}`);
|
||
}
|
||
await m.hard('contact.recognition-ui', G, "Bob's app, reloaded, recognises his contact in E's list — her name, no « Participant inconnu » (the browser hashes the same WebID the backend hashed)", `PARTICIPANTS (1); "${USERS.alice}" shown; no « Participant inconnu »`, async () => {
|
||
need(S.contacts, 'the contact pair (contact.accepted)');
|
||
const page = need(S.bobPage, "Bob'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 === 1 && v.text.includes(USERS.alice) && unknownRows(v.text) === 0, 45000, 1500);
|
||
return { ok: c.participants === 1 && c.text.includes(USERS.alice) && unknownRows(c.text) === 0, actual: `${showCounts(c)}; Alice's name ${c.text.includes(USERS.alice) ? 'shown' : 'ABSENT'}; « Participant inconnu » rows: ${unknownRows(c.text)}` };
|
||
});
|
||
await m.hard('contact.profile-events-ui', G, "Bob taps Alice in that list → her profile screen lists the event she participates in (her participations, read when asked)", `route /users/<Alice>; "${TITLE_E}" listed`, async () => {
|
||
const page = need(S.bobPage, "Bob's page");
|
||
await page.getByText(USERS.alice, { exact: true }).first().click({ timeout: 10000 });
|
||
const t = await poll(() => bodyText(page), x => x.includes(TITLE_E), 30000, 1000);
|
||
const onRoute = decodeURIComponent(new URL(page.url()).pathname) === `/users/${need(S.alice, 'Alice').webId}`;
|
||
return { ok: onRoute && t.includes(TITLE_E), actual: `route ok: ${onRoute} (${new URL(page.url()).pathname.slice(0, 60)}); "${TITLE_E}" ${t.includes(TITLE_E) ? 'listed' : `absent; page: ${t.slice(0, 160)}`}` };
|
||
});
|
||
}
|
||
|
||
async function leaveAfterContact(): Promise<void> {
|
||
const G = 'leave M2 (Alice, through the app — Bob now her contact)';
|
||
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');
|
||
const page = need(S.alicePage, "Alice's page");
|
||
await page.goto(`${APP}/events/${encodeURIComponent(need(S.E, 'E'))}`, { waitUntil: 'domcontentloaded' });
|
||
await mpCard(page, TITLE_M2).getByRole('button', { name: /✓ Inscrit/ }).waitFor({ timeout: 20000 }).catch(() => {});
|
||
return tapMeetingPoint(page, TITLE_M2, /✓ Inscrit/, /^S'inscrire$/);
|
||
});
|
||
await m.hard('leave.collection-2', G, "Alice's member URN disappears from M2's attendee list", 'items ∌ her URN within 30 s (read 200)', async () => {
|
||
need(S.joined.M2, 'the join on M2 landed (join.collection-2)');
|
||
const r = await refIn(need(S.A2, 'attendees of M2'), await ownMemberUrn(need(S.alice, 'Alice'), need(S.E, 'E')), false);
|
||
return { ok: r.ok, actual: `HTTP ${r.status} items=${JSON.stringify(r.items)}` };
|
||
});
|
||
await m.hard('leave.participations-2', G, 'M2 leaves her participations collection (her read)', 'items ∌ M2 within 30 s', async () => {
|
||
const alice = need(S.alice, 'Alice');
|
||
const r = await refIn(need(await ownParticipations(alice), 'her participations collection'), need(S.M2, 'M2'), false, alice.token);
|
||
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.distinct', G, "E's distinct counter is back to its pre-join value (every list read 200)", 'distinct participants across M and M2 = 0', async () => {
|
||
need(S.joined.M && S.joined.M2, 'both joins landed');
|
||
const d = await eventCounter();
|
||
return { ok: d.mps.length === 2 && d.distinct === need(S.before, 'pre-join counts').participants, actual: `MPs=${d.mps.length} distinct=${d.distinct}` };
|
||
});
|
||
await m.hard('leave.notify-anonymous', G, 'Bob — even now her contact — is notified of this Leave ANONYMOUSLY (every notification is anonymous: the contacts list is forgeable)', `a leave notification about M2 naming neither her name nor her WebID, within 30 s`, async () => {
|
||
need(S.contacts, 'the contact pair (contact.accepted)');
|
||
return notifiedLine(need(S.M2, 'M2'), 'leave', false);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* A direct sign-up to the event itself (FP-001): Alice posts a Join naming E, the way
|
||
* the app does; her participations collection then lists E, under the same audience as
|
||
* a meeting point membership — her /contacts group only.
|
||
*/
|
||
async function directSignup(): Promise<void> {
|
||
const G = 'direct sign-up to E (Alice; Bob her contact, Carol not)';
|
||
const E = (): string => need(S.E, 'E');
|
||
const listsE = async (): Promise<{ ok: boolean; items: string[] }> => {
|
||
const alice = need(S.alice, 'Alice');
|
||
const uri = need(await ownParticipations(alice), 'her participations collection');
|
||
const r = await refIn(uri, E(), true, alice.token);
|
||
return { ok: r.ok, items: r.items };
|
||
};
|
||
await m.hard('direct.join', G, 'Alice posts a Join naming E, to the app actor alone; her own participations collection lists E', 'items ∋ E within 30 s (her read)', async () => {
|
||
await postOutboxActivity(need(S.alice, 'Alice'), 'Join', E(), appActorUri(config));
|
||
const r = await listsE();
|
||
return { ok: r.ok, actual: `items=${JSON.stringify(r.items)}` };
|
||
});
|
||
await m.hard('direct.non-contact-refused', G, "a direct sign-up is not listable by a non-contact: Carol is refused Alice's participations collection although it now lists E (URI handed out of band)", 'refused (403, or the pod\'s 404 while Alice reads it 200)', async () => {
|
||
const alice = need(S.alice, 'Alice');
|
||
const listed = await listsE();
|
||
need(listed.ok, 'the direct sign-up landed (direct.join)');
|
||
const refusal = await collectionRefusal(need(await ownParticipations(alice), 'her participations collection'), need(S.carol, 'Carol').token, alice, AUTHN);
|
||
return { ok: refusal.ok, actual: refusal.actual };
|
||
});
|
||
await m.hard('direct.contact-lists', G, "a contact lists the direct sign-ups: Bob, her contact, reads Alice's participations collection (linked from her profile) and finds E", '200, items ∋ E', async () => {
|
||
need(S.contacts, 'the contact pair (contact.accepted)');
|
||
const profile = need(await profileUriOf(need(S.alice, 'Alice').webId), "the profile Alice's public actor document links");
|
||
const link = need(participationsCollectionOf(parseJson((await http('GET', profile, { token: need(S.bob, 'Bob').token })).text)), "the participations link on Alice's profile, read by Bob");
|
||
const r = await poll(() => http('GET', link, { token: need(S.bob, 'Bob').token }), v => v.status === 200 && itemUris(parseJson(v.text)).includes(E()), 30000);
|
||
const items = itemUris(parseJson(r.text));
|
||
return { ok: r.status === 200 && items.includes(E()), actual: `HTTP ${r.status} items=${JSON.stringify(items)}` };
|
||
});
|
||
await m.hard('direct.leave', G, 'Alice posts a Leave naming E: her participations collection no longer lists it (the group leaves the event as it found it)', 'items ∌ E within 30 s (her read)', async () => {
|
||
const alice = need(S.alice, 'Alice');
|
||
await postOutboxActivity(alice, 'Leave', E(), appActorUri(config));
|
||
const uri = need(await ownParticipations(alice), 'her participations collection');
|
||
const r = await refIn(uri, E(), false, alice.token);
|
||
return { ok: r.ok, actual: `HTTP ${r.status} items=${JSON.stringify(r.items)}` };
|
||
});
|
||
}
|
||
|
||
|
||
/**
|
||
* Who reads a person's participations is her /contacts WAC group, through ONE
|
||
* stable `acl:agentGroup` grant the backend never revisits: the platform keeps the
|
||
* group's membership (decision_2026-10-29_participation-privacy-mechanism). So these
|
||
* lines assert the PLATFORM's group behaviour through that grant: the target of a
|
||
* pending request already reads (intended), a contact who accepted reads, a removed
|
||
* contact still reads (tracked platform defect), and the ACL never changes. Every act
|
||
* here is the user's own outbox post, the shape the pod frontend posts. HTTP only and
|
||
* LAST: it runs after every UI drive, and the pages are closed first.
|
||
*/
|
||
async function contactChanges(): Promise<void> {
|
||
const G = 'contact changes — the /contacts group grant is stable; membership is the platform\'s';
|
||
for (const page of [S.bobPage, S.alicePage, S.carolPage]) await page?.close().catch(() => {});
|
||
/**
|
||
* Alice's participations as `reader` OBTAINS them through the system: her profile (its URI from
|
||
* her public actor document) read under the reader's session, then the collection that profile
|
||
* links, read under the same session. `status` is the collection's read, or the profile's when the
|
||
* chain stops there (a refused profile yields no collection); `via` says how far it went.
|
||
*/
|
||
const participationsAs = async (reader: ApodsSession): Promise<{ status: number; items: string[]; via: string }> => {
|
||
const profileUri = need(await profileUriOf(need(S.alice, 'Alice').webId), "the profile Alice's public actor document links");
|
||
const p = await http('GET', profileUri, { token: reader.token });
|
||
if (p.status !== 200) return { status: p.status, items: [], via: `her profile → HTTP ${p.status}` };
|
||
const uri = participationsCollectionOf(parseJson(p.text));
|
||
if (!uri) return { status: 0, items: [], via: 'her profile (read 200) links no participations collection' };
|
||
const r = await http('GET', uri, { token: reader.token });
|
||
return { status: r.status, items: r.status === 200 ? itemUris(parseJson(r.text)) : [], via: `her profile → ${uri} → HTTP ${r.status}` };
|
||
};
|
||
const aliceGroup = async (): Promise<string> => contactsGroupUri(await ownContactsUri(need(S.alice, 'Alice')));
|
||
|
||
await m.hard('contacts.acl-before', G, "Alice's participations ACL now: read granted to exactly her /contacts WAC group — no per-contact acl:agent, no public read", 'own Read names exactly her /contacts group; no acl:agent; no foaf:Agent / AuthenticatedAgent on any rule', async () => {
|
||
need(S.contacts, 'the contact pair (contact.accepted)');
|
||
const alice = need(S.alice, 'Alice');
|
||
const group = await aliceGroup();
|
||
const a = await participationsAcl(alice);
|
||
return { ok: a.onlyGroup(group), actual: `${a.summary}; expected group ${group}` };
|
||
});
|
||
|
||
let carolShared = false;
|
||
await m.hard('contacts.request-to-carol', G, "Alice posts a contact request to Carol (Offer{Add{Profile}}, her own outbox, addressed to her) — the platform shares Alice's profile with the request's target AND adds the target to Alice's /contacts group at once", "2xx; Carol then reads Alice's profile (her session) within 30 s", async () => {
|
||
const alice = need(S.alice, 'Alice');
|
||
const carol = need(S.carol, 'Carol');
|
||
const r = await http('POST', alice.outbox, {
|
||
token: alice.token, contentType: 'application/ld+json',
|
||
body: JSON.stringify({
|
||
'@context': PLATFORM_CONTEXT, type: 'Offer', actor: alice.webId,
|
||
object: { type: 'Add', object: { id: need(S.aliceProfile, "Alice's profile URI"), type: 'Profile' } },
|
||
target: carol.webId, to: carol.webId,
|
||
}),
|
||
});
|
||
if (r.status < 200 || r.status >= 300) return { ok: false, actual: `Offer post → HTTP ${r.status}` };
|
||
const p = await poll(() => http('GET', need(S.aliceProfile, "Alice's profile"), { token: carol.token }), v => v.status === 200, 30000);
|
||
carolShared = p.status === 200;
|
||
return { ok: carolShared, actual: `Offer HTTP ${r.status}; Carol reads Alice's profile: HTTP ${p.status}` };
|
||
});
|
||
await m.hard('contacts.request-target-reads', G, "Carol — the target of Alice's request, NOT yet accepted — CAN read Alice's participations collection (intended, user decision 2026-10-29), finding it on Alice's profile read under her own session: sending the request puts the target in Alice's /contacts group, which the collection grants", "200, items ∋ VM within 30 s; the ACL still names exactly the group (no per-agent grant for Carol)", async () => {
|
||
if (!carolShared) throw new Error("prerequisite missing: the request took effect (contacts.request-to-carol) — without it this proves nothing");
|
||
const alice = need(S.alice, 'Alice');
|
||
const carol = need(S.carol, 'Carol');
|
||
const r = await poll(() => participationsAs(carol), v => v.status === 200 && v.items.includes(need(S.VM, 'VM')), 30000);
|
||
const a = await participationsAcl(alice);
|
||
return { ok: r.status === 200 && r.items.includes(need(S.VM, 'VM')) && a.onlyGroup(await aliceGroup()), actual: `Carol read: ${r.via} items=${JSON.stringify(r.items)}; ${a.summary}` };
|
||
});
|
||
|
||
let carolContact = false;
|
||
await m.hard('contacts.carol-accepts', G, "Carol accepts the request (Accept naming the stored Offer, her own outbox) — both contact lists name each other", 'mutual apods:contacts within 30 s', async () => {
|
||
const alice = need(S.alice, 'Alice');
|
||
const carol = need(S.carol, 'Carol');
|
||
const carolActor = parseJson((await http('GET', carol.webId, { token: carol.token })).text);
|
||
const requestsUri = need(apodsLink(carolActor, 'contactRequests'), "Carol's contact-requests collection");
|
||
const offer = await poll(async () => {
|
||
const rec = parseJson((await http('GET', requestsUri, { token: carol.token })).text);
|
||
return arrayOf(rec?.['items'] ?? rec?.['as:items']).find(item => linkId(asRecord(item)?.['actor']) === alice.webId);
|
||
}, v => !!v, 30000);
|
||
const offerId = linkId(asRecord(offer));
|
||
if (!offerId) return { ok: false, actual: "Alice's Offer never appeared in Carol's contact-requests" };
|
||
const acc = await http('POST', carol.outbox, {
|
||
token: carol.token, contentType: 'application/ld+json',
|
||
body: JSON.stringify({ '@context': PLATFORM_CONTEXT, type: 'Accept', actor: carol.webId, object: offerId, to: alice.webId }),
|
||
});
|
||
if (acc.status < 200 || acc.status >= 300) return { ok: false, actual: `Accept post → HTTP ${acc.status}` };
|
||
const mutual = await poll(async () => (await ownContacts(alice)).includes(carol.webId) && (await ownContacts(carol)).includes(alice.webId), v => v, 30000);
|
||
carolContact = mutual;
|
||
return { ok: mutual, actual: `mutual apods:contacts: ${mutual}` };
|
||
});
|
||
await m.hard('contacts.new-contact-reads', G, "Carol, now Alice's mutual contact, still reads her participations collection, found on Alice's profile (her session)", '200, items ∋ VM (Alice attends it) within 30 s', async () => {
|
||
if (!carolContact) throw new Error('prerequisite missing: the contact pair (contacts.carol-accepts)');
|
||
const r = await poll(() => participationsAs(need(S.carol, 'Carol')), v => v.status === 200, 30000);
|
||
return { ok: r.status === 200 && r.items.includes(need(S.VM, 'VM')), actual: `${r.via} items=${JSON.stringify(r.items)}` };
|
||
});
|
||
|
||
let removed = false;
|
||
await m.hard('contacts.platform-removal', G, "Alice removes Bob from her contacts — the platform's removal: the Remove the pod frontend posts (object: Bob, origin: her apods:contacts), from her own outbox, under her session", "2xx; her apods:contacts no longer name Bob within 30 s, Bob's still name her (the removal is one-sided)", async () => {
|
||
const alice = need(S.alice, 'Alice');
|
||
const bob = need(S.bob, 'Bob');
|
||
const aliceActor = parseJson((await http('GET', alice.webId, { token: alice.token })).text);
|
||
const contactsUri = need(apodsLink(aliceActor, 'contacts'), "Alice's apods:contacts");
|
||
const r = await http('POST', alice.outbox, {
|
||
token: alice.token, contentType: 'application/ld+json',
|
||
body: JSON.stringify({ '@context': PLATFORM_CONTEXT, type: 'Remove', actor: alice.webId, object: bob.webId, origin: contactsUri }),
|
||
});
|
||
if (r.status < 200 || r.status >= 300) return { ok: false, actual: `Remove post → HTTP ${r.status}` };
|
||
const gone = await poll(async () => !(await ownContacts(alice)).includes(bob.webId), v => v, 30000);
|
||
const bobKeeps = (await ownContacts(bob)).includes(alice.webId);
|
||
removed = gone;
|
||
return { ok: gone && bobKeeps, actual: `Remove HTTP ${r.status}; Bob out of her list: ${gone}; her still in his: ${bobKeeps}` };
|
||
});
|
||
await m.known('known-defect', 'contacts.removed-still-reads', G,
|
||
"Bob — removed from Alice's contacts by the platform's one-sided removal — STILL reads her participations collection, found on her profile read under his session (the pod's removeContact does not take him out of her /contacts WAC group; platform defect reported upstream)",
|
||
'tracked platform defect: the removed contact keeps read through the group; it should become refused once the platform removes him from the group', async () => {
|
||
if (!removed) return { ok: false, actual: 'the removal did not take effect', notice: 'the removal step failed — the tracked behaviour is unverified this run' };
|
||
const alice = need(S.alice, 'Alice');
|
||
const bob = need(S.bob, 'Bob');
|
||
const read = await poll(() => participationsAs(bob), v => v.status === 200, 20000);
|
||
if (read.status === 200) return { ok: true, actual: `${read.via} — Bob still reads it (items=${JSON.stringify(read.items)})` };
|
||
// A refusal is proved on the collection itself, its URI handed out of band (he may no longer obtain it).
|
||
const uri = need(await ownParticipations(alice), 'her participations collection');
|
||
const [direct, owner] = await Promise.all([http('GET', uri, { token: bob.token }), http('GET', uri, { token: alice.token })]);
|
||
if (refused(direct.status, AUTHN) || (direct.status === 404 && owner.status === 200)) {
|
||
return { ok: true, actual: `${read.via}; out of band HTTP ${direct.status} (owner ${owner.status}) — now REFUSED`, notice: 'a removed contact is no longer able to read the participations collection — the platform now removes him from the /contacts group: PROMOTE this line to a hard "refused" assertion' };
|
||
}
|
||
return { ok: false, actual: `${read.via}; out of band HTTP ${direct.status} (owner ${owner.status})`, notice: 'neither the tracked read nor a refusal — investigate before trusting this line' };
|
||
});
|
||
await m.hard('contacts.remaining-reads', G, "Carol — the contact Alice kept — still reads her participations collection after the removal, found on Alice's profile (her session)", '200, items ∋ VM', async () => {
|
||
if (!removed) throw new Error("prerequisite missing: the platform's removal (contacts.platform-removal)");
|
||
const r = await participationsAs(need(S.carol, 'Carol'));
|
||
return { ok: r.status === 200 && r.items.includes(need(S.VM, 'VM')), actual: `${r.via} items=${JSON.stringify(r.items)}` };
|
||
});
|
||
await m.hard('contacts.acl-after', G, "Alice's participations ACL after the changes: still read granted to exactly her /contacts group — one stable grant, never per-contact — no foaf:Agent", 'own Read names exactly her /contacts group; no acl:agent; no foaf:Agent / AuthenticatedAgent on any rule', async () => {
|
||
const alice = need(S.alice, 'Alice');
|
||
const group = await aliceGroup();
|
||
const a = await participationsAcl(alice);
|
||
return { ok: a.onlyGroup(group), actual: `${a.summary}; expected group ${group}` };
|
||
});
|
||
}
|
||
|
||
/**
|
||
* THE FORGED CONTACT — the attack decision_2026-10-29 names, run the way an attacker would: every act
|
||
* is Carol's own, from her own outbox, under her own session. (1) Carol sends Bob a contact request —
|
||
* the platform then shares her profile with him, its documented effect on a request's target. (2) She
|
||
* posts a forged Accept of a contact Offer Bob NEVER sent: Bob's pod, now able to fetch her profile,
|
||
* adds her to his apods:contacts LIST with no prior request checked (platform defect, tracked as
|
||
* KNOWN-DEFECT). (3) Only once that forged state holds does Bob join Carol's meeting point through his
|
||
* app, and the hard lines run against it: the backend's notification to Carol stays ANONYMOUS (naming
|
||
* keyed off the list would name him); Carol is refused Bob's profile, so she cannot even obtain his
|
||
* participations collection, and is refused it when handed its URI out of band — both are protected by
|
||
* Bob's /contacts WAC GROUP, which a received Accept cannot put her in. Without the forged state, the
|
||
* lines that need it are SKIPPED with a loud notice — never a PASS on a state that never existed.
|
||
* Runs before the contact-changes group, while the pages are open.
|
||
*/
|
||
async function forgedContact(): Promise<void> {
|
||
const G = 'forged contact (a forged Accept puts the stranger in the LIST; the GROUP grants her nothing)';
|
||
let forged = false;
|
||
/** A hard line that proves something only under the forged state: SKIPPED, loudly, without it. */
|
||
const underForgedState = async (id: string, name: string, expected: string, check: () => Promise<CheckOutcome>): Promise<void> => {
|
||
if (forged) { await m.hard(id, G, name, expected, check); return; }
|
||
m.info(id, G, `SKIPPED — ${name}`, "not run: the forged state (Carol in Bob's apods:contacts) was not produced — see forged.list-add",
|
||
'a hard line was SKIPPED: it needs the forged contact state, which this run could not produce — it proves nothing until it runs');
|
||
};
|
||
|
||
await m.hard('forged.setup-carol-meeting-point', G, `Carol creates event CE "${TITLE_CE}" and its meeting point CM "${TITLE_CM}" through her app`, 'CE and CM in her pod, CM anchored to CE by as:context and carrying its apods:attendees collection', async () => {
|
||
const page = need(S.carolPage, "Carol's signed-in page");
|
||
const carol = need(S.carol, 'Carol');
|
||
const e = await createEventUi(page, APP, TITLE_CE);
|
||
S.CE = need(e.eventUri, `an event route after creating CE (landed on ${e.finalUrl})`);
|
||
const shown = await createMeetingPointUi(page, TITLE_CM);
|
||
S.CM = await findMeetingPoint(TITLE_CM, S.CE, carol);
|
||
S.ACM = S.CM ? await attachedCollection(S.CM, carol) : undefined;
|
||
return { ok: S.CE.startsWith(`${carol.storage}/`) && shown && !!S.CM && !!S.ACM, actual: `CE ${S.CE}; CM shown: ${shown}; CM ${S.CM ?? '(not found)'}; its list ${S.ACM ?? '(none)'}` };
|
||
});
|
||
|
||
await m.hard('forged.request-to-bob', G, "Carol sends Bob a contact request (Offer{Add{her Profile}}, her own outbox, addressed to him) — the platform shares her profile with the request's target", "2xx; Bob then reads Carol's profile (his session; its URI from her public actor document) within 30 s", async () => {
|
||
const carol = need(S.carol, 'Carol');
|
||
const bob = need(S.bob, 'Bob');
|
||
const carolProfile = need(await profileUriOf(carol.webId), "the profile Carol's public actor document links");
|
||
const r = await http('POST', carol.outbox, {
|
||
token: carol.token, contentType: 'application/ld+json',
|
||
body: JSON.stringify({
|
||
'@context': PLATFORM_CONTEXT, type: 'Offer', actor: carol.webId,
|
||
object: { type: 'Add', object: { id: carolProfile, type: 'Profile' } },
|
||
target: bob.webId, to: bob.webId,
|
||
}),
|
||
});
|
||
if (r.status < 200 || r.status >= 300) return { ok: false, actual: `Offer post → HTTP ${r.status}` };
|
||
const p = await poll(() => http('GET', carolProfile, { token: bob.token }), v => v.status === 200, 30000);
|
||
return { ok: p.status === 200, actual: `Offer HTTP ${r.status}; Bob reads Carol's profile: HTTP ${p.status}` };
|
||
});
|
||
|
||
await m.known('known-defect', 'forged.list-add', G,
|
||
"Carol posts — her own outbox, her own session — a forged Accept of a contact Offer Bob NEVER sent (Offer{actor: Bob, Add{his Profile}, target: Carol}, to Bob): Bob's pod adds her to his apods:contacts LIST (acceptContactRequest.onReceive checks no prior request; platform defect reported upstream)",
|
||
"tracked platform defect: Carol in Bob's apods:contacts within 30 s — the forged state the next lines run against; it should stop once the platform checks for a request Bob sent", async () => {
|
||
const carol = need(S.carol, 'Carol');
|
||
const bob = need(S.bob, 'Bob');
|
||
const bobProfile = need(await profileUriOf(bob.webId), "the profile Bob's public actor document links");
|
||
// The control: she is not in his list before the forged Accept (her request put her in his contact REQUESTS only).
|
||
if ((await ownContacts(bob)).includes(carol.webId)) {
|
||
return { ok: false, actual: "Carol is ALREADY in Bob's apods:contacts before any forged Accept", notice: 'the list named Carol before the forged Accept — the forged state cannot be attributed to it: every line that needs it is SKIPPED this run' };
|
||
}
|
||
const post = await http('POST', carol.outbox, {
|
||
token: carol.token, contentType: 'application/ld+json',
|
||
body: JSON.stringify({
|
||
'@context': PLATFORM_CONTEXT, type: 'Accept', actor: carol.webId,
|
||
object: { type: 'Offer', actor: bob.webId, object: { type: 'Add', object: { id: bobProfile, type: 'Profile' } }, target: carol.webId },
|
||
to: bob.webId,
|
||
}),
|
||
});
|
||
if (post.status < 200 || post.status >= 300) {
|
||
return { ok: false, actual: `the forged Accept was not accepted by her outbox (HTTP ${post.status})`, notice: 'the forged Accept could not be posted — the forged state was NOT produced: every line that needs it is SKIPPED this run' };
|
||
}
|
||
forged = await poll(async () => (await ownContacts(bob)).includes(carol.webId), v => v, 30000, 1500);
|
||
if (forged) return { ok: true, actual: `Accept POST ${post.status}; Carol absent from Bob's apods:contacts before it, IN it after (his reads) — the forged state holds` };
|
||
return { ok: true, actual: `Accept POST ${post.status}; Carol NOT in Bob's apods:contacts after 30 s (his read)`, notice: 'the forged Accept no longer adds its sender to the apods:contacts LIST — the platform may now check for a prior request (then PROMOTE this line to a hard "not added" assertion), or the attack path changed: the lines that need the forged state are SKIPPED this run' };
|
||
});
|
||
|
||
await underForgedState('forged.bob-joins', "with Carol in his apods:contacts LIST, Bob opens CE in his app and taps S'inscrire on CM — his member URN lands in CM's list", "his URN at CE (computed by him from his own secret) in CM's items within 30 s (anonymous read)", async () => {
|
||
const page = need(S.bobPage, "Bob's signed-in page");
|
||
const CM = need(S.CM, "Carol's meeting point CM");
|
||
// His app finds CM through the catalog, read when the page loads (see visibility.setup-join).
|
||
await poll(() => catalogIds(), c => c.ids.includes(CM), 30000);
|
||
await page.goto(`${APP}/events/${encodeURIComponent(need(S.CE, "Carol's event CE"))}`, { waitUntil: 'domcontentloaded' });
|
||
await mpCard(page, TITLE_CM).getByRole('button', { name: /^S'inscrire$/ }).waitFor({ timeout: 30000 }).catch(() => {});
|
||
const tapped = await tapMeetingPoint(page, TITLE_CM, /^S'inscrire$/, /✓ Inscrit/);
|
||
const urn = await ownMemberUrn(need(S.bob, 'Bob'), need(S.CE, 'CE'));
|
||
const r = await refIn(need(S.ACM, "CM's attendees collection"), urn, true);
|
||
return { ok: tapped.ok && r.ok, actual: `${tapped.actual}; HTTP ${r.status} items=${JSON.stringify(r.items)}` };
|
||
});
|
||
await underForgedState('forged.notify-anonymous', "Carol — CM's creator, in Bob's LIST by her forged Accept — is notified of his Join ANONYMOUSLY: naming never keys off that list", "a join notification about CM in Carol's inbox (her session) naming neither Bob's name nor his WebID, within 30 s", async () =>
|
||
notifiedLine(need(S.CM, 'CM'), 'join', false, { creator: need(S.carol, 'Carol'), participant: need(S.bob, 'Bob'), name: USERS.bob }));
|
||
await underForgedState('forged.profile-refused', "Carol, in Bob's LIST by her forged Accept, GETs Bob's profile (its URI from his public actor document) — the profile is shared through his own acts only", 'refused 403, his participation secret never exposed', async () => {
|
||
const r = await http('GET', need(await profileUriOf(need(S.bob, 'Bob').webId), "Bob's profile URI"), { token: need(S.carol, 'Carol').token });
|
||
return { ok: refused(r.status, AUTHN) && !r.text.includes('participationSecret'), actual: brief(r).slice(0, 80) };
|
||
});
|
||
await underForgedState('forged.participations-refused', "Carol cannot obtain Bob's participations collection (the profile that links it is refused to her) and, handed its URI out of band, is refused it — his /contacts GROUP grants read, and her forged Accept did not put her in it", "her read of his profile yields no link; the out-of-band GET refused (403, or the pod's 404 while Bob reads it 200)", async () => {
|
||
const bob = need(S.bob, 'Bob');
|
||
const carol = need(S.carol, 'Carol');
|
||
const asCarol = await http('GET', need(await profileUriOf(bob.webId), "Bob's profile URI"), { token: carol.token });
|
||
const obtained = asCarol.status === 200 ? participationsCollectionOf(parseJson(asCarol.text)) : undefined;
|
||
const uri = need(await poll(() => ownParticipations(bob), v => !!v, 30000), "Bob's participations collection (his own read — created at his Join on CM)");
|
||
const coll = await collectionRefusal(uri, carol.token, bob, AUTHN);
|
||
return { ok: refused(asCarol.status, AUTHN) && !obtained && coll.ok, actual: `his profile as Carol: HTTP ${asCarol.status} → link ${obtained ?? 'none'}; out of band: ${coll.actual}` };
|
||
});
|
||
}
|
||
|
||
async function deletesRefused(): Promise<void> {
|
||
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<void> {
|
||
const G = 'tracked platform behaviours (non-fatal)';
|
||
await m.known('known-deviation', 'deviation.anon-attendees-read', G,
|
||
"anonymous GET M's attendees (decision_2026-10-29: from an event, anyone counts the participations — the world-readable attendee list IS the model; its members are member URNs, so the world reads a count and opaque values, never identities)",
|
||
'model: world-readable; the members stay member URNs and the count public', 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 member URNs — values in no pod, identity not anonymously resolvable)` };
|
||
if (refused(r.status, ANON)) return { ok: true, actual: `HTTP ${r.status} — refused`, notice: 'the attendee list is no longer anonymously readable: a regression under the decided mechanism (decision_2026-10-29_participation-privacy-mechanism keeps the list world-readable, holding member URNs) — investigate before trusting this line' };
|
||
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 () => {
|
||
// HARMLESS BY CONSTRUCTION: 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}> <http://festipod.org/ns/test#anonymousPatchTripwire> "${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<void> {
|
||
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<number> => (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,}$/, '/<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)'),
|
||
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/<id> is never trusted',
|
||
'NOT exercised live yet (the sweeps are read-only). 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);
|
||
}
|
||
|
||
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}" through the app`, 'V and VM in his pod, VM anchored to V by as:context', async () => {
|
||
const page = need(S.bobPage, "Bob's signed-in page");
|
||
const v = await createEventUi(page, APP, TITLE_V);
|
||
S.V = v.eventUri;
|
||
need(S.V, `an event route after creating V (landed on ${v.finalUrl})`);
|
||
const shown = await createMeetingPointUi(page, TITLE_VM);
|
||
S.VM = await findMeetingPoint(TITLE_VM, need(S.V, 'event V'));
|
||
return { ok: !!S.V && shown && !!S.VM, actual: `V: ${S.V ?? '(none)'}; VM shown: ${shown}; VM: ${S.VM ?? '(not found)'}` };
|
||
});
|
||
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, "Alice opens V in her app and taps S'inscrire on VM — her member URN at V lands in VM's list", "her URN at V (computed by her) in VM's items within 30 s (anonymous read)", async () => {
|
||
const page = need(S.alicePage, "Alice's page");
|
||
// Her app finds VM through the catalog, read when the page loads: open V only once the catalog lists VM
|
||
// (the backend indexes it a moment after Bob's app shows it — a page loaded before that never shows its card).
|
||
const VM = need(S.VM, 'meeting point VM');
|
||
await poll(() => catalogIds(), c => c.ids.includes(VM), 30000);
|
||
await page.goto(`${APP}/events/${encodeURIComponent(need(S.V, 'event V'))}`, { waitUntil: 'domcontentloaded' });
|
||
await mpCard(page, TITLE_VM).getByRole('button', { name: /^S'inscrire$/ }).waitFor({ timeout: 30000 }).catch(() => {});
|
||
const tapped = await tapMeetingPoint(page, TITLE_VM, /^S'inscrire$/, /✓ Inscrit/);
|
||
const urn = await ownMemberUrn(need(S.alice, 'Alice'), need(S.V, 'event V'));
|
||
const r = await refIn(need(S.AVM, "VM's attendees collection"), urn, true);
|
||
if (r.ok) S.joined.VM = true;
|
||
return { ok: tapped.ok && r.ok, actual: `${tapped.actual}; HTTP ${r.status} items=${JSON.stringify(r.items)}` };
|
||
});
|
||
await m.hard('visibility.urn-per-event', G, "ANOTHER event, ANOTHER URN: Alice's URN at V differs from her URN at E — nothing links her two participations publicly", "her URN at V ≠ her URN at E; VM's list ∌ her URN at E", async () => {
|
||
need(S.joined.VM, 'the join on VM landed (visibility.setup-join)');
|
||
const alice = need(S.alice, 'Alice');
|
||
const atV = await ownMemberUrn(alice, need(S.V, 'V'));
|
||
const atE = await ownMemberUrn(alice, need(S.E, 'E'));
|
||
const items = (await attendees(need(S.AVM, "VM's attendees collection"))).items;
|
||
return { ok: atV !== atE && !items.includes(atE), actual: `at V ${atV.slice(-16)}, at E ${atE.slice(-16)}; VM's list holds her E URN: ${items.includes(atE)}` };
|
||
});
|
||
await m.hard('visibility.participations', G, 'her participations collection lists VM — and no longer M or M2 (her read)', 'items = [VM] within 30 s', async () => {
|
||
const alice = need(S.alice, 'Alice');
|
||
const r = await refIn(need(await ownParticipations(alice), 'her participations collection'), need(S.VM, 'VM'), true, alice.token);
|
||
return { ok: r.ok && r.items.length === 1, actual: `HTTP ${r.status} items=${JSON.stringify(r.items)}` };
|
||
});
|
||
await m.hard('visibility.notify-anonymous', G, 'Bob — even her contact — is notified of this Join ANONYMOUSLY (every notification is anonymous)', `a join notification about VM naming neither her name nor her WebID, within 30 s`, async () => {
|
||
need(S.contacts, 'the contact pair (contact.accepted)');
|
||
return notifiedLine(need(S.VM, 'VM'), 'join', false);
|
||
});
|
||
// THE CORRELATION LINE: the participant's public outbox listing and the
|
||
// attendee list must share NO string — a stranger crawling both finds nothing to join.
|
||
await m.hard('visibility.correlation-surfaces-disjoint', G,
|
||
"Alice's public surfaces are uncorrelatable: her ANONYMOUS outbox listing names neither VM nor V nor any member of VM's list",
|
||
'no listed activity names VM, V or a URN of the list', async () => {
|
||
const alice = need(S.alice, 'Alice');
|
||
const listItems = (await attendees(need(S.AVM, "VM's attendees collection"))).items;
|
||
const listed = await collectionItems(alice.outbox);
|
||
const text = JSON.stringify(listed.items);
|
||
const leaks = [need(S.VM, 'VM'), need(S.V, 'V'), ...listItems].filter(s => text.includes(s));
|
||
return { ok: listed.status === 200 && leaks.length === 0, actual: `outbox HTTP ${listed.status}, ${listed.items.length} activit(ies) listed anonymously; shared strings: ${JSON.stringify(leaks)}` };
|
||
});
|
||
|
||
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}: Alice is still a member of VM (only the public read moved)`, "her URN at V (computed by her) in VM's items (read by Bob, 200)", async () => {
|
||
need(S.joined.VM, 'the join on VM landed (visibility.setup-join)');
|
||
const urn = await ownMemberUrn(need(S.alice, 'Alice'), need(S.V, 'V'));
|
||
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(urn), 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 yet. 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
|
||
// 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<string, number>();
|
||
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'})`);
|
||
}
|
||
|
||
/** User decision 2026-10-26: the suite must not accumulate pod datasets across runs.
|
||
* Best-effort deletion of the run's three accounts; the platform tombstones the
|
||
* account and drops its dataset on a delay. Failures are reported, never thrown. */
|
||
async function cleanupAccounts(): Promise<void> {
|
||
const G = 'cleanup';
|
||
const accounts: [string, string, ApodsSession | undefined][] = [
|
||
['bob', USERS.bob, S.bob],
|
||
['alice', USERS.alice, S.alice],
|
||
['carol', USERS.carol, S.carol],
|
||
];
|
||
for (const [who, username, session] of accounts) {
|
||
if (!session) { m.info(`cleanup.skip-${who}`, G, `${username} not deleted`, 'no session (setup did not reach it)'); continue; }
|
||
const r = await http('DELETE', `${config.podProviderUrl}/.account/${username}`, { token: session.token, timeoutMs: 60000 });
|
||
const ok = r.status >= 200 && r.status < 300;
|
||
m.info(`cleanup.${ok ? 'deleted' : 'failed'}-${who}`, G,
|
||
`${username} ${ok ? 'deleted' : 'deletion refused'}`,
|
||
ok ? `HTTP ${r.status} — the platform drops the dataset on a delay` : brief(r));
|
||
}
|
||
}
|
||
|
||
(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 contactChannel();
|
||
await afterContact();
|
||
await leaveAfterContact();
|
||
await directSignup();
|
||
await visibility();
|
||
await forgedContact();
|
||
await contactChanges();
|
||
} finally {
|
||
await uiHygiene();
|
||
await browser.close().catch(() => {});
|
||
await cleanupAccounts();
|
||
}
|
||
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, CE: S.CE, CM: S.CM, ACM: S.ACM, aliceJoinM: S.aliceJoinM }, lines: m.lines, hops: ui.hops, aliceTraffic: S.aliceTraffic.map(({ token: _t, ...r }) => r), 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);
|
||
});
|