Files
festipod/backend/services/participations.service.js
T
Sylvain Duchesne 908f7ea973 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.
2026-10-30 17:06:38 +01:00

192 lines
11 KiB
JavaScript

const urlJoin = require('url-join');
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
const { arrayOf } = require('@semapps/ldp');
const CONFIG = require('../config/config');
const { isInActorPod } = require('./actor-pod');
const { createKeyedSerializer } = require('./keyed-serializer');
const { uriOf } = require('./emitted-activity');
const { guardedCreateAndAttach } = require('./collection-attach');
const { hasInheritedPublicRead, hasOwnPublicRead, revocationGraph, ownReaders, aclUriOf, readAcl, putOwnRights } = require('./public-read-acl');
const FP_PARTICIPATIONS = 'http://festipod.org/participations';
/** The collection URIs a profile links under `fp:participations` (full IRI or CURIE). */
const participationsLinksOf = profile =>
arrayOf(profile[FP_PARTICIPATIONS] ?? profile['fp:participations'])
.map(uriOf)
.filter(uri => typeof uri === 'string');
// A person's participations collection (decision_2026-10-29_participation-privacy-mechanism): the
// person-side record of the meeting points they joined, for themself and for their contacts. An
// unordered `as:Collection` in the participant's pod, attached to their `as:Profile` under
// `http://festipod.org/participations`, whose items are meeting point URIs — added on a Join and
// removed on a Leave by registration.service.js, acting as the participant.
//
// Who may read it: its owner, the app, and the participant's `/contacts` WAC group — through ONE
// `acl:agentGroup` grant on the collection's own `#Read` rule (the welcometomyplace pattern), the
// group URI derived from the `apods:contacts` collection (`pod-wac-groups.getUriFromCollectionUri`).
// The GROUP, not the `apods:contacts` LIST: a received `Accept` of a contact `Offer` is added to the
// list with no prior request checked (platform defect reported upstream), so the list is forgeable —
// a stranger could grant themself read; the group gains a member only through the person's OWN
// emitted activities (a contact request they send, an acceptance they emit), which a forged received
// activity cannot do. (The group does admit the target of a request the person SENT before it is
// accepted, and keeps a removed contact — platform behaviours, both deliberate here: see the e2e.)
// And never anyone else: the pod creates a collection world-readable (`acl:Read` for `foaf:Agent`,
// observed 2026-10-12 on the attendee lists), and a participations collection readable by strangers
// would list a person's events to anyone.
//
// `syncReaders` makes that true from the collection's ACL as the pod serves it NOW, read as the
// participant: the pod's own public read goes (the `revocationGraph` PUT of `public-read-acl.js` —
// `pod-permissions.remove` cannot do it, see there), then the `/contacts` group gets its grant if it
// has none (`pod-permissions.add`, the welcometomyplace grant call). It runs on every Join (`ensure`,
// before the meeting point is added). The grant names a STABLE group URI, so it never needs revisiting
// when the person's contacts change — the pod keeps the group's membership, nothing here to re-sync.
// Idempotent: on a collection already in that state, one ACL read and nothing written. A failure
// throws — the act that needed it is not recorded, the next one retries.
//
// Created at a person's first sign-up, never on a profile's Create (no activity handler here): a
// profile with no collection has no participation to list. One run at a time per person — two Joins of
// one person on two meeting points run concurrently and would otherwise both find no collection and
// attach one each.
module.exports = {
name: 'participations',
mixins: [PodCollectionsHandlerMixin],
settings: {
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Profile'),
attachPredicate: FP_PARTICIPATIONS,
collectionOptions: {
ordered: false,
summary: 'Festipod participations'
},
// Items are meeting points, not agents: no mirrored WAC group.
createWacGroup: false
},
dependencies: ['pod-permissions'],
// Replaces the mixin's whole map (moleculer does not merge a custom schema key): no handler, so
// no collection is attached to a profile when it is created.
activities: {},
created() {
this.serialize = createKeyedSerializer();
},
actions: {
// Nothing attached → no WAC group (see collection-attach.js).
createAndAttach: guardedCreateAndAttach,
/** The collection the participant's profile links now, or undefined (see `collectionOf`). */
collectionOf: {
params: {
actorUri: { type: 'string' },
profileUri: { type: 'string' }
},
async handler(ctx) {
return this.collectionOf(ctx, ctx.params);
}
},
/** The participant's collection, created if none and readable by their `/contacts` group only (see `ensure`). */
ensure: {
params: {
actorUri: { type: 'string' },
profileUri: { type: 'string' },
contactsUri: { type: 'string', optional: true }
},
async handler(ctx) {
return this.ensure(ctx, ctx.params);
}
}
},
methods: {
/**
* The collection `profileUri` (the profile of `actorUri`) links NOW — read from the pod as the
* participant — or undefined when it links none. Throws when the profile cannot be read, links
* several, or links one outside the participant's pod (ignored: nothing is written to it).
*/
async collectionOf(ctx, { actorUri, profileUri }) {
if (!isInActorPod(profileUri, actorUri)) throw new Error(`the profile ${profileUri} is not in the pod of ${actorUri}`);
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: profileUri, actorUri });
if (status !== 200 || !body || uriOf(body) !== profileUri) {
throw new Error(`the pod does not return the profile ${profileUri} (HTTP ${status})`);
}
const links = participationsLinksOf(body);
if (links.length > 1) throw new Error(`the profile ${profileUri} links several participations collections`);
if (links.length === 1 && !isInActorPod(links[0], actorUri)) {
throw new Error(`the profile ${profileUri} links a participations collection outside the pod of ${actorUri}: ignored`);
}
return links[0];
},
/**
* The participant's collection, ready to receive a meeting point: the one the profile links, or
* one created and attached now when it links none; then readable by the participant's `/contacts`
* WAC group and by no one else (`syncReaders`). One run at a time per participant. Resolves the
* collection URI; throws when any of this cannot be made true — nothing may then be added to it.
*
* After an attach, the collection used is the one the profile links when READ AGAIN, never the
* URI the attach returned: the platform's `createAndAttach` returns the collection it created even
* when the pod refused the profile PATCH that links it (it does not check that write), and returns
* nothing when the profile gained a link meanwhile (another app). A created collection the profile
* does not link is left unused, empty — its contacts could never find it.
*/
async ensure(ctx, { actorUri, profileUri, contactsUri }) {
return this.serialize(actorUri, async signal => {
let collectionUri = await this.collectionOf(ctx, { actorUri, profileUri });
if (!collectionUri) {
const attached = await this.actions.createAndAttach({ resourceUri: profileUri, actorUri }, { parentCtx: ctx });
collectionUri = await this.collectionOf(ctx, { actorUri, profileUri });
if (!collectionUri) {
throw new Error(
attached
? `the pod created ${attached} but did not link it to ${profileUri}: left unused`
: `no participations collection could be attached to ${profileUri}`
);
}
if (attached === collectionUri) this.logger.info(`Attached the participations collection ${collectionUri} to ${profileUri}`);
else if (attached) this.logger.warn(`${profileUri} links ${collectionUri}, not the collection ${attached} just created: that one is left unused`);
}
await this.syncReaders(ctx, { collectionUri, actorUri, contactsUri, signal });
return collectionUri;
});
},
/**
* Makes `collectionUri` readable by the participant's `/contacts` WAC group (one `acl:agentGroup`
* grant on its own `#Read` rule, the group URI derived from their `apods:contacts` collection) and
* by no one else: the pod's own public read is removed first (the `revocationGraph` PUT, one call,
* the app keeping its `#Control`), then the group is granted read if it has none. The grant names a
* stable group URI whose membership the pod keeps, so it is set once and never re-synced. Gives up
* before any write once its time is out (`signal`). Idempotent: on a collection already in that
* state, one ACL read and nothing written. Throws when the ACL cannot be read, the pod refuses a
* change, or the collection stays world-readable through a rule inherited from its container (not
* removable here) — the Join that needed it is then not recorded, and retries.
*/
async syncReaders(ctx, { collectionUri, actorUri, contactsUri, signal }) {
if (!contactsUri || !isInActorPod(contactsUri, actorUri)) {
throw new Error(`${actorUri} names no contacts collection in their pod: who may read ${collectionUri} is unknown`);
}
const groupUri = await ctx.call('pod-wac-groups.getUriFromCollectionUri', { collectionUri: contactsUri });
const acl = await readAcl(ctx, collectionUri, actorUri);
if (!acl.authorizations) throw new Error(`the ACL of ${collectionUri} could not be read (HTTP ${acl.status})`);
if (hasInheritedPublicRead(acl.authorizations)) {
throw new Error(`${collectionUri} is world-readable through a rule inherited from its container: not removable here`);
}
if (hasOwnPublicRead(acl.authorizations)) {
if (signal && signal.aborted) throw new Error(`out of time before removing the public read of ${collectionUri}`);
const document = revocationGraph(acl.authorizations, collectionUri, actorUri, aclUriOf(collectionUri));
const status = await putOwnRights(ctx, collectionUri, document);
if (status !== 204 && status !== 200) {
throw new Error(`the pod refused to remove the public read of ${collectionUri} (HTTP ${status})`);
}
this.logger.info(`Removed the public read of ${collectionUri}`);
}
if (!ownReaders(acl.authorizations).agentGroup.includes(groupUri)) {
if (signal && signal.aborted) throw new Error(`out of time before granting the contacts group read on ${collectionUri}`);
const added = await ctx.call('pod-permissions.add', {
uri: collectionUri,
agentPredicate: 'acl:agentGroup',
agentUri: groupUri,
mode: 'acl:Read',
actorUri
});
if (!added) throw new Error(`the pod refused the read grant of the contacts group ${groupUri} on ${collectionUri}`);
this.logger.info(`Granted the contacts group ${groupUri} read on ${collectionUri}`);
}
}
}
};