feat(auth): staging wallet partagé — import assisté par fichier + e2e multi-navigateur

Stopgap staging multi-user sur wallet partagé (cf. brief_2026-06-15_shared-wallet-shim).

Distribution / import du wallet :
- AccessGateScreen : barrière d'accès ON PAR DÉFAUT (désactivable via
  globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ pour tests/dev). Fournit le FICHIER
  .ngw + le mot de passe + un guide en 3 étapes (import assisté sur nextgraph.eu —
  le broker hébergé n'autorise pas l'import inline pendant l'auth web-app).
- sharedWallet.ts + build.ts : fichier copié en /shared-wallet.ngw, mot de passe gravé.
- Ancien LoginScreen (/login) retiré ; atterrissage post-login -> /home.
- NextGraphContext : dé-piégeage de l'état "connecting" au retour (pageshow/bfcache).

Couche multistore stopgap : storeRegistry, isolation, AccountContext, FestipodDataContext.

Tests e2e multi-navigateur :
- browserPool + world.openBrowser : contextes frais isolés, 2 axes orthogonaux
  (nb de navigateurs × modèle de wallet own/shared).
- @humain : parcours humain complet (télécharge -> importe le fichier sur
  nextgraph.eu -> Entrer -> pseudo -> accueil).
- Bypass de la barrière pour @e2e via context.addInitScript.
- Convention @wip exclue via cucumber.json.

Docs (concepts) : nextgraph-platform (knowledge_broker-import-constraint,
decision_2026-06-17_assisted-wallet-import), bdd-testing (knowledge_multibrowser-harness).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-06-30 12:04:02 +02:00
parent 222658a75d
commit 266e33556d
40 changed files with 2224 additions and 360 deletions
+92
View File
@@ -0,0 +1,92 @@
/**
* AccountContext — the *simulated* application-level login.
*
* STOPGAP — part of the shared-wallet shim (see
* .project/concepts/nextgraph-platform/brief_2026-06-15_shared-wallet-shim.md
* and decision_2026-06-15_shared-wallet-login-flow.md).
*
* The real NextGraph login (a redirect to the broker, opening the single
* SHARED wallet) is perceived by the user as a *technical access barrier*,
* NOT as a login. THIS context is what the user perceives as the login:
* they pick a username (no password — declarative), which is persisted in
* localStorage so the "session" survives reloads and a different device,
* re-opening the same shared wallet, lands on the same accounts.
*
* `login()` / `logout()` here are FAUX: they only read/write the username in
* localStorage. They must NEVER call NextGraph (ng.session_stop /
* wallet_close) — the shared wallet stays open underneath. The real logout
* lives, hidden, in Settings.
*
* Default value is non-null so `useAccount()` never throws outside a provider
* (the @ui render harness wraps screens without this provider).
*/
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
const STORAGE_KEY = 'festipod.account.username';
export interface AccountContextValue {
/** App-level identity (the perceived "login"). null = not connected. */
username: string | null;
/** Faux login — persists the username. No NextGraph call. */
login: (username: string) => void;
/** Faux logout — clears the username only. No NextGraph call. */
logout: () => void;
}
function readStored(): string | null {
if (typeof window === 'undefined') return null;
try {
return window.localStorage.getItem(STORAGE_KEY);
} catch {
return null;
}
}
const AccountContext = createContext<AccountContextValue>({
username: null,
login: () => {},
logout: () => {},
});
export function AccountProvider({ children }: { children: ReactNode }) {
const [username, setUsername] = useState<string | null>(() => readStored());
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 logout = useCallback(() => {
try {
window.localStorage.removeItem(STORAGE_KEY);
} catch {
/* ignore */
}
setUsername(null);
}, []);
return (
<AccountContext.Provider value={{ username, login, logout }}>
{children}
</AccountContext.Provider>
);
}
export function useAccount(): AccountContextValue {
return useContext(AccountContext);
}
/**
* Normalise a username for matching (case-insensitive, optional leading `@`).
* Lets the perceived login accept "marie", "@marie", "Marie" interchangeably.
*/
export function normalizeUsername(username: string | null | undefined): string {
return (username ?? '').trim().replace(/^@+/, '').toLowerCase();
}
+110 -23
View File
@@ -15,7 +15,19 @@ import {
seedFriendships,
} from '../data/seedData';
import { useNextGraph } from './NextGraphContext';
import { useShapeWithDefaults } from '../hooks/useShapeWithDefaults';
import { useAccount, normalizeUsername } from './AccountContext';
import { applyIsolation } from '../utils/isolation';
import { ensureAccount, resolveReadGraphs, resolveWriteGraph, createEntityDoc, listEntityDocs } from '../utils/storeRegistry';
import { useShapeWithDefaults, type ShapeScope } from '../hooks/useShapeWithDefaults';
// Multi-document mode (storeRegistry): one document per (account × scope),
// mirroring the target per-user stores. Default OFF — the validated mono-store
// path stays the default until the multi-document path is broker-validated.
// Flip via FESTIPOD_MULTISTORE=1 at build time. See brief_2026-06-15_shared-wallet-shim.
// Browser-safe env read: the bundler inlines process.env.NODE_ENV but NOT
// custom vars, so a bare `process.env.FESTIPOD_MULTISTORE` throws
// "process is not defined" in the browser harness. Guard it.
const MULTISTORE = typeof process !== 'undefined' && process?.env?.FESTIPOD_MULTISTORE === '1';
import {
FpEventShapeType,
FpUserProfileShapeType,
@@ -53,7 +65,7 @@ interface FestipodDataContextValue {
setSelectedUserId(id: string): void;
selectedUser: FpUserData | undefined;
createEvent(event: Omit<FpEventData, 'id'>): FpEventData;
createEvent(event: Omit<FpEventData, 'id'>): Promise<FpEventData>;
updateEvent(id: string, updates: Partial<FpEventData>): void;
joinEvent(eventId: string, userId?: string): void;
leaveEvent(eventId: string, userId?: string): void;
@@ -161,6 +173,7 @@ function buildQueries(
// ============================================================================
function useLocalData(empty?: boolean): FestipodDataContextValue {
const { username } = useAccount();
const [selectedEventId, setSelectedEventId] = useState<string>(empty ? '' : 'event-1');
const [selectedUserId, setSelectedUserId] = useState<string>('');
@@ -170,7 +183,12 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
const meetingPoints = empty ? [] : seedMeetingPoints;
const friendships = empty ? [] : seedFriendships;
const currentUserId = empty ? '' : CURRENT_USER_ID;
// Resolve current user from the chosen account username; fall back to the
// demo default so @ui tests and standalone dev keep working unchanged.
const accountUser = username
? users.find(u => normalizeUsername(u.username) === normalizeUsername(username))
: undefined;
const currentUserId = empty ? '' : (accountUser?.id ?? CURRENT_USER_ID);
const currentUser = users.find(u => u.id === currentUserId);
const selectedEvent = events.find(e => e.id === selectedEventId);
const selectedUser = users.find(u => u.id === selectedUserId);
@@ -182,7 +200,7 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
'| selectedEvent:', selectedEvent?.title ?? '(none)');
// Local mode: mutations are no-ops (static defaults)
const createEvent = useCallback((event: Omit<FpEventData, 'id'>): FpEventData => {
const createEvent = useCallback(async (event: Omit<FpEventData, 'id'>): Promise<FpEventData> => {
console.log('[FestipodData] createEvent (local, no-op):', event.title);
return { ...event, id: nextId('event') };
}, []);
@@ -226,18 +244,56 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
function useNgData(): FestipodDataContextValue {
const { session } = useNextGraph();
// Use private store NURI as scope (same as expense-tracker-rdf).
// This opens the store repo in the verifier, enabling both reads and writes.
const privateNuri = session && `did:ng:${session.private_store_id}`;
const { username } = useAccount();
// Mono-store fallback scope: the shared wallet's private store NURI. Opens the
// repo in the verifier (enables reads + writes), per the data-layer rule.
const privateNuri = session ? `did:ng:${session.private_store_id}` : undefined;
// Multi-document state (storeRegistry): read fan-out (all accounts' docs per
// scope) and the current account's write docs. Populated by the effect below.
const [readGraphs, setReadGraphs] = useState<{ public: string[]; protected: string[] }>({ public: [], protected: [] });
const [writeGraphs, setWriteGraphs] = useState<{ protected?: string }>({});
useEffect(() => {
if (!MULTISTORE || !privateNuri || !username) return;
let cancelled = false;
(async () => {
try {
await ensureAccount(username); // create this account's index docs on first sight
// Public = per-entity documents (events/PdR) listed by the public index.
// Protected = grouped in each account's protected index document.
const [pub, prot, wProt] = await Promise.all([
listEntityDocs('public'),
resolveReadGraphs('protected'),
resolveWriteGraph(username, 'protected'),
]);
if (cancelled) return;
setReadGraphs({ public: pub, protected: prot });
setWriteGraphs({ protected: wProt });
} catch (err) {
console.error('[FestipodData] storeRegistry init failed:', err);
}
})();
return () => { cancelled = true; };
}, [privateNuri, username]);
// Scope per entity: events live in the PUBLIC docs, profiles + participations
// in the PROTECTED docs. Mono-store mode collapses all to the private store.
const publicScope: ShapeScope = MULTISTORE
? (readGraphs.public.length ? { graphs: readGraphs.public } : undefined)
: privateNuri;
const protectedScope: ShapeScope = MULTISTORE
? (readGraphs.protected.length ? { graphs: readGraphs.protected } : undefined)
: privateNuri;
// useShapeWithDefaults: show EMPTY data until NG populates (no seed defaults)
const emptyEvents: FpEventData[] = [];
const emptyUsers: FpUserData[] = [];
const emptyParticipations: FpParticipationData[] = [];
const eventsShape = useShapeWithDefaults(FpEventShapeType, privateNuri, emptyEvents, mapEvent, true);
const usersShape = useShapeWithDefaults(FpUserProfileShapeType, privateNuri, emptyUsers, mapUser, true);
const participationsShape = useShapeWithDefaults(FpParticipationShapeType, privateNuri, emptyParticipations, mapParticipation, true);
const eventsShape = useShapeWithDefaults(FpEventShapeType, publicScope, emptyEvents, mapEvent, true);
const usersShape = useShapeWithDefaults(FpUserProfileShapeType, protectedScope, emptyUsers, mapUser, true);
const participationsShape = useShapeWithDefaults(FpParticipationShapeType, protectedScope, emptyParticipations, mapParticipation, true);
const events = eventsShape.items;
const users = usersShape.items;
@@ -252,8 +308,9 @@ function useNgData(): FestipodDataContextValue {
// Auto-select first event when data appears from NG
useEffect(() => {
if (!selectedEventId && events.length > 0) {
setSelectedEventId(events[0].id);
const first = events[0];
if (!selectedEventId && first) {
setSelectedEventId(first.id);
}
}, [events.length, selectedEventId]);
@@ -264,6 +321,7 @@ function useNgData(): FestipodDataContextValue {
const hasTriedAutoSeed = useRef(false);
useEffect(() => {
if (process.env.NODE_ENV === 'production') return;
if (MULTISTORE) return; // seed targets the private store; multi-doc seeding is a separate concern
if (hasTriedAutoSeed.current) return;
if (!privateNuri) return;
const t = setTimeout(() => {
@@ -283,25 +341,51 @@ function useNgData(): FestipodDataContextValue {
}, [privateNuri, eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet]);
// --- Derived ---
const currentUser = users.find(u => u.username === '@mariedupont') || users[0];
// Resolve current user from the chosen account username (the perceived login);
// fall back to the legacy default while the account layer hydrates.
const currentUser =
(username ? users.find(u => normalizeUsername(u.username) === normalizeUsername(username)) : undefined)
|| users.find(u => u.username === '@mariedupont')
|| users[0];
const currentUserId = currentUser?.id || '';
const selectedEvent = events.find(e => e.id === selectedEventId);
const selectedUser = users.find(u => u.id === selectedUserId);
const queries = buildQueries(events, users, participations, meetingPoints, friendships, currentUserId);
// 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,
);
const queries = buildQueries(
events, users, isolated.participations, meetingPoints, isolated.friendships, currentUserId,
);
console.log('[FestipodData] Render — NG | events:', events.length,
'| users:', users.length, '| participations:', participations.length,
'| selectedEvent:', selectedEvent?.title ?? '(none)');
// --- Mutations (NG) ---
// privateNuri is both the useShape scope AND the @graph for writes
const graph = privateNuri || '';
// Participations stay GROUPED in the account's protected index document.
// Mono-store mode collapses everything to the private store.
const protectedGraph = (MULTISTORE ? writeGraphs.protected : undefined) || privateNuri || '';
const createEvent = useCallback((event: Omit<FpEventData, 'id'>): FpEventData => {
const createEvent = useCallback(async (event: Omit<FpEventData, 'id'>): Promise<FpEventData> => {
console.log('[FestipodData] createEvent (NG):', event.title);
// Per-entity: in multistore each event is its OWN document. Mono-store: the
// private store. (Multistore create reactivity is best-effort — the new doc
// is appended to the read fan-out so it shows after re-subscribe.)
const eventGraph = MULTISTORE
? await createEntityDoc(username || '', 'public')
: (privateNuri || '');
if (MULTISTORE && eventGraph) {
setReadGraphs(prev =>
prev.public.includes(eventGraph) ? prev : { ...prev, public: [...prev.public, eventGraph] },
);
}
eventsShape.ngSet.add({
"@graph": graph, "@type": "http://festipod.org/Event", "@id": "",
"@graph": eventGraph, "@type": "http://festipod.org/Event", "@id": "",
title: event.title, description: event.description, date: event.date,
location: event.location, distance: event.distance,
participantCount: event.participantCount || 1,
@@ -310,13 +394,13 @@ function useNgData(): FestipodDataContextValue {
const addedEvent = [...eventsShape.ngSet].find(e => e.title === event.title);
if (addedEvent && currentUserId) {
participationsShape.ngSet.add({
"@graph": graph, "@type": "http://festipod.org/Participation", "@id": "",
"@graph": protectedGraph, "@type": "http://festipod.org/Participation", "@id": "",
event: addedEvent["@id"], user: currentUserId, isConfirmed: true,
} as FpParticipation);
setSelectedEventId(addedEvent["@id"]);
}
return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` };
}, [graph, eventsShape.ngSet, participationsShape.ngSet, currentUserId]);
}, [protectedGraph, eventsShape.ngSet, participationsShape.ngSet, currentUserId, privateNuri, username]);
const updateEvent = useCallback((id: string, updates: Partial<FpEventData>) => {
console.log('[FestipodData] updateEvent (NG):', id, updates);
@@ -340,14 +424,14 @@ function useNgData(): FestipodDataContextValue {
return;
}
participationsShape.ngSet.add({
"@graph": graph, "@type": "http://festipod.org/Participation", "@id": "",
"@graph": protectedGraph, "@type": "http://festipod.org/Participation", "@id": "",
event: eventId, user: uid, isConfirmed: true,
} as FpParticipation);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
if (ngEvent) {
ngEvent.participantCount = ngEvent.participantCount + 1;
}
}, [graph, participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
}, [protectedGraph, participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
const leaveEvent = useCallback((eventId: string, userId?: string) => {
const uid = userId || currentUserId;
@@ -401,7 +485,10 @@ function useNgData(): FestipodDataContextValue {
return {
currentUserId, currentUser,
events, users, participations, meetingPoints, friendships,
events, users,
participations: isolated.participations,
meetingPoints,
friendships: isolated.friendships,
selectedEventId, setSelectedEventId, selectedEvent,
selectedUserId, setSelectedUserId, selectedUser,
...queries,
+17
View File
@@ -55,6 +55,23 @@ export function NextGraphProvider({ children }: { children: ReactNode }) {
});
}, []);
// Un-stick the gate after a broker redirect that didn't complete (e.g. "no
// wallet": the user imports in another tab, comes back via the back button).
// The standalone page is restored from bfcache with status frozen on
// 'connecting' → "Entrer" stays disabled. Reset it so they can retry.
useEffect(() => {
if (isInsideBroker) return;
const onPageShow = (e: PageTransitionEvent) => {
if (e.persisted && !session) {
ngInitStarted = false;
setStatus('disconnected');
setError(undefined);
}
};
window.addEventListener('pageshow', onPageShow);
return () => window.removeEventListener('pageshow', onPageShow);
}, []);
// connect(): called by the user clicking "Se connecter".
// When outside the broker, initNgWeb() will redirect to the broker.
const connect = useCallback(() => {
+12 -5
View File
@@ -18,18 +18,25 @@ export interface ShapeWithDefaults<NgT extends BaseType, AppT> {
ngSet: DeepSignalSet<NgT>;
}
/**
* `scope` is either a single store/document NURI (mono-store mode) or a
* `{ graphs }` set of document NURIs (multi-document mode — storeRegistry).
* `useShape` accepts both natively.
*/
export type ShapeScope = string | { graphs: string[] } | undefined;
export function useShapeWithDefaults<NgT extends BaseType, AppT>(
shapeType: ShapeType<NgT>,
storeNuri: string | undefined,
storeNuri: ShapeScope,
defaults: AppT[],
mapFromNg: (item: NgT) => AppT,
shapesReady: boolean,
): ShapeWithDefaults<NgT, AppT> {
// Use private store NURI as scope (like expense-tracker-rdf).
// This opens the store repo in the verifier, enabling writes.
const ngSet = useShape(shapeType, storeNuri) as DeepSignalSet<NgT>;
// Mono-store: a single store NURI opens the repo in the verifier (enables
// writes). Multi-document: a { graphs } scope subscribes to several docs.
const ngSet = useShape(shapeType, storeNuri as any) as DeepSignalSet<NgT>;
const usingDefaults = !shapesReady;
const items = usingDefaults ? defaults : [...ngSet].map(mapFromNg);
const items = usingDefaults ? defaults : [...ngSet].map(item => mapFromNg(item as unknown as NgT));
return { items, ngSet };
}
-1
View File
@@ -22,7 +22,6 @@ const screenNameMap: Record<string, string> = {
'profil': 'profile',
'profil utilisateur': 'user-profile',
'profil d\'un utilisateur': 'user-profile',
'connexion': 'login',
'paramètres': 'settings',
'réglages': 'settings',
'points de rencontre': 'meeting-points',
+140
View File
@@ -0,0 +1,140 @@
import type { Browser, BrowserContext, Page, Frame } from 'playwright';
/**
* Shared browser state for the BDD harness, owned by the lifecycle hooks
* (hooks.ts) and consumed per-scenario by the World (world.ts).
*
* The harness runs TWO kinds of browser:
*
* - the **wallet context** — a single persistent Chromium profile that holds
* the shared NextGraph wallet (created once by ensureAuth). Legacy single-
* browser @data/@e2e scenarios run here, already logged-in.
*
* - **fresh contexts** — ephemeral, fully isolated contexts spun up on demand
* from a non-persistent `freshBrowser`. Each has its own storage partition
* (its own localStorage, hence NO wallet). This is what lets a single
* scenario drive several browsers and test that a fresh browser can acquire
* the shared wallet (auto-import / textcode / QR / rendezvous).
*
* Extracting this into a module (rather than module-level `let`s in hooks.ts)
* gives both hooks.ts and world.ts a single, live source of truth without an
* import cycle.
*/
export interface BrowserPool {
/** Non-persistent launcher used to mint fresh isolated contexts. */
freshBrowser: Browser | null;
/** Persistent profile carrying the shared wallet (legacy single-browser path). */
walletContext: BrowserContext | null;
/** Local URL of the NG test harness (window.__testData), real-broker mode only. */
harnessUrl: string;
/** Local URL of the real app server, @e2e mode only. */
appUrl: string;
/** Top-level origin of the NextGraph broker (where the wallet localStorage lives). */
brokerOrigin: string;
/** True when the real broker + wallet are available (vs. mock fallback). */
useRealBroker: boolean;
/** Context-level permissions granted to every context (avoids prompts). */
permissions: string[];
/**
* Storage state captured once from the persistent wallet profile, injected
* into fresh contexts to provision the SHARED wallet across several browsers
* (test-level provisioning, distinct from the in-app auto-import). Null when
* capture failed / mock mode.
*/
sharedWalletState: Awaited<ReturnType<BrowserContext['storageState']>> | null;
/** Password of the e2e shared wallet file (festipod-e2e-tests) — for assertions. */
sharedWalletPassword: string;
/**
* Navigate a page through the NG broker to load `appUrl` in its iframe and
* return the app's Frame. Set by hooks.ts (closes over the broker login flow).
*/
setupBrokerPage: (page: Page, appUrl: string) => Promise<Frame>;
/** Finish the broker login once the page is already on the broker (post-redirect). */
completeBrokerLogin: (page: Page, appUrl: string, walletPassword?: string) => Promise<Frame>;
/**
* Drive the standalone nextgraph.eu "Import a Wallet File" flow on `page`:
* upload the .ngw file, unlock with `password`. The wallet FILE is the static,
* reusable assisted-import primitive (a TextCode is a transient 5-min transfer,
* unusable to embed). After this the page's context holds the wallet.
*/
importWalletViaFile: (page: Page, filePath: string, password: string) => Promise<void>;
/**
* Build (once) a STAGING bundle of the real app — gate ON + the shared wallet
* TextCode baked in — serve it statically, and return its URL. Used by the
* human-flow e2e to exercise the real AccessGateScreen. Memoised.
*/
ensureStagingApp: () => Promise<string>;
}
export const pool: BrowserPool = {
freshBrowser: null,
walletContext: null,
harnessUrl: '',
appUrl: '',
brokerOrigin: 'https://nextgraph.net',
useRealBroker: false,
permissions: [],
sharedWalletState: null,
sharedWalletPassword: '',
setupBrokerPage: async () => {
throw new Error('browserPool not initialized — did BeforeAll run?');
},
completeBrokerLogin: async () => {
throw new Error('browserPool not initialized — did BeforeAll run?');
},
importWalletViaFile: async () => {
throw new Error('browserPool not initialized — did BeforeAll run?');
},
ensureStagingApp: async () => {
throw new Error('browserPool not initialized — did BeforeAll run?');
},
};
/**
* Wallet model for a named browser — an axis ORTHOGONAL to "how many browsers":
* - 'own' — fresh isolated context, its own (or no) wallet → distinct NG
* identity. The target model (each user their own wallet), usable
* for multi-browser tests of the future cross-wallet sharing.
* - 'shared' — context pre-loaded with THE shared wallet (storageState
* injection) → same NG identity across browsers. The current
* stopgap model.
*/
export type WalletModel = 'own' | 'shared';
/** A named browser participating in a multi-browser scenario. */
export interface NamedBrowser {
name: string;
wallet: WalletModel;
context: BrowserContext;
page: Page;
/** The app's iframe Frame once loaded through the broker (null until loaded). */
appFrame: Frame | null;
}
/**
* Mint a fresh, fully isolated browser context under the given wallet model.
* - 'own' → empty storage partition (no wallet).
* - 'shared' → seeded with the captured shared-wallet storageState.
* Throws if the fresh browser launcher is not available (mock mode), or if a
* 'shared' context is requested but the wallet state could not be captured.
*/
export async function spawnContext(wallet: WalletModel): Promise<BrowserContext> {
if (!pool.freshBrowser) {
throw new Error(
'Fresh browser not launched — multi-browser scenarios require real-broker mode.',
);
}
if (wallet === 'shared') {
if (!pool.sharedWalletState) {
throw new Error(
'Shared wallet storageState not captured — cannot provision a shared-wallet browser.',
);
}
return pool.freshBrowser.newContext({
permissions: pool.permissions,
storageState: pool.sharedWalletState,
});
}
return pool.freshBrowser.newContext({ permissions: pool.permissions });
}
+222 -28
View File
@@ -5,11 +5,29 @@ import * as http from 'http';
import * as fs from 'fs';
import * as path from 'path';
import type { FestipodWorld } from './world';
import { pool } from './browserPool';
setDefaultTimeout(90000);
let browser: Browser;
let browserContext: BrowserContext;
// Non-persistent launcher for fresh, isolated contexts (multi-browser scenarios).
let freshBrowser: Browser | null = null;
// Context-level permissions granted to every context (avoids prompts).
const CONTEXT_PERMISSIONS = ['notifications', 'clipboard-read', 'clipboard-write', 'geolocation'];
// Launch args: disable Private Network Access so the broker (nextgraph.eu) can
// load our local harness at http://127.0.0.1:{port} inside an iframe.
const LAUNCH_ARGS = [
'--disable-features=PrivateNetworkAccessRespectPreflightResults,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessForWorkers,PrivateNetworkAccessForNavigations',
'--allow-insecure-localhost',
'--disable-web-security',
];
// Full Chrome binary (not chrome-headless-shell) so localStorage persists.
function resolveChromePath(): string | undefined {
const p = chromium.executablePath().replace('chrome-headless-shell', 'chrome').replace('chromium_headless_shell', 'chromium');
return p.includes('headless') ? undefined : p;
}
// Harness paths
const HARNESS_ENTRY = 'src/shared/test-harness/harness.tsx';
@@ -27,9 +45,26 @@ let useRealBroker = false;
let appServerProcess: ChildProcess | null = null;
let appPort = 0;
// Human-flow e2e: a STAGING build of the app (gate ON + shared wallet baked in),
// served statically. Built lazily (only when the human-flow scenario runs).
const STAGING_OUTDIR = path.resolve('dist-staging');
let stagingServer: http.Server | null = null;
let stagingAppUrl = '';
const WALLET_NAME = 'festipod-tests';
const WALLET_PASSWORD = 'festipod-tests';
// The SHARED wallet for the assisted-import e2e: a static .ngw file placed at the
// worktree root + its password (identifier = password, per the e2e wallet setup).
const E2E_WALLET_PASSWORD = 'festipod-e2e-tests';
function findE2eWalletFile(): string {
const f = fs.readdirSync(process.cwd()).find((x) => x.endsWith('.ngw'));
if (!f) {
throw new Error('No .ngw wallet file at the worktree root — add the festipod-e2e-tests wallet file.');
}
return path.resolve(f);
}
/**
* Navigate through the NG broker to load an app in its iframe.
* Handles wallet login and returns the app's Frame.
@@ -37,25 +72,48 @@ const WALLET_PASSWORD = 'festipod-tests';
async function setupBrokerPage(page: Page, appUrl: string): Promise<Frame> {
const brokerRedirect = `https://nextgraph.net/redir/#/?o=${encodeURIComponent(appUrl)}`;
await page.goto(brokerRedirect, { waitUntil: 'domcontentloaded' });
return completeBrokerLogin(page, appUrl);
}
// Automate wallet login if needed
/**
* Finish the broker flow once the page is ALREADY on the broker (e.g. after the
* app's own "Entrer" button redirected there): pick the saved wallet and unlock
* it if a login is shown, then return the app's iframe Frame. Reused by
* setupBrokerPage (persistent wallet, festipod-tests) and by the human-flow e2e
* (freshly imported wallet → pass its password).
*/
async function completeBrokerLogin(page: Page, appUrl: string, walletPassword: string = WALLET_PASSWORD): Promise<Frame> {
// Broker landing may show a "Login" button first → click it to reach the
// wallet-login page. (When the wallet session is already active, neither this
// nor the wallet link below appears, and we go straight to the app iframe.)
const loginButton = page.getByText('Login', { exact: true });
if (await loginButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await loginButton.click();
await page.waitForURL('**/wallet/login', { timeout: 5000 }).catch(() => {});
}
const walletLink = page.getByText('Click here to login with your wallet');
await walletLink.waitFor({ state: 'visible', timeout: 5000 });
// The broker redirect is multi-hop. Wait until EITHER the app iframe is already
// present (active wallet session) OR the wallet-login link appears (re-login
// needed — the session isn't persisted across browser launches).
const hasAppFrame = () => page.frames().some((f) => f.url().includes('127.0.0.1'));
const walletLink = page.getByText('Click here to login with your wallet', { exact: false });
const loginDeadline = Date.now() + 25000;
while (Date.now() < loginDeadline && !hasAppFrame() && !(await walletLink.isVisible().catch(() => false))) {
await page.waitForTimeout(500);
}
// On the wallet-login page ("Click here to login with your wallet <name>"):
// select the saved wallet and unlock it with its password.
if (!hasAppFrame() && await walletLink.isVisible().catch(() => false)) {
await walletLink.click();
await page.waitForTimeout(1000);
const passwordInput = page.locator('input[type="password"]');
await passwordInput.waitFor({ state: 'visible', timeout: 5000 });
await passwordInput.fill(WALLET_PASSWORD);
await passwordInput.press('Enter');
// Wait for login to complete and app iframe to load
await page.waitForTimeout(3000);
if (await passwordInput.isVisible({ timeout: 8000 }).catch(() => false)) {
await passwordInput.fill(walletPassword);
await passwordInput.press('Enter');
await page.waitForTimeout(3000);
}
}
// Verify iframe loaded after login
@@ -98,6 +156,74 @@ async function setupBrokerPage(page: Page, appUrl: string): Promise<Frame> {
return appFrame;
}
/**
* Drive the standalone nextgraph.eu "Import a Wallet File" flow: upload the .ngw
* file and unlock with the password. The wallet FILE is the STATIC, reusable
* assisted-import primitive (a TextCode is a transient 5-min device-to-device
* transfer — unusable to embed; see knowledge_broker-import-constraint). After
* this, the page's context holds the wallet.
*/
async function importWalletViaFile(page: Page, filePath: string, password: string): Promise<void> {
await page.goto('https://nextgraph.eu/#/wallet/login', { waitUntil: 'domcontentloaded' });
// Let the SPA render and the file input attach before uploading (uploading too
// early yields an EncryptionError — the wallet doesn't load).
await page.waitForTimeout(3000);
await page.locator('input[type=file]').waitFor({ state: 'attached', timeout: 15000 });
await page.setInputFiles('input[type=file]', filePath);
// A password prompt appears to unlock the wallet ("Enter your password").
const passwordInput = page.locator('input[type=password]').first();
await passwordInput.waitFor({ state: 'visible', timeout: 15000 });
await passwordInput.fill(password);
await passwordInput.press('Enter');
const confirm = page.getByRole('button', { name: /Confirm/i });
if (await confirm.isVisible({ timeout: 2000 }).catch(() => false)) await confirm.click().catch(() => {});
await page.waitForTimeout(8000); // unlock + verifier bootstrap from the broker
}
/**
* Build (once) a STAGING bundle of the real app — gate ON + the shared wallet
* FILE + password baked in — and serve it statically. Returns its URL. Lets the
* human-flow e2e exercise the real AccessGateScreen (which only renders in a
* staging build). Memoised; the build is cheap (~100-300ms).
*/
async function ensureStagingApp(): Promise<string> {
if (stagingAppUrl) return stagingAppUrl;
// Build into a SEPARATE outdir so it never collides with the harness bundles.
// Gate is ON by default (no ACCESS_GATE_DISABLED). The build copies the .ngw to
// dist-staging/shared-wallet.ngw + bakes the password.
execSync('bun run build.ts --outdir=dist-staging', {
env: {
...process.env,
FESTIPOD_SHARED_WALLET_FILE: findE2eWalletFile(),
FESTIPOD_SHARED_WALLET_PASSWORD: E2E_WALLET_PASSWORD,
},
stdio: 'pipe',
});
const mime: Record<string, string> = {
'.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css',
'.svg': 'image/svg+xml', '.map': 'application/json', '.json': 'application/json',
'.ico': 'image/x-icon', '.png': 'image/png', '.woff2': 'font/woff2',
};
stagingServer = http.createServer((req, res) => {
const urlPath = (req.url || '/').split('?')[0]!;
let filePath = path.join(STAGING_OUTDIR, urlPath === '/' ? 'index.html' : urlPath);
if (!filePath.startsWith(STAGING_OUTDIR) || !fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
filePath = path.join(STAGING_OUTDIR, 'index.html'); // SPA fallback
}
res.writeHead(200, { 'Content-Type': mime[path.extname(filePath)] || 'application/octet-stream' });
fs.createReadStream(filePath).pipe(res);
});
const port = await new Promise<number>((resolve) => {
stagingServer!.listen(0, '127.0.0.1', () => resolve((stagingServer!.address() as { port: number }).port));
});
stagingAppUrl = `http://127.0.0.1:${port}`;
console.log(`[Staging] App (gate ON, wallet baked) on ${stagingAppUrl}`);
return stagingAppUrl;
}
/**
* Automated wallet creation + login on nextgraph.eu.
* Flow:
@@ -227,6 +353,11 @@ BeforeAll({ timeout: 10 * 60 * 1000 }, async function () {
if (req.url === '/harness.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
res.end(harnessBundle);
} else if (req.url?.startsWith('/blank')) {
// Minimal page on the harness origin (no NG stack) — used by
// multi-browser isolation checks that only need localStorage.
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end('<!DOCTYPE html><html><head><meta charset="utf-8"><title>blank</title></head><body><div id="root"></div></body></html>');
} else {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(harnessHtml);
@@ -239,23 +370,30 @@ BeforeAll({ timeout: 10 * 60 * 1000 }, async function () {
});
console.log(`[Harness] HTTP server on http://127.0.0.1:${harnessPort}`);
// Launch Chromium with the same persistent profile (has the wallet).
// - Use full Chrome binary (not chrome-headless-shell) so localStorage persists
// - Grant permissions to avoid prompts
// - Disable Private Network Access (broker at nextgraph.eu needs to load
// our local harness at http://127.0.0.1:{port} in an iframe)
const chromePath = chromium.executablePath().replace('chrome-headless-shell', 'chrome').replace('chromium_headless_shell', 'chromium');
// Launch Chromium with the persistent profile (has the shared wallet).
const chromeExe = resolveChromePath();
browserContext = await chromium.launchPersistentContext(PLAYWRIGHT_PROFILE, {
headless: true,
executablePath: chromePath.includes('headless') ? undefined : chromePath,
permissions: ['notifications', 'clipboard-read', 'clipboard-write', 'geolocation'],
args: [
'--disable-features=PrivateNetworkAccessRespectPreflightResults,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessForWorkers,PrivateNetworkAccessForNavigations',
'--allow-insecure-localhost',
'--disable-web-security',
],
executablePath: chromeExe,
permissions: CONTEXT_PERMISSIONS,
args: LAUNCH_ARGS,
});
console.log('[Hooks] Real broker mode ready');
// The persistent context drives @data/@e2e (which exercise the screens, not
// the access gate). Disable the gate there so the real app renders directly.
// Fresh contexts (@humain/@multibrowser) don't get this → gate ON by default.
await browserContext.addInitScript(() => {
(globalThis as Record<string, unknown>).__FESTIPOD_ACCESS_GATE_DISABLED__ = true;
});
// Launch a non-persistent browser to mint fresh, isolated contexts on
// demand — each with its own storage partition (no wallet). This is what
// lets a single scenario drive several browsers (multi-browser).
freshBrowser = await chromium.launch({
headless: true,
executablePath: chromeExe,
args: LAUNCH_ARGS,
});
console.log('[Hooks] Real broker mode ready (persistent wallet + fresh-context launcher)');
// Start real app server for @e2e tests
appPort = await new Promise<number>((resolve) => {
@@ -285,12 +423,52 @@ BeforeAll({ timeout: 10 * 60 * 1000 }, async function () {
check();
});
console.log(`[E2E] App server on http://127.0.0.1:${appPort}`);
// Publish the live harness state to the pool for the World to consume.
pool.freshBrowser = freshBrowser;
pool.walletContext = browserContext;
pool.harnessUrl = `http://127.0.0.1:${harnessPort}`;
pool.appUrl = `http://127.0.0.1:${appPort}`;
pool.useRealBroker = true;
pool.permissions = CONTEXT_PERMISSIONS;
pool.setupBrokerPage = setupBrokerPage;
pool.completeBrokerLogin = completeBrokerLogin;
pool.importWalletViaFile = importWalletViaFile;
pool.ensureStagingApp = ensureStagingApp;
pool.sharedWalletPassword = E2E_WALLET_PASSWORD;
// Warm up the persistent wallet profile through the broker, then capture its
// storage state. Injecting this into fresh contexts provisions the SHARED
// wallet across several browsers (shared-wallet multi-browser tests). This
// is test-level provisioning — distinct from the assisted import. The broker
// login can flake, so retry a couple of times before giving up.
for (let attempt = 1; attempt <= 3 && !pool.sharedWalletState; attempt++) {
const warmPage = await browserContext.newPage();
try {
await setupBrokerPage(warmPage, `http://127.0.0.1:${harnessPort}`);
const state = await browserContext.storageState();
// Require the broker origin (where the wallet lives) — else it's incomplete.
if (state.origins.some((o) => o.origin.includes('nextgraph'))) {
pool.sharedWalletState = state;
console.log(`[Hooks] Captured shared wallet storageState — origins: ${state.origins.map((o) => o.origin).join(', ')}`);
} else {
console.warn(`[Hooks] storageState capture attempt ${attempt}: no nextgraph origin yet, retrying`);
}
} catch (e) {
console.warn(`[Hooks] storageState capture attempt ${attempt} failed:`, (e as Error).message);
} finally {
await warmPage.close();
}
}
if (!pool.sharedWalletState) console.warn('[Hooks] Could not capture shared wallet storageState after 3 attempts');
} catch (err) {
console.warn(`[Hooks] NG harness build/auth failed, falling back to mock: ${err}`);
useRealBroker = false;
browser = await chromium.launch({ headless: true });
browserContext = await browser.newContext();
console.log('[Hooks] Mock mode (no broker)');
pool.useRealBroker = false;
pool.permissions = CONTEXT_PERMISSIONS;
console.log('[Hooks] Mock mode (no broker) — multi-browser scenarios unavailable');
}
});
@@ -304,9 +482,16 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
this.screenSourceContent = '';
this.currentScreen = null;
// Launch Playwright page for @data and @e2e scenarios
// Multi-browser scenarios drive their own isolated browsers via steps
// (this.openBrowser). They must NOT get the legacy single shared page.
const tags = scenario.pickle.tags.map(t => t.name);
const needsPlaywright = tags.includes('@data') || tags.includes('@e2e');
const multiBrowser = tags.includes('@multibrowser');
if (multiBrowser && !useRealBroker) {
throw new Error('@multibrowser scenarios require real broker mode (fresh-context launcher).');
}
// Launch a single Playwright page for legacy @data and @e2e scenarios.
const needsPlaywright = (tags.includes('@data') || tags.includes('@e2e')) && !multiBrowser;
if (needsPlaywright) {
this.page = await browserContext.newPage();
@@ -318,7 +503,7 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
});
}
if (tags.includes('@data')) {
if (tags.includes('@data') && !multiBrowser) {
if (useRealBroker) {
const harnessUrl = `http://127.0.0.1:${harnessPort}`;
this.appFrame = await setupBrokerPage(this.page!, harnessUrl);
@@ -341,7 +526,7 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
}
}
if (tags.includes('@e2e')) {
if (tags.includes('@e2e') && !multiBrowser) {
if (!useRealBroker || !appPort) {
throw new Error('@e2e scenarios require real broker mode (NG harness + app server)');
}
@@ -381,12 +566,16 @@ After({ timeout: 10000 }, async function (this: FestipodWorld, scenario) {
this.appFrame = null;
}
// Close any named browsers opened by multi-browser scenarios
await this.closeBrowsers();
// Clean up UI-layer
this.cleanup();
});
AfterAll(async function () {
if (browserContext) await browserContext.close();
if (freshBrowser) await freshBrowser.close();
if (browser) await browser.close();
if (harnessServer) {
await new Promise<void>((resolve) => harnessServer!.close(() => resolve()));
@@ -395,5 +584,10 @@ AfterAll(async function () {
appServerProcess.kill();
appServerProcess = null;
}
if (stagingServer) {
await new Promise<void>((resolve) => stagingServer!.close(() => resolve()));
stagingServer = null;
}
if (fs.existsSync(STAGING_OUTDIR)) await fs.promises.rm(STAGING_OUTDIR, { recursive: true, force: true });
console.log('Festipod BDD tests completed.');
});
+61 -10
View File
@@ -4,6 +4,7 @@ import type { Page, Frame } from 'playwright';
import * as fs from 'fs';
import * as path from 'path';
import { renderScreen as renderUiScreen, unmountRender } from '../test-harness/renderHelper';
import { pool, spawnContext, type NamedBrowser, type WalletModel } from './browserPool';
export interface FestipodWorld extends World {
currentRoute: string;
@@ -23,6 +24,14 @@ export interface FestipodWorld extends World {
page: Page | null;
appFrame: Frame | null;
// Multi-browser (named, isolated contexts) — for cross-browser wallet tests.
// The wallet model (own vs shared) is an axis orthogonal to browser count.
browsers: Map<string, NamedBrowser>;
openBrowser(name: string, wallet: WalletModel): Promise<NamedBrowser>;
browser(name: string): NamedBrowser;
loadAppInBrowser(name: string, target?: 'app' | 'harness'): Promise<NamedBrowser>;
closeBrowsers(): Promise<void>;
navigateTo(route: string): Promise<void>;
getFormField(name: string): { required: boolean; value: string } | undefined;
getCurrentScreenFields(): string[];
@@ -42,7 +51,6 @@ export interface FestipodWorld extends World {
// Map screen IDs to their source file paths (relative to project root)
const screenFileMap: Record<string, string> = {
'home': 'src/modules/home/screens/HomeScreen.tsx',
'login': 'src/modules/auth/screens/LoginScreen.tsx',
'profile': 'src/modules/user/screens/ProfileScreen.tsx',
'update-profile': 'src/modules/user/screens/UpdateProfileScreen.tsx',
'user-profile': 'src/modules/user/screens/UserProfileScreen.tsx',
@@ -129,11 +137,6 @@ export const screenExpectedContent: Record<string, string[]> = {
'Confidentialité',
'Localisation',
],
'login': [
'Email',
'Mot de passe',
'Se connecter',
],
'event-detail': [
'Participants',
'À propos',
@@ -191,10 +194,6 @@ export const screenRequiredFields: Record<string, string[]> = {
'Confidentialité',
'Rayon de notification',
],
'login': [
'Email',
'Mot de passe',
],
'event-detail': [
'Titre',
'Date',
@@ -240,10 +239,62 @@ class CustomWorld extends World implements FestipodWorld {
page: Page | null = null;
appFrame: Frame | null = null;
// Multi-browser (named, isolated contexts)
browsers: Map<string, NamedBrowser> = new Map();
constructor(options: IWorldOptions) {
super(options);
}
/**
* Open a fresh, fully isolated browser under `name` with the given wallet
* model ('own' = no wallet / its own; 'shared' = pre-loaded with THE shared
* wallet). The page is created but not navigated — drive it via
* `this.browser(name).page` or load the app via `loadAppInBrowser(name)`.
*/
async openBrowser(name: string, wallet: WalletModel): Promise<NamedBrowser> {
if (this.browsers.has(name)) return this.browsers.get(name)!;
const context = await spawnContext(wallet);
const page = await context.newPage();
page.on('pageerror', (err) => console.error(`[Browser ${name} error]`, err.message));
page.on('console', (msg) => {
if (msg.type() === 'error') console.error(`[Browser ${name} console]`, msg.text());
});
const handle: NamedBrowser = { name, wallet, context, page, appFrame: null };
this.browsers.set(name, handle);
return handle;
}
browser(name: string): NamedBrowser {
const handle = this.browsers.get(name);
if (!handle) throw new Error(`Browser "${name}" not opened — call openBrowser("${name}") first.`);
return handle;
}
/**
* Navigate a named browser through the NG broker to load the app (or the NG
* test harness) in its iframe, recording the app Frame on the handle.
* NOTE: a fresh browser has no wallet, so the broker login can only succeed
* once the wallet-acquisition path (auto-import / textcode / …) is wired.
*/
async loadAppInBrowser(name: string, target: 'app' | 'harness' = 'app'): Promise<NamedBrowser> {
const handle = this.browser(name);
const url = target === 'harness' ? pool.harnessUrl : pool.appUrl;
handle.appFrame = await pool.setupBrokerPage(handle.page, url);
return handle;
}
async closeBrowsers(): Promise<void> {
for (const handle of this.browsers.values()) {
try {
await handle.context.close();
} catch {
// context may already be gone
}
}
this.browsers.clear();
}
async navigateTo(route: string): Promise<void> {
this.navigationHistory.push(route);
this.currentRoute = route;
+118 -1
View File
@@ -12,6 +12,7 @@ import { createRoot } from 'react-dom/client';
import { NextGraphProvider, useNextGraph } from '../context/NextGraphContext';
import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataContext';
import { useShape } from '@ng-org/orm/react';
import { ng } from '@ng-org/web';
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
import {
FpEventShapeType,
@@ -67,6 +68,11 @@ function ConnectedHarness() {
const participations = useShape(FpParticipationShapeType, privateNuri) as DeepSignalSet<FpParticipation>;
const [bridgeReady, setBridgeReady] = useState(false);
// Stopgap multi-store validation: a doc created on demand via doc_create,
// mounted into a real useShape({graphs}) by <SmokeProbe>.
const [smokeDoc, setSmokeDoc] = useState<string | null>(null);
// Per-entity fan-out validation: several entity docs read together.
const [fanoutGraphs, setFanoutGraphs] = useState<string[]>([]);
useEffect(() => {
// Small delay for useShape to populate
@@ -146,6 +152,54 @@ function ConnectedHarness() {
loadTestData() {
return bootstrapWallet(events as any, users as any, participations as any);
},
// --- Stopgap multi-store validation (see brief_2026-06-15_shared-wallet-shim) ---
/**
* Create a fresh graph document via doc_create and mount it into a real
* useShape({graphs}) subscription (<SmokeProbe>). Returns the NURI.
* Validates: doc_create returns a usable graph NURI.
*/
async createSmokeDoc() {
const nuri = await ng.doc_create(session.session_id, 'Graph', 'data:graph', 'store', undefined);
setSmokeDoc(nuri);
return nuri;
},
/**
* Round-trip the sharedWalletShim through the wallet: create an account
* (3 docs + SPARQL INSERT), drop the cache, reload from the wallet via
* SPARQL SELECT. Validates: doc_create ×3 + shim sparql_update/query.
*/
async validateShim(username: string) {
const reg = await import('../utils/storeRegistry');
reg.resetRegistryCache();
const created = await reg.ensureAccount(username);
reg.resetRegistryCache();
const reloaded = (await reg.allAccounts()).find(
a => a.username === username,
) ?? null;
return { created, reloaded };
},
/**
* Per-entity granularity + fan-out: 2 accounts, one event document each
* (via createEntityDoc → indexed), then mount a multi-graph useShape over
* both (<FanoutProbe>). Returns the two doc NURIs and the index listing.
* Validates: 1-doc-per-entity, index append/read, fan-out across N docs.
*/
async setupFanout() {
const reg = await import('../utils/storeRegistry');
reg.resetRegistryCache();
await reg.ensureAccount('@fan-a');
await reg.ensureAccount('@fan-b');
const docA = await reg.createEntityDoc('@fan-a', 'public');
const docB = await reg.createEntityDoc('@fan-b', 'public');
reg.resetRegistryCache();
const listed = await reg.listEntityDocs('public');
setFanoutGraphs([docA, docB]);
return { docA, docB, listed };
},
};
console.log('[HarnessNG] Ready — events:', events.size, 'users:', users.size,
@@ -157,7 +211,70 @@ function ConnectedHarness() {
return () => clearTimeout(timer);
}, [events, users, participations, ngCtx, appData]);
return <div id="harness-status">{bridgeReady ? 'READY' : 'LOADING_SHAPES'}</div>;
return (
<>
<div id="harness-status">{bridgeReady ? 'READY' : 'LOADING_SHAPES'}</div>
{smokeDoc && <SmokeProbe docNuri={smokeDoc} />}
{fanoutGraphs.length > 0 && <FanoutProbe graphs={fanoutGraphs} />}
</>
);
}
// ============================================================================
// FanoutProbe — real useShape({graphs}) over SEVERAL entity documents.
// Exposes window.__fanout for the per-entity fan-out @data scenario.
// ============================================================================
function FanoutProbe({ graphs }: { graphs: string[] }) {
const set = useShape(FpEventShapeType, { graphs } as any) as DeepSignalSet<FpEvent>;
useEffect(() => {
(window as any).__fanout = {
ready: true,
graphs,
addEventTo(docNuri: string, title: string) {
set.add({
'@graph': docNuri,
'@type': 'http://festipod.org/Event',
'@id': '',
title,
participantCount: 1,
} as FpEvent);
},
count() { return set.size; },
titles() { return [...set].map(e => e.title); },
};
}, [set, graphs]);
return null;
}
// ============================================================================
// SmokeProbe — real useShape({graphs}) on a doc_create'd document.
// Exposes window.__smoke for the multi-store @data validation scenario.
// ============================================================================
function SmokeProbe({ docNuri }: { docNuri: string }) {
const set = useShape(FpParticipationShapeType, { graphs: [docNuri] } as any) as DeepSignalSet<FpParticipation>;
useEffect(() => {
(window as any).__smoke = {
ready: true,
docNuri,
add() {
set.add({
'@graph': docNuri,
'@type': 'http://festipod.org/Participation',
'@id': '',
event: 'urn:smoke:event',
user: 'urn:smoke:user',
isConfirmed: true,
} as FpParticipation);
},
count() { return set.size; },
items() {
return [...set].map(p => ({ '@id': p['@id'], event: p.event, user: p.user }));
},
};
}, [set, docNuri]);
return null;
}
// ============================================================================
+65
View File
@@ -0,0 +1,65 @@
/**
* isolation — app-level enforcement of the authorization matrix.
*
* STOPGAP (see brief_2026-06-15_shared-wallet-shim): one shared wallet means
* everything is physically readable. To make staging *behave* like the target
* infra, 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 NOT crypto-enforced — it's a deliberate, removable scaffold (the real
* crypto isolation arrives with per-user wallets). 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';
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<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;
}
/**
* 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'.
* - friendships: only links involving the user or one of their connections.
*/
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)),
};
}
+19
View File
@@ -50,6 +50,25 @@ export async function login() {
await ng.login();
}
/**
* REAL NextGraph logout — stops the session of the SHARED wallet.
*
* STOPGAP: must stay HIDDEN (Settings/debug only). The everyday "Déconnexion"
* is the FAUX one (AccountContext.logout, clears the username only). Calling
* this forces a new broker redirect on the next access — see
* decision_2026-06-15_shared-wallet-login-flow.
*/
export async function logoutNg(): Promise<void> {
const userId = session && (session as Record<string, unknown>).user;
if (!userId) return;
try {
await (ng as unknown as { session_stop: (u: unknown) => Promise<void> }).session_stop(userId);
console.log('[NG session] session_stop done');
} catch (error) {
console.error('[NG session] logout error:', error);
}
}
export interface NextGraphSession {
ng: typeof NG;
session_id: string;
+273
View File
@@ -0,0 +1,273 @@
/**
* storeRegistry — resolves (account, scope) → document NURI.
*
* 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.
*/
import { ng } from '@ng-org/web';
import { sessionPromise } from './ngSession';
import { normalizeUsername } from '../context/AccountContext';
export type Scope = 'public' | 'protected' | 'private';
/** Domain entity kinds and the scope (= future store) each one lives in. */
export type EntityKind = 'event' | 'meetingPoint' | 'profile' | 'profilePrivate' | 'participation' | 'connectionIndex';
/** Maps a domain entity to its scope, exactly per the authorization matrix. */
export function entityScope(kind: EntityKind): Scope {
switch (kind) {
case 'event': // declared by the user → their public store
case 'meetingPoint': // hosted by the user → their public store
return 'public';
case 'profile': // network profile → protected store
case 'participation': // participation → protected store
case 'connectionIndex': // connections index → protected store
return 'protected';
case 'profilePrivate': // settings, email → private store
return 'private';
}
}
// --- sharedWalletShim model ----------------------------------------------
export interface AccountRecord {
username: string;
docPublic: string;
docProtected: string;
docPrivate: string;
}
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;
}