Files
festipod/backend/services/attendees.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

89 lines
4.9 KiB
JavaScript

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