Harden the backend's trust surface: an allow-listed public surface, decisions taken on data re-read from the pod
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.
This commit is contained in:
@@ -1,19 +1,21 @@
|
||||
const urlJoin = require('url-join');
|
||||
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
|
||||
const { PodCollectionsHandlerMixin, PodResourcesHandlerMixin } = require('@activitypods/app');
|
||||
const CONFIG = require('../config/config');
|
||||
const { hasPublicIntent } = require('./public-intent');
|
||||
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 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.
|
||||
// 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],
|
||||
@@ -27,57 +29,55 @@ module.exports = {
|
||||
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;
|
||||
}
|
||||
// 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 = resource.id || resource['@id'];
|
||||
const resourceUri = uriOf(resource);
|
||||
try {
|
||||
const collectionUri = await this.actions.createAndAttach({ resourceUri, actorUri }, { parentCtx: ctx });
|
||||
if (collectionUri && hasPublicIntent(resource)) {
|
||||
await ctx.call('public-read.grant', { resourceUri: collectionUri, actorUri });
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user