908f7ea973
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.
156 lines
8.8 KiB
JavaScript
156 lines
8.8 KiB
JavaScript
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';
|
|
const E = `${ALICE}/data/events/e1`;
|
|
const C = `${ALICE}/data/e1-attendees`;
|
|
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, recordFails = false } = {}) {
|
|
const logs = { info: [], warn: [], error: [] };
|
|
const calls = [];
|
|
const ctx = {
|
|
call: async (action, params) => {
|
|
calls.push({ action, params });
|
|
if (action === 'pod-resources.get') {
|
|
const body = pod[params.resourceUri];
|
|
return body ? { ok: true, status: 200, body } : { ok: false, status: 404 };
|
|
}
|
|
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;
|
|
}
|
|
};
|
|
const svc = {
|
|
name: schema.name,
|
|
settings: { ...PodCollectionsHandlerMixin.settings, ...schema.settings },
|
|
logger: { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) },
|
|
actions: {}
|
|
};
|
|
for (const [name, fn] of Object.entries({ ...PodCollectionsHandlerMixin.methods, ...(schema.methods || {}) })) svc[name] = fn.bind(svc);
|
|
const actions = { ...PodCollectionsHandlerMixin.actions, ...schema.actions };
|
|
for (const [name, def] of Object.entries(actions)) {
|
|
svc.actions[name] = (params, opts) => def.handler.call(svc, { ...opts, params, call: ctx.call });
|
|
}
|
|
const emit = (key, hint, actorUri) => schema.activities[key].onEmit.call(svc, ctx, hint, actorUri);
|
|
return { svc, ctx, calls, logs, emit };
|
|
}
|
|
|
|
const created = objectUri => ({ id: CREATE, type: 'Create', actor: ALICE, object: objectUri });
|
|
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, 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',
|
|
'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 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', '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 () => {
|
|
const { calls, emit } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', name: 'private' } }, { attached: C });
|
|
await emit('create', created(E), ALICE);
|
|
expect(calls.some(c => c.action === 'public-read.reconcile' && c.params.resourceUri === E)).toBe(true);
|
|
expect(calls.some(c => c.action === 'public-read.grant')).toBe(false);
|
|
});
|
|
|
|
test('nothing attached (already carries a collection): no WAC group, no throw, still reconciled', async () => {
|
|
const { calls, emit, logs } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', 'apods:attendees': C } }, { attached: undefined });
|
|
await emit('create', created(E), ALICE);
|
|
expect(actions(calls)).not.toContain('pod-wac-groups.create');
|
|
expect(actions(calls)).not.toContain('public-read.recordCreated'); // not Festipod's collection: never revoked
|
|
expect(actions(calls)).toContain('public-read.reconcile');
|
|
expect(logs.error).toEqual([]);
|
|
});
|
|
|
|
test("a forged Create naming another actor's event, or a stored Note, touches nothing", async () => {
|
|
const malloryEvent = `${MALLORY}/data/events/e`;
|
|
let w = bind(attendees, {}, { attached: C });
|
|
await w.emit('create', { type: 'Create', actor: ALICE, object: { id: malloryEvent, type: 'Event' } }, ALICE);
|
|
expect(actions(w.calls).filter(a => a !== 'pod-resources.get')).toEqual([]);
|
|
w = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Note', to: 'as:Public' } }, { attached: C });
|
|
await w.emit('create', created(E), ALICE);
|
|
expect(actions(w.calls).filter(a => a !== 'pod-resources.get')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('contacts — the webhook is a hint, and nothing attached creates no group', () => {
|
|
const profile = { id: PROFILE, type: ['as:Profile', 'vcard:Individual'] };
|
|
|
|
test('the normal case: the collection is attached to the STORED profile, for the outbox owner', async () => {
|
|
const { calls, emit } = bind(contacts, { [CREATE]: created(PROFILE), [PROFILE]: profile }, { attached: `${ALICE}/data/contacts` });
|
|
// The body claims Mallory created it: the outbox owner (Alice), re-read from the pod, is who counts.
|
|
await emit('create', { ...created(PROFILE), actor: MALLORY }, ALICE);
|
|
const attach = calls.find(c => c.action === 'pod-collections.createAndAttach');
|
|
expect(attach.params).toMatchObject({ resourceUri: PROFILE, actorUri: ALICE });
|
|
expect(actions(calls)).toContain('pod-wac-groups.create');
|
|
});
|
|
|
|
test('a Create the pod does not store attaches nothing', async () => {
|
|
const { calls, emit } = bind(contacts, { [PROFILE]: profile }, { attached: `${ALICE}/data/contacts` });
|
|
await emit('create', { type: 'Create', actor: ALICE, object: profile }, ALICE);
|
|
expect(actions(calls)).not.toContain('pod-collections.createAndAttach');
|
|
});
|
|
|
|
test('a stored object that is not a Profile attaches nothing', async () => {
|
|
const { calls, emit } = bind(contacts, { [CREATE]: created(PROFILE), [PROFILE]: { id: PROFILE, type: 'Event' } }, { attached: `${ALICE}/data/contacts` });
|
|
await emit('create', created(PROFILE), ALICE);
|
|
expect(actions(calls)).not.toContain('pod-collections.createAndAttach');
|
|
});
|
|
|
|
test('nothing attached (the profile already carries contacts): no WAC group, and nothing thrown', async () => {
|
|
const { calls, emit, logs } = bind(contacts, { [CREATE]: created(PROFILE), [PROFILE]: profile }, { attached: undefined });
|
|
await expect(emit('create', created(PROFILE), ALICE)).resolves.toBeUndefined();
|
|
expect(actions(calls)).toContain('pod-collections.createAndAttach');
|
|
expect(actions(calls)).not.toContain('pod-wac-groups.create');
|
|
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([]);
|
|
});
|
|
});
|