Deliver Join and Leave to the app's inbox instead of an unaddressed outbox post
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.
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
const { ACTIVITY_TYPES, matchActivity } = require('@semapps/activitypub');
|
||||
const { PodActivitiesHandlerMixin } = require('@activitypods/app');
|
||||
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, verifiedEmit, singleUriOf, uriOf } = require('./emitted-activity');
|
||||
const { neverThrowingMatcher, verifiedDelivery, singleUriOf, uriOf, hasAsType } = require('./emitted-activity');
|
||||
const { participationSecretOf, memberUrn } = require('./member-urn');
|
||||
const { readItems, expectItem } = require('./collection-items');
|
||||
|
||||
@@ -37,9 +36,12 @@ const firstStringOf = value =>
|
||||
|
||||
/**
|
||||
* What a Join/Leave targets: the stored object must be a meeting point — an `as:Event` (checked by
|
||||
* `verifiedEmit`) anchored to an event by `as:context` — whose creator is its `dc:creator`, believed
|
||||
* only because the meeting point lies in that creator's pod (the pod stamps the creator, and nobody
|
||||
* can store a resource in someone else's pod). Resolves `{ ok, eventUri, creatorUri }` or a refusal.
|
||||
* `verifiedDelivery`, and by `meetingPointOf` on the creator's copy) anchored to an event by
|
||||
* `as:context` — whose creator is its `dc:creator`, required to own the pod the meeting point lies
|
||||
* in. Only on the copy the creator's pod serves (`meetingPointOf`) is that creator believed (the pod
|
||||
* stamps the creator, and nobody can store a resource in someone else's pod); 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`);
|
||||
@@ -81,10 +83,12 @@ function notificationTemplate(change) {
|
||||
};
|
||||
}
|
||||
|
||||
// Participation (decision_2026-10-29_participation-privacy-mechanism): a person joins (leaves) a
|
||||
// meeting point with an `as:Join` (`as:Leave`) posted to their OWN outbox, naming the meeting point,
|
||||
// addressed to no one — so only its author and the app read it. The creator's inbox receives nothing.
|
||||
// The backend acts on EMISSION (`onEmit`, the welcometomyplace registration pattern), and records:
|
||||
// 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;
|
||||
@@ -95,11 +99,15 @@ function notificationTemplate(change) {
|
||||
// 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 webhook that reports the act is a hint (emitted-activity.js): the handler acts only on the
|
||||
// activity and the meeting point as the pod stores them, and only for the people they really name:
|
||||
// - the participant is the owner of the outbox the webhook fired on, and the stored activity's actor;
|
||||
// - the creator is the meeting point's `dc:creator`, accepted only when the meeting point lies in
|
||||
// that creator's pod (`meetingPointTarget`);
|
||||
// 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
|
||||
@@ -115,24 +123,27 @@ function notificationTemplate(change) {
|
||||
// (the two writes are not atomic).
|
||||
module.exports = {
|
||||
name: 'registration',
|
||||
mixins: [PodActivitiesHandlerMixin],
|
||||
mixins: [ActivitiesHandlerMixin],
|
||||
dependencies: ['participations'],
|
||||
created() {
|
||||
this.serialize = createKeyedSerializer();
|
||||
},
|
||||
async started() {
|
||||
this.logger.info('Registration watcher subscribed');
|
||||
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`, the outbox
|
||||
* owner: resolves the meeting point and the participant, applies the act, then notifies the
|
||||
* 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: meetingPoint }, participantUri) {
|
||||
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 target = meetingPointTarget(meetingPoint, meetingPointUri);
|
||||
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, {
|
||||
@@ -158,6 +169,28 @@ module.exports = {
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 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
|
||||
@@ -364,12 +397,12 @@ module.exports = {
|
||||
match: neverThrowingMatcher(function (activity, fetcher) {
|
||||
return matchActivity(JOIN_PATTERN, activity, fetcher);
|
||||
}),
|
||||
// The participant's outbox: `actorUri` is the participant; the meeting point may lie in any pod
|
||||
// (its creator's — checked by `meetingPointTarget`).
|
||||
onEmit: verifiedEmit(
|
||||
// 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, actorUri) {
|
||||
await this.onParticipation(ctx, 'join', verdict, actorUri);
|
||||
async function (ctx, verdict, participantUri) {
|
||||
await this.onParticipation(ctx, 'join', verdict, participantUri);
|
||||
},
|
||||
{ objectType: 'Event', objectIn: 'any' }
|
||||
)
|
||||
@@ -378,10 +411,10 @@ module.exports = {
|
||||
match: neverThrowingMatcher(function (activity, fetcher) {
|
||||
return matchActivity(LEAVE_PATTERN, activity, fetcher);
|
||||
}),
|
||||
onEmit: verifiedEmit(
|
||||
onReceive: verifiedDelivery(
|
||||
'Leave',
|
||||
async function (ctx, verdict, actorUri) {
|
||||
await this.onParticipation(ctx, 'leave', verdict, actorUri);
|
||||
async function (ctx, verdict, participantUri) {
|
||||
await this.onParticipation(ctx, 'leave', verdict, participantUri);
|
||||
},
|
||||
{ objectType: 'Event', objectIn: 'any' }
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user