Files
festipod/backend/services/registration.service.js
T

419 lines
26 KiB
JavaScript

const { ACTIVITY_TYPES, ActivitiesHandlerMixin, matchActivity } = require('@semapps/activitypub');
const { arrayOf } = require('@semapps/ldp');
const CONFIG = require('../config/config');
const { isInActorPod } = require('./actor-pod');
const { createKeyedSerializer } = require('./keyed-serializer');
const { isNewerAct, lastAttendance, persistMember, recordAttendance } = require('./attendance-ledger');
const { neverThrowingMatcher, verifiedDelivery, singleUriOf, uriOf, hasAsType } = require('./emitted-activity');
const { participationSecretOf, memberUrn } = require('./member-urn');
const { readItems, expectItem } = require('./collection-items');
const AS = 'https://www.w3.org/ns/activitystreams#';
const APODS_ATTENDEES = 'http://activitypods.org/ns/core#attendees';
const APODS_CONTACTS = 'http://activitypods.org/ns/core#contacts';
const DC_CREATOR = 'http://purl.org/dc/terms/creator';
// Routing matches the activity TYPE alone: routing is a hint, the verified handler re-reads the
// activity and its object from the pod and decides (see emitted-activity.js).
const JOIN_PATTERN = { type: ACTIVITY_TYPES.JOIN };
const LEAVE_PATTERN = { type: ACTIVITY_TYPES.LEAVE };
/** The time the pod stamped on a stored activity (`published`, under any of its spellings). */
const publishedOf = activity => {
if (!activity || typeof activity !== 'object') return undefined;
const value = activity.published ?? activity['as:published'] ?? activity[`${AS}published`];
return value && typeof value === 'object' ? value['@value'] : value;
};
/** The single URI a node names under the first of `keys` it carries (compacted, CURIE or full IRI). */
const linkOf = (node, ...keys) => singleUriOf(keys.map(key => node[key]).find(value => value !== undefined));
/** The first string a property holds (a string, a value node, or a list of either). */
const firstStringOf = value =>
arrayOf(value)
.map(v => (typeof v === 'string' ? v : v && typeof v === 'object' ? v['@value'] : undefined))
.find(v => typeof v === 'string' && v.trim().length > 0);
/**
* What a Join/Leave targets: an `as:Event` whose `dc:creator` owns the pod it lies in. A meeting point
* is anchored to its event by `as:context`; an event signed up to directly is its own anchor. Only on
* the copy the creator's pod serves (`meetingPointOf`) is that creator believed; on the copy read
* through the participant's pod, it only names where to read that one. Resolves `{ ok, eventUri, creatorUri }` or a refusal.
*/
function meetingPointTarget(meetingPoint, meetingPointUri) {
const eventUri = linkOf(meetingPoint, 'context', 'as:context', `${AS}context`) ?? meetingPointUri;
const creatorUri = linkOf(meetingPoint, 'dc:creator', DC_CREATOR);
if (!creatorUri) return { ok: false, reason: `the meeting point ${meetingPointUri} names no single dc:creator` };
if (!isInActorPod(meetingPointUri, creatorUri)) {
return { ok: false, reason: `the meeting point ${meetingPointUri} is not in the pod of its creator ${creatorUri}` };
}
return { ok: true, eventUri, creatorUri };
}
/**
* The notification template for the creator of a target (`pod-notifications.send`, rendered in the
* creator's language): always ANONYMOUS, linking to the event, or to its meeting points when the target
* is one. Every value travels as a template PARAMETER, never inside the template, so no user text is
* compiled.
*
* Never names the participant: naming would need a trustworthy signal that the creator is one of the
* participant's contacts, and the only one the app can read — the participant's `apods:contacts` list —
* is forgeable (a stranger's received `Accept` of a contact `Offer` is added to it with no prior
* request checked; platform defect reported upstream). Until that list can be trusted, every
* notification is anonymous.
*/
function notificationTemplate(change, isMeetingPoint) {
const joined = change === 'join';
const nameParam = isMeetingPoint ? 'meetingPointName' : 'eventName';
const subject = isMeetingPoint
? { en: 'your meeting point', fr: 'votre point de rencontre' }
: { en: 'your event', fr: 'votre événement' };
const title = {
en: `A participant ${joined ? 'joined' : 'left'} ${subject.en} "{{{${nameParam}}}}"`,
fr: `Un participant a ${joined ? 'rejoint' : 'quitté'} ${subject.fr} « {{{${nameParam}}}} »`
};
const link = isMeetingPoint ? '/events/{{encodeUri eventUri}}/meeting-points' : '/events/{{encodeUri eventUri}}';
return { title, actions: [{ caption: { en: 'View', fr: 'Voir' }, link }] };
}
// Participation (decision_2026-10-29_participation-privacy-mechanism; its delivery:
// decision_2026-11-03_participation-delivered-to-the-app): a person joins (leaves) a meeting point with an `as:Join`
// (`as:Leave`) posted to their OWN outbox, naming the meeting point, addressed to the app actor alone —
// so only its author and the app read it. Their pod delivers it to the app's inbox; the creator's inbox
// receives nothing. The backend acts on RECEPTION in the app's inbox (`onReceive` of the semapps
// `ActivitiesHandlerMixin`, as the app framework handles an app registration), and records:
// - in the meeting point's `apods:attendees` collection (the creator's pod, acting as the creator):
// the participant's MEMBER URN, never their WebID — `member-urn.js`, computed from the secret in
// their profile. A Join whose author's profile carries no usable secret is refused, nothing written;
// - in the participant's participations collection (their pod, acting as the participant): the
// meeting point URI — the collection created at the first Join and kept readable by the
// participant's `/contacts` WAC group only (`participations.service.js`).
// Then the creator is notified ANONYMOUSLY — always: naming would need a trustworthy "is the creator a
// contact of the participant" signal, and the only one the app can read (the participant's
// `apods:contacts` list) is forgeable (platform defect reported upstream, see `notificationTemplate`).
// The creator has no other right on the participant: they learn a count and opaque URNs.
// The delivery that reports the act is a pointer (emitted-activity.js, `verifiedDelivery`): the handler
// acts only on the activity and the meeting point as the pods store them, and only for the people they
// really name:
// - the participant is the delivered activity's `actor` — the app's inbox accepts a delivery only
// signed by its actor — and the activity must lie in that actor's pod, its stored copy naming them:
// any server may deliver, nobody acts for someone else;
// - the meeting point is the copy its creator's pod serves (`meetingPointOf`), never the one read
// through the participant's server; the creator is its `dc:creator`, accepted only when the meeting
// point lies in that creator's pod (`meetingPointTarget`);
// - the collections written to are the ones the STORED meeting point and the participant's STORED
// profile name, and must lie in the pod of whoever owns them.
// Joins and leaves of one meeting point are processed one at a time (keyed-serializer.js), and the
// order they reach that lock does not decide: under the lock, an act is applied only when it is newer —
// by the time the participant's pod stamped on it — than the last act applied for that participant and
// meeting point (`attendance-ledger.js`, persisted, which also holds the member URN that act applied). Whatever order the notifications run in, the
// participant ends in the state of their latest act. The member URN is STICKY per (meeting point,
// participant): chosen once and persisted BEFORE any attendee-list write, so every later act reuses it —
// the pair is never listed under two URNs, even after a half-way failure and a secret rewrite. An act is
// recorded only once BOTH collections, READ BACK, show it (collection-items.js — the platform's own
// collection calls answer "empty" on a failed read and resolve on a refused write); one that fails
// half-way is not, nor notified, and the participant's next act on that meeting point re-applies it
// (the two writes are not atomic).
module.exports = {
name: 'registration',
mixins: [ActivitiesHandlerMixin],
dependencies: ['participations'],
created() {
this.serialize = createKeyedSerializer();
},
async started() {
this.logger.info("Registration listens to the Joins and Leaves delivered to the app's inbox");
},
methods: {
/**
* Handles one verified Join/Leave (`change`: 'join' | 'leave') of `participantUri`, its verified
* emitter: resolves the meeting point and the participant, applies the act, then notifies the
* creator when the act changed the participant's state. A refusal is logged; nothing is written.
*/
async onParticipation(ctx, change, { activity, objectUri: meetingPointUri, object: claimedMeetingPoint }, participantUri) {
const refuse = reason => this.logger.warn(`${this.name} refused the ${change} ${uriOf(activity)} of ${participantUri}: ${reason}`);
const claimed = meetingPointTarget(claimedMeetingPoint, meetingPointUri);
if (!claimed.ok) return refuse(claimed.reason);
const target = await this.meetingPointOf(ctx, meetingPointUri, claimed.creatorUri);
if (!target.ok) return refuse(target.reason);
const { meetingPoint } = target;
const participant = await this.participantOf(ctx, participantUri);
if (!participant.ok) return refuse(participant.reason);
const outcome = await this.updateAttendance(ctx, {
change,
eventUri: target.eventUri,
secret: participant.secret,
meetingPointUri,
meetingPoint,
activity,
creatorUri: target.creatorUri,
participantUri,
profileUri: participant.profileUri,
contactsUri: participant.contactsUri
});
if (outcome && outcome.changed) {
await this.notifyCreator(ctx, {
change,
meetingPointUri,
meetingPoint,
eventUri: target.eventUri,
creatorUri: target.creatorUri,
participantUri
});
}
},
/**
* The meeting point as the pod that holds it serves it. The verification read it through the
* participant's pod — for a participant on another server, through the proxy THEIR actor document
* declares, which answers what it wants (another meeting point's list, another name). Only the
* creator's pod speaks for the meeting point: it is read again there, through the creator's access
* (a GET to that pod, signed by the app), and the act uses that copy alone. It must still be an
* `as:Event` anchored by `as:context` whose `dc:creator` is that same creator. Resolves
* `{ ok, meetingPoint, eventUri, creatorUri }` or a refusal.
*/
async meetingPointOf(ctx, meetingPointUri, creatorUri) {
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: meetingPointUri, actorUri: creatorUri });
if (status !== 200 || !body || uriOf(body) !== meetingPointUri) {
return { ok: false, reason: `the pod of ${creatorUri} does not return the meeting point ${meetingPointUri} (HTTP ${status})` };
}
if (!hasAsType(body, 'Event')) return { ok: false, reason: `the meeting point ${meetingPointUri} its pod returns is not an Event` };
const target = meetingPointTarget(body, meetingPointUri);
if (!target.ok) return target;
if (target.creatorUri !== creatorUri) {
return { ok: false, reason: `the meeting point ${meetingPointUri} names ${target.creatorUri} as its creator in its pod, not ${creatorUri}` };
}
return { ok: true, meetingPoint: body, eventUri: target.eventUri, creatorUri };
},
/**
* The participant as their pod describes them, read as the participant: their actor document
* (its `url` — the `as:Profile`, which must lie in their pod — and `apods:contacts`), then the
* profile, which must carry a usable participation secret. Resolves `{ ok, profileUri,
* contactsUri, secret }` or a refusal. `contactsUri` is the participant's `apods:contacts`
* collection — whose `/contacts` WAC group is what may read their participations collection.
*/
async participantOf(ctx, participantUri) {
const read = async uri => {
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: uri, actorUri: participantUri });
return status === 200 && body && uriOf(body) === uri ? body : undefined;
};
const actor = await read(participantUri);
if (!actor) return { ok: false, reason: `the pod does not return the actor ${participantUri}` };
const profileUri = linkOf(actor, 'url', 'as:url', `${AS}url`);
if (!profileUri || !isInActorPod(profileUri, participantUri)) {
return { ok: false, reason: `${participantUri} names no single profile in their pod (${profileUri})` };
}
const profile = await read(profileUri);
if (!profile) return { ok: false, reason: `the pod does not return the profile ${profileUri}` };
const secret = participationSecretOf(profile);
if (!secret) return { ok: false, reason: `the profile ${profileUri} carries no usable participation secret` };
const contactsUri = linkOf(actor, 'apods:contacts', APODS_CONTACTS);
return { ok: true, profileUri, contactsUri, secret };
},
/**
* The attendees collection of a meeting point of `actorUri`'s pod, from the STORED meeting point.
* It is attached asynchronously when the meeting point is created (attendees service), so a
* Join/Leave processed in that window sees none: the meeting point is read again a bounded number
* of times until it names one; only then give up. The collection must lie in the same pod.
*/
async attendeesCollectionOf(ctx, meetingPoint, meetingPointUri, actorUri) {
let collectionUri = linkOf(meetingPoint, 'apods:attendees', APODS_ATTENDEES);
for (let attempt = 0; !collectionUri && attempt < 3; attempt++) {
await new Promise(resolve => setTimeout(resolve, 500));
try {
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: meetingPointUri, actorUri });
if (status === 200 && body) collectionUri = linkOf(body, 'apods:attendees', APODS_ATTENDEES);
} catch (e) {
this.logger.warn(`Could not re-read ${meetingPointUri} while waiting for its attendees collection: ${e.message}`);
}
}
if (collectionUri && !isInActorPod(collectionUri, actorUri)) {
this.logger.warn(`${meetingPointUri} names an attendees collection outside its pod: ignored`);
return undefined;
}
return collectionUri;
},
/**
* The participant's participations collection, under the meeting point's lock: for a Join, the one
* `participations.ensure` makes ready (created if none, contacts-only); for a Leave, the one the
* profile links now, if any. Resolves `{ ok, collectionUri }` — `collectionUri` undefined for a
* Leave without a collection — or `{ ok: false }` once the failure is logged: the act must then
* write nothing and stay unrecorded, so that the next act retries.
*/
async participationsCollectionOf(ctx, { change, participantUri, profileUri, contactsUri }) {
try {
const collectionUri =
change === 'join'
? await ctx.call('participations.ensure', { actorUri: participantUri, profileUri, contactsUri })
: await ctx.call('participations.collectionOf', { actorUri: participantUri, profileUri });
return { ok: true, collectionUri };
} catch (e) {
this.logger.error(
`The participations collection of ${participantUri} is not ready (${e.message}): the ${change} is NOT recorded, the next act retries`
);
return { ok: false };
}
},
/**
* Applies one participant act (`join` / `leave`, the stored activity `activity`) — under the
* meeting point's lock, and only when the act is newer than the last one applied for that member
* there (see the header). A stale act changes nothing; an act without a pod stamp cannot be placed
* and changes nothing. The act is applied to the meeting point's attendees (as the creator), then
* to the participant's participations collection (as the participant), each write read back
* (`applyToAttendees`, `applyToParticipations`), and recorded once both show it; a failed read, a
* refused write or a write that does not show leaves the act unrecorded (logged). Gives up before
* any write once its time is out (`signal`). Resolves `{ changed }` when the act was applied —
* `changed` when it added or removed the member, or flipped the state the ledger held — and
* undefined when it was not.
*
* KNOWN LIMIT — a write in flight at the timeout: an add or remove ALREADY SENT when the time ran
* out still lands, and is not recorded. If a newer act of the same participant was applied in the
* meantime (on a read taken before the late write landed), the collection shows the older act's
* effect until the participant acts again. It is deliberately NOT corrected from the ledger
* afterwards: when nothing superseded the late act (a rejoin, say), the late write is right and it
* is the ledger that is behind — re-applying the ledger would undo it, and the two cases cannot be
* told apart here. Needs a pod slower than the 30 s limit.
*/
async updateAttendance(ctx, { change, eventUri, secret, meetingPointUri, meetingPoint, activity, creatorUri, participantUri, profileUri, contactsUri }) {
const act = { activityUri: uriOf(activity), published: publishedOf(activity), change };
if (Number.isNaN(Date.parse(act.published))) {
this.logger.warn(`${change} ${act.activityUri} of ${participantUri} carries no pod stamp (published): it cannot be ordered, unchanged`);
return undefined;
}
return this.serialize(meetingPointUri, async signal => {
const dataset = CONFIG.MAIN_DATASET;
const last = await lastAttendance(ctx, { dataset, meetingPointUri, participantUri });
if (!isNewerAct(act, last)) {
this.logger.info(`${change} ${act.activityUri} of ${participantUri} on ${meetingPointUri} is not newer than the last act applied (${last.activityUri}, possibly the same act notified again): unchanged`);
return undefined;
}
const collectionUri = await this.attendeesCollectionOf(ctx, meetingPoint, meetingPointUri, creatorUri);
if (!collectionUri) {
this.logger.error(`${meetingPointUri} has no attendees collection: the ${change} ${act.activityUri} of ${participantUri} is NOT recorded, the next act retries`);
return undefined;
}
if (signal.aborted) return undefined;
const participations = await this.participationsCollectionOf(ctx, { change, participantUri, profileUri, contactsUri });
if (!participations.ok || signal.aborted) return undefined;
// The member URN the act applies is STICKY per (meeting point, participant): the one the ledger
// already holds for the pair, else one computed from the secret on the profile now and persisted
// write-once BEFORE any attendee-list write — the act then applies whatever member the ledger holds
// after that write (another act's, if one landed first) — so that every later act (Join or Leave,
// after any half-way failure, timeout or secret rewrite) uses that same URN and the pair is never
// listed under two.
if (signal.aborted) return undefined;
let wrote;
try {
const memberUri =
last && last.member
? last.member
: await persistMember(ctx, { dataset, meetingPointUri, participantUri, member: memberUrn({ participantUri, eventUri, secret }) });
if (signal.aborted) return undefined;
wrote = await this.applyToAttendees(ctx, { change, collectionUri, memberUri, creatorUri, meetingPointUri, signal });
await this.applyToParticipations(ctx, { change, collectionUri: participations.collectionUri, meetingPointUri, participantUri, signal });
} catch (e) {
this.logger.error(`The ${change} ${act.activityUri} of ${participantUri} on ${meetingPointUri} is NOT recorded (${e.message}): the next act retries`);
return undefined;
}
if (signal.aborted) return undefined;
await recordAttendance(ctx, { dataset, meetingPointUri, participantUri, act });
const wasAttending = Boolean(last && last.change === 'join');
return { changed: wrote || wasAttending !== (change === 'join') };
});
},
/**
* Applies `change` for `memberUri` to the meeting point's attendee list `collectionUri`, in the
* creator's pod and as the creator: reads the list — a failed read throws, it never means "not
* listed" — then adds the member on a Join when absent, removes it on a Leave when present (the
* attendees handler's `add` / `remove`, the reference-app calls) and reads the list back: throws
* unless the write shows. Resolves whether it wrote (false when the list already matched the act).
* Throws instead of writing once its time is out (`signal`).
*/
async applyToAttendees(ctx, { change, collectionUri, memberUri, creatorUri, meetingPointUri, signal }) {
const joining = change === 'join';
const attending = (await readItems(ctx, { collectionUri, actorUri: creatorUri })).includes(memberUri);
if (attending === joining) {
this.logger.info(`${memberUri} already ${joining ? 'attends' : 'does not attend'} ${meetingPointUri}`);
return false;
}
if (signal.aborted) throw new Error(`out of time before the ${change} write to ${collectionUri}`);
await ctx.call(joining ? 'attendees.add' : 'attendees.remove', { collectionUri, itemUri: memberUri, actorUri: creatorUri });
await expectItem(ctx, { collectionUri, itemUri: memberUri, actorUri: creatorUri, present: joining });
return true;
},
/**
* Applies `change` to the participant's participations collection `collectionUri`, in their pod and
* as them: adds the meeting point on a Join, removes it on a Leave (the participations handler's
* `add` / `remove`, the reference-app calls — adding an item already there or removing one that is
* not changes nothing, so no read first), then reads the collection back: throws unless it shows
* the act. Nothing to do on a Leave when the participant has no collection (`collectionUri`
* undefined). Throws instead of writing once its time is out (`signal`).
*/
async applyToParticipations(ctx, { change, collectionUri, meetingPointUri, participantUri, signal }) {
if (!collectionUri) {
this.logger.info(`${participantUri} has no participations collection: nothing to remove ${meetingPointUri} from`);
return;
}
const joining = change === 'join';
if (signal.aborted) throw new Error(`out of time before the ${change} write to ${collectionUri}`);
await ctx.call(joining ? 'participations.add' : 'participations.remove', { collectionUri, itemUri: meetingPointUri, actorUri: participantUri });
await expectItem(ctx, { collectionUri, itemUri: meetingPointUri, actorUri: participantUri, present: joining });
},
/**
* Tells the creator of the target (a meeting point or an event) that a participant joined or left
* it — ALWAYS anonymously.
* The participant is never named: the only membership signal the app can read (the participant's
* `apods:contacts` list) is forgeable, so naming cannot be trusted (platform defect reported
* upstream, see `notificationTemplate`). The notification carries no `activity`: given one,
* `pod-notifications.send` fetches the emitter's profile. A creator acting on their own meeting
* point or event is not notified. A failure is logged, never thrown: the act stands.
*/
async notifyCreator(ctx, { change, meetingPointUri, meetingPoint, eventUri, creatorUri, participantUri }) {
const isMeetingPoint = eventUri !== meetingPointUri;
try {
if (creatorUri === participantUri) {
this.logger.info(`${participantUri} acted on their own ${meetingPointUri}: no notification`);
return;
}
this.logger.info(`Notifying ${creatorUri} of a ${change} on ${meetingPointUri}, anonymously`);
await ctx.call('pod-notifications.send', {
template: notificationTemplate(change, isMeetingPoint),
recipientUri: creatorUri,
context: meetingPointUri,
[isMeetingPoint ? 'meetingPointName' : 'eventName']: firstStringOf(meetingPoint.name ?? meetingPoint['as:name'] ?? meetingPoint[`${AS}name`]) ?? '',
eventUri
});
} catch (e) {
this.logger.error(`Could not notify ${creatorUri} of a ${change} on ${meetingPointUri}: ${e.message}`);
}
}
},
activities: {
join: {
match: neverThrowingMatcher(function (activity, fetcher) {
return matchActivity(JOIN_PATTERN, activity, fetcher);
}),
// Delivered to the app's inbox: the participant is the verified emitter; the meeting point may lie
// in any pod (its creator's — checked by `meetingPointTarget`).
onReceive: verifiedDelivery(
'Join',
async function (ctx, verdict, participantUri) {
await this.onParticipation(ctx, 'join', verdict, participantUri);
},
{ objectType: 'Event', objectIn: 'any' }
)
},
leave: {
match: neverThrowingMatcher(function (activity, fetcher) {
return matchActivity(LEAVE_PATTERN, activity, fetcher);
}),
onReceive: verifiedDelivery(
'Leave',
async function (ctx, verdict, participantUri) {
await this.onParticipation(ctx, 'leave', verdict, participantUri);
},
{ objectType: 'Event', objectIn: 'any' }
)
}
}
};