// 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 };