From aacc2ec3ee38f5b5f900f21cd2644db4dcb56522 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Fri, 3 Jul 2026 15:51:23 +0200 Subject: [PATCH] feat(data): PdR registration via inbox, notifications, public discovery, protected store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polyfill-enabled features (T02). All NextGraph I/O goes through @ng-eventually/client (docs/inbox/storeRegistry); no direct @ng-org. - Shapes: FpMeetingPoint + FpNotification are now real SHEX shapes with ORM bindings (previously app-TS-only, unpersisted). - Registration (registration.ts, new): joinEvent persists a Participation + deposits to the host's inbox + creates a Notification (from = registrant if connected, anonymous otherwise). leaveEvent deletes the Participation authoritatively via SPARQL DELETE-WHERE (sweep by event+user AND by subject, then re-query to confirm) — the désinscription CRDT-resurrection bug is fixed: the reactive delete is applied only once the broker confirms 0 remaining. - Public discovery: useNgData fans out over every account's public docs so a user sees others' public events without a connection (dedup union). - Cap attribution: createEntityDoc declares the ReadCap (open + makePublic/ grantRead per scope), activating the per-document read filter. - Protected store (T02.h): the default path now reads/writes shareable domain entities in the native protected store (did:ng:${protected_store_id}) instead of private — verified openable against the broker — matching the per-wallet target. Private still anchors the shim/inbox + settings. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/shared/context/FestipodDataContext.tsx | 208 +++++++++-- src/shared/data/registration.ts | 341 ++++++++++++++++++ src/shared/data/types.ts | 19 + .../shapes/orm/festipodShapes.schema.ts | 125 +++++++ .../shapes/orm/festipodShapes.shapeTypes.ts | 16 +- .../shapes/orm/festipodShapes.typings.ts | 114 ++++++ src/shared/shapes/shex/festipodShapes.shex | 34 ++ src/shared/utils/ngGraph.ts | 22 +- src/shared/utils/storeRegistry.ts | 30 +- 9 files changed, 876 insertions(+), 33 deletions(-) create mode 100644 src/shared/data/registration.ts diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index f095772..7337bd8 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -5,7 +5,16 @@ import type { FpParticipationData, FpMeetingPointData, FpFriendshipData, + FpNotificationData, } from '../data/types'; +import { + hostInboxNuri, + depositRegistration, + buildNotification, + insertNotification, + readRegistrationNotifications, + deleteParticipation, +} from '../data/registration'; import { CURRENT_USER_ID, seedEvents, @@ -49,6 +58,8 @@ interface FestipodDataContextValue { participations: FpParticipationData[]; meetingPoints: FpMeetingPointData[]; friendships: FpFriendshipData[]; + /** Host-facing notifications, surfaced from the inbox curator (T02.c). */ + notifications: FpNotificationData[]; getEvent(id: string): FpEventData | undefined; getUser(id: string): FpUserData | undefined; @@ -67,8 +78,8 @@ interface FestipodDataContextValue { createEvent(event: Omit): Promise; updateEvent(id: string, updates: Partial): void; - joinEvent(eventId: string, userId?: string): void; - leaveEvent(eventId: string, userId?: string): void; + joinEvent(eventId: string, userId?: string): Promise | void; + leaveEvent(eventId: string, userId?: string): Promise | void; addMeetingPoint(mp: Omit): void; addFriend(friendId: string): void; updateProfile(updates: Partial): void; @@ -230,6 +241,7 @@ function useLocalData(empty?: boolean): FestipodDataContextValue { return { currentUserId, currentUser, events, users, participations, meetingPoints, friendships, + notifications: [], selectedEventId, setSelectedEventId, selectedEvent, selectedUserId, setSelectedUserId, selectedUser, ...queries, @@ -245,9 +257,17 @@ function useLocalData(empty?: boolean): FestipodDataContextValue { function useNgData(): FestipodDataContextValue { const { session } = useNextGraph(); const { username } = useAccount(); - // Mono-store fallback scope: the shared wallet's private store NURI. Opens the - // repo in the verifier (enables reads + writes), per the data-layer rule. + // Mono-store scopes (MULTISTORE off). Two native stores of the shared wallet: + // - privateNuri: kept as the anchor for the inbox shim (host inbox deposits) + // and for private settings. Opens the repo in the verifier. + // - protectedNuri: T02.h (axe A) — the SHAREABLE domain entities (events, + // profiles, participations) now READ from and WRITE to the real protected + // native store, representative of the target per-user wallet. Verified to + // open for ORM reads+writes exactly like private (round-trip, no + // RepoNotFound — see protected-store.feature). Subscribing useShape with + // this NURI opens its repo in the verifier (required for writes). const privateNuri = session ? `did:ng:${session.private_store_id}` : undefined; + const protectedNuri = session ? `did:ng:${session.protected_store_id}` : undefined; // Multi-document state (storeRegistry): read fan-out (all accounts' docs per // scope) and the current account's write docs. Populated by the effect below. @@ -277,14 +297,45 @@ function useNgData(): FestipodDataContextValue { return () => { cancelled = true; }; }, [privateNuri, username]); + // --- Public discovery (T02.e): cross-account fan-out, ALWAYS on ------------ + // Materialize the cross-account source of PUBLIC entities so a user discovers + // other accounts' public events *without a connection* (Alice sees Bob's + // public event even if they're not friends). This is the "simple" model: the + // shared wallet makes every account's public index physically listable, so we + // aggregate `allAccounts → each docPublic → listEntityDocs('public')` and read + // the resulting per-entity documents via useShape({graphs}). Public docs are + // `makePublic` (T02.d), so the ReadCap filter never blocks this fan-out. + // + // Additive & non-regressive: runs in BOTH modes but only contributes when the + // shim has registered public entity docs. In the default mono-store path the + // shim is empty (no account ever registered → fan-out is []), so the discovery + // shape stays empty and the mono-store `events` read is untouched. When the + // shim IS populated (multi-account staging), discovery unions those events in. + const [discoveryGraphs, setDiscoveryGraphs] = useState([]); + useEffect(() => { + if (!privateNuri) return; + let cancelled = false; + (async () => { + try { + const pub = await listEntityDocs('public'); // fans out over ALL accounts + if (!cancelled) setDiscoveryGraphs(pub); + } catch (err) { + console.error('[FestipodData] public discovery fan-out failed:', err); + } + })(); + return () => { cancelled = true; }; + }, [privateNuri, username]); + const discoveryScope: ShapeScope = discoveryGraphs.length ? { graphs: discoveryGraphs } : undefined; + // Scope per entity: events live in the PUBLIC docs, profiles + participations - // in the PROTECTED docs. Mono-store mode collapses all to the private store. + // in the PROTECTED docs. Mono-store mode collapses all to the PROTECTED native + // store (T02.h, axe A) — the shareable domain entities read from there. const publicScope: ShapeScope = MULTISTORE ? (readGraphs.public.length ? { graphs: readGraphs.public } : undefined) - : privateNuri; + : protectedNuri; const protectedScope: ShapeScope = MULTISTORE ? (readGraphs.protected.length ? { graphs: readGraphs.protected } : undefined) - : privateNuri; + : protectedNuri; // useShapeWithDefaults: show EMPTY data until NG populates (no seed defaults) const emptyEvents: FpEventData[] = []; @@ -295,13 +346,29 @@ function useNgData(): FestipodDataContextValue { const usersShape = useShapeWithDefaults(FpUserProfileShapeType, protectedScope, emptyUsers, mapUser, true); const participationsShape = useShapeWithDefaults(FpParticipationShapeType, protectedScope, emptyParticipations, mapParticipation, true); - const events = eventsShape.items; + // Cross-account public discovery: read the fan-out documents as events. + const discoveryShape = useShapeWithDefaults(FpEventShapeType, discoveryScope, emptyEvents, mapEvent, true); + + // Union the current-scope events with the cross-account discovered ones, + // de-duplicated by id (an event already read via publicScope must not appear + // twice). Discovery is purely additive — it never hides an existing event. + const events = React.useMemo(() => { + const seen = new Set(eventsShape.items.map(e => e.id)); + const merged = [...eventsShape.items]; + for (const e of discoveryShape.items) { + if (e.id && !seen.has(e.id)) { seen.add(e.id); merged.push(e); } + } + return merged; + }, [eventsShape.items, discoveryShape.items]); const users = usersShape.items; const participations = participationsShape.items; // Not in SHEX shapes yet const [meetingPoints, setMeetingPoints] = useState([]); const [friendships, setFriendships] = useState([]); + // Host-facing notifications, materialized from the current user's inboxes + // (the emulated curator, T02.b/c). Data-level surfacing of "new participants". + const [notifications, setNotifications] = useState([]); const [selectedEventId, setSelectedEventId] = useState(''); const [selectedUserId, setSelectedUserId] = useState(''); @@ -351,6 +418,44 @@ function useNgData(): FestipodDataContextValue { const selectedEvent = events.find(e => e.id === selectedEventId); const selectedUser = users.find(u => u.id === selectedUserId); + // --- Notification materialization (T02.c) --------------------------------- + // Run the emulated inbox curator over the current user's hosted events and + // surface "new participant" deposits as host-facing FpNotifications. Keyed on + // the events the user hosts/selects; polls once per (events, selectedEvent). + // Data-level surfacing — the notification module reads `notifications`. + const hostedEventIds = React.useMemo( + () => events.filter(e => currentUserId && e.id).map(e => e.id), + [events, currentUserId], + ); + useEffect(() => { + if (!privateNuri || hostedEventIds.length === 0) return; + let cancelled = false; + (async () => { + try { + // In the polyfill every host inbox resolves to the shared private store, + // so read it ONCE and let the curator filter deposits per hosted event. + const targetInbox = await hostInboxNuri(''); + const all: FpNotificationData[] = []; + for (const evId of hostedEventIds) { + const notifs = await readRegistrationNotifications(targetInbox, evId); + all.push(...notifs); + } + if (!cancelled && all.length) { + setNotifications(prev => { + const seen = new Set(prev.map(n => n.id)); + const merged = [...prev]; + for (const n of all) if (!seen.has(n.id)) { seen.add(n.id); merged.push(n); } + return merged; + }); + } + } catch (err) { + console.error('[FestipodData] notification materialization failed:', err); + } + })(); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [privateNuri, hostedEventIds.join('|')]); + // Isolation (staging realism): the app honors the matrix in connected mode — // participations/connections narrowed to self + connections. See isolation.ts. const isolated = applyIsolation( @@ -368,8 +473,9 @@ function useNgData(): FestipodDataContextValue { // --- Mutations (NG) --- // Participations stay GROUPED in the account's protected index document. - // Mono-store mode collapses everything to the private store. - const protectedGraph = (MULTISTORE ? writeGraphs.protected : undefined) || privateNuri || ''; + // Mono-store mode writes to the PROTECTED native store (T02.h, axe A) — the + // same store the domain read scopes subscribe, so writes round-trip. + const protectedGraph = (MULTISTORE ? writeGraphs.protected : undefined) || protectedNuri || ''; const createEvent = useCallback(async (event: Omit): Promise => { console.log('[FestipodData] createEvent (NG):', event.title); @@ -378,7 +484,7 @@ function useNgData(): FestipodDataContextValue { // is appended to the read fan-out so it shows after re-subscribe.) const eventGraph = MULTISTORE ? await createEntityDoc(username || '', 'public') - : (privateNuri || ''); + : (protectedNuri || ''); if (MULTISTORE && eventGraph) { setReadGraphs(prev => prev.public.includes(eventGraph) ? prev : { ...prev, public: [...prev.public, eventGraph] }, @@ -400,7 +506,7 @@ function useNgData(): FestipodDataContextValue { setSelectedEventId(addedEvent["@id"]); } return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` }; - }, [protectedGraph, eventsShape.ngSet, participationsShape.ngSet, currentUserId, privateNuri, username]); + }, [protectedGraph, eventsShape.ngSet, participationsShape.ngSet, currentUserId, protectedNuri, username]); const updateEvent = useCallback((id: string, updates: Partial) => { console.log('[FestipodData] updateEvent (NG):', id, updates); @@ -415,7 +521,7 @@ function useNgData(): FestipodDataContextValue { } }, [eventsShape.ngSet]); - const joinEvent = useCallback((eventId: string, userId?: string) => { + const joinEvent = useCallback(async (eventId: string, userId?: string) => { const uid = userId || currentUserId; console.log('[FestipodData] joinEvent (NG):', eventId, 'user:', uid); const existing = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid); @@ -423,6 +529,7 @@ function useNgData(): FestipodDataContextValue { console.log('[FestipodData] Already participating, skipping'); return; } + // 1) Persist the Participation (reactive ORM set — mono-store default path). participationsShape.ngSet.add({ "@graph": protectedGraph, "@type": "http://festipod.org/Participation", "@id": "", event: eventId, user: uid, isConfirmed: true, @@ -431,21 +538,77 @@ function useNgData(): FestipodDataContextValue { if (ngEvent) { ngEvent.participantCount = ngEvent.participantCount + 1; } + // 2) Notify the host: deposit into the event/host inbox via the GENERIC lib + // inbox (T02.b) + mint the host FpNotification (T02.a). `from` = registrant + // when connected, anonymous (null) otherwise. Best-effort: a failed deposit + // must not roll back a successful registration. + try { + const registrantId = uid || null; // no current user → anonymous deposit + // Recipient = the event host. The Event shape carries no host IRI yet, so + // we key the host inbox/notification on the eventId (the host of THAT + // event). This is the domain injection the generic lib deliberately omits. + const recipientId = eventId; + const targetInbox = await hostInboxNuri(eventId); + const { ts, uid: depositUid } = await depositRegistration(targetInbox, eventId, registrantId); + const notif = buildNotification(recipientId, eventId, registrantId, ts); + await insertNotification(protectedGraph, notif).catch(() => { /* data-level best-effort */ }); + // Surface immediately in reactive state (materialization also refreshes it). + // Use the stable per-deposit uid for the id (F5 dedup) so it matches the + // curator-materialized id and same-ms/anon deposits never collide. + setNotifications(prev => [...prev, { ...notif, id: `notif-${depositUid}` }]); + } catch (err) { + console.error('[FestipodData] joinEvent inbox/notify failed:', err); + } }, [protectedGraph, participationsShape.ngSet, eventsShape.ngSet, currentUserId]); - const leaveEvent = useCallback((eventId: string, userId?: string) => { + const leaveEvent = useCallback(async (eventId: string, userId?: string) => { const uid = userId || currentUserId; console.log('[FestipodData] leaveEvent (NG):', eventId, 'user:', uid); const ngPart = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid); - if (ngPart) { - console.log('[FestipodData] Deleting participation via ngSet.delete():', ngPart["@id"]); - participationsShape.ngSet.delete(ngPart); - const ngEvent = findNg(eventsShape.ngSet as any as Set, e => e["@id"] === eventId); - if (ngEvent) { - ngEvent.participantCount = Math.max(0, ngEvent.participantCount - 1); - } + if (!ngPart) return; + // DÉSINSCRIPTION FIX (caveat_participation-deletion): `ngSet.delete()` alone + // triggers reactivity but the item RESURRECTS via broker sync. The AUTHORITATIVE + // deletion is a SPARQL DELETE via the real injected `ng` (docs.sparqlUpdate), + // which removes the Participation server-side so it does NOT come back after + // re-sync. The delete targets the participation's own @graph (the doc it lives + // in) — mono-store: the private store; multistore: the protected write doc — + // and is identified by the participation's OWN subject IRI (ngPart["@id"]), + // not a string-match on the object IRIs (the F2 bug: object string-match could + // hit 0 rows on IRI-form drift → silent no-op → resurrection). + const graphNuri = ngPart["@graph"] || protectedGraph; + const subjectIri = ngPart["@id"]; + let result; + try { + result = await deleteParticipation(graphNuri, eventId, uid, subjectIri); + } catch (err) { + console.error('[FestipodData] SPARQL DELETE participation failed:', err); + // Do NOT flip the UI: the broker still holds the triple, so flipping the + // reactive set would resurrect on the next sync. Surface the failure. + throw err instanceof Error ? err : new Error(String(err)); } - }, [participationsShape.ngSet, eventsShape.ngSet, currentUserId]); + // AUTHORITATIVE verification: only flip the UI once the broker RE-QUERY confirms + // the participation is actually gone (remaining === 0). If the delete matched + // nothing (weak match / IRI-form drift / wrong graph), remaining stays > 0 — + // flipping the reactive set here would show "not participating" while the broker + // still holds the triple, and it would resurrect after re-sync. Surface instead. + if (result.remaining > 0) { + const msg = `[FestipodData] leaveEvent: SPARQL delete removed nothing ` + + `(before=${result.before}, remaining=${result.remaining}, bySubject=${result.bySubject}) ` + + `for event=${eventId} user=${uid} — NOT flipping UI (would resurrect).`; + console.error(msg); + throw new Error(msg); + } + // Confirmed gone server-side → reflect it in the reactive UI. This is the LOCAL + // reflection of the authoritative delete (not a second persistence path): the + // button flips to not-registered and STAYS so — the broker no longer holds the + // triple to resurrect. `isParticipating` reads this set, so the item must leave + // it for the UI to update immediately. + participationsShape.ngSet.delete(ngPart); + const ngEvent = findNg(eventsShape.ngSet as any as Set, e => e["@id"] === eventId); + if (ngEvent) { + ngEvent.participantCount = Math.max(0, ngEvent.participantCount - 1); + } + }, [participationsShape.ngSet, eventsShape.ngSet, currentUserId, protectedGraph]); const addMeetingPoint = useCallback((mp: Omit) => { setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]); @@ -489,6 +652,7 @@ function useNgData(): FestipodDataContextValue { participations: isolated.participations, meetingPoints, friendships: isolated.friendships, + notifications, selectedEventId, setSelectedEventId, selectedEvent, selectedUserId, setSelectedUserId, selectedUser, ...queries, diff --git a/src/shared/data/registration.ts b/src/shared/data/registration.ts new file mode 100644 index 0000000..9d17f74 --- /dev/null +++ b/src/shared/data/registration.ts @@ -0,0 +1,341 @@ +/** + * Registration domain glue — the FESTIPOD interpretation layered on top of the + * GENERIC `@ng-eventually/client` `inbox` mechanism (T02.b) and the low-level + * `docs` SPARQL primitives. + * + * The lib stays domain-agnostic: it knows only "deposit an opaque payload into + * an inbox document NURI" and "run a SPARQL update against the real injected + * ng". THIS module supplies the Festipod domain: + * - how to derive a meeting-point / host inbox NURI (`hostInboxNuri`), + * - the shape of the deposit payload (`RegistrationPayload`), + * - how a deposit becomes a host-facing `FpNotification` (`buildNotification`), + * - the SPARQL DELETE-WHERE that DURABLY removes a Participation server-side + * (`deleteParticipation`) — the documented fallback for the CRDT resurrection + * bug (see caveat_participation-deletion). + * + * Importable by `shared/` and by domain modules (meeting/notification) — it never + * imports a module, only the lib. See T02.a (shapes) / T02.b (inbox). + */ + +import { inbox, docs, escapeLiteral, escapeIri, assertNuri } from '@ng-eventually/client'; +import { sessionPromise } from '../utils/ngSession'; +import type { FpNotificationData } from './types'; + +/** Notification IRI/type constants (mirror the SHEX Notification shape). */ +export const NOTIF_TYPE_NEW_PARTICIPANT = 'new-participant'; +const NOTIF_TYPE_IRI = 'http://festipod.org/Notification'; +const P = { + recipient: 'http://festipod.org/recipient', + type: 'http://festipod.org/type', + ref: 'http://festipod.org/ref', + payload: 'http://festipod.org/payload', + timestamp: 'http://festipod.org/timestamp', + isRead: 'http://festipod.org/isRead', + partType: 'http://festipod.org/Participation', + partEvent: 'http://festipod.org/event', + partUser: 'http://festipod.org/user', +} as const; + +/** + * The opaque payload a registrant deposits into the host's inbox on join. The + * lib treats this as `unknown`; only this domain module reads its fields. + */ +export interface RegistrationPayload { + kind: typeof NOTIF_TYPE_NEW_PARTICIPANT; + eventId: string; + /** The registrant's user id, or null when the deposit was anonymous. */ + userId: string | null; + /** + * A STABLE, per-deposit unique id minted at deposit time (F5 dedup). The lib's + * `Deposit` surfaces only `{ from, payload, ts }` — no stable id — so two + * deposits in the same ms by the same anon principal would otherwise both mint + * `notif-${ts}-anon` and collide (a re-join would silently duplicate OR be + * dropped by the seen-set). Carrying our own `uid` in the payload makes the + * derived notification id collision-free without changing the lib. */ + uid: string; +} + +/** Mint a stable, collision-resistant per-deposit uid (time + randomness). */ +function mintDepositUid(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +/** + * Resolve the inbox document NURI for a meeting point / host. + * + * Preference order: the explicit MeetingPoint `inbox` NURI (SHEX field, T02.a) + * when known → else the shared-wallet PRIVATE STORE NURI. The lib's `inbox` + * uses `targetInbox` as BOTH the RDF graph AND the SPARQL anchor, and the anchor + * must be a REAL repo NURI (a `urn:` is rejected as `InvalidNuri` by the broker) + * — so in the polyfill every host inbox physically resolves to the shared wallet + * private store; deposits are DISCRIMINATED by their `eventId` payload (the + * curator filters per event). At migration this becomes the host's native inbox + * NURI and the deposits move to per-host docs. Async because the session (hence + * the private_store_id) is resolved lazily. + */ +export async function hostInboxNuri(eventId: string, explicitInbox?: string): Promise { + void eventId; // reserved: per-event inbox docs at migration + if (explicitInbox) return explicitInbox; + const { private_store_id } = await sessionPromise; + return `did:ng:${private_store_id}`; +} + +/** + * Build the host-facing notification from a registration deposit. The recipient + * is the event host; `ref` points at the event; the payload carries the raw + * registration deposit (who joined). Kept pure so callers own persistence. + */ +export function buildNotification( + recipientId: string, + eventId: string, + registrantId: string | null, + ts: number, +): Omit { + return { + recipientId, + type: NOTIF_TYPE_NEW_PARTICIPANT, + ref: eventId, + payload: JSON.stringify({ eventId, userId: registrantId }), + timestamp: new Date(ts).toISOString(), + isRead: false, + }; +} + +/** + * Deposit a registration into the host's inbox (generic lib `inbox.post`) + + * return the deposit ts so the caller can mint a matching notification. + * `from` = the registrant id when connected, or `null` for an anonymous deposit. + */ +export async function depositRegistration( + targetInbox: string, + eventId: string, + registrantId: string | null, +): Promise<{ ts: number; uid: string }> { + const ts = Date.now(); + const uid = mintDepositUid(); + const payload: RegistrationPayload = { + kind: NOTIF_TYPE_NEW_PARTICIPANT, + eventId, + userId: registrantId, + uid, + }; + await inbox.post(targetInbox, { from: registrantId ?? null, payload, ts }); + return { ts, uid }; +} + +/** + * Materialize a host inbox's deposits into host-facing notifications (data-level + * surfacing). The emulated curator (`inbox.read`) returns the raw deposits; we + * map each registration deposit to an `FpNotificationData` for `recipientId`. + */ +export async function readRegistrationNotifications( + targetInbox: string, + recipientEventId: string, +): Promise { + const deposits = await inbox.read(targetInbox); + const notifs: FpNotificationData[] = []; + for (const d of deposits) { + const p = d.payload as Partial | null; + if (!p || p.kind !== NOTIF_TYPE_NEW_PARTICIPANT || !p.eventId) continue; + // The polyfill inbox is shared (private store): keep only deposits for the + // event whose host is reading. `recipientEventId` doubles as the recipient. + if (recipientEventId && p.eventId !== recipientEventId) continue; + const built = buildNotification(recipientEventId, p.eventId, d.from ?? null, d.ts); + // F5 dedup: prefer the stable per-deposit uid carried in the payload so + // same-ms / anonymous deposits never collide. Fall back to the legacy + // ts+principal id for deposits minted before the uid existed. + const id = p.uid ? `notif-${p.uid}` : `notif-${d.ts}-${p.userId ?? 'anon'}`; + notifs.push({ ...built, id }); + } + return notifs; +} + +/** + * How the deletion identified the Participation, for the caller's verification. + * `remaining` is the authoritative post-delete count of Participations still + * matching (event, user) in `graphNuri` — re-queried from the broker AFTER the + * update. A durable delete leaves `remaining === 0`; a non-zero value means the + * delete matched nothing (or partially), so the caller must NOT flip the UI. + */ +export interface DeleteParticipationResult { + /** Participations matching (event, user) BEFORE the delete (re-queried). */ + before: number; + /** Participations matching (event, user) AFTER the delete (re-queried). */ + remaining: number; + /** Whether we deleted by the participation's own subject IRI (vs. fallback). */ + bySubject: boolean; +} + +/** Count Participations matching (event, user) in `graphNuri`, authoritatively + * (re-query the real broker via `docs.sparqlQuery`). We match ?event / ?user + * by IRI OR by literal string value so the count is tolerant of how the ORM + * serialized these fields — this is a COUNT (read-only), so tolerance here is + * safe (unlike a DELETE, it can never over-remove). */ +async function countParticipations( + sid: string, + graphNuri: string, + eventId: string, + userId: string, +): Promise { + const g = assertNuri(graphNuri); + const evL = escapeLiteral(eventId); + const usL = escapeLiteral(userId); + const query = ` + SELECT (COUNT(DISTINCT ?s) AS ?n) WHERE { + GRAPH <${g}> { + ?s a <${P.partType}> ; + <${P.partEvent}> ?event ; + <${P.partUser}> ?user . + FILTER( STR(?event) = "${evL}" && STR(?user) = "${usL}" ) + } + }`; + const result = await docs.sparqlQuery(sid, query, undefined, graphNuri); + // Tolerant binding extraction (mirrors the lib's readBindings shape). + const anyRes = result as { + results?: { bindings?: Array> }; + }; + const rows = Array.isArray(result) + ? (result as Array>) + : anyRes.results?.bindings ?? []; + const raw = rows[0]?.n?.value ?? '0'; + const n = parseInt(raw, 10); + return Number.isFinite(n) ? n : 0; +} + +/** + * DURABLY delete a Participation server-side via SPARQL DELETE-WHERE (the real + * injected `ng` through `docs.sparqlUpdate`) — the documented fallback for the + * CRDT resurrection bug (see caveat_participation-deletion): `ngSet.delete()` + * triggers reactivity but the item resurrects via broker sync. This removes + * every triple of the matching Participation subject in `graphNuri`, then + * re-queries the broker to CONFIRM the removal (returns the affected counts so + * the caller can verify before flipping the UI). + * + * Identification (durable, removes ALL matches — the broker can hold DUPLICATE + * participations for one (event, user)): + * 1. AUTHORITATIVE SWEEP by (event, user) — binds ?event/?user by IRI value + * (`sameTerm`) AND literal string value, covering the absolute IRI form and + * any base-resolved literal form (NOT the weak `STR()`-only match of the F2 + * bug). Removes every matching participation, including duplicates. + * 2. BELT-AND-SUSPENDERS by the participation's OWN subject IRI (`subjectIri`, + * from `ngPart["@id"]`) when known — exact, bound as an IRI, catches any + * residual the sweep missed on object-form drift. + * Only deleting the single reactive subject (the naive fix) leaves duplicates + * behind → the participation resurrects. + * + * IMPORTANT (caveat): do NOT rely on `ngSet.delete()` to persist the removal in + * the SAME flow — combining the two paths creates a CRDT conflict. The caller + * uses THIS as the authoritative delete and only reflects the result in reactive + * state for the immediate UI, AND only once `remaining === 0`. + */ +export async function deleteParticipation( + graphNuri: string, + eventId: string, + userId: string, + subjectIri?: string, +): Promise { + const sid = (await sessionPromise).session_id; + const g = assertNuri(graphNuri); + // The ORM batches `ngSet.add` into a microtask + the broker needs a moment to + // land it in the SPARQL-queryable graph. A leave that follows a join tightly + // (tests; a fast user) can reach here BEFORE the join's write is queryable — + // deleting then would no-op on absent triples (the F2 resurrection root). Flush + // the ORM microtask and poll the authoritative count until the participation is + // visible (bounded) so the delete operates on real triples. In the running app + // the triple is already present, so the first poll returns immediately. + await Promise.resolve(); // flush pending ORM microtask batch + let before = await countParticipations(sid, graphNuri, eventId, userId); + for (let i = 0; before === 0 && i < 10; i++) { + await new Promise(r => setTimeout(r, 100)); + before = await countParticipations(sid, graphNuri, eventId, userId); + } + + // A usable subject IRI is a non-empty, IRI-safe value (the ORM assigns "" to + // freshly-added, not-yet-persisted items — that's not a real subject). + const hasSubject = typeof subjectIri === 'string' && subjectIri.length > 0; + + // AUTHORITATIVE SWEEP: delete EVERY Participation matching (event, user) — not + // just the one subject the reactive set surfaced. The broker can hold DUPLICATE + // participations for the same (event, user) (observed: a re-join / stale item + // leaves 2); deleting only `ngPart["@id"]` removes one and the other survives → + // the désinscription silently no-ops on the duplicate and resurrects. So the + // durable delete matches by (event, user), binding ?event/?user by IRI value + // (sameTerm) AND by literal string value — covering the absolute IRI form and + // any base-resolved literal form (NOT the weak `STR()`-only match of the F2 bug). + const evIri = escapeIri(eventId); + const usIri = escapeIri(userId); + const evL = escapeLiteral(eventId); + const usL = escapeLiteral(userId); + const sweep = ` + DELETE { + GRAPH <${g}> { ?s ?p ?o } + } + WHERE { + GRAPH <${g}> { + ?s a <${P.partType}> ; + <${P.partEvent}> ?event ; + <${P.partUser}> ?user ; + ?p ?o . + FILTER( + ( sameTerm(?event, <${evIri}>) || STR(?event) = "${evL}" ) && + ( sameTerm(?user, <${usIri}>) || STR(?user) = "${usL}" ) + ) + } + }`; + await docs.sparqlUpdate(sid, sweep, graphNuri); + + // BELT-AND-SUSPENDERS: also delete by the participation's OWN subject IRI when + // known. This catches the residual case where an object-form drift makes the + // (event, user) sweep miss a subject we nonetheless hold the id for — exact, + // bound as an IRI, cannot no-op on drift. + if (hasSubject) { + const s = assertNuri(subjectIri!); + const bySubject = ` + DELETE { + GRAPH <${g}> { <${s}> ?p ?o } + } + WHERE { + GRAPH <${g}> { <${s}> ?p ?o } + }`; + await docs.sparqlUpdate(sid, bySubject, graphNuri); + } + + // Re-query the broker to CONFIRM every matching participation is gone + // (authoritative — not the reactive set). Caller checks `remaining === 0`. + const remaining = await countParticipations(sid, graphNuri, eventId, userId); + return { before, remaining, bySubject: hasSubject }; +} + +/** + * Persist a host notification server-side as an `FpNotification` (SHEX shape, + * T02.a) via SPARQL INSERT DATA into `graphNuri`. Complements the reactive path; + * used so notifications survive a refresh at the data level. + */ +export async function insertNotification( + graphNuri: string, + notif: Omit, +): Promise { + const sid = (await sessionPromise).session_id; + const g = assertNuri(graphNuri); + const subject = `urn:festipod:notif:${Date.now()}:${Math.random().toString(36).slice(2)}`; + // recipient/ref are bare domain ids ("user-1", "event-1"), not absolute IRIs; + // store them as string literals to keep the INSERT valid (the raw shape read + // is not the primary surfacing path — the inbox curator is). Every literal is + // escaped via the lib's escapeLiteral (guards \ " \n \r \t — SPARQL injection). + const refTriple = notif.ref ? `\n <${P.ref}> "${escapeLiteral(notif.ref)}" ;` : ''; + const payloadTriple = notif.payload + ? `\n <${P.payload}> "${escapeLiteral(notif.payload)}" ;` + : ''; + const update = ` + INSERT DATA { + GRAPH <${g}> { + <${assertNuri(subject)}> a <${NOTIF_TYPE_IRI}> ; + <${P.recipient}> "${escapeLiteral(notif.recipientId)}" ; + <${P.type}> "${escapeLiteral(notif.type)}" ;${refTriple}${payloadTriple} + <${P.timestamp}> "${escapeLiteral(notif.timestamp)}" ; + <${P.isRead}> "${notif.isRead}" . + } + }`; + await docs.sparqlUpdate(sid, update, graphNuri); + return subject; +} diff --git a/src/shared/data/types.ts b/src/shared/data/types.ts index 5225640..2011c26 100644 --- a/src/shared/data/types.ts +++ b/src/shared/data/types.ts @@ -46,10 +46,29 @@ export interface FpParticipationData { export interface FpMeetingPointData { id: string; eventId: string; + /** userId of the host (aligned with the SHEX MeetingPoint.host reference). */ + hostId?: string; + title?: string; + description?: string; + /** Where participants gather (SHEX MeetingPoint.place). */ + place?: string; location: string; time: string; hostName: string; hostInitials: string; + /** NURI of the meeting point's inbox (wired in T02.b/c). */ + inbox?: string; +} + +export interface FpNotificationData { + id: string; + recipientId: string; + type: string; + /** Reference (IRI/id) to the subject resource. */ + ref?: string; + payload?: string; + timestamp: string; + isRead: boolean; } export interface FpFriendshipData { diff --git a/src/shared/shapes/orm/festipodShapes.schema.ts b/src/shared/shapes/orm/festipodShapes.schema.ts index ca1636d..063be57 100644 --- a/src/shared/shapes/orm/festipodShapes.schema.ts +++ b/src/shared/shapes/orm/festipodShapes.schema.ts @@ -176,4 +176,129 @@ export const festipodShapesSchema: Schema = { }, ], }, + "http://festipod.org/MeetingPoint": { + iri: "http://festipod.org/MeetingPoint", + predicates: [ + { + dataTypes: [ + { + valType: "iri", + literals: ["http://festipod.org/MeetingPoint"], + }, + ], + maxCardinality: 1, + minCardinality: 1, + iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + readablePredicate: "@type", + }, + { + dataTypes: [{ valType: "iri" }], + maxCardinality: 1, + minCardinality: 1, + iri: "http://festipod.org/event", + readablePredicate: "event", + }, + { + dataTypes: [{ valType: "iri" }], + maxCardinality: 1, + minCardinality: 1, + iri: "http://festipod.org/host", + readablePredicate: "host", + }, + { + dataTypes: [{ valType: "string" }], + maxCardinality: 1, + minCardinality: 1, + iri: "http://festipod.org/title", + readablePredicate: "title", + }, + { + dataTypes: [{ valType: "string" }], + maxCardinality: 1, + minCardinality: 0, + iri: "http://festipod.org/description", + readablePredicate: "description", + }, + { + dataTypes: [{ valType: "string" }], + maxCardinality: 1, + minCardinality: 0, + iri: "http://festipod.org/place", + readablePredicate: "place", + }, + { + dataTypes: [{ valType: "string" }], + maxCardinality: 1, + minCardinality: 0, + iri: "http://festipod.org/time", + readablePredicate: "time", + }, + { + dataTypes: [{ valType: "string" }], + maxCardinality: 1, + minCardinality: 0, + iri: "http://festipod.org/inbox", + readablePredicate: "inbox", + }, + ], + }, + "http://festipod.org/Notification": { + iri: "http://festipod.org/Notification", + predicates: [ + { + dataTypes: [ + { + valType: "iri", + literals: ["http://festipod.org/Notification"], + }, + ], + maxCardinality: 1, + minCardinality: 1, + iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + readablePredicate: "@type", + }, + { + dataTypes: [{ valType: "iri" }], + maxCardinality: 1, + minCardinality: 1, + iri: "http://festipod.org/recipient", + readablePredicate: "recipient", + }, + { + dataTypes: [{ valType: "string" }], + maxCardinality: 1, + minCardinality: 1, + iri: "http://festipod.org/type", + readablePredicate: "type", + }, + { + dataTypes: [{ valType: "iri" }], + maxCardinality: 1, + minCardinality: 0, + iri: "http://festipod.org/ref", + readablePredicate: "ref", + }, + { + dataTypes: [{ valType: "string" }], + maxCardinality: 1, + minCardinality: 0, + iri: "http://festipod.org/payload", + readablePredicate: "payload", + }, + { + dataTypes: [{ valType: "string" }], + maxCardinality: 1, + minCardinality: 1, + iri: "http://festipod.org/timestamp", + readablePredicate: "timestamp", + }, + { + dataTypes: [{ valType: "boolean" }], + maxCardinality: 1, + minCardinality: 1, + iri: "http://festipod.org/isRead", + readablePredicate: "isRead", + }, + ], + }, }; diff --git a/src/shared/shapes/orm/festipodShapes.shapeTypes.ts b/src/shared/shapes/orm/festipodShapes.shapeTypes.ts index f115e47..7a271ff 100644 --- a/src/shared/shapes/orm/festipodShapes.shapeTypes.ts +++ b/src/shared/shapes/orm/festipodShapes.shapeTypes.ts @@ -1,6 +1,12 @@ import type { ShapeType } from "@ng-org/shex-orm"; import { festipodShapesSchema } from "./festipodShapes.schema"; -import type { FpEvent, FpUserProfile, FpParticipation } from "./festipodShapes.typings"; +import type { + FpEvent, + FpUserProfile, + FpParticipation, + FpMeetingPoint, + FpNotification, +} from "./festipodShapes.typings"; // ShapeTypes for festipodShapes export const FpEventShapeType: ShapeType = { @@ -15,3 +21,11 @@ export const FpParticipationShapeType: ShapeType = { schema: festipodShapesSchema, shape: "http://festipod.org/Participation", }; +export const FpMeetingPointShapeType: ShapeType = { + schema: festipodShapesSchema, + shape: "http://festipod.org/MeetingPoint", +}; +export const FpNotificationShapeType: ShapeType = { + schema: festipodShapesSchema, + shape: "http://festipod.org/Notification", +}; diff --git a/src/shared/shapes/orm/festipodShapes.typings.ts b/src/shared/shapes/orm/festipodShapes.typings.ts index 0fa42a8..130865f 100644 --- a/src/shared/shapes/orm/festipodShapes.typings.ts +++ b/src/shared/shapes/orm/festipodShapes.typings.ts @@ -161,3 +161,117 @@ export interface FpParticipation { */ isConfirmed: boolean; } + +/** + * MeetingPoint Type + */ +export interface FpMeetingPoint { + /** + * The graph IRI. + */ + readonly "@graph": IRI; + /** + * The subject IRI. + */ + readonly "@id": IRI; + /** + * Original IRI: http://www.w3.org/1999/02/22-rdf-syntax-ns#type + */ + "@type": "http://festipod.org/MeetingPoint"; + /** + * Reference to the public event this meeting point is anchored to + * + * Original IRI: http://festipod.org/event + */ + event: IRI; + /** + * Reference to the user hosting the meeting point + * + * Original IRI: http://festipod.org/host + */ + host: IRI; + /** + * The title of the meeting point + * + * Original IRI: http://festipod.org/title + */ + title: string; + /** + * A description of the meeting point + * + * Original IRI: http://festipod.org/description + */ + description?: string; + /** + * Where participants gather (e.g. 'Entrée principale, sous l'horloge') + * + * Original IRI: http://festipod.org/place + */ + place?: string; + /** + * When participants gather (display string) + * + * Original IRI: http://festipod.org/time + */ + time?: string; + /** + * NURI of the meeting point's inbox (wired in T02.b/c) + * + * Original IRI: http://festipod.org/inbox + */ + inbox?: string; +} + +/** + * Notification Type + */ +export interface FpNotification { + /** + * The graph IRI. + */ + readonly "@graph": IRI; + /** + * The subject IRI. + */ + readonly "@id": IRI; + /** + * Original IRI: http://www.w3.org/1999/02/22-rdf-syntax-ns#type + */ + "@type": "http://festipod.org/Notification"; + /** + * Reference to the user receiving the notification + * + * Original IRI: http://festipod.org/recipient + */ + recipient: IRI; + /** + * Notification kind (e.g. 'new-participant', 'meeting-point-join') + * + * Original IRI: http://festipod.org/type + */ + type: string; + /** + * Reference to the subject resource (meeting point, participation, user) + * + * Original IRI: http://festipod.org/ref + */ + ref?: IRI; + /** + * Opaque JSON payload for the notification + * + * Original IRI: http://festipod.org/payload + */ + payload?: string; + /** + * ISO8601 timestamp when the notification was created + * + * Original IRI: http://festipod.org/timestamp + */ + timestamp: string; + /** + * Whether the recipient has read the notification + * + * Original IRI: http://festipod.org/isRead + */ + isRead: boolean; +} diff --git a/src/shared/shapes/shex/festipodShapes.shex b/src/shared/shapes/shex/festipodShapes.shex index 380cb51..e0b0865 100644 --- a/src/shared/shapes/shex/festipodShapes.shex +++ b/src/shared/shapes/shex/festipodShapes.shex @@ -47,3 +47,37 @@ fp:Participation { fp:isConfirmed xsd:boolean // rdfs:comment "Whether the participation is confirmed" ; } + +fp:MeetingPoint { + a [fp:MeetingPoint] ; + fp:event IRI + // rdfs:comment "Reference to the public event this meeting point is anchored to" ; + fp:host IRI + // rdfs:comment "Reference to the user hosting the meeting point" ; + fp:title xsd:string + // rdfs:comment "The title of the meeting point" ; + fp:description xsd:string ? + // rdfs:comment "A description of the meeting point" ; + fp:place xsd:string ? + // rdfs:comment "Where participants gather (e.g. 'Entrée principale, sous l'horloge')" ; + fp:time xsd:string ? + // rdfs:comment "When participants gather (display string)" ; + fp:inbox xsd:string ? + // rdfs:comment "NURI of the meeting point's inbox (wired in T02.b/c)" ; +} + +fp:Notification { + a [fp:Notification] ; + fp:recipient IRI + // rdfs:comment "Reference to the user receiving the notification" ; + fp:type xsd:string + // rdfs:comment "Notification kind (e.g. 'new-participant', 'meeting-point-join')" ; + fp:ref IRI ? + // rdfs:comment "Reference to the subject resource (meeting point, participation, user)" ; + fp:payload xsd:string ? + // rdfs:comment "Opaque JSON payload for the notification" ; + fp:timestamp xsd:string + // rdfs:comment "ISO8601 timestamp when the notification was created" ; + fp:isRead xsd:boolean + // rdfs:comment "Whether the recipient has read the notification" ; +} diff --git a/src/shared/utils/ngGraph.ts b/src/shared/utils/ngGraph.ts index 2b482c5..b87b5e5 100644 --- a/src/shared/utils/ngGraph.ts +++ b/src/shared/utils/ngGraph.ts @@ -1,9 +1,15 @@ /** * NextGraph graph NURI management. * - * Returns the private store NURI as @graph for ORM entity creation. - * This matches the expense-tracker-rdf approach: useShape with - * private_store_id scope opens the repo, and writes target the same NURI. + * Returns the PROTECTED store NURI as @graph for ORM entity creation of the + * SHAREABLE domain entities (events, profiles, participations). T02.h (axe A) + * switched the default domain scope from the private store to the real + * protected native store (`did:ng:${protected_store_id}`) — the store + * representative of the target per-user wallet. Verified empirically: the + * protected store opens for ORM reads AND writes the same way private does + * (round-trip probe, no RepoNotFound). Like private, subscribing useShape with + * the protected store NURI as scope opens its repo in the verifier, and writes + * target the same NURI. (Private stays the anchor for the inbox shim + settings.) */ import { sessionPromise } from './ngSession'; @@ -11,8 +17,8 @@ import { sessionPromise } from './ngSession'; let cachedGraphNuri: string | undefined; /** - * Get the graph NURI for adding ORM entities. - * Uses the private store NURI (same as expense-tracker-rdf). + * Get the graph NURI for adding shareable ORM entities. + * Uses the protected store NURI (T02.h; was the private store before). */ export async function ensureGraphNuri( ...sets: Iterable<{ readonly "@graph": string }>[] @@ -30,9 +36,9 @@ export async function ensureGraphNuri( } } - // Use private store NURI (the repo is opened by useShape with this scope) + // Use PROTECTED store NURI (the repo is opened by useShape with this scope). const session = await sessionPromise; - cachedGraphNuri = `did:ng:${session.private_store_id}`; - console.log('[ngGraph] Using private store as graph:', cachedGraphNuri); + cachedGraphNuri = `did:ng:${session.protected_store_id}`; + console.log('[ngGraph] Using protected store as graph:', cachedGraphNuri); return cachedGraphNuri; } diff --git a/src/shared/utils/storeRegistry.ts b/src/shared/utils/storeRegistry.ts index d91c748..f465292 100644 --- a/src/shared/utils/storeRegistry.ts +++ b/src/shared/utils/storeRegistry.ts @@ -17,7 +17,7 @@ import { storeRegistry as libStoreRegistry, type AccountRecord as LibAccountRecord, } from '@ng-eventually/client'; -import { configureStoreRegistry } from '@ng-eventually/client/polyfill'; +import { configureStoreRegistry, getCaps } from '@ng-eventually/client/polyfill'; import { sessionPromise } from './ngSession'; import { normalizeUsername } from '../context/AccountContext'; @@ -60,9 +60,35 @@ export const { loadShim, ensureAccount, resolveWriteGraph, - createEntityDoc, listEntityDocs, allAccounts, resolveReadGraphs, resetRegistryCache, } = libStoreRegistry; + +/** + * Create a per-entity document AND declare its ReadCap/WriteCap policy — the + * app-side ACTIVATION of the emulated cap registry (dormant until an app + * declares a policy). The lib's `createEntityDoc` stays domain-agnostic; the + * DOMAIN mapping (scope → who may read) is Festipod's, so it lives here. + * + * In the target this is a native cap operation attached at store/repo creation; + * here it is `getCaps().open(doc, scope, owner)`: + * - public → world-readable (`makePublic`) — events, meeting points + * - protected → owner reads now; connections granted later (a separate grant) + * - private → owner only + * The owner always holds the WRITE cap (so only the owner may `sparql_update` + * the doc once the guard is active). `owner` = the account username (the same + * principal key the shim uses and that the app sets via `setCurrentUser`). + * + * NOTE ON BASELINE: `createEntityDoc` is only reached in MULTISTORE mode; the + * default mono-store path never calls it and never sets a current user, so both + * the ReadCap filter and the write guard stay inert (passthrough) by default. + */ +export async function createEntityDoc(username: string, scope: Scope): Promise { + const entityNuri = await libStoreRegistry.createEntityDoc(username, scope); + // Declare the cap policy for the freshly-created entity document. `owner` is + // the account username (principal). This is what makes ReadCap ACTIVE. + getCaps().open(entityNuri, scope, normalizeUsername(username)); + return entityNuri; +}