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:
@@ -32,8 +32,8 @@ Fonctionnalité: Smoke e2e de la pile ActivityPods locale
|
||||
# L'app s'installe sur le Pod (consentement de l'agent d'autorisation, ou pour un
|
||||
# scénario sans navigateur l'endpoint d'enregistrement — voir l'étape
|
||||
# « installée sur le Pod »). Une fois installée, l'activité `as:Join` postée sur
|
||||
# l'outbox de l'utilisateur, sans destinataire, est traitée par le backend à
|
||||
# l'émission : il inscrit dans la collection `apods:attendees` du point de
|
||||
# l'outbox de l'utilisateur, adressée à l'acteur de l'app seul, est livrée dans
|
||||
# l'inbox de l'app et traitée par le backend à sa réception : il inscrit dans la collection `apods:attendees` du point de
|
||||
# rencontre l'URN de membre dérivée du secret de participation du profil
|
||||
# (écrit avant le Join, comme le fait l'app) — jamais le WebID
|
||||
# (decision_2026-10-29_participation-privacy-mechanism). Le point de rencontre est créé dans le
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
participationSecretOf,
|
||||
podLogin,
|
||||
postOutboxActivity,
|
||||
appActorUri,
|
||||
readAttendees,
|
||||
readResource,
|
||||
recordUri,
|
||||
@@ -279,15 +280,16 @@ When('la session rejoint le point de rencontre', async function (this: FestipodW
|
||||
// decision_2026-10-29_participation-privacy-mechanism): the participation
|
||||
// secret on my profile first (the backend refuses a Join whose author's
|
||||
// profile carries none), then an as:Join naming the meeting point, posted to
|
||||
// my OWN outbox with NO addressee — not public, not addressed to the creator.
|
||||
// my OWN outbox and addressed to the app actor alone — not public, not
|
||||
// addressed to the creator (decision_2026-11-03_participation-delivered-to-the-app).
|
||||
await ensureParticipationSecret(pod);
|
||||
await postOutboxActivity(pod, 'Join', mpUri);
|
||||
await postOutboxActivity(pod, 'Join', mpUri, appActorUri(apodsStackConfig()));
|
||||
});
|
||||
|
||||
When('la session quitte le point de rencontre', async function (this: FestipodWorld) {
|
||||
const pod = session(this);
|
||||
const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');
|
||||
await postOutboxActivity(pod, 'Leave', mpUri);
|
||||
await postOutboxActivity(pod, 'Leave', mpUri, appActorUri(apodsStackConfig()));
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -400,7 +400,7 @@ const isSecret = (value: unknown) => typeof value === 'string' && /^[0-9a-f]{64}
|
||||
|
||||
// --- joining: the secret first, then a Join naming the meeting point --------------
|
||||
|
||||
test("the first join: a secret is generated and written to my profile BEFORE the Join is posted; the Join names the meeting point with no addressee; my participations collection, linked once the backend processed it, confirms it", async () => {
|
||||
test("the first join: a secret is generated and written to my profile BEFORE the Join is posted; the Join names the meeting point, addressed to the app actor alone; my participations collection, linked once the backend processed it, confirms it", async () => {
|
||||
await mount();
|
||||
|
||||
const served = server.reads;
|
||||
@@ -412,8 +412,8 @@ test("the first join: a secret is generated and written to my profile BEFORE the
|
||||
expect(server.profileWrites).toHaveLength(1);
|
||||
expect(Object.keys(server.profileWrites[0]!)).toEqual([FP_SECRET]);
|
||||
expect(isSecret(server.profileWrites[0]![FP_SECRET])).toBe(true);
|
||||
// Exactly these keys: no `to`, no `cc`.
|
||||
expect(server.posted).toEqual([{ '@context': 'https://www.w3.org/ns/activitystreams', type: 'Join', actor: ALICE, object: M }]);
|
||||
// Exactly these keys: `to` the app actor (the client id the backend URL derives), no `cc`.
|
||||
expect(server.posted).toEqual([{ '@context': 'https://www.w3.org/ns/activitystreams', type: 'Join', actor: ALICE, object: M, to: `${BACKEND}/as/actor/app` }]);
|
||||
|
||||
// The membership shows at once (overlay) while the backend has processed
|
||||
// nothing: my profile links no collection yet, the list is empty.
|
||||
@@ -493,7 +493,7 @@ test("a leave is never undone by a stale read: the membership is my participatio
|
||||
const served = server.reads;
|
||||
await latest!.leaveMeetingPoint(M);
|
||||
const leftAt = Date.now();
|
||||
expect(server.posted).toEqual([{ '@context': 'https://www.w3.org/ns/activitystreams', type: 'Leave', actor: ALICE, object: M }]);
|
||||
expect(server.posted).toEqual([{ '@context': 'https://www.w3.org/ns/activitystreams', type: 'Leave', actor: ALICE, object: M, to: `${BACKEND}/as/actor/app` }]);
|
||||
expect(server.profileWrites).toEqual([]);
|
||||
// The leave shows at once (overlay). The re-reads now land on STALE data —
|
||||
// the list still holds her URN, her collection still lists M: the count
|
||||
|
||||
@@ -65,12 +65,12 @@
|
||||
*
|
||||
* WHAT LIVES BESIDE REFINE. A participation is not a CRUD write and the
|
||||
* refine-providers package implements no `custom` method: joining or leaving
|
||||
* a meeting point POSTs an `as:Join`/`as:Leave` naming it, with no addressee,
|
||||
* to the user's own outbox (`data/outbox.ts`). The FIRST join ensures the
|
||||
* a meeting point POSTs an `as:Join`/`as:Leave` naming it, addressed to the app
|
||||
* actor alone, to the user's own outbox (`data/outbox.ts`). The FIRST join ensures the
|
||||
* participation secret on my profile beforehand — generated when absent and
|
||||
* written through the data provider's `update` — since the backend refuses a
|
||||
* Join whose author has none. The backend handles the activity on emission
|
||||
* and updates the attendee list and my participations collection
|
||||
* Join whose author has none. The backend handles the activity on its delivery
|
||||
* to the app's inbox and updates the attendee list and my participations collection
|
||||
* asynchronously — so the join/leave reflects in an optimistic overlay here,
|
||||
* confirmed by re-reading my participations collection (see
|
||||
* `settleMembershipActs` in `refineDerive.ts`), with a bounded schedule of
|
||||
@@ -103,7 +103,7 @@ import { fetchCatalogEvents, fetchCatalogMeetingPointIds } from '../data/catalog
|
||||
import { postJoinActivity, postLeaveActivity } from '../data/outbox';
|
||||
import { contactsCollectionOf, fetchCollectionMemberIds, postContactRequest, postEventInvitation } from '../data/contacts';
|
||||
import { useCurrentSession } from '../utils/currentSession';
|
||||
import { getBackendUrl } from '../utils/runtimeConfig';
|
||||
import { getBackendUrl, resolveActivityPodsAuthConfig } from '../utils/runtimeConfig';
|
||||
import { useCurrentPrincipal } from '../utils/currentPrincipal';
|
||||
import type { BootstrapResult } from '../utils/ngBootstrap';
|
||||
import { RefineProvider, useBackend } from '../refine/RefineProvider';
|
||||
@@ -187,6 +187,11 @@ interface MyParticipationsRead {
|
||||
}
|
||||
const NO_PARTICIPATIONS_READ: MyParticipationsRead = { generation: 0, attempted: 0, ids: [] };
|
||||
|
||||
/** The Festipod app actor, the only addressee of a Join or a Leave: the Solid-OIDC client id, which is the actor's URI. */
|
||||
function appActorUri(): string {
|
||||
return resolveActivityPodsAuthConfig(window.location.origin).clientId;
|
||||
}
|
||||
|
||||
/** What a contact's profile gives this session beyond their display fields. */
|
||||
interface ContactKeys {
|
||||
/** Their participation secret — what recognising them in an attendee list takes. */
|
||||
@@ -1178,7 +1183,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
console.log(`${logPrefix} joinMeetingPoint (refine):`, meetingPointId);
|
||||
// The secret first: without it the backend records nothing.
|
||||
await ensureParticipationSecret();
|
||||
await postJoinActivity({ meetingPointUri: mp.id, session });
|
||||
await postJoinActivity({ meetingPointUri: mp.id, session, appActorUri: appActorUri() });
|
||||
afterMembershipAct(mp.id, 'join');
|
||||
}, [logPrefix, allMeetingPoints, session, webId, ensureParticipationSecret, afterMembershipAct]);
|
||||
|
||||
@@ -1201,7 +1206,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
throw new Error(msg);
|
||||
}
|
||||
console.log(`${logPrefix} leaveMeetingPoint (refine):`, meetingPointId);
|
||||
await postLeaveActivity({ meetingPointUri: mp.id, session });
|
||||
await postLeaveActivity({ meetingPointUri: mp.id, session, appActorUri: appActorUri() });
|
||||
afterMembershipAct(mp.id, 'leave');
|
||||
}, [logPrefix, allMeetingPoints, myMeetingPointIds, session, webId, afterMembershipAct]);
|
||||
|
||||
|
||||
@@ -1028,7 +1028,7 @@ export const parsedFeatures: ParsedFeature[] = [
|
||||
}
|
||||
],
|
||||
"filePath": "src/modules/event/features/e2e-activitypods.feature",
|
||||
"rawContent": "# language: fr\n@EVENT @priority-1 @e2e-activitypods\nFonctionnalité: Smoke e2e de la pile ActivityPods locale\n En tant que développeur validant le branchement ActivityPods\n Je pilote la pile locale réelle (pod provider + backend Festipod + app servie)\n Afin de prouver que les flux du modèle passent réellement par le Pod.\n\n # PÉRIMÈTRE. Cette couche est DISTINCTE des suites `@data`/`@e2e`/`@multibrowser`\n # suspendues avec le workstream NextGraph : elle ne signe aucun wallet et ne monte\n # aucun broker. Elle n'existe que si la pile locale tourne — son mode d'emploi\n # reproductible est le concept `data-layer`,\n # `cookbook_local-activitypods-platform.md` (pod provider :3000, backend Festipod\n # :3005 par défaut). Le chargement de l'app reprend le motif `@e2e` du\n # repo (serveur réel + Playwright), pas le wallet partagé.\n\n Scénario: La pile locale est joignable et le backend sert son application\n Alors le pod provider local répond sur son endpoint OpenID\n Et le backend Festipod sert la description de son application\n\n @browser\n Scénario: L'application démarre en variante ActivityPods et propose la connexion au Pod\n Quand l'application Festipod est chargée depuis la pile locale\n Alors l'entrée de connexion au Pod est proposée\n Et aucune erreur runtime n'a été émise au chargement\n\n Scénario: Un événement et un point de rencontre rattaché par as:context sont créés sur le Pod\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Quand je crée un événement \"Concert smoke\" et un point de rencontre \"Rdv entrée\" rattaché par as:context\n Alors la lecture du point de rencontre expose son lien as:context vers l'événement\n Et l'événement et le point de rencontre partagent le conteneur as:Event\n\n # L'app s'installe sur le Pod (consentement de l'agent d'autorisation, ou pour un\n # scénario sans navigateur l'endpoint d'enregistrement — voir l'étape\n # « installée sur le Pod »). Une fois installée, l'activité `as:Join` postée sur\n # l'outbox de l'utilisateur, sans destinataire, est traitée par le backend à\n # l'émission : il inscrit dans la collection `apods:attendees` du point de\n # rencontre l'URN de membre dérivée du secret de participation du profil\n # (écrit avant le Join, comme le fait l'app) — jamais le WebID\n # (decision_2026-10-29_participation-privacy-mechanism). Le point de rencontre est créé dans le\n # conteneur enregistré (`as/event`) ; sa collection `apods:attendees` est attachée\n # par le backend lui-même, à la création — le scénario la lit, il ne la crée pas.\n Scénario: Rejoindre puis quitter un point de rencontre met à jour la collection apods:attendees\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Et l'application Festipod est installée sur le Pod de la session\n Et un point de rencontre \"Rdv entrée\" rattaché à un événement, avec sa collection apods:attendees\n Quand la session rejoint le point de rencontre\n Alors la collection apods:attendees contient la session\n Quand la session quitte le point de rencontre\n Alors la collection apods:attendees ne contient plus la session\n\n # Dépend du scénario précédent pour la mise en place, mais lit la collection\n # elle-même : le compteur est le total de la collection (jamais les profils\n # résolus) et une URN de membre, que rien ne résout, reste comptée et rendue\n # anonyme.\n Scénario: Le compteur et la liste des participants tiennent une référence non résolue\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Et l'application Festipod est installée sur le Pod de la session\n Et un point de rencontre \"Rdv entrée\" rattaché à un événement, avec sa collection apods:attendees\n Quand la session rejoint le point de rencontre\n Alors le compteur du point de rencontre compte cette inscription\n Et la liste des participants rend ce participant comme anonyme\n",
|
||||
"rawContent": "# language: fr\n@EVENT @priority-1 @e2e-activitypods\nFonctionnalité: Smoke e2e de la pile ActivityPods locale\n En tant que développeur validant le branchement ActivityPods\n Je pilote la pile locale réelle (pod provider + backend Festipod + app servie)\n Afin de prouver que les flux du modèle passent réellement par le Pod.\n\n # PÉRIMÈTRE. Cette couche est DISTINCTE des suites `@data`/`@e2e`/`@multibrowser`\n # suspendues avec le workstream NextGraph : elle ne signe aucun wallet et ne monte\n # aucun broker. Elle n'existe que si la pile locale tourne — son mode d'emploi\n # reproductible est le concept `data-layer`,\n # `cookbook_local-activitypods-platform.md` (pod provider :3000, backend Festipod\n # :3005 par défaut). Le chargement de l'app reprend le motif `@e2e` du\n # repo (serveur réel + Playwright), pas le wallet partagé.\n\n Scénario: La pile locale est joignable et le backend sert son application\n Alors le pod provider local répond sur son endpoint OpenID\n Et le backend Festipod sert la description de son application\n\n @browser\n Scénario: L'application démarre en variante ActivityPods et propose la connexion au Pod\n Quand l'application Festipod est chargée depuis la pile locale\n Alors l'entrée de connexion au Pod est proposée\n Et aucune erreur runtime n'a été émise au chargement\n\n Scénario: Un événement et un point de rencontre rattaché par as:context sont créés sur le Pod\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Quand je crée un événement \"Concert smoke\" et un point de rencontre \"Rdv entrée\" rattaché par as:context\n Alors la lecture du point de rencontre expose son lien as:context vers l'événement\n Et l'événement et le point de rencontre partagent le conteneur as:Event\n\n # L'app s'installe sur le Pod (consentement de l'agent d'autorisation, ou pour un\n # scénario sans navigateur l'endpoint d'enregistrement — voir l'étape\n # « installée sur le Pod »). Une fois installée, l'activité `as:Join` postée sur\n # l'outbox de l'utilisateur, adressée à l'acteur de l'app seul, est livrée dans\n # l'inbox de l'app et traitée par le backend à sa réception : il inscrit dans la collection `apods:attendees` du point de\n # rencontre l'URN de membre dérivée du secret de participation du profil\n # (écrit avant le Join, comme le fait l'app) — jamais le WebID\n # (decision_2026-10-29_participation-privacy-mechanism). Le point de rencontre est créé dans le\n # conteneur enregistré (`as/event`) ; sa collection `apods:attendees` est attachée\n # par le backend lui-même, à la création — le scénario la lit, il ne la crée pas.\n Scénario: Rejoindre puis quitter un point de rencontre met à jour la collection apods:attendees\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Et l'application Festipod est installée sur le Pod de la session\n Et un point de rencontre \"Rdv entrée\" rattaché à un événement, avec sa collection apods:attendees\n Quand la session rejoint le point de rencontre\n Alors la collection apods:attendees contient la session\n Quand la session quitte le point de rencontre\n Alors la collection apods:attendees ne contient plus la session\n\n # Dépend du scénario précédent pour la mise en place, mais lit la collection\n # elle-même : le compteur est le total de la collection (jamais les profils\n # résolus) et une URN de membre, que rien ne résout, reste comptée et rendue\n # anonyme.\n Scénario: Le compteur et la liste des participants tiennent une référence non résolue\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Et l'application Festipod est installée sur le Pod de la session\n Et un point de rencontre \"Rdv entrée\" rattaché à un événement, avec sa collection apods:attendees\n Quand la session rejoint le point de rencontre\n Alors le compteur du point de rencontre compte cette inscription\n Et la liste des participants rend ce participant comme anonyme\n",
|
||||
"screenIds": []
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,12 +5,15 @@ import type { OutboxActivityInput } from './outbox';
|
||||
const ACTOR = 'http://localhost:3000/alice';
|
||||
const OUTBOX = 'http://localhost:3000/alice/outbox';
|
||||
// What a Join names (decision_2026-10-29_participation-privacy-mechanism): the
|
||||
// meeting point itself, with no addressee.
|
||||
// meeting point itself; its only addressee is the app actor
|
||||
// (decision_2026-11-03_participation-delivered-to-the-app).
|
||||
const MEETING_POINT = 'http://localhost:3000/bob/data/events/concert-bar';
|
||||
const APP_ACTOR = 'http://localhost:3005/as/actor/app';
|
||||
|
||||
const input: OutboxActivityInput = {
|
||||
meetingPointUri: MEETING_POINT,
|
||||
session: { webId: ACTOR, token: 'tok-1' },
|
||||
appActorUri: APP_ACTOR,
|
||||
};
|
||||
|
||||
/** Installs a scripted fetch double, returning the calls it saw. */
|
||||
@@ -38,7 +41,7 @@ function jsonResponse(body: unknown, status = 200): Response {
|
||||
});
|
||||
}
|
||||
|
||||
test('postJoinActivity discovers the outbox from the actor document and posts a Join naming the meeting point, with no addressee', async () => {
|
||||
test('postJoinActivity discovers the outbox from the actor document and posts a Join naming the meeting point, addressed to the app actor alone', async () => {
|
||||
const double = installFetch((url) => {
|
||||
if (url === ACTOR) return jsonResponse({ id: ACTOR, outbox: OUTBOX, url: `${ACTOR}/as/profile` });
|
||||
return jsonResponse({}, 201);
|
||||
@@ -53,20 +56,21 @@ test('postJoinActivity discovers the outbox from the actor document and posts a
|
||||
const headers = postCall!.init.headers as Record<string, string>;
|
||||
expect(headers['Authorization']).toBe('Bearer tok-1');
|
||||
expect(headers['Content-Type']).toBe('application/ld+json');
|
||||
// Exactly these keys: no `to`, no `cc` — the activity stays readable by its
|
||||
// author and the app only.
|
||||
// Exactly these keys: `to` the app actor, no `cc`, not public — the activity
|
||||
// stays readable by its author and the app only.
|
||||
expect(JSON.parse(String(postCall!.init.body))).toEqual({
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Join',
|
||||
actor: ACTOR,
|
||||
object: MEETING_POINT,
|
||||
to: APP_ACTOR,
|
||||
});
|
||||
} finally {
|
||||
double.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('postLeaveActivity posts a Leave naming the meeting point, with no addressee', async () => {
|
||||
test('postLeaveActivity posts a Leave naming the meeting point, addressed to the app actor alone', async () => {
|
||||
const double = installFetch((url) => {
|
||||
if (url === ACTOR) return jsonResponse({ id: ACTOR, outbox: OUTBOX });
|
||||
return jsonResponse({}, 201);
|
||||
@@ -78,6 +82,7 @@ test('postLeaveActivity posts a Leave naming the meeting point, with no addresse
|
||||
type: 'Leave',
|
||||
actor: ACTOR,
|
||||
object: MEETING_POINT,
|
||||
to: APP_ACTOR,
|
||||
});
|
||||
} finally {
|
||||
double.restore();
|
||||
|
||||
@@ -7,13 +7,14 @@
|
||||
* posted to the USER'S OWN outbox with the user's token, a withdrawal an
|
||||
* `as:Leave` on the same path (decision_2026-10-29_participation-privacy-mechanism).
|
||||
*
|
||||
* THE ACTIVITY. Its `object` is the meeting point itself, and it carries no
|
||||
* `to`/`cc`: not public, not addressed to the meeting point's creator. A
|
||||
* non-public activity is readable only by its author and the app (the app reads
|
||||
* the outbox through `apods:ReadOutbox`), so the target stays hidden from
|
||||
* everyone else. The Festipod backend handles it on emission — it records the
|
||||
* author's member URN in the meeting point's attendee list and the meeting
|
||||
* point in the author's participations collection.
|
||||
* THE ACTIVITY. Its `object` is the meeting point itself, and its only
|
||||
* addressee is the Festipod app actor (`to`): not public, not addressed to the
|
||||
* meeting point's creator. A non-public activity is readable only by its author
|
||||
* and its addressees, so the target stays hidden from everyone else. The user's
|
||||
* pod delivers it to the app's inbox, where the Festipod backend handles it —
|
||||
* it records the author's member URN in the meeting point's attendee list and
|
||||
* the meeting point in the author's participations collection
|
||||
* (decision_2026-11-03_participation-delivered-to-the-app).
|
||||
*
|
||||
* THE FLOW. The outbox URI is read off the user's own actor (WebID) document
|
||||
* (`outbox` predicate) at post time — the reference integration resolves it
|
||||
@@ -37,20 +38,23 @@ export interface OutboxActivityInput {
|
||||
meetingPointUri: string;
|
||||
/** The authenticated session — whose outbox receives the activity. */
|
||||
session: CurrentSession;
|
||||
/** URI of the Festipod app actor, the activity's only addressee (the Solid-OIDC client id). */
|
||||
appActorUri: string;
|
||||
}
|
||||
|
||||
async function postActivity(type: 'Join' | 'Leave', input: OutboxActivityInput): Promise<void> {
|
||||
const { meetingPointUri, session } = input;
|
||||
const { meetingPointUri, session, appActorUri } = input;
|
||||
const actor = await fetchActorDocument(session.webId, session.token);
|
||||
if (!actor.outbox) {
|
||||
throw new Error(`actor document for ${session.webId} carries no outbox — cannot post ${type}`);
|
||||
}
|
||||
// No addressee: the activity stays readable by its author and the app only.
|
||||
// The app actor alone: the activity stays readable by its author and the app only.
|
||||
const activity: Record<string, unknown> = {
|
||||
'@context': ACTIVITY_CONTEXT,
|
||||
type,
|
||||
actor: session.webId,
|
||||
object: meetingPointUri,
|
||||
to: appActorUri,
|
||||
};
|
||||
|
||||
const response = await fetch(actor.outbox, {
|
||||
|
||||
@@ -435,77 +435,77 @@ export const stepDefinitions: StepDefinitionInfo[] = [
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then(\"le pod provider local répond sur son endpoint OpenID\", async function (this: FestipodWorld) {\n const { podProviderUrl } = apodsStackConfig();\n const status = await urlStatus(`${podProviderUrl}/.well-known/openid-configuration`);\n expect(status, `GET ${podProviderUrl}/.well-known/openid-configuration`).to.equal(200);\n});",
|
||||
"lineNumber": 95
|
||||
"lineNumber": 100
|
||||
},
|
||||
{
|
||||
"pattern": "le backend Festipod sert la description de son application",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then(\"le backend Festipod sert la description de son application\", async function (this: FestipodWorld) {\n const { backendUrl } = apodsStackConfig();\n const description = await readResource(undefined, `${backendUrl}/app`);\n // The description IS the OIDC client the app registers with: it must carry\n // the interop access-need group the Pod grants, not merely answer 200.\n expect(JSON.stringify(description.type)).to.contain('Application');\n expect(linkId(description['interop:hasAccessNeedGroup']), 'access-need group').to.be.a('string');\n});",
|
||||
"lineNumber": 101
|
||||
"lineNumber": 106
|
||||
},
|
||||
{
|
||||
"pattern": "l'application Festipod est chargée depuis la pile locale",
|
||||
"keyword": "When",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "When(\"l'application Festipod est chargée depuis la pile locale\", async function (this: FestipodWorld) {\n const appUrl = requires(this, this.apods.appUrl, 'app URL (set by the @browser Before hook)');\n expect(this.page, 'a Playwright page (set by the @browser Before hook)').to.not.be.null;\n await this.page!.goto(appUrl, { waitUntil: 'domcontentloaded' });",
|
||||
"lineNumber": 112
|
||||
"lineNumber": 117
|
||||
},
|
||||
{
|
||||
"pattern": "l'entrée de connexion au Pod est proposée",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then(\"l'entrée de connexion au Pod est proposée\", async function (this: FestipodWorld) {\n const { podProviderUrl } = apodsStackConfig();\n const host = new URL(podProviderUrl).host;\n await this.page!.waitForSelector(`text=Se connecter avec ${host}`, { timeout: 30000 });",
|
||||
"lineNumber": 120
|
||||
"lineNumber": 125
|
||||
},
|
||||
{
|
||||
"pattern": "aucune erreur runtime n'a été émise au chargement",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then(\"aucune erreur runtime n'a été émise au chargement\", function (this: FestipodWorld) {\n expect(\n this.pageErrors,\n `Des erreurs runtime ont été émises au chargement de l'app :\\n` +\n this.pageErrors.map((e, i) => ` [${i + 1}] ${e}`).join('\\n'),\n ).to.be.empty;\n});",
|
||||
"lineNumber": 126
|
||||
"lineNumber": 131
|
||||
},
|
||||
{
|
||||
"pattern": "l'application Festipod est installée sur le Pod de la session",
|
||||
"keyword": "Given",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Given(\"l'application Festipod est installée sur le Pod de la session\", async function (this: FestipodWorld) {\n const pod = session(this);\n const config = apodsStackConfig();\n // The registered agent IS the Solid-OIDC client_id: the app actor URI the\n // backend serves as both the actor and its Client ID Document. Registering\n // against `/app` (the alias) is refused — the agent must be the actor URI.\n const description = await readResource(undefined, `${config.backendUrl}/app`);\n const appUri = requires(this, recordUri(description), 'app actor URI (the id of GET /app)');\n const groupUri = requires(this, linkId(description['interop:hasAccessNeedGroup']), 'access-need group URI');\n const group = await readResource(undefined, groupUri);\n const acceptedAccessNeeds = arrayOf(group['interop:hasAccessNeed']);\n const acceptedSpecialRights = arrayOf(group['apods:hasSpecialRights']);\n\n const response = await fetch(`${config.podProviderUrl}/.auth-agent/app-registrations/register`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${pod.token}` },\n body: JSON.stringify({ appUri, acceptedAccessNeeds, acceptedSpecialRights }),\n });",
|
||||
"lineNumber": 143
|
||||
"lineNumber": 148
|
||||
},
|
||||
{
|
||||
"pattern": "la session rejoint le point de rencontre",
|
||||
"keyword": "When",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "When('la session rejoint le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n // The app's own path (src/shared/data/outbox.ts): as:Join to the user's own\n // outbox, `object` = the meeting point URI and `to` = the HOST (the record's\n // dc:creator). Without the addressee the backend silently drops the activity;\n // and an activity carrying no routing target never reaches the host.\n const host = linkId((await readResource(pod, mpUri))['dc:creator']) ?? pod.webId;\n await postOutboxActivity(pod, 'Join', mpUri, host);\n});",
|
||||
"lineNumber": 267
|
||||
"sourceCode": "When('la session rejoint le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n // The app's own sequence (refineData.tsx, src/shared/data/outbox.ts —\n // decision_2026-10-29_participation-privacy-mechanism): the participation\n // secret on my profile first (the backend refuses a Join whose author's\n // profile carries none), then an as:Join naming the meeting point, posted to\n // my OWN outbox and addressed to the app actor alone — not public, not\n // addressed to the creator (decision_2026-11-03_participation-delivered-to-the-app).\n await ensureParticipationSecret(pod);\n await postOutboxActivity(pod, 'Join', mpUri, appActorUri(apodsStackConfig()));\n});",
|
||||
"lineNumber": 276
|
||||
},
|
||||
{
|
||||
"pattern": "la session quitte le point de rencontre",
|
||||
"keyword": "When",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "When('la session quitte le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n const host = linkId((await readResource(pod, mpUri))['dc:creator']) ?? pod.webId;\n await postOutboxActivity(pod, 'Leave', mpUri, host);\n});",
|
||||
"lineNumber": 278
|
||||
"sourceCode": "When('la session quitte le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n await postOutboxActivity(pod, 'Leave', mpUri, appActorUri(apodsStackConfig()));\n});",
|
||||
"lineNumber": 289
|
||||
},
|
||||
{
|
||||
"pattern": "la collection apods:attendees contient la session",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then('la collection apods:attendees contient la session', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));\n expect(members.map(linkId)).to.include(pod.webId);\n});",
|
||||
"lineNumber": 285
|
||||
"sourceCode": "Then('la collection apods:attendees contient la session', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const urn = await ownMemberUrn(this);\n const members = (await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(urn))).map(linkId);\n expect(members, 'the member URN derived from my secret is listed').to.include(urn);\n expect(members, 'the list never holds a WebID').to.not.include(pod.webId);\n});",
|
||||
"lineNumber": 308
|
||||
},
|
||||
{
|
||||
"pattern": "le compteur du point de rencontre compte cette inscription",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then('le compteur du point de rencontre compte cette inscription', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));\n const collection = await readResource(pod, collectionUri);\n // The count is the collection total — kept as-is, never the number of\n // RESOLVED profiles, so it stays total for a reader who resolves nobody.\n const raw = collection.totalItems ?? collection['as:totalItems'];\n const total = raw === undefined ? members.length : Number(raw);\n expect(total, 'the count reflects the joined session').to.be.greaterThan(0);\n expect(members.map(linkId), 'the counted session is the one listed').to.include(pod.webId);\n});",
|
||||
"lineNumber": 304
|
||||
"sourceCode": "Then('le compteur du point de rencontre compte cette inscription', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const urn = await ownMemberUrn(this);\n const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(urn));\n const collection = await readResource(pod, collectionUri);\n // The count is the collection total — kept as-is, never the number of\n // RESOLVED profiles, so it stays total for a reader who resolves nobody.\n const raw = collection.totalItems ?? collection['as:totalItems'];\n const total = raw === undefined ? members.length : Number(raw);\n expect(total, 'the count reflects the joined session').to.be.greaterThan(0);\n expect(members.map(linkId), 'the counted session is the one listed (its member URN)').to.include(urn);\n});",
|
||||
"lineNumber": 331
|
||||
},
|
||||
{
|
||||
"pattern": "la liste des participants rend ce participant comme anonyme",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then('la liste des participants rend ce participant comme anonyme', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));\n const joined = members.find(member => linkId(member) === pod.webId);\n expect(joined, 'the joined session is listed').to.not.be.undefined;\n // \"Anonymous\" is the read-side fact: the member travels as a REFERENCE with no\n // profile the reader can resolve — the list has nothing but the URI to render,\n // which is exactly what the app shows for a participant it cannot dereference.\n const profileFields = ['name', 'preferredUsername', 'as:name', 'nickname'];\n const hasProfile =\n typeof joined === 'object' && joined !== null && profileFields.some(field => (joined as Record<string, unknown>)[field] !== undefined);\n expect(hasProfile, 'a listed participant carries no resolved profile (rendered anonymous)').to.be.false;\n});",
|
||||
"lineNumber": 317
|
||||
"sourceCode": "Then('la liste des participants rend ce participant comme anonyme', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const urn = await ownMemberUrn(this);\n const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(urn));\n const joined = members.find(member => linkId(member) === urn);\n expect(joined, 'the joined session is listed').to.not.be.undefined;\n // \"Anonymous\" is the read-side fact: the member is a member URN — a value in\n // no pod, no WebID, nothing to dereference — so a reader who cannot compute\n // it from a contact's secret has nothing but the count to render.\n expect(members.map(linkId).every(id => typeof id === 'string' && id.startsWith(MEMBER_URN_PREFIX)), 'every member is a member URN').to.be.true;\n const profileFields = ['name', 'preferredUsername', 'as:name', 'nickname'];\n const hasProfile =\n typeof joined === 'object' && joined !== null && profileFields.some(field => (joined as Record<string, unknown>)[field] !== undefined);\n expect(hasProfile, 'a listed participant carries no resolved profile (rendered anonymous)').to.be.false;\n});",
|
||||
"lineNumber": 345
|
||||
},
|
||||
{
|
||||
"pattern": "le portefeuille contient des données de test",
|
||||
|
||||
@@ -46,6 +46,11 @@ export function apodsStackConfig(): ApodsStackConfig {
|
||||
};
|
||||
}
|
||||
|
||||
/** The Festipod app actor of a stack: the only addressee of a Join or a Leave. */
|
||||
export function appActorUri(config: ApodsStackConfig): string {
|
||||
return `${config.backendUrl}/as/actor/app`;
|
||||
}
|
||||
|
||||
/** An authenticated Pod session, plus the actor references the flows need. */
|
||||
export interface ApodsSession {
|
||||
webId: string;
|
||||
@@ -203,11 +208,12 @@ export const PARTICIPATIONS = 'http://festipod.org/participations';
|
||||
/**
|
||||
* Post a participation activity (`Join` / `Leave`) to the session's own outbox,
|
||||
* the shape the app posts (`src/shared/data/outbox.ts`): `object` = the meeting
|
||||
* point, NO `to`/`cc` — not public, addressed to no one, so only its author and
|
||||
* the app read it. Returns the stored activity's URI (`Location`).
|
||||
* point, `to` = the app actor alone (`appActorUri`) — not public, not addressed to
|
||||
* the creator, so only its author and the app read it; the pod delivers it to the
|
||||
* app's inbox. Returns the stored activity's URI (`Location`).
|
||||
*/
|
||||
export async function postOutboxActivity(session: ApodsSession, type: 'Join' | 'Leave', object: string): Promise<string | undefined> {
|
||||
const activity: JsonRecord = { '@context': AS_CONTEXT, type, actor: session.webId, object };
|
||||
export async function postOutboxActivity(session: ApodsSession, type: 'Join' | 'Leave', object: string, to: string): Promise<string | undefined> {
|
||||
const activity: JsonRecord = { '@context': AS_CONTEXT, type, actor: session.webId, object, to };
|
||||
const response = await request(session.outbox, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/ld+json' },
|
||||
|
||||
Reference in New Issue
Block a user