Rewrite FestipodDataContext over the Refine seam, meeting-point centric

This commit is contained in:
Sylvain Duchesne
2026-10-09 21:04:35 +02:00
parent b09884d586
commit c195211629
25 changed files with 2949 additions and 1580 deletions
+29 -10
View File
@@ -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
View File
@@ -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) {
+7 -33
View File
@@ -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 {
+35 -38
View File
@@ -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>
+7 -4
View File
@@ -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');
});
+45
View File
@@ -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';
}
+222
View File
@@ -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
+501
View File
@@ -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,
};
}
+107
View File
@@ -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();
}
});
+80
View File
@@ -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;
}
}
+43
View File
@@ -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;
}
+111
View File
@@ -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();
}
});
+77
View File
@@ -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);
}
+6 -6
View File
@@ -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,
});
});
+55 -6
View File
@@ -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,
+21
View File
@@ -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;
/**
+4 -4
View File
@@ -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
+33
View File
@@ -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();
});
+64
View File
@@ -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);
}
+34
View File
@@ -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. */