9e2df2478b
A participation activity is now addressed to the Festipod app actor alone and handled on reception in the app's inbox, the way the ActivityPods developer advised: use the ActivityPub protocol rather than a side channel. Any compatible sender may deliver; the inbox accepts only the activity's own signer, and the backend still requires the activity to lie in that actor's pod and acts on the stored copy. The meeting point is read again in its creator's pod before anything is written: read through the participant's pod, a foreign server's proxy chose the attendee list and the notification's name. Confidentiality is unchanged: the Join stays readable by its author and the app only. Recorded in decision_2026-11-03_participation-delivered-to-the-app, a named exception to festipod-only-reference-app-patterns.
289 lines
15 KiB
JavaScript
289 lines
15 KiB
JavaScript
// What the backend may believe about an activity its watcher reports, or a server delivers to the
|
|
// app's inbox — the check in front of every Festipod handler fed by that watcher (public-read,
|
|
// catalog, attendees, contacts) or by that inbox (registration). A delivered body, like a webhook
|
|
// body, is only a pointer.
|
|
//
|
|
// THE WEBHOOK IS A HINT, NOT A SOURCE. The webhook route (`/.webhooks/:uuid`) is unauthenticated: the
|
|
// listener only matches the uuid, the watcher derives the box owner from the body's `target`, and an
|
|
// activity or object EMBEDDED in the body is used as written, never re-read (platform behaviour,
|
|
// @activitypods/app 2.3.2 `pod-activities-watcher.processWebhook`, @semapps/solid listener
|
|
// `transfer`). Nothing in the body is trusted here except as a pointer: the activity is re-read by
|
|
// its id from the pod, the object by its id, both through the box owner's access, and the handlers
|
|
// act only on what the pod returns.
|
|
//
|
|
// WHO EMITTED IT. A resource lies in an actor's pod only if that actor (or an app it authorised)
|
|
// wrote it: the pod refuses to create or modify a resource under another actor's path (see
|
|
// `actor-pod.js`). So:
|
|
// - an OUTBOX notification names its emitter: the owner of that outbox (`verifiedEmit`). The
|
|
// activity must lie in that owner's pod and the stored activity must name that owner as `actor`;
|
|
// - an INBOX notification does not (`verifiedReceive`): the emitter is the stored activity's own
|
|
// `actor`, believed only because the activity lies in THAT actor's pod — someone else's claim
|
|
// about an actor cannot be stored there;
|
|
// - a DELIVERY to the app's own inbox names its emitter (`verifiedDelivery`): the inbox accepts it
|
|
// only signed by the activity's `actor` (@semapps/activitypub 1.2.7 `inbox.post` refuses an actor
|
|
// other than the signer, INVALID_ACTOR). Nothing else in the body is believed: the activity must
|
|
// lie in that actor's pod and the copy stored there name them.
|
|
//
|
|
// An activity is accepted only when all of this holds:
|
|
// 1. it is identified by a URI (an embedded activity without an id was never stored);
|
|
// 2. for an outbox notification, that URI is strictly inside the emitter's pod;
|
|
// 3. the pod returns it at that URI, as a resource with that id;
|
|
// 4. its stored type is the one the handler expects, and it names exactly one `actor`: the emitter
|
|
// (outbox), or an actor whose pod holds the activity (inbox);
|
|
// 5. it names exactly one object, by URI, lying where the handler requires (the emitter's pod for
|
|
// a resource the emitter creates, updates or deletes; the inbox owner's pod for an activity
|
|
// about the inbox owner's object; anywhere when the handler checks the location itself);
|
|
// 6. for a Delete, the pod says the object is gone (404/410) or serves a Tombstone — a live object
|
|
// means the deletion is not true; otherwise the pod returns the object at that URI, as a
|
|
// resource with that id, declaring the ActivityStreams type the handler acts on (a Note is not
|
|
// an Event, whatever the notification says).
|
|
//
|
|
// WHAT IT DOES NOT DO: order notifications. Handlers run concurrently, one per webhook; this check
|
|
// reads the pod BEFORE the handler acts, so on its own an older notification's action can still land
|
|
// after a newer one's. A handler whose decision depends on the object's current state therefore takes
|
|
// its own read INSIDE `keyed-serializer.js`, keyed by the object — that, not this check, is what makes
|
|
// a replayed or reordered notification harmless.
|
|
|
|
const { isInActorPod } = require('./actor-pod');
|
|
|
|
const AS = 'https://www.w3.org/ns/activitystreams#';
|
|
|
|
const listOf = value => (value === undefined || value === null ? [] : Array.isArray(value) ? value : [value]);
|
|
|
|
/** The URI a value names: a string, or a node's `id`/`@id`; undefined otherwise. */
|
|
function uriOf(value) {
|
|
if (typeof value === 'string') return value;
|
|
if (value && typeof value === 'object') {
|
|
const id = value.id || value['@id'];
|
|
return typeof id === 'string' ? id : undefined;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** The single URI a property holds (one value, a URI or a node with an id); undefined otherwise. */
|
|
function singleUriOf(value) {
|
|
const values = listOf(value);
|
|
return values.length === 1 ? uriOf(values[0]) : undefined;
|
|
}
|
|
|
|
/** The ActivityStreams types a node declares, as full IRIs (`Create`, `as:Create` and the IRI agree). */
|
|
function asTypesOf(node) {
|
|
if (!node || typeof node !== 'object') return [];
|
|
return listOf(node.type || node['@type']).map(type => {
|
|
if (typeof type !== 'string') return undefined;
|
|
if (type.startsWith(AS)) return type;
|
|
if (type.startsWith('as:')) return AS + type.slice('as:'.length);
|
|
return type.includes(':') ? type : AS + type;
|
|
});
|
|
}
|
|
|
|
// `name` is an AS name (`Event`), an `as:` name, or a full IRI: only bare names get the AS namespace.
|
|
const hasAsType = (node, name) => {
|
|
const iri = name.startsWith('as:') ? AS + name.slice('as:'.length) : name.includes(':') ? name : AS + name;
|
|
return asTypesOf(node).includes(iri);
|
|
};
|
|
|
|
const ACTIVITY_TYPES = new Set(['Create', 'Update', 'Delete', 'Join', 'Leave']);
|
|
|
|
/** `objectOwnerUri` value for a handler that checks where the object lies itself. */
|
|
const ANY_POD = Symbol('any pod');
|
|
|
|
/**
|
|
* verifyActivity({ activity, type, emitterUri, objectOwnerUri, objectType, read }) →
|
|
* { ok: true, activity, emitterUri, objectUri, object } (object: the stored object; undefined for a Delete)
|
|
* { ok: false, reason }
|
|
*
|
|
* `activity` is what the watcher or the app's inbox handed over (a URI or a node — only its id is
|
|
* used). `type` is the activity type the handler expects. `emitterUri` is the owner of the outbox the
|
|
* notification names, the `actor` the app's inbox verified as the signer of a delivery, or `null` for
|
|
* an inbox notification (the emitter is then the stored activity's actor). The object must lie in the
|
|
* pod of `objectOwnerUri`, or anywhere with `ANY_POD`. `objectType` is the type the stored object
|
|
* must declare — an ActivityStreams name or a full IRI; not checked for a Delete. `read(uri)` reads a
|
|
* resource as the box owner — for a delivery, as its emitter — and resolves `{ status, body }`
|
|
* (`status` 0 when the request itself failed). Never throws for a refusal; a throwing `read` propagates.
|
|
*/
|
|
async function verifyActivity({ activity, type, emitterUri, objectOwnerUri, objectType, read }) {
|
|
if (!ACTIVITY_TYPES.has(type)) throw new Error(`Unsupported activity type ${type}`);
|
|
if (objectOwnerUri === undefined) throw new Error('objectOwnerUri is required (a URI, or ANY_POD)');
|
|
if (type !== 'Delete' && !objectType) throw new Error(`objectType is required for a ${type}`);
|
|
const refuse = reason => ({ ok: false, reason });
|
|
|
|
const activityUri = uriOf(activity);
|
|
if (!activityUri) return refuse('the activity has no id');
|
|
if (emitterUri !== null && !isInActorPod(activityUri, emitterUri)) {
|
|
return refuse(`the activity ${activityUri} is not in the pod of ${emitterUri}`);
|
|
}
|
|
|
|
const storedActivity = await read(activityUri);
|
|
if (storedActivity.status !== 200 || !storedActivity.body) {
|
|
return refuse(`the pod does not return the activity ${activityUri} (HTTP ${storedActivity.status})`);
|
|
}
|
|
const stored = storedActivity.body;
|
|
if (uriOf(stored) !== activityUri) return refuse(`the pod returned another resource for ${activityUri}`);
|
|
if (!hasAsType(stored, type)) return refuse(`the stored activity ${activityUri} is not a ${type}`);
|
|
const storedActor = singleUriOf(stored.actor);
|
|
let emitter = emitterUri;
|
|
if (emitter === null) {
|
|
if (!storedActor || !isInActorPod(activityUri, storedActor)) {
|
|
return refuse(`the stored activity ${activityUri} is not in the pod of the actor it names`);
|
|
}
|
|
emitter = storedActor;
|
|
} else if (storedActor !== emitter) {
|
|
return refuse(`the stored activity ${activityUri} is not attributed to ${emitter}`);
|
|
}
|
|
|
|
const objectUri = singleUriOf(stored.object);
|
|
if (!objectUri) return refuse(`the stored activity ${activityUri} has no single object id`);
|
|
if (objectOwnerUri !== ANY_POD && !isInActorPod(objectUri, objectOwnerUri)) {
|
|
return refuse(`the object ${objectUri} is not in the pod of ${objectOwnerUri}`);
|
|
}
|
|
|
|
const storedObject = await read(objectUri);
|
|
if (type === 'Delete') {
|
|
const gone = storedObject.status === 404 || storedObject.status === 410;
|
|
const tombstone = storedObject.status === 200 && hasAsType(storedObject.body, 'Tombstone');
|
|
if (!gone && !tombstone) return refuse(`the object ${objectUri} is not deleted (HTTP ${storedObject.status})`);
|
|
return { ok: true, activity: stored, emitterUri: emitter, objectUri, object: undefined };
|
|
}
|
|
if (storedObject.status !== 200 || !storedObject.body) {
|
|
return refuse(`the pod does not return the object ${objectUri} (HTTP ${storedObject.status})`);
|
|
}
|
|
if (uriOf(storedObject.body) !== objectUri) return refuse(`the pod returned another resource for ${objectUri}`);
|
|
if (!hasAsType(storedObject.body, objectType)) return refuse(`the stored object ${objectUri} is not a ${objectType}`);
|
|
return { ok: true, activity: stored, emitterUri: emitter, objectUri, object: storedObject.body };
|
|
}
|
|
|
|
/**
|
|
* The outbox case: an activity `actorUri` emitted about an object in its own pod (a resource it
|
|
* created, updated or deleted).
|
|
*/
|
|
function verifyEmittedActivity({ activity, actorUri, type, objectType, read }) {
|
|
return verifyActivity({ activity, type, emitterUri: actorUri, objectOwnerUri: actorUri, objectType, read });
|
|
}
|
|
|
|
/** `read` for the checks: a GET through the platform's `pod-resources.get`, as the box owner. */
|
|
function podReader(ctx, actorUri) {
|
|
return async uri => {
|
|
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: uri, actorUri });
|
|
return { status: typeof status === 'number' ? status : 0, body };
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A matcher that never throws: on error it logs and reports no match. The watcher runs every
|
|
* service's matchers for an activity in sequence without catching, inside a webhook call the
|
|
* platform does not await — a throw would skip the handlers after this one and end the process.
|
|
* The match itself runs on the webhook's (untrusted) data; it only routes, the verified handler decides.
|
|
*/
|
|
function neverThrowingMatcher(match) {
|
|
return async function (activity, fetcher) {
|
|
try {
|
|
return await match.call(this, activity, fetcher);
|
|
} catch (e) {
|
|
this.logger.error(`${this.name} could not match activity ${uriOf(activity)}: ${e.message}`);
|
|
return { match: false, dereferencedActivity: activity };
|
|
}
|
|
};
|
|
}
|
|
|
|
/** Runs `handle` (bound to the service) on an accepted verdict; logs a refusal or a failure, never throws. */
|
|
function verifiedHandler(box, type, handle, verify) {
|
|
return async function (ctx, activity, actorUri) {
|
|
try {
|
|
const verdict = await verify(activity, actorUri, podReader(ctx, actorUri));
|
|
if (!verdict.ok) {
|
|
this.logger.warn(`${this.name} ignored a ${type} reported in the ${box} of ${actorUri}: ${verdict.reason}`);
|
|
return;
|
|
}
|
|
await handle.call(this, ctx, verdict, actorUri);
|
|
} catch (e) {
|
|
this.logger.error(`${this.name} could not process a ${type} reported in the ${box} of ${actorUri}: ${e.message}`);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* An `onEmit` (outbox handler) that runs `handle(ctx, verdict, actorUri)` only for an activity
|
|
* `verifyActivity` accepts as emitted by `actorUri`, the outbox owner. `objectType` is the type the
|
|
* stored object must declare; `objectIn` is `'emitter'` (default: the object is in the emitter's pod)
|
|
* or `'any'` (the handler checks where the object lies, e.g. the meeting point a Join names, in its
|
|
* creator's pod).
|
|
*/
|
|
function verifiedEmit(type, handle, { objectType, objectIn = 'emitter' } = {}) {
|
|
if (objectIn !== 'emitter' && objectIn !== 'any') throw new Error(`Unknown objectIn ${objectIn}`);
|
|
return verifiedHandler('outbox', type, handle, (activity, actorUri, read) =>
|
|
verifyActivity({
|
|
activity,
|
|
type,
|
|
emitterUri: actorUri,
|
|
objectOwnerUri: objectIn === 'any' ? ANY_POD : actorUri,
|
|
objectType,
|
|
read
|
|
})
|
|
);
|
|
}
|
|
|
|
/**
|
|
* An `onReceive` (inbox handler) that runs `handle(ctx, verdict, actorUri)` only for an activity
|
|
* `verifyActivity` accepts as emitted by the actor it names, about an object in the pod of
|
|
* `actorUri`, the inbox owner. `verdict.emitterUri` is that emitter.
|
|
*/
|
|
function verifiedReceive(type, handle, { objectType } = {}) {
|
|
return verifiedHandler('inbox', type, handle, (activity, actorUri, read) =>
|
|
verifyActivity({ activity, type, emitterUri: null, objectOwnerUri: actorUri, objectType, read })
|
|
);
|
|
}
|
|
|
|
/**
|
|
* An `onReceive` for the APP's own inbox (`@semapps/activitypub` `ActivitiesHandlerMixin`, whose
|
|
* third argument is the inbox owner, the app): runs `handle(ctx, verdict, emitterUri)` only for an
|
|
* activity `verifyActivity` accepts as emitted by the delivered activity's `actor`, the signer the
|
|
* inbox verified. Before anything is read, the activity's URI must lie in that actor's pod; the
|
|
* activity and its object are then read through that actor's pod (`podReader`), the stored activity
|
|
* having to name that same actor. `objectType` and `objectIn` are those of `verifiedEmit`. With
|
|
* `objectIn: 'any'`, an object outside the actor's pod is read through the proxy THEIR actor document
|
|
* declares — for an actor on another server, an answer that server chooses: a handler acting on such
|
|
* an object reads it again where it lies (registration's `meetingPointOf`).
|
|
*/
|
|
function verifiedDelivery(type, handle, { objectType, objectIn = 'emitter' } = {}) {
|
|
if (objectIn !== 'emitter' && objectIn !== 'any') throw new Error(`Unknown objectIn ${objectIn}`);
|
|
return async function (ctx, activity, inboxOwnerUri) {
|
|
try {
|
|
const claimed = activity && typeof activity === 'object' ? singleUriOf(activity.actor) : undefined;
|
|
if (!claimed) {
|
|
this.logger.warn(`${this.name} ignored a ${type} delivered to ${inboxOwnerUri}: it names no single actor`);
|
|
return;
|
|
}
|
|
const verdict = await verifyActivity({
|
|
activity,
|
|
type,
|
|
emitterUri: claimed,
|
|
objectOwnerUri: objectIn === 'any' ? ANY_POD : claimed,
|
|
objectType,
|
|
read: podReader(ctx, claimed)
|
|
});
|
|
if (!verdict.ok) {
|
|
this.logger.warn(`${this.name} ignored a ${type} delivered to ${inboxOwnerUri} as from ${claimed}: ${verdict.reason}`);
|
|
return;
|
|
}
|
|
await handle.call(this, ctx, verdict, verdict.emitterUri);
|
|
} catch (e) {
|
|
this.logger.error(`${this.name} could not process a ${type} delivered to ${inboxOwnerUri}: ${e.message}`);
|
|
}
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
verifyActivity,
|
|
verifyEmittedActivity,
|
|
podReader,
|
|
neverThrowingMatcher,
|
|
verifiedEmit,
|
|
verifiedReceive,
|
|
verifiedDelivery,
|
|
uriOf,
|
|
singleUriOf,
|
|
hasAsType,
|
|
ANY_POD
|
|
};
|