1952 lines
138 KiB
TypeScript
1952 lines
138 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)}` };
|
||
});
|
||
}
|
||
|
||
|
||
/**
|
||
* A direct sign-up to an event whose declarer is NOT the participant's contact
|
||
* (FP-001, R6/R7): Alice signs up to Carol's event CE, Carol being no contact of
|
||
* hers. The declarer is told, anonymously, and gains no right to read the act, the
|
||
* list or the participations. Runs once CE exists (forged.setup-carol-meeting-point).
|
||
*/
|
||
async function directSignupToDeclared(): Promise<void> {
|
||
const G = "direct sign-up to CE (Alice; Carol declared CE and is not Alice's contact)";
|
||
const alice = (): ApodsSession => need(S.alice, 'Alice');
|
||
const carol = (): ApodsSession => need(S.carol, 'Carol');
|
||
const CE = (): string => need(S.CE, "Carol's event CE");
|
||
const noticed = (change: 'join' | 'leave'): Promise<CheckOutcome> =>
|
||
notifiedLine(CE(), change, false, { creator: carol(), participant: alice(), name: USERS.alice });
|
||
await m.hard('direct.notify-anonymous', G, 'Alice posts a Join naming CE: Carol, its declarer, is notified in her own inbox, naming neither her name nor her WebID', 'a join notification about CE naming neither the display name nor the WebID, within 30 s', async () => {
|
||
await postOutboxActivity(alice(), 'Join', CE(), appActorUri(config));
|
||
return noticed('join');
|
||
});
|
||
await m.hard('direct.activity-declarer-refused', G, "Carol — CE's declarer, not her contact — GETs Alice's Join naming CE (its URI handed out of band)", 'refused 403', async () => {
|
||
const act = need(await poll(() => ownActivity(alice(), 'Join', CE()), v => !!v, 15000), "Alice's Join naming CE");
|
||
const r = await http('GET', need(recordUri(act), "the Join's URI"), { token: carol().token });
|
||
return { ok: refused(r.status, AUTHN), actual: brief(r).slice(0, 80) };
|
||
});
|
||
await m.hard('direct.list-opaque', G, "CE's own attendee list (anonymous read) holds Alice's member URN, computed from her secret with CE as anchor, and no WebID", 'her URN ∈ items, her WebID ∉ items', async () => {
|
||
const list = need(await attachedCollection(CE(), carol()), "CE's attendees collection");
|
||
const urn = await ownMemberUrn(alice(), CE());
|
||
const r = await refIn(list, urn, true);
|
||
return { ok: r.ok && !r.items.includes(alice().webId), actual: `HTTP ${r.status} items=${JSON.stringify(r.items)}` };
|
||
});
|
||
await m.hard('direct.declarer-refused-participations', G, "Carol, the declarer and no contact of Alice, is refused Alice's participations collection although it lists CE (URI handed out of band)", 'refused (403, or the pod\'s 404 while Alice reads it 200)', async () => {
|
||
const uri = need(await ownParticipations(alice()), 'her participations collection');
|
||
const listed = await refIn(uri, CE(), true, alice().token);
|
||
need(listed.ok, 'the direct sign-up landed (direct.notify-anonymous)');
|
||
const refusal = await collectionRefusal(uri, carol().token, alice(), AUTHN);
|
||
return { ok: refusal.ok, actual: refusal.actual };
|
||
});
|
||
await m.hard('direct.leave-notify-anonymous', G, 'Alice posts a Leave naming CE (the group leaves the event as it found it): Carol is notified of the withdrawal, anonymously', 'a leave notification about CE naming neither the display name nor the WebID, within 30 s', async () => {
|
||
await postOutboxActivity(alice(), 'Leave', CE(), appActorUri(config));
|
||
return noticed('leave');
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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 directSignupToDeclared();
|
||
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);
|
||
});
|