88f23b7023
The backend served its settings dataset (including the app actor's private key) to anonymous callers, through the SPARQL endpoint and through the semapps LDP catch-all route, since WebACL is off. An allow-list mounted on every route now exposes only what the app and the pod need (/app, /as/actor/app and its Client ID Document, /catalog, the public key, access needs, the JSON-LD context, nodeinfo, the app inbox and the webhook endpoint); everything else answers 404. Every watcher-fed service (public read, catalog, attendees, contacts, registration) now decides on the activity and object re-read from the pod as the app, checks they belong to the outbox owner and that the stored object has the expected type, and never acts on the notification body. Decisions for one object are serialized and taken on a read made when acting, with a 30 s bound and an abort check before every write. The anonymous read follows the event's visibility both ways, attendees collection included; Festipod records the grants it makes and revokes only those, leaving any other public share untouched; an unreadable ACL changes nothing. A participant's attendance follows their latest act by the pod's timestamp, not the order notifications arrive. Known limits (no conditional write on the pod, a write in flight at the timeout) are documented in the code.
84 lines
4.5 KiB
JavaScript
84 lines
4.5 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 (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 of a PUBLIC
|
|
// event 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. What it does settle: 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'
|
|
},
|
|
createWacGroup: true
|
|
},
|
|
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}`);
|
|
}
|
|
}
|
|
}
|
|
};
|