Make events and meeting points readable by anyone only when Festipod marks them public
Festipod's create path now addresses events and meeting points to the ActivityStreams Public collection (to: as:Public), and the backend grants acl:Read to foaf:Agent on a created as:Event only when it carries that public intent (one predicate, hasPublicIntent, in backend/services/public-intent.js). Before this, every as:Event written to a user's outbox would have been made world-readable, including other apps' events and events addressed to a single friend. The grant is idempotent (it reads the ACL first) and never throws into the webhook path. The attendees service overrides the collections mixin so that an event posted with apods:attendees already attached no longer reaches new URL(undefined) and takes the whole backend process down. Attendee lists stay world-readable as the pod creates them; whether they should is an open product question.
This commit is contained in:
@@ -1,15 +1,19 @@
|
||||
const urlJoin = require('url-join');
|
||||
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
|
||||
const CONFIG = require('../config/config');
|
||||
const { hasPublicIntent } = require('./public-intent');
|
||||
|
||||
// Visibility decision: the attendees collection is readable by ANY visitor, including
|
||||
// unauthenticated ones (acl:agentClass foaf:Agent). A meeting point's participant count and
|
||||
// its list of participants must be visible to non-contacts — the app shows outsiders the
|
||||
// anonymous participants plus the total counter, and that is a product feature, not a leak.
|
||||
// Member URIs are not secret; the anonymity guarantee lives in each profile's own WAC (a
|
||||
// participant whose profile is not shared simply renders as anonymous), not here. The
|
||||
// reference welcometomyplace grants this to its announces group, but Festipod has no
|
||||
// announces concept, so the grant is public.
|
||||
// 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],
|
||||
@@ -22,28 +26,57 @@ module.exports = {
|
||||
},
|
||||
createWacGroup: true
|
||||
},
|
||||
dependencies: ['public-read'],
|
||||
actions: {
|
||||
async givePublicReadPermission(ctx) {
|
||||
const { collectionUri, actorUri } = ctx.params;
|
||||
// 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;
|
||||
|
||||
await ctx.call('pod-permissions.add', {
|
||||
uri: collectionUri,
|
||||
agentPredicate: 'acl:agentClass',
|
||||
agentUri: 'http://xmlns.com/foaf/0.1/Agent',
|
||||
mode: 'acl:Read',
|
||||
actorUri
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
},
|
||||
hooks: {
|
||||
after: {
|
||||
async createAndAttach(ctx, res) {
|
||||
const { actorUri } = ctx.params;
|
||||
const collectionUri = res;
|
||||
|
||||
await this.actions.givePublicReadPermission({ collectionUri, actorUri }, { parentCtx: ctx });
|
||||
|
||||
return res;
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user