908f7ea973
Participation privacy (decision_2026-10-29_participation-privacy-mechanism): a sign-up is a private Join naming the meeting point, addressed to nobody, handled by the backend on emission. Attendee lists hold member URNs hashed from (WebID, event, a secret kept in the participant's profile), so counts stay public and only contacts recognise anyone; the creator has no read right and is notified anonymously. Each person's participations collection, created by the backend at the first sign-up, is readable by their /contacts WAC group only, never world-readable. The member URN is sticky per (meeting point, participant) and every collection write is verified by reading back, so a refused write leaves the act unrecorded and retryable. The EventRef mechanism and its browser-side provisioning are gone. The backend now runs with WebACL on, as the reference apps do, on its own secure datasets festipod / settings-festipod instead of the pod provider's settings dataset; pod-notifications needs it. The compose header and the deployment leaf say how to reset a stack whose datasets predate this. The rights matrix covers the new mechanism live (148 hard lines), including a forged contact Accept between throwaway accounts: the stranger enters the contacts list (platform defect, reported upstream) but is refused the profile and the participations. Tests may attack as long as they never break the dev environment (rule_tests-never-break-the-dev-environment). Also lands the coherence-pass work that was still uncommitted: contacts as the Pod's social graph and invite links, image upload to the Pod, the meeting-point form, profile completion gate, and the doctrine for them. Superseded briefs and decisions are deleted rather than annotated.
242 lines
12 KiB
JavaScript
242 lines
12 KiB
JavaScript
// What the backend may believe about an activity its watcher reports — the check in front of every
|
|
// Festipod handler fed by that watcher (public-read, catalog, attendees, contacts, registration).
|
|
//
|
|
// 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.
|
|
//
|
|
// 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 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,
|
|
* 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 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 })
|
|
);
|
|
}
|
|
|
|
module.exports = {
|
|
verifyActivity,
|
|
verifyEmittedActivity,
|
|
podReader,
|
|
neverThrowingMatcher,
|
|
verifiedEmit,
|
|
verifiedReceive,
|
|
uriOf,
|
|
singleUriOf,
|
|
hasAsType,
|
|
ANY_POD
|
|
};
|