Keep sign-ups unlinkable on ActivityPods, and land the pending coherence-pass work
Participation privacy (decision_2026-10-29_participation-privacy-mechanism): a sign-up is a private Join naming the meeting point, addressed to nobody, handled by the backend on emission. Attendee lists hold member URNs hashed from (WebID, event, a secret kept in the participant's profile), so counts stay public and only contacts recognise anyone; the creator has no read right and is notified anonymously. Each person's participations collection, created by the backend at the first sign-up, is readable by their /contacts WAC group only, never world-readable. The member URN is sticky per (meeting point, participant) and every collection write is verified by reading back, so a refused write leaves the act unrecorded and retryable. The EventRef mechanism and its browser-side provisioning are gone. The backend now runs with WebACL on, as the reference apps do, on its own secure datasets festipod / settings-festipod instead of the pod provider's settings dataset; pod-notifications needs it. The compose header and the deployment leaf say how to reset a stack whose datasets predate this. The rights matrix covers the new mechanism live (148 hard lines), including a forged contact Accept between throwaway accounts: the stranger enters the contacts list (platform defect, reported upstream) but is refused the profile and the participations. Tests may attack as long as they never break the dev environment (rule_tests-never-break-the-dev-environment). Also lands the coherence-pass work that was still uncommitted: contacts as the Pod's social graph and invite links, image upload to the Pod, the meeting-point form, profile completion gate, and the doctrine for them. Superseded briefs and decisions are deleted rather than annotated.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* DIAGNOSTIC (not a gate): reproduce the invite-link contact flow end to end
|
||||
* with two FRESH accounts and inspect what lands in each pod's apods:contacts —
|
||||
* then run the app's own fetchCollectionMemberIds against the real collections.
|
||||
*
|
||||
* Usage: bun scripts/diag-invite.ts
|
||||
*/
|
||||
import {
|
||||
loginWithRetry,
|
||||
http,
|
||||
poll,
|
||||
parseJson,
|
||||
itemUris,
|
||||
need,
|
||||
sleep,
|
||||
} from './e2e-rights-matrix-support';
|
||||
import type { ApodsSession } from '../src/shared/support/activitypodsStack';
|
||||
// The app's real reader — tested here against a REAL collection document.
|
||||
import { fetchCollectionMemberIds } from '../src/shared/data/contacts';
|
||||
|
||||
const TS = Date.now();
|
||||
const PASS = 'Test1test';
|
||||
const POD = 'http://localhost:3000';
|
||||
const A_NAME = `diaginv${TS}a`;
|
||||
const B_NAME = `diaginv${TS}b`;
|
||||
const config = { podProviderUrl: POD, backendUrl: process.env.BACKEND_URL || 'http://localhost:3003' };
|
||||
|
||||
const CRED_CTX = [
|
||||
'https://www.w3.org/ns/credentials/v2',
|
||||
{ as: 'https://www.w3.org/ns/activitystreams#', apods: 'http://activitypods.org/ns/core#', acl: 'http://www.w3.org/ns/auth/acl#' },
|
||||
];
|
||||
|
||||
async function actorDoc(session: ApodsSession): Promise<Record<string, any>> {
|
||||
const r = await http('GET', session.webId, { token: session.token });
|
||||
return need(parseJson(r.text), `actor doc for ${session.webId}`);
|
||||
}
|
||||
const linkOf = (v: any): string | undefined =>
|
||||
typeof v === 'string' ? v : v?.id ?? v?.['@id'];
|
||||
|
||||
async function main() {
|
||||
console.log('1) Signup of two fresh accounts (browser-driven, slow)...');
|
||||
const a = await loginWithRetry(config, A_NAME, PASS);
|
||||
const b = await loginWithRetry(config, B_NAME, PASS);
|
||||
console.log(' A (inviter):', a.webId);
|
||||
console.log(' B (invitee):', b.webId);
|
||||
|
||||
const aDoc = await actorDoc(a);
|
||||
const bDoc = await actorDoc(b);
|
||||
const aProfile = need(linkOf(aDoc.url), 'A profile URI');
|
||||
const bProfile = need(linkOf(bDoc.url), 'B profile URI');
|
||||
const bOutbox = need(linkOf(bDoc.outbox), 'B outbox');
|
||||
|
||||
console.log('2) A creates the invite capability...');
|
||||
const issue = async () =>
|
||||
http('POST', `${a.webId}/vc/v0.3/credentials/issue`, {
|
||||
token: a.token,
|
||||
contentType: 'application/json',
|
||||
accept: 'application/ld+json',
|
||||
timeoutMs: 30000,
|
||||
body: JSON.stringify({
|
||||
credential: {
|
||||
'@context': CRED_CTX,
|
||||
type: 'VerifiableCredential',
|
||||
name: 'Invite Link',
|
||||
credentialSubject: {
|
||||
'apods:hasActivityGrant': {
|
||||
type: 'as:Offer',
|
||||
'as:to': { '@id': a.webId },
|
||||
'as:target': { '@id': a.webId },
|
||||
'as:object': { type: 'as:Add', 'as:object': { type: 'as:Profile' } },
|
||||
},
|
||||
'apods:hasAuthorization': {
|
||||
type: 'acl:Authorization',
|
||||
'acl:mode': 'acl:Read',
|
||||
'acl:accessTo': { '@id': aProfile },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
let capRes = await issue();
|
||||
for (let attempt = 0; capRes.status === 0 && attempt < 10; attempt++) {
|
||||
console.log(' VC issue unreachable (', capRes.error ?? 'timeout', ') — retrying in 3 s...');
|
||||
await sleep(3000);
|
||||
capRes = await issue();
|
||||
}
|
||||
const capJson = parseJson(capRes.text);
|
||||
const capUri = typeof capJson?.id === 'string' ? capJson.id : undefined;
|
||||
console.log(' VC issue:', capRes.status, capUri ?? `${capRes.error ?? ''} ${capRes.text.slice(0, 160)}`);
|
||||
if (!capUri) throw new Error('capability creation failed');
|
||||
|
||||
console.log('3) B reads the capability (proof the grant works)...');
|
||||
const capDoc = await http('GET', capUri, { token: b.token, accept: 'application/ld+json' });
|
||||
console.log(' capability fetch:', capDoc.status, capDoc.text.slice(0, 120).replace(/\s+/g, ' '));
|
||||
|
||||
console.log('4) B builds the presentation (challenge from A, sign at B)...');
|
||||
const challengeRes = await http('POST', `${a.webId}/vc/v0.3/challenges`, { token: b.token, accept: 'application/ld+json' });
|
||||
const challenge = need(parseJson(challengeRes.text)?.challenge, 'challenge');
|
||||
const presRes = await http('POST', `${b.webId}/vc/v0.3/presentations/`, {
|
||||
token: b.token,
|
||||
contentType: 'application/json',
|
||||
accept: 'application/ld+json',
|
||||
body: JSON.stringify({
|
||||
presentation: {
|
||||
'@context': CRED_CTX,
|
||||
type: 'VerifiablePresentation',
|
||||
verifiableCredential: [need(parseJson(capDoc.text), 'VC doc')],
|
||||
},
|
||||
options: { challenge, persist: false },
|
||||
}),
|
||||
});
|
||||
const presentation = parseJson(presRes.text);
|
||||
console.log(' presentation:', presRes.status, presentation ? 'ok' : presRes.text.slice(0, 160));
|
||||
|
||||
console.log('5) B posts Offer{Add{profile}} + capability to B\'s outbox...');
|
||||
const offer = await http('POST', bOutbox, {
|
||||
token: b.token,
|
||||
contentType: 'application/ld+json',
|
||||
accept: 'application/ld+json',
|
||||
body: JSON.stringify({
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Offer',
|
||||
actor: b.webId,
|
||||
object: { type: 'Add', object: { id: bProfile, type: 'Profile' } },
|
||||
target: a.webId,
|
||||
to: a.webId,
|
||||
capability: presentation,
|
||||
}),
|
||||
});
|
||||
console.log(' outbox post:', offer.status, offer.text.slice(0, 120).replace(/\s+/g, ' '));
|
||||
|
||||
console.log('6) Polling the apods:contacts collections (up to 60 s)...');
|
||||
const contactsUriOf = (doc: Record<string, any>) => linkOf(doc['apods:contacts']);
|
||||
const aContacts = need(contactsUriOf(aDoc), 'A contacts URI');
|
||||
const bContacts = need(contactsUriOf(bDoc), 'B contacts URI');
|
||||
const readItems = async (uri: string, token: string) =>
|
||||
itemUris(parseJson((await http('GET', uri, { token })).text));
|
||||
|
||||
const aPoll = await poll(() => readItems(aContacts, a.token), v => v.length > 0, 60000, 2000);
|
||||
await sleep(3000);
|
||||
const bPoll = await readItems(bContacts, b.token);
|
||||
console.log(' A contacts items:', JSON.stringify(aPoll));
|
||||
console.log(' B contacts items:', JSON.stringify(bPoll));
|
||||
|
||||
console.log('7) The APP reader against the REAL collection documents...');
|
||||
const aDocRaw = (await http('GET', aContacts, { token: a.token })).text;
|
||||
const bDocRaw = (await http('GET', bContacts, { token: b.token })).text;
|
||||
console.log(' A collection doc (first 400):', aDocRaw.replace(/\s+/g, ' ').slice(0, 400));
|
||||
console.log(' B collection doc (first 400):', bDocRaw.replace(/\s+/g, ' ').slice(0, 400));
|
||||
const appReadsA = await fetchCollectionMemberIds(aContacts, a.token, a.webId);
|
||||
const appReadsB = await fetchCollectionMemberIds(bContacts, b.token, b.webId);
|
||||
console.log(' app fetchCollectionMemberIds(A):', JSON.stringify(appReadsA));
|
||||
console.log(' app fetchCollectionMemberIds(B):', JSON.stringify(appReadsB));
|
||||
|
||||
const ok = appReadsA.includes(b.webId) && appReadsB.includes(a.webId);
|
||||
console.log(ok ? 'VERDICT: platform flow + app reader BOTH OK' : 'VERDICT: MISMATCH — see above');
|
||||
process.exit(ok ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('DIAG FAILED:', e); process.exit(2); });
|
||||
@@ -99,6 +99,9 @@ async function createMeetingPoint(page: Page): Promise<boolean> {
|
||||
await propose.first().click().catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
await page.locator('input[placeholder="Ex : Gouvernance coopérative — retours SCIC"]').fill('Rdv flow').catch(() => {});
|
||||
// The place is the form's mandatory field (title optional) — without it the
|
||||
// submit bails on the client and nothing is created.
|
||||
await page.locator('input[placeholder="Café en face du tiers-lieu"]').fill('Devant la gare').catch(() => {});
|
||||
await page.getByRole('button', { name: /Créer le point de rencontre/ }).click().catch(() => {});
|
||||
await page.waitForTimeout(5000);
|
||||
const txt = await bodyText(page);
|
||||
@@ -136,11 +139,18 @@ function classify(e: HttpError): string | null {
|
||||
await browser.close();
|
||||
await new Promise(r => setTimeout(r, 1200));
|
||||
|
||||
// User decision 2026-10-26: the suite must not accumulate pod datasets. The
|
||||
// platform tombstones the account and drops its dataset on a delay.
|
||||
const accountDeleted = await fetch(`${config.podProviderUrl}/.account/${USER}`, {
|
||||
method: 'DELETE', headers: { Authorization: `Bearer ${session.token}` }, signal: AbortSignal.timeout(60000),
|
||||
}).then(r => r.status).catch(() => 0);
|
||||
|
||||
const fatal = errors4xx.filter(e => classify(e) === null);
|
||||
const benign = errors4xx.map(e => ({ e, label: classify(e) })).filter(x => x.label).map(x => ({ status: x.e.status, url: x.e.url, label: x.label }));
|
||||
const report = {
|
||||
user: USER,
|
||||
authenticated,
|
||||
accountDeleted,
|
||||
login: login.detail,
|
||||
installed: install.actual,
|
||||
eventCreated,
|
||||
|
||||
@@ -325,6 +325,12 @@ function benign(e: HttpError): boolean {
|
||||
await browser.close();
|
||||
await sleep(1000);
|
||||
|
||||
// User decision 2026-10-26: the suite must not accumulate pod datasets. The
|
||||
// platform tombstones the account and drops its dataset on a delay.
|
||||
const accountDeleted = await fetch(`${config.podProviderUrl}/.account/${USER}`, {
|
||||
method: 'DELETE', headers: { Authorization: `Bearer ${user.token}` }, signal: AbortSignal.timeout(60000),
|
||||
}).then(r => r.status).catch(() => 0);
|
||||
|
||||
// The deferred probe lands at the end of the minute: never before (throttle),
|
||||
// and not much after (it was deferred, not dropped). Measured between the
|
||||
// app's own log lines, so a few hundred ms of request latency either way.
|
||||
@@ -374,6 +380,7 @@ function benign(e: HttpError): boolean {
|
||||
const report = {
|
||||
user: USER,
|
||||
appUri,
|
||||
accountDeleted,
|
||||
positive,
|
||||
negative,
|
||||
recovered,
|
||||
|
||||
@@ -104,9 +104,8 @@ export class Matrix {
|
||||
}
|
||||
|
||||
/**
|
||||
* A MANUAL-CHECK line: an assertion this script deliberately does NOT exercise
|
||||
* live (it would send a forged or spoofed activity — user decision 2026-10-13,
|
||||
* defensive only). Printed with where it is covered; never affects the exit code.
|
||||
* A MANUAL-CHECK line: an assertion this script does not exercise live yet.
|
||||
* Printed with where it is covered; never affects the exit code.
|
||||
*/
|
||||
manual(id: string, group: string, name: string, coverage: string): void {
|
||||
this.push({ id, group, name, kind: 'manual', expected: '(not exercised live)', actual: coverage, status: 'MANUAL-CHECK' });
|
||||
@@ -262,7 +261,7 @@ export async function installApp(config: ApodsStackConfig, session: ApodsSession
|
||||
if (reg.status < 200 || reg.status >= 300) return { ok: false, actual: `register → ${brief(reg)}` };
|
||||
const container = await poll(
|
||||
() => http('GET', `${session.storage}/as/event`, { token: session.token }),
|
||||
r => r.status === 200, 30000, 500,
|
||||
r => r.status === 200, 90000, 500,
|
||||
);
|
||||
return { ok: container.status === 200, actual: `register → HTTP ${reg.status}; ${session.storage}/as/event → ${brief(container).slice(0, 40)}` };
|
||||
}
|
||||
@@ -516,6 +515,9 @@ export async function createMeetingPointUi(page: Page, title: string): Promise<b
|
||||
await propose.first().click().catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
await page.locator('input[placeholder="Ex : Gouvernance coopérative — retours SCIC"]').fill(title, { timeout: 10000 }).catch(() => {});
|
||||
// The form REQUIRES a place since the title became optional (the pod has no
|
||||
// slot for a free-text "when"; the place is the one mandatory fact).
|
||||
await page.locator('input[placeholder="Café en face du tiers-lieu"]').fill('Devant la mairie', { timeout: 10000 }).catch(() => {});
|
||||
await page.getByRole('button', { name: /Créer le point de rencontre/ }).click({ timeout: 5000 }).catch(() => {});
|
||||
const txt = await poll(() => bodyText(page), t => t.includes(title), 15000, 500);
|
||||
return txt.includes(title) && !/pas pu/i.test(txt);
|
||||
|
||||
+1024
-160
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user