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,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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user