Rewrite FestipodDataContext over the Refine seam, meeting-point centric
This commit is contained in:
+29
-10
@@ -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 <HomeScreen />;
|
||||
case 'events': return <EventsScreen />;
|
||||
case 'create-event': return <CreateEventScreen />;
|
||||
case 'login': return null;
|
||||
case 'login': return <AuthenticatedLoginLanding />;
|
||||
case 'event-detail': return <EventDetailScreen />;
|
||||
case 'update-event': return <UpdateEventScreen />;
|
||||
case 'invite': return <InviteScreen />;
|
||||
@@ -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 = (
|
||||
<FestipodDataProvider>
|
||||
<RouterProvider>
|
||||
<AuthGate>
|
||||
<FestipodDataProvider>
|
||||
<div className="app-container">
|
||||
<AuthGate>
|
||||
<AppContent />
|
||||
</AuthGate>
|
||||
<AppContent />
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</RouterProvider>
|
||||
</FestipodDataProvider>
|
||||
</FestipodDataProvider>
|
||||
</AuthGate>
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
{isNg ? <NextGraphProvider>{appTree}</NextGraphProvider> : appTree}
|
||||
<RouterProvider>
|
||||
{isNg ? <NextGraphProvider>{appTree}</NextGraphProvider> : appTree}
|
||||
</RouterProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
+12
-2
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
@@ -150,30 +151,26 @@ export function EventDetailScreen() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ margin: '4px 16px 16px', display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
variant={joined ? 'green' : 'primary'}
|
||||
onClick={handleToggleJoin}
|
||||
style={{ flex: 1, padding: '12px 0' }}
|
||||
>
|
||||
{joined ? '✓ Je participe' : "J'y serai"}
|
||||
</Button>
|
||||
{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 && (
|
||||
<div style={{ margin: '4px 16px 16px', display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
onClick={() => navigate(`/events/${eventId}/invite`)}
|
||||
style={{ flex: 1, padding: '12px 0' }}
|
||||
>
|
||||
Inviter
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meetingPoints.length > 0 && (
|
||||
<div style={{ padding: '0 16px 8px' }}>
|
||||
<EventMeetingPoints
|
||||
points={meetingPoints}
|
||||
joinedIds={new Set()}
|
||||
onToggle={() => {}}
|
||||
joinedIds={joinedMeetingPointIds}
|
||||
onToggle={handleToggleMeetingPoint}
|
||||
expanded
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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<FpEventData, 'id'>): Promise<FpEventData>;
|
||||
updateEvent(id: string, updates: Partial<FpEventData>): void | Promise<void>;
|
||||
/**
|
||||
* 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> | void;
|
||||
/** Withdraw from a meeting point — the symmetric fact to `joinMeetingPoint`. */
|
||||
leaveMeetingPoint(meetingPointId: string, userId?: string): Promise<void> | void;
|
||||
addMeetingPoint(mp: Omit<FpMeetingPointData, 'id'>): void;
|
||||
addFriend(friendId: string): void;
|
||||
updateProfile(updates: Partial<FpUserData>): void | Promise<void>;
|
||||
loadTestData(): Promise<BootstrapResult>;
|
||||
}
|
||||
|
||||
export const FestipodDataContext = createContext<FestipodDataContextValue | null>(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:<key>` 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:<handle>` 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<string>();
|
||||
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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||
<RefineProvider>
|
||||
<RefineDataInner>{children}</RefineDataInner>
|
||||
</RefineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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 <FestipodDataContext.Provider value={inertDataValue()}>{children}</FestipodDataContext.Provider>;
|
||||
}
|
||||
return <RefineDataMounted backend={backend}>{children}</RefineDataMounted>;
|
||||
}
|
||||
|
||||
function RefineDataMounted({ backend, children }: { backend: RefineBackend; children: ReactNode }) {
|
||||
const data = useRefineData(backend);
|
||||
return <FestipodDataContext.Provider value={data}>{children}</FestipodDataContext.Provider>;
|
||||
}
|
||||
|
||||
/** 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<FpEventData, 'id'>) => ({ ...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<string, 'join' | 'leave'>;
|
||||
|
||||
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<string, number>();
|
||||
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<MembershipOverlay>(new Map());
|
||||
|
||||
const participations = useMemo(() => {
|
||||
if (overlay.size === 0 || !webId) return derived.participations;
|
||||
const byMp = new Map<string, FpParticipationData[]>();
|
||||
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<string>('');
|
||||
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<FpUserData[]>([]);
|
||||
const [unresolved, setUnresolved] = useState<ReadonlySet<string>>(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<string>();
|
||||
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<string>('');
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<FpEventData, 'id'>): Promise<FpEventData> => {
|
||||
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<FpEventData>) => {
|
||||
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<FpMeetingPointData, 'id'>) => {
|
||||
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<FpUserData>) => {
|
||||
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<BootstrapResult> => {
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, string>)['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();
|
||||
}
|
||||
});
|
||||
@@ -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<ActorDocument> {
|
||||
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<unknown> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<string, unknown> = {
|
||||
'@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<void> {
|
||||
return postActivity('Join', input);
|
||||
}
|
||||
|
||||
/** Post an `as:Leave` activity to the user's outbox — leaving a meeting point. */
|
||||
export function postLeaveActivity(input: OutboxActivityInput): Promise<void> {
|
||||
return postActivity('Leave', input);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<FpEventData> = {
|
||||
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<FpMeetingPointData> = {
|
||||
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<FpParticipationData> = {
|
||||
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,
|
||||
|
||||
@@ -55,6 +55,27 @@ export interface RecordBinding<T extends { id: string }> {
|
||||
* `updateReplaces` is true.
|
||||
*/
|
||||
toPatch(updates: Partial<T>): Record<string, unknown>;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user