From 3f47ea886f9abae7b7e842271e3cf633252a8e0a Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Fri, 3 Jul 2026 10:13:11 +0200 Subject: [PATCH] Rewire app onto @ng-eventually/client; drop direct @ng-org runtime imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consume the shim mechanics now living in the lib (docs/storeRegistry/ isolation/accounts) and remove the remaining direct @ng-org runtime imports. - storeRegistry.ts keeps ONLY the Festipod EntityKind/entityScope mapping, injects it via configureStoreRegistry({ getSession, normalizeUser }), and re-exports the lib's storeRegistry.* (callers unchanged). Drops `import { ng } from '@ng-org/web'`. - harness-ng.tsx createSmokeDoc now uses docs.docCreate (real injected ng, no DataCloneError) instead of ng.doc_create. Drops the @ng-org import. - AccountContext.tsx thin React wrapper over accounts.AccountStore + normalizeUsername; historical key `festipod.account.username` pinned → zero behavior change. Context/Provider stay in the app. - isolation.ts Festipod wrapper over the lib's pure isolation.applyIsolation. Invariant reached: `grep "from '@ng-org'" src/ | grep -v 'import type'` lists only ngSession (the configure injection point) + the two documented test-harness exceptions (auth-setup.tsx, harness.tsx mock). No doc_create goes through the lib's public proxy. App build + harness-ng bundle OK. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/shared/context/AccountContext.tsx | 49 ++--- src/shared/test-harness/harness-ng.tsx | 10 +- src/shared/utils/isolation.ts | 49 +++-- src/shared/utils/storeRegistry.ts | 289 ++++--------------------- 4 files changed, 101 insertions(+), 296 deletions(-) diff --git a/src/shared/context/AccountContext.tsx b/src/shared/context/AccountContext.tsx index eeea818..66f50a1 100644 --- a/src/shared/context/AccountContext.tsx +++ b/src/shared/context/AccountContext.tsx @@ -21,8 +21,15 @@ * (the @ui render harness wraps screens without this provider). */ -import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; +import { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from 'react'; +// Thin React wrapper over the lib's framework-agnostic accounts core (T01.c): +// AccountStore (localStorage-backed faux login) + normalizeUsername. This file +// keeps ONLY the React Context/Provider glue; the login/logout/normalize logic +// lives in the lib. See decision_2026-06-17_eventually-library. +import { accounts } from '@ng-eventually/client'; +// Preserve the historical Festipod localStorage key so existing "logins" survive +// (the lib's default key differs; we pin ours explicitly → no behavior change). const STORAGE_KEY = 'festipod.account.username'; export interface AccountContextValue { @@ -34,13 +41,10 @@ export interface AccountContextValue { logout: () => void; } -function readStored(): string | null { - if (typeof window === 'undefined') return null; - try { - return window.localStorage.getItem(STORAGE_KEY); - } catch { - return null; - } +/** Browser-safe storage (null in SSR → lib store degrades to non-persisting). */ +function makeStore(): accounts.AccountStore { + const ls = typeof window !== 'undefined' ? window.localStorage : null; + return new accounts.AccountStore(ls, STORAGE_KEY); } const AccountContext = createContext({ @@ -50,27 +54,18 @@ const AccountContext = createContext({ }); export function AccountProvider({ children }: { children: ReactNode }) { - const [username, setUsername] = useState(() => readStored()); + const store = useMemo(() => makeStore(), []); + const [username, setUsername] = useState(() => store.get()); const login = useCallback((name: string) => { - const clean = name.trim(); - if (!clean) return; - try { - window.localStorage.setItem(STORAGE_KEY, clean); - } catch { - /* ignore — staging, no security */ - } - setUsername(clean); - }, []); + const next = store.login(name); + if (next) setUsername(next); + }, [store]); const logout = useCallback(() => { - try { - window.localStorage.removeItem(STORAGE_KEY); - } catch { - /* ignore */ - } + store.logout(); setUsername(null); - }, []); + }, [store]); return ( @@ -85,8 +80,6 @@ export function useAccount(): AccountContextValue { /** * Normalise a username for matching (case-insensitive, optional leading `@`). - * Lets the perceived login accept "marie", "@marie", "Marie" interchangeably. + * Re-exported from the lib's accounts core so app callers keep this import path. */ -export function normalizeUsername(username: string | null | undefined): string { - return (username ?? '').trim().replace(/^@+/, '').toLowerCase(); -} +export const normalizeUsername = accounts.normalizeUsername; diff --git a/src/shared/test-harness/harness-ng.tsx b/src/shared/test-harness/harness-ng.tsx index 88b68cf..9a348fa 100644 --- a/src/shared/test-harness/harness-ng.tsx +++ b/src/shared/test-harness/harness-ng.tsx @@ -12,12 +12,12 @@ import { createRoot } from 'react-dom/client'; import { NextGraphProvider, useNextGraph } from '../context/NextGraphContext'; import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataContext'; // useShape routed through the lib (SDK-identical surface); caps from /polyfill. -import { useShape } from '@ng-eventually/client'; +import { useShape, docs } from '@ng-eventually/client'; import { getCaps, setCurrentUser, resetCaps } from '@ng-eventually/client/polyfill'; import type { DeepSignalSet } from '@ng-eventually/client'; -// doc_create goes straight to the real SDK: the lib's `ng` proxy over @ng-org's -// iframe-RPC proxy breaks doc_create's postMessage marshaling (see storeRegistry). -import { ng } from '@ng-org/web'; +// doc_create goes through the lib's `docs` primitive (T01.a): it calls the REAL +// injected `ng` directly (never the public proxy), so postMessage marshaling +// stays intact (no DataCloneError). See decision_2026-06-17_eventually-library. import { FpEventShapeType, FpUserProfileShapeType, @@ -192,7 +192,7 @@ function ConnectedHarness() { * Validates: doc_create returns a usable graph NURI. */ async createSmokeDoc() { - const nuri = await ng.doc_create(session.session_id, 'Graph', 'data:graph', 'store', undefined); + const nuri = await docs.docCreate(session.session_id, 'Graph', 'data:graph', 'store', undefined); setSmokeDoc(nuri); return nuri; }, diff --git a/src/shared/utils/isolation.ts b/src/shared/utils/isolation.ts index 9e4c258..5ca6359 100644 --- a/src/shared/utils/isolation.ts +++ b/src/shared/utils/isolation.ts @@ -23,6 +23,12 @@ import type { 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[]; @@ -34,12 +40,10 @@ export interface IsolatableData { /** The set the current user may see protected data for: self + direct connections. */ export function connectionIds(currentUserId: string, friendships: FpFriendshipData[]): Set { - const set = new Set([currentUserId]); - for (const f of friendships) { - if (f.userId === currentUserId) set.add(f.friendId); - else if (f.friendId === currentUserId) set.add(f.userId); - } - return set; + const connections = isolation.connectionsFromLinks( + friendships.map(f => ({ a: f.userId, b: f.friendId })), + ); + return isolation.visibleSet(currentUserId, connections); } /** @@ -49,17 +53,36 @@ export function connectionIds(currentUserId: string, friendships: FpFriendshipDa * - 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'. + * - 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 visible = connectionIds(currentUserId, data.friendships); - return { - ...data, - participations: data.participations.filter(p => visible.has(p.userId)), - friendships: data.friendships.filter(f => visible.has(f.userId) || visible.has(f.friendId)), - }; + 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/storeRegistry.ts b/src/shared/utils/storeRegistry.ts index a17862e..d91c748 100644 --- a/src/shared/utils/storeRegistry.ts +++ b/src/shared/utils/storeRegistry.ts @@ -1,37 +1,23 @@ /** - * storeRegistry — resolves (account, scope) → document NURI. + * storeRegistry (Festipod glue) — the GENERIC mechanism now lives in the lib + * (`@ng-eventually/client` `storeRegistry`, ported in T01.b). This file keeps + * ONLY the Festipod domain mapping (entity kind → native scope) and injects the + * consumer wiring the lib needs (session + username normalization) via + * `configureStoreRegistry(...)`. * - * STOPGAP — heart of the shared-wallet shim (see - * .project/concepts/nextgraph-platform/brief_2026-06-15_shared-wallet-shim.md). - * - * Everyone shares ONE wallet. To *mirror the target infra* (where each user - * has their own public/protected/private stores), we create one document per - * (account × scope) INSIDE the shared wallet, via `doc_create`. Because there - * is a single wallet and isolation is enforced in the app layer (not crypto), - * all these documents physically live in the shared wallet's private store — - * the scope (public/protected/private) is a LOGICAL attribute we track here, - * not a physical NextGraph store. - * - * The mapping (account → its 3 document NURIs) is the `sharedWalletShim`, - * persisted as RDF in the shared wallet's private store (the anchor, always - * known from the session). That makes login cross-device: another device - * opening the same wallet reads the same shim and finds the same accounts. - * - * MIGRATION: when real per-user wallets / cross-wallet reads land, only the - * resolver below changes — (account, scope) maps to the user's REAL store - * NURI instead of a document in the shared wallet. Screens don't change. - * - * NOTE: the NextGraph runtime path (doc_create, SPARQL shim r/w) is built - * against the verified SDK surface but must be validated against a live broker. + * The lib knows only the three native scopes (`public|protected|private`) and + * performs all NextGraph I/O through the real injected `ng` (never the public + * proxy → no DataCloneError). Everything the app previously implemented here + * (shim model, doc_create, SPARQL r/w, index/fan-out) is now the lib's job; the + * app re-exports the lib surface so existing callers stay unchanged. See + * decision_2026-06-17_eventually-library and brief_2026-06-15_shared-wallet-shim. */ -// doc_create / SPARQL go straight to the real SDK: the lib's `ng` proxy (a JS -// Proxy over @ng-org's iframe-RPC proxy) breaks doc_create's postMessage -// marshaling (DataCloneError). useShape/login/ReadCap DO route through the lib; -// this low-level path stays direct until storeRegistry moves INTO the lib (where -// it would use the injected real ng, no double-proxy). See -// decision_2026-06-17_eventually-library. -import { ng } from '@ng-org/web'; +import { + storeRegistry as libStoreRegistry, + type AccountRecord as LibAccountRecord, +} from '@ng-eventually/client'; +import { configureStoreRegistry } from '@ng-eventually/client/polyfill'; import { sessionPromise } from './ngSession'; import { normalizeUsername } from '../context/AccountContext'; @@ -55,225 +41,28 @@ export function entityScope(kind: EntityKind): Scope { } } -// --- sharedWalletShim model ---------------------------------------------- +// --- Consumer wiring injected into the lib's storeRegistry (polyfill-era) --- +// The lib is Festipod-agnostic: it reaches the shared-wallet session and the +// username normalization through these injected deps. Idempotent module-load +// side effect (the app imports storeRegistry before any registry call). +configureStoreRegistry({ + getSession: async () => { + const session = await sessionPromise; + return { sessionId: session.session_id, privateStoreId: session.private_store_id }; + }, + normalizeUser: normalizeUsername, +}); -export interface AccountRecord { - username: string; - docPublic: string; - docProtected: string; - docPrivate: string; -} +// --- Re-export the lib's account record + registry surface (unchanged API) --- +export type AccountRecord = LibAccountRecord; -const SHIM = 'urn:festipod:shim'; -const P = { - type: `${SHIM}:Account`, - username: `${SHIM}:username`, - docPublic: `${SHIM}:docPublic`, - docProtected: `${SHIM}:docProtected`, - docPrivate: `${SHIM}:docPrivate`, - contains: `${SHIM}:contains`, // index → entity document NURI -}; -// Fixed subject of the per-(account×scope) index document. The index doc plays -// the role of the future store-container: it lists the NURIs of the entity -// documents (one per event/PdR) that live "in" that scope. -const INDEX_SUBJECT = `${SHIM}:index`; - -function accountSubject(username: string): string { - return `${SHIM}:account:${normalizeUsername(username)}`; -} - -// In-memory cache of the shim, keyed by normalized username. -let cache: Map | null = null; - -/** The shim lives in the shared wallet's private store (always-known anchor). */ -async function anchorNuri(): Promise { - const session = await sessionPromise; - return `did:ng:${session.private_store_id}`; -} - -/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */ -function readBindings(result: unknown): Array> { - if (!result) return []; - const anyRes = result as any; - if (Array.isArray(anyRes)) return anyRes; - if (anyRes?.results?.bindings) return anyRes.results.bindings; - return []; -} - -function bindingValue(row: Record, key: string): string { - return row[key]?.value ?? ''; -} - -/** Load all accounts from the shim into the cache. */ -export async function loadShim(): Promise> { - if (cache) return cache; - const session = await sessionPromise; - const anchor = await anchorNuri(); - const query = ` - SELECT ?username ?docPublic ?docProtected ?docPrivate WHERE { - GRAPH <${anchor}> { - ?acc a <${P.type}> ; - <${P.username}> ?username ; - <${P.docPublic}> ?docPublic ; - <${P.docProtected}> ?docProtected ; - <${P.docPrivate}> ?docPrivate . - } - }`; - const map = new Map(); - try { - const result = await ng.sparql_query(session.session_id, query, undefined, anchor); - for (const row of readBindings(result)) { - const username = bindingValue(row, 'username'); - if (!username) continue; - map.set(normalizeUsername(username), { - username, - docPublic: bindingValue(row, 'docPublic'), - docProtected: bindingValue(row, 'docProtected'), - docPrivate: bindingValue(row, 'docPrivate'), - }); - } - } catch (error) { - console.error('[storeRegistry] loadShim failed:', error); - } - cache = map; - return map; -} - -/** Create one graph document in the shared wallet (→ a NURI). */ -async function createDoc(): Promise { - const session = await sessionPromise; - // crdt="Graph" (RDF/SPARQL/ORM), class="data:graph", destination="store", - // store_repo=undefined → shared wallet's private store. (Verified SDK surface.) - const nuri = await (ng as unknown as { - doc_create: (s: unknown, crdt: string, cls: string, dest: string, store?: unknown) => Promise; - }).doc_create(session.session_id, 'Graph', 'data:graph', 'store', undefined); - return nuri; -} - -/** - * Ensure an account exists in the shim, creating its 3 scope documents on - * first sight. Idempotent — returns the existing record if already present. - */ -export async function ensureAccount(username: string): Promise { - const map = await loadShim(); - const key = normalizeUsername(username); - const existing = map.get(key); - if (existing) return existing; - - const [docPublic, docProtected, docPrivate] = await Promise.all([ - createDoc(), - createDoc(), - createDoc(), - ]); - const record: AccountRecord = { username, docPublic, docProtected, docPrivate }; - - const session = await sessionPromise; - const anchor = await anchorNuri(); - const subj = accountSubject(username); - const update = ` - INSERT DATA { - GRAPH <${anchor}> { - <${subj}> a <${P.type}> ; - <${P.username}> "${username}" ; - <${P.docPublic}> "${docPublic}" ; - <${P.docProtected}> "${docProtected}" ; - <${P.docPrivate}> "${docPrivate}" . - } - }`; - try { - await ng.sparql_update(session.session_id, update, anchor); - } catch (error) { - console.error('[storeRegistry] ensureAccount persist failed:', error); - } - map.set(key, record); - return record; -} - -/** The index document NURI of an account for a scope (the store-container). */ -function indexDocOf(record: AccountRecord, scope: Scope): string { - return scope === 'public' ? record.docPublic - : scope === 'protected' ? record.docProtected - : record.docPrivate; -} - -/** - * NURI of the document where `username` writes GROUPED entities of `scope` - * (e.g. participations, profile — no per-entity document / no inbox needed). - * For per-entity scopes (events, PdR) use {@link createEntityDoc} instead. - */ -export async function resolveWriteGraph(username: string, scope: Scope): Promise { - const record = await ensureAccount(username); - return indexDocOf(record, scope); -} - -/** - * Create a dedicated document for ONE entity (event, PdR) — mirrors the target, - * where each such entity is its own document/repo (addressable, future inbox). - * The new document's NURI is appended to the account's scope index document - * (the store-container). Returns the entity document NURI (use it as `@graph`). - */ -export async function createEntityDoc(username: string, scope: Scope): Promise { - const record = await ensureAccount(username); - const indexDoc = indexDocOf(record, scope); - const entityNuri = await createDoc(); - const session = await sessionPromise; - try { - await ng.sparql_update( - session.session_id, - `INSERT DATA { GRAPH <${indexDoc}> { <${INDEX_SUBJECT}> <${P.contains}> "${entityNuri}" } }`, - indexDoc, - ); - } catch (error) { - console.error('[storeRegistry] createEntityDoc index append failed:', error); - } - return entityNuri; -} - -/** - * Every entity document NURI of `scope`, across all accounts — the read - * fan-out for per-entity scopes (events, PdR). Reads each account's scope index - * document and unions the contained NURIs. Use as `useShape(shape, { graphs })`. - */ -export async function listEntityDocs(scope: Scope): Promise { - const accounts = await allAccounts(); - const session = await sessionPromise; - const out: string[] = []; - for (const a of accounts) { - const indexDoc = indexDocOf(a, scope); - try { - const res = await ng.sparql_query( - session.session_id, - `SELECT ?e WHERE { GRAPH <${indexDoc}> { <${INDEX_SUBJECT}> <${P.contains}> ?e } }`, - undefined, - indexDoc, - ); - for (const row of readBindings(res)) { - const v = bindingValue(row, 'e'); - if (v) out.push(v); - } - } catch (error) { - console.error('[storeRegistry] listEntityDocs read failed:', error); - } - } - return out; -} - -/** All known accounts (from the shim). */ -export async function allAccounts(): Promise { - return [...(await loadShim()).values()]; -} - -/** NURIs of every account's document for `scope` (read fan-out). */ -export async function resolveReadGraphs(scope: Scope): Promise { - const accounts = await allAccounts(); - return accounts.map(a => - scope === 'public' ? a.docPublic - : scope === 'protected' ? a.docProtected - : a.docPrivate, - ); -} - -/** Reset cache (e.g. after switching the shared wallet). Mostly for tests. */ -export function resetRegistryCache(): void { - cache = null; -} +export const { + loadShim, + ensureAccount, + resolveWriteGraph, + createEntityDoc, + listEntityDocs, + allAccounts, + resolveReadGraphs, + resetRegistryCache, +} = libStoreRegistry;