Rewire app onto @ng-eventually/client; drop direct @ng-org runtime imports

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) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-03 10:13:11 +02:00
parent a54c119b4d
commit 3f47ea886f
4 changed files with 101 additions and 296 deletions
+21 -28
View File
@@ -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<AccountContextValue>({
@@ -50,27 +54,18 @@ const AccountContext = createContext<AccountContextValue>({
});
export function AccountProvider({ children }: { children: ReactNode }) {
const [username, setUsername] = useState<string | null>(() => readStored());
const store = useMemo(() => makeStore(), []);
const [username, setUsername] = useState<string | null>(() => 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 (
<AccountContext.Provider value={{ username, login, logout }}>
@@ -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;
+5 -5
View File
@@ -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;
},
+36 -13
View File
@@ -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<string> {
const set = new Set<string>([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<T extends IsolatableData>(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 };
}
+39 -250
View File
@@ -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<string, AccountRecord> | null = null;
/** The shim lives in the shared wallet's private store (always-known anchor). */
async function anchorNuri(): Promise<string> {
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<Record<string, { value: string }>> {
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<string, { value: string }>, key: string): string {
return row[key]?.value ?? '';
}
/** Load all accounts from the shim into the cache. */
export async function loadShim(): Promise<Map<string, AccountRecord>> {
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<string, AccountRecord>();
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<string> {
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<string>;
}).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<AccountRecord> {
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<string> {
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<string> {
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<string[]> {
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<AccountRecord[]> {
return [...(await loadShim()).values()];
}
/** NURIs of every account's document for `scope` (read fan-out). */
export async function resolveReadGraphs(scope: Scope): Promise<string[]> {
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;