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
+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([]);
});