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:
@@ -124,6 +124,9 @@ test('event toValues composes the datetimes and never stores the derived fields'
|
||||
// text value resolved into a garbage IRI.
|
||||
location: { name: 'La Maison du Vélo, Lyon' },
|
||||
image: 'https://example.org/atelier.jpg',
|
||||
// Public intent, written on create: the backend grants anonymous read only to a
|
||||
// created as:Event addressed to the Public collection.
|
||||
to: 'https://www.w3.org/ns/activitystreams#Public',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,14 +142,22 @@ test('event toValues keeps a date-only end and drops a time-only start', () => {
|
||||
location: '',
|
||||
participantCount: 0,
|
||||
}),
|
||||
).toEqual({ name: 'T', content: '', startTime: '2026-03-01', endTime: '2026-03-03', location: { name: '' } });
|
||||
).toEqual({
|
||||
name: 'T',
|
||||
content: '',
|
||||
startTime: '2026-03-01',
|
||||
endTime: '2026-03-03',
|
||||
location: { name: '' },
|
||||
to: 'https://www.w3.org/ns/activitystreams#Public',
|
||||
});
|
||||
expect(
|
||||
event.toValues({ title: 'T', description: '', date: '', startTime: '09:00', location: '', participantCount: 0 }),
|
||||
).toEqual({ name: 'T', content: '', location: { name: '' } });
|
||||
).toEqual({ name: 'T', content: '', location: { name: '' }, to: 'https://www.w3.org/ns/activitystreams#Public' });
|
||||
});
|
||||
|
||||
test('event toPatch sends only what changed', () => {
|
||||
const { event } = getBackend('activitypods').bindings;
|
||||
// No addressing either: it is written on create, and the merge keeps it on an edit.
|
||||
expect(event.toPatch({ title: 'Nouveau titre' })).toEqual({ name: 'Nouveau titre' });
|
||||
});
|
||||
|
||||
@@ -206,6 +217,8 @@ test('meetingPoint toValues writes the context and the named location, never org
|
||||
content: 'Sous l’horloge',
|
||||
location: { name: 'Café de la Place' },
|
||||
'as:context': 'http://localhost:3000/alice/data/event-1',
|
||||
// A meeting point is public too: created addressed to the Public collection.
|
||||
to: 'https://www.w3.org/ns/activitystreams#Public',
|
||||
});
|
||||
// The host is the Pod-stamped creator; nothing here writes an `organizer` term
|
||||
// the platform context does not define (it would not round-trip).
|
||||
|
||||
@@ -367,11 +367,24 @@ function attendeesFromRecord(record: unknown): { members: unknown[]; total?: num
|
||||
return { members, total: total ?? members.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* PUBLIC ADDRESSING, WRITTEN ON CREATE. Events and meeting points are public
|
||||
* (`visibility: 'public'` on both bindings), and on this backend the intent travels
|
||||
* as ActivityStreams addressing: each is created addressed `to` the Public
|
||||
* collection. `create` sends every variable verbatim, and the pod stores the
|
||||
* addressing on the record (`"to": "as:Public"`). Festipod's backend reads it on the
|
||||
* created record (`backend/services/public-intent.js`) and grants anonymous read ONLY to an
|
||||
* `as:Event` that carries it — one created without it stays private to its owner,
|
||||
* whichever client created it. Create only: `update` merges onto the stored record,
|
||||
* so the addressing survives an edit without being re-sent.
|
||||
*/
|
||||
const PUBLIC_AUDIENCE = { to: 'https://www.w3.org/ns/activitystreams#Public' } as const;
|
||||
|
||||
const eventBinding: RecordBinding<FpEventData> = {
|
||||
recordKey: recordId,
|
||||
domainId: recordId,
|
||||
toDomain: eventFromRecord,
|
||||
toValues: (data) => eventValues(data),
|
||||
toValues: (data) => ({ ...eventValues(data), ...PUBLIC_AUDIENCE }),
|
||||
toPatch: (updates) => eventValues(updates),
|
||||
// Events and meeting points share the as/Event container; an `as:context` link
|
||||
// is what makes a record a MEETING point, so its absence is the event test.
|
||||
@@ -385,7 +398,7 @@ const meetingPointBinding: RecordBinding<FpMeetingPointData> = {
|
||||
recordKey: recordId,
|
||||
domainId: recordId,
|
||||
toDomain: meetingPointFromRecord,
|
||||
toValues: (data) => meetingPointValues(data),
|
||||
toValues: (data) => ({ ...meetingPointValues(data), ...PUBLIC_AUDIENCE }),
|
||||
toPatch: (updates) => meetingPointValues(updates),
|
||||
// The distinction lives in the record, not the container — see the event binding.
|
||||
isInstance: (record) => linkId(asRecord(record)?.['as:context']) !== undefined,
|
||||
|
||||
Reference in New Issue
Block a user