Close the adversarial review round: auth hardening, data fidelity against the platform context, backend grants and docker coherence

This commit is contained in:
Sylvain Duchesne
2026-10-10 09:54:53 +02:00
parent 13d9f179ef
commit 8dfc574346
42 changed files with 11138 additions and 235 deletions
+52 -11
View File
@@ -33,7 +33,7 @@
* resolved — undefined for everyone else.
*/
import React, { useState, useCallback, useEffect, useMemo, type ReactNode } from 'react';
import React, { useState, useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react';
import { useList, useOne, useCreate, useUpdate, useInvalidate } from '@refinedev/core';
import type { FpEventData, FpUserData, FpMeetingPointData } from '../data/types';
import { fetchActorDocument, fetchActorProfileRecord } from '../data/actor';
@@ -109,10 +109,12 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
const webId = session?.webId;
const token = session?.token;
// WHO AM I — the Solid identity the session signed in as (display + the
// membership key; the attendees collection carries actor URIs). The app's own
// entity id space (`currentUserId`) is the PROFILE document URI, resolved
// from the actor document below — the two never mix.
// WHO AM I — the Solid identity the session signed in as. On this backend the
// ACTOR URI (`webId`) is the canonical user key: the attendees collection
// carries actor URIs, so `currentUserId` is the actor URI too, and the profile
// record fetched for display is stamped with it (its own document URI is not
// the identity this app compares memberships against). `currentPrincipal` is
// display/log attribution only.
const currentPrincipal = useCurrentPrincipal();
// --- READS over the shared as/Event container ------------------------------
@@ -187,8 +189,13 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
});
const myProfile: FpUserData | undefined = useMemo(() => {
const record: unknown = myProfileQuery.result;
return record !== undefined ? bindings.profile.toDomain(record) : undefined;
}, [myProfileQuery.result, bindings]);
if (record === undefined) return undefined;
const domain = bindings.profile.toDomain(record);
// Stamp the ACTOR URI as the id: it is the key memberships and every other
// user reference live in. The profile's own document URI (which the binding
// resolves) is deliberately not the id the app compares against.
return webId ? { ...domain, id: webId } : domain;
}, [myProfileQuery.result, bindings, webId]);
const currentUser = myProfile;
const currentUserId = myProfile?.id ?? '';
@@ -216,11 +223,25 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
if (record === undefined) {
refused.push(id);
} else {
fresh.push(bindings.profile.toDomain(record));
// Stamp the ACTOR URI the record was fetched for — the same key an
// attendee reference carries — so `resolveParticipantUser` can match
// it. The profile document URI differs and would never match.
fresh.push({ ...bindings.profile.toDomain(record), id });
}
}
if (cancelled) return;
if (fresh.length) setResolvedProfiles(prev => [...prev, ...fresh]);
if (fresh.length) {
setResolvedProfiles(prev => [...prev, ...fresh]);
// A successful fetch is a sign that access may have been granted since
// an earlier refusal: drop the refusal marks for the attendees still in
// scope, so a later pass retries them — a refusal is not permanent for
// the page's life.
setUnresolved(prev => {
if (prev.size === 0) return prev;
const next = new Set([...prev].filter(id => !attendeeIds.includes(id)));
return next.size === prev.size ? prev : next;
});
}
if (refused.length) setUnresolved(prev => new Set([...prev, ...refused]));
})();
return () => { cancelled = true; };
@@ -315,6 +336,24 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
const { mutateAsync: updateRecord } = useUpdate();
const invalidate = useInvalidate();
// A join/leave is maintained asynchronously by the backend, so the immediate
// invalidate can re-fetch BEFORE the collection is updated — and nothing
// re-reads afterwards (the list subscription only reports membership changes).
// ONE delayed re-invalidation per action closes that gap: no polling, at most
// one pending timer, cancelled on unmount.
const revalidateTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (revalidateTimer.current) clearTimeout(revalidateTimer.current);
revalidateTimer.current = null;
}, []);
const scheduleListRevalidate = useCallback(() => {
if (revalidateTimer.current) clearTimeout(revalidateTimer.current);
revalidateTimer.current = setTimeout(() => {
revalidateTimer.current = null;
void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
}, 1500);
}, [invalidate, bindings]);
const createEvent = useCallback(async (event: Omit<FpEventData, 'id'>): Promise<FpEventData> => {
console.log(`${logPrefix} createEvent (refine):`, event.title);
const response = await createRecord({
@@ -381,7 +420,8 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
await postJoinActivity({ meetingPointUri: mp.id, hostUri: mp.hostId, session });
setOverlay(prev => new Map(prev).set(mp.id, 'join'));
void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
}, [logPrefix, derived.meetingPoints, session, webId, invalidate, bindings]);
scheduleListRevalidate();
}, [logPrefix, derived.meetingPoints, session, webId, invalidate, bindings, scheduleListRevalidate]);
const leaveMeetingPoint = useCallback(async (meetingPointId: string, userId?: string) => {
if (userId && userId !== webId) {
@@ -403,7 +443,8 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
await postLeaveActivity({ meetingPointUri: mp.id, hostUri: mp.hostId, session });
setOverlay(prev => new Map(prev).set(mp.id, 'leave'));
void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
}, [logPrefix, derived.meetingPoints, participations, session, webId, invalidate, bindings]);
scheduleListRevalidate();
}, [logPrefix, derived.meetingPoints, participations, session, webId, invalidate, bindings, scheduleListRevalidate]);
// --- The rest of the contract -------------------------------------------------------
const addFriend = useCallback((friendId: string) => {
File diff suppressed because one or more lines are too long
+18 -5
View File
@@ -72,17 +72,30 @@ test('postJoinActivity discovers the outbox from the actor document and posts a
}
});
test('postLeaveActivity posts a Leave, and a missing host means no to field', async () => {
test('postLeaveActivity posts a Leave addressed to the host', async () => {
const double = installFetch((url) => {
if (url === ACTOR) return jsonResponse({ id: ACTOR, outbox: OUTBOX });
return jsonResponse({}, 201);
});
try {
await postLeaveActivity(input(undefined));
await postLeaveActivity(input('http://localhost:3000/bob'));
const postCall = double.calls[1]!;
const body = JSON.parse(String(postCall.init.body));
expect(body.type).toBe('Leave');
expect(body).not.toHaveProperty('to');
expect(body.to).toBe('http://localhost:3000/bob');
} finally {
double.restore();
}
});
// A Join/Leave with no addressee is silently dropped by the backend, so posting
// one is refused loudly BEFORE any network call is made.
test('a missing host refuses the post before any network call', async () => {
const double = installFetch(() => jsonResponse({ id: ACTOR, outbox: OUTBOX }));
try {
await expect(postJoinActivity(input(undefined))).rejects.toThrow(/host is unknown|no addressee/i);
await expect(postLeaveActivity(input(undefined))).rejects.toThrow(/host is unknown|no addressee/i);
expect(double.calls).toHaveLength(0);
} finally {
double.restore();
}
@@ -91,7 +104,7 @@ test('postLeaveActivity posts a Leave, and a missing host means no to field', as
test('an actor document without an outbox refuses the post, naming the activity', async () => {
const double = installFetch(() => jsonResponse({ id: ACTOR }));
try {
await expect(postJoinActivity(input(undefined))).rejects.toThrow(/carries no outbox.*Join/s);
await expect(postJoinActivity(input('http://localhost:3000/bob'))).rejects.toThrow(/carries no outbox.*Join/s);
expect(double.calls).toHaveLength(1);
} finally {
double.restore();
@@ -104,7 +117,7 @@ test('a non-2xx outbox response throws with the status', async () => {
return jsonResponse({ error: 'Forbidden' }, 403);
});
try {
await expect(postLeaveActivity(input(undefined))).rejects.toThrow(/HTTP 403/);
await expect(postLeaveActivity(input('http://localhost:3000/bob'))).rejects.toThrow(/HTTP 403/);
} finally {
double.restore();
}
+15 -3
View File
@@ -15,7 +15,10 @@
* (`outbox` predicate) at post time — the reference integration resolves it
* the same way, and posting before the actor document has been read is what it
* guards against. The activity's `object` is the meeting point's URI and its
* `to` is the meeting point's host, so the host's Pod receives it.
* `to` is the meeting point's host (the record's Pod-stamped `dc:creator`), so
* the host's Pod receives it. THE HOST IS REQUIRED: a Join/Leave with no
* addressee is silently dropped by the backend, so posting without a host URI
* throws rather than constructing an activity nothing will ever receive.
*
* ASYNCHRONY IS THE PLATFORM'S. The collection update lands when the backend
* has processed the activity — the caller must not treat the 2xx of the post
@@ -32,7 +35,7 @@ const ACTIVITY_CONTEXT = 'https://www.w3.org/ns/activitystreams';
export interface OutboxActivityInput {
/** URI of the meeting point the activity targets. */
meetingPointUri: string;
/** The meeting point's host (as:organizer) — where the activity is addressed. */
/** The meeting point's host (the record's `dc:creator`) — where the activity is addressed. */
hostUri: string | undefined;
/** The authenticated session — whose outbox receives the activity. */
session: CurrentSession;
@@ -40,6 +43,15 @@ export interface OutboxActivityInput {
async function postActivity(type: 'Join' | 'Leave', input: OutboxActivityInput): Promise<void> {
const { meetingPointUri, hostUri, session } = input;
// A Join/Leave is addressed to the host: without a `to` the backend never
// routes it, so the attendee would silently never be recorded. Fail loudly
// before posting rather than construct an activity nothing receives.
if (!hostUri) {
throw new Error(
`cannot post ${type} for ${meetingPointUri}: the meeting point's host is unknown ` +
'(no addressee) — the activity would never be delivered.',
);
}
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}`);
@@ -49,8 +61,8 @@ async function postActivity(type: 'Join' | 'Leave', input: OutboxActivityInput):
type,
actor: session.webId,
object: meetingPointUri,
to: hostUri,
};
if (hostUri) activity['to'] = hostUri;
const response = await fetch(actor.outbox, {
method: 'POST',
+56
View File
@@ -430,6 +430,62 @@ export const stepDefinitions: StepDefinitionInfo[] = [
"sourceCode": "Then('le compte autoritatif de participation de A à l\\'événement {string} est {int}', { timeout: 60000 }, async function (this: FestipodWorld, _title: string, expected: number) {\n const freshFrame = (this as any).recoFreshFrame;\n const eventId = (this as any).isoEventId;\n await freshFrame.evaluate(async () => { await (window as any).__testData.ensureCurrentUser(); });",
"lineNumber": 165
},
{
"pattern": "le pod provider local répond sur son endpoint OpenID",
"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": 40
},
{
"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": 46
},
{
"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": 57
},
{
"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": 65
},
{
"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": 71
},
{
"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 (data/outbox.ts): as:Join to the user's outbox, carrying\n // the meeting point record so the backend finds its apods:attendees collection.\n await postOutboxActivity(pod, 'Join', await readResource(pod, mpUri), pod.webId);\n});",
"lineNumber": 158
},
{
"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 await postOutboxActivity(pod, 'Leave', await readResource(pod, mpUri), pod.webId);\n});",
"lineNumber": 166
},
{
"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 readAttendees(pod, collectionUri);\n expect(members.map(linkId)).to.include(pod.webId);\n});",
"lineNumber": 172
},
{
"pattern": "le portefeuille contient des données de test",
"keyword": "Given",
+39 -71
View File
@@ -15,11 +15,11 @@ interface RawFeatureTestStatus {
const rawResults: RawFeatureTestStatus[] = [
{
"featureId": "us-13",
"totalScenarios": 10,
"passed": 7,
"totalScenarios": 7,
"passed": 4,
"failed": 0,
"skipped": 3,
"lastRun": "2026-01-26T17:31:35.878Z",
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Accéder au formulaire de relai d'événement",
@@ -37,18 +37,6 @@ const rawResults: RawFeatureTestStatus[] = [
"name": "Pouvoir annuler le relai d'événement",
"status": "passed"
},
{
"name": "Détecter un événement similaire déjà relayé",
"status": "passed"
},
{
"name": "Importer un événement depuis une source externe",
"status": "passed"
},
{
"name": "Pas d'alerte doublon lors d'un import externe",
"status": "passed"
},
{
"name": "Modifier un événement",
"status": "skipped"
@@ -69,7 +57,7 @@ const rawResults: RawFeatureTestStatus[] = [
"passed": 3,
"failed": 0,
"skipped": 0,
"lastRun": "2026-01-26T17:31:35.878Z",
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Accéder aux détails d'un événement terminé",
@@ -91,7 +79,7 @@ const rawResults: RawFeatureTestStatus[] = [
"passed": 0,
"failed": 0,
"skipped": 5,
"lastRun": "2026-01-26T17:31:35.878Z",
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Voir les commentaires existants",
@@ -117,35 +105,23 @@ const rawResults: RawFeatureTestStatus[] = [
},
{
"featureId": "us-7",
"totalScenarios": 6,
"passed": 2,
"totalScenarios": 3,
"passed": 3,
"failed": 0,
"skipped": 4,
"lastRun": "2026-01-26T17:31:35.878Z",
"skipped": 0,
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Consulter un événement avant inscription",
"status": "passed"
},
{
"name": "S'inscrire à un événement",
"status": "skipped"
},
{
"name": "Se désinscrire d'un événement",
"status": "skipped"
"name": "Voir le bouton d'inscription sur l'écran",
"status": "passed"
},
{
"name": "Rechercher un événement existant",
"status": "passed"
},
{
"name": "Vérifier les données de l'écran",
"status": "skipped"
},
{
"name": "Rechercher dans une base existante (Mobilizon)",
"status": "skipped"
}
]
},
@@ -155,7 +131,7 @@ const rawResults: RawFeatureTestStatus[] = [
"passed": 0,
"failed": 0,
"skipped": 8,
"lastRun": "2026-01-26T17:31:35.879Z",
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Consulter un macro-événement",
@@ -193,26 +169,22 @@ const rawResults: RawFeatureTestStatus[] = [
},
{
"featureId": "us-16",
"totalScenarios": 4,
"passed": 4,
"totalScenarios": 3,
"passed": 3,
"failed": 0,
"skipped": 0,
"lastRun": "2026-01-26T17:31:35.879Z",
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Accéder aux points de rencontre",
"status": "passed"
},
{
"name": "Voir le bouton pour proposer un point de rencontre",
"name": "Voir le formulaire de proposition",
"status": "passed"
},
{
"name": "Ouvrir le formulaire de proposition",
"status": "passed"
},
{
"name": "Définir l'heure de rencontre",
"name": "Renseigner les détails du point de rencontre",
"status": "passed"
}
]
@@ -223,7 +195,7 @@ const rawResults: RawFeatureTestStatus[] = [
"passed": 0,
"failed": 0,
"skipped": 5,
"lastRun": "2026-01-26T17:31:35.879Z",
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Partager un événement auquel je participe",
@@ -253,7 +225,7 @@ const rawResults: RawFeatureTestStatus[] = [
"passed": 1,
"failed": 0,
"skipped": 3,
"lastRun": "2026-01-26T17:31:35.879Z",
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Configurer les notifications de nouveaux participants",
@@ -279,7 +251,7 @@ const rawResults: RawFeatureTestStatus[] = [
"passed": 3,
"failed": 0,
"skipped": 2,
"lastRun": "2026-01-26T17:31:35.879Z",
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Voir les événements à venir sur l'accueil",
@@ -309,7 +281,7 @@ const rawResults: RawFeatureTestStatus[] = [
"passed": 5,
"failed": 0,
"skipped": 1,
"lastRun": "2026-01-26T17:31:35.879Z",
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Accéder au profil d'un participant",
@@ -343,7 +315,7 @@ const rawResults: RawFeatureTestStatus[] = [
"passed": 3,
"failed": 0,
"skipped": 4,
"lastRun": "2026-01-26T17:31:35.879Z",
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Accéder à la liste des événements depuis le profil",
@@ -381,7 +353,7 @@ const rawResults: RawFeatureTestStatus[] = [
"passed": 3,
"failed": 0,
"skipped": 2,
"lastRun": "2026-01-26T17:31:35.879Z",
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Accéder à la liste des inscrits d'un événement",
@@ -411,7 +383,7 @@ const rawResults: RawFeatureTestStatus[] = [
"passed": 5,
"failed": 0,
"skipped": 1,
"lastRun": "2026-01-26T17:31:35.879Z",
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Accéder à mon profil",
@@ -445,7 +417,7 @@ const rawResults: RawFeatureTestStatus[] = [
"passed": 2,
"failed": 0,
"skipped": 3,
"lastRun": "2026-01-26T17:31:35.879Z",
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Accéder aux paramètres de profil",
@@ -475,7 +447,7 @@ const rawResults: RawFeatureTestStatus[] = [
"passed": 5,
"failed": 0,
"skipped": 0,
"lastRun": "2026-01-26T17:31:35.879Z",
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Accéder au partage de profil",
@@ -505,7 +477,7 @@ const rawResults: RawFeatureTestStatus[] = [
"passed": 5,
"failed": 0,
"skipped": 0,
"lastRun": "2026-01-26T17:31:35.879Z",
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Accéder au partage depuis le profil",
@@ -535,7 +507,7 @@ const rawResults: RawFeatureTestStatus[] = [
"passed": 2,
"failed": 0,
"skipped": 1,
"lastRun": "2026-01-26T17:31:35.879Z",
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Accéder aux paramètres de notification",
@@ -557,7 +529,7 @@ const rawResults: RawFeatureTestStatus[] = [
"passed": 1,
"failed": 0,
"skipped": 2,
"lastRun": "2026-01-26T17:31:35.879Z",
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Accéder aux paramètres de notification",
@@ -575,11 +547,11 @@ const rawResults: RawFeatureTestStatus[] = [
},
{
"featureId": "us-26",
"totalScenarios": 4,
"passed": 3,
"totalScenarios": 3,
"passed": 2,
"failed": 0,
"skipped": 1,
"lastRun": "2026-01-26T17:31:35.879Z",
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Accéder au formulaire de relai d'événement",
@@ -589,10 +561,6 @@ const rawResults: RawFeatureTestStatus[] = [
"name": "Définir le rayon d'intérêt",
"status": "skipped"
},
{
"name": "Choisir une thématique",
"status": "passed"
},
{
"name": "Vérifier les champs obligatoires",
"status": "passed"
@@ -605,7 +573,7 @@ const rawResults: RawFeatureTestStatus[] = [
"passed": 4,
"failed": 0,
"skipped": 1,
"lastRun": "2026-01-26T17:31:35.879Z",
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Accéder au profil pour voir la photo",
@@ -635,7 +603,7 @@ const rawResults: RawFeatureTestStatus[] = [
"passed": 0,
"failed": 0,
"skipped": 5,
"lastRun": "2026-01-26T17:31:35.879Z",
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Accéder aux détails d'un événement terminé",
@@ -665,7 +633,7 @@ const rawResults: RawFeatureTestStatus[] = [
"passed": 0,
"failed": 0,
"skipped": 3,
"lastRun": "2026-01-26T17:31:35.879Z",
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Accéder au bilan consolidé",
@@ -687,7 +655,7 @@ const rawResults: RawFeatureTestStatus[] = [
"passed": 0,
"failed": 0,
"skipped": 7,
"lastRun": "2026-01-26T17:31:35.879Z",
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Accéder à la création d'atelier",
@@ -725,7 +693,7 @@ const rawResults: RawFeatureTestStatus[] = [
"passed": 0,
"failed": 0,
"skipped": 5,
"lastRun": "2026-01-26T17:31:35.879Z",
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Accéder à la zone de notes personnelles",
@@ -755,7 +723,7 @@ const rawResults: RawFeatureTestStatus[] = [
"passed": 0,
"failed": 0,
"skipped": 5,
"lastRun": "2026-01-26T17:31:35.879Z",
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Voir les commentaires existants d'un atelier",
@@ -785,7 +753,7 @@ const rawResults: RawFeatureTestStatus[] = [
"passed": 0,
"failed": 0,
"skipped": 4,
"lastRun": "2026-01-26T17:31:35.879Z",
"lastRun": "2026-10-10T07:48:50.596Z",
"scenarios": [
{
"name": "Voir les ateliers d'un événement",
@@ -119,7 +119,10 @@ test('event toValues composes the datetimes and never stores the derived fields'
content: 'Fabriquer des objets',
startTime: '2026-02-08T14:00',
endTime: '2026-02-08T17:00',
location: 'La Maison du Vélo, Lyon',
// `location` is `@type: @id` in the platform context: the display text is
// written as an inline named node so it round-trips as `as:name`, never as a
// text value resolved into a garbage IRI.
location: { name: 'La Maison du Vélo, Lyon' },
image: 'https://example.org/atelier.jpg',
});
});
@@ -136,10 +139,10 @@ test('event toValues keeps a date-only end and drops a time-only start', () => {
location: '',
participantCount: 0,
}),
).toEqual({ name: 'T', content: '', startTime: '2026-03-01', endTime: '2026-03-03', location: '' });
).toEqual({ name: 'T', content: '', startTime: '2026-03-01', endTime: '2026-03-03', location: { name: '' } });
expect(
event.toValues({ title: 'T', description: '', date: '', startTime: '09:00', location: '', participantCount: 0 }),
).toEqual({ name: 'T', content: '', location: '' });
).toEqual({ name: 'T', content: '', location: { name: '' } });
});
test('event toPatch sends only what changed', () => {
@@ -147,7 +150,7 @@ test('event toPatch sends only what changed', () => {
expect(event.toPatch({ title: 'Nouveau titre' })).toEqual({ name: 'Nouveau titre' });
});
test('meetingPoint toDomain reads the context link in both its forms', () => {
test('meetingPoint toDomain reads the context link and the host from dc:creator', () => {
const { meetingPoint } = getBackend('activitypods').bindings;
const objectForm = meetingPoint.toDomain({
id: 'http://localhost:3000/alice/data/mp-1',
@@ -155,7 +158,8 @@ test('meetingPoint toDomain reads the context link in both its forms', () => {
name: 'Rdv entrée',
content: 'On se retrouve là-bas',
'as:context': { id: 'http://localhost:3000/alice/data/event-1' },
organizer: 'http://localhost:3000/alice',
// The Pod's DocumentTaggerMixin stamps this on create — the host convention.
'dc:creator': 'http://localhost:3000/alice',
location: { name: 'Café de la Place' },
});
expect(objectForm).toEqual({
@@ -167,6 +171,14 @@ test('meetingPoint toDomain reads the context link in both its forms', () => {
location: 'Café de la Place',
time: '',
});
// The full IRI form (a context that does not compact `dc`) and the legacy
// `organizer` fallback both still resolve the host.
expect(
meetingPoint.toDomain({
id: 'mp-full-iri',
'dc:creator': { '@id': 'http://localhost:3000/alice' },
}).hostId,
).toBe('http://localhost:3000/alice');
const stringForm = meetingPoint.toDomain({
id: 'http://localhost:3000/alice/data/mp-2',
'as:context': 'http://localhost:3000/alice/data/event-1',
@@ -178,7 +190,7 @@ test('meetingPoint toDomain reads the context link in both its forms', () => {
expect(stringForm.title).toBeUndefined();
});
test('meetingPoint toValues writes the context and organizer, not the unmapped fields', () => {
test('meetingPoint toValues writes the context and the named location, never organizer', () => {
const { meetingPoint } = getBackend('activitypods').bindings;
const values = meetingPoint.toValues({
eventId: 'http://localhost:3000/alice/data/event-1',
@@ -192,10 +204,12 @@ test('meetingPoint toValues writes the context and organizer, not the unmapped f
expect(values).toEqual({
name: 'Rdv entrée',
content: 'Sous l’horloge',
location: 'Café de la Place',
organizer: 'http://localhost:3000/alice',
location: { name: 'Café de la Place' },
'as:context': 'http://localhost:3000/alice/data/event-1',
});
// The host is the Pod-stamped creator; nothing here writes an `organizer` term
// the platform context does not define (it would not round-trip).
expect(values).not.toHaveProperty('organizer');
});
test('profile toDomain maps the AS2 profile fields and derives the initials', () => {
@@ -240,7 +254,8 @@ test('profile toValues writes the mapped fields and none of the derived ones', (
name: 'Marie Dupont',
preferredUsername: 'mariedupont',
summary: 'Bio',
location: 'Lyon',
// `location` is `@type: @id`: the city is a named node, not a bare text value.
location: { name: 'Lyon' },
});
});
+36 -8
View File
@@ -67,8 +67,11 @@
* together, and the data/live providers take the auth provider as a parameter. Its
* `appStatusCheckInterval` is disabled deliberately: construction installs a
* `setInterval` and a `visibilitychange` listener with no teardown (usage
* declaration, friction 1), and Festipod drives `getAppStatus()` on its own
* schedule. The `clientId` names the app; the app description document it points to
* declaration, friction 1). Driving `getAppStatus()` on Festipod's own schedule is
* the planned replacement, but it is NOT wired yet: the contract documents the
* method's name only, not the shape of the `AppStatus` result, so the follow-up is
* parked until that surface is pinned (handoff, follow-ups). The `clientId` names
* the app; the app description document it points to
* is served by Festipod's own backend, and the values resolve from the runtime
* config — the SAME source the AuthGate flow uses, so both halves agree.
*/
@@ -134,6 +137,17 @@ function namedValue(value: unknown): string | undefined {
return o ? asString(o['name']) ?? asString(o['url']) : undefined;
}
/**
* A display string written into a predicate the platform context types as `@id`
* (`location` → `as:location` with `"@type": "@id"`). A bare text value is
* resolved against the base IRI into a garbage IRI on expansion, so the text is
* wrapped in an inline named node — expanded to a blank node carrying `as:name`,
* which IS defined in the context — and `namedValue` reads it back.
*/
function namedTextValue(text: string): Record<string, unknown> {
return { name: text };
}
/** A media value: a bare URL string, or an object such as an `as:Link` carrying url. */
function urlValue(value: unknown): string | undefined {
const direct = asString(value);
@@ -200,7 +214,7 @@ function eventValues(data: Partial<FpEventData>): Record<string, unknown> {
if (startTime !== undefined) out['startTime'] = startTime;
const endTime = composeDateTime(data.endDate, data.endTime);
if (endTime !== undefined) out['endTime'] = endTime;
if (data.location !== undefined) out['location'] = data.location;
if (data.location !== undefined) out['location'] = namedTextValue(data.location);
if (data.coverImage !== undefined) out['image'] = data.coverImage;
return out;
}
@@ -229,7 +243,12 @@ function eventFromRecord(record: unknown): FpEventData {
/**
* The meeting point field mapping. A meeting point is an `as/Event` plus `as:context`
* pointing at the anchor event; `as:organizer` carries the host.
* pointing at the anchor event. THE HOST IS NOT A STORED PREDICATE: the Pod's
* DocumentTaggerMixin stamps `dc:creator` on every created resource, and the
* creator IS the host (the welcometomyplace convention, also what the on-behalf
* backend reads) — so the host is read back from `dc:creator`, never written.
* `organizer` is not a term of the platform context, so writing it would not
* round-trip and the host would be lost on read.
* NOT mapped, on purpose: `time` (a display string such as '30 min avant', which is
* not an AS2 datetime — unmapped until the creation flow produces one), `place` (AS2
* has a single location slot, taken by `location`) and `inbox` (nothing provisions an
@@ -239,12 +258,19 @@ function meetingPointValues(data: Partial<FpMeetingPointData>): Record<string, u
const out: Record<string, unknown> = {};
if (data.title !== undefined) out['name'] = data.title;
if (data.description !== undefined) out['content'] = data.description;
if (data.location !== undefined) out['location'] = data.location;
if (data.hostId !== undefined) out['organizer'] = data.hostId;
if (data.location !== undefined) out['location'] = namedTextValue(data.location);
if (data.eventId !== undefined) out['as:context'] = data.eventId;
return out;
}
/** The Pod-stamped creator of a record, tolerant of the term's compact and full forms. */
function creatorId(record: JsonRecord | undefined): string | undefined {
return (
linkId(firstEntry(record?.['dc:creator'])) ??
linkId(firstEntry(record?.['http://purl.org/dc/terms/creator']))
);
}
function meetingPointFromRecord(record: unknown): FpMeetingPointData {
const r = asRecord(record);
return {
@@ -252,7 +278,9 @@ function meetingPointFromRecord(record: unknown): FpMeetingPointData {
// A meeting point IS an `as/Event` with a context; one without it is an event,
// and the reader tells the two apart by this very field.
eventId: linkId(r?.['as:context']) ?? '',
hostId: linkId(r?.['organizer']),
// The host is the Pod-stamped creator; the `organizer` fallback is kept for
// robustness against records written before the convention was adopted.
hostId: creatorId(r) ?? linkId(r?.['organizer']),
title: firstString(r?.['name']),
description: firstString(r?.['content']),
location: namedValue(r?.['location']) ?? '',
@@ -271,7 +299,7 @@ function profileValues(data: Partial<FpUserData>): Record<string, unknown> {
if (data.name !== undefined) out['name'] = data.name;
if (data.username !== undefined) out['preferredUsername'] = data.username;
if (data.bio !== undefined) out['summary'] = data.bio;
if (data.city !== undefined) out['location'] = data.city;
if (data.city !== undefined) out['location'] = namedTextValue(data.city);
return out;
}
+4 -1
View File
@@ -45,7 +45,10 @@ export interface ResolvedAuthConfig {
*/
export function resolveActivityPodsAuthConfig(origin: string): ResolvedAuthConfig {
const backendClientId = runtimeConfig.backendClientId?.trim();
const backendUrl = runtimeConfig.backendUrl?.trim();
// Strip trailing slashes first: appending `/app` to `http://host/` would double
// the slash (`http://host//app`), and the client id must be the exact URI the
// backend serves.
const backendUrl = runtimeConfig.backendUrl?.trim().replace(/\/+$/, '');
const clientId = backendClientId || (backendUrl ? `${backendUrl}/app` : `${origin}/app`);
return {
clientId,