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
+35 -7
View File
@@ -2,6 +2,7 @@ const { test, expect, describe } = require('bun:test');
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
const attendees = require('./attendees.service');
const contacts = require('./contacts.service');
const participations = require('./participations.service');
const ALICE = 'http://localhost:3000/alice';
const MALLORY = 'http://localhost:3000/mallory';
@@ -11,7 +12,7 @@ const PROFILE = `${ALICE}/data/profiles/me`;
const CREATE = `${ALICE}/data/activities/create-1`;
/** A schema bound like moleculer binds it: mixin methods first, the service's own over them. */
function bind(schema, pod, { attached, groupFails = false } = {}) {
function bind(schema, pod, { attached, groupFails = false, recordFails = false } = {}) {
const logs = { info: [], warn: [], error: [] };
const calls = [];
const ctx = {
@@ -23,6 +24,7 @@ function bind(schema, pod, { attached, groupFails = false } = {}) {
}
if (action === 'pod-collections.createAndAttach') return attached;
if (action === 'pod-wac-groups.create' && groupFails) throw new Error('group creation failed');
if (action === 'public-read.recordCreated' && recordFails) throw new Error('record failed');
return undefined;
}
};
@@ -45,25 +47,25 @@ const created = objectUri => ({ id: CREATE, type: 'Create', actor: ALICE, object
const actions = calls => calls.map(c => c.action);
describe('attendees — a verified Create attaches the collection, then aligns its public read with the event', () => {
test('a public event: collection attached, group created, then public-read.reconcile on the event', async () => {
test('a public event: collection attached, recorded, then reconciled — NO WAC group (members are member URNs, not agents)', async () => {
const { calls, emit } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', to: 'as:Public' } }, { attached: C });
await emit('create', created(E), ALICE);
expect(actions(calls).filter(a => a !== 'pod-resources.get')).toEqual([
'pod-collections.createAndAttach',
'public-read.recordCreated',
'pod-wac-groups.create',
'public-read.reconcile'
]);
expect(actions(calls)).not.toContain('pod-wac-groups.create');
expect(calls.find(c => c.action === 'public-read.recordCreated').params).toEqual({ resourceUri: C, actorUri: ALICE });
expect(calls.find(c => c.action === 'public-read.reconcile').params).toEqual({ resourceUri: E, actorUri: ALICE });
});
test('the collection is recorded as Festipod\'s even when the WAC group creation after the attach fails', async () => {
const { calls, emit, logs } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', name: 'private' } }, { attached: C, groupFails: true });
test('the reconcile still runs when the recording right after the attach fails', async () => {
const { calls, emit, logs } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', name: 'private' } }, { attached: C, recordFails: true });
await emit('create', created(E), ALICE);
const order = actions(calls).filter(a => a !== 'pod-resources.get');
expect(order).toEqual(['pod-collections.createAndAttach', 'public-read.recordCreated', 'pod-wac-groups.create', 'public-read.reconcile']);
expect(logs.error.some(m => m.includes('group creation failed'))).toBe(true);
expect(order).toEqual(['pod-collections.createAndAttach', 'public-read.recordCreated', 'public-read.reconcile']);
expect(logs.error.some(m => m.includes('record failed'))).toBe(true);
});
test('a PRIVATE event: its collection is reconciled too (the pod made it world-readable; it must not stay so)', async () => {
@@ -125,3 +127,29 @@ describe('contacts — the webhook is a hint, and nothing attached creates no gr
expect(logs.error).toEqual([]);
});
});
describe('participations — attached at a sign-up only, through the guarded createAndAttach', () => {
const profile = { id: PROFILE, type: ['as:Profile', 'vcard:Individual'] };
test('a profile Create attaches nothing: the service handles no activity', () => {
expect(Object.keys(participations.activities)).toEqual([]);
});
test('createAndAttach on the profile, with the participations predicate, and no WAC group', async () => {
const { svc, calls } = bind(participations, { [PROFILE]: profile }, { attached: `${ALICE}/data/as/collection/p1` });
const uri = await svc.actions.createAndAttach({ resourceUri: PROFILE, actorUri: ALICE });
expect(uri).toBe(`${ALICE}/data/as/collection/p1`);
expect(calls.find(c => c.action === 'pod-collections.createAndAttach').params).toMatchObject({
resourceUri: PROFILE,
attachPredicate: 'http://festipod.org/participations',
actorUri: ALICE
});
expect(actions(calls)).not.toContain('pod-wac-groups.create');
});
test('nothing attached: undefined, nothing thrown', async () => {
const { svc, logs } = bind(participations, { [PROFILE]: profile }, { attached: undefined });
await expect(svc.actions.createAndAttach({ resourceUri: PROFILE, actorUri: ALICE })).resolves.toBeUndefined();
expect(logs.error).toEqual([]);
});
});