Files
festipod/backend/services/public-intent.js
Sylvain Duchesne a0d2cb8f73 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.
2026-10-12 16:59:00 +02:00

98 lines
5.0 KiB
JavaScript

// Festipod's PUBLIC intent, as the backend reads it on a created resource.
//
// The signal is ActivityStreams public addressing: the ActivityStreams Public collection in
// `to` or `cc`. Festipod's create path writes it on every event and meeting point it creates
// (`src/shared/refine/providers/activitypods.ts`, the `toValues` of those two bindings), and
// the pod stores it on the object as written (observed 2026-10-12: stored as `"to": "as:Public"`;
// the outbox `Create` the pod generates for a container POST carries no addressing at all).
//
// What it is NOT: provenance. The decision does not depend on which client created the
// resource, only on whether the resource says it is public. An as:Event without public
// addressing — created by another app, posted directly on the pod, addressed to chosen
// recipients only, or a future Festipod record in a protected or private scope — never matches.
const AS_PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
// The three equivalent spellings of the Public collection: the full IRI and the two compacted
// forms JSON-LD compaction against the ActivityStreams context can produce.
const PUBLIC_FORMS = new Set([AS_PUBLIC, 'as:Public', 'Public']);
// `to` and `cc`, under the compacted names and their prefixed and full-IRI spellings (a
// representation compacted against a context that does not define the short terms).
const ADDRESSING_KEYS = [
'to',
'cc',
'as:to',
'as:cc',
'https://www.w3.org/ns/activitystreams#to',
'https://www.w3.org/ns/activitystreams#cc'
];
const listOf = value => (value === undefined || value === null ? [] : Array.isArray(value) ? value : [value]);
/** The address a `to`/`cc` entry names: a bare IRI string, or a node carrying `id`/`@id`. */
const addressOf = entry => {
if (typeof entry === 'string') return entry;
if (entry && typeof entry === 'object') {
const id = entry.id || entry['@id'];
return typeof id === 'string' ? id : undefined;
}
return undefined;
};
/**
* True when this ONE JSON-LD resource (an object or an activity) is addressed to the
* ActivityStreams Public collection in `to` or `cc`. Anything else is false: no addressing,
* addressing to actors or collections only, Public in `bto`/`bcc`/`audience`, a non-object input.
*/
const isAddressedToPublic = resource => {
if (!resource || typeof resource !== 'object') return false;
return ADDRESSING_KEYS.some(key => listOf(resource[key]).some(entry => PUBLIC_FORMS.has(addressOf(entry))));
};
/**
* hasPublicIntent(object, activity?) → boolean.
*
* `object` is the created resource as stored on the pod (what the app's activity watcher hands
* to `onCreate`: the dereferenced `object` of the `Create`). `activity` is optional: the `Create`
* itself, when the caller holds it. True when either one is addressed to the Public collection
* (see `isAddressedToPublic`); false when neither is — a missing argument counts as not addressed.
*
* This is the ONLY gate in front of making a resource world-readable or listing it publicly:
* a caller that grants or publishes on a creation must ask this first.
*/
const hasPublicIntent = (object, activity) => isAddressedToPublic(object) || isAddressedToPublic(activity);
// ── LISTED public: what a public index may list ───────────────────────────────────────────────
//
// ActivityStreams addressing distinguishes two public audiences by WHERE the Public collection is
// named, the convention fediverse servers follow: in `to`, the resource is public AND listed — meant
// to show up in public timelines and indexes; in `cc` only, it is public but UNLISTED — anyone who
// has its link may read it, but nothing should advertise it. The public-read grant serves both
// (`hasPublicIntent`); a public index — Festipod's catalog — may list only the first.
// `to` only, under the same three spellings as above.
const TO_KEYS = ['to', 'as:to', 'https://www.w3.org/ns/activitystreams#to'];
/**
* True when this ONE JSON-LD resource names the ActivityStreams Public collection in `to` (any of
* its spellings, as a bare IRI or a node). Public in `cc` only — unlisted — is false, as is
* everything `isAddressedToPublic` refuses.
*/
const isListedToPublic = resource => {
if (!resource || typeof resource !== 'object') return false;
return TO_KEYS.some(key => listOf(resource[key]).some(entry => PUBLIC_FORMS.has(addressOf(entry))));
};
/**
* hasListedPublicIntent(object, activity?) → boolean.
*
* Public intent (`hasPublicIntent`, same arguments) AND listed: the Public collection in `to` of
* the object or of the activity. Built on `hasPublicIntent`, so it can never be true where that one
* is false: nothing is listed that the public-read gate would not have accepted.
*/
const hasListedPublicIntent = (object, activity) =>
hasPublicIntent(object, activity) && (isListedToPublic(object) || isListedToPublic(activity));
module.exports = { AS_PUBLIC, isAddressedToPublic, hasPublicIntent, isListedToPublic, hasListedPublicIntent };