Open another user's event from Découvrir: detail, meeting points, counts and join

Tapping a catalog event used to show "Événement non trouvé" because the detail only searched the user's own events. An event is now read by URI, its meeting points come from the catalog, and each meeting point's count comes from its attendees collection (event count = sum); joining a foreign meeting point goes through the existing outbox flow and the counts move at once and survive a reload. Foreign records are read anonymously by direct HTTP: the session token only goes to the session's own origin, each read is bounded and independent, and one malformed catalog item, record or collection no longer takes the whole read down. Own counts were always 0 before, because the pod never embeds apods:attendees; they are now read the same way. Catalog-only cards show no count instead of a misleading zero.
This commit is contained in:
Sylvain Duchesne
2026-10-12 17:00:42 +02:00
parent a0d2cb8f73
commit fbb1f6f61b
18 changed files with 1179 additions and 99 deletions
@@ -1,3 +1,4 @@
import { useLayoutEffect } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Button, Avatar, EventCover, EventMeetingPoints, showToast, Text, type MeetingPointData } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
@@ -15,8 +16,19 @@ export function EventDetailScreen() {
getEventParticipants,
getEventMeetingPoints,
getEventOwnership,
openEvent,
getEventReadState,
} = useFestipodData();
// ANOTHER USER'S EVENT IS NOT IN THE OWN LIST. Découvrir links to events from
// every pod; the data layer reads one it does not hold only when asked. Asked
// in a LAYOUT effect so the request lands before the first paint — the
// "not found" state must never flash for an event that is about to load.
useLayoutEffect(() => {
if (eventId) openEvent(eventId);
}, [eventId, openEvent]);
const readState = eventId ? getEventReadState(eventId) : 'unavailable';
const event = eventId ? getEvent(eventId) : undefined;
const participants = eventId ? getEventParticipants(eventId) : [];
const meetingPointsRaw = eventId ? getEventMeetingPoints(eventId) : [];
@@ -27,6 +39,8 @@ export function EventDetailScreen() {
when: mp.time,
duration: '~60 min',
lieu: mp.location,
// Derived by the data layer (absent where it cannot be counted per point).
participantCount: mp.participantCount,
}));
// SIGNING UP IS PER MEETING POINT — the product model (on s'inscrit à un
@@ -85,7 +99,7 @@ export function EventDetailScreen() {
<span style={{ fontSize: 18, fontWeight: 700 }}>Événement</span>
</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Text>Événement non trouvé</Text>
<Text>{readState === 'loading' ? "Chargement de l'événement…" : 'Événement non trouvé'}</Text>
</div>
</div>
);
+27 -11
View File
@@ -16,13 +16,16 @@ const EVENT_COLORS = ['#E8590C', '#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E'];
function EventCard({
event,
participantCount,
onClick,
}: {
event: { id: string; title: string; date: string; location: string; distance?: number; participantCount: number };
event: { id: string; title: string; date: string; location: string; distance?: number };
/** `undefined` when nobody counted it (a catalog entry) — then no count is shown, never a made-up 0. */
participantCount: number | undefined;
onClick: () => void;
}) {
const color = EVENT_COLORS[Number(event.id.replace(/\D/g, '') || 0) % EVENT_COLORS.length] ?? '#E8590C';
const people = PEOPLE.slice(0, Math.min(5, Math.max(1, event.participantCount)));
const people = PEOPLE.slice(0, Math.min(5, Math.max(1, participantCount ?? 0)));
return (
<Card onClick={onClick} style={{ marginBottom: 12, padding: 0, overflow: 'hidden' }} accentColor={color}>
<div
@@ -38,12 +41,14 @@ function EventCard({
<div style={{ fontSize: 12.5, color: '#888', marginBottom: 10 }}>
{event.date} · {event.location}{event.distance != null && ` · ${event.distance} km`}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<AvatarStack people={people} size={26} />
<span style={{ fontSize: 12, color: '#666' }}>{event.participantCount} inscrits</span>
{participantCount !== undefined && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<AvatarStack people={people} size={26} />
<span style={{ fontSize: 12, color: '#666' }}>{participantCount} inscrits</span>
</div>
</div>
</div>
)}
</div>
</Card>
);
@@ -51,7 +56,7 @@ function EventCard({
export function EventsScreen() {
const navigate = useNavigate();
const { events: ownEvents } = useFestipodData();
const { events: ownEvents, getEvent } = useFestipodData();
const events = useDiscoverEvents(ownEvents);
return (
@@ -78,9 +83,20 @@ export function EventsScreen() {
Aucun événement à afficher
</Text>
)}
{events.map(event => (
<EventCard key={event.id} event={event} onClick={() => navigate(eventPath(event.id))} />
))}
{events.map(event => {
// An event the data layer READ (own, or another user's opened by URI)
// carries a derived count; a catalog entry nobody opened carries none —
// its card shows no count rather than a misleading "0 inscrits".
const held = getEvent(event.id);
return (
<EventCard
key={event.id}
event={held ?? event}
participantCount={held?.participantCount}
onClick={() => navigate(eventPath(event.id))}
/>
);
})}
</div>
<BottomNav active="discover" />
@@ -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}
<br />
📍 {p.lieu}
{p.participantCount !== undefined && (
<>
<br />
👥 {p.participantCount} inscrit{p.participantCount > 1 ? 's' : ''}
</>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
+1 -1
View File
@@ -56,7 +56,7 @@ import {
// Re-exported so existing importers of this module keep their surface: the
// hook, the context object, the demo provider, and the shared types.
export { useFestipodData, FestipodDataContext } from './festipodDataBase';
export type { FestipodDataContextValue, EventOwnership } from './festipodDataBase';
export type { FestipodDataContextValue, EventOwnership, EventReadState } from './festipodDataBase';
export { resolveDataProviderMode } from './dataProviderMode';
export type { DataProviderMode } from './dataProviderMode';
+40 -1
View File
@@ -41,6 +41,15 @@ import { normalizeIdentifier } from '../utils/identifier';
*/
export type EventOwnership = 'mine' | 'not-mine' | 'unknown';
/**
* Whether `getEvent` can answer for an event: `'ready'` (it is in the read
* set), `'loading'` (a read that would bring it in is in flight) or
* `'unavailable'` (not held and nothing is reading it — it does not exist, or
* this session may not read it). A screen renders "not found" on
* `'unavailable'` only, never while `'loading'`.
*/
export type EventReadState = 'ready' | 'loading' | 'unavailable';
export interface FestipodDataContextValue {
currentUserId: string;
currentUser: FpUserData | undefined;
@@ -73,6 +82,20 @@ export interface FestipodDataContextValue {
* own case — see {@link EventOwnership}.
*/
getEventOwnership(eventId: string): EventOwnership;
/**
* Bring an event this session does not hold into its read set, by its URI —
* another user's event, reached through discovery (the Découvrir list links
* to it). Once read, the per-event queries (`getEvent`,
* `getEventMeetingPoints`, `getEventParticipants`, `isParticipating`) and
* `joinMeetingPoint` / `leaveMeetingPoint` answer for it as for an own
* event, with its meeting points and their counts; `events` stays the
* session's OWN list. Idempotent; a no-op for an event already held and on
* backends whose read set is already everything they can read. A state
* change: call it from an effect, never during render.
*/
openEvent(eventId: string): void;
/** Where `getEvent(eventId)` stands — see {@link EventReadState}. */
getEventReadState(eventId: string): EventReadState;
selectedEventId: string;
setSelectedEventId(id: string): void;
@@ -206,9 +229,25 @@ export function buildQueries(
return meetingPoints.filter(mp => 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
// ============================================================================
+226 -31
View File
@@ -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<FpEventData, 'id'>) => ({ ...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<readonly string[]>([]);
// Events this session created — owned from the moment the create returned.
const [createdEventIds, setCreatedEventIds] = useState<readonly string[]>([]);
// 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<readonly string[]>([]);
// 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<string, unknown>, 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<string>();
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<string>('');
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<string>();
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<FpEventData, 'id'>): Promise<FpEventData> => {
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,
};
+88 -2
View File
@@ -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();
});
+85 -19
View File
@@ -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<string, number>();
// 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<string, number>,
): 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<string, 'join' | 'leave'>;
@@ -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 };
}
@@ -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);
});
});
+100
View File
@@ -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<string, JsonRecord>;
pending: ReadonlySet<string>;
}
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<ReadonlyMap<string, Landed>>(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<string, JsonRecord>();
for (const [uri, entry] of landed) if (entry.record) out.set(uri, entry.record);
return out;
}, [landed]);
const pending = useMemo(() => {
const out = new Set<string>();
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 };
}
+13 -3
View File
@@ -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<unknown> {
export async function fetchActorProfileRecord(webId: string, token?: string, trustedOrigin?: string): Promise<unknown> {
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;
+54
View File
@@ -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);
});
});
+39
View File
@@ -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<string, unknown>): 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 };
}
+89 -1
View File
@@ -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();
}
});
});
+114 -29
View File
@@ -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<unknown> {
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<FpEventData[]> {
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<string[] | undefined> {
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. */
+117
View File
@@ -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<string, unknown>();
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<Response>((_, 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);
});
});
+120
View File
@@ -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<JsonRecord | undefined> {
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<void> {
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;
}
}
+6
View File
@@ -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 {