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
+2 -2
View File
@@ -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=
@@ -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)
@@ -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
+1 -1
View File
@@ -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
+2 -4
View File
@@ -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"
}
+3 -4
View File
@@ -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:
+33
View File
@@ -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;
}
}
}
};
+25 -2
View File
@@ -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`);
+1 -1
View File
@@ -16,6 +16,6 @@
"formatOptions": {
"snippetInterface": "async-await"
},
"strict": false
"strict": true
}
}
+1 -1
View File
@@ -12,6 +12,6 @@
"formatOptions": {
"snippetInterface": "async-await"
},
"strict": false
"strict": true
}
}
+3 -2
View File
@@ -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}
-1
View File
@@ -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",
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -106,7 +106,7 @@ async function main() {
fs.mkdirSync(path.dirname(AUTH_STATE_PATH), { recursive: true });
fs.writeFileSync(AUTH_STATE_PATH, JSON.stringify(state, null, 2));
console.log(`\nAuth state saved to: ${AUTH_STATE_PATH}`);
console.log('You can now run: bun run test:data');
console.log('Auth state saved. The @data/@e2e suites that consume it are suspended (@suspendu) until the NextGraph workstream resumes.');
} catch {
console.log('\nCould not save auth state — browser may have closed too quickly.');
}
+95 -13
View File
@@ -29,14 +29,45 @@ import { ensureIdentity } from '@ng-eventually/polyfill';
import { startNgSession } from '../shared/utils/ngSession';
import { setCurrentPrincipal } from '../shared/utils/currentPrincipal';
import { setCurrentSession } from '../shared/utils/currentSession';
import type { AuthProvider } from '@activitypods/refine-providers';
import { useRouter, useNavigate } from './router';
import { initialBackendName } from '../shared/refine/providers';
import { getBackendUrl } from '../shared/utils/runtimeConfig';
import { buildActivityPodsAuthProvider, parseAuthFlowStage } from './auth/activityPodsAuth';
import {
buildActivityPodsAuthProvider,
parseAuthFlowStage,
safeRedirectPath,
} from './auth/activityPodsAuth';
import { PodProviderPicker } from './auth/PodProviderPicker';
type ActivityPodsAuthState = 'checking' | 'authenticated' | 'unauthenticated' | 'error';
type CallbackResult = Awaited<ReturnType<AuthProvider['handleCallback']>>;
/**
* The in-flight (or settled) `handleCallback()` exchange, keyed by the `?code=`
* it belongs to. React's StrictMode double-invokes effects in dev and the
* authorization code is single-use: a second exchange would race the first, throw
* on the loser, and show the fatal screen after a successful login. Both effect
* runs share this one promise, so the exchange happens exactly once; only the
* live run publishes its result. Scoped to the code value so a genuinely new
* login (a new code) is not mistaken for a re-entry.
*/
let callbackExchange: { code: string; promise: Promise<CallbackResult> } | undefined;
/**
* Drop the OIDC callback parameters once the exchange has run. The code is
* consumed; leaving it in the address bar lets a later effect re-run — or a
* reload — treat the page as a fresh callback.
*/
function stripCallbackParams(): void {
const url = new URL(window.location.href);
url.searchParams.delete('code');
url.searchParams.delete('state');
url.searchParams.delete('session_state');
window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`);
}
export function AuthGate({ children }: { children: ReactNode }) {
const { route } = useRouter();
const navigate = useNavigate();
@@ -86,28 +117,68 @@ export function AuthGate({ children }: { children: ReactNode }) {
let cancelled = false;
async function run() {
const params = new URLSearchParams(window.location.search);
const stage = parseAuthFlowStage(window.location.search);
// Captured before the exchange so stripping the params cannot erase what
// the failure decision below needs.
const code = params.get('code');
const hadFlowState = params.has('state');
try {
if (stage.stage === 'callback') {
const result = await provider.handleCallback();
if (cancelled) return;
const session = provider.getSession();
setCurrentPrincipal(result.webId);
setCurrentSession(session ? { webId: session.webId, token: session.token } : undefined);
navigate(`/login?register_app=1&redirect=${encodeURIComponent(stage.redirectPath)}`);
return;
// One exchange per code: the StrictMode double-run (both mounts see the
// same `?code=`) shares the in-flight promise instead of racing.
if (!callbackExchange || callbackExchange.code !== code) {
callbackExchange = { code: code ?? '', promise: provider.handleCallback() };
}
const exchange = callbackExchange;
try {
const result = await exchange.promise;
stripCallbackParams();
if (cancelled) return;
const session = provider.getSession();
setCurrentPrincipal(result.webId);
setCurrentSession(session ? { webId: session.webId, token: session.token } : undefined);
// The contract's callback result carries where to return to; honour
// it (validated) rather than trusting only the parsed URL.
const redirect = safeRedirectPath(result.redirect ?? stage.redirectPath);
navigate(`/login?register_app=1&redirect=${encodeURIComponent(redirect)}`);
return;
} catch (err) {
stripCallbackParams();
if (cancelled) return;
// A `?code=` with no flow state — a stale bookmark, any link that
// happens to carry the param — is not a callback the provider can
// complete: it had nothing to exchange. Degrade to the picker rather
// than the fatal screen. A callback that DID carry flow state (a
// `state` param) stays a visible error: it is a real attempt the
// provider refused.
if (!hadFlowState) {
setCurrentSession(undefined);
setApState('unauthenticated');
return;
}
throw err;
} finally {
if (callbackExchange === exchange) callbackExchange = undefined;
}
}
if (stage.stage === 'register_app') {
const session = provider.getSession();
const registration = session ? await provider.registerApp(session.webId) : undefined;
if (!session) {
// No live session at this stage — a stale `?register_app=1` link, or
// a token that has expired. There is nothing to register; show the
// picker instead of stalling on a permanent blank `checking` screen.
setCurrentSession(undefined);
if (!cancelled) setApState('unauthenticated');
return;
}
const registration = await provider.registerApp(session.webId);
if (cancelled) return;
if (registration) {
if (session) {
setCurrentPrincipal(session.webId);
setCurrentSession({ webId: session.webId, token: session.token });
}
setCurrentPrincipal(session.webId);
setCurrentSession({ webId: session.webId, token: session.token });
navigate(stage.redirectPath);
}
// If `registerApp` returned `undefined`, the browser left for the Pod's
@@ -195,9 +266,20 @@ export function AuthGate({ children }: { children: ReactNode }) {
return null;
}
if (apState === 'unauthenticated') {
// Seed the post-login destination: an explicit (validated) `?redirect=`
// wins; otherwise a deep link the user opened lands them back on that page
// once signed in, instead of dropping everyone on `/home`. Login and the
// bare welcome route are not deep links.
const hasExplicitRedirect = new URLSearchParams(window.location.search).has('redirect');
const deepLink =
route.page !== 'login' && route.page !== 'welcome' ? window.location.pathname : undefined;
const loginRedirect = hasExplicitRedirect
? parseAuthFlowStage(window.location.search).redirectPath
: deepLink ?? '/home';
return (
<PodProviderPicker
authProvider={apAuthProvider!}
redirect={loginRedirect}
onDemo={() => {
setDemoMode(true);
navigate('/home');
+7 -3
View File
@@ -5,6 +5,12 @@ import { resolveActivityPodsAuthConfig } from './activityPodsAuth';
interface PodProviderPickerProps {
authProvider: AuthProvider;
/**
* Where to send the user once the flow completes. The gate computes it —
* a validated `?redirect=` value, or the route the user was trying to reach —
* so the picker never reads the raw URL itself.
*/
redirect: string;
onDemo?: () => void;
}
@@ -15,13 +21,11 @@ interface PodProviderPickerProps {
* offered as a one-click option. The user can also type any Solid Pod provider
* URL. A "continue offline" button keeps the local demo fallback reachable.
*/
export function PodProviderPicker({ authProvider, onDemo }: PodProviderPickerProps) {
export function PodProviderPicker({ authProvider, redirect, onDemo }: PodProviderPickerProps) {
const { podProviderUrl } = resolveActivityPodsAuthConfig(window.location.origin);
const [customUrl, setCustomUrl] = useState('');
const startLogin = (issuer: string) => {
const params = new URLSearchParams(window.location.search);
const redirect = params.get('redirect') || '/home';
void authProvider.login({ issuer, redirect });
};
+24 -4
View File
@@ -26,6 +26,12 @@ test('clientId falls back to origin/app when nothing is configured', () => {
expect(cfg.clientId).toBe('http://localhost:5000/app');
});
test('clientId does not double the slash on a trailing-slash backendUrl', () => {
setRuntimeConfig({ backendUrl: 'http://localhost:3003/' });
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
expect(cfg.clientId).toBe('http://localhost:3003/app');
});
test('podProviderUrl is passed through when set', () => {
setRuntimeConfig({ podProviderUrl: 'http://localhost:3000' });
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
@@ -66,8 +72,22 @@ test('parseAuthFlowStage detects app registration stage', () => {
});
test('parseAuthFlowStage defaults to picker', () => {
expect(parseAuthFlowStage('')).toEqual({ stage: 'picker' });
// A bare `?redirect` does not change the stage; the redirect path is only read
// when the user lands back from the Pod provider.
expect(parseAuthFlowStage('?redirect=/events')).toEqual({ stage: 'picker' });
expect(parseAuthFlowStage('')).toEqual({ stage: 'picker', redirectPath: '/home' });
// A bare `?redirect` does not change the stage; it is carried (validated) so
// every stage exposes the same post-login destination.
expect(parseAuthFlowStage('?redirect=/events')).toEqual({
stage: 'picker',
redirectPath: '/events',
});
});
test('parseAuthFlowStage rejects non-same-origin redirects', () => {
// Absolute, protocol-relative, and backslash spellings all resolve off-origin
// — they must fall back to /home, never reach history.pushState.
expect(parseAuthFlowStage('?code=abc&redirect=https://evil.example').redirectPath).toBe('/home');
expect(parseAuthFlowStage('?register_app=1&redirect=//evil.example').redirectPath).toBe('/home');
expect(parseAuthFlowStage('?code=abc&redirect=/\\evil.example').redirectPath).toBe('/home');
expect(parseAuthFlowStage('?code=abc&redirect=relative/path').redirectPath).toBe('/home');
// A same-origin deep link survives untouched.
expect(parseAuthFlowStage('?code=abc&redirect=/events/42').redirectPath).toBe('/events/42');
});
+37 -4
View File
@@ -19,11 +19,43 @@ export function buildActivityPodsAuthProvider(origin: string): AuthProvider {
});
}
/**
* End the ActivityPods session on this device.
*
* Per the contract, `logout` clears local state — the stored OIDC token — and
* the token lives at ONE fixed storage key per page (`One session per page`,
* non-guarantees), so clearing it here ends the session for the whole app. The
* provider is built from the same resolved config as the gate and the data
* provider; the app's own session store (`utils/currentSession`) is cleared by
* the caller, before the reload.
*/
export async function logoutActivityPods(): Promise<void> {
await buildActivityPodsAuthProvider(window.location.origin).logout({});
}
/** Which stage of the Solid-OIDC / app-registration flow the current URL represents. */
export type AuthFlowStage =
| { stage: 'callback'; redirectPath: string }
| { stage: 'register_app'; redirectPath: string }
| { stage: 'picker' };
| { stage: 'picker'; redirectPath: string };
/**
* A same-origin post-login destination, or `/home`.
*
* Only a leading-slash path is accepted. An absolute URL, a protocol-relative
* `//host` value, its backslash spelling `/\host` (which the URL parser resolves
* the same way), or anything not starting with `/` falls back to `/home`.
* Validated here, in the one place the URL is parsed, so every consumer — the
* callback navigation and the picker's login call — inherits it. Without this an
* attacker-chosen `?redirect=` could leave the origin, and `history.pushState`
* would throw a SecurityError into the fatal screen.
*/
export function safeRedirectPath(raw: string | null | undefined): string {
if (!raw || !raw.startsWith('/') || raw.startsWith('//') || raw.startsWith('/\\')) {
return '/home';
}
return raw;
}
/**
* Parse the current URL search params to decide what AuthGate should do.
@@ -34,12 +66,13 @@ export type AuthFlowStage =
* registered before entering the app.
* - otherwise → show the Pod-provider picker.
*
* `redirect` is preserved through the stages and defaults to `/home`.
* `redirect` is validated (`safeRedirectPath`) and carried on every stage,
* defaulting to `/home`.
*/
export function parseAuthFlowStage(search: string): AuthFlowStage {
const params = new URLSearchParams(search);
const redirectPath = params.get('redirect') || '/home';
const redirectPath = safeRedirectPath(params.get('redirect'));
if (params.has('code')) return { stage: 'callback', redirectPath };
if (params.has('register_app')) return { stage: 'register_app', redirectPath };
return { stage: 'picker' };
return { stage: 'picker', redirectPath };
}
+75
View File
@@ -0,0 +1,75 @@
import { expect, test } from 'bun:test';
import { parsePath, routeToPath, eventPath, userPath, type Route } from './router';
// Ids on the ActivityPods backend are absolute IRIs. The router must carry them
// through the URL intact: encode on the way out, decode on the way in. A raw IRI
// in a path would be read as route structure (the `/` and `:` split it, and the
// `http:` segment falls through to the welcome page).
const IRI = 'http://localhost:3000/alice/data/abc';
test('an absolute-IRI event id survives routeToPath → parsePath intact', () => {
const route: Route = { page: 'event-detail', eventId: IRI };
const path = routeToPath(route);
expect(path).toBe(`/events/${encodeURIComponent(IRI)}`);
expect(parsePath(path)).toEqual({ page: 'event-detail', eventId: IRI });
});
test('an absolute-IRI event id survives every sub-route intact', () => {
const cases: Array<[Route, Route]> = [
[{ page: 'update-event', eventId: IRI }, { page: 'update-event', eventId: IRI }],
[{ page: 'invite', eventId: IRI }, { page: 'invite', eventId: IRI }],
[{ page: 'participants', eventId: IRI }, { page: 'participants', eventId: IRI }],
[{ page: 'meeting-points', eventId: IRI }, { page: 'meeting-points', eventId: IRI }],
];
for (const [route, expected] of cases) {
expect(parsePath(routeToPath(route))).toEqual(expected);
}
});
test('an absolute-IRI user id survives routeToPath → parsePath intact', () => {
const route: Route = { page: 'user-profile', userId: IRI };
expect(parsePath(routeToPath(route))).toEqual({ page: 'user-profile', userId: IRI });
});
// The id ITSELF may end in a segment that names a sub-route; encoding is what
// keeps it from being read as one.
test('an encoded id whose tail looks like a sub-route is not mistaken for one', () => {
const tricky = 'http://localhost:3000/alice/data/edit';
expect(parsePath(routeToPath({ page: 'event-detail', eventId: tricky }))).toEqual({
page: 'event-detail',
eventId: tricky,
});
expect(parsePath(eventPath(tricky, 'participants'))).toEqual({
page: 'participants',
eventId: tricky,
});
});
test('eventPath and userPath are the encoded path builders', () => {
expect(eventPath(IRI)).toBe(`/events/${encodeURIComponent(IRI)}`);
expect(eventPath(IRI, 'edit')).toBe(`/events/${encodeURIComponent(IRI)}/edit`);
expect(userPath(IRI)).toBe(`/users/${encodeURIComponent(IRI)}`);
});
// Defensive: a raw (unencoded) IRI still parses rather than falling through —
// the id is taken as the whole remainder and the sub-route from the tail.
test('a raw absolute-IRI path still parses as the whole remainder', () => {
expect(parsePath('/events/http://localhost:3000/alice/data/abc')).toEqual({
page: 'event-detail',
eventId: IRI,
});
expect(parsePath('/events/http://localhost:3000/alice/data/abc/participants')).toEqual({
page: 'participants',
eventId: IRI,
});
});
test('the fixed routes are unchanged', () => {
expect(parsePath('/')).toEqual({ page: 'welcome' });
expect(parsePath('/home')).toEqual({ page: 'home' });
expect(parsePath('/events')).toEqual({ page: 'events' });
expect(parsePath('/events/new')).toEqual({ page: 'create-event' });
expect(parsePath('/profile/edit')).toEqual({ page: 'edit-profile' });
expect(parsePath('/unknown')).toEqual({ page: 'welcome' });
});
+49 -13
View File
@@ -34,7 +34,21 @@ export interface RouteParams {
// Path parsing & generation
// ============================================================================
function parsePath(pathname: string): Route {
/**
* Decode one path segment. Ids on the ActivityPods backend are absolute IRIs, so
* they are percent-encoded by `routeToPath`; a malformed escape (a raw IRI that
* was never encoded) is returned verbatim rather than throwing — the captured
* text IS the id in that case.
*/
function decodeSegment(segment: string): string {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
export function parsePath(pathname: string): Route {
const path = pathname.replace(/\/+$/, '') || '/';
if (path === '/' || path === '') return { page: 'welcome' };
@@ -49,10 +63,14 @@ function parsePath(pathname: string): Route {
if (path === '/profile/share') return { page: 'share-profile' };
if (path === '/profile/connect') return { page: 'connect' };
// /events/:id/...
const eventMatch = path.match(/^\/events\/([^/]+)(?:\/(.+))?$/);
// /events/:id/... — the id is an absolute IRI, encoded by `routeToPath` so it
// holds no `/`; the sub-route is matched from the tail, and a raw (unencoded)
// IRI still parses as the whole remainder. An ENCODED id may itself contain a
// `/edit` segment without being read as a sub-route, which is why encoding is
// mandatory on every navigation.
const eventMatch = path.match(/^\/events\/(.+?)(?:\/(edit|invite|participants|meeting-points))?$/);
if (eventMatch) {
const eventId = eventMatch[1]!;
const eventId = decodeSegment(eventMatch[1]!);
const sub = eventMatch[2];
if (!sub) return { page: 'event-detail', eventId };
if (sub === 'edit') return { page: 'update-event', eventId };
@@ -61,10 +79,10 @@ function parsePath(pathname: string): Route {
if (sub === 'meeting-points') return { page: 'meeting-points', eventId };
}
// /users/:id
const userMatch = path.match(/^\/users\/([^/]+)$/);
// /users/:id — an absolute actor URI, encoded for the same reason.
const userMatch = path.match(/^\/users\/(.+)$/);
if (userMatch) {
return { page: 'user-profile', userId: userMatch[1]! };
return { page: 'user-profile', userId: decodeSegment(userMatch[1]!) };
}
return { page: 'welcome' };
@@ -77,21 +95,39 @@ export function routeToPath(route: Route): string {
case 'events': return '/events';
case 'create-event': return '/events/new';
case 'login': return '/login';
case 'event-detail': return `/events/${route.eventId}`;
case 'update-event': return `/events/${route.eventId}/edit`;
case 'invite': return `/events/${route.eventId}/invite`;
case 'participants': return `/events/${route.eventId}/participants`;
case 'meeting-points': return `/events/${route.eventId}/meeting-points`;
// Ids are absolute IRIs: they MUST be percent-encoded into a single path
// segment, or the `/` and `:` inside them are read as route structure and
// navigation falls through to the welcome page. `parsePath` decodes them.
case 'event-detail': return `/events/${encodeURIComponent(route.eventId)}`;
case 'update-event': return `/events/${encodeURIComponent(route.eventId)}/edit`;
case 'invite': return `/events/${encodeURIComponent(route.eventId)}/invite`;
case 'participants': return `/events/${encodeURIComponent(route.eventId)}/participants`;
case 'meeting-points': return `/events/${encodeURIComponent(route.eventId)}/meeting-points`;
case 'profile': return '/profile';
case 'edit-profile': return '/profile/edit';
case 'friends': return '/profile/friends';
case 'share-profile': return '/profile/share';
case 'connect': return '/profile/connect';
case 'user-profile': return `/users/${route.userId}`;
case 'user-profile': return `/users/${encodeURIComponent(route.userId)}`;
case 'settings': return '/settings';
}
}
/**
* Build a path to an event sub-route, encoding the id. Screens navigate by path
* rather than by `Route`, so this is the one place that keeps a raw IRI out of
* the URL structure.
*/
export function eventPath(eventId: string, sub?: 'edit' | 'invite' | 'participants' | 'meeting-points'): string {
const base = `/events/${encodeURIComponent(eventId)}`;
return sub ? `${base}/${sub}` : base;
}
/** Build a path to a user profile, encoding the actor URI. */
export function userPath(userId: string): string {
return `/users/${encodeURIComponent(userId)}`;
}
// ============================================================================
// Router context
// ============================================================================
+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) : 3000;
const port = process.env.PORT ? parseInt(process.env.PORT) : 5000;
// 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
@@ -2,7 +2,7 @@ import React, { useState, useMemo } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Header, Text, Input, Button, Placeholder, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { useNavigate } from '../../../app/router';
import { eventPath, useNavigate } from '../../../app/router';
const importableEvents = [
{
@@ -108,7 +108,7 @@ export function CreateEventScreen() {
return;
}
showToast('Événement relayé', 'success');
navigate(`/events/${newEvent.id}`);
navigate(eventPath(newEvent.id));
};
return (
@@ -239,7 +239,7 @@ export function CreateEventScreen() {
<Button
variant="primary"
style={{ width: '100%', padding: 10, fontSize: 13 }}
onClick={() => navigate(`/events/${ev.id}`)}
onClick={() => navigate(eventPath(ev.id))}
>
Voir cet événement
</Button>
+25 -11
View File
@@ -1,7 +1,7 @@
import { ArrowLeft } from 'lucide-react';
import { Button, Avatar, EventCover, EventMeetingPoints, showToast, Text, type MeetingPointData } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { useNavigate, useParams } from '../../../app/router';
import { eventPath, useNavigate, useParams, userPath } from '../../../app/router';
export function EventDetailScreen() {
const navigate = useNavigate();
@@ -68,11 +68,14 @@ export function EventDetailScreen() {
const canEdit = ownership === 'mine';
// Preview list shows the OTHER participants (deliberate — the total is in the
// header count; the full list at "Voir tous les participants" shows everyone).
// Compare on the PROFILE id: `currentUser.id` is the resolved profile NURI, the
// same space as `p.id` — whereas `currentUserId` is the `urn:festipod:user:`
// principal, which never equals a profile id in connected mode (so the old
// `p.id !== currentUserId` failed to drop self, leaking it in as "1 unknown").
// On this backend `currentUser.id` and `p.id` are BOTH actor URIs (the id the
// attendees collection keys memberships by), so comparing them drops self.
const knownParticipants = participants.filter(p => p.id !== currentUser?.id);
// The rest of the total could not be resolved to a profile — a non-contact
// sees only anonymous attendees. The total still counts them, so the
// difference is rendered as anonymous rows (mirrors ParticipantsListScreen),
// never dropped: the count and the rows must agree.
const unknownCount = Math.max(0, (event?.participantCount ?? 0) - participants.length);
if (!event) {
return (
@@ -101,7 +104,7 @@ export function EventDetailScreen() {
</div>
{canEdit && (
<span
onClick={() => navigate(`/events/${eventId}/edit`)}
onClick={() => navigate(eventPath(event.id, 'edit'))}
title="Modifier l'événement"
style={{ cursor: 'pointer', fontSize: 18, color: '#888' }}
>✎</span>
@@ -157,7 +160,7 @@ export function EventDetailScreen() {
{joined && (
<div style={{ margin: '4px 16px 16px', display: 'flex', gap: 8 }}>
<Button
onClick={() => navigate(`/events/${eventId}/invite`)}
onClick={() => navigate(eventPath(event.id, 'invite'))}
style={{ flex: 1, padding: '12px 0' }}
>
Inviter
@@ -178,7 +181,7 @@ export function EventDetailScreen() {
<div style={{ padding: '0 16px 8px' }}>
<button
onClick={() => navigate(`/events/${eventId}/meeting-points`)}
onClick={() => navigate(eventPath(event.id, 'meeting-points'))}
style={{ width: '100%', padding: 12, border: '2px dashed #ddd', borderRadius: 12, background: 'none', fontSize: 13, fontWeight: 600, color: '#999', cursor: 'pointer', marginTop: 4, fontFamily: 'var(--font-app)' }}
>
+ Proposer un point de rencontre
@@ -192,7 +195,7 @@ export function EventDetailScreen() {
{knownParticipants.map((p) => (
<div
key={p.id}
onClick={() => navigate(`/users/${p.id}`)}
onClick={() => navigate(userPath(p.id))}
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0', borderBottom: '1px solid #f5f5f5', cursor: 'pointer' }}
>
<Avatar initials={p.initials} size={38} color="#2B6CB0" />
@@ -204,10 +207,21 @@ export function EventDetailScreen() {
</div>
</div>
))}
{knownParticipants.length < event.participantCount && (
{Array.from({ length: unknownCount }, (_, i) => (
<div
key={`unknown-${i}`}
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0', borderBottom: '1px solid #f5f5f5' }}
>
<Avatar initials="?" size={38} color="#ccc" />
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, color: '#999' }}>Participant inconnu</div>
</div>
</div>
))}
{unknownCount > 0 && (
<div
style={{ marginTop: 12, padding: 12, background: '#f9f9f9', borderRadius: 12, textAlign: 'center', cursor: 'pointer' }}
onClick={() => navigate(`/events/${eventId}/participants`)}
onClick={() => navigate(eventPath(event.id, 'participants'))}
>
<span style={{ fontSize: 12, color: '#999' }}>Voir tous les participants →</span>
</div>
+2 -2
View File
@@ -1,7 +1,7 @@
import { ArrowLeft } from 'lucide-react';
import { Header, Input, Card, Badge, BottomNav, AvatarStack, getEventPhotoUrl, Text } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { useNavigate } from '../../../app/router';
import { eventPath, useNavigate } from '../../../app/router';
const PEOPLE = [
{ name: 'Marie Leroy', color: '#E8590C' },
@@ -77,7 +77,7 @@ export function EventsScreen() {
</Text>
)}
{events.map(event => (
<EventCard key={event.id} event={event} onClick={() => navigate(`/events/${event.id}`)} />
<EventCard key={event.id} event={event} onClick={() => navigate(eventPath(event.id))} />
))}
</div>
+3 -3
View File
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Header, Input, Text, Avatar, Checkbox, Button, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { useNavigate, useParams } from '../../../app/router';
import { eventPath, useNavigate, useParams } from '../../../app/router';
export function InviteScreen() {
const navigate = useNavigate();
@@ -24,7 +24,7 @@ export function InviteScreen() {
const send = () => {
showToast(`${selected.size} invitation${selected.size > 1 ? 's' : ''} envoyée${selected.size > 1 ? 's' : ''}`, 'success');
navigate(`/events/${eventId}`);
navigate(eventPath(eventId ?? ''));
};
if (step === 'message') {
@@ -122,7 +122,7 @@ export function InviteScreen() {
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Inviter des amis"
left={<ArrowLeft size={20} onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer' }} />}
left={<ArrowLeft size={20} onClick={() => navigate(eventPath(eventId ?? ''))} style={{ cursor: 'pointer' }} />}
/>
<div style={{ padding: '12px 16px', borderBottom: '1px solid #f0f0f0' }}>
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Button, Avatar, Input, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { useNavigate, useParams } from '../../../app/router';
import { eventPath, useNavigate, useParams } from '../../../app/router';
export function MeetingPointsScreen() {
const navigate = useNavigate();
@@ -28,13 +28,13 @@ export function MeetingPointsScreen() {
time: when || duration,
});
showToast(title ? `Point de rencontre créé : ${title}` : 'Point de rencontre créé', 'success');
navigate(`/events/${eventId}`);
navigate(eventPath(eventId ?? ''));
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ padding: '8px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
<button onClick={() => navigate(`/events/${eventId}`)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#333', padding: 0, display: 'flex', alignItems: 'center' }}><ArrowLeft size={20} /></button>
<button onClick={() => navigate(eventPath(eventId ?? ''))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#333', padding: 0, display: 'flex', alignItems: 'center' }}><ArrowLeft size={20} /></button>
<span style={{ fontSize: 17, fontWeight: 700 }}>Point de rencontre</span>
</div>
@@ -1,7 +1,7 @@
import { ArrowLeft } from 'lucide-react';
import { Header, Avatar, Text, Input } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { useNavigate, useParams } from '../../../app/router';
import { eventPath, useNavigate, useParams, userPath } from '../../../app/router';
const COLORS = ['#E8590C', '#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E', '#E53E3E'];
@@ -43,7 +43,7 @@ export function ParticipantsListScreen() {
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title={`Participants (${totalCount})`}
left={<ArrowLeft size={20} onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer' }} />}
left={<ArrowLeft size={20} onClick={() => navigate(eventPath(eventId ?? ''))} style={{ cursor: 'pointer' }} />}
/>
<div style={{ padding: 16, borderBottom: '1px solid #f0f0f0' }}>
@@ -54,7 +54,7 @@ export function ParticipantsListScreen() {
{rows.map((p) => (
<div
key={p.key}
onClick={p.known ? () => navigate(`/users/${p.key}`) : undefined}
onClick={p.known ? () => navigate(userPath(p.key)) : undefined}
style={{
display: 'flex',
alignItems: 'center',
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Header, Text, Input, Button, Placeholder, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { useNavigate, useParams } from '../../../app/router';
import { eventPath, useNavigate, useParams } from '../../../app/router';
export function UpdateEventScreen() {
const navigate = useNavigate();
@@ -46,7 +46,7 @@ export function UpdateEventScreen() {
}))
.then(() => {
showToast('Événement mis à jour', 'success');
navigate(`/events/${eventId}`);
navigate(eventPath(eventId ?? ''));
})
.catch((err: unknown) => {
console.error('[UpdateEvent] event update failed:', err);
@@ -61,7 +61,7 @@ export function UpdateEventScreen() {
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Modifier l'événement"
left={<span onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
left={<span onClick={() => navigate(eventPath(eventId ?? ''))} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
/>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
<Text style={{ textAlign: 'center', color: '#888' }}>
@@ -80,7 +80,7 @@ export function UpdateEventScreen() {
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Modifier l'événement"
left={<span onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
left={<span onClick={() => navigate(eventPath(eventId ?? ''))} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
/>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
<Text aria-busy="true" style={{ textAlign: 'center', color: '#888' }}>
@@ -95,7 +95,7 @@ export function UpdateEventScreen() {
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Modifier l'événement"
left={<span onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
left={<span onClick={() => navigate(eventPath(eventId ?? ''))} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
/>
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
+17 -4
View File
@@ -2,19 +2,32 @@ import { useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Header, Text, ListItem, Toggle, Divider, BottomNav } from '../../../shared/components/sketchy';
import { useNavigate } from '../../../app/router';
import { initialBackendName } from '../../../shared/refine/providers';
import { logoutNg } from '../../../shared/utils/ngSession';
import { setCurrentSession } from '../../../shared/utils/currentSession';
import { logoutActivityPods } from '../../../app/auth/activityPodsAuth';
export function SettingsScreen() {
const navigate = useNavigate();
const [notifications, setNotifications] = useState(true);
const [darkMode, setDarkMode] = useState(false);
const [location, setLocation] = useState(true);
const backendName = initialBackendName();
// The ONLY logout: stop the session of the wallet this deployment serves, which
// forces a broker redirect on the next access. There is nothing app-side to sign
// out of — the app never names an identity, so it holds none to drop.
// Sign out of the session THIS deployment actually runs.
//
// ActivityPods keeps its OIDC token in storage, so the NextGraph no-op would
// leave it in place and the gate would re-authenticate on the reload. The auth
// provider's own `logout` clears it, and the app's session store is dropped too
// so nothing re-reads the dead session in between. NextGraph (the suspended
// variant) keeps its wallet-session stop.
const handleLeaveEnvironment = async () => {
await logoutNg();
if (backendName === 'activitypods') {
await logoutActivityPods();
setCurrentSession(undefined);
} else {
await logoutNg();
}
if (typeof window !== 'undefined') window.location.reload();
};
@@ -1,7 +1,7 @@
import { ArrowLeft } from 'lucide-react';
import { Header, Text, Avatar, Input, Button, BottomNav } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { useNavigate } from '../../../app/router';
import { useNavigate, userPath } from '../../../app/router';
const COLORS = ['#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E', '#E53E3E', '#E8590C'];
@@ -25,7 +25,7 @@ export function FriendsListScreen() {
{friends.map((person, i) => (
<div
key={person.id}
onClick={() => navigate(`/users/${person.id}`)}
onClick={() => navigate(userPath(person.id))}
style={{
display: 'flex',
alignItems: 'center',
+2 -2
View File
@@ -1,6 +1,6 @@
import { Header, Avatar, Title, Text, Button, Card, Divider, BottomNav, Tag } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { useNavigate } from '../../../app/router';
import { eventPath, useNavigate } from '../../../app/router';
export function ProfileScreen() {
const navigate = useNavigate();
@@ -70,7 +70,7 @@ export function ProfileScreen() {
<div style={{ padding: 16 }}>
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Mes événements à venir</Text>
{myEvents.slice(0, 3).map((event) => (
<Card key={event.id} onClick={() => navigate(`/events/${event.id}`)} style={{ marginBottom: 12 }}>
<Card key={event.id} onClick={() => navigate(eventPath(event.id))} style={{ marginBottom: 12 }}>
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{event.title}</Text>
<Text style={{ margin: '4px 0 0 0', fontSize: 13, color: '#888' }}>{event.date}</Text>
</Card>
@@ -1,7 +1,7 @@
import { ArrowLeft } from 'lucide-react';
import { Header, Avatar, Title, Text, Button, Card, Badge, Divider } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { useNavigate, useGoBack, useParams } from '../../../app/router';
import { eventPath, useNavigate, useGoBack, useParams } from '../../../app/router';
export function UserProfileScreen() {
const navigate = useNavigate();
@@ -65,7 +65,7 @@ export function UserProfileScreen() {
<div style={{ padding: 16 }}>
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Événements à venir</Text>
{userEvents.map((event) => (
<Card key={event.id} onClick={() => navigate(`/events/${event.id}`)} style={{ marginBottom: 12 }}>
<Card key={event.id} onClick={() => navigate(eventPath(event.id))} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{event.title}</Text>
+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,