diff --git a/.project/concepts/data-layer/activitypods-providers/usage_festipod.md b/.project/concepts/data-layer/activitypods-providers/usage_festipod.md index 47d3bd3..7226060 100644 --- a/.project/concepts/data-layer/activitypods-providers/usage_festipod.md +++ b/.project/concepts/data-layer/activitypods-providers/usage_festipod.md @@ -8,7 +8,7 @@ against: 66616484b96fb2227cd8cdf02321aacfb4811365 Festipod is a mobile-first web application: users create **meeting points** grafted onto public events, and sign up to them. Its entire persistence goes through Refine, behind a **backend seam** that exists so the app can support several Refine backends and let the user switch between them at runtime. This package is one of the two backends targeted; the other is a Refine provider over NextGraph plus its polyfill. The seam's own contract — what a backend must answer before it can be added — lives in code, in `src/shared/refine/providers/types.ts`. -Nothing is wired yet, so the list below is **declared, not measured**: it is what Festipod will call, derived from the seam's requirements and from the observation of the package pinned beside this declaration. `against:` names that observation's commit. The package publishes no engagement, so there is nothing to pull and nothing that can drift — what is recorded here is read against our own reconstruction, and re-read when it is re-stamped. +The seam is wired since 2026-10-09 (T04.e): this backend is the registered default, and Festipod reads and writes through it — `useList` over the shared `as:Event` container (split into events and meeting points by the bindings' `isInstance`), creates and updates through the data provider, and the embedded `apods:attendees` collection through the participation binding's `membersOf`. Joins/leaves are the one write beside Refine (the package implements no `custom`): an `as:Join`/`as:Leave` posted to the user's own outbox by `src/shared/data/outbox.ts`, per the platform flow recorded in the 2026-10-09 brief. What the list below declares is therefore measured where a runtime check exists and declared elsewhere; `against:` names the observation's commit, and the package publishes no engagement, so there is nothing to pull and nothing that can drift — what is recorded here is read against our own reconstruction, and re-read when it is re-stamped. ## Consumed surface diff --git a/src/app/App.tsx b/src/app/App.tsx index f7000c4..f8e48a6 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,4 +1,5 @@ -import { RouterProvider, useRouter } from './router'; +import { useEffect } from 'react'; +import { RouterProvider, useNavigate, useRouter } from './router'; import { ThemeProvider } from '../shared/context/ThemeContext'; import { NextGraphProvider } from '../shared/context/NextGraphContext'; import { FestipodDataProvider } from '../shared/context/FestipodDataContext'; @@ -38,7 +39,7 @@ function AppContent() { case 'home': return ; case 'events': return ; case 'create-event': return ; - case 'login': return null; + case 'login': return ; case 'event-detail': return ; case 'update-event': return ; case 'invite': return ; @@ -54,26 +55,44 @@ function AppContent() { } } +/** + * What `/login` shows once the gate has let the user through: home. The route + * exists for the Solid-OIDC callback (`AuthGate` intercepts `?code` and + * `?register_app` before children render); a plain `/login` with nothing left + * to do is not a destination — send the user to the app. + */ +function AuthenticatedLoginLanding() { + const navigate = useNavigate(); + useEffect(() => { + navigate('/home'); + }, [navigate]); + return null; +} + export function App() { const backendName = initialBackendName(); const isNg = backendName === 'nextgraph'; + // THE AUTH GATE ABOVE THE DATA PROVIDER — signing in must have settled before + // any data path mounts. The gate renders children only once there is a + // session to read through (or an explicit offline-demo choice), so the data + // provider's dispatch decision never races a sign-in that has not finished. const appTree = ( - - + +
- - - +
-
-
+ + ); return ( - {isNg ? {appTree} : appTree} + + {isNg ? {appTree} : appTree} + ); } diff --git a/src/app/AuthGate.tsx b/src/app/AuthGate.tsx index d707827..268a0de 100644 --- a/src/app/AuthGate.tsx +++ b/src/app/AuthGate.tsx @@ -7,7 +7,9 @@ * a Pod-provider picker when no session exists, and the `/login` route handling * the OAuth callback plus the ActivityPods app-registration stage. The auth * provider is built with the app description document served by our backend as - * `clientId` and `${origin}/login` as `redirectUri`. + * `clientId` and `${origin}/login` as `redirectUri`. Every session the flow + * establishes is published to `utils/currentSession` — the module store the + * data provider's dispatch reads, independent of where each mounts in the tree. * * 2. NEXTGRAPH (suspended at the root, kept for the URL variant). The legacy * `ensureIdentity()` flow: the SDK mounts its own full-screen barrier and owns @@ -26,6 +28,7 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react'; 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 { useRouter, useNavigate } from './router'; import { initialBackendName } from '../shared/refine/providers'; import { getBackendUrl } from '../shared/utils/runtimeConfig'; @@ -89,7 +92,9 @@ export function AuthGate({ children }: { children: ReactNode }) { 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; } @@ -99,7 +104,10 @@ export function AuthGate({ children }: { children: ReactNode }) { const registration = session ? await provider.registerApp(session.webId) : undefined; if (cancelled) return; if (registration) { - if (session) setCurrentPrincipal(session.webId); + if (session) { + setCurrentPrincipal(session.webId); + setCurrentSession({ webId: session.webId, token: session.token }); + } navigate(stage.redirectPath); } // If `registerApp` returned `undefined`, the browser left for the Pod's @@ -111,8 +119,10 @@ export function AuthGate({ children }: { children: ReactNode }) { const session = provider.getSession(); if (session) { setCurrentPrincipal(session.webId); + setCurrentSession({ webId: session.webId, token: session.token }); if (!cancelled) setApState('authenticated'); } else { + setCurrentSession(undefined); if (!cancelled) setApState('unauthenticated'); } } catch (err) { diff --git a/src/app/auth/activityPodsAuth.ts b/src/app/auth/activityPodsAuth.ts index 23d679d..3525e14 100644 --- a/src/app/auth/activityPodsAuth.ts +++ b/src/app/auth/activityPodsAuth.ts @@ -1,39 +1,13 @@ import { authProvider as createActivityPodsAuthProvider } from '@activitypods/refine-providers'; import type { AuthProvider } from '@activitypods/refine-providers'; -import { getRuntimeConfig } from '../../shared/utils/runtimeConfig'; +import { resolveActivityPodsAuthConfig } from '../../shared/utils/runtimeConfig'; -/** - * Resolved ActivityPods auth configuration. - * - * `clientId` names the app description document the backend serves, as required - * by the Solid-OIDC flow. `redirectUri` is fixed to `${origin}/login` because the - * app renders its own callback route there. - */ -export interface ResolvedAuthConfig { - clientId: string; - redirectUri: string; - podProviderUrl: string | undefined; -} - -/** - * Resolve the auth configuration from runtime values. - * - * - `backendClientId` wins when explicitly set. - * - Otherwise, `backendUrl` is used to derive `${backendUrl}/app`. - * - Otherwise, fall back to `${origin}/app` so local dev still typechecks and - * can be exercised without a backend. - */ -export function resolveActivityPodsAuthConfig(origin: string): ResolvedAuthConfig { - const cfg = getRuntimeConfig(); - const backendClientId = cfg.backendClientId?.trim(); - const backendUrl = cfg.backendUrl?.trim(); - const clientId = backendClientId || (backendUrl ? `${backendUrl}/app` : `${origin}/app`); - return { - clientId, - redirectUri: `${origin}/login`, - podProviderUrl: cfg.podProviderUrl?.trim() || undefined, - }; -} +// Re-exported so existing callers (AuthGate, the flow-stage tests) keep their +// import surface; the implementation lives in the runtime-config module, the +// shared source of truth both the auth gate and the data provider resolve +// against — one clientId/redirect for the whole app. +export { resolveActivityPodsAuthConfig }; +export type { ResolvedAuthConfig } from '../../shared/utils/runtimeConfig'; /** Build an ActivityPods auth provider with the Festipod-specific configuration. */ export function buildActivityPodsAuthProvider(origin: string): AuthProvider { diff --git a/src/modules/event/screens/EventDetailScreen.tsx b/src/modules/event/screens/EventDetailScreen.tsx index 9cd9ea9..d145f5e 100644 --- a/src/modules/event/screens/EventDetailScreen.tsx +++ b/src/modules/event/screens/EventDetailScreen.tsx @@ -10,15 +10,14 @@ export function EventDetailScreen() { getEvent, currentUser, isParticipating, - joinEvent, - leaveEvent, + joinMeetingPoint, + leaveMeetingPoint, getEventParticipants, getEventMeetingPoints, getEventOwnership, } = useFestipodData(); const event = eventId ? getEvent(eventId) : undefined; - const joined = eventId ? isParticipating(eventId) : false; const participants = eventId ? getEventParticipants(eventId) : []; const meetingPointsRaw = eventId ? getEventMeetingPoints(eventId) : []; @@ -30,6 +29,30 @@ export function EventDetailScreen() { lieu: mp.location, })); + // SIGNING UP IS PER MEETING POINT — the product model (on s'inscrit à un + // point de rencontre, pas à un événement). There is no event-level join on + // this screen anymore: "joined" means a member of at least one of this + // event's meeting points, and it gates the Invite affordance the way the + // old event-level membership did. + const joinedMeetingPointIds = new Set( + meetingPointsRaw.filter(mp => isParticipating(mp.id)).map(mp => String(mp.id)), + ); + const joined = joinedMeetingPointIds.size > 0; + + const handleToggleMeetingPoint = (id: string, _title: string, willJoin: boolean) => { + const failed = (message: string) => (err: unknown) => { + console.error('[EventDetail] meeting point participation failed:', err); + showToast(message, 'error'); + }; + if (willJoin) { + void Promise.resolve(joinMeetingPoint(id)) + .catch(failed("L'inscription au point de rencontre n'a pas pu être enregistrée")); + } else { + void Promise.resolve(leaveMeetingPoint(id)) + .catch(failed("La désinscription du point de rencontre n'a pas pu être enregistrée")); + } + }; + // EDITING IS OWNING. Only a document's owner writes to it, and nothing delegates // a write, so "may I edit this event" is answered entirely by whether this // session owns the event's document — no permission call, no extra read. @@ -51,28 +74,6 @@ export function EventDetailScreen() { // `p.id !== currentUserId` failed to drop self, leaking it in as "1 unknown"). const knownParticipants = participants.filter(p => p.id !== currentUser?.id); - const handleToggleJoin = () => { - if (!eventId) return; - // THE CONFIRMATION FOLLOWS THE WRITE. It used to be shown on the spot, before - // the call had settled, so a sign-up that wrote nothing still read as - // « Tu participes ». The screen's own list flips immediately anyway (the data - // layer's optimistic overlay), so nothing is lost by waiting for the truth. - const confirmed = (message: string, tone: 'success' | 'info') => () => showToast(message, tone); - const failed = (message: string) => (err: unknown) => { - console.error('[EventDetail] participation write failed:', err); - showToast(message, 'error'); - }; - if (joined) { - void Promise.resolve(leaveEvent(eventId)) - .then(confirmed('Participation annulée', 'info')) - .catch(failed("La désinscription n'a pas pu être enregistrée")); - } else { - void Promise.resolve(joinEvent(eventId)) - .then(confirmed('Tu participes à cet événement', 'success')) - .catch(failed("L'inscription n'a pas pu être enregistrée")); - } - }; - if (!event) { return (
@@ -150,30 +151,26 @@ export function EventDetailScreen() { )}
-
- - {joined && ( + {/* SIGNING UP IS PER MEETING POINT: no event-level join button here. The + join affordances live on the meeting points below; Invite follows + membership, the way the old event-level "Je participe" did. */} + {joined && ( +
- )} -
+
+ )} {meetingPoints.length > 0 && (
{}} + joinedIds={joinedMeetingPointIds} + onToggle={handleToggleMeetingPoint} expanded />
diff --git a/src/modules/event/steps/ui/event.steps.ts b/src/modules/event/steps/ui/event.steps.ts index feadf32..4095749 100644 --- a/src/modules/event/steps/ui/event.steps.ts +++ b/src/modules/event/steps/ui/event.steps.ts @@ -77,13 +77,16 @@ Then('les événements affichent leur lieu', async function (this: FestipodWorld 'Event cards should display a location (text or 📍 icon)').to.be.true; }); +// The product model signs up to a MEETING POINT, not to the event (arbitration +// of the event-join removal, T04.e): the signup affordance on this screen is the +// per-meeting-point "S'inscrire" toggle, not an event-level join button. Then('je peux m\'inscrire à l\'événement', async function (this: FestipodWorld) { expect(this.currentScreenId).to.equal('event-detail'); const doc = this.renderedDoc; expect(doc, 'Screen should be rendered').to.not.be.null; const buttons = Array.from(doc!.querySelectorAll('button')).map(b => b.textContent ?? ''); - const hasToggle = buttons.some(t => t.includes("J'y serai") || t.includes('Je participe')); - expect(hasToggle, 'Event detail should expose a join/leave toggle button').to.be.true; + const hasSignup = buttons.some(t => t.includes("S'inscrire") || t.includes('Inscrit')); + expect(hasSignup, 'Event detail should expose a meeting-point signup button').to.be.true; }); Then('je peux me désinscrire de l\'événement', async function (this: FestipodWorld) { @@ -91,8 +94,8 @@ Then('je peux me désinscrire de l\'événement', async function (this: Festipod const doc = this.renderedDoc; expect(doc, 'Screen should be rendered').to.not.be.null; const buttons = Array.from(doc!.querySelectorAll('button')).map(b => b.textContent ?? ''); - const hasToggle = buttons.some(t => t.includes("J'y serai") || t.includes('Je participe')); - expect(hasToggle, 'Event detail should expose a join/leave toggle button').to.be.true; + const hasSignup = buttons.some(t => t.includes("S'inscrire") || t.includes('Inscrit')); + expect(hasSignup, 'Event detail should expose a meeting-point signup toggle').to.be.true; }); // --- Create-event form assertions --- diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index a9f17d8..e909d60 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -1,23 +1,35 @@ -import React, { createContext, useContext, useState, useCallback, useEffect, useRef, type ReactNode } from 'react'; +/** + * FestipodDataContext.tsx — the provider implementations and the dispatcher. + * + * THE DISPATCHER (`FestipodDataProvider`) routes by SESSION REALITY, not by + * which module imported whom: + * + * - A LIVE NEXTGRAPH SESSION (`useNextGraph().status`) → `NgDataProvider` + * (`ngData.tsx`) — the suspended polyfill path, kept for the `nextgraph` + * URL variant and the `@data` broker harness. Never mounted at the root. + * - AN ACTIVITYPODS SESSION (`utils/currentSession`, published by AuthGate) + * → `RefineDataProvider` (`refineData.tsx`) — the default backend behind + * the Refine seam. + * - OTHERWISE → `LocalDataProvider` — the disconnected demo over the seed + * fixtures. What the `@ui` layer renders against, and what an offline + * choice ("Continuer hors ligne", unconfigured backend) serves. + * + * THE AUTH GATE SITS ABOVE (see `app/App.tsx`): children reach the dispatcher + * only once signing in has settled or been deliberately skipped, so the data + * paths never fire against a session that does not exist yet. The transitional + * local-empty states below cover the NG harness's own pre-identity renders. + * + * The screen-facing contract, the shared queries and the hook live in + * `festipodDataBase.ts`; the NextGraph implementation in `ngData.tsx`; the + * ActivityPods implementation in `refineData.tsx`. + */ + +import { useState, useCallback, type ReactNode } from 'react'; import type { FpEventData, FpUserData, - FpParticipationData, FpMeetingPointData, - FpFriendshipData, - FpNotificationData, } from '../data/types'; -import { - depositRegistration, - depositLeave, - readRegistrationNotifications, - materializeAttendance, - deleteParticipation, - countUserParticipations, - canonicalEventId, -} from '../data/registration'; -import { inbox } from '@ng-eventually/polyfill'; -import type { Nuri } from '@ng-eventually/polyfill'; import { CURRENT_USER_ID, seedEvents, @@ -27,228 +39,26 @@ import { seedFriendships, } from '../data/seedData'; import { useNextGraph } from './NextGraphContext'; -import { normalizeIdentifier } from '../utils/identifier'; -// Relationship is a Festipod concept: the app keeps its own bilateral registry -// and shares its own documents' keys with its neighbours (see shared/utils/connections). -import { declareConnections } from '../utils/connections'; -import { - listMyEntityDocs, - createEntityDoc, - openDocumentInbox, -} from '../utils/storeRegistry'; import { useCurrentPrincipal } from '../utils/currentPrincipal'; -import { createSerialTask } from '../utils/serialTask'; -import { useShapeQuery } from '../data/useShapeQuery'; -import { adaptEvents, adaptUsers, adaptParticipations } from '../data/shapeAdapters'; -// The ORM generator emits BARE shape names (`EventShapeType`, `Event`, …), taken -// from each shape's IRI local name. They are aliased back to the `Fp*` spelling -// HERE, at the import, so nothing downstream depends on the generator's naming: -// `Event` and `Notification` are DOM globals, and the bare names collide with the -// app's own vocabulary (`FpEventData` & co. in `data/types.ts`). The prefix cannot -// be restored at the generator — the emitted name follows the shape IRI, and those -// IRIs are the persisted RDF classes (`ENTITY_TYPE`), not ours to rename. +import { useCurrentSession } from '../utils/currentSession'; +import { initialBackendName } from '../refine/providers'; +import { NgDataProvider } from './ngData'; +import { RefineDataProvider } from './refineData'; +import { resolveDataProviderMode } from './dataProviderMode'; import { - EventShapeType as FpEventShapeType, - UserProfileShapeType as FpUserProfileShapeType, - ParticipationShapeType as FpParticipationShapeType, -} from '../shapes/orm/festipodShapes.shapeTypes'; -import { writeEntity, updateEntityField, ENTITY_TYPE, str, int, flt, bool, iri } from '../data/entityWrites'; -import { bootstrapWallet, type BootstrapResult } from '../utils/ngBootstrap'; -import { autoSeedEnabled, fixtureSeedEnabled, shouldAutoSeed } from '../utils/autoSeed'; + FestipodDataContext, + buildQueries, + nextId, + type FestipodDataContextValue, + type EventOwnership, +} from './festipodDataBase'; -// ============================================================================ -// Context interface -// ============================================================================ - -/** - * Whether this session may WRITE an event's document — which, by the SDK - * contract, is exactly whether it OWNS that document: *"Only a document's owner - * writes to it. Holding its read key never grants a write"*, and *"No delegated - * writing. A received key never grants a write, and no call adds a writer to a - * document"*. So there is nothing extra to ask: owning it IS being able to write - * it, and the owned-document listing already answers that. - * - * `'unknown'` is a REAL third answer, never a polite `'not-mine'`. The listing - * may not have landed yet, or may have failed — and a rejection means UNKNOWN, - * never "this session owns nothing". Collapsing it into `'not-mine'` is how you - * end up telling an owner, silently and wrongly, that the thing is not theirs. - */ -export type EventOwnership = 'mine' | 'not-mine' | 'unknown'; - -interface FestipodDataContextValue { - currentUserId: string; - currentUser: FpUserData | undefined; - /** - * WHO THE SESSION SIGNED IN AS — the identity `ensureIdentity()` returned, - * known before any document is read. For DISPLAY (and log attribution) only: - * it is a different id space from `currentUserId` (a profile document NURI), - * it is never written into an entity, and no data call takes it. - */ - currentPrincipal: string; - - events: FpEventData[]; - users: FpUserData[]; - participations: FpParticipationData[]; - meetingPoints: FpMeetingPointData[]; - friendships: FpFriendshipData[]; - /** Host-facing notifications, surfaced from the inbox read (T02.c). */ - notifications: FpNotificationData[]; - - getEvent(id: string): FpEventData | undefined; - getUser(id: string): FpUserData | undefined; - getEventParticipants(eventId: string): FpUserData[]; - getUserEvents(userId: string): FpEventData[]; - isParticipating(eventId: string, userId?: string): boolean; - getFriends(userId?: string): FpUserData[]; - getEventMeetingPoints(eventId: string): FpMeetingPointData[]; - /** - * May this session WRITE this event's document? Callers MUST treat `'unknown'` - * as its own case — see {@link EventOwnership}. - */ - getEventOwnership(eventId: string): EventOwnership; - - selectedEventId: string; - setSelectedEventId(id: string): void; - selectedEvent: FpEventData | undefined; - selectedUserId: string; - setSelectedUserId(id: string): void; - selectedUser: FpUserData | undefined; - - createEvent(event: Omit): Promise; - updateEvent(id: string, updates: Partial): void | Promise; - joinEvent(eventId: string, userId?: string): Promise | void; - leaveEvent(eventId: string, userId?: string): Promise | void; - addMeetingPoint(mp: Omit): void; - addFriend(friendId: string): void; - updateProfile(updates: Partial): void | Promise; - loadTestData(): Promise; -} - -const FestipodDataContext = createContext(null); - -// ============================================================================ -// Helpers -// ============================================================================ - -let idCounter = 100; -function nextId(prefix: string): string { - return `${prefix}-${++idCounter}`; -} - -// LEGACY, READ ONLY. Participations written by an older version key their user -// (`fp:user`) as `urn:festipod:user:` instead of the UserProfile's `did:ng:` -// document NURI. Nothing MINTS this any more — today's writes carry the profile -// NURI (`currentUserId`) — but `resolveParticipantUser` must still recognize the -// old form, or those participations render as "participant inconnu". -const USER_PRINCIPAL_PREFIX = 'urn:festipod:user:'; - -/** Waits between attempts at resolving an owned-document set (see the effects). */ -const OWNED_RETRY_BACKOFF_MS = [500, 1500, 4000]; - -/** - * What a BRAND-NEW profile is created with. - * - * A profile is entirely Festipod's own object — the data layer knows nothing of - * pseudos or display names — but the UserProfile SHEX shape makes `fp:name`, - * `fp:initials` and `fp:username` MANDATORY, so a profile cannot be written - * empty. And nothing about the person is known at sign-in: the identity the - * session signed in as is OPAQUE (no display name; it is never parsed, never - * rendered, never written into an entity). So the three required fields carry a - * PLACEHOLDER that reads on screen as "not filled in yet": none of them is a - * person's name or handle, and none is derived from the identity. The user - * replaces them through `updateProfile` (UpdateProfileScreen). - */ -const UNSET_PROFILE = { - name: 'Profil à compléter', - initials: '?', - username: '(pseudo non défini)', -} as const; - -/** - * Resolve a Participation's `fp:user` to its UserProfile. - * - * TODAY'S WRITES need no resolving: `fp:user` carries the profile's own document - * NURI, so the DIRECT match `u.id === userId` answers — and it is tried first. - * The demo/local fixtures coincide there too (both sides are the bare `user-1`). - * - * LEGACY ONLY: participations written under the earlier scheme carry - * `urn:festipod:user:`, which matches no profile id. For those - * — and ONLY those — the handle is stripped off and compared to - * `normalizeIdentifier(profile.username)`. This username bridge is a READ-side - * survival for old data; it plays NO part in deciding who the current user is - * (my profile is the profile document I own — see the "WHO AM I" block below). - * - * A per-deposit materializer uid (`mint...`, e.g. `mrktnoke-rzd699dk`) is a third, - * unrelated space: it identifies an inbox deposit for the count, never a user. - */ -function resolveParticipantUser(userId: string, users: FpUserData[]): FpUserData | undefined { - // 1) Direct id match — demo/local seed space (`user-1`), or any coincident space. - const direct = users.find(u => u.id === userId); - if (direct) return direct; - // 2) NG principal space: `urn:festipod:user:` → match on normalized username. - const key = userId.startsWith(USER_PRINCIPAL_PREFIX) - ? userId.slice(USER_PRINCIPAL_PREFIX.length) - : userId; - return users.find(u => u.username && normalizeIdentifier(u.username) === key); -} - -// NG shape → app type mapping lives in `../data/shapeAdapters` (domain adapters -// over the SDK's `watchShape` subjects). - -// ============================================================================ -// Shared queries builder — same logic for both local and NG modes -// ============================================================================ - -function buildQueries( - events: FpEventData[], - users: FpUserData[], - participations: FpParticipationData[], - meetingPoints: FpMeetingPointData[], - friendships: FpFriendshipData[], - currentUserId: string, -) { - const getEvent = (id: string) => events.find(e => e.id === id); - const getUser = (id: string) => users.find(u => u.id === id); - - const getEventParticipants = (eventId: string) => { - // Resolve each of the event's participations to its UserProfile across the two - // id spaces (participation principal vs profile NURI) — see - // `resolveParticipantUser`. A raw `partUserIds.includes(u.id)` join never - // matched in connected mode (principal ≠ NURI) → every participant rendered as - // "unknown". Deduped, returned in `users` order to match the prior contract. - const eventParts = participations.filter(p => p.eventId === eventId); - const resolvedIds = new Set(); - for (const p of eventParts) { - const u = resolveParticipantUser(p.userId, users); - if (u) resolvedIds.add(u.id); - } - return users.filter(u => resolvedIds.has(u.id)); - }; - - const getUserEvents = (userId: string) => { - const partEventIds = participations.filter(p => p.userId === userId).map(p => p.eventId); - return events.filter(e => partEventIds.includes(e.id)); - }; - - const isParticipating = (eventId: string, userId?: string) => { - const uid = userId || currentUserId; - return participations.some(p => p.eventId === eventId && p.userId === uid); - }; - - const getFriends = (userId?: string) => { - const uid = userId || currentUserId; - const friendIds = friendships - .filter(f => f.userId === uid || f.friendId === uid) - .map(f => f.userId === uid ? f.friendId : f.userId); - return users.filter(u => friendIds.includes(u.id)); - }; - - const getEventMeetingPoints = (eventId: string) => { - return meetingPoints.filter(mp => mp.eventId === eventId); - }; - - return { getEvent, getUser, getEventParticipants, getUserEvents, isParticipating, getFriends, getEventMeetingPoints }; -} +// Re-exported so existing importers of this module keep their surface: the +// hook, the context object, the demo provider, and the shared types. +export { useFestipodData, FestipodDataContext } from './festipodDataBase'; +export type { FestipodDataContextValue, EventOwnership } from './festipodDataBase'; +export { resolveDataProviderMode } from './dataProviderMode'; +export type { DataProviderMode } from './dataProviderMode'; // ============================================================================ // Local (disconnected) provider — uses seed data directly, read-only @@ -292,11 +102,11 @@ function useLocalData(empty?: boolean): FestipodDataContextValue { const updateEvent = useCallback((_id: string, _updates: Partial) => { console.log(`${logPrefix} updateEvent (local, no-op)`); }, [logPrefix]); - const joinEvent = useCallback((_eventId: string) => { - console.log(`${logPrefix} joinEvent (local, no-op)`); + const joinMeetingPoint = useCallback((_meetingPointId: string) => { + console.log(`${logPrefix} joinMeetingPoint (local, no-op)`); }, [logPrefix]); - const leaveEvent = useCallback((_eventId: string) => { - console.log(`${logPrefix} leaveEvent (local, no-op)`); + const leaveMeetingPoint = useCallback((_meetingPointId: string) => { + console.log(`${logPrefix} leaveMeetingPoint (local, no-op)`); }, [logPrefix]); const addMeetingPoint = useCallback((_mp: Omit) => { console.log(`${logPrefix} addMeetingPoint (local, no-op)`); @@ -307,7 +117,7 @@ function useLocalData(empty?: boolean): FestipodDataContextValue { const updateProfile = useCallback((_updates: Partial) => { console.log(`${logPrefix} updateProfile (local, no-op)`); }, [logPrefix]); - const loadTestData = useCallback(async (): Promise => { + const loadTestData = useCallback(async () => { console.log(`${logPrefix} loadTestData (local, no-op)`); return { seeded: false, userIdMap: new Map(), eventIdMap: new Map(), createdDocs: { public: [], protected: [] } }; }, []); @@ -327,1256 +137,42 @@ function useLocalData(empty?: boolean): FestipodDataContextValue { selectedUserId, setSelectedUserId, selectedUser, ...queries, getEventOwnership, - createEvent, updateEvent, joinEvent, leaveEvent, + createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint, addMeetingPoint, addFriend, updateProfile, loadTestData, }; } -// ============================================================================ -// NextGraph-connected provider — reads directly from NG wallet -// ============================================================================ - -function useNgData(): FestipodDataContextValue { - const { session } = useNextGraph(); - // The app speaks ONLY in logical scopes — it holds no store id and builds no - // `did:ng:${…}` NURI. It creates ONE document PER ENTITY in its scope - // (`createEntityDoc(scope)`, the SDK create) and READS via the SDK's reactive, - // `useQuery`-shaped surface `watchShape(shape, scope)` (bound to React by - // `useShapeQuery`). The observable resolves the scope to the current identity's - // wallet (its own scope docs + discovery for public), awaits the sync barrier, - // and pushes on every change — no bespoke re-query, no manual doc listing, no - // per-doc subscription in the app. See rule_app-uses-sdk-surface-only. - const ready = !!session; - - // --- REACTIVE READS via the SDK surface (`watchShape` bound with useShapeQuery) -- - // Three scoped shape reads, mapped to the app's domain types. Each is reactive - // (broker push, no polling): a locally-created entity, a seeded doc, or a remote - // peer's public event all re-render through the observable's own subscriptions. - // • events = public (my own public event docs + discovery index) - // • profiles/users = protected (my own) - // • participations = protected (my own; cap-filtered by the SDK) - const eventQuery = useShapeQuery(FpEventShapeType, 'public'); - const userQuery = useShapeQuery(FpUserProfileShapeType, 'protected'); - const partQuery = useShapeQuery(FpParticipationShapeType, 'protected'); - // The RAW reactive sets straight from `watchShape` (before the optimistic - // overlay). The exposed `events`/`users`/`participations` merge these with the - // pending overlay below — see the "OPTIMISTIC OVERLAY" block. - const reactiveUsers = React.useMemo(() => adaptUsers(userQuery.data), [userQuery.data]); - const reactiveEvents = React.useMemo(() => adaptEvents(eventQuery.data), [eventQuery.data]); - const reactiveParticipations = React.useMemo( - () => adaptParticipations(partQuery.data), - [partQuery.data], - ); - - // --- OPTIMISTIC OVERLAY (standard useQuery-mutation pattern; NO polling) ------ - // `watchShape` only surfaces a doc AFTER the broker push (a real latency), so a - // mutation's effect is invisible in the reactive set for a beat. To keep the - // screens' local-first feel, each mutation reflects its effect IMMEDIATELY in a - // pure app-side React overlay laid OVER the SDK surface (never touching the SDK - // internals — see rule_app-uses-sdk-surface-only), then RECONCILED by the push: - // • `pendingAddEvents` / `pendingAddParticipations` — entities created - // optimistically, keyed by their `@id` (= the doc NURI `writeEntity` returns, - // the SAME id `watchShape` surfaces as `s.subject` → they match exactly). - // • `pendingRemoveIds` — participation ids removed optimistically (leave). - // The exposed set = merge(reactive, pendingAdds) minus pendingRemoves, DEDUPED by - // id. Reconciliation (effects below) auto-cleans the overlay the moment the - // reactive read catches up: an add whose id now appears in the reactive set is - // dropped from pendingAdds; a remove whose id is no longer in the reactive set is - // dropped from pendingRemoves. No re-query, no interval — the overlay only reacts - // to `watchShape`'s own pushes. - const [pendingAddEvents, setPendingAddEvents] = useState([]); - const [pendingAddUsers, setPendingAddUsers] = useState([]); - const [pendingAddParticipations, setPendingAddParticipations] = useState([]); - const [pendingRemoveIds, setPendingRemoveIds] = useState>(() => new Set()); - - const users = React.useMemo(() => { - if (pendingAddUsers.length === 0) return reactiveUsers; - const seen = new Set(reactiveUsers.map(u => u.id)); - const extra = pendingAddUsers.filter(u => !seen.has(u.id)); - return extra.length ? [...reactiveUsers, ...extra] : reactiveUsers; - }, [reactiveUsers, pendingAddUsers]); - - const events = React.useMemo(() => { - if (pendingAddEvents.length === 0) return reactiveEvents; - const seen = new Set(reactiveEvents.map(e => e.id)); - const extra = pendingAddEvents.filter(e => !seen.has(e.id)); - return extra.length ? [...reactiveEvents, ...extra] : reactiveEvents; - }, [reactiveEvents, pendingAddEvents]); - - const participations = React.useMemo(() => { - let base = reactiveParticipations; - if (pendingAddParticipations.length) { - const seen = new Set(base.map(p => p.id)); - const extra = pendingAddParticipations.filter(p => !seen.has(p.id)); - if (extra.length) base = [...base, ...extra]; - } - if (pendingRemoveIds.size) base = base.filter(p => !pendingRemoveIds.has(p.id)); - return base; - }, [reactiveParticipations, pendingAddParticipations, pendingRemoveIds]); - - // RECONCILIATION — drop each optimistic add once the reactive read carries its id - // (the push arrived), and each optimistic remove once the reactive read no longer - // carries its id (the SPARQL delete propagated). Guarded to no-op when there is - // nothing to reconcile, so a stable reactive set does not churn state. - useEffect(() => { - if (pendingAddEvents.length === 0) return; - const live = new Set(reactiveEvents.map(e => e.id)); - setPendingAddEvents(prev => { - const next = prev.filter(e => !live.has(e.id)); - return next.length === prev.length ? prev : next; - }); - }, [reactiveEvents, pendingAddEvents]); - - useEffect(() => { - if (pendingAddUsers.length === 0) return; - const live = new Set(reactiveUsers.map(u => u.id)); - setPendingAddUsers(prev => { - const next = prev.filter(u => !live.has(u.id)); - return next.length === prev.length ? prev : next; - }); - }, [reactiveUsers, pendingAddUsers]); - - useEffect(() => { - if (pendingAddParticipations.length === 0) return; - const live = new Set(reactiveParticipations.map(p => p.id)); - setPendingAddParticipations(prev => { - const next = prev.filter(p => !live.has(p.id)); - return next.length === prev.length ? prev : next; - }); - }, [reactiveParticipations, pendingAddParticipations]); - - useEffect(() => { - if (pendingRemoveIds.size === 0) return; - const live = new Set(reactiveParticipations.map(p => p.id)); - setPendingRemoveIds(prev => { - let changed = false; - const next = new Set(prev); - for (const id of prev) if (!live.has(id)) { next.delete(id); changed = true; } - return changed ? next : prev; - }); - }, [reactiveParticipations, pendingRemoveIds]); - // The read is "settled" once every scope has reached its sync barrier - // (`isSuccess`). A synced-but-empty scope reads `isSuccess` with `data: []` — the - // distinction the auto-seed relies on to tell "still syncing" from "truly empty". - const readReady = - eventQuery.isSuccess && userQuery.isSuccess && partQuery.isSuccess; - - // Session-local map `${eventId}|${userId}` → the join deposit's uid, so a leave - // in the SAME session can carry `regUid` for a precise cancellation. Absent it - // (cross-session leave), the owner's materializer falls back to (event, user) - // matching — so this is an optimization, not a correctness dependency. - // - // Nothing ever clears this map, and nothing needs to: a browser context is one - // user for its whole life (`ensureIdentity()` answers once, before anything - // renders), so there is never anyone else's leftovers in it. - const joinUidsRef = useRef>(new Map()); - - // OPTION B — the set of event docs the CURRENT identity OWNS (its own public - // event docs). Each such NURI IS the event `@id` (writeEntity uses the doc NURI - // as the subject). The owner-materializer subscribes to each owned event's inbox - // and writes `participantCount` on THAT (owned) doc — never on someone else's. - const [ownedEventIds, setOwnedEventIds] = useState([]); - // Events the listing has RULED OUT — canonical ids that were already visible in - // the reactive read when a listing RESOLVED, and were absent from its result. - // ONLY these may be shown as "not yours". - // - // Why a set and not a "the listing answered" boolean: a listing is a SNAPSHOT, - // and it can only ever UNDER-report ownership, because a document created after - // it was taken cannot possibly be in it. So a miss is authoritative for what - // existed at the time and says NOTHING about anything that arrived since. With a - // boolean, a second tab on the same wallet that creates an event has it pushed - // here, misses the stale snapshot, and its owner is told the event is not theirs - // — permanently. That is the exact failure this whole answer exists to avoid. - const [ruledOutEventIds, setRuledOutEventIds] = useState>(new Set()); - // The owned set in canonical id-form — the form every event-id comparison here - // uses, so an event reached under a different overlay is still the same event. - const ownedCanonicalIds = React.useMemo( - () => new Set(ownedEventIds.map(canonicalEventId)), - [ownedEventIds], - ); - - // WHICH PROTECTED DOCUMENTS ARE MINE — the ground on which "my profile" rests. - // `null` means NOT ANSWERED YET (the listing has not landed, or it failed): - // "unknown" and "I own nothing" are indistinguishable as an empty array, and - // only one of them may lead to creating a profile. Nothing downstream may read - // `null` as an empty set. - const [myProtectedDocs, setMyProtectedDocs] = useState(null); - // The profile document THIS session created for the signed-in person. Set once, - // by the creation effect; it settles "which of my profile documents is mine" - // without looking at any field of any profile. - const [myProfileDocId, setMyProfileDocId] = useState(''); - - /** - * Fold documents THIS SESSION just created into the owned set. - * - * The backfill below runs once, on connection, so it only ever knows what - * existed at mount. Everything created afterwards has to announce itself, or it - * is owned in fact and unowned as far as this tree is concerned — and the - * owner-materializer, which only ever looks at this set, never runs on it. - */ - const claimOwnedEventDocs = useCallback((created: Nuri[]) => { - if (created.length === 0) return; - setOwnedEventIds(prev => [...new Set([...prev, ...created])]); - }, []); - - // Resolve the CURRENT identity's owned public event docs for the materializer - // ONLY (decoupled from the read — `watchShape` resolves reads itself). Bounded to - // this session's own documents (`listMyEntityDocs('public')` — "mine" needs no - // identity, the session is one user's). Runs on connection to backfill events - // owned before this mount; `createEvent` and the seed (`loadTestData` / - // auto-seed) claim what they create through `claimOwnedEventDocs`. This is NOT a - // read path (it feeds no `events`/`users`/`participations`), only the - // owner-count derivation. - // - // A REJECTION HERE MEANS "UNKNOWN", NEVER "THIS SESSION OWNS NOTHING". The two - // are indistinguishable downstream — both leave the set empty — but only one of - // them is true, and taking the wrong one silently disables the owner - // materializer, so every event this session hosts stops converging. So the - // failure is RETRIED, and if it still will not answer, it is said loudly instead - // of leaving a plausible-looking empty set behind. - // Read through refs inside the async listing below, so classification uses what - // is on screen when the listing RESOLVES rather than what was there when it - // started — the two differ by exactly the push that prompted a re-listing. - const eventsRef = useRef(events); - eventsRef.current = events; - const ownedCanonicalIdsRef = useRef(ownedCanonicalIds); - ownedCanonicalIdsRef.current = ownedCanonicalIds; - // Events the snapshot cannot speak about: neither owned nor ruled out. While - // this is non-empty the listing is re-taken — ONCE per new arrival, never on a - // timer ([[rule_no-broker-polling]]): a listing classifies everything visible at - // the moment it resolves, so this empties and the effect falls silent until a - // genuinely new event turns up. - const unclassifiedEventKey = React.useMemo(() => { - const pending = events - .map(e => canonicalEventId(e.id)) - .filter(c => !ownedCanonicalIds.has(c) && !ruledOutEventIds.has(c)); - return [...new Set(pending)].sort().join('|'); - }, [events, ownedCanonicalIds, ruledOutEventIds]); - const listingTaken = useRef(false); - useEffect(() => { - if (!ready) return; - // Nothing left to learn: a listing has been taken and every visible event is - // classified. Without this the effect would re-list on its own state change. - if (listingTaken.current && unclassifiedEventKey === '') return; - let cancelled = false; - (async () => { - for (let attempt = 0; !cancelled; attempt++) { - try { - const myPublic = await listMyEntityDocs('public'); - if (cancelled) return; - setOwnedEventIds(prev => [...new Set([...prev, ...myPublic])]); - listingTaken.current = true; - // The listing ANSWERED — so every event visible RIGHT NOW that it did - // not return is genuinely not this session's, and may be shown as such. - // Anything arriving after this point stays unclassified and triggers a - // fresh listing rather than inheriting this one's silence. Events already - // claimed as mine (created here, possibly racing this listing) are never - // ruled out. - // REBUILT, never accumulated: every listing RE-ADJUDICATES everything - // visible, so a later listing can overturn an earlier ruling instead of - // being outvoted by it. - // - // KNOWN RESIDUAL, and it needs the provider — not more code here. This - // infers "not yours" from ABSENCE, and absence is not authoritative: the - // reactive read and this listing are two different mechanisms, so an - // event can be on screen a moment before the listing can see it. Ruled - // out in that window, it is only re-examined if some OTHER unclassified - // event later triggers a listing. Self-healing would need either a timer - // (polling — forbidden) or the "may I write this?" call the surface does - // not publish. Per `rule_app-uses-sdk-surface-only` that is a GAP to - // raise, not one to paper over here, so it is left visible and stated. - const ownedNow = new Set(myPublic.map(canonicalEventId)); - setRuledOutEventIds(() => { - const next = new Set(); - for (const e of eventsRef.current) { - const c = canonicalEventId(e.id); - if (!ownedNow.has(c) && !ownedCanonicalIdsRef.current.has(c)) next.add(c); - } - return next; - }); - return; - } catch (err) { - const wait = OWNED_RETRY_BACKOFF_MS[attempt]; - if (wait === undefined) { - console.error( - `${logPrefix} owned-events resolution FAILED after ${OWNED_RETRY_BACKOFF_MS.length + 1} ` + - `attempts — the owned set is UNKNOWN, not empty, and events hosted by this session ` + - `will not converge until it is known:`, - err, - ); - return; - } - console.warn( - `${logPrefix} owned-events resolution failed (attempt ${attempt + 1}) — retrying in ${wait}ms:`, - err, - ); - await new Promise(r => setTimeout(r, wait)); - } - } - })(); - return () => { cancelled = true; }; - }, [ready, unclassifiedEventKey]); - - // Not in SHEX shapes yet - const [meetingPoints, setMeetingPoints] = useState([]); - const [friendships, setFriendships] = useState([]); - // Host-facing notifications, materialized from the current user's inboxes - // (the inbox read, T02.b/c). Data-level surfacing of "new participants". - const [notifications, setNotifications] = useState([]); - - const [selectedEventId, setSelectedEventId] = useState(''); - const [selectedUserId, setSelectedUserId] = useState(''); - - // Auto-select first event when data appears from NG - useEffect(() => { - const first = events[0]; - if (!selectedEventId && first) { - setSelectedEventId(first.id); - } - }, [events.length, selectedEventId]); - - // Auto-seed: bootstrap seed data into a genuinely EMPTY wallet. Gated on the - // SDK's `isSuccess` (readReady) — the sync barrier is reached for every scope — - // so an empty set means "synced and truly empty", NOT "still syncing". This - // replaces the old 3s chronometer heuristic (which guessed a sync delay and mis- - // fired a re-seed on the 3rd connect). `isPending` → wait; `isSuccess` + empty - // data → seed. Gated behind the `FESTIPOD_AUTO_SEED` env var (see utils/autoSeed): - // OFF BY DEFAULT so a fresh/persistent wallet is never silently bloated with demo - // data — the seed only runs when explicitly opted in. Explicit `loadTestData` - // (the test-data action + every @data test) is UNAFFECTED (it seeds directly). - // `hasTriedAutoSeed` keeps it single-shot (also suppressed by an explicit - // `loadTestData`). - // - // ALL OF THAT IS CURRENTLY MOOT: the fixture seed is switched OFF outright - // (`fixtureSeedEnabled`), so this effect returns on its first line and no - // fixture reaches the wallet. `bootstrapWallet` enforces the same switch, so - // the check here buys only silence — no misleading "bootstrapping…" log, and - // no work started that would write nothing. - const hasTriedAutoSeed = useRef(false); - useEffect(() => { - if (!fixtureSeedEnabled()) return; - if (!autoSeedEnabled()) return; - if (hasTriedAutoSeed.current) return; - if (!ready) return; - if (!readReady) return; // still syncing — do NOT mistake pending for empty - // MY OWN PROFILE IS NOT "DATA IN THE WALLET". It is created at sign-in on a - // brand-new wallet, so counting it here would permanently disable the seed on - // exactly the wallets it exists for. The question the gate asks is "does this - // wallet already hold something worth preserving", and my own empty profile - // does not. - const walletHasData = events.length > 0 || users.some(u => u.id !== myProfileDocId); - if (!shouldAutoSeed(walletHasData)) { - console.log(`${logPrefix} Auto-seed (FESTIPOD_AUTO_SEED): wallet already has data — skip`); - return; - } - // Enabled AND synced-empty → a real empty wallet. Seed once. - hasTriedAutoSeed.current = true; - console.log(`${logPrefix} Auto-seed (FESTIPOD_AUTO_SEED): wallet empty (synced), bootstrapping…`); - bootstrapWallet(false, createEntityDoc) - // The seed's public event docs were created BY THIS SESSION — they are mine. - .then(result => claimOwnedEventDocs(result.createdDocs.public)) - .catch(err => console.error(`${logPrefix} Auto-seed failed:`, err)); - // The reactive `watchShape` reads pick the seeded per-entity docs up on their - // own (each createEntityDoc appends to the scope index → the container-index - // subscription re-resolves → the new docs enter the read). No registerDoc/relist. - }, [ready, readReady, events.length, users, myProfileDocId, claimOwnedEventDocs]); - - // --- Derived --- - // WHO AM I — answered in TWO id spaces that must not be confused. - // - // (1) `currentPrincipal` — what signing in returned. Known as soon as the one - // identity await settles, i.e. before any document has been read. It names - // a PERSON, OPAQUELY: it is never parsed, never rendered as a name, never - // written into an entity and never handed to a data call. It exists here - // for display of "am I signed in" and for log attribution. - // (2) `currentUserId` — the app's own entity space: the `@id` of MY PROFILE - // DOCUMENT (a doc NURI). This is what a Participation's `fp:user` carries - // and what `resolveParticipantUser` matches directly, so it is the only - // value a mutation may write. - // - // THERE IS NO JOIN BETWEEN THE TWO, and there must not be one. A profile is - // Festipod's own object; the identity says nothing about it. **My profile is - // the profile document I OWN** — `listMyEntityDocs('protected')` answers "which - // documents are mine", and the UserProfile among them is mine. No field of any - // profile takes part in the answer: no username comparison, no normalization, - // no positional pick. - // - // THREE OUTCOMES, and "somebody else's profile" is not one of them: - // • exactly one owned profile → that is me; - // • none → I have no profile yet, and the creation effect below makes one - // (until it lands, `currentUserId` is '' and the mutations that need it - // REJECT — they never write an entity keyed on nobody); - // • several, none of them created by this session → genuinely AMBIGUOUS (the - // opt-in fixture seed writes its profiles into my own protected scope), so - // the answer is NO PROFILE, said loudly. Picking one would be picking a - // person at random and calling them "you". - const currentPrincipal = useCurrentPrincipal(); - /** The profiles I own: the reactive profiles whose document is one of mine. */ - const myOwnedProfiles = React.useMemo(() => { - if (myProtectedDocs === null) return null; // UNKNOWN — never "none" - const mine = new Set(myProtectedDocs); - return users.filter(u => mine.has(u.id)); - }, [users, myProtectedDocs]); - const currentUser = React.useMemo(() => { - // This session created it → no ambiguity possible, whatever else is owned. - if (myProfileDocId) return users.find(u => u.id === myProfileDocId); - if (myOwnedProfiles === null) return undefined; // not answered yet - if (myOwnedProfiles.length === 1) return myOwnedProfiles[0]; - if (myOwnedProfiles.length === 0) return undefined; // none — one gets created - // SEVERAL profile documents are mine, and none was created by this session: - // the fixture seed writes its profiles into my own protected scope, so a - // reloaded demo wallet lands here. Pick the first by document reference — - // stable across reloads, and arbitrary, which is honest: while the profile - // is not a built feature, "which of my fixtures am I" has no true answer. - // - // This is NOT the impersonation that was removed. That one reached for a - // profile by NAME and could land on a document belonging to somebody else. - // Every candidate here is a document I own, so the invariant that holds is - // the one that matters: the app never presents another person's profile as - // mine. Delete this branch the day a profile is really created and known. - return [...myOwnedProfiles].sort((a, b) => a.id.localeCompare(b.id))[0]; - }, [users, myProfileDocId, myOwnedProfiles]); - const currentUserId = currentUser?.id || ''; - // Identity-first log prefix, reused by every DATA log below (including the - // closures defined earlier in this function body — they only execute after - // this render has finished, by which point `logPrefix` is initialized). Several - // browser contexts can be tailed at once, so a line must say WHOSE it is: the - // profile NURI once it is read, and the signed-in principal before that — which - // is now from the very first render, instead of the anonymous `[app][data]`. - const logPrefix = currentUserId || currentPrincipal - ? `[${currentUserId || currentPrincipal}][app][data]` - : '[app][data]'; - const selectedEvent = events.find(e => e.id === selectedEventId); - // DISPLAY READ — log participantCount exactly as currently exposed for - // rendering. Compared against the owner-materializer's WRITE logs below, this - // pinpoints whether a stuck counter is a DATA problem (never incremented) or a - // DISPLAY/read problem (incremented but not re-read until the next session). - if (selectedEvent) { - console.log( - `${logPrefix} participation read for display — event=${canonicalEventId(selectedEvent.id)} ` + - `"${selectedEvent.title}": participantCount lu = ${selectedEvent.participantCount}`, - ); - } - const selectedUser = users.find(u => u.id === selectedUserId); - - // --- MY PROFILE: which documents are mine ---------------------------------- - // Resolve the PROTECTED documents this identity owns. That set is the whole - // basis of "which profile is mine", and it is also what decides whether a - // profile has to be CREATED — so a failure here must never look like an answer: - // a rejection means UNKNOWN, and reading it as "I own nothing" would create a - // second profile for someone who already has one. Retried; if it still will not - // answer, the set stays `null` (no profile resolved, no profile created) and it - // is said loudly. - useEffect(() => { - if (!ready) return; - let cancelled = false; - (async () => { - for (let attempt = 0; !cancelled; attempt++) { - try { - const mine = await listMyEntityDocs('protected'); - if (cancelled) return; - setMyProtectedDocs(prev => [...new Set([...(prev ?? []), ...mine])]); - return; - } catch (err) { - const wait = OWNED_RETRY_BACKOFF_MS[attempt]; - if (wait === undefined) { - console.error( - `${logPrefix} my-protected-documents resolution FAILED after ` + - `${OWNED_RETRY_BACKOFF_MS.length + 1} attempts — which profile is mine is UNKNOWN, ` + - `not absent: no profile will be resolved and none will be created until it is known:`, - err, - ); - return; - } - console.warn( - `${logPrefix} my-protected-documents resolution failed (attempt ${attempt + 1}) — ` + - `retrying in ${wait}ms:`, - err, - ); - await new Promise(r => setTimeout(r, wait)); - } - } - })(); - return () => { cancelled = true; }; - }, [ready]); - - // --- MY PROFILE: create one when I have none ------------------------------- - // A profile is Festipod's own object, and signing in produces none — so the - // first time a person signs in, the app makes theirs. Gated on BOTH the - // protected read having settled (`userQuery.isSuccess`: synced-and-empty, not - // still-syncing) and the owned-document set being KNOWN, because "I have no - // profile" is only true when both have answered. Single-shot per session; on - // failure the guard is released, so a later change to the owned set retries. - const hasTriedProfileCreate = useRef(false); - useEffect(() => { - if (!ready) return; - if (hasTriedProfileCreate.current) return; - if (!userQuery.isSuccess) return; - if (myOwnedProfiles === null) return; // UNKNOWN — never read as "none" - if (myOwnedProfiles.length > 0) return; // I already have one (or more) - hasTriedProfileCreate.current = true; - (async () => { - console.log(`${logPrefix} no profile of mine — creating one (fields left visibly unset)`); - const graph = await createEntityDoc('protected'); - // The three fields the UserProfile shape makes mandatory, written with the - // "not filled in yet" placeholders — nothing here comes from the identity. - await writeEntity(graph, ENTITY_TYPE.user, { - name: str(UNSET_PROFILE.name), - initials: str(UNSET_PROFILE.initials), - username: str(UNSET_PROFILE.username), - }); - // This document is MINE — claimed explicitly, so it is recognized as my - // profile whatever else the protected scope holds (fixtures included). - setMyProfileDocId(graph); - setMyProtectedDocs(prev => [...new Set([...(prev ?? []), graph])]); - // OPTIMISTIC OVERLAY, same pattern as events/participations: surface the - // profile immediately so `currentUserId` resolves without waiting for the - // broker push; the reconciliation effect drops it once the read carries it. - const optimisticProfile: FpUserData = { id: graph, ...UNSET_PROFILE }; - setPendingAddUsers(prev => (prev.some(u => u.id === graph) ? prev : [...prev, optimisticProfile])); - })().catch(err => { - hasTriedProfileCreate.current = false; - console.error(`${logPrefix} creating my profile FAILED — this session has no profile:`, err); - }); - }, [ready, userQuery.isSuccess, myOwnedProfiles]); - - // --- MY PROFILE: say it when the answer is arbitrary ------------------------ - // Several profile documents are mine and none was created by this session (the - // opt-in fixture seed writes its profiles into my own protected scope). One is - // picked deterministically so the app stays usable on a demo wallet, but the - // pick carries no meaning — say so once, or a fixture person silently becomes - // "you" and nobody wonders why. - const warnedAmbiguousProfile = useRef(false); - useEffect(() => { - if (myProfileDocId) return; - if (myOwnedProfiles === null || myOwnedProfiles.length <= 1) return; - if (warnedAmbiguousProfile.current) return; - warnedAmbiguousProfile.current = true; - console.warn( - `${logPrefix} ${myOwnedProfiles.length} profile documents are mine and none was created by ` + - `this session (a fixture seed run on this wallet is the usual cause). The first by document ` + - `reference is used as mine — a stable but ARBITRARY pick, so the name shown as yours is ` + - `demo data, not you.`, - ); - }, [myOwnedProfiles, myProfileDocId]); - - // --- OWNER MATERIALIZER (Option B, brief §B.2 + T02.c notifications) ------- - // The event OWNER's session materializes its OWN events' inbox deposits into - // (1) the correct `participantCount` on its OWN event doc, and - // (2) host-facing "new participant" FpNotifications. - // This is what makes the count CORRECT and reactive WITHOUT any non-owner ever - // writing the event doc: the joiner only deposits; the owner counts. - // - // Reactive, no polling: subscribe the inbox document via `inbox.watch` (a - // `doc_subscribe` push). One watch PER owned event: each event has its OWN - // inbox, whose address its owner obtains with `openDocumentInbox`. - // - // IDEMPOTENCE / CONVERGENCE: the count is DERIVED from the SET of distinct - // active registrations (`materializeAttendance`: distinct join uids MINUS - // cancelled ones), never an unbounded ±1. A broker re-sync replays the same - // deposits → same set → same count. `participantCount = activeRegistrations.size` - // (no host baseline — the creator does not auto-participate). The write is GUARDED - // (write only when the value actually changes) so re-materializing an unchanged - // inbox does not thrash the doc / loop the reactive read. - // - // OWNER-OFFLINE = EVENTUAL (brief §E.2): only the owner's session runs this, so - // while the owner is disconnected the count doesn't advance for others (the - // deposits persist in the inbox — nothing is lost; a future service will - // materialize on the owner's behalf). - // Dedup owned events by their CANONICAL id-form (base repo id, stripping any - // `:v:` suffix): the SAME event can enter `ownedEventIds` under two - // overlays (create-time vs a later `listMyEntityDocs` backfill), and iterating - // both would materialize + count-write the same event twice. Keep ONE real NURI - // per canonical id as the write/anchor target (the count is written on a live - // doc NURI — never a stripped id). See `canonicalEventId` in registration.ts. - const ownedEvents = React.useMemo(() => { - const byCanon = new Map(); - for (const nuri of ownedEventIds) { - const c = canonicalEventId(nuri); - if (!byCanon.has(c)) byCanon.set(c, nuri); - } - return [...byCanon.values()].sort(); - }, [ownedEventIds]); - // Primitive identity of the set above — the effect's dependency (an array is a - // new reference on every render). - const ownedKey = ownedEvents.join('|'); - - // WHICH EVENTS MAY THIS SESSION WRITE — the same owned set, asked the other way - // round. Nothing new is called: the contract makes writing a document and owning - // it the same thing, so `listMyEntityDocs('public')` (already resolved above for - // the materializer) is the whole answer. Matched on the CANONICAL id-form, like - // every other event-id comparison here, so an event reached under a different - // overlay is still recognized as the same event. - const getEventOwnership = useCallback((eventId: string): EventOwnership => { - const canon = canonicalEventId(eventId); - // A hit is authoritative EVEN BEFORE any listing has answered: an event this - // session just created was claimed directly, and it is mine whatever a - // listing later says. Checked FIRST, so ownership always beats a stale miss. - if (ownedCanonicalIds.has(canon)) return 'mine'; - // A miss counts only for events a resolved listing actually looked past. - if (ruledOutEventIds.has(canon)) return 'not-mine'; - // Otherwise the answer is genuinely still coming — a listing is pending, has - // failed, or this event arrived after the last one. Never 'not-mine' here. - return 'unknown'; - }, [ownedCanonicalIds, ruledOutEventIds]); - // Last count WRITTEN per owned event (keyed by canonical id), stamped with the - // cycle that wrote it. The stamp is what makes a STALE cycle harmless — see the - // guard below. - const materializedCountRef = useRef>(new Map()); - // Monotonic cycle number, shared by every materialize cycle of this session. It - // has to outlive the effect: when the owned set changes the effect re-runs, and - // a cycle started by the PREVIOUS run can still be in flight. - const cycleSeqRef = useRef(0); - useEffect(() => { - if (!ready) return; - const owned = ownedEvents; - if (owned.length === 0) return; - let cancelled = false; - - // ONE inbox address per owned event, resolved ONCE here and shared by the two - // things that must not disagree: the cycle that READS the inbox and the watch - // that SUBSCRIBES to it. Resolving them apart is how the owner ends up watching - // one address while a deposit lands in another — the sign-up is then never seen - // in the session that made it. `openDocumentInbox` is itself single-flight per - // document (utils/storeRegistry), so the app can no longer open a second inbox - // at all; this map is the narrower promise that the reader and the watcher hold - // the very same value. - // - // Resolved on first need and kept — but a FAILED resolution is released, not - // kept: it means the address is UNKNOWN, so the next trigger must really ask - // again instead of inheriting a permanent "no inbox". - let resolvingInboxes: Promise> | null = null; - const inboxesForOwnedEvents = (): Promise> => { - if (resolvingInboxes) return resolvingInboxes; - const attempt = (async () => { - const byEvent = new Map(); - for (const evId of owned) byEvent.set(evId, await openDocumentInbox(evId)); - return byEvent; - })().catch(err => { - if (resolvingInboxes === attempt) resolvingInboxes = null; - throw err; - }); - resolvingInboxes = attempt; - return attempt; - }; - - const runCycle = async (trigger: string) => { - if (cancelled) return; - const seq = ++cycleSeqRef.current; - try { - const inboxes = await inboxesForOwnedEvents(); - console.log( - `${logPrefix} owner participation materialize START (trigger=${trigger}, cycle=${seq}) — ` + - `${owned.length} owned event(s)`, - ); - const notifs: FpNotificationData[] = []; - // Each event has its OWN inbox, and only its OWNER can open it. The address - // comes from the shared map above — the same one the watch subscribed; a - // depositor never sees it (they name the document instead). - for (const [evId, targetInbox] of inboxes) { - const canonId = canonicalEventId(evId); - // BEFORE — the event's readable detail (short id + title) and the - // participantCount value as currently READ/exposed (the app-side `events` - // state), captured before this cycle's derive+write. Comparing this to the - // AFTER log below tells whether the counter is a DATA problem (never - // incremented) or a DISPLAY/read problem (incremented but not re-read). - // - // Read through the REF, never the closure: this effect only re-runs on - // [ready, ownedKey], so the `events` it captured is the snapshot from the - // render that wired the watch — which is why this line printed `(unknown)` - // for the whole session and told the last investigation nothing. Matched - // on the canonical id-form, like every other event-id comparison here. - const knownEvent = eventsRef.current.find(e => canonicalEventId(e.id) === canonId); - const knownCount = knownEvent?.participantCount; - console.log( - `${logPrefix} participation materialize — event=${canonId}` + - (knownEvent?.title ? ` "${knownEvent.title}"` : '') + - ` — participantCount before write (as currently read) = ${knownCount ?? '(unknown)'}`, - ); - // (1) COUNT — derive the distinct active-registration set for this event - // and write it on MY OWN event doc (only when it changed). `materializeAttendance` - // reads through the synced-view contract (`inbox.readSynced`), so a - // registrant's deposit is visible even on a cold session. - const active = await materializeAttendance(targetInbox, evId); - const nextCount = active.length; // no host baseline (creator not auto-in) - const written = materializedCountRef.current.get(canonId); - // THE VALUE A CYCLE CARRIES IS ONLY AS FRESH AS THE READ IT CAME FROM. - // Cycles of this effect can no longer interleave (they are serialized - // below), but the effect re-runs whenever the owned set changes, and a - // cycle from the previous run can still be in flight — holding a count it - // derived BEFORE the fresher one's. Writing it would put the stale value - // back on the document, which is a count that goes backwards for no - // visible reason. So a cycle may only overwrite what an OLDER cycle wrote. - if (written && written.seq > seq) { - console.log( - `${logPrefix} owner participation materialize — event=${canonId}: cycle ${seq} is STALE ` + - `(cycle ${written.seq} already wrote ${written.count}) — not writing ${nextCount}`, - ); - } else if (written?.count === nextCount) { - // Write ONLY when the derived value actually changes (anti-loop). This - // memo does NOT lock in a premature 0: the barrier-gated read above makes - // the first post-connection materialize see the real deposits, so once the - // set becomes non-empty the value differs and the correct count is - // written. A transient write failure reverts the memo so the next trigger - // retries. The guard's sole job is to avoid re-writing an UNCHANGED value. - console.log( - `${logPrefix} owner participation materialize — event=${canonId}: ` + - `participantCount unchanged (${nextCount}) — no write`, - ); - } else if (!cancelled) { - materializedCountRef.current.set(canonId, { count: nextCount, seq }); - console.log( - `${logPrefix} owner participation materialize — event=${canonId}: ` + - `participantCount ${written?.count ?? '(none)'} → ${nextCount} (writing own doc, cycle=${seq})`, - ); - // The write lands on the owned event doc, which `watchShape('public')` - // already subscribes → the reactive read re-renders the new count on - // the broker push (no manual re-query). - let writeOk = true; - await updateEntityField(evId, evId, 'participantCount', int(nextCount)) - .catch(err => { - // Revert the memo so a transient write failure retries next trigger - // — but only if it is still OURS. A fresher cycle's value stands. - writeOk = false; - const current = materializedCountRef.current.get(canonId); - if (current && current.seq === seq) materializedCountRef.current.delete(canonId); - console.error(`${logPrefix} owner participation materialize count WRITE FAILED:`, err); - }); - if (writeOk) { - // AFTER — the write has landed on the owner's own doc. N → M reuses the - // SAME "before" reference point logged above, so it is directly - // comparable: a DISPLAY-read log (see `selectedEvent`, above in this - // file) still showing the old N after this fires means the counter data - // is fine and it is the read side that lags. - console.log( - `${logPrefix} participation materialize — event=${canonId}: ` + - `participantCount AFTER write = ${knownCount ?? '(unknown)'} → ${nextCount}`, - ); - } - } - // (2) NOTIFICATIONS — surface "new participant" deposits (unchanged T02.c). - const evNotifs = await readRegistrationNotifications(targetInbox, evId); - notifs.push(...evNotifs); - } - if (!cancelled && notifs.length) { - setNotifications(prev => { - const seen = new Set(prev.map(n => n.id)); - const merged = [...prev]; - for (const n of notifs) if (!seen.has(n.id)) { seen.add(n.id); merged.push(n); } - return merged; - }); - } - } catch (err) { - console.error(`${logPrefix} owner participation materialization failed:`, err); - } - }; - - // A CYCLE IS A READ-DERIVE-WRITE, AND TWO OF THEM MUST NOT OVERLAP. The two - // triggers below fire within the same instant on the connection that creates an - // event, and run concurrently they both read the inbox before either writes — - // so the one that finishes last puts its own, older reading back on the - // document. Serialized, a request arriving mid-cycle is served by ONE follow-up - // cycle once the current one has finished (a cycle re-derives everything from - // the inbox, so one follow-up covers however many requests it coalesces). - const materialize = createSerialTask(runCycle); - - // (A) RELIABLE-AT-CONNECTION: run one materialization directly on this trigger - // ([ready, ownedKey]). This is the spec's core — the owner, at its NEXT - // CONNECTION, deterministically processes its owned events' inbox, reading - // through the synced-view contract. It does NOT depend on a cross-session - // inbox push arriving. - void materialize('connection').catch(err => - console.error(`${logPrefix} owner participation materialize cycle rejected:`, err)); - - // (B) SAME-SESSION LIVE: `inbox.watch` fires on the initial state push and on - // every later deposit visible to THIS verifier (a local deposit, or a remote one - // that pushes) — no polling. Re-materialize on each so the owner's own session - // stays live when a deposit does push. Cross-session convergence does NOT rely on - // this (it relies on (A) at the owner's next connection); this only sharpens the - // same-session/live case. One watch PER owned event — each event has its OWN - // inbox document — and the address watched is the one taken from the SHARED map - // above, so what is watched is exactly what the cycle reads. - const unsubscribes: Array<() => void> = []; - void (async () => { - const inboxes = await inboxesForOwnedEvents(); - if (cancelled) return; - for (const targetInbox of inboxes.values()) { - unsubscribes.push(inbox.watch(targetInbox, () => - void materialize('inbox-push').catch(err => - console.error(`${logPrefix} owner participation materialize cycle rejected:`, err)))); - } - })().catch(err => console.error(`${logPrefix} owner inbox watch wiring failed:`, err)); - return () => { cancelled = true; for (const stop of unsubscribes) stop(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ready, ownedKey]); - - // Protected-sharing act: the app owns the relationship concept — it declares the - // current identity's own connections (a Festipod domain fact) and, for each - // bilateral link, hands the SDK directed read grants so an owner's connections - // may read that owner's PROTECTED entities (public = all; private = owner only). - // The declaration names only the current user's own peers, asserted as the - // current identity. The app carries no access CHECK (see knowledge_trust-model) - // — it only declares its own relationship graph, then trusts the SDK to enforce - // the resulting per-document grants. No store id, no document NURI crosses here. - useEffect(() => { - if (!ready || !currentUserId) return; - // `inbox.share(doc, toUser)` names a PERSON, so the app models both sides of - // a relationship in ONE key space: the normalized profile username. It maps - // each peer IRI → that key before declaring, and asserts AS its own key. - // Peers with no known profile are skipped (they cannot be named). - const idKeyOf = (userIri: string): string | undefined => { - const u = users.find(x => x.id === userIri); - return u?.username ? normalizeIdentifier(u.username) : undefined; - }; - const selfKey = idKeyOf(currentUserId); - if (!selfKey) return; - const myPeers = friendships - .filter(f => f.userId === currentUserId || f.friendId === currentUserId) - .map(f => (f.userId === currentUserId ? f.friendId : f.userId)) - .map(idKeyOf) - .filter((k): k is string => !!k); - // Sharing is I/O now (a key handed to each neighbour's inbox), so it is - // fire-and-forget from this effect — nothing downstream waits on it. - void declareConnections(myPeers, selfKey) - .catch(err => console.error(`${logPrefix} declareConnections failed:`, err)); - }, [ready, friendships, currentUserId, users]); - - const queries = buildQueries( - events, users, participations, meetingPoints, friendships, currentUserId, - ); - - console.log(`${logPrefix} Render — NG | events:`, events.length, - '| users:', users.length, '| participations:', participations.length, - '| selectedEvent:', selectedEvent?.title ?? '(none)'); - - // --- Mutations (NG) --- - // Each entity is written as its OWN document, created via the SDK in its scope - // (`createEntityDoc(scope)`) — never a store-level document. Creating a doc - // appends its NURI to the scope index, which `watchShape` subscribes; the new - // entity enters the reactive read on the resulting push (no manual registration). - // The SDK declares the per-document ReadCap policy on create (public / protected / - // private) — the app carries no access logic. - - const createEvent = useCallback(async (event: Omit): Promise => { - console.log(`${logPrefix} createEvent (NG):`, event.title); - // The SDK create returns THIS entity's OWN public document and declares its - // access policy (public → world-readable). Placement is named by SCOPE - // alone: the session belongs to one user, so there is no owner to name. - // A creation that cannot be recorded THROWS — it never hands back a - // reference that would read empty forever — so this rejects the whole - // `createEvent` rather than returning a half-made event. - // Create the event's OWN document in the PUBLIC scope (one doc per entity), - // then WRITE the event RDF DIRECTLY into that document (writeEntity) — not via - // the scope-coupled `ngSet.add`, which can't write into a not-yet-subscribed - // per-entity doc against the real broker. The doc's NURI is appended to the - // public scope index, which `watchShape('public')` subscribes → the event - // enters the reactive read on the push. The written subject IRI is the `@id`. - const eventGraph = await createEntityDoc('public'); - // An event is a document people SIGN UP TO, so its owner opens its inbox - // here, at creation. A document only HAS one if its owner opened it; without - // this, a registrant's deposit would have nothing to reach. - await openDocumentInbox(eventGraph); - const eventId = await writeEntity(eventGraph, ENTITY_TYPE.event, { - title: str(event.title), description: str(event.description), date: str(event.date), - // `date` is the human LABEL the form composed; these four are the machine - // values it collected, written UNALTERED as the form's own ISO forms - // (`YYYY-MM-DD`, `HH:MM`). They are stored alongside the label, never - // derived from it — the label is display text and cannot be parsed back. - startDate: str(event.startDate), endDate: str(event.endDate), - startTime: str(event.startTime), endTime: str(event.endTime), - location: str(event.location), distance: flt(event.distance), - // No host notion: the creator merely SIGNALS a public event and is NOT - // obliged to participate, so the count starts at 0 (the owner-materializer - // derives it from the active-registration set — |active|, no host baseline). - participantCount: int(event.participantCount || 0), - coverImage: str(event.coverImage), - }); - // OPTION B: this event's doc is MINE (I just created it), so track it as owned - // → the owner-materializer subscribes to its inbox and maintains its count. - setOwnedEventIds(prev => (prev.includes(eventGraph) ? prev : [...prev, eventGraph])); - // OPTIMISTIC OVERLAY: surface the created event IMMEDIATELY (its id = eventId = - // the doc NURI, the SAME id `watchShape('public')` will surface as `s.subject`, - // so the reconciliation effect drops this add once the push arrives). Without - // it, `getEvent(eventId)` right after create returns undefined until the push. - // participantCount starts at 0 (creator does not auto-participate). - const optimisticEvent: FpEventData = { ...event, id: eventId, participantCount: event.participantCount || 0 }; - setPendingAddEvents(prev => (prev.some(e => e.id === eventId) ? prev : [...prev, optimisticEvent])); - // The creator does NOT auto-participate (no host notion — settled product - // decision): NO participation is written on create. The creator sees "J'y - // serai" and may join/leave their own event like anyone else. - if (currentUserId) setSelectedEventId(eventId); - const addedEvent = { "@id": eventId, title: event.title }; - // Make the PUBLIC event discoverable: submit its reference to the SDK global - // discovery index (an SDK act — the app holds no index/store id). The SDK - // enforces public-only: passing the event's own document lets it refuse a - // non-public doc. Best-effort: a failed submission must not roll back a - // successful event creation. - if (addedEvent) { - // NO index submission any more. There is no discovery: a reader reaches a - // document only by FOLLOWING A LINK it was given. Announcing a public event - // beyond its creator is therefore a Festipod-level concern (a directory - // document whose link the app knows), not an SDK call — see the directory - // work. Until it lands, a created event is reachable by its creator only. - } - return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` }; - }, [currentUserId]); - - const updateEvent = useCallback(async (id: string, updates: Partial) => { - console.log(`${logPrefix} updateEvent (NG):`, id, updates); - // The event's `@id` IS its own document NURI (one entity = one document), so - // it is both the write graph and the subject. Persist each provided mutable - // field DIRECTLY via SPARQL (the durable write); `watchShape` re-reads on the - // resulting broker push (the doc is already subscribed) — no manual re-query. - // No pre-flight NURI guard: every SDK entry takes the reference as it stands - // and validates at its own door. - const graph = id; - const persists: Promise[] = []; - if (updates.participantCount !== undefined) { - persists.push(updateEntityField(graph, id, 'participantCount', int(updates.participantCount))); - } - if (updates.title !== undefined) persists.push(updateEntityField(graph, id, 'title', str(updates.title))); - if (updates.description !== undefined) persists.push(updateEntityField(graph, id, 'description', str(updates.description))); - if (updates.date !== undefined) persists.push(updateEntityField(graph, id, 'date', str(updates.date))); - // The machine dates/times travel with the label, not instead of it. A field - // arriving as `''` (the form's empty date input) CLEARS the triple rather than - // leaving a stale value behind — that is `updateEntityField`'s empty handling, - // and it is the right reading here: the user emptied the input. - if (updates.startDate !== undefined) persists.push(updateEntityField(graph, id, 'startDate', str(updates.startDate))); - if (updates.endDate !== undefined) persists.push(updateEntityField(graph, id, 'endDate', str(updates.endDate))); - if (updates.startTime !== undefined) persists.push(updateEntityField(graph, id, 'startTime', str(updates.startTime))); - if (updates.endTime !== undefined) persists.push(updateEntityField(graph, id, 'endTime', str(updates.endTime))); - if (updates.location !== undefined) persists.push(updateEntityField(graph, id, 'location', str(updates.location))); - if (updates.distance !== undefined) persists.push(updateEntityField(graph, id, 'distance', flt(updates.distance))); - await Promise.all(persists).catch(err => console.error(`${logPrefix} persist event update failed:`, err)); - }, []); - - const joinEvent = useCallback(async (eventId: string, userId?: string) => { - const uid = userId || currentUserId; - console.log(`${logPrefix} joinEvent (NG):`, eventId, 'user:', uid); - // A Participation MUST carry a user reference (SHEX `fp:user` is mandatory) — - // writing one without it produces an entity the read drops (the participation - // silently never round-trips). REJECT rather than return: returning here made - // the sign-up a no-op that wrote nothing, threw nothing and let the screen - // congratulate the user. The cause is named, because it is actionable: no - // profile of mine is resolved yet. - if (!uid) { - const msg = - `joinEvent refused for event=${canonicalEventId(eventId)}: no profile of mine is resolved, ` + - `so a Participation would carry no fp:user and would never round-trip. ` + - `Wait for the profile to be created/read, or resolve the ambiguity reported above.`; - console.error(`${logPrefix} ${msg}`); - throw new Error(msg); - } - // IDEMPOTENCE — check AUTHORITATIVELY against the broker, not the reactive set. - // The reactive participation set can lag a just-written participation, so a - // second join checking only the set would write a DUPLICATE (breaking "exactly - // one participation"). The broker query sees the real state regardless of lag. - // - // A FAILED count is UNKNOWN, not zero, so it is NOT caught here: reading it as - // "not participating yet" is precisely how a duplicate gets written. The - // rejection propagates out of `joinEvent`, and the screen that called it says - // the sign-up could not be recorded — which is the truth. - const already = await countUserParticipations(eventId, uid); - if (already > 0) { - console.log(`${logPrefix} Already participating (broker-confirmed), skipping`); - return; - } - // 1) Persist the Participation as its OWN document in the PROTECTED scope - // (one doc per entity). Its NURI is appended to the protected scope index, - // which `watchShape('protected')` subscribes → the participation enters - // the reactive read on the push. - // A creation that cannot be recorded THROWS, so a failure here rejects - // `joinEvent` instead of depositing a registration for a participation - // document that would read empty forever. - const partGraph = await createEntityDoc('protected'); - // WRITE the participation RDF DIRECTLY into its own document (writeEntity) — - // not via the scope-coupled `ngSet.add` (can't write a not-yet-subscribed - // per-entity doc against the real broker). The written subject is the - // participation's `@id` (its own graph is partGraph, used later by the - // authoritative delete). - await writeEntity(partGraph, ENTITY_TYPE.participation, { - event: iri(eventId), user: iri(uid), isConfirmed: bool(true), - }); - // OPTIMISTIC OVERLAY: surface the participation IMMEDIATELY so the joiner shows - // up in the list and `isParticipating` is true before the broker push. Its id = - // partGraph = the doc NURI, the SAME id `watchShape('protected')` will surface - // as `s.subject` (adaptParticipations sets `id: s.subject`) → the reconciliation - // effect drops this add once the push arrives. If a stale pendingRemove targeted - // this exact id (re-join same doc — never happens, ids are fresh), clear it too. - const optimisticPart: FpParticipationData = { id: partGraph, eventId, userId: uid, isConfirmed: true }; - setPendingAddParticipations(prev => (prev.some(p => p.id === partGraph) ? prev : [...prev, optimisticPart])); - // OPTION B (brief §B): the joiner does NOT write `participantCount` on the - // EVENT doc — that doc belongs to the OWNER, and a non-owner write there is an - // isolation violation (NextGraph write is membership-bound; there is no append - // — see brief §1). The count now moves only via the OWNER materializing this - // deposit onto its OWN event doc (the `hostedEventIds` materializer below). - // The joiner's sole writes are: their OWN participation doc (above) + the - // inbox DEPOSIT (below). While the owner is offline the count doesn't advance - // for others — accepted eventual behaviour (brief §E.2); nothing is lost. - // 2) Tell the host. THE DEPOSIT IS THE DELIVERY: `inbox.postToDocument(doc, …)` - // is what the surface publishes for reaching a document's owner — anyone - // may deposit, only the owner reads. The host's notification is then built - // on the OWNER's side, out of the deposits it reads from its own event's - // inbox (`readRegistrationNotifications`, in the materializer above). - // - // Nothing else is written here. A host FpNotification used to be minted at - // this point into the JOINER's own protected scope with `recipient` set to - // the event — a document the host cannot read, and never will: the joiner - // owns it and the surface has no way to hand it over. It also pushed that - // notification into THIS session's own list, so the joiner saw a - // "new participant" notice addressed to someone else. Both are gone; the - // deposit alone carries the news, and it reaches its reader. - // - // Best-effort: a failed deposit must not roll back a successful - // registration (the participation document is already written). - try { - const registrantId = uid || null; // no current user → anonymous deposit - // The event's `@id` IS its document NURI, and a deposit NAMES that document - // — the joiner resolves no inbox and holds no address. - // Carry the joiner's participation-doc NURI so the owner (if a connection) - // could read it in clear; the count itself does not depend on reading it. - console.log( - `${logPrefix} joinEvent — depositing participation registration into event inbox: ` + - `event=${canonicalEventId(eventId)} user=${uid} (count now moves via the OWNER ` + - `materializing this deposit on its own doc, at its next connection)`, - ); - const { uid: depositUid } = await depositRegistration(eventId, registrantId, partGraph); - // Remember the join uid so a same-session leave can cancel it precisely. - joinUidsRef.current.set(`${eventId}|${uid}`, depositUid); - } catch (err) { - console.error(`${logPrefix} joinEvent inbox deposit failed:`, err); - } - }, [events, currentUserId]); - - const leaveEvent = useCallback(async (eventId: string, userId?: string) => { - const uid = userId || currentUserId; - console.log(`${logPrefix} leaveEvent (NG):`, eventId, 'user:', uid); - // Find the participation in the union-read set. Each participation is its OWN - // document (writeEntity uses the doc NURI as the subject), so `part.id` is BOTH - // the subject IRI AND the graph NURI it lives in. - const part = participations.find(p => p.eventId === eventId && p.userId === uid); - // REJECT rather than pretend. Withdrawal is AUTHORITATIVE (see - // caveat_participation-deletion): the caller only reaches here because it - // believes a participation exists, so finding none is a real disagreement - // about the state — either no profile of mine is resolved (`uid` empty), or - // the participation the screen showed is not in the set. Returning silently - // deleted nothing while the screen announced a withdrawal, and the sign-up - // came back on the next read. - if (!part) { - const msg = uid - ? `leaveEvent refused for event=${canonicalEventId(eventId)}: no participation of user=${uid} ` + - `in the participation set — nothing was deleted, so the withdrawal must not be announced.` - : `leaveEvent refused for event=${canonicalEventId(eventId)}: no profile of mine is resolved, ` + - `so the participation to withdraw cannot even be named.`; - console.error(`${logPrefix} ${msg}`); - throw new Error(msg); - } - // DÉSINSCRIPTION FIX (caveat_participation-deletion): the AUTHORITATIVE deletion - // is a SPARQL DELETE via the real injected `ng` (docs.sparqlUpdate), which - // removes the Participation server-side so it does NOT resurrect after re-sync. - // The delete targets the participation's own document (part.id) and is - // identified by its OWN subject IRI (part.id), not a string-match on object IRIs. - const graphNuri = part.id; - const subjectIri = part.id; - let result; - try { - result = await deleteParticipation(graphNuri, eventId, uid, subjectIri); - } catch (err) { - console.error(`${logPrefix} SPARQL DELETE participation failed:`, err); - throw err instanceof Error ? err : new Error(String(err)); - } - // AUTHORITATIVE verification: only proceed once the broker RE-QUERY confirms - // the participation is gone (remaining === 0). If the delete matched nothing, - // surface it rather than falsely flip the UI (it would resurrect on re-sync). - if (result.remaining > 0) { - const msg = `${logPrefix} leaveEvent: SPARQL delete removed nothing ` + - `(before=${result.before}, remaining=${result.remaining}, bySubject=${result.bySubject}) ` + - `for event=${eventId} user=${uid} — NOT flipping UI (would resurrect).`; - console.error(msg); - throw new Error(msg); - } - // OPTIMISTIC OVERLAY: the delete is broker-confirmed (remaining === 0) but the - // reactive `watchShape('protected')` set only drops the participation on the - // NEXT push (a beat later). Mark its id removed NOW so the leaver disappears - // from the list immediately; the reconciliation effect clears this pendingRemove - // once the reactive set no longer carries the id. Also drop it from - // pendingAddParticipations in case it was still only-optimistic (join+leave in - // the same session before the join's push landed). - setPendingRemoveIds(prev => { - if (prev.has(part.id)) return prev; - const next = new Set(prev); - next.add(part.id); - return next; - }); - setPendingAddParticipations(prev => { - const next = prev.filter(p => p.id !== part.id); - return next.length === prev.length ? prev : next; - }); - // OPTION B, symmetric (brief §B "Désinscription"): the leaver does NOT write - // `participantCount` on the EVENT doc (owner-owned — same isolation violation - // as the join). Instead it DEPOSITS a `leave-participant` marker into the - // event inbox; the OWNER materializes it and recomputes the count on its OWN - // doc (idempotent — a re-synced leave never double-decrements, since the count - // is derived from the SET of distinct active registrations, not from −1). - try { - const registrantId = uid || null; - // Same as `joinEvent`: the leave marker NAMES the event document; no inbox - // address is resolved on the depositor's side. - // Carry the join uid when this session minted it (precise cancellation); - // otherwise the owner falls back to (eventId, userId) matching. - const regUid = joinUidsRef.current.get(`${eventId}|${uid}`); - console.log( - `${logPrefix} leaveEvent — depositing participation leave marker into event inbox: ` + - `event=${canonicalEventId(eventId)} user=${uid} regUid=${regUid ?? '(none)'}`, - ); - await depositLeave(eventId, registrantId, regUid); - joinUidsRef.current.delete(`${eventId}|${uid}`); - } catch (err) { - console.error(`${logPrefix} leaveEvent inbox deposit failed:`, err); - } - // The participation doc is subscribed by `watchShape('protected')`; the SPARQL - // DELETE pushes → the reactive read drops it (`isParticipating` reflects it). - // The count itself follows the owner's materialization of the leave marker - // (reactive, cross-session). - }, [participations, events, currentUserId]); - - const addMeetingPoint = useCallback((mp: Omit) => { - setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]); - }, []); - - const addFriend = useCallback((friendId: string) => { - setFriendships(prev => { - if (prev.some(f => - (f.userId === currentUserId && f.friendId === friendId) || - (f.userId === friendId && f.friendId === currentUserId) - )) return prev; - return [...prev, { id: `ng-fr-${Date.now()}`, userId: currentUserId, friendId }]; - }); - }, [currentUserId]); - - const updateProfile = useCallback(async (updates: Partial) => { - console.log(`${logPrefix} updateProfile (NG):`, updates); - // MY profile is its own document (subject IRI = doc NURI), and it is the ONLY - // document these edits may land in. There used to be a `?? users[0]` fallback - // here: with no profile resolved, a person editing their own pseudo wrote it - // into a stranger's profile document. Refuse instead — and say which of the - // two reasons it is, since only one of them clears up on its own. - const target = currentUser; - if (!target) { - const msg = - `updateProfile refused: no profile of mine is resolved, and these edits must never land in ` + - `someone else's profile document.`; - console.error(`${logPrefix} ${msg}`); - throw new Error(msg); - } - const graph = target.id; - const persists: Promise[] = []; - if (updates.name !== undefined) persists.push(updateEntityField(graph, graph, 'name', str(updates.name))); - if (updates.initials !== undefined) persists.push(updateEntityField(graph, graph, 'initials', str(updates.initials))); - if (updates.username !== undefined) persists.push(updateEntityField(graph, graph, 'username', str(updates.username))); - if (updates.role !== undefined) persists.push(updateEntityField(graph, graph, 'role', str(updates.role))); - if (updates.isPublic !== undefined) persists.push(updateEntityField(graph, graph, 'isPublic', bool(updates.isPublic))); - await Promise.all(persists).catch(err => console.error(`${logPrefix} persist profile update failed:`, err)); - }, [currentUser, users]); - - const loadTestData = useCallback(async (): Promise => { - console.log(`${logPrefix} loadTestData (NG)`); - // THIS NO LONGER SEEDS. `bootstrapWallet` refuses while the fixture seed is - // switched off (`fixtureSeedEnabled`), so what comes back is `seeded: false` - // with no documents, and everything below is a no-op on that empty result. - // The call is kept, and kept honest, rather than removed: the switch is what - // decides, in one place, and flipping it back restores this path unchanged. - // An EXPLICIT load is authoritative — SUPPRESS the dev auto-seed so only ONE - // seed runs (marking the guard at the START, before the awaited seed, closes - // the window where the auto-seed effect could also fire on a still-empty read). - hasTriedAutoSeed.current = true; - // Same reading as the auto-seed gate: my own profile is not "data in the - // wallet" — it exists on every wallet from the first sign-in, so counting it - // would make an explicit load a permanent no-op. - const walletHasData = events.length > 0 || users.some(u => u.id !== myProfileDocId); - const result = await bootstrapWallet(walletHasData, createEntityDoc); - // The seeded per-entity docs are appended to their scope indices, which - // `watchShape` subscribes → they enter the reactive reads on the push. No - // manual registration / re-list. - // OWNERSHIP is a different question from READING, and it is not answered by - // the push: these public event docs were created by THIS session, so claim - // them — otherwise the owner-materializer never opens their inboxes and their - // `participantCount` is never derived. - claimOwnedEventDocs(result.createdDocs.public); - // The seeded PROTECTED docs are deliberately NOT claimed as mine: they are - // FIXTURES (nobody signed in as them), and folding them into the owned-profile - // set is exactly what would make "which profile is mine" ambiguous. - return result; - }, [events.length, users, myProfileDocId, claimOwnedEventDocs]); - - return { - currentUserId, currentUser, currentPrincipal, - events, users, - participations, - meetingPoints, - friendships, - notifications, - selectedEventId, setSelectedEventId, selectedEvent, - selectedUserId, setSelectedUserId, selectedUser, - ...queries, - getEventOwnership, - createEvent, updateEvent, joinEvent, leaveEvent, - addMeetingPoint, addFriend, updateProfile, loadTestData, - }; -} - -// ============================================================================ -// Provider — switches between local and NG -// ============================================================================ - export function LocalDataProvider({ children, empty }: { children: ReactNode; empty?: boolean }) { const data = useLocalData(empty); return {children}; } -function NgDataProvider({ children }: { children: ReactNode }) { - const data = useNgData(); - return {children}; -} +// ============================================================================ +// Dispatcher — which data path this page runs +// ============================================================================ export function FestipodDataProvider({ children }: { children: ReactNode }) { const { status } = useNextGraph(); - // SIGNING IN MUST HAVE SETTLED BEFORE THE DATA PROVIDER STARTS. Every call the - // NG provider makes addresses "MY documents" — placement by scope - // (`createEntityDoc` / `listMyEntityDocs`) and the reactive scope reads alike — - // and there is no "my" until `ensureIdentity()` has answered. Being CONNECTED is - // not being SIGNED IN: the session opens first, the identity settles after. A - // provider mounted in between fires its reads and its owned-documents - // resolution against no identity — they fail once, at mount, and the memoized - // observables never retry, so the screens stay empty for the whole session - // while the writes that come later succeed. - // - // The order is carried HERE, by which provider is mounted, rather than by a - // guard repeated in every effect — the invariant cannot then be forgotten by - // the next call site. const signedIn = useCurrentPrincipal() !== ''; - console.log('[app][data] Provider — NG status:', status, '| signed in:', signedIn); + const hasRefineSession = useCurrentSession() !== undefined; + const backendName = initialBackendName(); - if (status === 'connected' && signedIn) { - return {children}; + const mode = resolveDataProviderMode({ + ngStatus: status, + signedIn, + hasRefineSession, + backendName, + }); + console.log('[app][data] Provider — mode:', mode, '| NG status:', status, '| backend:', backendName); + + switch (mode) { + case 'ng': + return {children}; + case 'refine': + return {children}; + case 'local-empty': + return {children}; + case 'local': + return {children}; } - if (status === 'connecting' || status === 'connected') { - // NG initializing, or connected but not signed in yet: show empty state (no - // misleading seed data flash, and no read issued before there is a "my"). - return {children}; - } - // Disconnected or error: demo mode with seed data - return {children}; -} - -// ============================================================================ -// Hook -// ============================================================================ - -export function useFestipodData() { - const context = useContext(FestipodDataContext); - if (!context) { - throw new Error('useFestipodData must be used within a FestipodDataProvider'); - } - return context; } diff --git a/src/shared/context/dataProviderMode.test.ts b/src/shared/context/dataProviderMode.test.ts new file mode 100644 index 0000000..670f7d5 --- /dev/null +++ b/src/shared/context/dataProviderMode.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from 'bun:test'; +import { resolveDataProviderMode } from './dataProviderMode'; + +const base = { + ngStatus: 'disconnected' as const, + signedIn: false, + hasRefineSession: false, + backendName: 'activitypods', +}; + +// A live NextGraph session wins over everything — this is how the @data broker +// harness (which mounts NextGraphProvider at the root URL) keeps its path even +// though the URL names the default ActivityPods backend. +test('a connecting or connected-but-unsigned NG session shows an empty local state', () => { + expect(resolveDataProviderMode({ ...base, ngStatus: 'connecting' })).toBe('local-empty'); + expect(resolveDataProviderMode({ ...base, ngStatus: 'connected', signedIn: false })).toBe('local-empty'); +}); + +test('a connected and signed-in NG session routes to the polyfill path', () => { + expect(resolveDataProviderMode({ ...base, ngStatus: 'connected', signedIn: true })).toBe('ng'); + expect(resolveDataProviderMode({ ...base, ngStatus: 'connected', signedIn: true, hasRefineSession: true })).toBe('ng'); +}); + +test('an ActivityPods session with a registered backend routes to refine', () => { + expect(resolveDataProviderMode({ ...base, hasRefineSession: true })).toBe('refine'); + expect(resolveDataProviderMode({ ...base, hasRefineSession: true, backendName: undefined })).toBe('local'); +}); + +test('no session at all serves the seeded demo (offline, unconfigured backend)', () => { + expect(resolveDataProviderMode(base)).toBe('local'); + expect(resolveDataProviderMode({ ...base, backendName: undefined })).toBe('local'); +}); + +test('an NG error without a refine session falls back to the demo, like disconnected', () => { + expect(resolveDataProviderMode({ ...base, ngStatus: 'error' })).toBe('local'); +}); diff --git a/src/shared/context/dataProviderMode.ts b/src/shared/context/dataProviderMode.ts new file mode 100644 index 0000000..d47cb53 --- /dev/null +++ b/src/shared/context/dataProviderMode.ts @@ -0,0 +1,45 @@ +/** + * Which data path a page runs — the dispatcher's decision, pure. + * + * The rule is SESSION REALITY FIRST, backend name second: + * + * - A live NextGraph session wins — this covers the `nextgraph` URL variant + * AND the `@data` broker harness, which mounts `NextGraphProvider` directly + * at the root URL. While it connects, or once connected but before the one + * identity await settles, the app shows an EMPTY local state: no reads may + * fire before "who am I" is answered. + * - Otherwise, an ActivityPods session (published by AuthGate) routes to the + * registered Refine backend — the default. Only the session distinguishes + * "authenticated" from "offline demo": with a session the screens read the + * Pod; without one (unconfigured backend, "Continuer hors ligne") they get + * the seeded demo. + */ + +export type DataProviderMode = 'ng' | 'refine' | 'local-empty' | 'local'; + +export interface DataProviderModeInput { + /** The NextGraph connection status ('disconnected' when no provider is mounted). */ + ngStatus: 'disconnected' | 'connecting' | 'connected' | 'error'; + /** Whether the one identity await has settled (NextGraph path). */ + signedIn: boolean; + /** Whether an ActivityPods session exists. */ + hasRefineSession: boolean; + /** The backend the URL selected (`undefined` when none is registered). */ + backendName: string | undefined; +} + +export function resolveDataProviderMode(input: DataProviderModeInput): DataProviderMode { + const { ngStatus, signedIn, hasRefineSession, backendName } = input; + + if (ngStatus === 'connecting') return 'local-empty'; + if (ngStatus === 'connected') { + // The NG harness and the nextgraph variant both gate on the identity + // settling before exposing anything; the data path follows the same rule. + return signedIn ? 'ng' : 'local-empty'; + } + + // No live NextGraph session: the polyfill path is not mounted (its status + // stays 'disconnected' without a provider, including at the root variant). + if (hasRefineSession && backendName) return 'refine'; + return 'local'; +} diff --git a/src/shared/context/festipodDataBase.ts b/src/shared/context/festipodDataBase.ts new file mode 100644 index 0000000..545835e --- /dev/null +++ b/src/shared/context/festipodDataBase.ts @@ -0,0 +1,222 @@ +/** + * FestipodDataContext — the screen-facing data contract, and the shared pieces + * every provider implementation builds on. + * + * The app signs up to a MEETING POINT, not to an event (product model): an + * event is only the public anchor meeting points graft onto. So the + * participation surface is `joinMeetingPoint` / `leaveMeetingPoint`; there is + * no event-level join. Provider implementations: + * + * - `LocalDataProvider` (`FestipodDataContext.tsx`) — the disconnected demo, + * seed fixtures, mutations as no-ops. What the `@ui` layer renders against. + * - `RefineDataProvider` (`refineData.tsx`) — the ActivityPods backend behind + * the Refine seam: CRUD through the data provider, joins/leaves through the + * outbox service beside it. + * - `NgDataProvider` (`ngData.tsx`) — the suspended NextGraph+polyfill path, + * preserved for the `nextgraph` URL variant; NOT mounted at the root. + */ + +import { createContext, useContext } from 'react'; +import type { + FpEventData, + FpUserData, + FpParticipationData, + FpMeetingPointData, + FpFriendshipData, + FpNotificationData, +} from '../data/types'; +import type { BootstrapResult } from '../utils/ngBootstrap'; +import { normalizeIdentifier } from '../utils/identifier'; + +// ============================================================================ +// Context interface +// ============================================================================ + +/** + * Whether this session may WRITE an event's document — asked the same way on + * every backend: only a record's owner writes it. `'unknown'` is a REAL third + * answer (the read that settles it may not have landed yet), never a polite + * `'not-mine'`: collapsing it is how an owner gets told, silently and wrongly, + * that the thing is not theirs. + */ +export type EventOwnership = 'mine' | 'not-mine' | 'unknown'; + +export interface FestipodDataContextValue { + currentUserId: string; + currentUser: FpUserData | undefined; + /** + * WHO THE SESSION SIGNED IN AS — the identity signing in returned, known + * before any document is read. For display (and log attribution) only: it is + * a different id space from `currentUserId` (a profile document URI), it is + * never written into an entity, and no data call takes it. + */ + currentPrincipal: string; + + events: FpEventData[]; + users: FpUserData[]; + participations: FpParticipationData[]; + meetingPoints: FpMeetingPointData[]; + friendships: FpFriendshipData[]; + /** Host-facing notifications. Empty on backends without an inbox read. */ + notifications: FpNotificationData[]; + + getEvent(id: string): FpEventData | undefined; + getUser(id: string): FpUserData | undefined; + getEventParticipants(eventId: string): FpUserData[]; + getUserEvents(userId: string): FpEventData[]; + /** Membership test against a MEETING POINT id (defaults to the current user). */ + isParticipating(meetingPointId: string, userId?: string): boolean; + getFriends(userId?: string): FpUserData[]; + getEventMeetingPoints(eventId: string): FpMeetingPointData[]; + /** + * May this session WRITE this event? Callers MUST treat `'unknown'` as its + * own case — see {@link EventOwnership}. + */ + getEventOwnership(eventId: string): EventOwnership; + + selectedEventId: string; + setSelectedEventId(id: string): void; + selectedEvent: FpEventData | undefined; + selectedUserId: string; + setSelectedUserId(id: string): void; + selectedUser: FpUserData | undefined; + + createEvent(event: Omit): Promise; + updateEvent(id: string, updates: Partial): void | Promise; + /** + * Sign up to a meeting point. Backend-specific delivery (outbox `as:Join` on + * ActivityPods; a participation record on the suspended NextGraph path), one + * product fact everywhere: afterwards this user counts among the meeting + * point's participants. + */ + joinMeetingPoint(meetingPointId: string, userId?: string): Promise | void; + /** Withdraw from a meeting point — the symmetric fact to `joinMeetingPoint`. */ + leaveMeetingPoint(meetingPointId: string, userId?: string): Promise | void; + addMeetingPoint(mp: Omit): void; + addFriend(friendId: string): void; + updateProfile(updates: Partial): void | Promise; + loadTestData(): Promise; +} + +export const FestipodDataContext = createContext(null); + +// ============================================================================ +// Helpers shared by every provider +// ============================================================================ + +let idCounter = 100; +export function nextId(prefix: string): string { + return `${prefix}-${++idCounter}`; +} + +// LEGACY, READ ONLY. Participations written by an older version key their user +// (`fp:user`) as `urn:festipod:user:` instead of the UserProfile's +// `did:ng:` document NURI. Nothing MINTS this any more — today's writes carry +// the profile NURI (`currentUserId`) — but `resolveParticipantUser` must still +// recognize the old form, or those participations render as "participant +// inconnu". +export const USER_PRINCIPAL_PREFIX = 'urn:festipod:user:'; + +/** + * Resolve a Participation's `fp:user` to its UserProfile. TODAY'S WRITES need + * no resolving (`fp:user` carries the profile's own document id, the direct + * match — tried first). LEGACY ONLY: `urn:festipod:user:` matches no + * profile id, so the handle is stripped and compared to the normalized + * username — a READ-side survival for old data, playing no part in deciding + * who the current user is. + */ +export function resolveParticipantUser(userId: string, users: FpUserData[]): FpUserData | undefined { + const direct = users.find(u => u.id === userId); + if (direct) return direct; + const key = userId.startsWith(USER_PRINCIPAL_PREFIX) + ? userId.slice(USER_PRINCIPAL_PREFIX.length) + : userId; + return users.find(u => u.username && normalizeIdentifier(u.username) === key); +} + +/** + * What a BRAND-NEW profile is created with — "not filled in yet" placeholders, + * replaced by the user through `updateProfile`. A profile is the app's own + * object and signing in produces none of it; nothing here is derived from the + * signed-in identity. + */ +export const UNSET_PROFILE = { + name: 'Profil à compléter', + initials: '?', + username: '(pseudo non défini)', +} as const; + +/** + * The shared in-memory queries — the same logic for every provider, fed with + * that provider's arrays. Cross-entity derivations only: nothing here touches + * a backend. + * + * THE `participations` ARRAY'S `eventId` IS THE JOINED TARGET'S ID. On the + * local/NextGraph providers that is an event; on the ActivityPods provider it + * is a meeting point. `isParticipating` and `getUserEvents` filter on it + * as-is, so a provider whose participations are keyed differently (the + * ActivityPods reader derives event-level participants from the meeting + * points) overrides those two queries and reuses the rest. + */ +export function buildQueries( + events: FpEventData[], + users: FpUserData[], + participations: FpParticipationData[], + meetingPoints: FpMeetingPointData[], + friendships: FpFriendshipData[], + currentUserId: string, +) { + const getEvent = (id: string) => events.find(e => e.id === id); + const getUser = (id: string) => users.find(u => u.id === id); + + const getEventParticipants = (eventId: string) => { + // Resolve each participation's `user` to its UserProfile across the id + // spaces — see `resolveParticipantUser`. Deduped, returned in `users` + // order. Whoever stays unresolved (a profile the reader may not read) is + // NOT here — the count on the event is the total, the screens render the + // remainder as generic participants. + const eventParts = participations.filter(p => p.eventId === eventId); + const resolvedIds = new Set(); + for (const p of eventParts) { + const u = resolveParticipantUser(p.userId, users); + if (u) resolvedIds.add(u.id); + } + return users.filter(u => resolvedIds.has(u.id)); + }; + + const getUserEvents = (userId: string) => { + const partEventIds = participations.filter(p => p.userId === userId).map(p => p.eventId); + return events.filter(e => partEventIds.includes(e.id)); + }; + + const isParticipating = (meetingPointId: string, userId?: string) => { + const uid = userId || currentUserId; + return participations.some(p => p.eventId === meetingPointId && p.userId === uid); + }; + + const getFriends = (userId?: string) => { + const uid = userId || currentUserId; + const friendIds = friendships + .filter(f => f.userId === uid || f.friendId === uid) + .map(f => f.userId === uid ? f.friendId : f.userId); + return users.filter(u => friendIds.includes(u.id)); + }; + + const getEventMeetingPoints = (eventId: string) => { + return meetingPoints.filter(mp => mp.eventId === eventId); + }; + + return { getEvent, getUser, getEventParticipants, getUserEvents, isParticipating, getFriends, getEventMeetingPoints }; +} + +// ============================================================================ +// Hook +// ============================================================================ + +export function useFestipodData() { + const context = useContext(FestipodDataContext); + if (!context) { + throw new Error('useFestipodData must be used within a FestipodDataProvider'); + } + return context; +} diff --git a/src/shared/context/ngData.tsx b/src/shared/context/ngData.tsx new file mode 100644 index 0000000..75b8c5d --- /dev/null +++ b/src/shared/context/ngData.tsx @@ -0,0 +1,1281 @@ +/** + * NgDataProvider — the suspended NextGraph+polyfill data path. + * + * PRESERVED, NOT MOUNTED AT THE ROOT. Per the 2026-10-09 directive (concept + * `data-layer`, `brief_2026-10-09_activitypods-first-backend`) ActivityPods is + * the sole priority backend; this path stays in the repo for the `nextgraph` + * URL variant and the `@data` broker harness, and the dispatcher + * (`FestipodDataContext.tsx`) routes to it only when a live NextGraph session + * is present. Everything below is the pre-Refine implementation: reactive + * `watchShape` reads, per-entity documents in scopes, inbox deposits and the + * owner materializer. + * + * THE MEETING-POINT-CENTRIC SURFACE, ON THIS PATH. The product model signs up + * to a meeting point; NextGraph persists no meeting point document (the local + * list below is session state only), so `joinMeetingPoint`/`leaveMeetingPoint` + * carry the id they are given — the event id in every flow that exists today — + * and write the participation record exactly as the event-join they replace + * did. The participation's target IS the joined target, whatever id that is. + */ + +import React, { useState, useCallback, useEffect, useRef, type ReactNode } from 'react'; +import type { + FpEventData, + FpUserData, + FpParticipationData, + FpMeetingPointData, + FpFriendshipData, + FpNotificationData, +} from '../data/types'; +import { + depositRegistration, + depositLeave, + readRegistrationNotifications, + materializeAttendance, + deleteParticipation, + countUserParticipations, + canonicalEventId, +} from '../data/registration'; +import { inbox } from '@ng-eventually/polyfill'; +import type { Nuri } from '@ng-eventually/polyfill'; +import { useNextGraph } from './NextGraphContext'; +import { normalizeIdentifier } from '../utils/identifier'; +// Relationship is a Festipod concept: the app keeps its own bilateral registry +// and shares its own documents' keys with its neighbours (see shared/utils/connections). +import { declareConnections } from '../utils/connections'; +import { + listMyEntityDocs, + createEntityDoc, + openDocumentInbox, +} from '../utils/storeRegistry'; +import { useCurrentPrincipal } from '../utils/currentPrincipal'; +import { createSerialTask } from '../utils/serialTask'; +import { useShapeQuery } from '../data/useShapeQuery'; +import { adaptEvents, adaptUsers, adaptParticipations } from '../data/shapeAdapters'; +// The ORM generator emits BARE shape names (`EventShapeType`, `Event`, …), taken +// from each shape's IRI local name. They are aliased back to the `Fp*` spelling +// HERE, at the import, so nothing downstream depends on the generator's naming: +// `Event` and `Notification` are DOM globals, and the bare names collide with +// the app's own vocabulary (`FpEventData` & co. in `data/types.ts`). The prefix cannot +// be restored at the generator — the emitted name follows the shape IRI, and those +// IRIs are the persisted RDF classes (`ENTITY_TYPE`), not ours to rename. +import { + EventShapeType as FpEventShapeType, + UserProfileShapeType as FpUserProfileShapeType, + ParticipationShapeType as FpParticipationShapeType, +} from '../shapes/orm/festipodShapes.shapeTypes'; +import { writeEntity, updateEntityField, ENTITY_TYPE, str, int, flt, bool, iri } from '../data/entityWrites'; +import { bootstrapWallet, type BootstrapResult } from '../utils/ngBootstrap'; +import { autoSeedEnabled, fixtureSeedEnabled, shouldAutoSeed } from '../utils/autoSeed'; +import { + FestipodDataContext, + buildQueries, + UNSET_PROFILE, + type FestipodDataContextValue, + type EventOwnership, +} from './festipodDataBase'; + +/** Waits between attempts at resolving an owned-document set (see the effects). */ +const OWNED_RETRY_BACKOFF_MS = [500, 1500, 4000]; + +// ============================================================================ +// NextGraph-connected provider — reads directly from NG wallet +// ============================================================================ + +function useNgData(): FestipodDataContextValue { + const { session } = useNextGraph(); + // The app speaks ONLY in logical scopes — it holds no store id and builds no + // `did:ng:${…}` NURI. It creates ONE document PER ENTITY in its scope + // (`createEntityDoc(scope)`, the SDK create) and READS via the SDK's reactive, + // `useQuery`-shaped surface `watchShape(shape, scope)` (bound to React by + // `useShapeQuery`). The observable resolves the scope to the current identity's + // wallet (its own scope docs + discovery for public), awaits the sync barrier, + // and pushes on every change — no bespoke re-query, no manual doc listing, no + // per-doc subscription in the app. See rule_app-uses-sdk-surface-only. + const ready = !!session; + + // --- REACTIVE READS via the SDK surface (`watchShape` bound with useShapeQuery) -- + // Three scoped shape reads, mapped to the app's domain types. Each is reactive + // (broker push, no polling): a locally-created entity, a seeded doc, or a remote + // peer's public event all re-render through the observable's own subscriptions. + // • events = public (my own public event docs + discovery index) + // • profiles/users = protected (my own) + // • participations = protected (my own; cap-filtered by the SDK) + const eventQuery = useShapeQuery(FpEventShapeType, 'public'); + const userQuery = useShapeQuery(FpUserProfileShapeType, 'protected'); + const partQuery = useShapeQuery(FpParticipationShapeType, 'protected'); + // The RAW reactive sets straight from `watchShape` (before the optimistic + // overlay). The exposed `events`/`users`/`participations` merge these with the + // pending overlay below — see the "OPTIMISTIC OVERLAY" block. + const reactiveUsers = React.useMemo(() => adaptUsers(userQuery.data), [userQuery.data]); + const reactiveEvents = React.useMemo(() => adaptEvents(eventQuery.data), [eventQuery.data]); + const reactiveParticipations = React.useMemo( + () => adaptParticipations(partQuery.data), + [partQuery.data], + ); + + // --- OPTIMISTIC OVERLAY (standard useQuery-mutation pattern; NO polling) ------ + // `watchShape` only surfaces a doc AFTER the broker push (a real latency), so a + // mutation's effect is invisible in the reactive set for a beat. To keep the + // screens' local-first feel, each mutation reflects its effect IMMEDIATELY in a + // pure app-side React overlay laid OVER the SDK surface (never touching the SDK + // internals — see rule_app-uses-sdk-surface-only), then RECONCILED by the push: + // • `pendingAddEvents` / `pendingAddParticipations` — entities created + // optimistically, keyed by their `@id` (= the doc NURI `writeEntity` returns, + // the SAME id `watchShape` surfaces as `s.subject` → they match exactly). + // • `pendingRemoveIds` — participation ids removed optimistically (leave). + // The exposed set = merge(reactive, pendingAdds) minus pendingRemoves, DEDUPED by + // id. Reconciliation (effects below) auto-cleans the overlay the moment the + // reactive read catches up: an add whose id now appears in the reactive set is + // dropped from pendingAdds; a remove whose id is no longer in the reactive set is + // dropped from pendingRemoves. No re-query, no interval — the overlay only reacts + // to `watchShape`'s own pushes. + const [pendingAddEvents, setPendingAddEvents] = useState([]); + const [pendingAddUsers, setPendingAddUsers] = useState([]); + const [pendingAddParticipations, setPendingAddParticipations] = useState([]); + const [pendingRemoveIds, setPendingRemoveIds] = useState>(() => new Set()); + + const users = React.useMemo(() => { + if (pendingAddUsers.length === 0) return reactiveUsers; + const seen = new Set(reactiveUsers.map(u => u.id)); + const extra = pendingAddUsers.filter(u => !seen.has(u.id)); + return extra.length ? [...reactiveUsers, ...extra] : reactiveUsers; + }, [reactiveUsers, pendingAddUsers]); + + const events = React.useMemo(() => { + if (pendingAddEvents.length === 0) return reactiveEvents; + const seen = new Set(reactiveEvents.map(e => e.id)); + const extra = pendingAddEvents.filter(e => !seen.has(e.id)); + return extra.length ? [...reactiveEvents, ...extra] : reactiveEvents; + }, [reactiveEvents, pendingAddEvents]); + + const participations = React.useMemo(() => { + let base = reactiveParticipations; + if (pendingAddParticipations.length) { + const seen = new Set(base.map(p => p.id)); + const extra = pendingAddParticipations.filter(p => !seen.has(p.id)); + if (extra.length) base = [...base, ...extra]; + } + if (pendingRemoveIds.size) base = base.filter(p => !pendingRemoveIds.has(p.id)); + return base; + }, [reactiveParticipations, pendingAddParticipations, pendingRemoveIds]); + + // RECONCILIATION — drop each optimistic add once the reactive read carries its id + // (the push arrived), and each optimistic remove once the reactive read no longer + // carries its id (the SPARQL delete propagated). Guarded to no-op when there is + // nothing to reconcile, so a stable reactive set does not churn state. + useEffect(() => { + if (pendingAddEvents.length === 0) return; + const live = new Set(reactiveEvents.map(e => e.id)); + setPendingAddEvents(prev => { + const next = prev.filter(e => !live.has(e.id)); + return next.length === prev.length ? prev : next; + }); + }, [reactiveEvents, pendingAddEvents]); + + useEffect(() => { + if (pendingAddUsers.length === 0) return; + const live = new Set(reactiveUsers.map(u => u.id)); + setPendingAddUsers(prev => { + const next = prev.filter(u => !live.has(u.id)); + return next.length === prev.length ? prev : next; + }); + }, [reactiveUsers, pendingAddUsers]); + + useEffect(() => { + if (pendingAddParticipations.length === 0) return; + const live = new Set(reactiveParticipations.map(p => p.id)); + setPendingAddParticipations(prev => { + const next = prev.filter(p => !live.has(p.id)); + return next.length === prev.length ? prev : next; + }); + }, [reactiveParticipations, pendingAddParticipations]); + + useEffect(() => { + if (pendingRemoveIds.size === 0) return; + const live = new Set(reactiveParticipations.map(p => p.id)); + setPendingRemoveIds(prev => { + let changed = false; + const next = new Set(prev); + for (const id of prev) if (!live.has(id)) { next.delete(id); changed = true; } + return changed ? next : prev; + }); + }, [reactiveParticipations, pendingRemoveIds]); + // The read is "settled" once every scope has reached its sync barrier + // (`isSuccess`). A synced-but-empty scope reads `isSuccess` with `data: []` — the + // distinction the auto-seed relies on to tell "still syncing" from "truly empty". + const readReady = + eventQuery.isSuccess && userQuery.isSuccess && partQuery.isSuccess; + + // Session-local map `${eventId}|${userId}` → the join deposit's uid, so a leave + // in the SAME session can carry `regUid` for a precise cancellation. Absent it + // (cross-session leave), the owner's materializer falls back to (event, user) + // matching — so this is an optimization, not a correctness dependency. + // + // Nothing ever clears this map, and nothing needs to: a browser context is one + // user for its whole life (`ensureIdentity()` answers once, before anything + // renders), so there is never anyone else's leftovers in it. + const joinUidsRef = useRef>(new Map()); + + // OPTION B — the set of event docs the CURRENT identity OWNS (its own public + // event docs). Each such NURI IS the event `@id` (writeEntity uses the doc NURI + // as the subject). The owner-materializer subscribes to each owned event's inbox + // and writes `participantCount` on THAT (owned) doc — never on someone else's. + const [ownedEventIds, setOwnedEventIds] = useState([]); + // Events the listing has RULED OUT — canonical ids that were already visible in + // the reactive read when a listing RESOLVED, and were absent from its result. + // ONLY these may be shown as "not yours". + // + // Why a set and not a "the listing answered" boolean: a listing is a SNAPSHOT, + // and it can only ever UNDER-report ownership, because a document created after + // it was taken cannot possibly be in it. So a miss is authoritative for what + // existed at the time and says NOTHING about anything that arrived since. With a + // boolean, a second tab on the same wallet that creates an event has it pushed + // here, misses the stale snapshot, and its owner is told the event is not theirs + // — permanently. That is the exact failure this whole answer exists to avoid. + const [ruledOutEventIds, setRuledOutEventIds] = useState>(new Set()); + // The owned set in canonical id-form — the form every event-id comparison here + // uses, so an event reached under a different overlay is still the same event. + const ownedCanonicalIds = React.useMemo( + () => new Set(ownedEventIds.map(canonicalEventId)), + [ownedEventIds], + ); + + // WHICH PROTECTED DOCUMENTS ARE MINE — the ground on which "my profile" rests. + // `null` means NOT ANSWERED YET (the listing has not landed, or it failed): + // "unknown" and "I own nothing" are indistinguishable as an empty array, and + // only one of them may lead to creating a profile. Nothing downstream may read + // `null` as an empty set. + const [myProtectedDocs, setMyProtectedDocs] = useState(null); + // The profile document THIS session created for the signed-in person. Set once, + // by the creation effect; it settles "which of my profile documents is mine" + // without looking at any field of any profile. + const [myProfileDocId, setMyProfileDocId] = useState(''); + + /** + * Fold documents THIS SESSION just created into the owned set. + * + * The backfill below runs once, on connection, so it only ever knows what + * existed at mount. Everything created afterwards has to announce itself, or it + * is owned in fact and unowned as far as this tree is concerned — and the + * owner-materializer, which only ever looks at this set, never runs on it. + */ + const claimOwnedEventDocs = useCallback((created: Nuri[]) => { + if (created.length === 0) return; + setOwnedEventIds(prev => [...new Set([...prev, ...created])]); + }, []); + + // Resolve the CURRENT identity's owned public event docs for the materializer + // ONLY (decoupled from the read — `watchShape` resolves reads itself). Bounded to + // this session's own documents (`listMyEntityDocs('public')` — "mine" needs no + // identity, the session is one user's). Runs on connection to backfill events + // owned before this mount; `createEvent` and the seed (`loadTestData` / + // auto-seed) claim what they create through `claimOwnedEventDocs`. This is NOT a + // read path (it feeds no `events`/`users`/`participations`), only the + // owner-count derivation. + // + // A REJECTION HERE MEANS "UNKNOWN", NEVER "THIS SESSION OWNS NOTHING". The two + // are indistinguishable downstream — both leave the set empty — but only one of + // them is true, and taking the wrong one silently disables the owner + // materializer, so every event this session hosts stops converging. So the + // failure is RETRIED, and if it still will not answer, it is said loudly instead + // of leaving a plausible-looking empty set behind. + // Read through refs inside the async listing below, so classification uses what + // is on screen when the listing RESOLVES rather than what was there when it + // started — the two differ by exactly the push that prompted a re-listing. + const eventsRef = useRef(events); + eventsRef.current = events; + const ownedCanonicalIdsRef = useRef(ownedCanonicalIds); + ownedCanonicalIdsRef.current = ownedCanonicalIds; + // Events the snapshot cannot speak about: neither owned nor ruled out. While + // this is non-empty the listing is re-taken — ONCE per new arrival, never on a + // timer ([[rule_no-broker-polling]]): a listing classifies everything visible at + // the moment it resolves, so this empties and the effect falls silent until a + // genuinely new event turns up. + const unclassifiedEventKey = React.useMemo(() => { + const pending = events + .map(e => canonicalEventId(e.id)) + .filter(c => !ownedCanonicalIds.has(c) && !ruledOutEventIds.has(c)); + return [...new Set(pending)].sort().join('|'); + }, [events, ownedCanonicalIds, ruledOutEventIds]); + const listingTaken = useRef(false); + useEffect(() => { + if (!ready) return; + // Nothing left to learn: a listing has been taken and every visible event is + // classified. Without this the effect would re-list on its own state change. + if (listingTaken.current && unclassifiedEventKey === '') return; + let cancelled = false; + (async () => { + for (let attempt = 0; !cancelled; attempt++) { + try { + const myPublic = await listMyEntityDocs('public'); + if (cancelled) return; + setOwnedEventIds(prev => [...new Set([...prev, ...myPublic])]); + listingTaken.current = true; + // The listing ANSWERED — so every event visible RIGHT NOW that it did + // not return is genuinely not this session's, and may be shown as such. + // Anything arriving after this point stays unclassified and triggers a + // fresh listing rather than inheriting this one's silence. Events already + // claimed as mine (created here, possibly racing this listing) are never + // ruled out. + // REBUILT, never accumulated: every listing RE-ADJUDICATES everything + // visible, so a later listing can overturn an earlier ruling instead of + // being outvoted by it. + // + // KNOWN RESIDUAL, and it needs the provider — not more code here. This + // infers "not yours" from ABSENCE, and absence is not authoritative: the + // reactive read and this listing are two different mechanisms, so an + // event can be on screen a moment before the listing can see it. Ruled + // out in that window, it is only re-examined if some OTHER unclassified + // event later triggers a listing. Self-healing would need either a timer + // (polling — forbidden) or the "may I write this?" call the surface does + // not publish. Per `rule_app-uses-sdk-surface-only` that is a GAP to + // raise, not one to paper over here, so it is left visible and stated. + const ownedNow = new Set(myPublic.map(canonicalEventId)); + setRuledOutEventIds(() => { + const next = new Set(); + for (const e of eventsRef.current) { + const c = canonicalEventId(e.id); + if (!ownedNow.has(c) && !ownedCanonicalIdsRef.current.has(c)) next.add(c); + } + return next; + }); + return; + } catch (err) { + const wait = OWNED_RETRY_BACKOFF_MS[attempt]; + if (wait === undefined) { + console.error( + `${logPrefix} owned-events resolution FAILED after ${OWNED_RETRY_BACKOFF_MS.length + 1} ` + + `attempts — the owned set is UNKNOWN, not empty, and events hosted by this session ` + + `will not converge until it is known:`, + err, + ); + return; + } + console.warn( + `${logPrefix} owned-events resolution failed (attempt ${attempt + 1}) — retrying in ${wait}ms:`, + err, + ); + await new Promise(r => setTimeout(r, wait)); + } + } + })(); + return () => { cancelled = true; }; + }, [ready, unclassifiedEventKey]); + + // Not in SHEX shapes yet + const [meetingPoints, setMeetingPoints] = useState([]); + const [friendships, setFriendships] = useState([]); + // Host-facing notifications, materialized from the current user's inboxes + // (the inbox read, T02.b/c). Data-level surfacing of "new participants". + const [notifications, setNotifications] = useState([]); + + const [selectedEventId, setSelectedEventId] = useState(''); + const [selectedUserId, setSelectedUserId] = useState(''); + + // Auto-select first event when data appears from NG + useEffect(() => { + const first = events[0]; + if (!selectedEventId && first) { + setSelectedEventId(first.id); + } + }, [events.length, selectedEventId]); + + // Auto-seed: bootstrap seed data into a genuinely EMPTY wallet. Gated on the + // SDK's `isSuccess` (readReady) — the sync barrier is reached for every scope — + // so an empty set means "synced and truly empty", NOT "still syncing". This + // replaces the old 3s chronometer heuristic (which guessed a sync delay and mis- + // fired a re-seed on the 3rd connect). `isPending` → wait; `isSuccess` + empty + // data → seed. Gated behind the `FESTIPOD_AUTO_SEED` env var (see utils/autoSeed): + // OFF BY DEFAULT so a fresh/persistent wallet is never silently bloated with demo + // data — the seed only runs when explicitly opted in. Explicit `loadTestData` + // (the test-data action + every @data test) is UNAFFECTED (it seeds directly). + // `hasTriedAutoSeed` keeps it single-shot (also suppressed by an explicit + // `loadTestData`). + // + // ALL OF THAT IS CURRENTLY MOOT: the fixture seed is switched OFF outright + // (`fixtureSeedEnabled`), so this effect returns on its first line and no + // fixture reaches the wallet. `bootstrapWallet` enforces the same switch, so + // the check here buys only silence — no misleading "bootstrapping…" log, and + // no work started that would write nothing. + const hasTriedAutoSeed = useRef(false); + useEffect(() => { + if (!fixtureSeedEnabled()) return; + if (!autoSeedEnabled()) return; + if (hasTriedAutoSeed.current) return; + if (!ready) return; + if (!readReady) return; // still syncing — do NOT mistake pending for empty + // MY OWN PROFILE IS NOT "DATA IN THE WALLET". It is created at sign-in on a + // brand-new wallet, so counting it here would permanently disable the seed on + // exactly the wallets it exists for. The question the gate asks is "does this + // wallet already hold something worth preserving", and my own empty profile + // does not. + const walletHasData = events.length > 0 || users.some(u => u.id !== myProfileDocId); + if (!shouldAutoSeed(walletHasData)) { + console.log(`${logPrefix} Auto-seed (FESTIPOD_AUTO_SEED): wallet already has data — skip`); + return; + } + // Enabled AND synced-empty → a real empty wallet. Seed once. + hasTriedAutoSeed.current = true; + console.log(`${logPrefix} Auto-seed (FESTIPOD_AUTO_SEED): wallet empty (synced), bootstrapping…`); + bootstrapWallet(false, createEntityDoc) + // The seed's public event docs were created BY THIS SESSION — they are mine. + .then(result => claimOwnedEventDocs(result.createdDocs.public)) + .catch(err => console.error(`${logPrefix} Auto-seed failed:`, err)); + // The reactive `watchShape` reads pick the seeded per-entity docs up on their + // own (each createEntityDoc appends to the scope index → the container-index + // subscription re-resolves → the new docs enter the read). No registerDoc/relist. + }, [ready, readReady, events.length, users, myProfileDocId, claimOwnedEventDocs]); + + // --- Derived --- + // WHO AM I — answered in TWO id spaces that must not be confused. + // + // (1) `currentPrincipal` — what signing in returned. Known as soon as the one + // identity await settles, i.e. before any document has been read. It names + // a PERSON, OPAQUELY: it is never parsed, never rendered as a name, never + // written into an entity and never handed to a data call. It exists here + // for display of "am I signed in" and for log attribution. + // (2) `currentUserId` — the app's own entity space: the `@id` of MY PROFILE + // DOCUMENT (a doc NURI). This is what a Participation's `fp:user` carries + // and what `resolveParticipantUser` matches directly, so it is the only + // value a mutation may write. + // + // THERE IS NO JOIN BETWEEN THE TWO, and there must not be one. A profile is + // Festipod's own object; the identity says nothing about it. **My profile is + // the profile document I OWN** — `listMyEntityDocs('protected')` answers "which + // documents are mine", and the UserProfile among them is mine. No field of any + // profile takes part in the answer: no username comparison, no normalization, + // no positional pick. + // + // THREE OUTCOMES, and "somebody else's profile" is not one of them: + // • exactly one owned profile → that is me; + // • none → I have no profile yet, and the creation effect below makes one + // (until it lands, `currentUserId` is '' and the mutations that need it + // REJECT — they never write an entity keyed on nobody); + // • several, none of them created by this session → genuinely AMBIGUOUS (the + // opt-in fixture seed writes its profiles into my own protected scope), so + // the answer is NO PROFILE, said loudly. Picking one would be picking a + // person at random and calling them "you". + const currentPrincipal = useCurrentPrincipal(); + /** The profiles I own: the reactive profiles whose document is one of mine. */ + const myOwnedProfiles = React.useMemo(() => { + if (myProtectedDocs === null) return null; // UNKNOWN — never "none" + const mine = new Set(myProtectedDocs); + return users.filter(u => mine.has(u.id)); + }, [users, myProtectedDocs]); + const currentUser = React.useMemo(() => { + // This session created it → no ambiguity possible, whatever else is owned. + if (myProfileDocId) return users.find(u => u.id === myProfileDocId); + if (myOwnedProfiles === null) return undefined; // not answered yet + if (myOwnedProfiles.length === 1) return myOwnedProfiles[0]; + if (myOwnedProfiles.length === 0) return undefined; // none — one gets created + // SEVERAL profile documents are mine, and none was created by this session: + // the fixture seed writes its profiles into my own protected scope, so a + // reloaded demo wallet lands here. Pick the first by document reference — + // stable across reloads, and arbitrary, which is honest: while the profile + // is not a built feature, "which of my fixtures am I" has no true answer. + // + // This is NOT the impersonation that was removed. That one reached for a + // profile by NAME and could land on a document belonging to somebody else. + // Every candidate here is a document I own, so the invariant that holds is + // the one that matters: the app never presents another person's profile as + // mine. Delete this branch the day a profile is really created and known. + return [...myOwnedProfiles].sort((a, b) => a.id.localeCompare(b.id))[0]; + }, [users, myProfileDocId, myOwnedProfiles]); + const currentUserId = currentUser?.id || ''; + // Identity-first log prefix, reused by every DATA log below (including the + // closures defined earlier in this function body — they only execute after + // this render has finished, by which point `logPrefix` is initialized). Several + // browser contexts can be tailed at once, so a line must say WHOSE it is: the + // profile NURI once it is read, and the signed-in principal before that — which + // is now from the very first render, instead of the anonymous `[app][data]`. + const logPrefix = currentUserId || currentPrincipal + ? `[${currentUserId || currentPrincipal}][app][data]` + : '[app][data]'; + const selectedEvent = events.find(e => e.id === selectedEventId); + // DISPLAY READ — log participantCount exactly as currently exposed for + // rendering. Compared against the owner-materializer's WRITE logs below, this + // pinpoints whether a stuck counter is a DATA problem (never incremented) or a + // DISPLAY/read problem (incremented but not re-read until the next session). + if (selectedEvent) { + console.log( + `${logPrefix} participation read for display — event=${canonicalEventId(selectedEvent.id)} ` + + `"${selectedEvent.title}": participantCount lu = ${selectedEvent.participantCount}`, + ); + } + const selectedUser = users.find(u => u.id === selectedUserId); + + // --- MY PROFILE: which documents are mine ---------------------------------- + // Resolve the PROTECTED documents this identity owns. That set is the whole + // basis of "which profile is mine", and it is also what decides whether a + // profile has to be CREATED — so a failure here must never look like an answer: + // a rejection means UNKNOWN, and reading it as "I own nothing" would create a + // second profile for someone who already has one. Retried; if it still will not + // answer, the set stays `null` (no profile resolved, no profile created) and it + // is said loudly. + useEffect(() => { + if (!ready) return; + let cancelled = false; + (async () => { + for (let attempt = 0; !cancelled; attempt++) { + try { + const mine = await listMyEntityDocs('protected'); + if (cancelled) return; + setMyProtectedDocs(prev => [...new Set([...(prev ?? []), ...mine])]); + return; + } catch (err) { + const wait = OWNED_RETRY_BACKOFF_MS[attempt]; + if (wait === undefined) { + console.error( + `${logPrefix} my-protected-documents resolution FAILED after ` + + `${OWNED_RETRY_BACKOFF_MS.length + 1} attempts — which profile is mine is UNKNOWN, ` + + `not absent: no profile will be resolved and none will be created until it is known:`, + err, + ); + return; + } + console.warn( + `${logPrefix} my-protected-documents resolution failed (attempt ${attempt + 1}) — ` + + `retrying in ${wait}ms:`, + err, + ); + await new Promise(r => setTimeout(r, wait)); + } + } + })(); + return () => { cancelled = true; }; + }, [ready]); + + // --- MY PROFILE: create one when I have none ------------------------------- + // A profile is Festipod's own object, and signing in produces none — so the + // first time a person signs in, the app makes theirs. Gated on BOTH the + // protected read having settled (`userQuery.isSuccess`: synced-and-empty, not + // still-syncing) and the owned-document set being KNOWN, because "I have no + // profile" is only true when both have answered. Single-shot per session; on + // failure the guard is released, so a later change to the owned set retries. + const hasTriedProfileCreate = useRef(false); + useEffect(() => { + if (!ready) return; + if (hasTriedProfileCreate.current) return; + if (!userQuery.isSuccess) return; + if (myOwnedProfiles === null) return; // UNKNOWN — never read as "none" + if (myOwnedProfiles.length > 0) return; // I already have one (or more) + hasTriedProfileCreate.current = true; + (async () => { + console.log(`${logPrefix} no profile of mine — creating one (fields left visibly unset)`); + const graph = await createEntityDoc('protected'); + // The three fields the UserProfile shape makes mandatory, written with the + // "not filled in yet" placeholders — nothing here comes from the identity. + await writeEntity(graph, ENTITY_TYPE.user, { + name: str(UNSET_PROFILE.name), + initials: str(UNSET_PROFILE.initials), + username: str(UNSET_PROFILE.username), + }); + // This document is MINE — claimed explicitly, so it is recognized as my + // profile whatever else the protected scope holds (fixtures included). + setMyProfileDocId(graph); + setMyProtectedDocs(prev => [...new Set([...(prev ?? []), graph])]); + // OPTIMISTIC OVERLAY, same pattern as events/participations: surface the + // profile immediately so `currentUserId` resolves without waiting for the + // broker push; the reconciliation effect drops it once the read carries it. + const optimisticProfile: FpUserData = { id: graph, ...UNSET_PROFILE }; + setPendingAddUsers(prev => (prev.some(u => u.id === graph) ? prev : [...prev, optimisticProfile])); + })().catch(err => { + hasTriedProfileCreate.current = false; + console.error(`${logPrefix} creating my profile FAILED — this session has no profile:`, err); + }); + }, [ready, userQuery.isSuccess, myOwnedProfiles]); + + // --- MY PROFILE: say it when the answer is arbitrary ------------------------ + // Several profile documents are mine and none was created by this session (the + // opt-in fixture seed writes its profiles into my own protected scope). One is + // picked deterministically so the app stays usable on a demo wallet, but the + // pick carries no meaning — say so once, or a fixture person silently becomes + // "you" and nobody wonders why. + const warnedAmbiguousProfile = useRef(false); + useEffect(() => { + if (myProfileDocId) return; + if (myOwnedProfiles === null || myOwnedProfiles.length <= 1) return; + if (warnedAmbiguousProfile.current) return; + warnedAmbiguousProfile.current = true; + console.warn( + `${logPrefix} ${myOwnedProfiles.length} profile documents are mine and none was created by ` + + `this session (a fixture seed run on this wallet is the usual cause). The first by document ` + + `reference is used as mine — a stable but ARBITRARY pick, so the name shown as yours is ` + + `demo data, not you.`, + ); + }, [myOwnedProfiles, myProfileDocId]); + + // --- OWNER MATERIALIZER (Option B, brief §B.2 + T02.c notifications) ------- + // The event OWNER's session materializes its OWN events' inbox deposits into + // (1) the correct `participantCount` on its OWN event doc, and + // (2) host-facing "new participant" FpNotifications. + // This is what makes the count CORRECT and reactive WITHOUT any non-owner ever + // writing the event doc: the joiner only deposits; the owner counts. + // + // Reactive, no polling: subscribe the inbox document via `inbox.watch` (a + // `doc_subscribe` push). One watch PER owned event: each event has its OWN + // inbox, whose address its owner obtains with `openDocumentInbox`. + // + // IDEMPOTENCE / CONVERGENCE: the count is DERIVED from the SET of distinct + // active registrations (`materializeAttendance`: distinct join uids MINUS + // cancelled ones), never an unbounded ±1. A broker re-sync replays the same + // deposits → same set → same count. `participantCount = activeRegistrations.size` + // (no host baseline — the creator does not auto-participate). The write is GUARDED + // (write only when the value actually changes) so re-materializing an unchanged + // inbox does not thrash the doc / loop the reactive read. + // + // OWNER-OFFLINE = EVENTUAL (brief §E.2): only the owner's session runs this, so + // while the owner is disconnected the count doesn't advance for others (the + // deposits persist in the inbox — nothing is lost; a future service will + // materialize on the owner's behalf). + // Dedup owned events by their CANONICAL id-form (base repo id, stripping any + // `:v:` suffix): the SAME event can enter `ownedEventIds` under two + // overlays (create-time vs a later `listMyEntityDocs` backfill), and iterating + // both would materialize + count-write the same event twice. Keep ONE real NURI + // per canonical id as the write/anchor target (the count is written on a live + // doc NURI — never a stripped id). See `canonicalEventId` in registration.ts. + const ownedEvents = React.useMemo(() => { + const byCanon = new Map(); + for (const nuri of ownedEventIds) { + const c = canonicalEventId(nuri); + if (!byCanon.has(c)) byCanon.set(c, nuri); + } + return [...byCanon.values()].sort(); + }, [ownedEventIds]); + // Primitive identity of the set above — the effect's dependency (an array is a + // new reference on every render). + const ownedKey = ownedEvents.join('|'); + + // WHICH EVENTS MAY THIS SESSION WRITE — the same owned set, asked the other way + // round. Nothing new is called: the contract makes writing a document and owning + // it the same thing, so `listMyEntityDocs('public')` (already resolved above for + // the materializer) is the whole answer. Matched on the CANONICAL id-form, like + // every other event-id comparison here, so an event reached under a different + // overlay is still recognized as the same event. + const getEventOwnership = useCallback((eventId: string): EventOwnership => { + const canon = canonicalEventId(eventId); + // A hit is authoritative EVEN BEFORE any listing has answered: an event this + // session just created was claimed directly, and it is mine whatever a + // listing later says. Checked FIRST, so ownership always beats a stale miss. + if (ownedCanonicalIds.has(canon)) return 'mine'; + // A miss counts only for events a resolved listing actually looked past. + if (ruledOutEventIds.has(canon)) return 'not-mine'; + // Otherwise the answer is genuinely still coming — a listing is pending, has + // failed, or this event arrived after the last one. Never 'not-mine' here. + return 'unknown'; + }, [ownedCanonicalIds, ruledOutEventIds]); + // Last count WRITTEN per owned event (keyed by canonical id), stamped with the + // cycle that wrote it. The stamp is what makes a STALE cycle harmless — see the + // guard below. + const materializedCountRef = useRef>(new Map()); + // Monotonic cycle number, shared by every materialize cycle of this session. It + // has to outlive the effect: when the owned set changes the effect re-runs, and + // a cycle started by the PREVIOUS run can still be in flight. + const cycleSeqRef = useRef(0); + useEffect(() => { + if (!ready) return; + const owned = ownedEvents; + if (owned.length === 0) return; + let cancelled = false; + + // ONE inbox address per owned event, resolved ONCE here and shared by the two + // things that must not disagree: the cycle that READS the inbox and the watch + // that SUBSCRIBES to it. Resolving them apart is how the owner ends up watching + // one address while a deposit lands in another — the sign-up is then never seen + // in the session that made it. `openDocumentInbox` is itself single-flight per + // document (utils/storeRegistry), so the app can no longer open a second inbox + // at all; this map is the narrower promise that the reader and the watcher hold + // the very same value. + // + // Resolved on first need and kept — but a FAILED resolution is released, not + // kept: it means the address is UNKNOWN, so the next trigger must really ask + // again instead of inheriting a permanent "no inbox". + let resolvingInboxes: Promise> | null = null; + const inboxesForOwnedEvents = (): Promise> => { + if (resolvingInboxes) return resolvingInboxes; + const attempt = (async () => { + const byEvent = new Map(); + for (const evId of owned) byEvent.set(evId, await openDocumentInbox(evId)); + return byEvent; + })().catch(err => { + if (resolvingInboxes === attempt) resolvingInboxes = null; + throw err; + }); + resolvingInboxes = attempt; + return attempt; + }; + + const runCycle = async (trigger: string) => { + if (cancelled) return; + const seq = ++cycleSeqRef.current; + try { + const inboxes = await inboxesForOwnedEvents(); + console.log( + `${logPrefix} owner participation materialize START (trigger=${trigger}, cycle=${seq}) — ` + + `${owned.length} owned event(s)`, + ); + const notifs: FpNotificationData[] = []; + // Each event has its OWN inbox, and only its OWNER can open it. The address + // comes from the shared map above — the same one the watch subscribed; a + // depositor never sees it (they name the document instead). + for (const [evId, targetInbox] of inboxes) { + const canonId = canonicalEventId(evId); + // BEFORE — the event's readable detail (short id + title) and the + // participantCount value as currently READ/exposed (the app-side `events` + // state), captured before this cycle's derive+write. Comparing this to the + // AFTER log below tells whether the counter is a DATA problem (never + // incremented) or a DISPLAY/read problem (incremented but not re-read). + // + // Read through the REF, never the closure: this effect only re-runs on + // [ready, ownedKey], so the `events` it captured is the snapshot from the + // render that wired the watch — which is why this line printed `(unknown)` + // for the whole session and told the last investigation nothing. Matched + // on the canonical id-form, like every other event-id comparison here. + const knownEvent = eventsRef.current.find(e => canonicalEventId(e.id) === canonId); + const knownCount = knownEvent?.participantCount; + console.log( + `${logPrefix} participation materialize — event=${canonId}` + + (knownEvent?.title ? ` "${knownEvent.title}"` : '') + + ` — participantCount before write (as currently read) = ${knownCount ?? '(unknown)'}`, + ); + // (1) COUNT — derive the distinct active-registration set for this event + // and write it on MY OWN event doc (only when it changed). `materializeAttendance` + // reads through the synced-view contract (`inbox.readSynced`), so a + // registrant's deposit is visible even on a cold session. + const active = await materializeAttendance(targetInbox, evId); + const nextCount = active.length; // no host baseline (creator not auto-in) + const written = materializedCountRef.current.get(canonId); + // THE VALUE A CYCLE CARRIES IS ONLY AS FRESH AS THE READ IT CAME FROM. + // Cycles of this effect can no longer interleave (they are serialized + // below), but the effect re-runs whenever the owned set changes, and a + // cycle from the previous run can still be in flight — holding a count it + // derived BEFORE the fresher one's. Writing it would put the stale value + // back on the document, which is a count that goes backwards for no + // visible reason. So a cycle may only overwrite what an OLDER cycle wrote. + if (written && written.seq > seq) { + console.log( + `${logPrefix} owner participation materialize — event=${canonId}: cycle ${seq} is STALE ` + + `(cycle ${written.seq} already wrote ${written.count}) — not writing ${nextCount}`, + ); + } else if (written?.count === nextCount) { + // Write ONLY when the derived value actually changes (anti-loop). This + // memo does NOT lock in a premature 0: the barrier-gated read above makes + // the first post-connection materialize see the real deposits, so once the + // set becomes non-empty the value differs and the correct count is + // written. A transient write failure reverts the memo so the next trigger + // retries. The guard's sole job is to avoid re-writing an UNCHANGED value. + console.log( + `${logPrefix} owner participation materialize — event=${canonId}: ` + + `participantCount unchanged (${nextCount}) — no write`, + ); + } else if (!cancelled) { + materializedCountRef.current.set(canonId, { count: nextCount, seq }); + console.log( + `${logPrefix} owner participation materialize — event=${canonId}: ` + + `participantCount ${written?.count ?? '(none)'} → ${nextCount} (writing own doc, cycle=${seq})`, + ); + // The write lands on the owned event doc, which `watchShape('public')` + // already subscribes → the reactive read re-renders the new count on + // the broker push (no manual re-query). + let writeOk = true; + await updateEntityField(evId, evId, 'participantCount', int(nextCount)) + .catch(err => { + // Revert the memo so a transient write failure retries next trigger + // — but only if it is still OURS. A fresher cycle's value stands. + writeOk = false; + const current = materializedCountRef.current.get(canonId); + if (current && current.seq === seq) materializedCountRef.current.delete(canonId); + console.error(`${logPrefix} owner participation materialize count WRITE FAILED:`, err); + }); + if (writeOk) { + // AFTER — the write has landed on the owner's own doc. N → M reuses the + // SAME "before" reference point logged above, so it is directly + // comparable: a DISPLAY-read log (see `selectedEvent`, above in this + // file) still showing the old N after this fires means the counter data + // is fine and it is the read side that lags. + console.log( + `${logPrefix} participation materialize — event=${canonId}: ` + + `participantCount AFTER write = ${knownCount ?? '(unknown)'} → ${nextCount}`, + ); + } + } + // (2) NOTIFICATIONS — surface "new participant" deposits (unchanged T02.c). + const evNotifs = await readRegistrationNotifications(targetInbox, evId); + notifs.push(...evNotifs); + } + if (!cancelled && notifs.length) { + setNotifications(prev => { + const seen = new Set(prev.map(n => n.id)); + const merged = [...prev]; + for (const n of notifs) if (!seen.has(n.id)) { seen.add(n.id); merged.push(n); } + return merged; + }); + } + } catch (err) { + console.error(`${logPrefix} owner participation materialization failed:`, err); + } + }; + + // A CYCLE IS A READ-DERIVE-WRITE, AND TWO OF THEM MUST NOT OVERLAP. The two + // triggers below fire within the same instant on the connection that creates an + // event, and run concurrently they both read the inbox before either writes — + // so the one that finishes last puts its own, older reading back on the + // document. Serialized, a request arriving mid-cycle is served by ONE follow-up + // cycle once the current one has finished (a cycle re-derives everything from + // the inbox, so one follow-up covers however many requests it coalesces). + const materialize = createSerialTask(runCycle); + + // (A) RELIABLE-AT-CONNECTION: run one materialization directly on this trigger + // ([ready, ownedKey]). This is the spec's core — the owner, at its NEXT + // CONNECTION, deterministically processes its owned events' inbox, reading + // through the synced-view contract. It does NOT depend on a cross-session + // inbox push arriving. + void materialize('connection').catch(err => + console.error(`${logPrefix} owner participation materialize cycle rejected:`, err)); + + // (B) SAME-SESSION LIVE: `inbox.watch` fires on the initial state push and on + // every later deposit visible to THIS verifier (a local deposit, or a remote one + // that pushes) — no polling. Re-materialize on each so the owner's own session + // stays live when a deposit does push. Cross-session convergence does NOT rely on + // this (it relies on (A) at the owner's next connection); this only sharpens the + // same-session/live case. One watch PER owned event — each event has its OWN + // inbox document — and the address watched is the one taken from the SHARED map + // above, so what is watched is exactly what the cycle reads. + const unsubscribes: Array<() => void> = []; + void (async () => { + const inboxes = await inboxesForOwnedEvents(); + if (cancelled) return; + for (const targetInbox of inboxes.values()) { + unsubscribes.push(inbox.watch(targetInbox, () => + void materialize('inbox-push').catch(err => + console.error(`${logPrefix} owner participation materialize cycle rejected:`, err)))); + } + })().catch(err => console.error(`${logPrefix} owner inbox watch wiring failed:`, err)); + return () => { cancelled = true; for (const stop of unsubscribes) stop(); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ready, ownedKey]); + + // Protected-sharing act: the app owns the relationship concept — it declares the + // current identity's own connections (a Festipod domain fact) and, for each + // bilateral link, hands the SDK directed read grants so an owner's connections + // may read that owner's PROTECTED entities (public = all; private = owner only). + // The declaration names only the current user's own peers, asserted as the + // current identity. The app carries no access CHECK (see knowledge_trust-model) + // — it only declares its own relationship graph, then trusts the SDK to enforce + // the resulting per-document grants. No store id, no document NURI crosses here. + useEffect(() => { + if (!ready || !currentUserId) return; + // `inbox.share(doc, toUser)` names a PERSON, so the app models both sides of + // a relationship in ONE key space: the normalized profile username. It maps + // each peer IRI → that key before declaring, and asserts AS its own key. + // Peers with no known profile are skipped (they cannot be named). + const idKeyOf = (userIri: string): string | undefined => { + const u = users.find(x => x.id === userIri); + return u?.username ? normalizeIdentifier(u.username) : undefined; + }; + const selfKey = idKeyOf(currentUserId); + if (!selfKey) return; + const myPeers = friendships + .filter(f => f.userId === currentUserId || f.friendId === currentUserId) + .map(f => (f.userId === currentUserId ? f.friendId : f.userId)) + .map(idKeyOf) + .filter((k): k is string => !!k); + // Sharing is I/O now (a key handed to each neighbour's inbox), so it is + // fire-and-forget from this effect — nothing downstream waits on it. + void declareConnections(myPeers, selfKey) + .catch(err => console.error(`${logPrefix} declareConnections failed:`, err)); + }, [ready, friendships, currentUserId, users]); + + const queries = buildQueries( + events, users, participations, meetingPoints, friendships, currentUserId, + ); + + console.log(`${logPrefix} Render — NG | events:`, events.length, + '| users:', users.length, '| participations:', participations.length, + '| selectedEvent:', selectedEvent?.title ?? '(none)'); + + // --- Mutations (NG) --- + // Each entity is written as its OWN document, created via the SDK in its scope + // (`createEntityDoc(scope)`) — never a store-level document. Creating a doc + // appends its NURI to the scope index, which `watchShape` subscribes; the new + // entity enters the reactive read on the resulting push (no manual registration). + // The SDK declares the per-document ReadCap policy on create (public / protected / + // private) — the app carries no access logic. + + const createEvent = useCallback(async (event: Omit): Promise => { + console.log(`${logPrefix} createEvent (NG):`, event.title); + // The SDK create returns THIS entity's OWN public document and declares its + // access policy (public → world-readable). Placement is named by SCOPE + // alone: the session belongs to one user, so there is no owner to name. + // A creation that cannot be recorded THROWS — it never hands back a + // reference that would read empty forever — so this rejects the whole + // `createEvent` rather than returning a half-made event. + // Create the event's OWN document in the PUBLIC scope (one doc per entity), + // then WRITE the event RDF DIRECTLY into that document (writeEntity) — not via + // the scope-coupled `ngSet.add`, which can't write into a not-yet-subscribed + // per-entity doc against the real broker. The doc's NURI is appended to the + // public scope index, which `watchShape('public')` subscribes → the event + // enters the reactive read on the push. The written subject IRI is the `@id`. + const eventGraph = await createEntityDoc('public'); + // An event is a document people SIGN UP TO, so its owner opens its inbox + // here, at creation. A document only HAS one if its owner opened it; without + // this, a registrant's deposit would have nothing to reach. + await openDocumentInbox(eventGraph); + const eventId = await writeEntity(eventGraph, ENTITY_TYPE.event, { + title: str(event.title), description: str(event.description), date: str(event.date), + // `date` is the human LABEL the form composed; these four are the machine + // values it collected, written UNALTERED as the form's own ISO forms + // (`YYYY-MM-DD`, `HH:MM`). They are stored alongside the label, never + // derived from it — the label is display text and cannot be parsed back. + startDate: str(event.startDate), endDate: str(event.endDate), + startTime: str(event.startTime), endTime: str(event.endTime), + location: str(event.location), distance: flt(event.distance), + // No host notion: the creator merely SIGNALS a public event and is NOT + // obliged to participate, so the count starts at 0 (the owner-materializer + // derives it from the active-registration set — |active|, no host baseline). + participantCount: int(event.participantCount || 0), + coverImage: str(event.coverImage), + }); + // OPTION B: this event's doc is MINE (I just created it), so track it as owned + // → the owner-materializer subscribes to its inbox and maintains its count. + setOwnedEventIds(prev => (prev.includes(eventGraph) ? prev : [...prev, eventGraph])); + // OPTIMISTIC OVERLAY: surface the created event IMMEDIATELY (its id = eventId = + // the doc NURI, the SAME id `watchShape('public')` will surface as `s.subject`, + // so the reconciliation effect drops this add once the push arrives). Without + // it, `getEvent(eventId)` right after create returns undefined until the push. + // participantCount starts at 0 (creator does not auto-participate). + const optimisticEvent: FpEventData = { ...event, id: eventId, participantCount: event.participantCount || 0 }; + setPendingAddEvents(prev => (prev.some(e => e.id === eventId) ? prev : [...prev, optimisticEvent])); + // The creator does NOT auto-participate (no host notion — settled product + // decision): NO participation is written on create. The creator sees "J'y + // serai" and may join/leave their own event like anyone else. + if (currentUserId) setSelectedEventId(eventId); + const addedEvent = { "@id": eventId, title: event.title }; + // Make the PUBLIC event discoverable: submit its reference to the SDK global + // discovery index (an SDK act — the app holds no index/store id). The SDK + // enforces public-only: passing the event's own document lets it refuse a + // non-public doc. Best-effort: a failed submission must not roll back a + // successful event creation. + if (addedEvent) { + // NO index submission any more. There is no discovery: a reader reaches a + // document only by FOLLOWING A LINK it was given. Announcing a public event + // beyond its creator is therefore a Festipod-level concern (a directory + // document whose link the app knows), not an SDK call — see the directory + // work. Until it lands, a created event is reachable by its creator only. + } + return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` }; + }, [currentUserId]); + + const updateEvent = useCallback(async (id: string, updates: Partial) => { + console.log(`${logPrefix} updateEvent (NG):`, id, updates); + // The event's `@id` IS its own document NURI (one entity = one document), so + // it is both the write graph and the subject. Persist each provided mutable + // field DIRECTLY via SPARQL (the durable write); `watchShape` re-reads on the + // resulting broker push (the doc is already subscribed) — no manual re-query. + // No pre-flight NURI guard: every SDK entry takes the reference as it stands + // and validates at its own door. + const graph = id; + const persists: Promise[] = []; + if (updates.participantCount !== undefined) { + persists.push(updateEntityField(graph, id, 'participantCount', int(updates.participantCount))); + } + if (updates.title !== undefined) persists.push(updateEntityField(graph, id, 'title', str(updates.title))); + if (updates.description !== undefined) persists.push(updateEntityField(graph, id, 'description', str(updates.description))); + if (updates.date !== undefined) persists.push(updateEntityField(graph, id, 'date', str(updates.date))); + // The machine dates/times travel with the label, not instead of it. A field + // arriving as `''` (the form's empty date input) CLEARS the triple rather than + // leaving a stale value behind — that is `updateEntityField`'s empty handling, + // and it is the right reading here: the user emptied the input. + if (updates.startDate !== undefined) persists.push(updateEntityField(graph, id, 'startDate', str(updates.startDate))); + if (updates.endDate !== undefined) persists.push(updateEntityField(graph, id, 'endDate', str(updates.endDate))); + if (updates.startTime !== undefined) persists.push(updateEntityField(graph, id, 'startTime', str(updates.startTime))); + if (updates.endTime !== undefined) persists.push(updateEntityField(graph, id, 'endTime', str(updates.endTime))); + if (updates.location !== undefined) persists.push(updateEntityField(graph, id, 'location', str(updates.location))); + if (updates.distance !== undefined) persists.push(updateEntityField(graph, id, 'distance', flt(updates.distance))); + await Promise.all(persists).catch(err => console.error(`${logPrefix} persist event update failed:`, err)); + }, []); + + const joinMeetingPoint = useCallback(async (eventId: string, userId?: string) => { + const uid = userId || currentUserId; + console.log(`${logPrefix} joinMeetingPoint (NG):`, eventId, 'user:', uid); + // A Participation MUST carry a user reference (SHEX `fp:user` is mandatory) — + // writing one without it produces an entity the read drops (the participation + // silently never round-trips). REJECT rather than return: returning here made + // the sign-up a no-op that wrote nothing, threw nothing and let the screen + // congratulate the user. The cause is named, because it is actionable: no + // profile of mine is resolved yet. + if (!uid) { + const msg = + `joinMeetingPoint refused for event=${canonicalEventId(eventId)}: no profile of mine is resolved, ` + + `so a Participation would carry no fp:user and would never round-trip. ` + + `Wait for the profile to be created/read, or resolve the ambiguity reported above.`; + console.error(`${logPrefix} ${msg}`); + throw new Error(msg); + } + // IDEMPOTENCE — check AUTHORITATIVELY against the broker, not the reactive set. + // The reactive participation set can lag a just-written participation, so a + // second join checking only the set would write a DUPLICATE (breaking "exactly + // one participation"). The broker query sees the real state regardless of lag. + // + // A FAILED count is UNKNOWN, not zero, so it is NOT caught here: reading it as + // "not participating yet" is precisely how a duplicate gets written. The + // rejection propagates out of `joinMeetingPoint`, and the screen that called it says + // the sign-up could not be recorded — which is the truth. + const already = await countUserParticipations(eventId, uid); + if (already > 0) { + console.log(`${logPrefix} Already participating (broker-confirmed), skipping`); + return; + } + // 1) Persist the Participation as its OWN document in the PROTECTED scope + // (one doc per entity). Its NURI is appended to the protected scope index, + // which `watchShape('protected')` subscribes → the participation enters + // the reactive read on the push. + // A creation that cannot be recorded THROWS, so a failure here rejects + // `joinMeetingPoint` instead of depositing a registration for a participation + // document that would read empty forever. + const partGraph = await createEntityDoc('protected'); + // WRITE the participation RDF DIRECTLY into its own document (writeEntity) — + // not via the scope-coupled `ngSet.add` (can't write a not-yet-subscribed + // per-entity doc against the real broker). The written subject is the + // participation's `@id` (its own graph is partGraph, used later by the + // authoritative delete). + await writeEntity(partGraph, ENTITY_TYPE.participation, { + event: iri(eventId), user: iri(uid), isConfirmed: bool(true), + }); + // OPTIMISTIC OVERLAY: surface the participation IMMEDIATELY so the joiner shows + // up in the list and `isParticipating` is true before the broker push. Its id = + // partGraph = the doc NURI, the SAME id `watchShape('protected')` will surface + // as `s.subject` (adaptParticipations sets `id: s.subject`) → the reconciliation + // effect drops this add once the push arrives. If a stale pendingRemove targeted + // this exact id (re-join same doc — never happens, ids are fresh), clear it too. + const optimisticPart: FpParticipationData = { id: partGraph, eventId, userId: uid, isConfirmed: true }; + setPendingAddParticipations(prev => (prev.some(p => p.id === partGraph) ? prev : [...prev, optimisticPart])); + // OPTION B (brief §B): the joiner does NOT write `participantCount` on the + // EVENT doc — that doc belongs to the OWNER, and a non-owner write there is an + // isolation violation (NextGraph write is membership-bound; there is no append + // — see brief §1). The count now moves only via the OWNER materializing this + // deposit onto its OWN event doc (the `hostedEventIds` materializer below). + // The joiner's sole writes are: their OWN participation doc (above) + the + // inbox DEPOSIT (below). While the owner is offline the count doesn't advance + // for others — accepted eventual behaviour (brief §E.2); nothing is lost. + // 2) Tell the host. THE DEPOSIT IS THE DELIVERY: `inbox.postToDocument(doc, …)` + // is what the surface publishes for reaching a document's owner — anyone + // may deposit, only the owner reads. The host's notification is then built + // on the OWNER's side, out of the deposits it reads from its own event's + // inbox (`readRegistrationNotifications`, in the materializer above). + // + // Nothing else is written here. A host FpNotification used to be minted at + // this point into the JOINER's own protected scope with `recipient` set to + // the event — a document the host cannot read, and never will: the joiner + // owns it and the surface has no way to hand it over. It also pushed that + // notification into THIS session's own list, so the joiner saw a + // "new participant" notice addressed to someone else. Both are gone; the + // deposit alone carries the news, and it reaches its reader. + // + // Best-effort: a failed deposit must not roll back a successful + // registration (the participation document is already written). + try { + const registrantId = uid || null; // no current user → anonymous deposit + // The event's `@id` IS its document NURI, and a deposit NAMES that document + // — the joiner resolves no inbox and holds no address. + // Carry the joiner's participation-doc NURI so the owner (if a connection) + // could read it in clear; the count itself does not depend on reading it. + console.log( + `${logPrefix} joinMeetingPoint — depositing participation registration into event inbox: ` + + `event=${canonicalEventId(eventId)} user=${uid} (count now moves via the OWNER ` + + `materializing this deposit on its own doc, at its next connection)`, + ); + const { uid: depositUid } = await depositRegistration(eventId, registrantId, partGraph); + // Remember the join uid so a same-session leave can cancel it precisely. + joinUidsRef.current.set(`${eventId}|${uid}`, depositUid); + } catch (err) { + console.error(`${logPrefix} joinMeetingPoint inbox deposit failed:`, err); + } + }, [events, currentUserId]); + + const leaveMeetingPoint = useCallback(async (eventId: string, userId?: string) => { + const uid = userId || currentUserId; + console.log(`${logPrefix} leaveMeetingPoint (NG):`, eventId, 'user:', uid); + // Find the participation in the union-read set. Each participation is its OWN + // document (writeEntity uses the doc NURI as the subject), so `part.id` is BOTH + // the subject IRI AND the graph NURI it lives in. + const part = participations.find(p => p.eventId === eventId && p.userId === uid); + // REJECT rather than pretend. Withdrawal is AUTHORITATIVE (see + // caveat_participation-deletion): the caller only reaches here because it + // believes a participation exists, so finding none is a real disagreement + // about the state — either no profile of mine is resolved (`uid` empty), or + // the participation the screen showed is not in the set. Returning silently + // deleted nothing while the screen announced a withdrawal, and the sign-up + // came back on the next read. + if (!part) { + const msg = uid + ? `leaveMeetingPoint refused for event=${canonicalEventId(eventId)}: no participation of user=${uid} ` + + `in the participation set — nothing was deleted, so the withdrawal must not be announced.` + : `leaveMeetingPoint refused for event=${canonicalEventId(eventId)}: no profile of mine is resolved, ` + + `so the participation to withdraw cannot even be named.`; + console.error(`${logPrefix} ${msg}`); + throw new Error(msg); + } + // DÉSINSCRIPTION FIX (caveat_participation-deletion): the AUTHORITATIVE deletion + // is a SPARQL DELETE via the real injected `ng` (docs.sparqlUpdate), which + // removes the Participation server-side so it does NOT resurrect after re-sync. + // The delete targets the participation's own document (part.id) and is + // identified by its OWN subject IRI (part.id), not a string-match on object IRIs. + const graphNuri = part.id; + const subjectIri = part.id; + let result; + try { + result = await deleteParticipation(graphNuri, eventId, uid, subjectIri); + } catch (err) { + console.error(`${logPrefix} SPARQL DELETE participation failed:`, err); + throw err instanceof Error ? err : new Error(String(err)); + } + // AUTHORITATIVE verification: only proceed once the broker RE-QUERY confirms + // the participation is gone (remaining === 0). If the delete matched nothing, + // surface it rather than falsely flip the UI (it would resurrect on re-sync). + if (result.remaining > 0) { + const msg = `${logPrefix} leaveMeetingPoint: SPARQL delete removed nothing ` + + `(before=${result.before}, remaining=${result.remaining}, bySubject=${result.bySubject}) ` + + `for event=${eventId} user=${uid} — NOT flipping UI (would resurrect).`; + console.error(msg); + throw new Error(msg); + } + // OPTIMISTIC OVERLAY: the delete is broker-confirmed (remaining === 0) but the + // reactive `watchShape('protected')` set only drops the participation on the + // NEXT push (a beat later). Mark its id removed NOW so the leaver disappears + // from the list immediately; the reconciliation effect clears this pendingRemove + // once the reactive set no longer carries the id. Also drop it from + // pendingAddParticipations in case it was still only-optimistic (join+leave in + // the same session before the join's push landed). + setPendingRemoveIds(prev => { + if (prev.has(part.id)) return prev; + const next = new Set(prev); + next.add(part.id); + return next; + }); + setPendingAddParticipations(prev => { + const next = prev.filter(p => p.id !== part.id); + return next.length === prev.length ? prev : next; + }); + // OPTION B, symmetric (brief §B "Désinscription"): the leaver does NOT write + // `participantCount` on the EVENT doc (owner-owned — same isolation violation + // as the join). Instead it DEPOSITS a `leave-participant` marker into the + // event inbox; the OWNER materializes it and recomputes the count on its OWN + // doc (idempotent — a re-synced leave never double-decrements, since the count + // is derived from the SET of distinct active registrations, not from −1). + try { + const registrantId = uid || null; + // Same as `joinMeetingPoint`: the leave marker NAMES the event document; no inbox + // address is resolved on the depositor's side. + // Carry the join uid when this session minted it (precise cancellation); + // otherwise the owner falls back to (eventId, userId) matching. + const regUid = joinUidsRef.current.get(`${eventId}|${uid}`); + console.log( + `${logPrefix} leaveMeetingPoint — depositing participation leave marker into event inbox: ` + + `event=${canonicalEventId(eventId)} user=${uid} regUid=${regUid ?? '(none)'}`, + ); + await depositLeave(eventId, registrantId, regUid); + joinUidsRef.current.delete(`${eventId}|${uid}`); + } catch (err) { + console.error(`${logPrefix} leaveMeetingPoint inbox deposit failed:`, err); + } + // The participation doc is subscribed by `watchShape('protected')`; the SPARQL + // DELETE pushes → the reactive read drops it (`isParticipating` reflects it). + // The count itself follows the owner's materialization of the leave marker + // (reactive, cross-session). + }, [participations, events, currentUserId]); + + const addMeetingPoint = useCallback((mp: Omit) => { + setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]); + }, []); + + const addFriend = useCallback((friendId: string) => { + setFriendships(prev => { + if (prev.some(f => + (f.userId === currentUserId && f.friendId === friendId) || + (f.userId === friendId && f.friendId === currentUserId) + )) return prev; + return [...prev, { id: `ng-fr-${Date.now()}`, userId: currentUserId, friendId }]; + }); + }, [currentUserId]); + + const updateProfile = useCallback(async (updates: Partial) => { + console.log(`${logPrefix} updateProfile (NG):`, updates); + // MY profile is its own document (subject IRI = doc NURI), and it is the ONLY + // document these edits may land in. There used to be a `?? users[0]` fallback + // here: with no profile resolved, a person editing their own pseudo wrote it + // into a stranger's profile document. Refuse instead — and say which of the + // two reasons it is, since only one of them clears up on its own. + const target = currentUser; + if (!target) { + const msg = + `updateProfile refused: no profile of mine is resolved, and these edits must never land in ` + + `someone else's profile document.`; + console.error(`${logPrefix} ${msg}`); + throw new Error(msg); + } + const graph = target.id; + const persists: Promise[] = []; + if (updates.name !== undefined) persists.push(updateEntityField(graph, graph, 'name', str(updates.name))); + if (updates.initials !== undefined) persists.push(updateEntityField(graph, graph, 'initials', str(updates.initials))); + if (updates.username !== undefined) persists.push(updateEntityField(graph, graph, 'username', str(updates.username))); + if (updates.role !== undefined) persists.push(updateEntityField(graph, graph, 'role', str(updates.role))); + if (updates.isPublic !== undefined) persists.push(updateEntityField(graph, graph, 'isPublic', bool(updates.isPublic))); + await Promise.all(persists).catch(err => console.error(`${logPrefix} persist profile update failed:`, err)); + }, [currentUser, users]); + + const loadTestData = useCallback(async (): Promise => { + console.log(`${logPrefix} loadTestData (NG)`); + // THIS NO LONGER SEEDS. `bootstrapWallet` refuses while the fixture seed is + // switched off (`fixtureSeedEnabled`), so what comes back is `seeded: false` + // with no documents, and everything below is a no-op on that empty result. + // The call is kept, and kept honest, rather than removed: the switch is what + // decides, in one place, and flipping it back restores this path unchanged. + // An EXPLICIT load is authoritative — SUPPRESS the dev auto-seed so only ONE + // seed runs (marking the guard at the START, before the awaited seed, closes + // the window where the auto-seed effect could also fire on a still-empty read). + hasTriedAutoSeed.current = true; + // Same reading as the auto-seed gate: my own profile is not "data in the + // wallet" — it exists on every wallet from the first sign-in, so counting it + // would make an explicit load a permanent no-op. + const walletHasData = events.length > 0 || users.some(u => u.id !== myProfileDocId); + const result = await bootstrapWallet(walletHasData, createEntityDoc); + // The seeded per-entity docs are appended to their scope indices, which + // `watchShape` subscribes → they enter the reactive reads on the push. No + // manual registration / re-list. + // OWNERSHIP is a different question from READING, and it is not answered by + // the push: these public event docs were created by THIS session, so claim + // them — otherwise the owner-materializer never opens their inboxes and their + // `participantCount` is never derived. + claimOwnedEventDocs(result.createdDocs.public); + // The seeded PROTECTED docs are deliberately NOT claimed as mine: they are + // FIXTURES (nobody signed in as them), and folding them into the owned-profile + // set is exactly what would make "which profile is mine" ambiguous. + return result; + }, [events.length, users, myProfileDocId, claimOwnedEventDocs]); + + return { + currentUserId, currentUser, currentPrincipal, + events, users, + participations, + meetingPoints, + friendships, + notifications, + selectedEventId, setSelectedEventId, selectedEvent, + selectedUserId, setSelectedUserId, selectedUser, + ...queries, + getEventOwnership, + createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint, + addMeetingPoint, addFriend, updateProfile, loadTestData, + }; +} + +// ============================================================================ +// Provider +// ============================================================================ + +export function NgDataProvider({ children }: { children: ReactNode }) { + const data = useNgData(); + return {children}; +} diff --git a/src/shared/context/refineData.tsx b/src/shared/context/refineData.tsx new file mode 100644 index 0000000..351a720 --- /dev/null +++ b/src/shared/context/refineData.tsx @@ -0,0 +1,501 @@ +/** + * RefineDataProvider — the ActivityPods data path, behind the Refine seam. + * + * MOUNTED ONLY WHEN THE DISPATCHER (`FestipodDataContext.tsx`) has both a + * registered Refine backend and an authenticated session to read through — + * the AuthGate above guarantees the session exists before this tree mounts. + * + * WHAT GOES THROUGH REFINE. Everything CRUD: the event list (`useList` over + * the `as:Event` container — the pod provider is own-pod scoped, so the list + * is what THIS user relayed; cross-pod discovery is the catalog's job, a + * follow-up behind this same seam), the meeting point create, the event and + * profile updates. Records are split into events vs meeting points BY THE + * BINDING (`isInstance`) — the two share one container and `as:context` + * presence is the only distinction. + * + * WHAT LIVES BESIDE REFINE. A participation is not a CRUD write and the + * refine-providers package implements no `custom` method: joining or leaving + * a meeting point POSTs an `as:Join`/`as:Leave` activity to the user's own + * outbox (`data/outbox.ts`), and the on-behalf backend maintains the + * `apods:attendees` collection. The collection travels inside the event + * record and is read by the participation binding (`membersOf`). A write made + * beside Refine does not refresh its cache, and the backend updates the + * collection asynchronously — so the join/leave reflects in an optimistic + * overlay here and the lists are invalidated for the write to land when it + * lands. + * + * THE COUNT AND THE ALICE/BOB RULE. The participant count of a meeting point + * is the collection's total — never the number of RESOLVED profiles, so it + * stays total for a reader who may read no profile. Resolving an attendee to + * a person is two fetches (actor document → profile) whose second is + * WAC-gated: a non-contact's refusal is absorbed in `data/actor.ts` and the + * screen renders a generic participant. `getUser` is a pure lookup over what + * resolved — undefined for everyone else. + */ + +import React, { useState, useCallback, useEffect, useMemo, type ReactNode } from 'react'; +import { useList, useOne, useCreate, useUpdate, useInvalidate } from '@refinedev/core'; +import type { FpEventData, FpUserData, FpParticipationData, FpMeetingPointData } from '../data/types'; +import { fetchActorDocument, fetchActorProfileRecord } from '../data/actor'; +import { postJoinActivity, postLeaveActivity } from '../data/outbox'; +import { useCurrentSession } from '../utils/currentSession'; +import { useCurrentPrincipal } from '../utils/currentPrincipal'; +import type { BootstrapResult } from '../utils/ngBootstrap'; +import { RefineProvider, useBackend } from '../refine/RefineProvider'; +import type { RefineBackend } from '../refine/providers'; +import { + FestipodDataContext, + buildQueries, + resolveParticipantUser, + nextId, + type FestipodDataContextValue, + type EventOwnership, +} from './festipodDataBase'; + +export function RefineDataProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function RefineDataInner({ children }: { children: ReactNode }) { + const backend = useBackend(); + if (!backend) { + // Unreachable through the dispatcher (a session implies a registered + // backend); render an inert value rather than a tree whose data hook + // throws — nothing here may look like an empty wallet either. + console.error('[refine] RefineDataProvider mounted with no registered backend'); + return {children}; + } + return {children}; +} + +function RefineDataMounted({ backend, children }: { backend: RefineBackend; children: ReactNode }) { + const data = useRefineData(backend); + return {children}; +} + +/** A do-nothing context value for the unreachable no-backend mount. */ +function inertDataValue(): FestipodDataContextValue { + const none = { + currentUserId: '', currentUser: undefined, currentPrincipal: '', + events: [], users: [], participations: [], meetingPoints: [], friendships: [], notifications: [], + selectedEventId: '', selectedEvent: undefined, selectedUserId: '', selectedUser: undefined, + getEvent: () => undefined, getUser: () => undefined, + getEventParticipants: () => [], getUserEvents: () => [], isParticipating: () => false, + getFriends: () => [], getEventMeetingPoints: () => [], getEventOwnership: (): EventOwnership => 'unknown', + setSelectedEventId: () => {}, setSelectedUserId: () => {}, + createEvent: async (event: Omit) => ({ ...event, id: nextId('event') }), + updateEvent: () => {}, joinMeetingPoint: () => {}, leaveMeetingPoint: () => {}, + addMeetingPoint: () => {}, addFriend: () => {}, updateProfile: () => {}, loadTestData: async () => emptyBootstrap(), + }; + return none; +} + +function emptyBootstrap(): BootstrapResult { + return { seeded: false, userIdMap: new Map(), eventIdMap: new Map(), createdDocs: { public: [], protected: [] } }; +} + +/** A membership overlay entry: what this session just joined or left. */ +type MembershipOverlay = Map; + +function useRefineData(backend: RefineBackend): FestipodDataContextValue { + const bindings = backend.bindings; + const session = useCurrentSession(); + 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. + const currentPrincipal = useCurrentPrincipal(); + + // --- READS over the shared as/Event container ------------------------------ + // ONE list for both entities: events and meeting points resolve the same + // container, so a single fetch serves both and the bindings split it. + // `enabled: !!session` — pre-session this path renders empty rather than + // firing unauthenticated queries. + const containerQuery = useList({ + resource: bindings.event.resource, + queryOptions: { enabled: !!session, retry: false }, + }); + const rawRecords: unknown[] = useMemo( + () => (containerQuery.result.data ?? []) as unknown[], + [containerQuery.result.data], + ); + const containerFailed = containerQuery.query.isError; + + // --- SPLIT + PARTICIPATION/COUNT DERIVATION (vocabulary lives in bindings) -- + const derived = useMemo(() => { + const events: FpEventData[] = []; + const meetingPoints: FpMeetingPointData[] = []; + // participations keyed BY MEETING POINT id: joining targets the meeting + // point; the anchor event's participants are derived as the union across + // its meeting points (below). + const participations: FpParticipationData[] = []; + const totals = new Map(); + for (const record of rawRecords) { + if (bindings.meetingPoint.isInstance?.(record) ?? false) { + const mp = bindings.meetingPoint.toDomain(record); + meetingPoints.push(mp); + const attendees = bindings.participation.membersOf?.(record); + const members = attendees?.members ?? []; + for (const member of members) { + const mapped = bindings.participation.toDomain(member); + participations.push({ ...mapped, id: `${mp.id}|${mapped.userId}`, eventId: mp.id }); + } + totals.set(mp.id, attendees?.total ?? members.length); + } else if (bindings.event.isInstance?.(record) ?? true) { + events.push(bindings.event.toDomain(record)); + } + } + // The event's count is the SUM of its meeting points' collection totals — + // derived at read time, never a stored counter. A record whose collection + // could not be read contributes 0, which is the honest "unknown" reading + // of a total nobody could count for us. + for (const event of events) { + event.participantCount = meetingPoints + .filter(mp => mp.eventId === event.id) + .reduce((sum, mp) => sum + (totals.get(mp.id) ?? 0), 0); + } + return { events, meetingPoints, participations, totals }; + }, [rawRecords, bindings]); + + // --- OPTIMISTIC MEMBERSHIP OVERLAY ------------------------------------------ + // `watchShape`-style latency, Refine edition: the outbox post returns before + // the backend has maintained the collection, and the container re-fetch can + // still precede it. The overlay reflects the join/leave immediately and + // reconciles away once the read carries the truth — no polling. + const [overlay, setOverlay] = useState(new Map()); + + const participations = useMemo(() => { + if (overlay.size === 0 || !webId) return derived.participations; + const byMp = new Map(); + for (const p of derived.participations) { + const list = byMp.get(p.eventId) ?? []; + list.push(p); + byMp.set(p.eventId, list); + } + for (const [mpId, op] of overlay) { + const current = byMp.get(mpId) ?? []; + if (op === 'join') { + if (!current.some(p => p.userId === webId)) { + const mp = derived.meetingPoints.find(m => m.id === mpId); + byMp.set(mpId, [...current, { id: `${mpId}|${webId}`, eventId: mpId, userId: webId, isConfirmed: true }]); + } + } else { + byMp.set(mpId, current.filter(p => p.userId !== webId)); + } + } + return [...byMp.values()].flat(); + }, [derived, overlay, webId]); + + // RECONCILIATION — drop each overlay entry once the container read carries + // what it asserts (join: membership present; leave: membership absent). + useEffect(() => { + if (overlay.size === 0 || !webId) return; + setOverlay(prev => { + let changed = false; + const next = new Map(prev); + for (const [mpId, op] of prev) { + const present = derived.participations.some(p => p.eventId === mpId && p.userId === webId); + if ((op === 'join' && present) || (op === 'leave' && !present)) { + next.delete(mpId); + changed = true; + } + } + return changed ? next : prev; + }); + }, [derived.participations, overlay, webId]); + + // --- MY PROFILE (the app's own entity id space) ------------------------------ + // The actor (WebID) document links the `as:Profile` record via `url`; the + // profile read is a plain getOne on that URI. No profile link (or an + // unreadable profile) → no currentUser: `updateProfile` refuses loudly + // rather than landing edits on a stranger's document. + const [profileUri, setProfileUri] = useState(''); + useEffect(() => { + if (!webId || !token) return; + let cancelled = false; + fetchActorDocument(webId, token) + .then(actor => { + if (!cancelled) setProfileUri(actor.profileUri ?? ''); + }) + .catch(err => console.error('[refine] actor document read failed:', err)); + return () => { cancelled = true; }; + }, [webId, token]); + + const myProfileQuery = useOne({ + resource: bindings.profile.resource, + id: profileUri, + queryOptions: { enabled: !!profileUri && !!session, retry: false }, + }); + const myProfile: FpUserData | undefined = useMemo(() => { + const record: unknown = myProfileQuery.result; + return record !== undefined ? bindings.profile.toDomain(record) : undefined; + }, [myProfileQuery.result, bindings]); + const currentUser = myProfile; + const currentUserId = myProfile?.id ?? ''; + + // --- PARTICIPANT RESOLUTION (the Alice/Bob rule) ------------------------------ + // Attendee refs are actor URIs; resolving them to displayable profiles is + // two fetches whose second is WAC-gated. Refusals are absorbed in + // `data/actor.ts` (undefined) — a non-contact renders as a generic + // participant and the TOTAL above stays the collection's count. + const [resolvedProfiles, setResolvedProfiles] = useState([]); + const [unresolved, setUnresolved] = useState>(new Set()); + const attendeeIds = useMemo( + () => [...new Set(participations.map(p => p.userId))].filter(id => id !== webId), + [participations, webId], + ); + useEffect(() => { + if (!token) return; + const missing = attendeeIds.filter(id => !unresolved.has(id) && !resolvedProfiles.some(u => u.id === id)); + if (missing.length === 0) return; + let cancelled = false; + void (async () => { + const fresh: FpUserData[] = []; + const refused: string[] = []; + for (const id of missing) { + const record = await fetchActorProfileRecord(id, token); + if (record === undefined) { + refused.push(id); + } else { + fresh.push(bindings.profile.toDomain(record)); + } + } + if (cancelled) return; + if (fresh.length) setResolvedProfiles(prev => [...prev, ...fresh]); + if (refused.length) setUnresolved(prev => new Set([...prev, ...refused])); + })(); + return () => { cancelled = true; }; + // resolvedProfiles is read to compute `missing` but not listed: listing it + // would re-run the effect on every resolution it itself performs. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [attendeeIds, unresolved, token, bindings]); + + const users = useMemo(() => { + const all = myProfile ? [myProfile, ...resolvedProfiles] : resolvedProfiles; + const seen = new Set(); + return all.filter(u => (seen.has(u.id) ? false : (seen.add(u.id), true))); + }, [myProfile, resolvedProfiles]); + + // --- Selection (same state contract as every provider) ----------------------- + const [selectedEventId, setSelectedEventId] = useState(''); + const [selectedUserId, setSelectedUserId] = useState(''); + + useEffect(() => { + const first = derived.events[0]; + if (!selectedEventId && first) setSelectedEventId(first.id); + }, [derived.events, selectedEventId]); + + // Identity-first log prefix — same shape as the other providers. + const logPrefix = currentUserId || currentPrincipal + ? `[${currentUserId || currentPrincipal}][app][data]` + : '[app][data]'; + + // --- Event-level derivations over meeting-point memberships ------------------- + const mpIdsOf = (eventId: string) => + derived.meetingPoints.filter(mp => mp.eventId === eventId).map(mp => mp.id); + + // Shared queries whose participation semantics already match (memberships are + // keyed by the joined target's id — here, the meeting point). `currentUserId` + // passed here is the MEMBERSHIP default key, which on this backend is the + // actor URI, not the profile URI. + const shared = buildQueries( + derived.events, + users, + participations, + derived.meetingPoints, + [], + webId ?? '', + ); + + const getEventParticipants = useCallback((eventId: string) => { + // The event's participants are the union across its meeting points' + // memberships, resolved to profiles. Unresolved attendees are NOT here — + // the count on the event stays the total. + const ids = new Set(); + for (const mpId of mpIdsOf(eventId)) { + for (const p of participations.filter(part => part.eventId === mpId)) { + const u = resolveParticipantUser(p.userId, users); + if (u) ids.add(u.id); + } + } + return users.filter(u => ids.has(u.id)); + }, [derived.meetingPoints, participations, users]); + + const getUserEvents = useCallback((userId: string) => { + // Answerable for the signed-in user only: memberships are what this + // session can read. Someone else's events-by-participation are not + // reachable through an own-pod-scoped read. + if (userId !== currentUserId || !webId) return []; + const joinedEventIds = new Set(); + for (const p of participations) { + if (p.userId !== webId) continue; + const mp = derived.meetingPoints.find(m => m.id === p.eventId); + if (mp) joinedEventIds.add(mp.eventId); + } + return derived.events.filter(e => joinedEventIds.has(e.id)); + }, [currentUserId, webId, participations, derived.meetingPoints, derived.events]); + + // --- Ownership: editing is owning ------------------------------------------------ + // The list is own-pod scoped, so membership in it IS ownership. A failed + // container read means the pod holds no event container — hence nothing + // owned: 'not-mine', said once. + useEffect(() => { + if (containerFailed) { + console.warn(`${logPrefix} events container unreadable (not provisioned, or grants missing) — treating as empty`); + } + }, [containerFailed, logPrefix]); + + const getEventOwnership = useCallback((eventId: string): EventOwnership => { + if (derived.events.some(e => e.id === eventId)) return 'mine'; + if (containerQuery.query.isPending) return 'unknown'; + return 'not-mine'; + }, [derived.events, containerQuery.query.isPending]); + + // --- Mutations through the data provider ------------------------------------------ + const { mutateAsync: createRecord } = useCreate(); + const { mutateAsync: updateRecord } = useUpdate(); + const invalidate = useInvalidate(); + + const createEvent = useCallback(async (event: Omit): Promise => { + console.log(`${logPrefix} createEvent (refine):`, event.title); + const response = await createRecord({ + resource: bindings.event.resource, + values: bindings.event.toValues(event), + }); + const created = bindings.event.toDomain(response.data); + void invalidate({ resource: bindings.event.resource, invalidates: ['list'] }); + if (currentUserId) setSelectedEventId(created.id); + return created; + }, [logPrefix, createRecord, invalidate, bindings, currentUserId]); + + const updateEvent = useCallback(async (id: string, updates: Partial) => { + console.log(`${logPrefix} updateEvent (refine):`, id, updates); + await updateRecord({ + resource: bindings.event.resource, + id, + values: bindings.event.toPatch(updates), + }); + void invalidate({ resource: bindings.event.resource, invalidates: ['list'] }); + }, [logPrefix, updateRecord, invalidate, bindings]); + + const addMeetingPoint = useCallback((mp: Omit) => { + console.log(`${logPrefix} addMeetingPoint (refine):`, mp.location); + // The host is the creator — this session. The screen navigates away right + // after; the optimistic mutation + invalidation lands the point on the + // detail screen when the write returns. + const values = bindings.meetingPoint.toValues({ ...mp, hostId: mp.hostId ?? webId }); + void createRecord({ resource: bindings.meetingPoint.resource, values }) + .then(() => invalidate({ resource: bindings.event.resource, invalidates: ['list'] })) + .catch(err => console.error(`${logPrefix} addMeetingPoint failed:`, err)); + }, [logPrefix, createRecord, invalidate, bindings, webId]); + + const updateProfile = useCallback(async (updates: Partial) => { + console.log(`${logPrefix} updateProfile (refine):`, updates); + if (!profileUri) { + const msg = 'updateProfile refused: no profile of mine is resolved (the actor document ' + + 'links none, or it is not readable) — these edits must never land in someone else\'s document.'; + console.error(`${logPrefix} ${msg}`); + throw new Error(msg); + } + await updateRecord({ + resource: bindings.profile.resource, + id: profileUri, + values: bindings.profile.toPatch(updates), + }); + void invalidate({ resource: bindings.profile.resource, invalidates: ['list', 'detail'] }); + }, [logPrefix, updateRecord, invalidate, bindings, profileUri]); + + // --- Join / leave through the outbox, beside Refine -------------------------------- + const joinMeetingPoint = useCallback(async (meetingPointId: string, userId?: string) => { + if (userId && userId !== webId) { + // Memberships are the user's own acts; the optional userId exists for + // parity with the other providers (and their harnesses). + console.warn(`${logPrefix} joinMeetingPoint for another user (${userId}) is not supported on this backend — ignored`); + return; + } + const mp = derived.meetingPoints.find(m => m.id === meetingPointId); + if (!mp || !session) { + throw new Error(`joinMeetingPoint refused: ${meetingPointId} is not in the read set ` + + '(or there is no session) — the write would target a reference this session cannot see.'); + } + console.log(`${logPrefix} joinMeetingPoint (refine):`, meetingPointId); + 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]); + + const leaveMeetingPoint = useCallback(async (meetingPointId: string, userId?: string) => { + if (userId && userId !== webId) { + console.warn(`${logPrefix} leaveMeetingPoint for another user (${userId}) is not supported on this backend — ignored`); + return; + } + const mp = derived.meetingPoints.find(m => m.id === meetingPointId); + if (!mp || !session) { + throw new Error(`leaveMeetingPoint refused: ${meetingPointId} is not in the read set ` + + '(or there is no session).'); + } + const member = participations.find(p => p.eventId === mp.id && p.userId === webId); + if (!member) { + const msg = `leaveMeetingPoint refused: no membership of mine on ${mp.id} — nothing was withdrawn.`; + console.error(`${logPrefix} ${msg}`); + throw new Error(msg); + } + console.log(`${logPrefix} leaveMeetingPoint (refine):`, meetingPointId); + 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]); + + // --- The rest of the contract ------------------------------------------------------- + const addFriend = useCallback((friendId: string) => { + // Friendship has no persistence on this backend (the seam declares four + // entities; friendship is deliberately not one of them). The contacts + // concept lives on the platform — wiring it is a follow-up, not a local + // lie. + console.log(`${logPrefix} addFriend (refine, no-op — friendship is not persisted on this backend):`, friendId); + }, [logPrefix]); + + const loadTestData = useCallback(async (): Promise => { + // Seeding fixtures into a Pod is meaningless on this backend (and the + // fixture seed is switched off outright) — the honest answer is "nothing + // was seeded". + console.log(`${logPrefix} loadTestData (refine, no-op)`); + return emptyBootstrap(); + }, [logPrefix]); + + const selectedEvent = derived.events.find(e => e.id === selectedEventId); + const selectedUser = users.find(u => u.id === selectedUserId); + + console.log(`${logPrefix} Render — refine/${backend.name} | events:`, derived.events.length, + '| meetingPoints:', derived.meetingPoints.length, '| participations:', participations.length, + '| selectedEvent:', selectedEvent?.title ?? '(none)'); + + return { + currentUserId, currentUser, currentPrincipal, + events: derived.events, + users, + participations, + meetingPoints: derived.meetingPoints, + friendships: [], + notifications: [], + selectedEventId, setSelectedEventId, selectedEvent, + selectedUserId, setSelectedUserId, selectedUser, + getEvent: shared.getEvent, + getUser: shared.getUser, + getEventParticipants, + getUserEvents, + isParticipating: shared.isParticipating, + getFriends: shared.getFriends, + getEventMeetingPoints: shared.getEventMeetingPoints, + getEventOwnership, + createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint, + addMeetingPoint, addFriend, updateProfile, loadTestData, + }; +} diff --git a/src/shared/data/actor.test.ts b/src/shared/data/actor.test.ts new file mode 100644 index 0000000..120a971 --- /dev/null +++ b/src/shared/data/actor.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from 'bun:test'; +import { fetchActorDocument, fetchActorProfileRecord } from './actor'; + +function installFetch(handler: (url: string, init: RequestInit) => Response) { + const calls: Array<{ url: string; init: RequestInit }> = []; + const original = globalThis.fetch; + globalThis.fetch = ((url: string | URL | Request, init?: RequestInit) => { + const urlString = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url; + const requestInit = init ?? {}; + calls.push({ url: urlString, init: requestInit }); + return Promise.resolve(handler(urlString, requestInit)); + }) as typeof fetch; + return { + calls, + restore() { + globalThis.fetch = original; + }, + }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/ld+json' }, + }); +} + +const ACTOR = 'http://localhost:3000/alice'; +const PROFILE = 'http://localhost:3000/alice/as/profile'; + +test('fetchActorDocument extracts the outbox and the profile link, compact or prefixed', async () => { + const double = installFetch((url) => { + if (url === ACTOR) { + return jsonResponse({ id: ACTOR, outbox: 'http://localhost:3000/alice/outbox', url: PROFILE }); + } + return jsonResponse({}, 404); + }); + try { + const actor = await fetchActorDocument(ACTOR, 'tok-1'); + expect(actor).toEqual({ outbox: 'http://localhost:3000/alice/outbox', profileUri: PROFILE }); + expect((double.calls[0]!.init.headers as Record)['Authorization']).toBe('Bearer tok-1'); + } finally { + double.restore(); + } +}); + +test('fetchActorDocument reads the as:-prefixed outbox form and tolerates a missing link', async () => { + const double = installFetch(() => jsonResponse({ '@id': ACTOR, 'as:outbox': { id: 'http://localhost:3000/alice/outbox' } })); + try { + const actor = await fetchActorDocument(ACTOR); + expect(actor.outbox).toBe('http://localhost:3000/alice/outbox'); + expect(actor.profileUri).toBeUndefined(); + } finally { + double.restore(); + } +}); + +test('fetchActorDocument throws on a non-2xx actor read — it is a probe, not the absorbing path', async () => { + const double = installFetch(() => jsonResponse({}, 500)); + try { + await expect(fetchActorDocument(ACTOR)).rejects.toThrow(/HTTP 500/); + } finally { + double.restore(); + } +}); + +// THE ALICE/BOB RULE: every failure mode of the profile read — a WAC refusal on +// the profile, a missing actor link, an unreadable actor — reads back as +// undefined, never throws. The non-contact answer IS the refusal. +test('fetchActorProfileRecord absorbs the WAC refusal and the missing-link cases', async () => { + const refusing = installFetch((url) => { + if (url === ACTOR) return jsonResponse({ id: ACTOR, url: PROFILE }); + return jsonResponse({ error: 'Forbidden' }, 403); + }); + try { + expect(await fetchActorProfileRecord(ACTOR, 'tok-1')).toBeUndefined(); + } finally { + refusing.restore(); + } + + const noLink = installFetch(() => jsonResponse({ id: ACTOR })); + try { + expect(await fetchActorProfileRecord(ACTOR, 'tok-1')).toBeUndefined(); + } finally { + noLink.restore(); + } + + const actorFails = installFetch(() => jsonResponse({}, 404)); + try { + expect(await fetchActorProfileRecord(ACTOR, 'tok-1')).toBeUndefined(); + } finally { + actorFails.restore(); + } +}); + +test('fetchActorProfileRecord returns the raw profile record on success', async () => { + const record = { id: PROFILE, type: 'Profile', name: 'Alice' }; + const double = installFetch((url) => { + if (url === ACTOR) return jsonResponse({ id: ACTOR, url: PROFILE }); + return jsonResponse(record); + }); + try { + expect(await fetchActorProfileRecord(ACTOR, 'tok-1')).toEqual(record); + } finally { + double.restore(); + } +}); diff --git a/src/shared/data/actor.ts b/src/shared/data/actor.ts new file mode 100644 index 0000000..6b935cc --- /dev/null +++ b/src/shared/data/actor.ts @@ -0,0 +1,80 @@ +/** + * Actor document and profile reads — the small, direct-HTTP side of the + * ActivityPods social model, living BESIDE Refine because the refine-providers + * package implements no `custom` method and these reads are not CRUD over the + * app's own containers. + * + * WHAT AN ACTOR DOCUMENT IS. The WebID document IS the ActivityPub actor: it + * carries the `outbox` (where `as:Join`/`as:Leave` are posted) and `url` + * (linking to the user's `as:Profile` record, the one readable display-name + * source). Neither is exposed by the auth provider's session or identity. + * + * THE ALICE/BOB RULE IS ABSORBED HERE. Resolving an attendee to a profile is + * two fetches (actor → `url` → profile), and the second is WAC-gated: only a + * contact may read it. A refusal, a missing link or a malformed document all + * read back as `undefined` — the caller renders a generic participant and the + * TOTAL stays the count of the collection, never of the resolved. A failure is + * not exceptional in this model; it IS the non-contact answer. + */ + +import { asRecord, linkId } from './jsonRecord'; + +/** The parts of the actor (WebID) document this app uses. */ +export interface ActorDocument { + /** The actor's outbox URI — where activities are posted. */ + outbox?: string; + /** URI of the actor's `as:Profile` record (the `url` predicate). */ + profileUri?: string; +} + +/** + * Fetch the actor document and extract what the app uses. The WebID document + * is public on the platform (verified: an anonymous read is not refused); the + * token is still sent — the reference integration does the same, and a pod + * whose WebID is not public answers whatever its ACL says. A network or + * non-2xx failure THROWS: this fetch is the app's own probe, not the + * refusal-absorbing path. + */ +export async function fetchActorDocument(webId: string, token?: string): Promise { + const response = await fetch(webId, { + headers: { + Accept: 'application/ld+json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + if (!response.ok) { + throw new Error(`actor document fetch failed: HTTP ${response.status} for ${webId}`); + } + const body: unknown = await response.json(); + const record = asRecord(body); + return { + outbox: linkId(record?.['outbox']) ?? linkId(record?.['as:outbox']), + profileUri: linkId(record?.['url']), + }; +} + +/** + * Resolve an actor (WebID) URI to its profile RECORD, or `undefined` when the + * profile cannot be read. Two fetches: the actor document, then the profile it + * links. Every failure mode — 403 (the Alice/Bob non-contact refusal), 404, a + * missing `url`, a non-object body — collapses to `undefined`; the record is + * returned raw for the binding's `toDomain` to map, so this module stays free + * of vocabulary. + */ +export async function fetchActorProfileRecord(webId: string, token?: string): Promise { + try { + const actor = await fetchActorDocument(webId, token); + if (!actor.profileUri) return undefined; + const response = await fetch(actor.profileUri, { + headers: { + Accept: 'application/ld+json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + if (!response.ok) return undefined; + const body: unknown = await response.json(); + return asRecord(body) ?? undefined; + } catch { + return undefined; + } +} diff --git a/src/shared/data/jsonRecord.ts b/src/shared/data/jsonRecord.ts new file mode 100644 index 0000000..aeefca8 --- /dev/null +++ b/src/shared/data/jsonRecord.ts @@ -0,0 +1,43 @@ +/** + * Typed readers for untyped JSON-LD values. + * + * Pod documents arrive as plain parsed JSON whose predicates are verbatim but + * whose values are untyped. These helpers narrow them without ever naming + * `any`, so callers stay honest about what a record MAY carry. The same + * vocabulary-free narrowing exists inside the ActivityPods binding + * (`refine/providers/activitypods.ts`); both copies are deliberate — the + * binding never exports its internals, and the data context must not import one + * backend module to serve another. + */ + +export type JsonRecord = Record; + +/** The value when it is a plain object (not null, not an array). */ +export function asRecord(value: unknown): JsonRecord | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as JsonRecord) + : undefined; +} + +/** The value when it is a string. */ +export function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +/** First string of a predicate that may hold a single value or an array of them. */ +export function firstString(value: unknown): string | undefined { + return asString(value) ?? (Array.isArray(value) ? asString(value[0]) : undefined); +} + +/** A linked value in an ActivityStreams record: a bare IRI string, or an object carrying id/@id. */ +export function linkId(value: unknown): string | undefined { + const direct = asString(value); + if (direct) return direct; + const object = asRecord(value); + return object ? (asString(object['id']) ?? asString(object['@id'])) : undefined; +} + +/** First entry of a predicate that may hold a single value or an array of them. */ +export function firstEntry(value: unknown): unknown { + return Array.isArray(value) ? value[0] : value; +} diff --git a/src/shared/data/outbox.test.ts b/src/shared/data/outbox.test.ts new file mode 100644 index 0000000..e5cf26c --- /dev/null +++ b/src/shared/data/outbox.test.ts @@ -0,0 +1,111 @@ +import { expect, test, afterEach } from 'bun:test'; +import { postJoinActivity, postLeaveActivity } from './outbox'; +import type { OutboxActivityInput } from './outbox'; + +const ACTOR = 'http://localhost:3000/alice'; +const OUTBOX = 'http://localhost:3000/alice/outbox'; +const MEETING_POINT = 'http://localhost:3000/alice/data/as/event/mp-1'; + +const input = (hostUri: string | undefined): OutboxActivityInput => ({ + meetingPointUri: MEETING_POINT, + hostUri, + session: { webId: ACTOR, token: 'tok-1' }, +}); + +/** Installs a scripted fetch double, returning the calls it saw. */ +function installFetch(handler: (url: string, init: RequestInit) => Response) { + const calls: Array<{ url: string; init: RequestInit }> = []; + const original = globalThis.fetch; + globalThis.fetch = ((url: string | URL | Request, init?: RequestInit) => { + const urlString = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url; + const requestInit = init ?? {}; + calls.push({ url: urlString, init: requestInit }); + return Promise.resolve(handler(urlString, requestInit)); + }) as typeof fetch; + return { + calls, + restore() { + globalThis.fetch = original; + }, + }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/ld+json' }, + }); +} + +afterEach(() => { + // Each test installs its own double; nothing to do here beyond documenting + // that fetch is always restored by the per-test handle. +}); + +test('postJoinActivity discovers the outbox from the actor document and posts a Join to it', async () => { + const double = installFetch((url, init) => { + if (url === ACTOR) return jsonResponse({ id: ACTOR, outbox: OUTBOX, url: `${ACTOR}/as/profile` }); + return jsonResponse({}, 201); + }); + try { + await postJoinActivity(input('http://localhost:3000/bob')); + expect(double.calls).toHaveLength(2); + const [actorCall, postCall] = double.calls; + expect(actorCall).toBeDefined(); + expect(postCall).toBeDefined(); + expect(actorCall!.url).toBe(ACTOR); + expect(postCall!.url).toBe(OUTBOX); + expect(postCall!.init.method).toBe('POST'); + const headers = postCall!.init.headers as Record; + expect(headers['Authorization']).toBe('Bearer tok-1'); + expect(headers['Content-Type']).toBe('application/ld+json'); + const body = JSON.parse(String(postCall!.init.body)); + expect(body).toEqual({ + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Join', + actor: ACTOR, + object: MEETING_POINT, + to: 'http://localhost:3000/bob', + }); + } finally { + double.restore(); + } +}); + +test('postLeaveActivity posts a Leave, and a missing host means no to field', async () => { + const double = installFetch((url) => { + if (url === ACTOR) return jsonResponse({ id: ACTOR, outbox: OUTBOX }); + return jsonResponse({}, 201); + }); + try { + await postLeaveActivity(input(undefined)); + const postCall = double.calls[1]!; + const body = JSON.parse(String(postCall.init.body)); + expect(body.type).toBe('Leave'); + expect(body).not.toHaveProperty('to'); + } finally { + double.restore(); + } +}); + +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); + expect(double.calls).toHaveLength(1); + } finally { + double.restore(); + } +}); + +test('a non-2xx outbox response throws with the status', async () => { + const double = installFetch((url) => { + if (url === ACTOR) return jsonResponse({ id: ACTOR, outbox: OUTBOX }); + return jsonResponse({ error: 'Forbidden' }, 403); + }); + try { + await expect(postLeaveActivity(input(undefined))).rejects.toThrow(/HTTP 403/); + } finally { + double.restore(); + } +}); diff --git a/src/shared/data/outbox.ts b/src/shared/data/outbox.ts new file mode 100644 index 0000000..b7c4a29 --- /dev/null +++ b/src/shared/data/outbox.ts @@ -0,0 +1,77 @@ +/** + * Outbox posting — how a user joins or leaves a meeting point. + * + * WHY THIS LIVES BESIDE REFINE. The refine-providers package implements no + * `custom` method: there is no escape hatch through the data provider, and a + * participation is not a CRUD write anyway. The platform's production + * precedent (recorded in the data-layer brief, reconnaissance of 2026-10-09) + * is: a join is an `as:Join` activity posted to the USER'S OWN outbox with the + * user's token; the on-behalf backend watches the flow and maintains the + * meeting point's `apods:attendees` collection; membership IS the attendance — + * a leave is `as:Leave` on the same path, and an absence from the collection + * reads as not attending. + * + * THE FLOW. The outbox URI is read off the user's own actor (WebID) document + * (`outbox` predicate) at post time — the reference integration resolves it + * 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. + * + * 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 + * as the membership. The data layer carries an optimistic overlay for exactly + * that gap. + */ + +import type { CurrentSession } from '../utils/currentSession'; +import { fetchActorDocument } from './actor'; + +/** The vocabulary context the posted activity carries. */ +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. */ + hostUri: string | undefined; + /** The authenticated session — whose outbox receives the activity. */ + session: CurrentSession; +} + +async function postActivity(type: 'Join' | 'Leave', input: OutboxActivityInput): Promise { + const { meetingPointUri, hostUri, session } = input; + const actor = await fetchActorDocument(session.webId, session.token); + if (!actor.outbox) { + throw new Error(`actor document for ${session.webId} carries no outbox — cannot post ${type}`); + } + const activity: Record = { + '@context': ACTIVITY_CONTEXT, + type, + actor: session.webId, + object: meetingPointUri, + }; + if (hostUri) activity['to'] = hostUri; + + const response = await fetch(actor.outbox, { + method: 'POST', + headers: { + 'Content-Type': 'application/ld+json', + Accept: 'application/ld+json', + Authorization: `Bearer ${session.token}`, + }, + body: JSON.stringify(activity), + }); + if (!response.ok) { + throw new Error(`outbox ${type} post failed: HTTP ${response.status} for ${actor.outbox}`); + } +} + +/** Post an `as:Join` activity to the user's outbox — signing up to a meeting point. */ +export function postJoinActivity(input: OutboxActivityInput): Promise { + return postActivity('Join', input); +} + +/** Post an `as:Leave` activity to the user's outbox — leaving a meeting point. */ +export function postLeaveActivity(input: OutboxActivityInput): Promise { + return postActivity('Leave', input); +} diff --git a/src/shared/data/stepDefinitions.ts b/src/shared/data/stepDefinitions.ts index 82ff46e..dca0881 100644 --- a/src/shared/data/stepDefinitions.ts +++ b/src/shared/data/stepDefinitions.ts @@ -700,29 +700,29 @@ export const stepDefinitions: StepDefinitionInfo[] = [ "pattern": "je peux m'inscrire à l'événement", "keyword": "Then", "file": "event.steps.ts", - "sourceCode": "Then('je peux m\\'inscrire à l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const doc = this.renderedDoc;\n expect(doc, 'Screen should be rendered').to.not.be.null;\n const buttons = Array.from(doc!.querySelectorAll('button')).map(b => b.textContent ?? '');\n const hasToggle = buttons.some(t => t.includes(\"J'y serai\") || t.includes('Je participe'));\n expect(hasToggle, 'Event detail should expose a join/leave toggle button').to.be.true;\n});", - "lineNumber": 80 + "sourceCode": "Then('je peux m\\'inscrire à l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const doc = this.renderedDoc;\n expect(doc, 'Screen should be rendered').to.not.be.null;\n const buttons = Array.from(doc!.querySelectorAll('button')).map(b => b.textContent ?? '');\n const hasSignup = buttons.some(t => t.includes(\"S'inscrire\") || t.includes('Inscrit'));\n expect(hasSignup, 'Event detail should expose a meeting-point signup button').to.be.true;\n});", + "lineNumber": 83 }, { "pattern": "je peux me désinscrire de l'événement", "keyword": "Then", "file": "event.steps.ts", - "sourceCode": "Then('je peux me désinscrire de l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const doc = this.renderedDoc;\n expect(doc, 'Screen should be rendered').to.not.be.null;\n const buttons = Array.from(doc!.querySelectorAll('button')).map(b => b.textContent ?? '');\n const hasToggle = buttons.some(t => t.includes(\"J'y serai\") || t.includes('Je participe'));\n expect(hasToggle, 'Event detail should expose a join/leave toggle button').to.be.true;\n});", - "lineNumber": 89 + "sourceCode": "Then('je peux me désinscrire de l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const doc = this.renderedDoc;\n expect(doc, 'Screen should be rendered').to.not.be.null;\n const buttons = Array.from(doc!.querySelectorAll('button')).map(b => b.textContent ?? '');\n const hasSignup = buttons.some(t => t.includes(\"S'inscrire\") || t.includes('Inscrit'));\n expect(hasSignup, 'Event detail should expose a meeting-point signup toggle').to.be.true;\n});", + "lineNumber": 92 }, { "pattern": "le formulaire contient le champ obligatoire {string}", "keyword": "Then", "file": "event.steps.ts", "sourceCode": "Then('le formulaire contient le champ obligatoire {string}', async function (this: FestipodWorld, fieldName: string) {\n expect(this.currentScreenId, 'This step is for form screens only').to.equal('create-event');\n expectRequiredField(this, fieldName);\n});", - "lineNumber": 100 + "lineNumber": 103 }, { "pattern": "le formulaire contient les champs obligatoires suivants:", "keyword": "Then", "file": "event.steps.ts", "sourceCode": "Then('le formulaire contient les champs obligatoires suivants:', async function (this: FestipodWorld, dataTable) {\n expect(this.currentScreenId, 'This step is for form screens only').to.equal('create-event');\n const expectedFields = dataTable.raw().flat();\n expectedFields.forEach((fieldName: string) => expectRequiredField(this, fieldName));\n});", - "lineNumber": 105 + "lineNumber": 108 }, { "pattern": "l'application connectée affiche l'accueil", diff --git a/src/shared/refine/providers/activitypods.test.ts b/src/shared/refine/providers/activitypods.test.ts index 2fed03b..58887fd 100644 --- a/src/shared/refine/providers/activitypods.test.ts +++ b/src/shared/refine/providers/activitypods.test.ts @@ -300,3 +300,69 @@ test('backend satisfies the RefineBackend contract shape', () => { time: '', }); }); + +// Events and meeting points share the as/Event container: the presence of +// `as:context` is the ONLY distinction, and the discrimination lives in the +// bindings (isInstance) so the reader splits a raw list without learning the +// predicate. +test('event and meetingPoint isInstance discriminate on the as:context link', () => { + const { event, meetingPoint } = getBackend('activitypods').bindings; + const plain = { id: 'http://localhost:3000/alice/data/1', type: 'Event', name: 'Soirée' }; + const grafted = { ...plain, id: 'http://localhost:3000/alice/data/2', 'as:context': 'http://localhost:3000/alice/data/0' }; + expect(event.isInstance?.(plain)).toBe(true); + expect(event.isInstance?.(grafted)).toBe(false); + expect(meetingPoint.isInstance?.(plain)).toBe(false); + expect(meetingPoint.isInstance?.(grafted)).toBe(true); +}); + +// The attendees collection travels inside the event record: members embedded +// when the reader may read them, a bare collection URI when not, absent when +// nobody joined. `totalItems` survives without members — the count stays total +// for a reader who may resolve no profile (the Alice/Bob rule). +test('participation membersOf reads the embedded apods:attendees collection in its shapes', () => { + const { participation } = getBackend('activitypods').bindings; + const bob = 'http://localhost:3000/bob'; + + const embedded = participation.membersOf?.({ + id: 'ev-1', + 'apods:attendees': { + id: 'http://localhost:3000/alice/data/as/collection/att-1', + totalItems: 1, + items: [bob], + }, + }); + expect(embedded).toEqual({ members: [bob], total: 1 }); + + const prefixed = participation.membersOf?.({ + id: 'ev-1', + 'apods:attendees': { totalItems: 2, 'as:items': [{ id: bob }] }, + }); + expect(prefixed?.members).toEqual([{ id: bob }]); + expect(prefixed?.total).toBe(2); + + // A collection URI without an embed: nothing readable, no count. + expect(participation.membersOf?.({ id: 'ev-1', 'apods:attendees': 'http://localhost:3000/alice/data/as/collection/att-1' })).toBeUndefined(); + + // No collection predicate at all → nobody joined yet. + expect(participation.membersOf?.({ id: 'ev-1', name: 'Soirée' })).toBeUndefined(); + + // totalItems without items still yields the count (members unknown → []). + const totalOnly = participation.membersOf?.({ + id: 'ev-1', + 'apods:attendees': { id: 'col-1', totalItems: 3 }, + }); + expect(totalOnly).toEqual({ members: [], total: 3 }); +}); + +// The reader stamps the host record's id onto each member — a member carries +// no link back to what it attends. +test('a mapped member is a confirmed participation keyed by the attendee', () => { + const { participation } = getBackend('activitypods').bindings; + const member = participation.toDomain('http://localhost:3000/bob'); + expect(member).toEqual({ + id: 'http://localhost:3000/bob', + eventId: '', + userId: 'http://localhost:3000/bob', + isConfirmed: true, + }); +}); diff --git a/src/shared/refine/providers/activitypods.ts b/src/shared/refine/providers/activitypods.ts index bf50dd0..7e615f5 100644 --- a/src/shared/refine/providers/activitypods.ts +++ b/src/shared/refine/providers/activitypods.ts @@ -69,8 +69,8 @@ * `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 - * is Festipod's own to serve (a static file suffices) and lands with the Solid-OIDC - * flow, so until then the URL is derived from the origin this page runs on. + * 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. */ import { @@ -79,6 +79,7 @@ import { liveProvider as createApodsLiveProvider, } from '@activitypods/refine-providers'; import type { FpEventData, FpMeetingPointData, FpParticipationData, FpUserData } from '../../data/types'; +import { resolveActivityPodsAuthConfig } from '../../utils/runtimeConfig'; import type { RecordBinding, RecordBindings, RefineBackend } from './types'; /** One JSON-LD record as the data provider returns it: predicates verbatim, values untyped. */ @@ -99,6 +100,11 @@ function firstString(value: unknown): string | undefined { return asString(value) ?? (Array.isArray(value) ? asString(value[0]) : undefined); } +/** First entry of a predicate that may hold a single value or an array of them. */ +function firstEntry(value: unknown): unknown { + return Array.isArray(value) ? value[0] : value; +} + /** * The single identifier. `id` first, `@id` otherwise; a record carrying neither * raises, per the contract's own rule. @@ -305,12 +311,44 @@ function participationWrite(): never { ); } +/** + * The embedded `apods:attendees` read, one per event record. Three shapes: + * an object carrying `items`/`as:items` (members embedded — dereferenced when + * the reader may read them), a bare collection URI (the collection exists but + * does not embed), or absent (nobody joined). `totalItems` is reported + * whenever the collection object carries it. The member shape itself is the + * reconstruction's known gap (usage declaration, friction 5): a member is read + * as a reference — bare URI, or object with id/@id — and membership IS the + * attendance. + */ +function attendeesFromRecord(record: unknown): { members: unknown[]; total?: number } | undefined { + const r = asRecord(record); + const attendees = firstEntry(r?.['apods:attendees']); + const collection = asRecord(attendees); + if (!collection) return undefined; + const totalValue = collection['totalItems']; + const total = typeof totalValue === 'number' ? totalValue : undefined; + // `items` is a list, not a single-or-array predicate: take it as it comes. + const rawItems = collection['items'] ?? collection['as:items']; + let members: unknown[] = []; + if (Array.isArray(rawItems)) { + members = rawItems; + } else if (rawItems !== undefined && rawItems !== null) { + members = [rawItems]; + } + if (members.length === 0 && total === undefined) return undefined; + return { members, total: total ?? members.length }; +} + const eventBinding: RecordBinding = { recordKey: recordId, domainId: recordId, toDomain: eventFromRecord, toValues: (data) => eventValues(data), toPatch: (updates) => eventValues(updates), + // Events and meeting points share the as/Event container; an `as:context` link + // is what makes a record a MEETING point, so its absence is the event test. + isInstance: (record) => linkId(asRecord(record)?.['as:context']) === undefined, updateReplaces: false, resource: 'events', visibility: 'public', @@ -322,6 +360,8 @@ const meetingPointBinding: RecordBinding = { toDomain: meetingPointFromRecord, toValues: (data) => meetingPointValues(data), toPatch: (updates) => meetingPointValues(updates), + // The distinction lives in the record, not the container — see the event binding. + isInstance: (record) => linkId(asRecord(record)?.['as:context']) !== undefined, updateReplaces: false, resource: 'meetingPoints', // A meeting point hangs off a public event; who joins it is the protected fact, not the point itself. @@ -347,6 +387,13 @@ const participationBinding: RecordBinding = { toDomain: participationFromMember, toValues: participationWrite, toPatch: participationWrite, + // The collection travels with the event record: an embedded object whose + // `items`/`as:items` hold the members (dereferenced when the reader may read + // them), a bare collection URI when it does not embed, absent when nobody + // joined yet. `totalItems` survives even when members are not dereferenced, + // so the COUNT can stay total when the members are not readable — the + // Alice/Bob rule. No embed → undefined (nothing readable, count unknown → 0). + membersOf: attendeesFromRecord, updateReplaces: false, // Read through the events resource: the collection is embedded in the event record // or fetched by `getOne` on the collection URI it carries — it is not a listed resource. @@ -377,11 +424,13 @@ const JSON_CONTEXT = 'https://activitypods.org/context.json'; export const activitypods: RefineBackend = { name: 'activitypods', build() { + // The SAME config the AuthGate flow resolves (runtime config is the shared + // source of truth — T04.d): one clientId/redirect for both halves, so the + // session the OIDC flow establishes is the session the data provider reads. + const authConfig = resolveActivityPodsAuthConfig(window.location.origin); const apodsAuth = createApodsAuthProvider({ - // The app description document this names is Festipod's own to serve — a static - // file suffices — and lands with the Solid-OIDC flow; until then the URL is the - // one this page can already answer for. - clientId: `${window.location.origin}/client.json`, + clientId: authConfig.clientId, + redirectUri: authConfig.redirectUri, // Disabled deliberately: the construction-time status timer leaks (usage // declaration, friction 1). Festipod drives getAppStatus() itself. appStatusCheckInterval: false, diff --git a/src/shared/refine/providers/types.ts b/src/shared/refine/providers/types.ts index 4749ad8..78cc088 100644 --- a/src/shared/refine/providers/types.ts +++ b/src/shared/refine/providers/types.ts @@ -55,6 +55,27 @@ export interface RecordBinding { * `updateReplaces` is true. */ toPatch(updates: Partial): Record; + /** + * Whether a raw record IS an instance of this binding's entity — the + * container-discrimination case. On ActivityPods, events and meeting points + * share the `as:Event` container and the presence of `as:context` is the only + * distinction (the container does not discriminate, and Refine's in-memory + * filters are value-based, not presence-based). The vocabulary lives here, + * in the binding, so the reader can split a raw list without learning the + * predicate. Absent means "every record of the resource is an instance". + */ + isInstance?(record: unknown): boolean; + /** + * The embedded-collection read, for entities whose records ARRIVE inside + * another record: an event's `apods:attendees` collection travels with the + * event record — its members dereferenced when the reader may read them, as + * bare references otherwise, and the collection URI alone when not embedded. + * Returns the members plus the total when known (a reader that may not + * dereference members can still hold a `totalItems`), or `undefined` when + * the record carries no readable collection. The reader stamps the host + * record's id onto each mapped member — a member has no link back. + */ + membersOf?(record: unknown): { members: unknown[]; total?: number } | undefined; /** True when `update` replaces the record rather than patching it. */ readonly updateReplaces: boolean; /** diff --git a/src/shared/test-harness/harness-ng.tsx b/src/shared/test-harness/harness-ng.tsx index fa47228..d647e90 100644 --- a/src/shared/test-harness/harness-ng.tsx +++ b/src/shared/test-harness/harness-ng.tsx @@ -245,10 +245,10 @@ function ScopedHarness({ publicGraph, protectedGraph }: { publicGraph: Nuri; pro // --- Mutations (app path: per-entity docs + reactivity) --- async joinEvent(eventId: string, userId: string) { - await AD().joinEvent(eventId, userId); + await AD().joinMeetingPoint(eventId, userId); }, async leaveEvent(eventId: string, userId: string) { - await AD().leaveEvent(eventId, userId); + await AD().leaveMeetingPoint(eventId, userId); }, // --- Real app-path registration (T02.c) ---------------------------- @@ -276,10 +276,10 @@ function ScopedHarness({ publicGraph, protectedGraph }: { publicGraph: Nuri; pro return { id, title }; }, async appJoinEvent(eventId: string, userId?: string) { - await AD().joinEvent(eventId, userId); + await AD().joinMeetingPoint(eventId, userId); }, async appLeaveEvent(eventId: string, userId?: string) { - await AD().leaveEvent(eventId, userId); + await AD().leaveMeetingPoint(eventId, userId); }, /** A LIVE current user id, resolved from the users set AT CALL TIME (not * frozen at bridge-build). Prefers the app context's principal; falls diff --git a/src/shared/utils/currentSession.test.ts b/src/shared/utils/currentSession.test.ts new file mode 100644 index 0000000..bfb7698 --- /dev/null +++ b/src/shared/utils/currentSession.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from 'bun:test'; +import { getCurrentSession, setCurrentSession } from './currentSession'; + +// The store is module-level and shared across the app; restore the absent +// state after each test so no test leaks a session into its neighbours. +function reset() { + setCurrentSession(undefined); +} + +test('no session until the auth flow publishes one', () => { + reset(); + expect(getCurrentSession()).toBeUndefined(); +}); + +test('the published session reads back, and a same-value republish is a no-op', () => { + reset(); + const session = { webId: 'http://localhost:3000/alice', token: 'tok-1' }; + setCurrentSession(session); + expect(getCurrentSession()).toEqual(session); + // Same reference AND same value: the store must not churn its listeners. + setCurrentSession({ webId: 'http://localhost:3000/alice', token: 'tok-1' }); + expect(getCurrentSession()).toEqual(session); + reset(); + expect(getCurrentSession()).toBeUndefined(); +}); + +test('a changed session replaces the previous one', () => { + reset(); + setCurrentSession({ webId: 'http://localhost:3000/alice', token: 'tok-1' }); + setCurrentSession({ webId: 'http://localhost:3000/alice', token: 'tok-2' }); + expect(getCurrentSession()?.token).toBe('tok-2'); + reset(); +}); diff --git a/src/shared/utils/currentSession.ts b/src/shared/utils/currentSession.ts new file mode 100644 index 0000000..4621022 --- /dev/null +++ b/src/shared/utils/currentSession.ts @@ -0,0 +1,64 @@ +/** + * currentSession — the ActivityPods session the AuthGate flow established + * (`webId` + OIDC token), as a module store observed with + * `useSyncExternalStore`. + * + * WHY A MODULE STORE and not a React context: the data provider's dispatch + * decision ("is there an authenticated session to read a Pod through?") is made + * by `FestipodDataProvider`, while the component that drives the Solid-OIDC + * flow — `AuthGate` — mounts inside it. A context the AuthGate published would + * be invisible there. The same constraint and solution as + * `utils/currentPrincipal.ts`. + * + * WHAT IT IS FOR: gating the Refine data path and authorizing the writes that + * live BESIDE Refine (the outbox `as:Join`/`as:Leave` service and the actor / + * profile reads). Every value here comes from the auth provider instance that + * owns the flow, set at the exact points `AuthGate` records the principal. + * + * WHAT IT IS NOT: it is not a second identity space. `webId` is the Solid + * identity; the app's entity space (`currentUserId`) is the profile document + * URI, resolved separately from the actor document. + */ + +import { useSyncExternalStore } from 'react'; + +/** The authenticated session's credentials. */ +export interface CurrentSession { + webId: string; + token: string; +} + +let session: CurrentSession | undefined; +const listeners = new Set<() => void>(); + +/** Record the session the auth flow established. `undefined` clears it. */ +export function setCurrentSession(next: CurrentSession | undefined): void { + const same = session?.webId === next?.webId && session?.token === next?.token; + if (same) return; + session = next; + for (const l of listeners) l(); +} + +/** The current session, or `undefined` when unauthenticated / offline demo. */ +export function getCurrentSession(): CurrentSession | undefined { + return session; +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function snapshot(): CurrentSession | undefined { + return session; +} + +/** + * Subscribe a component to the current session. The snapshot is a stable + * reference between real changes — no render loop. + */ +export function useCurrentSession(): CurrentSession | undefined { + return useSyncExternalStore(subscribe, snapshot, snapshot); +} diff --git a/src/shared/utils/runtimeConfig.ts b/src/shared/utils/runtimeConfig.ts index 62daa3e..07d3061 100644 --- a/src/shared/utils/runtimeConfig.ts +++ b/src/shared/utils/runtimeConfig.ts @@ -20,6 +20,40 @@ export interface RuntimeConfig { podProviderUrl?: string; } +/** + * The resolved ActivityPods auth configuration. + * + * `clientId` names the app description document the backend serves, as the + * Solid-OIDC flow requires; `redirectUri` is fixed to `${origin}/login` because + * the app renders its own callback route. THE DATA PROVIDER AND THE AUTH GATE + * MUST RESOLVE THE SAME VALUES — both go through this function so the two + * halves of the app can never drift apart. + */ +export interface ResolvedAuthConfig { + clientId: string; + redirectUri: string; + podProviderUrl: string | undefined; +} + +/** + * Resolve the auth configuration from runtime values. + * + * - `backendClientId` wins when explicitly set. + * - Otherwise `backendUrl` derives `${backendUrl}/app`. + * - Otherwise fall back to `${origin}/app` so a page without a configured + * backend still typechecks and can be exercised. + */ +export function resolveActivityPodsAuthConfig(origin: string): ResolvedAuthConfig { + const backendClientId = runtimeConfig.backendClientId?.trim(); + const backendUrl = runtimeConfig.backendUrl?.trim(); + const clientId = backendClientId || (backendUrl ? `${backendUrl}/app` : `${origin}/app`); + return { + clientId, + redirectUri: `${origin}/login`, + podProviderUrl: runtimeConfig.podProviderUrl?.trim() || undefined, + }; +} + let runtimeConfig: RuntimeConfig = {}; /** Replace the runtime config. Called once by `frontend.tsx` before `App` loads. */