const urlJoin = require('url-join'); const { PodCollectionsHandlerMixin } = require('@activitypods/app'); const CONFIG = require('../config/config'); const { hasPublicIntent } = require('./public-intent'); // Attaches an `apods:attendees` collection (and a WAC group of the same members) to every // `as:Event` created in a user's pod that this backend learns of through the outbox watcher — // plain events as well as meeting points (observed 2026-10-12). A join adds the actor to it. // // Who may read the collection: the pod creates it world-readable (`acl:Read` for // `foaf:Agent`) — a platform default, observed 2026-10-12, not a choice made here. Whether // attendee lists SHOULD be world-readable is an open product question: the functional-domain // doctrine places participation in the protected scope. This service does not settle it. On a // resource carrying Festipod's public intent it also applies `public-read.grant` to the // collection, which finds it already world-readable and adds nothing; on any other resource the // backend grants nothing on the collection. module.exports = { name: 'attendees', mixins: [PodCollectionsHandlerMixin], settings: { shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Event'), attachPredicate: 'http://activitypods.org/ns/core#attendees', collectionOptions: { ordered: false, summary: 'Meeting point attendees' }, createWacGroup: true }, dependencies: ['public-read'], actions: { // Replaces the mixin's action of the same name (`PodCollectionsHandlerMixin`, // @activitypods/app 2.3.2) with one difference. `pod-collections.createAndAttach` returns // undefined when it attaches nothing — the resource already carries an `apods:attendees` // value (written by its creator or by another app), or the pod refused to create the // collection — and the mixin then derives a WAC group slug from that undefined URI: // `new URL(undefined)` throws. The webhook call that runs the watcher is not awaited by the // platform, so that throw ended the whole backend process (observed 2026-10-12, an event // POSTed with `apods:attendees` already set). Here, nothing attached means no group to create. createAndAttach: { async handler(ctx) { const { resourceUri, actorUri } = ctx.params; const { attachPredicate, collectionOptions, createWacGroup } = this.settings; const collectionUri = await ctx.call('pod-collections.createAndAttach', { resourceUri, attachPredicate, collectionOptions, actorUri }); if (!collectionUri) { this.logger.info(`No attendees collection attached to ${resourceUri} (already carries one, or creation refused)`); return undefined; } if (createWacGroup) { await ctx.call('pod-wac-groups.create', { groupSlug: this.getGroupSlugFromCollectionUri(collectionUri), actorUri }); } return collectionUri; } } }, methods: { // Replaces the mixin's `onCreate`. The watcher runs the handlers of one `Create` in sequence // and does not catch, in an order that is not fixed (attendees ran before public-read and // catalog in the runs observed 2026-10-12): a throw here would skip whichever handlers come // after it, and — the webhook call being un-awaited — end the process. So a failure is // logged, never thrown. async onCreate(ctx, resource, actorUri) { const resourceUri = resource.id || resource['@id']; try { const collectionUri = await this.actions.createAndAttach({ resourceUri, actorUri }, { parentCtx: ctx }); if (collectionUri && hasPublicIntent(resource)) { await ctx.call('public-read.grant', { resourceUri: collectionUri, actorUri }); } } catch (e) { this.logger.error(`Could not set up the attendees collection of ${resourceUri}: ${e.message}`); } } } };