From 8dfc574346c2cd49fc9b0d728cf70d96f4bdb8da Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Sat, 10 Oct 2026 09:54:53 +0200 Subject: [PATCH] Close the adversarial review round: auth hardening, data fidelity against the platform context, backend grants and docker coherence --- .env.example | 4 +- .../cookbook_local-activitypods-platform.md | 3 +- .../handoff/refine-provider-abstraction.md | 15 +- AGENTS.md | 2 +- backend/package.json | 6 +- backend/pnpm-lock.yaml | 7 +- backend/services/attendees.service.js | 33 + backend/services/registration.service.js | 27 +- cucumber.json | 2 +- cucumber.smoke.json | 2 +- docker-compose.yml | 5 +- package.json | 1 - reports/cucumber-report.html | 2 +- reports/cucumber-report.json | 10291 +++++++++++++++- scripts/setup-test-auth.ts | 2 +- src/app/AuthGate.tsx | 108 +- src/app/auth/PodProviderPicker.tsx | 10 +- src/app/auth/activityPodsAuth.test.ts | 28 +- src/app/auth/activityPodsAuth.ts | 41 +- src/app/router.test.ts | 75 + src/app/router.tsx | 62 +- src/index.ts | 2 +- .../event/screens/CreateEventScreen.tsx | 6 +- .../event/screens/EventDetailScreen.tsx | 36 +- src/modules/event/screens/EventsScreen.tsx | 4 +- src/modules/event/screens/InviteScreen.tsx | 6 +- .../event/screens/MeetingPointsScreen.tsx | 6 +- .../event/screens/ParticipantsListScreen.tsx | 6 +- .../event/screens/UpdateEventScreen.tsx | 10 +- src/modules/home/screens/SettingsScreen.tsx | 21 +- .../user/screens/FriendsListScreen.tsx | 4 +- src/modules/user/screens/ProfileScreen.tsx | 4 +- .../user/screens/UserProfileScreen.tsx | 4 +- src/shared/context/refineData.tsx | 63 +- src/shared/data/features.ts | 186 +- src/shared/data/outbox.test.ts | 23 +- src/shared/data/outbox.ts | 18 +- src/shared/data/stepDefinitions.ts | 56 + src/shared/data/testResults.ts | 110 +- .../refine/providers/activitypods.test.ts | 33 +- src/shared/refine/providers/activitypods.ts | 44 +- src/shared/utils/runtimeConfig.ts | 5 +- 42 files changed, 11138 insertions(+), 235 deletions(-) create mode 100644 src/app/router.test.ts diff --git a/.env.example b/.env.example index ea4fb24..d28d71b 100644 --- a/.env.example +++ b/.env.example @@ -37,8 +37,8 @@ FESTIPOD_SHARED_WALLET_FILE_BASE64= FESTIPOD_AUTO_SEED= # ── Serveur ──────────────────────────────────────────────────────────────── -# Port HTTP du serveur (défaut 3000). -PORT=3000 +# Port HTTP du serveur (5000 : l'app est servie sur l'origine :5000 de la pile locale). +PORT=5000 # NODE_ENV=production bascule `bun run start` (pas de HMR). En dev, laisser vide. NODE_ENV= diff --git a/.project/concepts/data-layer/cookbook_local-activitypods-platform.md b/.project/concepts/data-layer/cookbook_local-activitypods-platform.md index 3ad8490..8eaafac 100644 --- a/.project/concepts/data-layer/cookbook_local-activitypods-platform.md +++ b/.project/concepts/data-layer/cookbook_local-activitypods-platform.md @@ -9,7 +9,7 @@ Platform checkout: `/home/sylvain/projects/activitypods/activitypods` (never mod ## Ports -fuseki 3030 (admin/admin), mailcatcher 1080, redis 6379, backend 3000, frontend 5000, arena 4567, tripleadmin 3033. +pod provider 3000 (OpenID + `/auth` signup/login), Festipod backend 3003, Festipod app 5000, fuseki 3030 (admin/admin), mailcatcher 1080, redis 6390, arena 4567, tripleadmin 3033. ## Bring up @@ -17,6 +17,7 @@ fuseki 3030 (admin/admin), mailcatcher 1080, redis 6379, backend 3000, frontend 2. Install once: `cd backend && COREPACK_ENABLE_AUTO_PIN=0 corepack yarn install --frozen-lockfile`, same in `frontend/`. Keep `COREPACK_ENABLE_AUTO_PIN=0`: corepack auto-pin rewrites `packageManager` in package.json. Note: the pod-provider README says `yarn start` for the frontend; only the `dev` script exists — use `BROWSER=none corepack yarn dev`. 3. Backend: `cd backend && corepack yarn start`. Wait for "ServiceBroker with ... service(s) started successfully" (~11 s). 4. Frontend: `cd frontend && BROWSER=none corepack yarn dev`. Verify: `GET http://localhost:5000/` → 200 and `GET http://localhost:3000/.well-known/config.js` → 200. +5. Festipod stack (this repo root): `cp backend/.env.example backend/.env` before `docker compose up`. The compose reads `backend/.env` as an optional `env_file`; on a fresh clone the file is gitignored and absent, so this copy is what feeds the backend its ActivityPods settings. With the coherent layout the app is served on :5000 and the Festipod backend on :3003. ## Account and resource flow (all VERIFIED 2026-10-09) diff --git a/.project/handoff/refine-provider-abstraction.md b/.project/handoff/refine-provider-abstraction.md index e87c049..6695569 100644 --- a/.project/handoff/refine-provider-abstraction.md +++ b/.project/handoff/refine-provider-abstraction.md @@ -8,7 +8,7 @@ status: open ## Where the work lives -Branch **`refine-backends`**, worktree `/home/sylvain/projects/festipod/festipod/.claude/worktrees/refine-backends`, pushed to origin. Two commits: `e282384` (the seam carried onto this base) and `df1db79` (URL selection + corrections). +Branch **`refine-backends`**, worktree `/home/sylvain/projects/festipod/festipod/.claude/worktrees/refine-backends`, pushed to origin; the tip records the T04 tree close plus the adversarial-review round — `## Current gate state` below names the states by date and tip. It is based on **`ng-eventually`** at `c7cda38` — the most advanced branch, and the reason this branch exists. An earlier attempt, **`festipod-refine`** (tip `352d1f1`, worktree `.claude/worktrees/festipod-64`), was built on `main`, which turned out to be far behind; the Refine work was transferred here and that branch is now only an archive. It holds the **only** worked implementation of a `@ng-org/refine-providers` backend (`src/shared/refine/providers/nextgraph.ts`, in its history up to the commit that withdrew it) — the reference for the shape of the next backend. @@ -109,9 +109,11 @@ So both platforms count deposits rather than people, and the gap sits in **neith - **The migration was delegated to a subagent** and verified independently afterwards: the five transferred files are byte-identical, the manifest merge preserved both pre-existing entries, and the gates were re-run by hand. Its one worry — that a missing local `node_modules` had made the baseline incomparable — was **unfounded** (pnpm had installed the full tree locally; 22 entries against the main checkout's 21). Its debt block was better than the specification it was given: it caught the `__FESTIPOD_BACKEND__` consequence and two branch-relative phrasings. - **Moving base falsified three claims in the transferred code**, all corrected: the inbox the count comment points at is local here; the worked provider implementation is not in this branch's ancestry; and the `MeetingPoint` SHEX shape **does** exist here, so its binding stopped being optional (a backend that cannot carry one of the four entities cannot serve the app). -## Current gate state (2026-10-09, end of the T04 tree, tip `f8c2e3b`) +## Current gate state (2026-10-10, adversarial-review round closed, tip = the commit recording this state) -Verified by the orchestrator's own runs: `bunx tsc --noEmit` exit 0 ; `bun test` 86 pass / 0 fail ; the UI cucumber layer (`not @wip and not @humain and not @data and not @e2e and not @multibrowser and not @suspendu`) 125 scenarios / 0 failed ; smoke `@e2e-activitypods` 3/3 against the live local stack (pod provider :3000, festipod backend :3003) ; `bun run build.ts` ok ; `contracts.py check --integrity-only` clean. `lint` carries only the pre-existing ERROR (`polyfill-surface/usage_festipod.md` missing `## Needs`). The polyfill `@data`/`@e2e`/`@multibrowser` suites are suspended with the NextGraph workstream (`@suspendu` tag, code preserved, nothing deleted). The app at root now runs the ActivityPods variant: Refine providers for Pod CRUD, Solid-OIDC sign-in (picker + `/login`), Moleculer backend. Known follow-ups: app-install consent on the pod (join→collection loop + OIDC round-trip both `@suspendu` until then), `redirect_uri` alignment, cross-pod catalog provider, friction 5 (attendees member shape), refine-providers pin re-evaluation when npm publishes gitHead. +Verified by the orchestrator's own runs after the round's fixes: `bunx tsc --noEmit` exit 0 ; `bun test` 96 pass / 0 fail ; the UI cucumber layer 125 scenarios / 0 failed (54 fully implemented + 71 `Scénario non implémenté` placeholders that carry a skipped step by design) ; smoke `@e2e-activitypods` 3/3 against the live local stack (pod provider :3000, festipod backend :3003, app served on an ephemeral port) ; `bun run build.ts` ok ; `contracts.py check --integrity-only` clean. + +The round: three adversarial passes (auth & security, data seam & backend, gates & ops); every confirmed finding was fixed in the commit recording this state. Fix themes — auth: `redirect` param validated (same-origin leading-slash only), callback re-entry guarded against StrictMode double-exchange, stale `?code=` URLs degrade to the picker instead of the fatal screen, `register_app` without a session falls back to the picker (was a permanent blank screen), ActivityPods logout wired through the provider, deep links survive login, clientId trailing slash normalized. Data fidelity: the meeting point's host is read from the Pod-stamped `dc:creator` (the `organizer` term is absent from the platform context and never round-tripped, so the Join lost its addressee), `location`/`city` are written as inline named nodes (`as:location` is `@type: @id`; a free-text string resolved into a garbage IRI), record ids are URL-encoded (absolute IRIs broke the router's `/events/:id` parse), participant resolution is keyed by actor URI (profiles were stored under their document URI and never matched), one delayed re-invalidation reconciles the count after join/leave, a Join/Leave without addressee throws instead of posting to no one, anonymous placeholder rows render on the event detail screen. Backend & ops: the attendees collection gets a public `acl:Read` grant in `after.createAndAttach` (the count is readable by everyone; anonymity lives in the profiles' own WAC), Join/Leave handlers retry while the collection is still attaching, `tsx` promoted to dependencies so the `--prod` image starts, compose `env_file` made optional with fuseki/redis services added and the frontend published on :5000 (the SEMAPPS_FRONT_URL default — fixes the shipped redirect_uri mismatch), `test:data` removed (zero matching scenarios since the suspension — a vacuous green), `strict: true` on both cucumber configs. `contracts.py check` also reports, pre-existing: `polyfill-surface: provider moved: a33fb8a214 → a8d53010c2` — the pulled copy is behind the pinned ref. @@ -123,6 +125,13 @@ Verified by the orchestrator's own runs: `bunx tsc --noEmit` exit 0 ; `bun test` - The four provider contributions. - Wiring the seam: DONE 2026-10-09 (T04 tree, tip `f8c2e3b`) — `FestipodDataContext` runs over Refine with the ActivityPods backend as the registered default; the join→collection loop is built but `@suspendu` until the app is installed on a pod (consent screen). Current state: `## Current gate state` above. - Both usage declarations are named `usage_festipod.md`, so the bare stem is ambiguous as a wikilink — refer to either by folder path. +- Driving `getAppStatus()` is parked: the pinned contract documents the method but not the shape of the `AppStatus` result, so "stale" is not decidable without guessing. Belongs in the declaration's Frictions/Needs when the outward channel opens. +- The `activitypods-providers` reconstruction is stamped at the 0.1.5-era pin while the installed package is 0.2.0 — re-constructing the contract at the installed version is a pending decision (the source is read once at adoption time; a re-read is its own decision). +- The attendees collection is publicly readable by decision (count for everyone; member URIs are not secret; anonymity lives in the profiles' WAC). Revisit if a product rule ever hides membership. +- The dedup defi (same event + same place + same time) is confirmed absent across the seam; the 2026-10-03 brief marks it an open challenge — a product call, not a regression. +- The catalog service's discovery index is process memory: a backend restart empties it while the Pods still hold the events. Latent until the cross-pod catalog provider lands. +- `src/modules/home/**` screens still build raw `/events/${id}` paths; `parsePath` tolerates them, but they should adopt `eventPath`/`userPath` next time they are touched. +- The dockerized path is validated by `docker compose config` only — the backend image has never been built or started by the gates; one manual operator run is owed. ## Standing constraints diff --git a/AGENTS.md b/AGENTS.md index c78ced8..4b42ef2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ Web app mobile-first où les utilisateurs créent des **points de rencontre** qu src/screens/index.ts # Registre d'écrans (utilisé par Storybook) ``` - **Un module n'importe QUE depuis `shared/` — jamais d'un autre module.** C'est l'invariant qui rend l'archi réelle. -- **Bun-first** : `bun` / `bun install` / `bun test` / `bun build`, jamais node/npm/vite/jest. `bun run dev` (port 3000). +- **Bun-first** : `bun` / `bun install` / `bun test` / `bun build`, jamais node/npm/vite/jest. `bun run dev` (portless, port dynamique ; défaut servi par `bun run start` : 5000). ## Frontière SDK diff --git a/backend/package.json b/backend/package.json index 982396c..e90db65 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,13 +28,11 @@ "moleculer-cron": "0.0.4", "moleculer-repl": "^0.6.6", "sharp": "0.31.3", + "tsx": "^4.20.3", "url-join": "^4.0.1" }, "engines": { "node": ">=22.12" }, - "packageManager": "pnpm@10.26.0+sha512.3b3f6c725ebe712506c0ab1ad4133cf86b1f4b687effce62a9b38b4d72e3954242e643190fc51fa1642949c735f403debd44f5cb0edd657abe63a8b6a7e1e402", - "devDependencies": { - "tsx": "^4.20.3" - } + "packageManager": "pnpm@10.26.0+sha512.3b3f6c725ebe712506c0ab1ad4133cf86b1f4b687effce62a9b38b4d72e3954242e643190fc51fa1642949c735f403debd44f5cb0edd657abe63a8b6a7e1e402" } diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index 89f23bf..1df5200 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -74,13 +74,12 @@ importers: sharp: specifier: 0.31.3 version: 0.31.3 - url-join: - specifier: ^4.0.1 - version: 4.0.1 - devDependencies: tsx: specifier: ^4.20.3 version: 4.23.15 + url-join: + specifier: ^4.0.1 + version: 4.0.1 packages: diff --git a/backend/services/attendees.service.js b/backend/services/attendees.service.js index 97ebfd4..63ee04d 100644 --- a/backend/services/attendees.service.js +++ b/backend/services/attendees.service.js @@ -2,6 +2,14 @@ const urlJoin = require('url-join'); const { PodCollectionsHandlerMixin } = require('@activitypods/app'); const CONFIG = require('../config/config'); +// Visibility decision: the attendees collection is readable by ANY visitor, including +// unauthenticated ones (acl:agentClass foaf:Agent). A meeting point's participant count and +// its list of participants must be visible to non-contacts — the app shows outsiders the +// anonymous participants plus the total counter, and that is a product feature, not a leak. +// Member URIs are not secret; the anonymity guarantee lives in each profile's own WAC (a +// participant whose profile is not shared simply renders as anonymous), not here. The +// reference welcometomyplace grants this to its announces group, but Festipod has no +// announces concept, so the grant is public. module.exports = { name: 'attendees', mixins: [PodCollectionsHandlerMixin], @@ -13,5 +21,30 @@ module.exports = { summary: 'Meeting point attendees' }, createWacGroup: true + }, + actions: { + async givePublicReadPermission(ctx) { + const { collectionUri, actorUri } = ctx.params; + + await ctx.call('pod-permissions.add', { + uri: collectionUri, + agentPredicate: 'acl:agentClass', + agentUri: 'http://xmlns.com/foaf/0.1/Agent', + mode: 'acl:Read', + actorUri + }); + } + }, + hooks: { + after: { + async createAndAttach(ctx, res) { + const { actorUri } = ctx.params; + const collectionUri = res; + + await this.actions.givePublicReadPermission({ collectionUri, actorUri }, { parentCtx: ctx }); + + return res; + } + } } }; diff --git a/backend/services/registration.service.js b/backend/services/registration.service.js index 5b69a5c..b590f2b 100644 --- a/backend/services/registration.service.js +++ b/backend/services/registration.service.js @@ -8,6 +8,29 @@ module.exports = { async started() { this.logger.info('Registration watcher subscribed'); }, + methods: { + /** + * The Event's `apods:attendees` collection is attached asynchronously by + * PodCollectionsHandlerMixin on create, so a Join/Leave processed in that window sees no + * collection. Returning immediately would drop the activity forever (the client overlay + * shows joined, nothing reconciles). Re-fetch the Event a bounded number of times until the + * collection appears; only then give up. + */ + async fetchAttendeesCollectionUri(ctx, event, actorUri) { + if (event['apods:attendees'] || !event.id) return event['apods:attendees']; + + for (let attempt = 0; attempt < 3; attempt++) { + await new Promise(resolve => setTimeout(resolve, 500)); + try { + const { ok, body } = await ctx.call('pod-resources.get', { resourceUri: event.id, actorUri }); + if (ok && body && body['apods:attendees']) return body['apods:attendees']; + } catch (e) { + this.logger.warn(`Could not re-fetch ${event.id} while waiting for its attendees collection: ${e.message}`); + } + } + return undefined; + } + }, activities: { join: { match: { @@ -36,7 +59,7 @@ module.exports = { }, async onReceive(ctx, activity, actorUri) { const event = activity.object; - const attendeesCollectionUri = event['apods:attendees']; + const attendeesCollectionUri = await this.fetchAttendeesCollectionUri(ctx, event, actorUri); if (!attendeesCollectionUri) { this.logger.warn(`No attendees collection on ${event.id}, skipping Join`); @@ -69,7 +92,7 @@ module.exports = { }, async onReceive(ctx, activity, actorUri) { const event = activity.object; - const attendeesCollectionUri = event['apods:attendees']; + const attendeesCollectionUri = await this.fetchAttendeesCollectionUri(ctx, event, actorUri); if (!attendeesCollectionUri) { this.logger.warn(`No attendees collection on ${event.id}, skipping Leave`); diff --git a/cucumber.json b/cucumber.json index 3075ead..d4649cc 100644 --- a/cucumber.json +++ b/cucumber.json @@ -16,6 +16,6 @@ "formatOptions": { "snippetInterface": "async-await" }, - "strict": false + "strict": true } } diff --git a/cucumber.smoke.json b/cucumber.smoke.json index d6d2134..8082550 100644 --- a/cucumber.smoke.json +++ b/cucumber.smoke.json @@ -12,6 +12,6 @@ "formatOptions": { "snippetInterface": "async-await" }, - "strict": false + "strict": true } } diff --git a/docker-compose.yml b/docker-compose.yml index 98ec98f..63bc42f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: context: . dockerfile: Dockerfile ports: - - "3000:3000" + - "5000:3000" environment: - PORT=3000 - NODE_ENV=production @@ -22,7 +22,8 @@ services: ports: - "3003:3003" env_file: - - ./backend/.env + - path: ./backend/.env + required: false environment: - SEMAPPS_HOME_URL=${SEMAPPS_HOME_URL:-http://localhost:3003} - SEMAPPS_FRONT_URL=${SEMAPPS_FRONT_URL:-http://localhost:5000} diff --git a/package.json b/package.json index e488c83..87547d7 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "build": "bun run build.ts", "test:cucumber": "bun run cucumber:run && bun run cucumber:report && bun run features:parse && bun run steps:extract", "cucumber:run": "node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json", - "test:data": "node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json --tags '@data and not @suspendu'", "test:auth-setup": "bun scripts/setup-test-auth.ts", "cucumber:report": "bun scripts/parse-test-results.ts", "features:parse": "bun scripts/parse-features.ts", diff --git a/reports/cucumber-report.html b/reports/cucumber-report.html index afb34b2..c01d078 100644 --- a/reports/cucumber-report.html +++ b/reports/cucumber-report.html @@ -50,7 +50,7 @@