diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index 6dee8db..99246d3 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -25,10 +25,8 @@ import { } from '../data/seedData'; import { useNextGraph } from './NextGraphContext'; import { useAccount, normalizeUsername } from './AccountContext'; -import { applyIsolation } from '../utils/isolation'; -import { isolation } from '@ng-eventually/client'; import { declareConnections } from '@ng-eventually/client/polyfill'; -import { resolveScopeGraph } from '../utils/storeRegistry'; +import { listEntityDocs, createEntityDoc } from '../utils/storeRegistry'; import { submitEventToIndex, readDiscoveredEvents } from '../data/discovery'; import { useShapeWithDefaults, type ShapeScope } from '../hooks/useShapeWithDefaults'; import { @@ -229,7 +227,7 @@ function useLocalData(empty?: boolean): FestipodDataContextValue { }, []); const loadTestData = useCallback(async (): Promise => { console.log('[FestipodData] loadTestData (local, no-op)'); - return { seeded: false, userIdMap: new Map(), eventIdMap: new Map() }; + return { seeded: false, userIdMap: new Map(), eventIdMap: new Map(), createdDocs: { public: [], protected: [] } }; }, []); return { @@ -252,31 +250,47 @@ function useNgData(): FestipodDataContextValue { const { session } = useNextGraph(); const { username } = useAccount(); // The app speaks ONLY in logical scopes — it holds no store id and builds no - // `did:ng:${…}` NURI. It asks the SDK (`resolveScopeGraph(scope)`) for the - // opaque graph NURI of each scope; the SDK owns the physical placement (today - // it resolves the shareable domain scopes to the shared wallet's native - // stores — its internal detail). `ready` gates the effects on the session. + // `did:ng:${…}` NURI. It creates ONE document PER ENTITY in its scope + // (`createEntityDoc(scope)`, the SDK create) and reads a scope by subscribing + // to the set of its per-entity documents (`listEntityDocs(scope)`). The SDK + // owns the physical placement AND the per-document isolation — the app carries + // no access logic (see rule_document-per-entity, knowledge_trust-model). + // `ready` gates the effects on the session. const ready = !!session; - // Scope-resolved graphs (from the SDK). Domain entities: events → public, - // profiles + participations → protected. Populated by the effect below. - const [scopeGraphs, setScopeGraphs] = useState<{ public?: string; protected?: string }>({}); + // Per-entity document sets, by scope (the SDK create appends here immediately + // so a freshly-created entity is visible without waiting for a re-list). Events + // → public; profiles + participations → protected. Seeded from listEntityDocs. + const [publicDocs, setPublicDocs] = useState([]); + const [protectedDocs, setProtectedDocs] = useState([]); + + /** Add a freshly-created entity document to its scope's live subscription set + * (reactivity: the new doc joins the useShape graphs immediately). */ + const registerDoc = useCallback((scope: 'public' | 'protected', nuri: string) => { + const setter = scope === 'public' ? setPublicDocs : setProtectedDocs; + setter(prev => (prev.includes(nuri) ? prev : [...prev, nuri])); + }, []); + useEffect(() => { if (!ready) return; let cancelled = false; (async () => { try { const [pub, prot] = await Promise.all([ - resolveScopeGraph('public'), - resolveScopeGraph('protected'), + listEntityDocs('public'), + listEntityDocs('protected'), ]); - if (!cancelled) setScopeGraphs({ public: pub, protected: prot }); + if (cancelled) return; + // Union with any docs already registered locally (don't drop a doc the + // user just created before the re-list caught up). + setPublicDocs(prev => [...new Set([...prev, ...pub])]); + setProtectedDocs(prev => [...new Set([...prev, ...prot])]); } catch (err) { - console.error('[FestipodData] scope resolution failed:', err); + console.error('[FestipodData] entity-doc listing failed:', err); } })(); return () => { cancelled = true; }; - }, [ready]); + }, [ready, username]); // --- Public discovery (T03.c): read the GLOBAL INDEX ---------------------- // Discovery is "read the global index" (the SDK read). The app asks the SDK @@ -309,11 +323,12 @@ function useNgData(): FestipodDataContextValue { }, [ready, username]); const discoveryScope: ShapeScope = discoveryGraphs.length ? { graphs: discoveryGraphs } : undefined; - // Scope per entity: events read/write the PUBLIC scope, profiles + - // participations the PROTECTED scope. Both are opaque SDK-resolved graph NURIs - // (the SDK owns placement) — the app never sees a store id. - const publicScope: ShapeScope = scopeGraphs.public; - const protectedScope: ShapeScope = scopeGraphs.protected; + // Scope per entity: events read the PUBLIC scope, profiles + participations the + // PROTECTED scope. Each scope subscribes to the SET of its per-entity documents + // (opaque SDK NURIs — the app never sees a store id). The SDK's per-document + // ReadCap filter returns only the documents the current identity may read. + const publicScope: ShapeScope = publicDocs.length ? { graphs: publicDocs } : undefined; + const protectedScope: ShapeScope = protectedDocs.length ? { graphs: protectedDocs } : undefined; // useShapeWithDefaults: show EMPTY data until NG populates (no seed defaults) const emptyEvents: FpEventData[] = []; @@ -376,7 +391,12 @@ function useNgData(): FestipodDataContextValue { eventsShape.ngSet as any, usersShape.ngSet as any, participationsShape.ngSet as any, - ).catch(err => console.error('[FestipodData] Auto-seed failed:', err)); + createEntityDoc, + ).then(({ createdDocs }) => { + // Register the seeded per-entity docs into the live subscription sets. + createdDocs.public.forEach(d => registerDoc('public', d)); + createdDocs.protected.forEach(d => registerDoc('protected', d)); + }).catch(err => console.error('[FestipodData] Auto-seed failed:', err)); } else { console.log('[FestipodData] Dev auto-seed: wallet already has data — skip'); } @@ -433,27 +453,24 @@ function useNgData(): FestipodDataContextValue { // eslint-disable-next-line react-hooks/exhaustive-deps }, [ready, hostedEventIds.join('|')]); - // Protected-sharing act: hand the SDK the current CONNECTIONS graph so it lets - // an owner's direct connections read that owner's PROTECTED entities (public = - // all; private = owner only). The app knows its connections (friendships — a - // domain fact) and declares them to the SDK; the SDK owns the enforcement. No - // store id, no document NURI crosses here — a pure domain graph. + // Protected-sharing act: declare the CURRENT identity's own connections to the + // SDK so an owner's connections may read that owner's PROTECTED entities (public + // = all; private = owner only). The declaration is AUTHENTICATED — it names only + // the current user's own peers and is bound to the current identity by the SDK; + // a protected read is granted only where BOTH sides connected (bilateral). The + // app carries NO access logic (see knowledge_trust-model) — it only declares its + // domain fact (friendships) and trusts the SDK's enforcement. No store id, no + // document NURI crosses here. useEffect(() => { - if (!ready) return; - declareConnections( - isolation.connectionsFromLinks(friendships.map(f => ({ a: f.userId, b: f.friendId }))), - ); - }, [ready, friendships]); - - // Isolation (staging realism): the app honors the matrix in connected mode — - // participations/connections narrowed to self + connections. See isolation.ts. - const isolated = applyIsolation( - { events, users, participations, meetingPoints, friendships }, - currentUserId, - ); + if (!ready || !currentUserId) return; + const myPeers = friendships + .filter(f => f.userId === currentUserId || f.friendId === currentUserId) + .map(f => (f.userId === currentUserId ? f.friendId : f.userId)); + declareConnections(myPeers, currentUserId); + }, [ready, friendships, currentUserId]); const queries = buildQueries( - events, users, isolated.participations, meetingPoints, isolated.friendships, currentUserId, + events, users, participations, meetingPoints, friendships, currentUserId, ); console.log('[FestipodData] Render — NG | events:', events.length, @@ -461,16 +478,23 @@ function useNgData(): FestipodDataContextValue { '| selectedEvent:', selectedEvent?.title ?? '(none)'); // --- Mutations (NG) --- - // Writes target the SCOPE-resolved graphs (opaque SDK NURIs — no store id). - // Participations + profiles → protected scope; events → public scope. The - // read scopes subscribe the same graphs, so writes round-trip. - const protectedGraph = protectedScope || ''; - const publicGraph = publicScope || ''; + // Each entity is written as its OWN document, created via the SDK in its scope + // (`createEntityDoc(scope)`) — never a store-level document. The new document's + // NURI is the entity's `@graph`, and it joins the scope's live subscription set + // immediately (registerDoc) so the entity is visible right away. The SDK + // declares the per-document ReadCap policy on create (public / protected / + // private) — the app carries no access logic. const createEvent = useCallback(async (event: Omit): Promise => { console.log('[FestipodData] createEvent (NG):', event.title); - // Events live in the PUBLIC scope (SDK-resolved graph — no store id). - const eventGraph = publicGraph; + // Owner principal = the account username (what setCurrentUser declares). The + // SDK create returns THIS entity's OWN public document and declares its + // ReadCap policy (public → world-readable). Fall back to a generic account + // label when no login is present (dev/demo). + const owner = username || currentUserId || 'anon'; + // Create the event's OWN document in the PUBLIC scope (one doc per entity). + const eventGraph = await createEntityDoc(owner, 'public'); + registerDoc('public', eventGraph); eventsShape.ngSet.add({ "@graph": eventGraph, "@type": "http://festipod.org/Event", "@id": "", title: event.title, description: event.description, date: event.date, @@ -480,24 +504,32 @@ function useNgData(): FestipodDataContextValue { } as FpEvent); const addedEvent = [...eventsShape.ngSet].find(e => e.title === event.title); if (addedEvent && currentUserId) { + // The host's participation is its OWN document in the PROTECTED scope. + const partGraph = await createEntityDoc(owner, 'protected'); + registerDoc('protected', partGraph); participationsShape.ngSet.add({ - "@graph": protectedGraph, "@type": "http://festipod.org/Participation", "@id": "", + "@graph": partGraph, "@type": "http://festipod.org/Participation", "@id": "", event: addedEvent["@id"], user: currentUserId, isConfirmed: true, } as FpParticipation); setSelectedEventId(addedEvent["@id"]); } // Make the PUBLIC event discoverable: submit its reference to the SDK global - // discovery index (an SDK act — the app holds no index/store id). `submitter` - // = the declaring user when known, anonymous otherwise. Best-effort: a failed - // submission must not roll back a successful event creation. + // discovery index (an SDK act — the app holds no index/store id). The SDK + // enforces public-only: passing the event's own document lets it refuse a + // non-public doc. Best-effort: a failed submission must not roll back a + // successful event creation. if (addedEvent) { + // Submitter is bound to the current identity by the SDK (it is not + // caller-supplied) — pass `null` for an anonymous submission (discovery + // needs no author). Avoids handing a principal the SDK would reject as a + // spoof (the app's user IRI differs from the declared identity key). submitEventToIndex( { doc: eventGraph, id: addedEvent["@id"], title: event.title }, - currentUserId || null, + null, ).catch(err => console.error('[FestipodData] submit event to index failed:', err)); } return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` }; - }, [protectedGraph, publicGraph, eventsShape.ngSet, participationsShape.ngSet, currentUserId, username]); + }, [eventsShape.ngSet, participationsShape.ngSet, currentUserId, username, registerDoc]); const updateEvent = useCallback((id: string, updates: Partial) => { console.log('[FestipodData] updateEvent (NG):', id, updates); @@ -520,9 +552,14 @@ function useNgData(): FestipodDataContextValue { console.log('[FestipodData] Already participating, skipping'); return; } - // 1) Persist the Participation (reactive ORM set — protected scope graph). + // 1) Persist the Participation as its OWN document in the PROTECTED scope + // (one doc per entity). Owner = the account username (setCurrentUser key). + // The new doc joins the protected subscription set immediately (reactivity). + const owner = username || uid || 'anon'; + const partGraph = await createEntityDoc(owner, 'protected'); + registerDoc('protected', partGraph); participationsShape.ngSet.add({ - "@graph": protectedGraph, "@type": "http://festipod.org/Participation", "@id": "", + "@graph": partGraph, "@type": "http://festipod.org/Participation", "@id": "", event: eventId, user: uid, isConfirmed: true, } as FpParticipation); const ngEvent = findNg(eventsShape.ngSet as any as Set, e => e["@id"] === eventId); @@ -542,7 +579,12 @@ function useNgData(): FestipodDataContextValue { 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 */ }); + // The host FpNotification is its OWN document in the PROTECTED scope (one + // doc per entity). Best-effort — the inbox materialization is the source of + // truth; this direct write only pre-warms the reactive read. + const notifGraph = await createEntityDoc(owner, 'protected'); + registerDoc('protected', notifGraph); + await insertNotification(notifGraph, 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 // notification id from the inbox and same-ms/anon deposits never collide. @@ -550,7 +592,7 @@ function useNgData(): FestipodDataContextValue { } catch (err) { console.error('[FestipodData] joinEvent inbox/notify failed:', err); } - }, [protectedGraph, participationsShape.ngSet, eventsShape.ngSet, currentUserId]); + }, [participationsShape.ngSet, eventsShape.ngSet, currentUserId, username, registerDoc]); const leaveEvent = useCallback(async (eventId: string, userId?: string) => { const uid = userId || currentUserId; @@ -562,11 +604,11 @@ function useNgData(): FestipodDataContextValue { // 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) — falling back to the protected SCOPE graph (SDK-resolved, no store id) - // — 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; + // in) — the participation's OWN per-entity document — 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"]; const subjectIri = ngPart["@id"]; let result; try { @@ -599,7 +641,7 @@ function useNgData(): FestipodDataContextValue { if (ngEvent) { ngEvent.participantCount = Math.max(0, ngEvent.participantCount - 1); } - }, [participationsShape.ngSet, eventsShape.ngSet, currentUserId, protectedGraph]); + }, [participationsShape.ngSet, eventsShape.ngSet, currentUserId]); const addMeetingPoint = useCallback((mp: Omit) => { setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]); @@ -630,19 +672,23 @@ function useNgData(): FestipodDataContextValue { const loadTestData = useCallback(async (): Promise => { console.log('[FestipodData] loadTestData (NG)'); - return bootstrapWallet( + const result = await bootstrapWallet( eventsShape.ngSet as any, usersShape.ngSet as any, participationsShape.ngSet as any, + createEntityDoc, ); - }, [eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet]); + result.createdDocs.public.forEach(d => registerDoc('public', d)); + result.createdDocs.protected.forEach(d => registerDoc('protected', d)); + return result; + }, [eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet, registerDoc]); return { currentUserId, currentUser, events, users, - participations: isolated.participations, + participations, meetingPoints, - friendships: isolated.friendships, + friendships, notifications, selectedEventId, setSelectedEventId, selectedEvent, selectedUserId, setSelectedUserId, selectedUser, diff --git a/src/shared/data/discovery.ts b/src/shared/data/discovery.ts index ed4e58d..3eae4d2 100644 --- a/src/shared/data/discovery.ts +++ b/src/shared/data/discovery.ts @@ -50,7 +50,9 @@ export async function submitEventToIndex( submitter: string | null = null, ): Promise { const payload: EventIndexRef = { kind: 'event', ...ref }; - await discovery.submitToIndex(payload, { from: submitter }); + // Pass the event's document NURI so the SDK enforces PUBLIC-ONLY at the index: + // a non-public document is refused (it must never leak past its scope). + await discovery.submitToIndex(payload, { from: submitter, doc: ref.doc }); } /** diff --git a/src/shared/data/registration.ts b/src/shared/data/registration.ts index 62fb5d1..b276f27 100644 --- a/src/shared/data/registration.ts +++ b/src/shared/data/registration.ts @@ -100,7 +100,12 @@ export function buildNotification( /** * 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. + * + * The registrant identity travels in the PAYLOAD (`userId`), which the host + * materializer reads. The transport-level `from` is left ANONYMOUS (`null`): the + * SDK binds `from` to the depositor's own identity and rejects a mismatched one + * as a spoof, and the app's user IRI is not the declared identity key — so the + * domain identity belongs in the payload, not in the transport `from`. */ export async function depositRegistration( targetInbox: string, @@ -115,7 +120,7 @@ export async function depositRegistration( userId: registrantId, uid, }; - await inbox.post(targetInbox, { from: registrantId ?? null, payload, ts }); + await inbox.post(targetInbox, { from: null, payload, ts }); return { ts, uid }; } diff --git a/src/shared/test-harness/harness-ng.tsx b/src/shared/test-harness/harness-ng.tsx index e6caa80..ae58470 100644 --- a/src/shared/test-harness/harness-ng.tsx +++ b/src/shared/test-harness/harness-ng.tsx @@ -14,7 +14,6 @@ import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataCo // useShape routed through the lib (SDK-identical surface); caps from /polyfill. import { useShape, docs, inbox as docsInbox } from '@ng-eventually/client'; import { getCaps, getCurrentUser, setCurrentUser, resetCaps, declareConnections } from '@ng-eventually/client/polyfill'; -import { isolation as ngIsolation } from '@ng-eventually/client'; import { hostInboxNuri as regInboxNuri } from '../data/registration'; import type { DeepSignalSet } from '@ng-eventually/client'; // doc_create goes through the lib's `docs` primitive (T01.a): it calls the REAL @@ -302,10 +301,12 @@ function ConnectedHarness() { setCurrentUser(reader); setFilterActive(true); }, - /** Declare the owner↔reader connection to the SDK (domain sharing act). - * The SDK then issues the protected doc's read cap to the connection. */ + /** Declare a BILATERAL owner↔reader connection to the SDK (domain sharing + * act). Each side asserts the other (bound to that identity); only then + * does the SDK issue the protected doc's read cap to the connection. */ connect(a: string, b: string) { - declareConnections(ngIsolation.connectionsFromLinks([{ a, b }])); + declareConnections([b], a); // a asserts b + declareConnections([a], b); // b asserts a → bilateral link materializes }, /** Does the CURRENT user read the public entity document — through the * SDK's own cap check — regardless of the protected caps? */ diff --git a/src/shared/utils/isolation.ts b/src/shared/utils/isolation.ts deleted file mode 100644 index d4fc9d4..0000000 --- a/src/shared/utils/isolation.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * isolation — app-side visibility filter for the authorization matrix. - * - * STOPGAP: the app HONORS the matrix by filtering reads by owner + connections: - * - * - public (events, meeting points) → visible to everyone - * - protected (participations, connections) → owner + connections - * - private (settings) → owner only - * - * This is a deliberate, removable scaffold. Applied in CONNECTED mode only; - * demo/@ui mode keeps full seed data. - * - * Pure functions — no NextGraph, no React. Trivially testable. - */ - -import type { - FpEventData, - FpUserData, - FpParticipationData, - FpMeetingPointData, - FpFriendshipData, -} from '../data/types'; -// The generic visibility matrix now lives in the lib (`isolation`, ported in -// T01.c): pure `applyIsolation(items, current, connections, accessors)` + -// `connectionsFromLinks`. This wrapper maps the Festipod shapes onto that -// generic surface (friendships → connection graph; participations/friendships -// → items with a Festipod owner+scope). See decision_2026-06-17_eventually-library. -import { isolation } from '@ng-eventually/client'; - -export interface IsolatableData { - events: FpEventData[]; - users: FpUserData[]; - participations: FpParticipationData[]; - meetingPoints: FpMeetingPointData[]; - friendships: FpFriendshipData[]; -} - -/** The set the current user may see protected data for: self + direct connections. */ -export function connectionIds(currentUserId: string, friendships: FpFriendshipData[]): Set { - const connections = isolation.connectionsFromLinks( - friendships.map(f => ({ a: f.userId, b: f.friendId })), - ); - return isolation.visibleSet(currentUserId, connections); -} - -/** - * Narrow data to what `currentUserId` is allowed to see. - * - * - events / meeting points: untouched (public). - * - users: untouched — names/avatars are referenced (denormalized) by public - * events and by visible participations; full profile-level isolation is a - * later refinement (matrix open question on host identity). - * - participations: only the user's own and their connections' (protected). - * - friendships: only links involving the user or one of their connections. - * - * Delegates the visibility matrix to the lib's pure `applyIsolation`, mapping - * each Festipod item to (owner, scope). A friendship is owned by *either* - * endpoint, so we model it as protected-owned-by-both via a synthetic owner - * check: keep the original link-based predicate for friendships, use the lib - * for the per-owner participation filter. - */ -export function applyIsolation(data: T, currentUserId: string): T { - // No identity yet → don't hide everything (e.g. during hydration). - if (!currentUserId) return data; - - const connections = isolation.connectionsFromLinks( - data.friendships.map(f => ({ a: f.userId, b: f.friendId })), - ); - - // Participations: owner = the participating user, scope = protected. - const participations = isolation.applyIsolation( - data.participations, - currentUserId, - connections, - { ownerOf: p => p.userId, scopeOf: () => 'protected' }, - ); - - // Friendships are two-ended links: keep a link if EITHER endpoint is visible. - const visible = isolation.visibleSet(currentUserId, connections); - const friendships = data.friendships.filter( - f => visible.has(f.userId) || visible.has(f.friendId), - ); - - return { ...data, participations, friendships }; -} diff --git a/src/shared/utils/ngBootstrap.ts b/src/shared/utils/ngBootstrap.ts index 03acc58..31f3f8e 100644 --- a/src/shared/utils/ngBootstrap.ts +++ b/src/shared/utils/ngBootstrap.ts @@ -3,21 +3,33 @@ * * Called once after NG connection + shapes ready. If the wallet already * has events/users, it's a returning user — skip seeding. + * + * ONE DOCUMENT PER ENTITY (rule_document-per-entity): every seeded entity is + * created as its OWN document in its scope via the SDK create (`createEntityDoc`, + * injected). Events live in the PUBLIC scope; profiles + participations in the + * PROTECTED scope. The created document NURIs are returned so the caller can add + * them to the live subscription set (reactivity). */ import type { DeepSignalSet } from '@ng-eventually/client'; import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings'; -import { ensureGraphNuri } from './ngGraph'; +import { normalizeUsername } from '../context/AccountContext'; import { seedEvents, seedUsers, seedParticipations, } from '../data/seedData'; +/** Scope of a seed entity + how to create its own document (SDK create). */ +export type Scope = 'public' | 'protected' | 'private'; +export type CreateEntityDoc = (owner: string, scope: Scope) => Promise; + export interface BootstrapResult { seeded: boolean; userIdMap: Map; eventIdMap: Map; + /** Every per-entity document created, by scope — register these to subscribe. */ + createdDocs: { public: string[]; protected: string[] }; } /** @@ -36,24 +48,24 @@ export async function bootstrapWallet( ngEvents: DeepSignalSet, ngUsers: DeepSignalSet, ngParticipations: DeepSignalSet, + createEntityDoc: CreateEntityDoc, ): Promise { + const createdDocs = { public: [] as string[], protected: [] as string[] }; // Already has data → returning user, nothing to seed if (ngEvents.size > 0 || ngUsers.size > 0) { console.log('[Bootstrap] Wallet already has data — events:', ngEvents.size, 'users:', ngUsers.size, 'participations:', ngParticipations.size); - return { seeded: false, userIdMap: new Map(), eventIdMap: new Map() }; + return { seeded: false, userIdMap: new Map(), eventIdMap: new Map(), createdDocs }; } - console.log('[Bootstrap] First time for this wallet — seeding default data...'); + console.log('[Bootstrap] First time for this wallet — seeding per-entity docs...'); - // Create (or get) a document in the private store for our data. - // The ORM requires a real document NURI as @graph, not the store ID. - const graph = await ensureGraphNuri(ngEvents, ngUsers, ngParticipations); - console.log('[Bootstrap] Using graph NURI:', graph); - - // Seed users — one at a time with ORM flush between each + // Seed users — one PROTECTED document each, owned by that user's account. const userIdMap = new Map(); for (const u of seedUsers) { + const owner = normalizeUsername(u.username); + const graph = await createEntityDoc(owner, 'protected'); + createdDocs.protected.push(graph); ngUsers.add({ "@graph": graph, "@type": "http://festipod.org/UserProfile", @@ -70,9 +82,13 @@ export async function bootstrapWallet( } console.log('[Bootstrap] Seeded', userIdMap.size, 'users'); - // Seed events — one at a time with ORM flush between each + // Seed events — one PUBLIC document each. The seed carries no host username, so + // the seed events are owned by the first seed user (a fixture-level choice). + const seedOwner = seedUsers[0] ? normalizeUsername(seedUsers[0].username) : 'seed'; const eventIdMap = new Map(); for (const e of seedEvents) { + const graph = await createEntityDoc(seedOwner, 'public'); + createdDocs.public.push(graph); ngEvents.add({ "@graph": graph, "@type": "http://festipod.org/Event", @@ -93,11 +109,15 @@ export async function bootstrapWallet( } console.log('[Bootstrap] Seeded', eventIdMap.size, 'events'); - // Seed participations with mapped IDs — one at a time + // Seed participations — one PROTECTED document each, owned by the participant. let partCount = 0; for (const p of seedParticipations) { const eventIri = eventIdMap.get(p.eventId) || p.eventId; const userIri = userIdMap.get(p.userId) || p.userId; + const seedUser = seedUsers.find(u => u.id === p.userId); + const owner = seedUser ? normalizeUsername(seedUser.username) : seedOwner; + const graph = await createEntityDoc(owner, 'protected'); + createdDocs.protected.push(graph); ngParticipations.add({ "@graph": graph, "@type": "http://festipod.org/Participation", @@ -111,5 +131,5 @@ export async function bootstrapWallet( } console.log('[Bootstrap] Seeded', partCount, 'participations'); - return { seeded: true, userIdMap, eventIdMap }; + return { seeded: true, userIdMap, eventIdMap, createdDocs }; }