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:
Sylvain Duchesne
2026-11-03 18:31:24 +01:00
parent ea359488b6
commit 9e2df2478b
20 changed files with 407 additions and 127 deletions
+5 -2
View File
@@ -14,8 +14,11 @@
// `pim:storage` (verified 2026-10-12: PATCH → 204, the profile then lists both) — so it proves
// nothing about who owns a resource.
//
// `actorUri` must come from the server side — the actor whose outbox the webhook fired on — never
// from the activity's own `actor` field, which the emitting client writes.
// `actorUri` must come from the server side — the actor whose outbox a webhook fired on, or the
// signer the app's inbox verified for a delivered activity — never from a field nothing vouches for,
// such as a webhook body's `actor`, which the emitting client writes. One use takes an unvouched
// value as a HINT only: registration's `meetingPointOf` reads a meeting point in the pod its first
// copy names, and believes that pod only once the copy it serves names the same creator.
const WEB_PROTOCOLS = new Set(['http:', 'https:']);
+57 -10
View File
@@ -1,5 +1,7 @@
// 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).
// 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
@@ -16,7 +18,11 @@
// 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.
// 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);
@@ -87,13 +93,14 @@ const ANY_POD = Symbol('any pod');
* { 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.
* `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}`);
@@ -227,6 +234,45 @@ function verifiedReceive(type, handle, { objectType } = {}) {
);
}
/**
* 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,
@@ -234,6 +280,7 @@ module.exports = {
neverThrowingMatcher,
verifiedEmit,
verifiedReceive,
verifiedDelivery,
uriOf,
singleUriOf,
hasAsType,
+93 -2
View File
@@ -1,5 +1,5 @@
const { test, expect, describe } = require('bun:test');
const { verifyEmittedActivity, verifyActivity, verifiedEmit, verifiedReceive, neverThrowingMatcher, ANY_POD } = require('./emitted-activity');
const { verifyEmittedActivity, verifyActivity, verifiedEmit, verifiedReceive, verifiedDelivery, neverThrowingMatcher, ANY_POD } = require('./emitted-activity');
const ALICE = 'http://localhost:3000/alice';
const BOB = 'http://localhost:3000/bob';
@@ -265,7 +265,7 @@ describe('verifiedReceive / verifiedEmit with objectIn "any"', () => {
});
});
describe('verifiedEmit — a Join from a participant\'s outbox (the registration flow)', () => {
describe('verifiedEmit — a Join from a participant\'s outbox', () => {
const MP = `${ALICE}/data/events/mp1`;
const JOIN = `${BOB}/outbox/join-1`;
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP };
@@ -298,3 +298,94 @@ describe('verifiedEmit — a Join from a participant\'s outbox (the registration
expect(service.logger.w).toContain('not attributed to');
});
});
describe("verifiedDelivery — a Join delivered to the app's inbox: the body's actor is a claim", () => {
const APP = 'http://localhost:3005/as/actor/app';
const MALLORY = 'http://localhost:3000/mallory';
const MP = `${ALICE}/data/events/mp1`;
const JOIN = `${BOB}/data/activities/join-1`;
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP, to: APP };
const meetingPoint = { id: MP, type: 'Event', context: `${ALICE}/data/events/e1` };
/** A pod answering `stored` (uri → body), recording every read as [uri, actorUri]. */
function setup(stored = { [JOIN]: join, [MP]: meetingPoint }) {
const reads = [];
const ctx = {
call: async (_action, { resourceUri, actorUri }) => {
reads.push([resourceUri, actorUri]);
const body = stored[resourceUri];
return body ? { status: 200, body } : { status: 404 };
}
};
const logs = { warn: [], error: [] };
const service = { name: 'test', logger: { info() {}, warn: m => logs.warn.push(m), error: m => logs.error.push(m) } };
const ran = [];
const deliver = (body, options = { objectType: 'Event', objectIn: 'any' }, handle = async (_ctx, verdict, emitterUri) => ran.push([emitterUri, verdict.object])) =>
verifiedDelivery('Join', handle, options).call(service, ctx, body, APP);
return { reads, logs, ran, deliver };
}
test("the normal case: the claimed actor's pod holds the activity naming them — the handler runs for them, on the STORED object, everything read through their pod", async () => {
const w = setup();
await w.deliver({ ...join, object: { id: `${ALICE}/data/events/other`, type: 'Event' } });
expect(w.ran).toEqual([[BOB, meetingPoint]]);
expect(w.reads).toEqual([[JOIN, BOB], [MP, BOB]]);
expect(w.logs.warn).toEqual([]);
});
test("a body claiming another actor for an activity of Bob's pod is refused before anything is read", async () => {
const w = setup();
await w.deliver({ ...join, actor: MALLORY });
expect(w.ran).toEqual([]);
expect(w.reads).toEqual([]);
expect(w.logs.warn.some(m => m.includes(`not in the pod of ${MALLORY}`))).toBe(true);
});
test("an activity held in Mallory's pod claiming Bob as its actor is refused before anything is read", async () => {
const forged = `${MALLORY}/data/activities/join-1`;
const w = setup({ [forged]: { ...join, id: forged }, [MP]: meetingPoint });
await w.deliver({ ...join, id: forged });
expect(w.ran).toEqual([]);
expect(w.reads).toEqual([]);
expect(w.logs.warn.some(m => m.includes(`not in the pod of ${BOB}`))).toBe(true);
});
test("an activity of Bob's pod whose stored copy names someone else is refused", async () => {
const w = setup({ [JOIN]: { ...join, actor: ALICE }, [MP]: meetingPoint });
await w.deliver(join);
expect(w.ran).toEqual([]);
expect(w.logs.warn.some(m => m.includes('not attributed to'))).toBe(true);
});
test('a body naming no single actor, or no body at all, is refused without a read', async () => {
for (const body of [{ ...join, actor: undefined }, { ...join, actor: [BOB, ALICE] }, JOIN, undefined]) {
const w = setup();
await w.deliver(body);
expect(w.ran).toEqual([]);
expect(w.reads).toEqual([]);
expect(w.logs.warn.some(m => m.includes('names no single actor'))).toBe(true);
}
});
test("an activity the claimed actor's pod does not hold is refused", async () => {
const w = setup({ [MP]: meetingPoint });
await w.deliver(join);
expect(w.ran).toEqual([]);
expect(w.logs.warn.some(m => m.includes('does not return the activity'))).toBe(true);
});
test('by default the object must lie in the emitter\'s pod; objectIn "any" lets the handler check it', async () => {
const w = setup();
await w.deliver(join, { objectType: 'Event' });
expect(w.ran).toEqual([]);
expect(w.logs.warn.some(m => m.includes(`not in the pod of ${BOB}`))).toBe(true);
});
test('a failing handler or read is logged, never thrown', async () => {
const w = setup();
await w.deliver(join, { objectType: 'Event', objectIn: 'any' }, async () => {
throw new Error('boom');
});
expect(w.logs.error.some(m => m.includes('boom'))).toBe(true);
});
});
+9 -4
View File
@@ -85,7 +85,7 @@ const MEMBER = 'http://festipod.org/attendanceMember';
* A collection (a key of `state.items`) answers a GET as the pod serves an unpaged collection: its
* `items` inline.
*/
function world({ stored = {}, items = {}, acls = {}, ledger = {}, delays = {}, fail = {}, timeoutMs } = {}) {
function world({ stored = {}, items = {}, acls = {}, ledger = {}, delays = {}, fail = {}, proxied = {}, timeoutMs } = {}) {
const calls = [];
const logs = { info: [], warn: [], error: [] };
const logger = { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) };
@@ -137,6 +137,11 @@ function world({ stored = {}, items = {}, acls = {}, ledger = {}, delays = {}, f
return { id: APP };
case 'pod-resources.get':
await wait(delays[`read:${params.resourceUri}`]);
// `proxied[reader][uri]`: what the reader's OWN server answers for a resource outside the
// reader's pod — the platform reads it through the proxy the reader's actor document declares.
if (proxied[params.actorUri] && params.resourceUri in proxied[params.actorUri] && !params.resourceUri.startsWith(`${params.actorUri}/`)) {
return { ok: true, status: 200, body: proxied[params.actorUri][params.resourceUri] };
}
return read(params.resourceUri);
case 'pod-collections.getItems': {
// As the platform does: the body's items, whatever the status — `[]` on a failed read.
@@ -263,10 +268,10 @@ function world({ stored = {}, items = {}, acls = {}, ledger = {}, delays = {}, f
}
for (const [name, fn] of Object.entries(registration.methods)) registrationSvc[name] = fn.bind(registrationSvc);
/** Notifies the registration service of an activity emitted in `actorUri`'s outbox. */
const emit = (key, hint, actorUri) => registration.activities[key].onEmit.call(registrationSvc, ctx, hint, actorUri);
/** Delivers an activity to the app's inbox, as the participant's pod does: the body is all the handler gets. */
const deliver = (key, body) => registration.activities[key].onReceive.call(registrationSvc, ctx, body, APP);
return { ctx, calls, state, logs, registration: registrationSvc, participations: participationsSvc, emit };
return { ctx, calls, state, logs, registration: registrationSvc, participations: participationsSvc, deliver };
}
module.exports = {
+62 -29
View File
@@ -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' }
)
+56 -17
View File
@@ -45,8 +45,8 @@ function setup({ acts = [], stored = {}, ...options } = {}) {
return world({ stored: { ...MEETING_POINTS, ...Object.fromEntries(acts.map(a => [a.id, a])), ...stored }, ...options });
}
const emitJoin = (w, act, actorUri = act.actor) => w.emit('join', act, actorUri);
const emitLeave = (w, act, actorUri = act.actor) => w.emit('leave', act, actorUri);
const emitJoin = (w, act) => w.deliver('join', act);
const emitLeave = (w, act) => w.deliver('leave', act);
const attendees = (w, mp = MP1) => w.state.items[attendeesOf(mp)] || [];
const participationsUri = (w, who = BOB) => {
const link = w.state.stored[profileOf(who)][FP_PARTICIPATIONS];
@@ -84,7 +84,7 @@ const protectedAcl = (uri, group = groupOf(BOB)) => [
{ '@id': `http://localhost:3000/_acl${new URL(uri).pathname}#Control`, 'acl:mode': 'acl:Control', 'acl:agent': APP }
];
describe("registration — a Join emitted in the participant's outbox", () => {
describe("registration — a Join delivered to the app's inbox", () => {
test('the normal case: the member URN joins the list, the meeting point joins the participant\'s collection, the act is recorded', async () => {
const j = join(MP1);
const w = setup({ acts: [j] });
@@ -105,20 +105,59 @@ describe("registration — a Join emitted in the participant's outbox", () => {
expect(w.logs.error).toEqual([]);
});
test('the webhook body is a hint: its actor and its embedded object are ignored', async () => {
test('the delivered body is a pointer: its embedded object is ignored, the stored meeting point is used', async () => {
const j = join(MP1);
const w = setup({ acts: [j] });
await emitJoin(w, { ...j, actor: MALLORY, object: { id: MP2, type: 'Event', 'apods:attendees': `${MALLORY}/data/c` } }, BOB);
await emitJoin(w, { ...j, object: { id: MP2, type: 'Event', 'apods:attendees': `${MALLORY}/data/c` } });
expect(attendees(w)).toEqual([urnOf(BOB)]);
expect(attendees(w, MP2)).toEqual([]);
expect(w.state.items[`${MALLORY}/data/c`]).toBeUndefined();
});
test('the act is handled on emission only: the creator\'s inbox has no handler', () => {
expect(registration.activities.join.onReceive).toBeUndefined();
expect(registration.activities.leave.onReceive).toBeUndefined();
expect(typeof registration.activities.join.onEmit).toBe('function');
expect(typeof registration.activities.leave.onEmit).toBe('function');
test("nobody joins or leaves for someone else: a delivery claiming Mallory for Bob's activity, or Bob for Mallory's, writes nothing", async () => {
const j = join(MP1);
const forged = { ...join(MP1, T0, MALLORY), actor: BOB };
const w = setup({ acts: [j, forged] });
await emitJoin(w, { ...j, actor: MALLORY });
await emitJoin(w, forged);
expect(writes(w.calls)).toEqual([]);
expect(attendees(w)).toEqual([]);
});
test("the meeting point is the one its creator's pod serves, not the copy the participant's server hands over", async () => {
// Bob's server answers the reads made through him: for MP1 (in Alice's pod), a forged copy
// naming MP2's attendee list and a name of its choosing.
const j = join(MP1);
const forged = meetingPoint(MP1, { name: 'Forged title', 'apods:attendees': attendeesOf(MP2) });
const w = setup({ acts: [j], proxied: { [BOB]: { [MP1]: forged } } });
await emitJoin(w, j);
expect(attendees(w)).toEqual([urnOf(BOB)]);
expect(attendees(w, MP2)).toEqual([]);
expect(w.state.notifications.map(n => n.meetingPointName)).toEqual(['Rendez-vous mp1']);
expect(w.calls.some(c => c.action === 'pod-resources.get' && c.params.resourceUri === MP1 && c.params.actorUri === ALICE)).toBe(true);
});
test("a meeting point its creator's pod does not serve, or serves under another creator, writes nothing", async () => {
const j = join(MP1);
for (const options of [
{ fail: { read: { [MP1]: 404 } }, proxied: { [BOB]: { [MP1]: meetingPoint(MP1) } }, contains: `does not return the meeting point ${MP1}` },
{ stored: { [MP1]: meetingPoint(MP1, { 'dc:creator': CAROL }) }, proxied: { [BOB]: { [MP1]: meetingPoint(MP1) } }, contains: `not in the pod of its creator ${CAROL}` },
{ stored: { [MP1]: meetingPoint(MP1, { type: 'Note' }) }, proxied: { [BOB]: { [MP1]: meetingPoint(MP1) } }, contains: 'is not an Event' }
]) {
const { contains, stored = {}, ...rest } = options;
const w = setup({ acts: [j], stored, ...rest });
await emitJoin(w, j);
expect(writes(w.calls)).toEqual([]);
expect(w.logs.warn.some(m => m.includes(contains))).toBe(true);
}
});
test("the act is handled on reception in the app's inbox only: no outbox handler", () => {
expect(registration.mixins).toContain(require('@semapps/activitypub').ActivitiesHandlerMixin);
expect(registration.activities.join.onEmit).toBeUndefined();
expect(registration.activities.leave.onEmit).toBeUndefined();
expect(typeof registration.activities.join.onReceive).toBe('function');
expect(typeof registration.activities.leave.onReceive).toBe('function');
});
});
@@ -661,7 +700,7 @@ describe('registration — a participant who rewrites their profile secret betwe
// THE DEFECT the sticky member fixes: a Join whose list add lands but a later step fails is left
// unrecorded; the participant rewrites their secret and joins again — that must not list a second URN.
const onEmitWith = (w, ctx, key, act) => registration.activities[key].onEmit.call(w.registration, ctx, act, act.actor);
const deliverWith = (w, ctx, key, act) => registration.activities[key].onReceive.call(w.registration, ctx, act, APP);
test('partial failure (the pod refuses the participations write after the list add) + secret rewrite + Join: ONE URN; a Leave empties the list', async () => {
const j1 = join(MP1, T0, BOB, 1);
@@ -697,13 +736,13 @@ describe('registration — a participant who rewrites their profile secret betwe
return w.ctx.call(action, params);
}
};
await onEmitWith(w, ctx, 'join', j1);
await deliverWith(w, ctx, 'join', j1);
expect(attendees(w)).toEqual([urnOf(BOB)]);
expect(ledgerOf(w).activityUri).toBeUndefined();
rotate(w, BOB, secretN(5));
await onEmitWith(w, ctx, 'join', j2);
await deliverWith(w, ctx, 'join', j2);
expect(attendees(w)).toEqual([urnOf(BOB)]);
await onEmitWith(w, ctx, 'leave', l);
await deliverWith(w, ctx, 'leave', l);
expect(attendees(w)).toEqual([]);
});
@@ -722,15 +761,15 @@ describe('registration — a participant who rewrites their profile secret betwe
return w.ctx.call(action, params);
}
};
const first = onEmitWith(w, ctx, 'join', j1); // abandoned at 80 ms, its member write still in flight
const first = deliverWith(w, ctx, 'join', j1); // abandoned at 80 ms, its member write still in flight
await new Promise(resolve => setTimeout(resolve, 100));
rotate(w, BOB, secretN(6));
await onEmitWith(w, ctx, 'join', j2); // persists, lists and records the NEW secret's URN
await deliverWith(w, ctx, 'join', j2); // persists, lists and records the NEW secret's URN
await first;
await new Promise(resolve => setTimeout(resolve, 150));
expect(attendees(w)).toEqual([urnWith(BOB, secretN(6))]);
expect(ledgerOf(w).member).toBe(urnWith(BOB, secretN(6))); // the late write was a no-op
await onEmitWith(w, ctx, 'leave', l);
await deliverWith(w, ctx, 'leave', l);
expect(attendees(w)).toEqual([]);
});