Ng eventually #1
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user