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:
Sylvain Duchesne
2026-10-30 17:06:38 +01:00
parent f90651eb9a
commit 908f7ea973
138 changed files with 8347 additions and 3679 deletions
@@ -0,0 +1,155 @@
const { test, expect, describe } = require('bun:test');
const participations = require('./participations.service');
const { hasOwnPublicRead, ownReaders } = require('./public-read-acl');
const { world, profile, profileOf, contactsOf, groupOf, createdCollectionAcl, APP, ALICE, BOB, MALLORY, FP_PARTICIPATIONS } = require('./participation-world');
const ensure = (w, extra = {}) => w.participations.actions.ensure({ actorUri: BOB, profileUri: profileOf(BOB), contactsUri: contactsOf(BOB), ...extra });
const collectionOf = w => w.participations.actions.collectionOf({ actorUri: BOB, profileUri: profileOf(BOB) });
const EXISTING = `${BOB}/data/as/collection/participations-existing`;
const linked = uri => ({ [profileOf(BOB)]: profile(BOB, { [FP_PARTICIPATIONS]: { id: uri } }) });
const aclId = (uri, rule) => `http://localhost:3000/_acl${new URL(uri).pathname}#${rule}`;
const appControl = uri => ({ '@id': aclId(uri, 'Control'), 'acl:mode': 'acl:Control', 'acl:agent': APP });
/** The /contacts WAC group that may read Bob's participations collection (what the grant names). */
const GROUP = groupOf(BOB);
/** A collection readable by Bob's /contacts group, the app keeping its Control. */
const groupRead = uri => ({ '@id': aclId(uri, 'Read'), 'acl:mode': 'acl:Read', 'acl:agentGroup': GROUP });
const protectedAcl = uri => [groupRead(uri), appControl(uri)];
/** The collection created at the first sign-up (the world names the first one `participations-1`). */
const CREATED = `${BOB}/data/as/collection/participations-1`;
/** Who the collection's own #Read rule lets read, plus every inherited rule's readers. */
const readersOf = (w, uri) => ownReaders(w.state.acls[uri]);
const writes = w => w.calls.filter(c => ['pod-collections.createAndAttach', 'pod-permissions.add', 'signature.proxy.query'].includes(c.action));
describe('participations — the collection is attached at the first sign-up, never on a profile Create', () => {
test('no activity handler, the full-IRI attach predicate, unordered, no mirrored group', () => {
expect(participations.activities).toEqual({});
expect(participations.settings.attachPredicate).toBe('http://festipod.org/participations');
expect(participations.settings.collectionOptions.ordered).toBe(false);
expect(participations.settings.createWacGroup).toBe(false);
});
});
describe('participations.collectionOf — the collection the profile links now', () => {
test('none, one, one outside the pod, several, an unreadable profile', async () => {
expect(await collectionOf(world())).toBeUndefined();
expect(await collectionOf(world({ stored: linked(EXISTING) }))).toBe(EXISTING);
expect(await collectionOf(world({ stored: { [profileOf(BOB)]: profile(BOB, { [FP_PARTICIPATIONS]: EXISTING }) } }))).toBe(EXISTING); // a plain IRI value
await expect(collectionOf(world({ stored: linked(`${MALLORY}/data/as/collection/x`) }))).rejects.toThrow('outside the pod');
await expect(collectionOf(world({ stored: { [profileOf(BOB)]: profile(BOB, { [FP_PARTICIPATIONS]: [{ id: EXISTING }, { id: `${EXISTING}-2` }] }) } }))).rejects.toThrow('several');
await expect(collectionOf(world({ stored: { [profileOf(BOB)]: undefined } }))).rejects.toThrow('does not return the profile');
});
test('a profile outside the participant\'s pod is never read', async () => {
const w = world();
await expect(w.participations.actions.collectionOf({ actorUri: BOB, profileUri: profileOf(MALLORY) })).rejects.toThrow('not in the pod');
expect(w.calls.some(c => c.action === 'pod-resources.get')).toBe(false);
});
});
describe('participations.ensure — a collection readable by the participant\'s /contacts group', () => {
test('no collection: one is created and attached, the /contacts group granted read, the pod\'s public read removed', async () => {
const w = world();
const uri = await ensure(w);
expect(uri).toBe(CREATED);
expect(w.state.created).toEqual([uri]);
expect(w.state.stored[profileOf(BOB)][FP_PARTICIPATIONS]).toEqual({ id: uri });
const attach = w.calls.find(c => c.action === 'pod-collections.createAndAttach');
expect(attach.params).toMatchObject({ resourceUri: profileOf(BOB), attachPredicate: FP_PARTICIPATIONS, actorUri: BOB });
const grants = w.calls.filter(c => c.action === 'pod-permissions.add').map(c => c.params);
expect(grants).toEqual([{ uri, agentPredicate: 'acl:agentGroup', agentUri: GROUP, mode: 'acl:Read', actorUri: BOB }]);
expect(hasOwnPublicRead(w.state.acls[uri])).toBe(false);
expect(readersOf(w, uri)).toEqual({ agent: [], agentClass: [], agentGroup: [GROUP] });
// The group URI is DERIVED from her apods:contacts collection; the list itself is never read.
expect(w.calls.find(c => c.action === 'pod-wac-groups.getUriFromCollectionUri').params).toEqual({ collectionUri: contactsOf(BOB) });
expect(w.calls.some(c => c.action === 'pod-collections.getItems')).toBe(false);
});
test('the public read goes FIRST (one PUT, the app keeping its Control), then the group grant', async () => {
const w = world();
const uri = await ensure(w);
const order = writes(w).map(c => c.action);
expect(order).toEqual(['pod-collections.createAndAttach', 'signature.proxy.query', 'pod-permissions.add']);
const put = w.calls.find(c => c.action === 'signature.proxy.query');
expect(put.params.url).toBe(`http://localhost:3000/_acl${new URL(uri).pathname}`);
expect(put.params.actorUri).toBe(APP);
const graph = JSON.parse(put.params.body)['@graph'];
expect(JSON.stringify(graph)).not.toContain('foaf:Agent');
expect(graph.some(n => n['@id'].endsWith('#Control') && n['acl:agent'] === APP)).toBe(true);
});
test('no contact yet: the group is granted all the same — its membership is the pod\'s business', async () => {
const w = world();
const uri = await ensure(w);
expect(readersOf(w, uri)).toEqual({ agent: [], agentClass: [], agentGroup: [GROUP] });
// No per-contact acl:agent grant is ever made; the group carries everyone.
expect(w.calls.every(c => c.action !== 'pod-permissions.add' || c.params.agentPredicate === 'acl:agentGroup')).toBe(true);
});
test('idempotent: on a collection already readable by the group, the ACL reads, nothing written', async () => {
const w = world({ stored: linked(EXISTING), acls: { [EXISTING]: protectedAcl(EXISTING) } });
expect(await ensure(w)).toBe(EXISTING);
expect(await ensure(w)).toBe(EXISTING);
expect(writes(w)).toEqual([]);
expect(w.calls.filter(c => c.action === 'pod-permissions.fetch')).toHaveLength(2);
});
test('an existing collection still world-readable: reused, the public read removed, the group granted', async () => {
const w = world({ stored: linked(EXISTING), acls: { [EXISTING]: createdCollectionAcl(EXISTING) } });
expect(await ensure(w)).toBe(EXISTING);
expect(w.state.created).toEqual([]);
expect(hasOwnPublicRead(w.state.acls[EXISTING])).toBe(false);
expect(readersOf(w, EXISTING)).toEqual({ agent: [], agentClass: [], agentGroup: [GROUP] });
});
test('an existing collection granting the group but still world-readable: the public read goes, the grant is not repeated', async () => {
const bothOnRead = { '@id': aclId(EXISTING, 'Read'), 'acl:mode': 'acl:Read', 'acl:agentGroup': GROUP, 'acl:agentClass': 'foaf:Agent' };
const w = world({ stored: linked(EXISTING), acls: { [EXISTING]: [bothOnRead, appControl(EXISTING)] } });
await ensure(w);
expect(hasOwnPublicRead(w.state.acls[EXISTING])).toBe(false);
expect(readersOf(w, EXISTING)).toEqual({ agent: [], agentClass: [], agentGroup: [GROUP] });
expect(writes(w).map(c => c.action)).toEqual(['signature.proxy.query']); // the PUT only, no repeated grant
});
test('one creation per participant: two concurrent calls attach ONE collection', async () => {
const w = world({ delays: { create: 30 } });
const [a, b] = await Promise.all([ensure(w), ensure(w)]);
expect(a).toBe(b);
expect(w.state.created).toHaveLength(1);
});
test('nothing attached (the profile gained a link meanwhile): the linked collection is used', async () => {
const w = world({ stored: linked(EXISTING), acls: { [EXISTING]: protectedAcl(EXISTING) } });
// collectionOf saw no link at first: simulate by an attach that finds one.
const original = w.participations.collectionOf;
let first = true;
w.participations.collectionOf = async (...args) => (first ? ((first = false), undefined) : original(...args));
expect(await ensure(w)).toBe(EXISTING);
expect(w.state.created).toEqual([]);
});
test('a collection the pod creates but does not link (the profile PATCH refused): ensure throws, the collection is never used', async () => {
const w = world({ fail: { attach: true } });
await expect(ensure(w)).rejects.toThrow(`the pod created ${CREATED} but did not link it`);
expect(w.state.created).toEqual([CREATED]);
expect(w.state.stored[profileOf(BOB)][FP_PARTICIPATIONS]).toBeUndefined();
// Not protected, not granted: nothing is done with a collection no contact could find.
expect(writes(w).map(c => c.action)).toEqual(['pod-collections.createAndAttach']);
// The profile is read again after the attach: the link counts, not what the attach returned.
expect(w.calls.filter(c => c.action === 'pod-resources.get' && c.params.resourceUri === profileOf(BOB))).toHaveLength(2);
});
test('every failure throws — nothing may then be added to the collection', async () => {
await expect(ensure(world({ fail: { putStatus: 403 } }))).rejects.toThrow('refused to remove the public read');
await expect(ensure(world({ stored: linked(EXISTING), acls: { [EXISTING]: [appControl(EXISTING)] }, fail: { grant: true } }))).rejects.toThrow('refused the read grant of the contacts group');
await expect(ensure(world({ fail: { aclRead: true } }))).rejects.toThrow('could not be read');
await expect(ensure(world(), { contactsUri: undefined })).rejects.toThrow('names no contacts collection');
await expect(ensure(world(), { contactsUri: `${MALLORY}/contacts` })).rejects.toThrow('names no contacts collection');
const inherited = world({
stored: linked(EXISTING),
acls: { [EXISTING]: [...protectedAcl(EXISTING), { '@id': 'http://localhost:3000/_acl/bob/data/as/collection#DefaultRead', 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent' }] }
});
await expect(ensure(inherited)).rejects.toThrow('inherited');
expect(writes(inherited)).toEqual([]);
});
});