const urlJoin = require('url-join'); const { PodCollectionsHandlerMixin, PodResourcesHandlerMixin } = require('@activitypods/app'); const CONFIG = require('../config/config'); const { neverThrowingMatcher, verifiedEmit, uriOf } = require('./emitted-activity'); const { guardedCreateAndAttach } = require('./collection-attach'); // Attaches an `apods:attendees` collection 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 participant's MEMBER URN to it (never a WebID — see // registration.service.js, member-urn.js and decision_2026-10-29_participation-privacy-mechanism), // so the collection's members are opaque URNs only readers of the participant's secret (they, their // contacts, the app) can recognise, and no WAC group mirrors them. // // 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. The collection is the PUBLIC // surface of a participation: a count of opaque members, no identity (the model, per // knowledge_data-scopes-and-discovery — not an open question). What this service settles: the // collection it creates never stays more readable than its event. Once attached, it records that // collection's public read as Festipod's (`public-read.recordCreated`) and asks `public-read.reconcile` // to align the event AND its collection with the event's current public intent — granted while the // event is public, revoked while it is not, in both directions on every later update. 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' }, // Members are member URNs, not agents — a mirrored WAC group would be meaningless // (decision_2026-10-29_participation-privacy-mechanism). createWacGroup: false }, dependencies: ['public-read'], // The mixin's create handler would act on the object embedded in the (unauthenticated) webhook // body, attributed to the activity's self-declared `actor`. Override it: the matcher stays the // mixin's (made never-throwing), but the handler runs only for a Create `verifiedEmit` accepts — // re-read from the pod as the app, attributed to the owner of the outbox the webhook fired on, // the STORED object, an `as:Event` of that owner's pod — and attaches the collection there. activities: { create: { match: neverThrowingMatcher(PodResourcesHandlerMixin.activities.create.match), onEmit: verifiedEmit( 'Create', async function (ctx, { object }, actorUri) { await this.onCreate(ctx, object, actorUri); }, { objectType: 'Event' } ) } }, actions: { // Nothing attached → no WAC group (see collection-attach.js). createAndAttach: guardedCreateAndAttach }, methods: { // Called by `createAndAttach` right after it attached a collection it CREATED, before anything // that may fail: Festipod created it, so the public read the pod gave it is Festipod's to take back // (public-read-ledger.js). A collection it did not create (nothing attached) is never recorded, // so its public read is never revoked. A failure here fails the attach step (logged by onCreate). async onCollectionAttached(ctx, { collectionUri, actorUri }) { await ctx.call('public-read.recordCreated', { resourceUri: collectionUri, actorUri }); }, // 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 = uriOf(resource); try { await this.actions.createAndAttach({ resourceUri, actorUri }, { parentCtx: ctx }); } catch (e) { this.logger.error(`Could not set up the attendees collection of ${resourceUri}: ${e.message}`); } // Whatever happened above, the collection the event now names follows the event's CURRENT // visibility. `reconcile` reads the event again under its lock, so this cannot race the // public-read handlers of the same event's later updates. try { await ctx.call('public-read.reconcile', { resourceUri, actorUri }); } catch (e) { this.logger.error(`Could not align the public read of ${resourceUri} and its attendees collection: ${e.message}`); } } } };