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
+15 -10
View File
@@ -12,8 +12,9 @@
* meeting point through her app and Bob joins it by tapping (the forged-contact
* group) — so EVERY participation goes the way the app does it: the app writes
* the participant's secret on their profile with their app-session token (the
* data provider's update), then posts the Join to their own outbox with no
* addressee (decision_2026-10-29_participation-privacy-mechanism). Carol (a
* data provider's update), then posts the Join to their own outbox, addressed
* to the app actor alone (decision_2026-10-29_participation-privacy-mechanism,
* decision_2026-11-03_participation-delivered-to-the-app). Carol (a
* non-contact) and Bob (the creator) read the event through their own app.
* Direct HTTP with each user's own token for the access-control lines (faster,
* and precise about statuses). Carol installs the app programmatically (the
@@ -781,9 +782,14 @@ async function ownActivity(session: ApodsSession, type: 'Join' | 'Leave', object
return items.find(a => typeIs(a, type) && linkId(a['object']) === object);
}
/** The addressing fields an activity carries — a participation activity must carry none. */
/** The addressees an activity names, as `field=uri` — a participation activity names the app actor alone, in `to`. */
const ADDRESSING = ['to', 'cc', 'bto', 'bcc', 'audience', 'as:to', 'as:cc', 'as:bto', 'as:bcc', 'as:audience'];
const addressingOf = (activity: JsonRecord): string[] => ADDRESSING.filter(k => activity[k] !== undefined);
const addressingOf = (activity: JsonRecord): string[] =>
ADDRESSING.flatMap(k => arrayOf(activity[k]).map(v => `${k.replace(/^as:/, '')}=${linkId(v) ?? JSON.stringify(v)}`));
const addressedToTheAppAlone = (activity: JsonRecord): boolean => {
const addressees = addressingOf(activity);
return addressees.length === 1 && addressees[0] === `to=${BACKEND}/as/actor/app`;
};
/**
* The pod's refusal of a COLLECTION: 401/403, or its 404 — which counts as one
@@ -910,15 +916,14 @@ async function join(): Promise<void> {
return { ok: !!back && own === sent, actual: `${reads.length} app-token read(s) after the PUT; secret returned: ${!!back} under the key ${JSON.stringify(key)}; her own read: ${own === sent ? 'same secret' : own ? 'ANOTHER secret' : 'none'}` };
});
// THE JOIN: in her own outbox, addressed to no one; only she reads it.
await m.hard('join.activity-shape', G, "Alice reads her own outbox: the Join names M and is addressed to NO ONE (not public, not the creator)", 'one Join, actor Alice, object M, no to/cc/bto/bcc/audience', async () => {
// THE JOIN: in her own outbox, addressed to the app actor alone; only she and the app read it.
await m.hard('join.activity-shape', G, "Alice reads her own outbox: the Join names M and is addressed to the app actor ALONE (not public, not the creator)", 'one Join, actor Alice, object M, to = the app actor, no other addressee', async () => {
const alice = need(S.alice, 'Alice');
const M = need(S.M, 'M');
const act = await poll(() => ownActivity(alice, 'Join', M), v => !!v, 15000);
if (!act) return { ok: false, actual: `no Join naming M in her outbox` };
S.aliceJoinM = recordUri(act);
const addressed = addressingOf(act);
return { ok: linkId(act['actor']) === alice.webId && addressed.length === 0 && !!S.aliceJoinM, actual: `${S.aliceJoinM}: actor ${linkId(act['actor'])}, object ${linkId(act['object'])}, addressing fields: ${addressed.join(',') || 'none'}` };
return { ok: linkId(act['actor']) === alice.webId && addressedToTheAppAlone(act) && !!S.aliceJoinM, actual: `${S.aliceJoinM}: actor ${linkId(act['actor'])}, object ${linkId(act['object'])}, addressees: ${addressingOf(act).join(',') || 'none'}` };
});
await m.hard('join.activity-author-reads', G, 'CONTROL: Alice (the author) GETs her Join', '200', async () => {
const r = await http('GET', need(S.aliceJoinM, "Alice's Join (join.activity-shape)"), { token: need(S.alice, 'Alice').token });
@@ -1101,9 +1106,9 @@ async function leave(): Promise<void> {
await mpCard(page, TITLE_M).getByRole('button', { name: /✓ Inscrit/ }).waitFor({ timeout: 20000 }).catch(() => {});
return tapMeetingPoint(page, TITLE_M, /✓ Inscrit/, /^S'inscrire$/);
});
await m.hard('leave.activity-shape', G, "Alice reads her own outbox: the Leave names M and is addressed to no one", 'a Leave, object M, no addressing field', async () => {
await m.hard('leave.activity-shape', G, "Alice reads her own outbox: the Leave names M and is addressed to the app actor alone", 'a Leave, object M, to = the app actor, no other addressee', async () => {
const act = await poll(() => ownActivity(need(S.alice, 'Alice'), 'Leave', need(S.M, 'M')), v => !!v, 15000);
return { ok: !!act && addressingOf(act).length === 0, actual: act ? `${recordUri(act)}: addressing fields: ${addressingOf(act).join(',') || 'none'}` : 'no Leave naming M in her outbox' };
return { ok: !!act && addressedToTheAppAlone(act), actual: act ? `${recordUri(act)}: addressees: ${addressingOf(act).join(',') || 'none'}` : 'no Leave naming M in her outbox' };
});
await m.hard('leave.collection', G, "Alice's member URN disappears from M's attendee list", 'items ∌ her URN within 30 s (read 200)', async () => {
need(S.joined.M, 'the join on M landed (join.collection)');