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:
@@ -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([]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user