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}`); } } } };