Run locally like the sibling apps: fixed ports, the pod provider in Docker, the app outside

Festipod now follows the local convention of welcometomyplace, mutual-aid.app
and portejunes: one fixed port pair per app (Festipod defaults: backend 3005,
app 4005; pod provider 3000/5000), overridable in backend/.env and .env.
`make start` runs docker-compose-dev.yml (the local pod provider from images,
Fuseki, Redis, mailcatcher, Arena); the backend and the app run outside Docker
with `cd backend && pnpm dev` and `pnpm dev`. pnpm stays the package manager.

The old docker-compose.yml, which containerized Festipod's own frontend and
backend and could not reach the host's Fuseki and Redis, is gone; portless is
dropped from the dev script, since the backend declares a single OIDC redirect
URI. The local backend runs without a Redis cache, as the sibling apps do. The
e2e suites and validate read the app, backend and pod provider URLs from the
env files instead of hardcoding them.

The deployment and local cookbook leaves record the traps met while resetting
datasets: a dataset re-created under the same name needs a Fuseki restart
(ClosedException otherwise), the anonymous WebACL probe at boot is expected,
and a change of the backend's URL leaves persistent accounts with a
participations collection the new app identity cannot control.
This commit is contained in:
Sylvain Duchesne
2026-11-01 11:40:27 +01:00
parent 908f7ea973
commit 5e3e821e2a
33 changed files with 280 additions and 129 deletions
+4 -4
View File
@@ -14,9 +14,9 @@ test('clientId uses explicit backendClientId when set', () => {
});
test('clientId derives the actor uri from backendUrl when backendClientId is empty', () => {
setRuntimeConfig({ backendUrl: 'http://localhost:3003', backendClientId: '' });
setRuntimeConfig({ backendUrl: 'http://localhost:3005', backendClientId: '' });
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
expect(cfg.clientId).toBe('http://localhost:3003/as/actor/app');
expect(cfg.clientId).toBe('http://localhost:3005/as/actor/app');
expect(cfg.redirectUri).toBe('http://localhost:5000/login');
});
@@ -27,9 +27,9 @@ test('clientId falls back to the origin actor uri when nothing is configured', (
});
test('clientId does not double the slash on a trailing-slash backendUrl', () => {
setRuntimeConfig({ backendUrl: 'http://localhost:3003/' });
setRuntimeConfig({ backendUrl: 'http://localhost:3005/' });
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
expect(cfg.clientId).toBe('http://localhost:3003/as/actor/app');
expect(cfg.clientId).toBe('http://localhost:3005/as/actor/app');
});
test('podProviderUrl is passed through when set', () => {
+1 -1
View File
@@ -1,7 +1,7 @@
import { serve } from "bun";
import index from "./index.html";
const port = process.env.PORT ? parseInt(process.env.PORT) : 4000;
const port = process.env.PORT ? parseInt(process.env.PORT) : 4005;
// Strict base64 check (not a mere `Buffer.from` attempt, which silently drops invalid
// characters instead of failing): reject anything that is not a well-formed base64 body
@@ -10,7 +10,7 @@ Fonctionnalité: Smoke e2e de la pile ActivityPods locale
# aucun broker. Elle n'existe que si la pile locale tourne — son mode d'emploi
# reproductible est le concept `data-layer`,
# `cookbook_local-activitypods-platform.md` (pod provider :3000, backend Festipod
# :3003, redis remappé :6390). Le chargement de l'app reprend le motif `@e2e` du
# :3005 par défaut). Le chargement de l'app reprend le motif `@e2e` du
# repo (serveur réel + Playwright), pas le wallet partagé.
Scénario: La pile locale est joignable et le backend sert son application
+1 -1
View File
@@ -255,7 +255,7 @@ let RefineDataProvider: (props: { children: ReactNode }) => ReactNode;
let root: ReturnType<typeof createRoot> | undefined;
beforeAll(async () => {
const window = new Window({ url: 'http://localhost:4000/' });
const window = new Window({ url: 'http://localhost:4005/' });
const g = globalThis as Record<string, unknown>;
for (const [name, value] of Object.entries({ window, document: window.document, navigator: window.navigator })) {
if (g[name] !== undefined) continue;
@@ -60,7 +60,7 @@ function Probe({ uris, options }: { uris: readonly string[]; options: PublicReco
}
beforeAll(async () => {
const window = new Window({ url: 'http://localhost:4000/' });
const window = new Window({ url: 'http://localhost:4005/' });
const g = globalThis as Record<string, unknown>;
for (const [name, value] of Object.entries({ window, document: window.document, navigator: window.navigator })) {
if (g[name] !== undefined) continue;
+1 -1
View File
@@ -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 # :3003, redis remappé :6390). 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 EST délivrée au backend, qui tient la collection\n # `apods:attendees` du point de rencontre. 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 référence non résolue reste comptée et rendue 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, 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",
"screenIds": []
},
{
+2 -2
View File
@@ -14,7 +14,7 @@ import { createHash, randomBytes } from 'node:crypto';
*
* THE LOCAL STACK. Its reproducible setup is the concept note
* `.project/concepts/data-layer/cookbook_local-activitypods-platform.md`
* (pod provider on :3000, Festipod backend on :3003, redis remapped to :6390).
* (pod provider on :3000, Festipod backend on :3005 by default, redis remapped to :6390).
* The values come from the same environment variables the app and the backend
* read, with the cookbook's defaults as fallback.
*
@@ -42,7 +42,7 @@ function stripTrailingSlash(url: string): string {
export function apodsStackConfig(): ApodsStackConfig {
return {
podProviderUrl: stripTrailingSlash(process.env.VITE_POD_PROVIDER_URL?.trim() || 'http://localhost:3000'),
backendUrl: stripTrailingSlash(process.env.VITE_BACKEND_URL?.trim() || 'http://localhost:3003'),
backendUrl: stripTrailingSlash(process.env.VITE_BACKEND_URL?.trim() || 'http://localhost:3005'),
};
}