navigate(eventPath(event.id))}
+ />
+ );
+ })}
diff --git a/src/shared/components/sketchy/EventMeetingPoints.tsx b/src/shared/components/sketchy/EventMeetingPoints.tsx
index ae5420e..4fee460 100644
--- a/src/shared/components/sketchy/EventMeetingPoints.tsx
+++ b/src/shared/components/sketchy/EventMeetingPoints.tsx
@@ -7,6 +7,8 @@ export interface MeetingPointData {
when: string;
duration: string;
lieu: string;
+ /** How many signed up to this point — shown when the caller knows it, omitted otherwise. */
+ participantCount?: number;
}
interface Props {
@@ -56,6 +58,12 @@ export function EventMeetingPoints({ points, joinedIds, onToggle, expanded = fal
🕒 {p.when} · {p.duration}
📍 {p.lieu}
+ {p.participantCount !== undefined && (
+ <>
+
+ 👥 {p.participantCount} inscrit{p.participantCount > 1 ? 's' : ''}
+ >
+ )}
mp.eventId === eventId);
};
- return { getEvent, getUser, getEventParticipants, getUserEvents, isParticipating, getFriends, getEventMeetingPoints };
+ // A provider whose read set is everything it can read answers from its
+ // arrays: held → ready, otherwise there is nothing more to read.
+ const getEventReadState = (id: string): EventReadState =>
+ events.some(e => e.id === id) ? 'ready' : 'unavailable';
+
+ return {
+ getEvent, getUser, getEventParticipants, getUserEvents, isParticipating, getFriends, getEventMeetingPoints,
+ openEvent: openEventInCompleteReadSet,
+ getEventReadState,
+ };
}
+/**
+ * `openEvent` for a provider whose read set is already everything it can read
+ * (the local fixtures, the suspended NextGraph path): nothing to bring in. One
+ * module-level function, so its identity is stable across renders.
+ */
+function openEventInCompleteReadSet(_eventId: string): void {}
+
// ============================================================================
// Hook
// ============================================================================
diff --git a/src/shared/context/refineData.tsx b/src/shared/context/refineData.tsx
index 9b6a35e..d9b724b 100644
--- a/src/shared/context/refineData.tsx
+++ b/src/shared/context/refineData.tsx
@@ -7,12 +7,33 @@
*
* 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
+ * is what THIS user relayed), 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.
*
+ * THE FOREIGN READ SET — another user's event, opened from Découvrir. The own
+ * list never holds it, so a screen asks for it (`openEvent`) and it is read BY
+ * URI. Its meeting points may live in other pods, and the backend catalog is
+ * the only cross-user index of them: the catalog names their URIs
+ * (`data/catalog.ts`) for every opened event — own ones included, so an owner
+ * sees the meeting points others proposed on their event — and each is read by
+ * URI. Those reads are direct reads (`data/publicRead.ts`), not the data
+ * provider's `getOne`/`getMany`: the URIs are chosen by other users, and the
+ * contract does not say a provider read can go out without the session's
+ * token — so they are ANONYMOUS everywhere but on the session's own origin,
+ * bounded in time, and each URI lands on its own. Foreign records join the own ones BEFORE the split, so every
+ * per-event query and the join/leave flow treat them alike; `events` and
+ * `meetingPoints` stay the own lists, and ownership stays the own list's answer.
+ *
+ * ATTENDEES COLLECTIONS ARE READ BY URI. The pod links a meeting point's
+ * `apods:attendees` collection by URI and never embeds it (verified
+ * 2026-10-12), while the participation binding counts an EMBEDDED collection.
+ * So every meeting point's linked collection — own and foreign alike — is read
+ * by URI the same way (public by design) and embedded before the split
+ * (`data/attendees.ts`). A collection that cannot be read stays a bare link
+ * and counts an honest zero.
+ *
* 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
@@ -37,8 +58,12 @@ import React, { useState, useCallback, useEffect, useMemo, useRef, type ReactNod
import { useList, useOne, useCreate, useUpdate, useInvalidate } from '@refinedev/core';
import type { FpEventData, FpUserData, FpMeetingPointData } from '../data/types';
import { fetchActorDocument, fetchActorProfileRecord } from '../data/actor';
+import { attendeesCollectionLink, embedAttendeesCollection } from '../data/attendees';
+import { tokenFor } from '../data/publicRead';
+import { fetchCatalogMeetingPointIds } from '../data/catalog';
import { postJoinActivity, postLeaveActivity } from '../data/outbox';
import { useCurrentSession } from '../utils/currentSession';
+import { getBackendUrl } from '../utils/runtimeConfig';
import { useCurrentPrincipal } from '../utils/currentPrincipal';
import type { BootstrapResult } from '../utils/ngBootstrap';
import { RefineProvider, useBackend } from '../refine/RefineProvider';
@@ -50,12 +75,15 @@ import {
nextId,
type FestipodDataContextValue,
type EventOwnership,
+ type EventReadState,
} from './festipodDataBase';
import {
applyMembershipOverlay,
+ countsWithOverlay,
deriveContainer,
type MembershipOverlay,
} from './refineDerive';
+import { usePublicRecords } from './usePublicRecords';
export function RefineDataProvider({ children }: { children: ReactNode }) {
return (
@@ -91,6 +119,7 @@ function inertDataValue(): FestipodDataContextValue {
getEvent: () => undefined, getUser: () => undefined,
getEventParticipants: () => [], getUserEvents: () => [], isParticipating: () => false,
getFriends: () => [], getEventMeetingPoints: () => [], getEventOwnership: (): EventOwnership => 'unknown',
+ openEvent: () => {}, getEventReadState: (): EventReadState => 'unavailable',
setSelectedEventId: () => {}, setSelectedUserId: () => {},
createEvent: async (event: Omit) => ({ ...event, id: nextId('event') }),
updateEvent: () => {}, joinMeetingPoint: () => {}, leaveMeetingPoint: () => {},
@@ -108,6 +137,11 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
const session = useCurrentSession();
const webId = session?.webId;
const token = session?.token;
+ // The origin that issued the session — the only one its token is sent to on
+ // reads that follow other users' links.
+ const trustedOrigin = useMemo(() => {
+ try { return webId ? new URL(webId).origin : undefined; } catch { return undefined; }
+ }, [webId]);
// WHO AM I — the Solid identity the session signed in as. On this backend the
// ACTOR URI (`webId`) is the canonical user key: the attendees collection
@@ -131,9 +165,135 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
[containerQuery.result.data],
);
const containerFailed = containerQuery.query.isError;
+ // Settled (read, or failed) — only then is "not one of mine" an answer.
+ const ownSettled = !containerQuery.query.isPending;
+
+ // What this session OWNS: the own container's records, split by the binding.
+ // Ownership, `events` and `meetingPoints` are answered from this split only.
+ const ownSplit = useMemo(() => deriveContainer(rawRecords, bindings), [rawRecords, bindings]);
+ const ownEventIds = useMemo(() => new Set(ownSplit.events.map(e => e.id)), [ownSplit.events]);
+ const ownMeetingPointIds = useMemo(
+ () => new Set(ownSplit.meetingPoints.map(mp => mp.id)),
+ [ownSplit.meetingPoints],
+ );
+
+ // --- OPENED EVENTS: the events screens asked to see ---------------------------
+ // Grows only: an opened event stays in the read set for the page's life
+ // (navigating back to it does not re-ask). Own events are opened too — the
+ // catalog lookup of their meeting points serves the owner as well.
+ const [openedIds, setOpenedIds] = useState([]);
+ // Events this session created — owned from the moment the create returned.
+ const [createdEventIds, setCreatedEventIds] = useState([]);
+ // Opening an event again RETRIES what failed — a read that a pod blip or a
+ // slow start past the read timeout lost, a catalog that could not be asked —
+ // and re-reads nothing that already landed (`openCount` is the retry trigger).
+ const [openCount, setOpenCount] = useState(0);
+ // The catalog is re-asked on an open only when its last read FAILED — a ref,
+ // not state: a failure must not itself trigger a re-ask (no loop against a
+ // catalog that alternates between failing and answering).
+ const catalogFailedRef = useRef(false);
+ const [catalogAsk, setCatalogAsk] = useState(0);
+ const openEvent = useCallback((eventId: string) => {
+ if (!eventId) return;
+ setOpenedIds(prev => (prev.includes(eventId) ? prev : [...prev, eventId]));
+ setOpenCount(n => n + 1);
+ if (catalogFailedRef.current) setCatalogAsk(n => n + 1);
+ }, []);
+ // Until the own list settles nothing is known to be foreign or not; an own
+ // event is never re-read by URI. Keyed by a joined string so the array
+ // identity only changes when the SET does (each own-list refetch builds a
+ // new Set).
+ const settledOpenedKey = ownSettled ? openedIds.join('\n') : '';
+ const settledOpenedIds = useMemo(() => (settledOpenedKey ? settledOpenedKey.split('\n') : []), [settledOpenedKey]);
+ const foreignEventKey = settledOpenedIds.filter(id => !ownEventIds.has(id)).join('\n');
+ const foreignEventIds = useMemo(() => (foreignEventKey ? foreignEventKey.split('\n') : []), [foreignEventKey]);
+
+ // Another user's event, read by URI (`data/publicRead.ts`): the URI came
+ // from the catalog or a link, so the read is anonymous — the token goes only
+ // to the session's own origin. Unreadable → absent → "not found".
+ const tokenOf = useCallback((uri: string) => tokenFor(uri, token, trustedOrigin), [token, trustedOrigin]);
+ const foreignEventsRead = usePublicRecords(session ? foreignEventIds : [], { retry: openCount, tokenOf });
+
+ // The opened events' meeting points held in OTHER pods: the catalog names
+ // them (the only cross-user index), fail-open — a catalog that cannot be read
+ // leaves the event with only the meeting points the own list holds.
+ const backendUrl = getBackendUrl();
+ const [catalogMeetingPointIds, setCatalogMeetingPointIds] = useState([]);
+ // Re-asked when the opened set changes, and — only after a catalog read
+ // failed — on the next open (`catalogAsk`). A failed read keeps the meeting
+ // points already listed (it is "could not ask", not "none").
+ useEffect(() => {
+ if (settledOpenedIds.length === 0 || !backendUrl || !session) {
+ setCatalogMeetingPointIds(prev => (prev.length === 0 ? prev : []));
+ return;
+ }
+ const controller = new AbortController();
+ void fetchCatalogMeetingPointIds(backendUrl, settledOpenedIds, controller.signal).then(ids => {
+ if (controller.signal.aborted) return;
+ catalogFailedRef.current = ids === undefined;
+ if (ids !== undefined) setCatalogMeetingPointIds(ids);
+ });
+ return () => controller.abort();
+ }, [settledOpenedIds, backendUrl, session, catalogAsk]);
+ // An own meeting point (on an own event, or one this user proposed on
+ // another's) is already in the own list.
+ const foreignMeetingPointKey = catalogMeetingPointIds.filter(id => !ownMeetingPointIds.has(id)).join('\n');
+ const foreignMeetingPointIds = useMemo(
+ () => (foreignMeetingPointKey ? foreignMeetingPointKey.split('\n') : []),
+ [foreignMeetingPointKey],
+ );
+ const foreignMeetingPointsRead = usePublicRecords(foreignMeetingPointIds, { retry: openCount, tokenOf });
+
+ // What was read is trusted for what it IS, not for what the catalog or the
+ // route said it was: an "event" URI that reads as a meeting point (and the
+ // reverse) is dropped by the binding's own test. Only the records of the
+ // CURRENT id lists are taken (the read keeps earlier ones while it refreshes).
+ const readRecords: unknown[] = useMemo(() => {
+ const pick = (ids: readonly string[], read: ReadonlyMap, keep: (record: unknown) => boolean) =>
+ ids.flatMap(id => {
+ const record = read.get(id);
+ return record !== undefined && keep(record) ? [record] : [];
+ });
+ const foreignEvents = pick(foreignEventIds, foreignEventsRead.records,
+ record => bindings.event.isInstance?.(record) ?? true);
+ const foreignMeetingPoints = pick(foreignMeetingPointIds, foreignMeetingPointsRead.records,
+ record => bindings.meetingPoint.isInstance?.(record) ?? false);
+ return foreignEvents.length === 0 && foreignMeetingPoints.length === 0
+ ? rawRecords
+ : [...rawRecords, ...foreignEvents, ...foreignMeetingPoints];
+ }, [rawRecords, foreignEventIds, foreignEventsRead.records, foreignMeetingPointIds,
+ foreignMeetingPointsRead.records, bindings]);
+
+ // --- ATTENDEES COLLECTIONS, read by URI and embedded --------------------------
+ // Every meeting point's linked collection, own and foreign alike — public by
+ // design, read by URI with the same token policy, keyed by the URI the record
+ // links (never by what the fetched document claims). Re-read after a
+ // join/leave (`membershipRefresh`); a failed one is retried on the next open.
+ const [membershipRefresh, setMembershipRefresh] = useState(0);
+ const collectionKey = useMemo(() => {
+ const links = new Set();
+ for (const record of readRecords) {
+ if (!(bindings.meetingPoint.isInstance?.(record) ?? false)) continue;
+ const link = attendeesCollectionLink(record);
+ if (link) links.add(link);
+ }
+ return [...links].join('\n');
+ }, [readRecords, bindings]);
+ const collectionIds = useMemo(() => (collectionKey ? collectionKey.split('\n') : []), [collectionKey]);
+ const collectionsRead = usePublicRecords(session ? collectionIds : [], {
+ refresh: membershipRefresh,
+ retry: openCount,
+ tokenOf,
+ });
+ const embeddedRecords = useMemo(
+ () => (collectionsRead.records.size === 0
+ ? readRecords
+ : readRecords.map(record => embedAttendeesCollection(record, collectionsRead.records))),
+ [readRecords, collectionsRead.records],
+ );
// --- SPLIT + PARTICIPATION/COUNT DERIVATION (vocabulary lives in bindings) --
- const derived = useMemo(() => deriveContainer(rawRecords, bindings), [rawRecords, bindings]);
+ const derived = useMemo(() => deriveContainer(embeddedRecords, bindings), [embeddedRecords, bindings]);
// --- OPTIMISTIC MEMBERSHIP OVERLAY ------------------------------------------
// `watchShape`-style latency, Refine edition: the outbox post returns before
@@ -146,6 +306,15 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
() => applyMembershipOverlay(derived.participations, overlay, webId),
[derived.participations, overlay, webId],
);
+ // The counts move with the overlay, so a join shows in the number at once.
+ const counted = useMemo(() => countsWithOverlay(derived, overlay, webId), [derived, overlay, webId]);
+ const allEvents = counted.events;
+ const allMeetingPoints = counted.meetingPoints;
+ const ownEvents = useMemo(() => allEvents.filter(e => ownEventIds.has(e.id)), [allEvents, ownEventIds]);
+ const ownMeetingPoints = useMemo(
+ () => allMeetingPoints.filter(mp => ownMeetingPointIds.has(mp.id)),
+ [allMeetingPoints, ownMeetingPointIds],
+ );
// RECONCILIATION — drop each overlay entry once the container read carries
// what it asserts (join: membership present; leave: membership absent).
@@ -219,7 +388,9 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
const fresh: FpUserData[] = [];
const refused: string[] = [];
for (const id of missing) {
- const record = await fetchActorProfileRecord(id, token);
+ // The token goes only to the session's own pod provider: attendee
+ // URIs come from collections other users' meeting points link to.
+ const record = await fetchActorProfileRecord(id, token, trustedOrigin);
if (record === undefined) {
refused.push(id);
} else {
@@ -248,7 +419,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
// 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]);
+ }, [attendeeIds, unresolved, token, trustedOrigin, bindings]);
const users = useMemo(() => {
const all = myProfile ? [myProfile, ...resolvedProfiles] : resolvedProfiles;
@@ -261,9 +432,9 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
const [selectedUserId, setSelectedUserId] = useState('');
useEffect(() => {
- const first = derived.events[0];
+ const first = ownEvents[0];
if (!selectedEventId && first) setSelectedEventId(first.id);
- }, [derived.events, selectedEventId]);
+ }, [ownEvents, selectedEventId]);
// Identity-first log prefix — same shape as the other providers.
const logPrefix = currentUserId || currentPrincipal
@@ -272,17 +443,17 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
// --- Event-level derivations over meeting-point memberships -------------------
const mpIdsOf = (eventId: string) =>
- derived.meetingPoints.filter(mp => mp.eventId === eventId).map(mp => mp.id);
+ allMeetingPoints.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,
+ allEvents,
users,
participations,
- derived.meetingPoints,
+ allMeetingPoints,
[],
webId ?? '',
);
@@ -299,21 +470,21 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
}
}
return users.filter(u => ids.has(u.id));
- }, [derived.meetingPoints, participations, users]);
+ }, [allMeetingPoints, 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.
+ // session can read (the own list, plus the events opened by URI).
+ // Someone else's events-by-participation are not reachable.
if (userId !== currentUserId || !webId) return [];
const joinedEventIds = new Set();
for (const p of participations) {
if (p.userId !== webId) continue;
- const mp = derived.meetingPoints.find(m => m.id === p.eventId);
+ const mp = allMeetingPoints.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]);
+ return allEvents.filter(e => joinedEventIds.has(e.id));
+ }, [currentUserId, webId, participations, allMeetingPoints, allEvents]);
// --- Ownership: editing is owning ------------------------------------------------
// The list is own-pod scoped, so membership in it IS ownership. A failed
@@ -326,10 +497,24 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
}, [containerFailed, logPrefix]);
const getEventOwnership = useCallback((eventId: string): EventOwnership => {
- if (derived.events.some(e => e.id === eventId)) return 'mine';
+ // The OWN list answers — an event opened by URI is read, not owned. An
+ // event THIS session just created is its own even before the refetch the
+ // create triggered lands (it may be read by URI first): the create wrote
+ // it into the own container.
+ if (ownEventIds.has(eventId) || createdEventIds.includes(eventId)) return 'mine';
if (containerQuery.query.isPending) return 'unknown';
return 'not-mine';
- }, [derived.events, containerQuery.query.isPending]);
+ }, [ownEventIds, createdEventIds, containerQuery.query.isPending]);
+
+ const getEventReadState = useCallback((eventId: string): EventReadState => {
+ if (allEvents.some(e => e.id === eventId)) return 'ready';
+ // The own list may still bring it — on its first read, and on a re-read
+ // (an event created a moment ago lands with the refetch the create
+ // triggered, possibly before its public grant lets the URI read succeed).
+ if (containerQuery.query.isPending || containerQuery.query.isFetching) return 'loading';
+ if (foreignEventsRead.pending.has(eventId)) return 'loading';
+ return 'unavailable';
+ }, [allEvents, containerQuery.query.isPending, containerQuery.query.isFetching, foreignEventsRead.pending]);
// --- Mutations through the data provider ------------------------------------------
const { mutateAsync: createRecord } = useCreate();
@@ -346,13 +531,19 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
if (revalidateTimer.current) clearTimeout(revalidateTimer.current);
revalidateTimer.current = null;
}, []);
+ // What a join/leave changes: the attendees collections (read by URI) and the
+ // own list.
+ const refreshMemberships = useCallback(() => {
+ void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
+ setMembershipRefresh(n => n + 1);
+ }, [invalidate, bindings]);
const scheduleListRevalidate = useCallback(() => {
if (revalidateTimer.current) clearTimeout(revalidateTimer.current);
revalidateTimer.current = setTimeout(() => {
revalidateTimer.current = null;
- void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
+ refreshMemberships();
}, 1500);
- }, [invalidate, bindings]);
+ }, [refreshMemberships]);
const createEvent = useCallback(async (event: Omit): Promise => {
console.log(`${logPrefix} createEvent (refine):`, event.title);
@@ -361,6 +552,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
values: bindings.event.toValues(event),
});
const created = bindings.event.toDomain(response.data);
+ setCreatedEventIds(prev => (prev.includes(created.id) ? prev : [...prev, created.id]));
void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
if (currentUserId) setSelectedEventId(created.id);
return created;
@@ -411,7 +603,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
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);
+ const mp = allMeetingPoints.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.');
@@ -419,16 +611,16 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
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'] });
+ refreshMemberships();
scheduleListRevalidate();
- }, [logPrefix, derived.meetingPoints, session, webId, invalidate, bindings, scheduleListRevalidate]);
+ }, [logPrefix, allMeetingPoints, session, webId, refreshMemberships, scheduleListRevalidate]);
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);
+ const mp = allMeetingPoints.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).');
@@ -442,9 +634,9 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
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'] });
+ refreshMemberships();
scheduleListRevalidate();
- }, [logPrefix, derived.meetingPoints, participations, session, webId, invalidate, bindings, scheduleListRevalidate]);
+ }, [logPrefix, allMeetingPoints, participations, session, webId, refreshMemberships, scheduleListRevalidate]);
// --- The rest of the contract -------------------------------------------------------
const addFriend = useCallback((friendId: string) => {
@@ -463,19 +655,20 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
return emptyBootstrap();
}, [logPrefix]);
- const selectedEvent = derived.events.find(e => e.id === selectedEventId);
+ const selectedEvent = allEvents.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,
+ console.log(`${logPrefix} Render — refine/${backend.name} | events:`, ownEvents.length,
+ '| opened:', allEvents.length - ownEvents.length,
+ '| meetingPoints:', allMeetingPoints.length, '| participations:', participations.length,
'| selectedEvent:', selectedEvent?.title ?? '(none)');
return {
currentUserId, currentUser, currentPrincipal,
- events: derived.events,
+ events: ownEvents,
users,
participations,
- meetingPoints: derived.meetingPoints,
+ meetingPoints: ownMeetingPoints,
friendships: [],
notifications: [],
selectedEventId, setSelectedEventId, selectedEvent,
@@ -488,6 +681,8 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
getFriends: shared.getFriends,
getEventMeetingPoints: shared.getEventMeetingPoints,
getEventOwnership,
+ openEvent,
+ getEventReadState,
createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint,
addMeetingPoint, addFriend, updateProfile, loadTestData,
};
diff --git a/src/shared/context/refineDerive.test.ts b/src/shared/context/refineDerive.test.ts
index 8668001..17d0bc8 100644
--- a/src/shared/context/refineDerive.test.ts
+++ b/src/shared/context/refineDerive.test.ts
@@ -1,5 +1,5 @@
-import { expect, test } from 'bun:test';
-import { deriveContainer, applyMembershipOverlay } from './refineDerive';
+import { expect, spyOn, test } from 'bun:test';
+import { deriveContainer, applyMembershipOverlay, countsWithOverlay } from './refineDerive';
import { getBackend } from '../refine/providers';
import type { FpParticipationData } from '../data/types';
@@ -126,3 +126,89 @@ test('an empty overlay or no session leaves the read untouched', () => {
expect(applyMembershipOverlay(read, new Map(), ALICE)).toEqual(read);
expect(applyMembershipOverlay(read, new Map([[MP, 'join']]), undefined)).toEqual(read);
});
+
+// ── Counts follow the overlay ──────────────────────────────────────────────
+// A join is posted beside Refine and lands in the collection asynchronously;
+// the number must move with the membership, not wait for the re-read.
+
+const MP2 = 'http://localhost:3000/alice/data/mp-2';
+function container(mp1Members: unknown[], mp2Members: unknown[]) {
+ return deriveContainer(
+ [
+ { id: EVENT, type: 'Event', name: 'Concert' },
+ { id: MP, type: 'Event', 'as:context': EVENT, 'apods:attendees': { id: COLLECTION, items: mp1Members } },
+ { id: MP2, type: 'Event', 'as:context': EVENT, 'apods:attendees': { id: `${COLLECTION}-2`, items: mp2Members } },
+ ],
+ bindings,
+ );
+}
+
+test("a meeting point carries its own count, and the event's is their sum", () => {
+ const derived = container([BOB], [BOB, ALICE]);
+ expect(derived.meetingPoints.map(mp => mp.participantCount)).toEqual([1, 2]);
+ expect(derived.events[0]?.participantCount).toBe(3);
+});
+
+test('a pending join adds one to its meeting point and to the event', () => {
+ const derived = container([BOB], []);
+ const counted = countsWithOverlay(derived, new Map([[MP, 'join']]), ALICE);
+ expect(counted.meetingPoints.find(mp => mp.id === MP)?.participantCount).toBe(2);
+ expect(counted.meetingPoints.find(mp => mp.id === MP2)?.participantCount).toBe(0);
+ expect(counted.events[0]?.participantCount).toBe(2);
+});
+
+test('a join the read already carries is not counted twice', () => {
+ const derived = container([BOB, ALICE], []);
+ const counted = countsWithOverlay(derived, new Map([[MP, 'join']]), ALICE);
+ expect(counted.meetingPoints.find(mp => mp.id === MP)?.participantCount).toBe(2);
+ expect(counted.events[0]?.participantCount).toBe(2);
+});
+
+test('a pending leave removes one; a leave the read no longer carries removes nothing', () => {
+ const pending = countsWithOverlay(container([BOB, ALICE], []), new Map([[MP, 'leave']]), ALICE);
+ expect(pending.meetingPoints.find(mp => mp.id === MP)?.participantCount).toBe(1);
+ expect(pending.events[0]?.participantCount).toBe(1);
+ const landed = countsWithOverlay(container([BOB], []), new Map([[MP, 'leave']]), ALICE);
+ expect(landed.meetingPoints.find(mp => mp.id === MP)?.participantCount).toBe(1);
+});
+
+test('no overlay, no session, or an overlay on an unknown meeting point leaves the counts as read', () => {
+ const derived = container([BOB], []);
+ expect(countsWithOverlay(derived, new Map(), ALICE).events).toBe(derived.events);
+ expect(countsWithOverlay(derived, new Map([[MP, 'join']]), undefined).meetingPoints).toBe(derived.meetingPoints);
+ const stray = countsWithOverlay(derived, new Map([['http://elsewhere/mp', 'join']]), ALICE);
+ expect(stray.events[0]?.participantCount).toBe(1);
+});
+
+// ── Per-item isolation: other pods' data never takes the read down ──────────
+
+test('an attendee entry carrying no reference is skipped; the meeting point and the other members stay', () => {
+ const warn = spyOn(console, 'warn').mockImplementation(() => {});
+ const derived = deriveContainer(
+ [
+ { id: EVENT, type: 'Event', name: 'Concert' },
+ { id: MP, type: 'Event', 'as:context': EVENT, 'apods:attendees': { id: COLLECTION, items: [42, BOB, {}, null] } },
+ ],
+ bindings,
+ );
+ expect(derived.meetingPoints.map(mp => mp.id)).toEqual([MP]);
+ expect(derived.participations.map(p => p.userId)).toEqual([BOB]);
+ expect(derived.events.map(e => e.id)).toEqual([EVENT]);
+ expect(warn).toHaveBeenCalled();
+ warn.mockRestore();
+});
+
+test('a record the bindings reject is skipped; the others are derived', () => {
+ const warn = spyOn(console, 'warn').mockImplementation(() => {});
+ const derived = deriveContainer(
+ [
+ { type: 'Event', name: 'No identifier' },
+ { id: EVENT, type: 'Event', name: 'Concert' },
+ { type: 'Event', 'as:context': EVENT },
+ ],
+ bindings,
+ );
+ expect(derived.events.map(e => e.id)).toEqual([EVENT]);
+ expect(derived.meetingPoints).toEqual([]);
+ warn.mockRestore();
+});
diff --git a/src/shared/context/refineDerive.ts b/src/shared/context/refineDerive.ts
index ae805d5..c522fcc 100644
--- a/src/shared/context/refineDerive.ts
+++ b/src/shared/context/refineDerive.ts
@@ -17,7 +17,14 @@
*
* 2. `applyMembershipOverlay` reflects a join/leave made beside Refine (through
* the outbox) before the collection read carries it. It reconciles away on
- * its own once the read shows the truth — no polling.
+ * its own once the read shows the truth — no polling. `countsWithOverlay`
+ * applies the same overlay to the COUNTS, so a join shows in the number the
+ * moment it shows in the membership.
+ *
+ * THE COLLECTION MUST BE EMBEDDED TO BE COUNTED. The pod links a meeting
+ * point's collection by URI and never embeds it (verified 2026-10-12); the
+ * reader fetches it and embeds it before calling `deriveContainer`
+ * (`data/attendees.ts`). A link left un-embedded reads as an honest zero.
*/
import type { FpEventData, FpMeetingPointData, FpParticipationData } from '../data/types';
@@ -54,33 +61,57 @@ export function deriveContainer(
const meetingPoints: FpMeetingPointData[] = [];
const participations: FpParticipationData[] = [];
const totals = new Map();
+ // PER-ITEM ISOLATION. Records and collection members come from other users'
+ // pods too (events opened by URI, their meeting points, the collections those
+ // link to): one that the bindings reject is skipped with a warning — it never
+ // takes the whole read down with it.
for (const record of records) {
- 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 });
+ try {
+ 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) {
+ try {
+ const mapped = bindings.participation.toDomain(member);
+ participations.push({ ...mapped, id: `${mp.id}|${mapped.userId}`, eventId: mp.id });
+ } catch (error) {
+ console.warn(`[refine] skipped an unreadable attendee of ${mp.id}`, error);
+ }
+ }
+ const total = attendees?.total ?? members.length;
+ totals.set(mp.id, total);
+ mp.participantCount = total;
+ } else if (bindings.event.isInstance?.(record) ?? true) {
+ events.push(bindings.event.toDomain(record));
}
- totals.set(mp.id, attendees?.total ?? members.length);
- } else if (bindings.event.isInstance?.(record) ?? true) {
- events.push(bindings.event.toDomain(record));
+ } catch (error) {
+ console.warn('[refine] skipped a record the bindings cannot read', error);
}
}
- // 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);
+ event.participantCount = eventParticipantCount(event.id, meetingPoints, totals);
}
return { events, meetingPoints, participations, totals };
}
+/**
+ * The event's count: the SUM of its meeting points' collection totals —
+ * derived at read time, never a stored counter. A meeting point whose
+ * collection could not be read contributes 0, which is the honest "unknown"
+ * reading of a total nobody could count for us.
+ */
+function eventParticipantCount(
+ eventId: string,
+ meetingPoints: readonly FpMeetingPointData[],
+ totals: ReadonlyMap,
+): number {
+ return meetingPoints
+ .filter(mp => mp.eventId === eventId)
+ .reduce((sum, mp) => sum + (totals.get(mp.id) ?? 0), 0);
+}
+
/** A membership overlay entry: what this session just joined or left. */
export type MembershipOverlay = Map;
@@ -115,3 +146,38 @@ export function applyMembershipOverlay(
}
return [...byMp.values()].flat();
}
+
+/**
+ * The counts this session should see: the read totals, adjusted by the same
+ * optimistic overlay `applyMembershipOverlay` applies to the memberships — a
+ * `join` the read does not carry yet adds one to that meeting point, a `leave`
+ * the read still carries removes one. Event counts are re-summed from the
+ * adjusted totals, exactly as `deriveContainer` sums them. Once the read
+ * carries the act, the overlay entry adjusts nothing (and is reconciled away).
+ * With no overlay, or no `webId` to attribute it to, the derived lists are
+ * returned as they are (same arrays).
+ */
+export function countsWithOverlay(
+ derived: DerivedContainer,
+ overlay: MembershipOverlay,
+ webId: string | undefined,
+): { events: FpEventData[]; meetingPoints: FpMeetingPointData[] } {
+ if (overlay.size === 0 || !webId) return { events: derived.events, meetingPoints: derived.meetingPoints };
+ const totals = new Map(derived.totals);
+ for (const [mpId, op] of overlay) {
+ const base = totals.get(mpId);
+ if (base === undefined) continue;
+ const readCarries = derived.participations.some(p => p.eventId === mpId && p.userId === webId);
+ if (op === 'join' && !readCarries) totals.set(mpId, base + 1);
+ else if (op === 'leave' && readCarries) totals.set(mpId, Math.max(0, base - 1));
+ }
+ const meetingPoints = derived.meetingPoints.map(mp => {
+ const total = totals.get(mp.id);
+ return total === undefined || total === mp.participantCount ? mp : { ...mp, participantCount: total };
+ });
+ const events = derived.events.map(event => {
+ const count = eventParticipantCount(event.id, meetingPoints, totals);
+ return count === event.participantCount ? event : { ...event, participantCount: count };
+ });
+ return { events, meetingPoints };
+}
diff --git a/src/shared/context/usePublicRecords.test.ts b/src/shared/context/usePublicRecords.test.ts
new file mode 100644
index 0000000..d09ddf4
--- /dev/null
+++ b/src/shared/context/usePublicRecords.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, test } from 'bun:test';
+import { landedAfter, needsRead } from './usePublicRecords';
+
+const RECORD = { id: 'http://localhost:3000/bob/data/e1', type: 'Event' };
+
+describe('landedAfter — last good wins', () => {
+ test('a successful read stores the record', () => {
+ expect(landedAfter(undefined, 0, 0, RECORD)).toEqual({ refresh: 0, retry: 0, record: RECORD, failed: false });
+ });
+ test('a failed re-read keeps the record already shown, and is marked failed', () => {
+ const shown = landedAfter(undefined, 0, 0, RECORD);
+ expect(landedAfter(shown, 1, 0, undefined)).toEqual({ refresh: 1, retry: 0, record: RECORD, failed: true });
+ });
+ test('a first read that fails stores nothing', () => {
+ expect(landedAfter(undefined, 0, 0, undefined).record).toBeUndefined();
+ });
+});
+
+describe('needsRead — refresh re-reads everything, retry re-reads only failures', () => {
+ const ok = landedAfter(undefined, 0, 0, RECORD);
+ const failed = landedAfter(undefined, 0, 0, undefined);
+ test('never read → read', () => {
+ expect(needsRead(undefined, 0, 0)).toBe(true);
+ });
+ test('landed for the current counters → no read', () => {
+ expect(needsRead(ok, 0, 0)).toBe(false);
+ expect(needsRead(failed, 0, 0)).toBe(false);
+ });
+ test('a new retry re-reads a failure, never a success', () => {
+ expect(needsRead(failed, 0, 1)).toBe(true);
+ expect(needsRead(ok, 0, 1)).toBe(false);
+ });
+ test('a new refresh re-reads both', () => {
+ expect(needsRead(ok, 1, 0)).toBe(true);
+ expect(needsRead(failed, 1, 0)).toBe(true);
+ });
+});
diff --git a/src/shared/context/usePublicRecords.ts b/src/shared/context/usePublicRecords.ts
new file mode 100644
index 0000000..ff1cc91
--- /dev/null
+++ b/src/shared/context/usePublicRecords.ts
@@ -0,0 +1,100 @@
+/**
+ * `usePublicRecords` — the React side of `data/publicRead.ts`: read a set of
+ * records by URI, each one INDEPENDENTLY.
+ *
+ * - `records` is keyed by the REQUESTED URI. A URI never read successfully is
+ * absent. LAST GOOD WINS: a re-read that fails keeps the record already
+ * shown (a blip never blanks the screen or turns a membership off).
+ * - `refresh` (a counter) re-reads every URI — what a join/leave needs for the
+ * attendees collections. `retry` (a counter) re-reads only the URIs whose
+ * last read FAILED — what re-opening an event needs, without re-reading
+ * everything that already landed.
+ * - `pending` holds the current URIs whose read for the current counters has
+ * not landed yet. It is computed from state, so a URI is pending from the
+ * very render it enters the set — "not read yet" is never mistaken for
+ * "absent".
+ * - Each URI lands on its own: one slow or dead host delays that URI only
+ * (and gives up after the read timeout), never the others.
+ */
+
+import { useEffect, useMemo, useRef, useState } from 'react';
+import type { JsonRecord } from '../data/jsonRecord';
+import { readEachRecord } from '../data/publicRead';
+
+export interface Landed {
+ /** The counters this read was made for. */
+ refresh: number;
+ retry: number;
+ /** The last record read successfully (kept across a failed re-read). */
+ record: JsonRecord | undefined;
+ /** Whether THIS read failed. */
+ failed: boolean;
+}
+
+export interface PublicRecordsRead {
+ records: ReadonlyMap;
+ pending: ReadonlySet;
+}
+
+export interface PublicRecordsOptions {
+ refresh?: number;
+ retry?: number;
+ /** The token to attach for a URI — the caller grants one only for its own origin. */
+ tokenOf?: (uri: string) => string | undefined;
+}
+
+/**
+ * What a read landing leaves for its URI: the counters it was made for, and
+ * the LAST GOOD record — a failed read keeps the previous one.
+ */
+export function landedAfter(
+ previous: Landed | undefined,
+ refresh: number,
+ retry: number,
+ record: JsonRecord | undefined,
+): Landed {
+ return { refresh, retry, record: record ?? previous?.record, failed: record === undefined };
+}
+
+/** Does this URI need a read for the current counters? */
+export function needsRead(entry: Landed | undefined, refresh: number, retry: number): boolean {
+ if (!entry || entry.refresh !== refresh) return true;
+ return entry.failed && entry.retry !== retry;
+}
+
+export function usePublicRecords(uris: readonly string[], options: PublicRecordsOptions = {}): PublicRecordsRead {
+ const { refresh = 0, retry = 0, tokenOf } = options;
+ const key = uris.join('\n');
+ const [landed, setLanded] = useState>(new Map());
+ // Read by the effect without re-running it: what already landed is not read
+ // again when the set grows, and the token policy is the caller's current one.
+ const landedRef = useRef(landed);
+ landedRef.current = landed;
+ const tokenOfRef = useRef(tokenOf);
+ tokenOfRef.current = tokenOf;
+
+ useEffect(() => {
+ if (!key) return;
+ const wanted = key.split('\n').filter(uri => needsRead(landedRef.current.get(uri), refresh, retry));
+ if (wanted.length === 0) return;
+ const controller = new AbortController();
+ void readEachRecord(
+ wanted,
+ (uri, record) => setLanded(prev => new Map(prev).set(uri, landedAfter(prev.get(uri), refresh, retry, record))),
+ { signal: controller.signal, tokenOf: uri => tokenOfRef.current?.(uri) },
+ );
+ return () => controller.abort();
+ }, [key, refresh, retry]);
+
+ const records = useMemo(() => {
+ const out = new Map();
+ for (const [uri, entry] of landed) if (entry.record) out.set(uri, entry.record);
+ return out;
+ }, [landed]);
+ const pending = useMemo(() => {
+ const out = new Set();
+ if (key) for (const uri of key.split('\n')) if (needsRead(landed.get(uri), refresh, retry)) out.add(uri);
+ return out;
+ }, [key, landed, refresh, retry]);
+ return { records, pending };
+}
diff --git a/src/shared/data/actor.ts b/src/shared/data/actor.ts
index 6b935cc..63f6def 100644
--- a/src/shared/data/actor.ts
+++ b/src/shared/data/actor.ts
@@ -18,6 +18,7 @@
*/
import { asRecord, linkId } from './jsonRecord';
+import { tokenFor } from './publicRead';
/** The parts of the actor (WebID) document this app uses. */
export interface ActorDocument {
@@ -60,15 +61,24 @@ export async function fetchActorDocument(webId: string, token?: string): Promise
* 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.
+ *
+ * THE TOKEN STAYS HOME when `trustedOrigin` is given. Attendee URIs come out of
+ * collections other users' meeting points link to, and an actor document names
+ * its own profile URI: with `trustedOrigin` (the session's own pod provider),
+ * each of the two fetches carries the token only when it targets that origin
+ * (`tokenFor`), and goes out anonymously anywhere else.
*/
-export async function fetchActorProfileRecord(webId: string, token?: string): Promise {
+export async function fetchActorProfileRecord(webId: string, token?: string, trustedOrigin?: string): Promise {
+ const credentialFor = (uri: string): string | undefined =>
+ trustedOrigin === undefined ? token : tokenFor(uri, token, trustedOrigin);
try {
- const actor = await fetchActorDocument(webId, token);
+ const actor = await fetchActorDocument(webId, credentialFor(webId));
if (!actor.profileUri) return undefined;
+ const profileToken = credentialFor(actor.profileUri);
const response = await fetch(actor.profileUri, {
headers: {
Accept: 'application/ld+json',
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ ...(profileToken ? { Authorization: `Bearer ${profileToken}` } : {}),
},
});
if (!response.ok) return undefined;
diff --git a/src/shared/data/attendees.test.ts b/src/shared/data/attendees.test.ts
new file mode 100644
index 0000000..c72950b
--- /dev/null
+++ b/src/shared/data/attendees.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, test } from 'bun:test';
+import { attendeesCollectionLink, embedAttendeesCollection } from './attendees';
+import { deriveContainer } from '../context/refineDerive';
+import { getBackend } from '../refine/providers';
+
+const bindings = getBackend('activitypods').bindings;
+
+const EVENT = 'http://localhost:3000/bob/data/event-1';
+const MP = 'http://localhost:3000/bob/data/mp-1';
+const COLLECTION = 'http://localhost:3000/bob/data/att-1';
+const ALICE = 'http://localhost:3000/alice';
+const CAROL = 'http://localhost:3000/carol';
+
+// The shapes the pod returns, as observed live: the record links the collection
+// by URI; the collection is its own resource with `items` and no `totalItems`.
+const meetingPoint = { id: MP, type: 'Event', 'as:context': EVENT, 'apods:attendees': COLLECTION };
+const collection = { id: COLLECTION, type: 'Collection', 'semapps:dereferenceItems': false, items: [ALICE, CAROL] };
+
+describe('attendeesCollectionLink', () => {
+ test('a bare URI is a link to read', () => {
+ expect(attendeesCollectionLink(meetingPoint)).toBe(COLLECTION);
+ expect(attendeesCollectionLink({ ...meetingPoint, 'apods:attendees': [COLLECTION] })).toBe(COLLECTION);
+ });
+ test('an embedded collection, no collection or no record is nothing to read', () => {
+ expect(attendeesCollectionLink({ ...meetingPoint, 'apods:attendees': collection })).toBeUndefined();
+ expect(attendeesCollectionLink({ id: EVENT, type: 'Event' })).toBeUndefined();
+ expect(attendeesCollectionLink(null)).toBeUndefined();
+ });
+});
+
+describe('embedAttendeesCollection', () => {
+ test('a read collection is embedded where the participation binding reads it', () => {
+ const embedded = embedAttendeesCollection(meetingPoint, new Map([[COLLECTION, collection]]));
+ const derived = deriveContainer([{ id: EVENT, type: 'Event', name: 'Concert' }, embedded], bindings);
+ expect(derived.participations.map(p => p.userId)).toEqual([ALICE, CAROL]);
+ // No totalItems on the pod's collection: the count is the items.
+ expect(derived.totals.get(MP)).toBe(2);
+ expect(derived.meetingPoints[0]?.participantCount).toBe(2);
+ expect(derived.events[0]?.participantCount).toBe(2);
+ });
+
+ test('an empty read collection counts zero', () => {
+ const embedded = embedAttendeesCollection(meetingPoint, new Map([[COLLECTION, { ...collection, items: [] }]]));
+ const derived = deriveContainer([embedded], bindings);
+ expect(derived.totals.get(MP)).toBe(0);
+ });
+
+ test('an unread collection leaves the record as it was (same object) — it reads as an honest zero', () => {
+ expect(embedAttendeesCollection(meetingPoint, new Map())).toBe(meetingPoint);
+ expect(embedAttendeesCollection(meetingPoint, new Map([[COLLECTION, 'not-a-record']]))).toBe(meetingPoint);
+ const event = { id: EVENT, type: 'Event' };
+ expect(embedAttendeesCollection(event, new Map([[COLLECTION, collection]]))).toBe(event);
+ });
+});
diff --git a/src/shared/data/attendees.ts b/src/shared/data/attendees.ts
new file mode 100644
index 0000000..2d76a76
--- /dev/null
+++ b/src/shared/data/attendees.ts
@@ -0,0 +1,39 @@
+/**
+ * Attendees collections, joined back onto the meeting point that links them.
+ *
+ * WHAT THE POD ACTUALLY RETURNS (verified live 2026-10-12, container listing,
+ * direct GET and catalog item alike): a meeting point record carries its
+ * `apods:attendees` collection as a BARE URI, never embedded. The collection is
+ * its own resource — `{ type: Collection, items: [...] }`, members as actor
+ * URIs, no `totalItems` — readable by anyone (public read, granted at creation).
+ *
+ * The participation binding reads members from an EMBEDDED collection object
+ * (`membersOf`), and an un-embedded link reads as an honest zero. So the
+ * reader fetches each linked collection by its URI and puts it back where the
+ * binding looks: this module is that join, pure and vocabulary-only — no fetch,
+ * no React. A collection that could not be read is simply not embedded, and
+ * the record keeps reading as zero.
+ */
+
+import { asRecord, asString, firstEntry } from './jsonRecord';
+
+const ATTENDEES = 'apods:attendees';
+
+/** The attendees collection a record LINKS without embedding it — its URI — or `undefined`. */
+export function attendeesCollectionLink(record: unknown): string | undefined {
+ return asString(firstEntry(asRecord(record)?.[ATTENDEES]));
+}
+
+/**
+ * The record with its linked attendees collection embedded, when that
+ * collection was read (`collections` is keyed by collection URI). Otherwise the
+ * record unchanged — the same object, so an unaffected record keeps its identity.
+ */
+export function embedAttendeesCollection(record: unknown, collections: ReadonlyMap): unknown {
+ const link = attendeesCollectionLink(record);
+ if (link === undefined) return record;
+ const collection = asRecord(collections.get(link));
+ const host = asRecord(record);
+ if (!collection || !host) return record;
+ return { ...host, [ATTENDEES]: collection };
+}
diff --git a/src/shared/data/catalog.test.ts b/src/shared/data/catalog.test.ts
index d848cc4..ed7f1d2 100644
--- a/src/shared/data/catalog.test.ts
+++ b/src/shared/data/catalog.test.ts
@@ -1,5 +1,11 @@
import { afterEach, describe, expect, spyOn, test } from 'bun:test';
-import { catalogEventsFromBody, fetchCatalogEvents, unionEvents } from './catalog';
+import {
+ catalogEventsFromBody,
+ catalogMeetingPointIdsOf,
+ fetchCatalogEvents,
+ fetchCatalogMeetingPointIds,
+ unionEvents,
+} from './catalog';
import type { FpEventData } from './types';
const ev = (id: string, title = id): FpEventData => ({
@@ -73,3 +79,85 @@ describe('fetchCatalogEvents fails open', () => {
warn.mockRestore();
});
});
+
+// PER-ITEM ISOLATION: the catalog aggregates many pods' records, so one bad item
+// must cost that item only — never the whole list.
+describe('one malformed catalog item is skipped, the valid ones are kept', () => {
+ const mixed = {
+ type: 'Collection',
+ items: [
+ { id: 'http://pod/x/e1', type: 'Event', name: 'Concert' },
+ { type: 'Event', name: 'No identifier' },
+ 'not-an-object',
+ { '@id': 'http://pod/y/e2', type: 'Event', name: 'Expo' },
+ { id: 'http://pod/x/m1', type: 'Event', name: 'Rdv', 'as:context': 'http://pod/x/e1' },
+ { type: 'Event', name: 'Rdv sans id', 'as:context': 'http://pod/x/e1' },
+ ],
+ };
+
+ test('events: the item the reader rejects is skipped and warned, the others mapped', () => {
+ const warn = spyOn(console, 'warn').mockImplementation(() => {});
+ const events = catalogEventsFromBody(mixed);
+ expect(events.map(e => e.id)).toEqual(['http://pod/x/e1', 'http://pod/y/e2']);
+ expect(warn).toHaveBeenCalled();
+ warn.mockRestore();
+ });
+
+ test('fetchCatalogEvents keeps the valid events of a body carrying a bad item', async () => {
+ const original = globalThis.fetch;
+ const warn = spyOn(console, 'warn').mockImplementation(() => {});
+ globalThis.fetch = (async () => new Response(JSON.stringify(mixed))) as unknown as typeof fetch;
+ try {
+ expect((await fetchCatalogEvents('http://b')).map(e => e.title)).toEqual(['Concert', 'Expo']);
+ } finally {
+ globalThis.fetch = original;
+ warn.mockRestore();
+ }
+ });
+
+ test('meeting points: an item without identifier is skipped, the readable one kept', () => {
+ const warn = spyOn(console, 'warn').mockImplementation(() => {});
+ expect(catalogMeetingPointIdsOf(mixed, ['http://pod/x/e1'])).toEqual(['http://pod/x/m1']);
+ warn.mockRestore();
+ });
+});
+
+describe('catalogMeetingPointIdsOf — the cross-user source of a foreign event\'s meeting points', () => {
+ test('returns the meeting points anchored on the asked events only, de-duplicated, in catalog order', () => {
+ const catalog = {
+ items: [
+ { id: 'http://pod/x/e1', type: 'Event', name: 'Concert' },
+ { id: 'http://pod/x/m1', type: 'Event', 'as:context': 'http://pod/x/e1' },
+ { id: 'http://pod/y/m2', type: 'Event', 'as:context': { id: 'http://pod/x/e1' } },
+ { id: 'http://pod/y/m3', type: 'Event', 'as:context': 'http://pod/y/e2' },
+ { id: 'http://pod/x/m1', type: 'Event', 'as:context': 'http://pod/x/e1' },
+ ],
+ };
+ expect(catalogMeetingPointIdsOf(catalog, ['http://pod/x/e1'])).toEqual(['http://pod/x/m1', 'http://pod/y/m2']);
+ expect(catalogMeetingPointIdsOf(catalog, ['http://pod/y/e2'])).toEqual(['http://pod/y/m3']);
+ expect(catalogMeetingPointIdsOf(catalog, ['http://pod/z/none'])).toEqual([]);
+ });
+
+ test('a malformed body has no meeting points', () => {
+ expect(catalogMeetingPointIdsOf(undefined, ['http://pod/x/e1'])).toEqual([]);
+ expect(catalogMeetingPointIdsOf({ items: 'x' }, ['http://pod/x/e1'])).toEqual([]);
+ });
+
+ test('fetchCatalogMeetingPointIds: an unreadable catalog is "could not ask" (undefined), not "none"; nothing asked, no fetch', async () => {
+ const original = globalThis.fetch;
+ let calls = 0;
+ const warn = spyOn(console, 'warn').mockImplementation(() => {});
+ try {
+ globalThis.fetch = (async () => { calls++; throw new Error('down'); }) as unknown as typeof fetch;
+ expect(await fetchCatalogMeetingPointIds('http://b', ['http://pod/x/e1'])).toBeUndefined();
+ expect(calls).toBe(1);
+ expect(await fetchCatalogMeetingPointIds('http://b', [])).toEqual([]);
+ expect(calls).toBe(1);
+ globalThis.fetch = (async () => new Response(JSON.stringify(body))) as unknown as typeof fetch;
+ expect(await fetchCatalogMeetingPointIds('http://b/', ['http://pod/x/e1'])).toEqual(['http://pod/x/m1']);
+ } finally {
+ globalThis.fetch = original;
+ warn.mockRestore();
+ }
+ });
+});
diff --git a/src/shared/data/catalog.ts b/src/shared/data/catalog.ts
index 69f9dd5..487adb5 100644
--- a/src/shared/data/catalog.ts
+++ b/src/shared/data/catalog.ts
@@ -9,56 +9,141 @@
*
* FAILS OPEN. Discovery is an enrichment of the list, never a precondition: a
* network error, a non-2xx answer or a malformed body all read back as "no
- * catalog events" (with one `console.warn`), so the caller still renders the
+ * catalog records" (with one `console.warn`), so the caller still renders the
* user's own events.
*
+ * PER-ITEM ISOLATION. The catalog aggregates records written by many pods; one
+ * malformed item (not an object, no identifier, a record the event reader
+ * rejects) is skipped with a warning and the valid items are kept — a bad item
+ * never empties the whole catalog.
+ *
* MEETING POINTS ARE IN THE CATALOG TOO (both are `as:Event`; a meeting point
- * carries `as:context` → its anchor event). They are filtered out here: the
- * Découvrir list shows events. The mapping is the app's existing event reader.
+ * carries `as:context` → its anchor event). They are filtered out of the
+ * Découvrir list, which shows events, and they are the ONLY cross-user source
+ * of another user's meeting points: `catalogMeetingPointIdsOf` answers "which
+ * meeting points hang off these events". The catalog item is an index entry,
+ * not the record — the caller reads the meeting point itself by its URI.
*/
import type { FpEventData } from './types';
-import { asRecord, linkId } from './jsonRecord';
+import { asRecord, asString, linkId, type JsonRecord } from './jsonRecord';
import { eventFromRecord } from '../refine/providers/activitypods';
-/** The records of a catalog body that are events (not meeting points), mapped to the app's event type. */
-export function catalogEventsFromBody(body: unknown): FpEventData[] {
+/** The catalog item's own identifier — `id` or `@id`, whichever is a string. */
+function itemId(record: JsonRecord): string | undefined {
+ return asString(record['id']) ?? asString(record['@id']);
+}
+
+/** The anchor event a catalog item points at — present on a meeting point, absent on an event. */
+function anchorOf(record: JsonRecord): string | undefined {
+ return linkId(record['as:context']);
+}
+
+/**
+ * The object items of a catalog body. A body without an `items` array has no
+ * items; a non-object item is skipped (and counted in one warning).
+ */
+export function catalogRecordsFromBody(body: unknown): JsonRecord[] {
const items = asRecord(body)?.['items'];
if (!Array.isArray(items)) return [];
- const events: FpEventData[] = [];
+ const records: JsonRecord[] = [];
+ let skipped = 0;
for (const item of items) {
const record = asRecord(item);
- if (!record) continue;
- if (linkId(record['as:context']) !== undefined) continue;
- const event = eventFromRecord(record);
- if (event.id) events.push(event);
+ if (record) records.push(record);
+ else skipped++;
+ }
+ if (skipped > 0) console.warn(`[catalog] skipped ${skipped} non-object catalog item(s)`);
+ return records;
+}
+
+/**
+ * The records of a catalog body that are events (not meeting points), mapped
+ * to the app's event type. An item the event reader rejects (no identifier, for
+ * one) is skipped and warned about; the others are kept.
+ */
+export function catalogEventsFromBody(body: unknown): FpEventData[] {
+ const events: FpEventData[] = [];
+ for (const record of catalogRecordsFromBody(body)) {
+ if (anchorOf(record) !== undefined) continue;
+ try {
+ events.push(eventFromRecord(record));
+ } catch (error) {
+ console.warn('[catalog] skipped a malformed catalog event item', error);
+ }
}
return events;
}
+/**
+ * The URIs of the catalog's meeting points that hang off one of `eventIds`
+ * (their `as:context`), in catalog order, de-duplicated. An item without an
+ * identifier cannot be read by URI and is skipped.
+ */
+export function catalogMeetingPointIdsOf(body: unknown, eventIds: readonly string[]): string[] {
+ const wanted = new Set(eventIds);
+ const out: string[] = [];
+ for (const record of catalogRecordsFromBody(body)) {
+ const anchor = anchorOf(record);
+ if (anchor === undefined || !wanted.has(anchor)) continue;
+ const id = itemId(record);
+ if (id === undefined) {
+ console.warn(`[catalog] skipped a meeting point item of ${anchor} that carries no identifier`);
+ continue;
+ }
+ if (!out.includes(id)) out.push(id);
+ }
+ return out;
+}
+
+/**
+ * Fetch the catalog body. Never throws: every failure is a warned `undefined`,
+ * which every reader above maps to "nothing".
+ */
+async function fetchCatalogBody(backendUrl: string, signal?: AbortSignal): Promise {
+ const url = `${backendUrl.replace(/\/+$/, '')}/catalog`;
+ try {
+ const response = await fetch(url, { headers: { Accept: 'application/json' }, signal });
+ if (!response.ok) {
+ console.warn(`[catalog] ${url} answered HTTP ${response.status} — no catalog records`);
+ return undefined;
+ }
+ const body: unknown = await response.json();
+ if (!Array.isArray(asRecord(body)?.['items'])) {
+ console.warn(`[catalog] ${url} returned a malformed body — no catalog records`);
+ return undefined;
+ }
+ return body;
+ } catch (error) {
+ if (signal?.aborted) return undefined;
+ console.warn(`[catalog] ${url} unreachable — no catalog records`, error);
+ return undefined;
+ }
+}
+
/**
* Fetch the catalog and return its events. Never throws: every failure is a
* warned empty list.
*/
export async function fetchCatalogEvents(backendUrl: string, signal?: AbortSignal): Promise {
- const url = `${backendUrl.replace(/\/+$/, '')}/catalog`;
- try {
- const response = await fetch(url, { headers: { Accept: 'application/json' }, signal });
- if (!response.ok) {
- console.warn(`[catalog] ${url} answered HTTP ${response.status} — listing own events only`);
- return [];
- }
- const body: unknown = await response.json();
- if (asRecord(body)?.['items'] === undefined || !Array.isArray(asRecord(body)?.['items'])) {
- console.warn(`[catalog] ${url} returned a malformed body — listing own events only`);
- return [];
- }
- return catalogEventsFromBody(body);
- } catch (error) {
- if (signal?.aborted) return [];
- console.warn(`[catalog] ${url} unreachable — listing own events only`, error);
- return [];
- }
+ return catalogEventsFromBody(await fetchCatalogBody(backendUrl, signal));
+}
+
+/**
+ * Fetch the catalog and return the URIs of the meeting points hanging off
+ * `eventIds`. Never throws. A catalog that could not be read answers
+ * `undefined` (warned) — NOT an empty list: "no meeting point" and "could not
+ * ask" are different answers, and a caller that already shows meeting points
+ * keeps them on the second.
+ */
+export async function fetchCatalogMeetingPointIds(
+ backendUrl: string,
+ eventIds: readonly string[],
+ signal?: AbortSignal,
+): Promise {
+ if (eventIds.length === 0) return [];
+ const body = await fetchCatalogBody(backendUrl, signal);
+ return body === undefined ? undefined : catalogMeetingPointIdsOf(body, eventIds);
}
/** Own events first, then catalog events not already listed, de-duplicated by event URI. */
diff --git a/src/shared/data/publicRead.test.ts b/src/shared/data/publicRead.test.ts
new file mode 100644
index 0000000..ab2fbd2
--- /dev/null
+++ b/src/shared/data/publicRead.test.ts
@@ -0,0 +1,117 @@
+import { afterEach, describe, expect, spyOn, test } from 'bun:test';
+import { fetchPublicRecord, readEachRecord, tokenFor } from './publicRead';
+import { fetchActorProfileRecord } from './actor';
+
+const original = globalThis.fetch;
+afterEach(() => { globalThis.fetch = original; });
+
+interface Call { url: string; init: RequestInit }
+function install(handler: (url: string) => Response): Call[] {
+ const calls: Call[] = [];
+ globalThis.fetch = ((url: string | URL | Request, init?: RequestInit) => {
+ const u = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
+ calls.push({ url: u, init: init ?? {} });
+ return Promise.resolve(handler(u));
+ }) as typeof fetch;
+ return calls;
+}
+const json = (body: unknown, status = 200) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/ld+json' } });
+const authOf = (call: Call | undefined) => new Headers(call?.init.headers).get('Authorization');
+
+const E = 'http://localhost:3000/bob/data/e1';
+const M = 'http://localhost:3000/bob/data/m1';
+const EVIL = 'https://elsewhere.example/thing';
+
+describe('fetchPublicRecord — anonymous, trusted for what was asked', () => {
+ test('reads without credentials of any kind', async () => {
+ const calls = install(() => json({ id: E, type: 'Event' }));
+ expect(await fetchPublicRecord(E)).toEqual({ id: E, type: 'Event' });
+ expect(authOf(calls[0])).toBeNull();
+ expect(calls[0]?.init.credentials).toBe('omit');
+ });
+
+ test('a document identifying itself as another resource is refused', async () => {
+ const warn = spyOn(console, 'warn').mockImplementation(() => {});
+ install(() => json({ id: M, type: 'Event', name: 'forged' }));
+ expect(await fetchPublicRecord(E)).toBeUndefined();
+ install(() => json({ type: 'Event' }));
+ expect(await fetchPublicRecord(E)).toBeUndefined();
+ warn.mockRestore();
+ });
+
+ test('a refusal, a network error, a non-object body or a non-http URI reads as absent', async () => {
+ install(() => json({ error: 'Forbidden' }, 403));
+ expect(await fetchPublicRecord(E)).toBeUndefined();
+ globalThis.fetch = (() => Promise.reject(new Error('down'))) as unknown as typeof fetch;
+ expect(await fetchPublicRecord(E)).toBeUndefined();
+ install(() => json([1, 2]));
+ expect(await fetchPublicRecord(E)).toBeUndefined();
+ const calls = install(() => json({ id: 'javascript:alert(1)' }));
+ expect(await fetchPublicRecord('javascript:alert(1)')).toBeUndefined();
+ expect(calls).toHaveLength(0);
+ });
+});
+
+describe('readEachRecord — per URI, as each lands', () => {
+ test('one unreadable URI costs that URI only; duplicates are read once; keyed by the requested URI', async () => {
+ const calls = install(url => (url === M ? json({}, 404) : json({ id: url, type: 'Event' })));
+ const landed = new Map();
+ await readEachRecord([E, M, E], (uri, record) => landed.set(uri, record));
+ expect(landed.get(E)).toEqual({ id: E, type: 'Event' });
+ expect(landed.has(M)).toBe(true);
+ expect(landed.get(M)).toBeUndefined();
+ expect(calls.map(c => c.url).sort()).toEqual([E, M]);
+ });
+
+ test('a host that never answers withholds nothing else, and gives up at the timeout', async () => {
+ globalThis.fetch = ((url: string | URL | Request, init?: RequestInit) => {
+ const u = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
+ if (u === EVIL) {
+ return new Promise((_, reject) => {
+ init?.signal?.addEventListener('abort', () => reject(new Error('aborted')));
+ });
+ }
+ return Promise.resolve(json({ id: u }));
+ }) as typeof fetch;
+ const order: string[] = [];
+ const started = Date.now();
+ await readEachRecord([EVIL, E], (uri, record) => order.push(`${uri}:${record ? 'read' : 'absent'}`), { timeoutMs: 150 });
+ expect(order).toEqual([`${E}:read`, `${EVIL}:absent`]);
+ expect(Date.now() - started).toBeLessThan(2000);
+ });
+
+ test('the token goes only where the caller grants it; nothing is reported after an abort', async () => {
+ const calls = install(url => json({ id: url }));
+ await readEachRecord([E, EVIL], () => {}, { tokenOf: uri => tokenFor(uri, 'tok', 'http://localhost:3000') });
+ expect(calls.map(c => [c.url, authOf(c)]).sort()).toEqual([[E, 'Bearer tok'], [EVIL, null]]);
+
+ const controller = new AbortController();
+ controller.abort();
+ const reported: string[] = [];
+ await readEachRecord([E], uri => reported.push(uri), { signal: controller.signal });
+ expect(reported).toEqual([]);
+ });
+});
+
+describe('tokenFor — the session token stays on its own origin', () => {
+ test('same origin gets the token, any other origin or a bad URI does not', () => {
+ expect(tokenFor(E, 'tok', 'http://localhost:3000')).toBe('tok');
+ expect(tokenFor(EVIL, 'tok', 'http://localhost:3000')).toBeUndefined();
+ expect(tokenFor('http://localhost:3001/x', 'tok', 'http://localhost:3000')).toBeUndefined();
+ expect(tokenFor('not a uri', 'tok', 'http://localhost:3000')).toBeUndefined();
+ expect(tokenFor(E, undefined, 'http://localhost:3000')).toBeUndefined();
+ expect(tokenFor(E, 'tok', undefined)).toBeUndefined();
+ });
+
+ test('profile resolution with a trusted origin never sends the token elsewhere', async () => {
+ const ACTOR = 'http://localhost:3000/carol';
+ const calls = install(url => (url === ACTOR ? json({ id: ACTOR, url: EVIL }) : json({ id: EVIL, name: 'x' })));
+ await fetchActorProfileRecord(ACTOR, 'tok', 'http://localhost:3000');
+ expect(calls.map(c => [c.url, authOf(c)])).toEqual([[ACTOR, 'Bearer tok'], [EVIL, null]]);
+
+ const foreignActor = install(() => json({ id: EVIL, url: `${EVIL}/profile` }, 200));
+ await fetchActorProfileRecord(EVIL, 'tok', 'http://localhost:3000');
+ expect(foreignActor.every(c => authOf(c) === null)).toBe(true);
+ });
+});
diff --git a/src/shared/data/publicRead.ts b/src/shared/data/publicRead.ts
new file mode 100644
index 0000000..1395c1d
--- /dev/null
+++ b/src/shared/data/publicRead.ts
@@ -0,0 +1,120 @@
+/**
+ * Reads by URI of other users' public data — living BESIDE Refine.
+ *
+ * WHY NOT THE DATA PROVIDER. The pinned contract lets `getOne`/`getMany` fetch
+ * any URI, but it does not say they can do so WITHOUT the session's
+ * credentials — and the URIs read here are chosen by OTHER users: a discovered
+ * event's URI (from the catalog, or from a link someone sent), the meeting
+ * points the catalog lists for it, the attendees collection each of those
+ * records links to. Sending the session token to wherever such a link points
+ * would hand a replayable credential to a host nobody vetted. These resources
+ * are public by design (granted world-readable at creation), so the read is
+ * ANONYMOUS — no `Authorization`, no cookies — except on the origin that
+ * issued the session (`tokenFor`): there the token adds only what the user may
+ * already read, and covers a resource whose public grant did not land.
+ *
+ * BOUNDED. Each read gives up after `timeoutMs` (a host that never answers is
+ * an unreadable URI, not a stalled page), and `readEachRecord` reports every
+ * URI as it lands — one slow host never withholds the others.
+ *
+ * TRUSTED FOR WHAT WAS ASKED, NOT FOR WHAT IT CLAIMS. A record is kept only
+ * when its own identifier (`id`/`@id`) is the URI that was requested, and the
+ * result is keyed by that URI — so a document cannot pass itself off as
+ * another resource (another event, another meeting point's collection).
+ *
+ * FAILS OPEN, PER URI. A refusal, a 404, a network error, a non-object body or
+ * a mismatched identifier all read back as "absent" for that URI only; the
+ * others are kept. Absent is the caller's "not readable → not shown" answer.
+ */
+
+import { asRecord, asString, type JsonRecord } from './jsonRecord';
+
+/** Is this an absolute http(s) URI — the only kind this module fetches? */
+function isHttpUri(uri: string): boolean {
+ try {
+ const { protocol } = new URL(uri);
+ return protocol === 'http:' || protocol === 'https:';
+ } catch {
+ return false;
+ }
+}
+
+/** How long one read may take before the URI counts as unreadable. */
+export const PUBLIC_READ_TIMEOUT_MS = 10000;
+
+export interface PublicReadOptions {
+ signal?: AbortSignal;
+ /** Bearer token to attach — the caller passes one only for its own origin (`tokenFor`). */
+ token?: string;
+ timeoutMs?: number;
+}
+
+/**
+ * Read one record by URI. `undefined` when it cannot be read in time, or when
+ * the document does not identify itself as `uri`.
+ */
+export async function fetchPublicRecord(uri: string, options: PublicReadOptions = {}): Promise {
+ const { signal, token, timeoutMs = PUBLIC_READ_TIMEOUT_MS } = options;
+ if (!isHttpUri(uri) || signal?.aborted) return undefined;
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ const forward = () => controller.abort();
+ signal?.addEventListener('abort', forward, { once: true });
+ try {
+ const response = await fetch(uri, {
+ headers: {
+ Accept: 'application/ld+json',
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ },
+ credentials: 'omit',
+ signal: controller.signal,
+ });
+ if (!response.ok) return undefined;
+ const record = asRecord(await response.json());
+ if (!record) return undefined;
+ const id = asString(record['id']) ?? asString(record['@id']);
+ if (id !== uri) {
+ console.warn(`[public-read] ${uri} answered a document identified as ${String(id)} — ignored`);
+ return undefined;
+ }
+ return record;
+ } catch {
+ return undefined;
+ } finally {
+ clearTimeout(timer);
+ signal?.removeEventListener('abort', forward);
+ }
+}
+
+/**
+ * Read each URI independently and report it AS IT LANDS (`onRead(uri, record)`,
+ * `record` undefined when unreadable) — keyed by the REQUESTED URI, duplicates
+ * read once. Nothing is reported after `signal` aborts. Resolves once every
+ * URI has been reported (or the signal aborted).
+ */
+export async function readEachRecord(
+ uris: readonly string[],
+ onRead: (uri: string, record: JsonRecord | undefined) => void,
+ options: { signal?: AbortSignal; tokenOf?: (uri: string) => string | undefined; timeoutMs?: number } = {},
+): Promise {
+ const { signal, tokenOf, timeoutMs } = options;
+ await Promise.all([...new Set(uris)].map(async uri => {
+ const record = await fetchPublicRecord(uri, { signal, token: tokenOf?.(uri), timeoutMs });
+ if (!signal?.aborted) onRead(uri, record);
+ }));
+}
+
+/**
+ * The `Authorization` the session may attach to a request for `uri`: its
+ * bearer token, but ONLY on the origin that issued the session (`trustedOrigin`,
+ * the session's own pod provider). Anywhere else — a URI another user's data
+ * pointed to — the request goes out without credentials.
+ */
+export function tokenFor(uri: string, token: string | undefined, trustedOrigin: string | undefined): string | undefined {
+ if (!token || !trustedOrigin) return undefined;
+ try {
+ return new URL(uri).origin === trustedOrigin ? token : undefined;
+ } catch {
+ return undefined;
+ }
+}
diff --git a/src/shared/data/types.ts b/src/shared/data/types.ts
index dd3566a..1276d92 100644
--- a/src/shared/data/types.ts
+++ b/src/shared/data/types.ts
@@ -58,6 +58,12 @@ export interface FpMeetingPointData {
time: string;
/** NURI of the meeting point's inbox (wired in T02.b/c). */
inbox?: string;
+ /**
+ * How many people signed up — DERIVED by the reader from the meeting point's
+ * attendees, never stored or written. Absent where the backend keys its
+ * participations by event rather than by meeting point (nothing to count it from).
+ */
+ participantCount?: number;
}
export interface FpNotificationData {