Merge main into ng-eventually: shared-wallet shim + multi-browser e2e
Brings 266e335 (staging shared wallet: file-assisted import + multi-browser
e2e) into the ng-eventually branch. Conflicts resolved so both lines of work
coexist and route through the lib where they overlap:
- harness-ng.tsx: combine ReadCap FilterProbe (ours) with main's SmokeProbe/
FanoutProbe; useShape + ng imported from @ng-eventually/client.
- ngSession.ts (auto): our single-injection-point configure() + main's hidden
logoutNg, which uses the lib's ng.
- useShapeWithDefaults.ts (auto): lib useShape + main's { graphs } multistore
scope.
- cucumber.json: single "tags": "not @wip" (both branches added it).
- brief_2026-06-15_shared-wallet-shim: keep main's implemented status; record
that the read filter now lives in the lib (decision_2026-06-17) while the
rest of the shim (storeRegistry/accounts/isolation) is still in-app, slated
to move into the lib.
Build OK; harness-ng bundles. TODO (next): verify all of main's NextGraph
surface routes through @ng-eventually/client (storeRegistry uses ng directly).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+14
-10
@@ -1,12 +1,13 @@
|
||||
import { RouterProvider, useRouter } from './router';
|
||||
import { ThemeProvider } from '../shared/context/ThemeContext';
|
||||
import { NextGraphProvider } from '../shared/context/NextGraphContext';
|
||||
import { AccountProvider } from '../shared/context/AccountContext';
|
||||
import { FestipodDataProvider } from '../shared/context/FestipodDataContext';
|
||||
import { AuthGate } from './AuthGate';
|
||||
import { ToastContainer } from '../shared/components/sketchy';
|
||||
|
||||
// Auth
|
||||
import { WelcomeScreen } from '../modules/auth/screens/WelcomeScreen';
|
||||
import { LoginScreen } from '../modules/auth/screens/LoginScreen';
|
||||
|
||||
// Home
|
||||
import { HomeScreen } from '../modules/home/screens/HomeScreen';
|
||||
@@ -34,7 +35,6 @@ function AppContent() {
|
||||
|
||||
switch (route.page) {
|
||||
case 'welcome': return <WelcomeScreen />;
|
||||
case 'login': return <LoginScreen />;
|
||||
case 'home': return <HomeScreen />;
|
||||
case 'events': return <EventsScreen />;
|
||||
case 'create-event': return <CreateEventScreen />;
|
||||
@@ -57,14 +57,18 @@ export function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<NextGraphProvider>
|
||||
<FestipodDataProvider>
|
||||
<RouterProvider>
|
||||
<div className="app-container">
|
||||
<AppContent />
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</RouterProvider>
|
||||
</FestipodDataProvider>
|
||||
<AccountProvider>
|
||||
<FestipodDataProvider>
|
||||
<RouterProvider>
|
||||
<div className="app-container">
|
||||
<AuthGate>
|
||||
<AppContent />
|
||||
</AuthGate>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</RouterProvider>
|
||||
</FestipodDataProvider>
|
||||
</AccountProvider>
|
||||
</NextGraphProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* AuthGate — the stopgap access flow (see decision_2026-06-15_shared-wallet-login-flow):
|
||||
* 1. Technical access barrier (AccessGateScreen) → opens the SHARED wallet via
|
||||
* the broker redirect (with the wallet file + guide it hands the user).
|
||||
* 2. Perceived app login (ConnexionScreen) → pick a username.
|
||||
* 3. The app.
|
||||
*
|
||||
* The gate is ON BY DEFAULT (Festipod never functions without NextGraph). It is
|
||||
* disabled only when `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true` —
|
||||
* injected by `build.ts` (from ACCESS_GATE_DISABLED=1) for a no-gate build, or
|
||||
* by the test harness via `context.addInitScript` for @e2e (which exercises the
|
||||
* screens, not the auth flow). Absent → gate ON.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { useNextGraph } from '../shared/context/NextGraphContext';
|
||||
import { useAccount } from '../shared/context/AccountContext';
|
||||
import { AccessGateScreen } from '../modules/auth/screens/AccessGateScreen';
|
||||
import { ConnexionScreen } from '../modules/auth/screens/ConnexionScreen';
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __FESTIPOD_ACCESS_GATE_DISABLED__: boolean | undefined;
|
||||
}
|
||||
const GATE_DISABLED = globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true;
|
||||
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const { status, error, connect } = useNextGraph();
|
||||
const { username } = useAccount();
|
||||
|
||||
// Gate explicitly disabled (no-gate build / @e2e harness) → straight to app.
|
||||
if (GATE_DISABLED) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// 1. Technical access barrier (real NG login) — until the shared wallet opens.
|
||||
if (status !== 'connected') {
|
||||
return <AccessGateScreen status={status} error={error} onEnter={connect} />;
|
||||
}
|
||||
|
||||
// 2. Perceived app login — until a username is chosen.
|
||||
if (!username) {
|
||||
return <ConnexionScreen />;
|
||||
}
|
||||
|
||||
// 3. The app.
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import React, { createContext, useContext, useState, useEffect, useCallback } fr
|
||||
|
||||
type Route =
|
||||
| { page: 'welcome' }
|
||||
| { page: 'login' }
|
||||
| { page: 'home' }
|
||||
| { page: 'events' }
|
||||
| { page: 'create-event' }
|
||||
@@ -38,7 +37,6 @@ function parsePath(pathname: string): Route {
|
||||
const path = pathname.replace(/\/+$/, '') || '/';
|
||||
|
||||
if (path === '/' || path === '') return { page: 'welcome' };
|
||||
if (path === '/login') return { page: 'login' };
|
||||
if (path === '/home') return { page: 'home' };
|
||||
if (path === '/events') return { page: 'events' };
|
||||
if (path === '/events/new') return { page: 'create-event' };
|
||||
@@ -73,7 +71,6 @@ function parsePath(pathname: string): Route {
|
||||
export function routeToPath(route: Route): string {
|
||||
switch (route.page) {
|
||||
case 'welcome': return '/';
|
||||
case 'login': return '/login';
|
||||
case 'home': return '/home';
|
||||
case 'events': return '/events';
|
||||
case 'create-event': return '/events/new';
|
||||
|
||||
@@ -6,29 +6,10 @@ Fonctionnalité: Connexion NextGraph et chargement des données
|
||||
Et charger les données de test dans mon portefeuille
|
||||
Afin d'utiliser l'application avec mes propres données
|
||||
|
||||
# --- UI layer: écran de connexion ---
|
||||
|
||||
@ui
|
||||
Scénario: L'écran de connexion affiche le bouton NextGraph
|
||||
Étant donné je suis sur la page "connexion"
|
||||
Alors l'écran contient un bouton "Se connecter avec NextGraph"
|
||||
|
||||
@ui @wip
|
||||
# Behavioral: requires simulating an NG status change. Better tested at the
|
||||
# @e2e layer where a real connected session triggers the redirect.
|
||||
Scénario: L'écran de connexion redirige automatiquement quand connecté
|
||||
Étant donné je suis sur la page "connexion"
|
||||
Alors l'écran gère la redirection automatique après connexion
|
||||
|
||||
@ui
|
||||
Scénario: L'état initial est "en cours" quand une connexion est en attente
|
||||
Étant donné je suis sur la page "connexion"
|
||||
Alors l'écran gère l'état de connexion en cours
|
||||
|
||||
@ui
|
||||
Scénario: Aucune donnée de démonstration n'est visible pendant la connexion
|
||||
Étant donné je suis sur la page "connexion"
|
||||
Alors l'écran n'importe pas de données de démonstration
|
||||
# NB : l'ancien écran /login (LoginScreen) a été retiré — l'accès NextGraph
|
||||
# passe désormais par l'AccessGateScreen (barrière ON par défaut), cf.
|
||||
# decision_2026-06-17_assisted-wallet-import. Les scénarios @ui qui testaient
|
||||
# le LoginScreen ont été supprimés en conséquence.
|
||||
|
||||
# --- Data layer: comportement du portefeuille ---
|
||||
|
||||
@@ -59,11 +40,6 @@ Fonctionnalité: Connexion NextGraph et chargement des données
|
||||
|
||||
# --- E2E layer: comportement réel dans le navigateur ---
|
||||
|
||||
@e2e
|
||||
Scénario: L'écran de connexion redirige vers l'accueil si déjà connecté
|
||||
Quand l'utilisateur navigue vers l'écran "login"
|
||||
Alors l'application affiche l'écran "home"
|
||||
|
||||
@e2e
|
||||
Scénario: La navigation interne met à jour l'URL
|
||||
Quand l'utilisateur navigue vers l'écran "events"
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* AccessGateScreen — the *technical access barrier* of the stopgap.
|
||||
*
|
||||
* STOPGAP (see decision_2026-06-15_shared-wallet-login-flow). This is the
|
||||
* REAL NextGraph login, shown before the app renders. Because it precedes the
|
||||
* app, the user reads it as "access to the test environment", not as an app
|
||||
* login. Clicking "Entrer" triggers `connect()`, which redirects to the broker
|
||||
* to open the SHARED wallet. After return (inside the broker iframe) NG
|
||||
* auto-connects and the app shows the perceived login (ConnexionScreen).
|
||||
*
|
||||
* ASSISTED IMPORT (see the broker-import constraint, concept nextgraph-platform
|
||||
* + decision_2026-06-17). The hosted broker can't import a wallet inline during
|
||||
* web-app auth: a first-time device has no wallet, so the broker redirect would
|
||||
* dead-end. We therefore HAND the user the shared wallet FILE (download) + the
|
||||
* shared password and guide a one-time import on nextgraph.eu ("Import a Wallet
|
||||
* File"), BEFORE they click "Entrer". The wallet FILE is the correct static
|
||||
* primitive — a TextCode is a transient 5-min transfer, unusable to embed. Shown
|
||||
* only when a shared wallet is configured (FESTIPOD_SHARED_WALLET_PASSWORD).
|
||||
*/
|
||||
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Button, Title, Text } from '../../../shared/components/sketchy';
|
||||
import { SHARED_WALLET_PASSWORD, SHARED_WALLET_FILE_URL, WALLET_IMPORT_URL, hasSharedWallet } from '../sharedWallet';
|
||||
|
||||
interface AccessGateScreenProps {
|
||||
status: 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
error?: string;
|
||||
onEnter: () => void;
|
||||
}
|
||||
|
||||
// One numbered step: a badge + a title + the action for that step.
|
||||
function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 18 }}>
|
||||
<div style={{
|
||||
flexShrink: 0, width: 26, height: 26, borderRadius: '50%', background: '#E8590C',
|
||||
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700, fontSize: 14,
|
||||
}}>{n}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={{ margin: '2px 0 8px', fontWeight: 600, fontSize: 14 }}>{title}</Text>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccessGateScreen({ status, error, onEnter }: AccessGateScreenProps) {
|
||||
const connecting = status === 'connecting';
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyPassword = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(SHARED_WALLET_PASSWORD);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// clipboard may be blocked — the password stays selectable
|
||||
}
|
||||
};
|
||||
|
||||
const entrer = (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onEnter}
|
||||
disabled={connecting}
|
||||
style={{ width: '100%', opacity: connecting ? 0.6 : 1 }}
|
||||
>
|
||||
{connecting ? 'Accès en cours…' : 'Entrer'}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<Title style={{ textAlign: 'center', fontSize: 30, marginBottom: 4 }}>Festipod</Title>
|
||||
<Text style={{ textAlign: 'center', marginBottom: 24, color: '#888' }}>Espace de test</Text>
|
||||
|
||||
{hasSharedWallet() ? (
|
||||
<>
|
||||
<Text style={{ textAlign: 'center', fontSize: 14, color: '#666', margin: '0 0 20px', lineHeight: 1.5 }}>
|
||||
Première connexion sur cet appareil ?<br />Chargez le portefeuille partagé, une seule fois.
|
||||
</Text>
|
||||
|
||||
<Step n={1} title="Téléchargez le portefeuille">
|
||||
<a
|
||||
data-testid="shared-wallet-download"
|
||||
href={SHARED_WALLET_FILE_URL}
|
||||
download="festipod-wallet.ngw"
|
||||
style={{
|
||||
display: 'block', textAlign: 'center', textDecoration: 'none',
|
||||
padding: 10, borderRadius: 10, background: '#E8590C', color: '#fff', fontWeight: 600, fontSize: 14,
|
||||
}}
|
||||
>
|
||||
⬇ Télécharger le portefeuille
|
||||
</a>
|
||||
</Step>
|
||||
|
||||
<Step n={2} title="Importez-le sur NextGraph">
|
||||
<Text style={{ margin: '0 0 8px', fontSize: 13, lineHeight: 1.6, color: '#666' }}>
|
||||
<a href={WALLET_IMPORT_URL} target="_blank" rel="noopener noreferrer" style={{ color: '#E8590C', fontWeight: 600 }}>
|
||||
Ouvrir la page d'import
|
||||
</a>{' '}(nouvel onglet) → « Import a Wallet File » → choisissez le fichier → mot de passe :
|
||||
</Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<code
|
||||
data-testid="shared-wallet-password"
|
||||
style={{ flex: 1, padding: '6px 10px', background: '#fff', border: '1px solid #eee', borderRadius: 8, fontSize: 13, userSelect: 'all' }}
|
||||
>
|
||||
{SHARED_WALLET_PASSWORD}
|
||||
</code>
|
||||
<Button variant="accent-outline" onClick={copyPassword} style={{ padding: '6px 10px', fontSize: 12 }}>
|
||||
{copied ? 'Copié ✓' : 'Copier'}
|
||||
</Button>
|
||||
</div>
|
||||
</Step>
|
||||
|
||||
<Step n={3} title="Revenez sur cet onglet et entrez">
|
||||
{entrer}
|
||||
</Step>
|
||||
</>
|
||||
) : (
|
||||
entrer
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<Text style={{ textAlign: 'center', fontSize: 12, color: '#c92a2a', marginTop: 12 }}>
|
||||
{error || "Accès à l'environnement impossible. Réessayez."}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Text style={{ textAlign: 'center', fontSize: 12, color: '#bbb' }}>
|
||||
Version beta
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* ConnexionScreen — the *perceived* login of the stopgap.
|
||||
*
|
||||
* STOPGAP (see decision_2026-06-15_shared-wallet-login-flow). The real NG
|
||||
* login (AccessGateScreen) already happened and is not perceived as a login;
|
||||
* THIS screen is what the user experiences as "logging in": they pick a
|
||||
* username (no password — declarative). The username is persisted by
|
||||
* AccountContext (localStorage) and resolved against the accounts living in
|
||||
* the shared wallet.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button, Input, Title, Text, Avatar } from '../../../shared/components/sketchy';
|
||||
import { useAccount } from '../../../shared/context/AccountContext';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate } from '../../../app/router';
|
||||
|
||||
export function ConnexionScreen() {
|
||||
const { login } = useAccount();
|
||||
const { users } = useFestipodData();
|
||||
const navigate = useNavigate();
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
// Choosing a username completes the login → land on the app (not the '/'
|
||||
// welcome/onboarding screen, which the access gate has replaced upstream).
|
||||
const doLogin = (name: string) => {
|
||||
login(name);
|
||||
navigate('/home');
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (value.trim()) doLogin(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<Title style={{ textAlign: 'center', fontSize: 32, marginBottom: 8 }}>Connexion</Title>
|
||||
<Text style={{ textAlign: 'center', marginBottom: 32, color: '#888' }}>
|
||||
Choisissez votre nom d'utilisateur
|
||||
</Text>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 24 }}>
|
||||
<Input
|
||||
placeholder="@votrepseudo"
|
||||
value={value}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setValue(e.target.value)}
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') submit();
|
||||
}}
|
||||
/>
|
||||
<Button variant="primary" onClick={submit} disabled={!value.trim()} style={{ width: '100%' }}>
|
||||
Se connecter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{users.length > 0 && (
|
||||
<>
|
||||
<Text style={{ textAlign: 'center', fontSize: 13, color: '#888', margin: '8px 0 12px' }}>
|
||||
ou reprenez un compte existant
|
||||
</Text>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{users.map(u => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => doLogin(u.username)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: 10,
|
||||
border: '1px solid #eee',
|
||||
borderRadius: 12,
|
||||
background: '#fff',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<Avatar initials={u.initials} color="#E8590C" size="sm" />
|
||||
<span>
|
||||
<Text style={{ margin: 0, fontWeight: 600 }}>{u.name}</Text>
|
||||
<Text style={{ margin: 0, fontSize: 12, color: '#888' }}>{u.username}</Text>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { LoginScreen } from './LoginScreen';
|
||||
import { withProviders } from '../../../../.storybook/decorators';
|
||||
|
||||
const meta: Meta<typeof LoginScreen> = {
|
||||
title: 'Screens/Auth/LoginScreen',
|
||||
component: LoginScreen,
|
||||
decorators: [withProviders],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof LoginScreen>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Button, Input, Title, Text, Divider } from '../../../shared/components/sketchy';
|
||||
import { useNextGraph } from '../../../shared/context/NextGraphContext';
|
||||
import { useNavigate } from '../../../app/router';
|
||||
|
||||
export function LoginScreen() {
|
||||
const navigate = useNavigate();
|
||||
const { status, connect } = useNextGraph();
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'connected') {
|
||||
navigate('/home');
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const handleNgLogin = () => {
|
||||
if (status === 'connected') {
|
||||
navigate('/home');
|
||||
} else {
|
||||
connect();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<Title style={{ textAlign: 'center', fontSize: 32, marginBottom: 8 }}>Festipod</Title>
|
||||
<Text style={{ textAlign: 'center', marginBottom: 32, color: '#888' }}>Créez et rejoignez des événements entre amis</Text>
|
||||
|
||||
{/* NextGraph login */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
{status === 'connected' ? (
|
||||
<div style={{ textAlign: 'center', marginBottom: 8 }}>
|
||||
<Text style={{ color: '#22543D', fontWeight: 'bold', margin: '0 0 8px 0' }}>
|
||||
✓ Connecté via NextGraph
|
||||
</Text>
|
||||
<Button variant="primary" onClick={() => navigate('/home')} style={{ width: '100%' }}>
|
||||
Continuer vers l'accueil
|
||||
</Button>
|
||||
</div>
|
||||
) : status === 'connecting' ? (
|
||||
<Button disabled style={{ width: '100%', opacity: 0.6 }}>
|
||||
Connexion NextGraph en cours...
|
||||
</Button>
|
||||
) : (
|
||||
<div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleNgLogin}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Se connecter avec NextGraph
|
||||
</Button>
|
||||
{status === 'error' && (
|
||||
<Text style={{ textAlign: 'center', fontSize: 12, color: '#888', marginTop: 8 }}>
|
||||
NextGraph non disponible — mode démonstration
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text style={{ textAlign: 'center', fontSize: 14, color: '#888', margin: '16px 0' }}>
|
||||
ou connexion classique (démo)
|
||||
</Text>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div>
|
||||
<Text style={{ marginBottom: 4, fontSize: 13, color: '#888' }}>Email</Text>
|
||||
<Input type="email" placeholder="vous@exemple.com" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text style={{ marginBottom: 4, fontSize: 13, color: '#888' }}>Mot de passe</Text>
|
||||
<Input type="password" placeholder="••••••••" />
|
||||
</div>
|
||||
|
||||
<Button variant="primary" onClick={() => navigate('/home')}>
|
||||
Se connecter
|
||||
</Button>
|
||||
|
||||
<Text style={{ textAlign: 'center', fontSize: 14, color: '#E8590C' }}>
|
||||
Mot de passe oublié ?
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Text style={{ textAlign: 'center', fontSize: 14, color: '#888' }}>
|
||||
Pas encore de compte ? <span style={{ color: '#E8590C', cursor: 'pointer' }}>S'inscrire</span>
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,18 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Button, Title, Text } from '../../../shared/components/sketchy';
|
||||
import { useNavigate } from '../../../app/router';
|
||||
import { useNextGraph } from '../../../shared/context/NextGraphContext';
|
||||
|
||||
export function WelcomeScreen() {
|
||||
const navigate = useNavigate();
|
||||
const { status } = useNextGraph();
|
||||
|
||||
// Onboarding is for NOT-connected users. A connected user landing on '/'
|
||||
// (e.g. a returning tester past the access gate) goes straight to the app.
|
||||
useEffect(() => {
|
||||
if (status === 'connected') navigate('/home');
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
@@ -41,12 +51,12 @@ export function WelcomeScreen() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="primary" onClick={() => navigate('/login')} style={{ marginBottom: 12 }}>
|
||||
<Button variant="primary" onClick={() => navigate('/home')} style={{ marginBottom: 12 }}>
|
||||
Rejoindre la communauté
|
||||
</Button>
|
||||
|
||||
<Text style={{ textAlign: 'center', fontSize: 13, color: '#888' }}>
|
||||
Déjà membre ? <span onClick={() => navigate('/login')} style={{ color: '#E8590C', cursor: 'pointer', fontWeight: 600 }}>Connexion</span>
|
||||
Déjà membre ? <span onClick={() => navigate('/home')} style={{ color: '#E8590C', cursor: 'pointer', fontWeight: 600 }}>Connexion</span>
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Shared wallet material for the staging stopgap.
|
||||
*
|
||||
* STOPGAP (see brief_2026-06-15_shared-wallet-shim + the broker-import
|
||||
* constraint, concept nextgraph-platform): the hosted broker can't auto-import
|
||||
* a wallet, so Festipod HANDS the user the shared wallet and guides a one-time
|
||||
* import on nextgraph.eu.
|
||||
*
|
||||
* The correct primitive is the **wallet FILE** (.ngw), NOT a TextCode: a
|
||||
* TextCode is a transient device-to-device transfer (5 min, source device
|
||||
* online, single use) — useless to embed. A wallet file is STATIC and reusable.
|
||||
* So Festipod serves the file (download) + shows the shared password; the user
|
||||
* imports it via nextgraph.eu → "Import a Wallet File".
|
||||
*
|
||||
* ZERO-SECURITY shared credential (friendly users) → embedding the file +
|
||||
* password is consistent with the posture.
|
||||
*
|
||||
* `build.ts` copies the file (from FESTIPOD_SHARED_WALLET_FILE) to the bundle as
|
||||
* `/shared-wallet.ngw`, and `define`s the password global from
|
||||
* FESTIPOD_SHARED_WALLET_PASSWORD. Empty password → no shared wallet configured
|
||||
* → the gate falls back to the plain flow.
|
||||
*/
|
||||
|
||||
// Build-injected global (not `process.env`, absent in the browser); any path
|
||||
// that doesn't inject it reads `undefined` → '' safely (no ReferenceError).
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __FESTIPOD_SHARED_WALLET_PASSWORD__: string | undefined;
|
||||
}
|
||||
|
||||
export const SHARED_WALLET_PASSWORD: string = globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__ ?? '';
|
||||
|
||||
/** URL of the shared wallet file in the bundle (copied by build.ts). */
|
||||
export const SHARED_WALLET_FILE_URL = '/shared-wallet.ngw';
|
||||
|
||||
/** Standalone NextGraph wallet app — where the import actually happens. */
|
||||
export const WALLET_IMPORT_URL = 'https://nextgraph.eu/#/wallet/login';
|
||||
|
||||
/** Whether Festipod has a shared wallet to hand over (drives the assisted UI). */
|
||||
export const hasSharedWallet = (): boolean => SHARED_WALLET_PASSWORD.trim().length > 0;
|
||||
@@ -13,7 +13,6 @@ import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
const SCREEN_MARKERS: Record<string, string> = {
|
||||
'home': 'Festipod',
|
||||
'events': 'Découvrir',
|
||||
'login': 'connecter',
|
||||
'profile': 'Mon profil',
|
||||
'create-event': "Relayer un événement",
|
||||
'settings': 'Paramètres',
|
||||
@@ -35,7 +34,6 @@ function pathForScreen(screenId: string): string {
|
||||
case 'home': return '/home';
|
||||
case 'events': return '/events';
|
||||
case 'create-event': return '/events/new';
|
||||
case 'login': return '/login';
|
||||
case 'profile': return '/profile';
|
||||
case 'edit-profile': return '/profile/edit';
|
||||
case 'friends-list': return '/profile/friends';
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
Then('l\'écran gère la redirection automatique après connexion', async function (this: FestipodWorld) {
|
||||
// Behavioral — covered by the @e2e scenario
|
||||
// "L'écran de connexion redirige vers l'accueil si déjà connecté".
|
||||
// At the @ui layer we only verify the screen mounts cleanly.
|
||||
expect(this.currentScreenId).to.equal('login');
|
||||
expect(this.renderedDoc, 'Login screen should render').to.not.be.null;
|
||||
});
|
||||
|
||||
Then('l\'écran gère l\'état de connexion en cours', async function (this: FestipodWorld) {
|
||||
const source = this.getRenderedText();
|
||||
const hasConnectingState =
|
||||
source.includes("status === 'connecting'") ||
|
||||
source.includes("Connexion NextGraph en cours");
|
||||
expect(hasConnectingState, 'LoginScreen should handle connecting state').to.be.true;
|
||||
});
|
||||
|
||||
Then('l\'écran n\'importe pas de données de démonstration', async function (this: FestipodWorld) {
|
||||
const source = this.getRenderedText();
|
||||
const importsSeedData = source.includes('seedData') || source.includes('seedEvents');
|
||||
const usesFestipodData = source.includes('useFestipodData');
|
||||
expect(importsSeedData, 'LoginScreen should not import seed data').to.be.false;
|
||||
expect(usesFestipodData, 'LoginScreen should not use FestipodData context').to.be.false;
|
||||
});
|
||||
@@ -74,12 +74,12 @@ export function CreateEventScreen() {
|
||||
setStep((step - 1) as Step);
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const submit = async () => {
|
||||
const dateLabel = startDate
|
||||
? (endDate ? `${startDate} - ${endDate}` : startDate)
|
||||
: 'Date à définir';
|
||||
|
||||
const newEvent = createEvent({
|
||||
const newEvent = await createEvent({
|
||||
title: name || 'Nouvel événement',
|
||||
date: dateLabel,
|
||||
startDate,
|
||||
|
||||
@@ -2,13 +2,31 @@ import { useState } from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Header, Text, ListItem, Toggle, Divider, BottomNav } from '../../../shared/components/sketchy';
|
||||
import { useNavigate } from '../../../app/router';
|
||||
import { useAccount } from '../../../shared/context/AccountContext';
|
||||
import { logoutNg } from '../../../shared/utils/ngSession';
|
||||
|
||||
export function SettingsScreen() {
|
||||
const navigate = useNavigate();
|
||||
const { logout } = useAccount();
|
||||
const [notifications, setNotifications] = useState(true);
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const [location, setLocation] = useState(true);
|
||||
|
||||
// Faux logout: clears the perceived login (username) only — the shared
|
||||
// wallet stays open underneath. In staging this returns to ConnexionScreen.
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
// Real logout (HIDDEN): stops the shared wallet session — forces a broker
|
||||
// redirect on next access. Stopgap-only escape hatch.
|
||||
const handleLeaveEnvironment = async () => {
|
||||
await logoutNg();
|
||||
logout();
|
||||
if (typeof window !== 'undefined') window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Header
|
||||
@@ -74,9 +92,16 @@ export function SettingsScreen() {
|
||||
|
||||
<Divider />
|
||||
|
||||
<ListItem onClick={() => navigate('/login')}>
|
||||
<ListItem onClick={handleLogout}>
|
||||
<Text style={{ margin: 0, color: '#E53E3E' }}>Se déconnecter</Text>
|
||||
</ListItem>
|
||||
|
||||
{/* Stopgap escape hatch — real wallet logout, kept discreet. */}
|
||||
<ListItem onClick={handleLeaveEnvironment}>
|
||||
<Text style={{ margin: 0, fontSize: 12, color: '#bbb' }}>
|
||||
Quitter l'environnement de test
|
||||
</Text>
|
||||
</ListItem>
|
||||
</div>
|
||||
|
||||
<BottomNav active="profile" />
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# language: fr
|
||||
@data @multibrowser
|
||||
Fonctionnalité: Harness multi-navigateur — modèles private-wallet et shared-wallet
|
||||
Pour comparer sereinement les deux modèles de wallet (chacun le sien vs partagé)
|
||||
En tant que développeur du stopgap puis de la cible NextGraph
|
||||
Le harness e2e doit piloter plusieurs navigateurs isolés dans un seul scénario,
|
||||
sous l'un OU l'autre modèle de wallet — deux axes orthogonaux.
|
||||
|
||||
# --- Axe machinerie : isolation des contextes (modèle private-wallet) ---
|
||||
|
||||
@private-wallet
|
||||
Scénario: Deux navigateurs avec leur propre wallet ont des stockages locaux indépendants
|
||||
Étant donné un navigateur "A" avec son propre wallet
|
||||
Et un navigateur "B" avec son propre wallet
|
||||
Quand j'écris "valeur-A" sous la clé "sonde" dans le navigateur "A"
|
||||
Alors la clé "sonde" vaut "valeur-A" dans le navigateur "A"
|
||||
Et la clé "sonde" est absente dans le navigateur "B"
|
||||
|
||||
# Le wallet NextGraph vit sur l'origine du broker (nextgraph.net). Ce scénario
|
||||
# prouve l'isolation du stockage LÀ, pas seulement sur l'origine locale.
|
||||
@private-wallet
|
||||
Scénario: Sur l'origine du broker, deux navigateurs private-wallet restent isolés
|
||||
Étant donné un navigateur "A" avec son propre wallet
|
||||
Et un navigateur "B" avec son propre wallet
|
||||
Quand le navigateur "A" charge l'origine du broker
|
||||
Et le navigateur "B" charge l'origine du broker
|
||||
Et j'écris "faux-wallet" sous la clé "ng_probe" dans le navigateur "A"
|
||||
Alors la clé "ng_probe" vaut "faux-wallet" dans le navigateur "A"
|
||||
Et la clé "ng_probe" est absente dans le navigateur "B"
|
||||
|
||||
# --- Axe wallet : provisioning shared-wallet (injection storageState) ---
|
||||
|
||||
# Deux navigateurs distincts portent LE MÊME wallet partagé (injecté au niveau
|
||||
# harness). Tous deux atteignent l'app connectée à NextGraph sans login manuel.
|
||||
@shared-wallet
|
||||
Scénario: Deux navigateurs partageant le wallet se connectent tous deux à NextGraph
|
||||
Étant donné un navigateur "A" avec le wallet partagé
|
||||
Et un navigateur "B" avec le wallet partagé
|
||||
Quand le navigateur "A" charge l'application via le broker
|
||||
Et le navigateur "B" charge l'application via le broker
|
||||
Alors le navigateur "A" est connecté à NextGraph
|
||||
Et le navigateur "B" est connecté à NextGraph
|
||||
|
||||
# --- Distribution produit : import ASSISTÉ (pas d'auto-import zéro-touche) ---
|
||||
#
|
||||
# L'auto-import zéro-touche par l'app est PROUVÉ IMPOSSIBLE avec le broker
|
||||
# hébergé : il n'implémente pas l'import inline pendant l'auth web-app et
|
||||
# renvoie vers nextgraph.eu (cross-origin, non pilotable par Festipod). Voir
|
||||
# concept nextgraph-platform → knowledge_broker-import-constraint et
|
||||
# decision_2026-06-17_assisted-wallet-import.
|
||||
#
|
||||
# PARCOURS HUMAIN COMPLET — exerce la VRAIE app (staging, gate ON) de bout en
|
||||
# bout : Festipod propose le FICHIER du portefeuille → l'humain le télécharge et
|
||||
# l'importe sur nextgraph.eu (« Import a Wallet File » + mot de passe) → revient
|
||||
# → « Entrer » → connecté. Le FICHIER est la primitive correcte (statique,
|
||||
# réutilisable) — le TextCode est un transfert temporaire 5 min, inutilisable à
|
||||
# embarquer (cf. nextgraph-platform → knowledge_broker-import-constraint).
|
||||
# (≠ scénario @shared-wallet ci-dessus, qui INJECTE le wallet via storageState
|
||||
# et court-circuite donc l'import — provisioning de TEST, pas le flux produit.)
|
||||
@shared-wallet @assisted-import @humain
|
||||
Scénario: Parcours humain — le testeur importe le portefeuille fourni par Festipod et se connecte
|
||||
Étant donné un nouveau testeur ouvre Festipod en staging sur un navigateur vierge
|
||||
Alors Festipod affiche l'écran d'accès avec le portefeuille à télécharger
|
||||
Quand le testeur télécharge le portefeuille et l'importe sur nextgraph.eu
|
||||
Et le testeur revient sur Festipod et clique « Entrer »
|
||||
Alors Festipod est connecté et propose de choisir un nom d'utilisateur
|
||||
Quand le testeur choisit un nom d'utilisateur
|
||||
Alors il arrive sur l'accueil de l'application
|
||||
@@ -0,0 +1,28 @@
|
||||
# language: fr
|
||||
@WORKSHOP @priority-1
|
||||
Fonctionnalité: Stopgap multi-store — primitives de données
|
||||
En tant que développeur
|
||||
Je veux valider, contre le vrai broker NextGraph, les primitives du stopgap
|
||||
wallet partagé (création de documents, ORM sur un document créé, aller-retour
|
||||
du sharedWalletShim) avant d'activer le mode multi-document.
|
||||
|
||||
# --- Data (broker réel) ---
|
||||
|
||||
@data
|
||||
Scénario: L'ORM lit et écrit dans un document créé par doc_create
|
||||
Étant donné un nouveau document de graphe est créé dans le wallet partagé
|
||||
Quand j'écris une participation dans ce document via l'ORM
|
||||
Alors la participation est lisible dans ce document
|
||||
|
||||
@data
|
||||
Scénario: Le sharedWalletShim fait l'aller-retour par le wallet
|
||||
Étant donné un compte "@smoketest" est enregistré dans le shim
|
||||
Alors le compte "@smoketest" est retrouvé après rechargement du shim
|
||||
Et le compte "@smoketest" possède trois documents de périmètre distincts
|
||||
|
||||
@data
|
||||
Scénario: Lecture fan-out sur plusieurs documents d'entité (1 doc par entité)
|
||||
Étant donné deux comptes ayant chacun un document d'événement indexé
|
||||
Quand j'écris un événement dans chacun de ces deux documents
|
||||
Alors un abonnement multi-graphes lit les deux événements ensemble
|
||||
Et l'index public liste les deux documents
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
import { pool } from '../../../../shared/support/browserPool';
|
||||
|
||||
// Multi-browser harness steps. Two ORTHOGONAL axes:
|
||||
// - browser count : several named, isolated browsers in one scenario;
|
||||
// - wallet model : 'own' (its own/no wallet) vs 'shared' (THE shared wallet).
|
||||
// The wallet model is carried explicitly by the Given phrasing.
|
||||
// See brief_2026-06-15_shared-wallet-shim.
|
||||
|
||||
Given('un navigateur {string} avec son propre wallet', async function (this: FestipodWorld, name: string) {
|
||||
const handle = await this.openBrowser(name, 'own');
|
||||
// Land on the local harness origin (no NG stack) so localStorage is available.
|
||||
await handle.page.goto(`${pool.harnessUrl}/blank`, { waitUntil: 'domcontentloaded' });
|
||||
});
|
||||
|
||||
Given('un navigateur {string} avec le wallet partagé', async function (this: FestipodWorld, name: string) {
|
||||
const handle = await this.openBrowser(name, 'shared');
|
||||
await handle.page.goto(`${pool.harnessUrl}/blank`, { waitUntil: 'domcontentloaded' });
|
||||
});
|
||||
|
||||
// --- Parcours HUMAIN complet (e2e fidèle) ---
|
||||
// Ouvre la VRAIE app en staging (gate ON), lit le code À L'ÉCRAN, l'importe sur
|
||||
// nextgraph.eu, revient, clique « Entrer » → app connectée. C'est la garantie
|
||||
// que Festipod remet à l'humain un code qui marche. Browser fixe "H".
|
||||
|
||||
Given('un nouveau testeur ouvre Festipod en staging sur un navigateur vierge', async function (this: FestipodWorld) {
|
||||
const url = await pool.ensureStagingApp();
|
||||
(this as any).stagingUrl = url;
|
||||
const handle = await this.openBrowser('H', 'own'); // vierge, AUCUN wallet
|
||||
await handle.page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
// L'écran d'accès (AccessGateScreen) doit s'afficher.
|
||||
await handle.page.getByText('Entrer', { exact: true }).waitFor({ state: 'visible', timeout: 15000 });
|
||||
});
|
||||
|
||||
Then('Festipod affiche l\'écran d\'accès avec le portefeuille à télécharger', async function (this: FestipodWorld) {
|
||||
const page = this.browser('H').page;
|
||||
// Le fichier du portefeuille est proposé au téléchargement…
|
||||
const downloadVisible = await page.locator('[data-testid=shared-wallet-download]').isVisible();
|
||||
expect(downloadVisible, 'le bouton de téléchargement du portefeuille doit être affiché').to.equal(true);
|
||||
// …et le mot de passe partagé est affiché (c'est bien CELUI du wallet e2e).
|
||||
const pwd = (await page.locator('[data-testid=shared-wallet-password]').innerText()).trim();
|
||||
expect(pwd, 'le mot de passe affiché doit être celui du wallet partagé').to.equal(pool.sharedWalletPassword);
|
||||
(this as any).displayedPassword = pwd;
|
||||
});
|
||||
|
||||
When('le testeur télécharge le portefeuille et l\'importe sur nextgraph.eu', async function (this: FestipodWorld) {
|
||||
const page = this.browser('H').page;
|
||||
// Télécharge le fichier DEPUIS l'écran Festipod (le vrai geste humain)…
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
page.locator('[data-testid=shared-wallet-download]').click(),
|
||||
]);
|
||||
const filePath = await download.path();
|
||||
// …et l'importe via "Import a Wallet File" avec le mot de passe lu à l'écran.
|
||||
await pool.importWalletViaFile(page, filePath!, (this as any).displayedPassword);
|
||||
});
|
||||
|
||||
When('le testeur revient sur Festipod et clique « Entrer »', async function (this: FestipodWorld) {
|
||||
const handle = this.browser('H');
|
||||
await handle.page.goto((this as any).stagingUrl, { waitUntil: 'domcontentloaded' });
|
||||
const entrer = handle.page.getByText('Entrer', { exact: true });
|
||||
await entrer.waitFor({ state: 'visible', timeout: 15000 });
|
||||
await entrer.click(); // déclenche le redirect vers le broker
|
||||
await handle.page.waitForURL('**nextgraph**', { timeout: 20000 }).catch(() => {});
|
||||
// Le broker demande de déverrouiller le wallet fraîchement importé → son mot de
|
||||
// passe (completeBrokerLogin attend la page de login wallet de façon robuste).
|
||||
handle.appFrame = await pool.completeBrokerLogin(handle.page, (this as any).stagingUrl, pool.sharedWalletPassword);
|
||||
});
|
||||
|
||||
Then('Festipod est connecté et propose de choisir un nom d\'utilisateur', async function (this: FestipodWorld) {
|
||||
const handle = this.browser('H');
|
||||
expect(handle.appFrame, 'l\'app doit être chargée dans l\'iframe broker').to.not.equal(null);
|
||||
// Connecté → AuthGate passe l'AccessGateScreen et montre ConnexionScreen.
|
||||
await handle.appFrame!.waitForFunction(
|
||||
() => /Choisissez votre nom d'utilisateur|Connexion/.test(document.body?.innerText ?? ''),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
});
|
||||
|
||||
When('le testeur choisit un nom d\'utilisateur', async function (this: FestipodWorld) {
|
||||
const frame = this.browser('H').appFrame!;
|
||||
await frame.locator('input[placeholder="@votrepseudo"]').fill('@testeur');
|
||||
await frame.getByRole('button', { name: 'Se connecter' }).click();
|
||||
});
|
||||
|
||||
Then('il arrive sur l\'accueil de l\'application', async function (this: FestipodWorld) {
|
||||
const frame = this.browser('H').appFrame!;
|
||||
// On atterrit sur /home (l'app), PAS sur l'onboarding hors-connexion ('/'
|
||||
// WelcomeScreen « Rejoindre la communauté »).
|
||||
await frame.waitForFunction(
|
||||
() => window.location.pathname.endsWith('/home') &&
|
||||
!(document.body?.innerText ?? '').includes('Rejoindre la communauté'),
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
});
|
||||
|
||||
When('le navigateur {string} charge l\'application via le broker', async function (this: FestipodWorld, name: string) {
|
||||
// Drive the named browser through the broker into the NG harness iframe.
|
||||
// A 'shared' browser carries the wallet (storageState) → the broker recognises
|
||||
// it; an 'own' browser has none → the broker cannot reach the app.
|
||||
await this.loadAppInBrowser(name, 'harness');
|
||||
});
|
||||
|
||||
Then('le navigateur {string} est connecté à NextGraph', async function (this: FestipodWorld, name: string) {
|
||||
const handle = this.browser(name);
|
||||
expect(handle.appFrame, `le navigateur ${name} doit avoir chargé l'app`).to.not.equal(null);
|
||||
// __testData.ready flips true only once the NG session is connected.
|
||||
await handle.appFrame!.waitForFunction(
|
||||
() => (window as any).__testData?.ready === true,
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
});
|
||||
|
||||
When('le navigateur {string} charge l\'origine du broker', async function (this: FestipodWorld, name: string) {
|
||||
// Navigate top-level to the broker origin (nextgraph.net) — the exact origin
|
||||
// where the NG wallet localStorage lives. Each fresh context has its own
|
||||
// storage partition there too.
|
||||
await this.browser(name).page.goto(pool.brokerOrigin, { waitUntil: 'domcontentloaded' });
|
||||
});
|
||||
|
||||
When(
|
||||
'j\'écris {string} sous la clé {string} dans le navigateur {string}',
|
||||
async function (this: FestipodWorld, value: string, key: string, name: string) {
|
||||
await this.browser(name).page.evaluate(
|
||||
([k, v]) => localStorage.setItem(k, v),
|
||||
[key, value] as [string, string],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Then(
|
||||
'la clé {string} vaut {string} dans le navigateur {string}',
|
||||
async function (this: FestipodWorld, key: string, expected: string, name: string) {
|
||||
const actual = await this.browser(name).page.evaluate((k) => localStorage.getItem(k), key);
|
||||
expect(actual, `localStorage["${key}"] dans le navigateur ${name}`).to.equal(expected);
|
||||
},
|
||||
);
|
||||
|
||||
Then(
|
||||
'la clé {string} est absente dans le navigateur {string}',
|
||||
async function (this: FestipodWorld, key: string, name: string) {
|
||||
const actual = await this.browser(name).page.evaluate((k) => localStorage.getItem(k), key);
|
||||
expect(actual, `localStorage["${key}"] dans le navigateur ${name} doit être isolé`).to.equal(null);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
// Data-layer validation of the shared-wallet multi-store stopgap, against the
|
||||
// REAL broker. Exercises the exact app mechanisms: doc_create, a real
|
||||
// useShape({graphs}) on the created doc (window.__smoke, via <SmokeProbe>), and
|
||||
// the sharedWalletShim round-trip (window.__testData.validateShim).
|
||||
// See brief_2026-06-15_shared-wallet-shim.
|
||||
|
||||
// --- Scenario 1: ORM on a doc_create'd document ---
|
||||
|
||||
Given('un nouveau document de graphe est créé dans le wallet partagé', async function (this: FestipodWorld) {
|
||||
const nuri = await this.appFrame!.evaluate(async () => {
|
||||
return await (window as any).__testData.createSmokeDoc();
|
||||
});
|
||||
expect(nuri, 'doc_create should return a NURI').to.be.a('string');
|
||||
expect((nuri as string).length, 'doc_create NURI should be non-empty').to.be.greaterThan(0);
|
||||
// Wait for <SmokeProbe> to mount the useShape({graphs}) and expose __smoke.
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => (window as any).__smoke?.ready === true,
|
||||
null,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
});
|
||||
|
||||
When('j\'écris une participation dans ce document via l\'ORM', async function (this: FestipodWorld) {
|
||||
await this.appFrame!.evaluate(() => (window as any).__smoke.add());
|
||||
});
|
||||
|
||||
Then('la participation est lisible dans ce document', async function (this: FestipodWorld) {
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => (window as any).__smoke.count() >= 1,
|
||||
null,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
const items = await this.appFrame!.evaluate(() => (window as any).__smoke.items());
|
||||
expect(items.length, 'participation should be readable via ORM on the created doc').to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
// --- Scenario 2: sharedWalletShim round-trip ---
|
||||
|
||||
Given('un compte {string} est enregistré dans le shim', async function (this: FestipodWorld, username: string) {
|
||||
const res = await this.appFrame!.evaluate(
|
||||
async (u) => await (window as any).__testData.validateShim(u),
|
||||
username,
|
||||
);
|
||||
(this as any).shimResult = res;
|
||||
expect(res?.created, 'ensureAccount should return a record').to.exist;
|
||||
});
|
||||
|
||||
Then('le compte {string} est retrouvé après rechargement du shim', function (this: FestipodWorld, username: string) {
|
||||
const res = (this as any).shimResult;
|
||||
expect(res?.reloaded, `account ${username} should reload from the wallet shim`).to.exist;
|
||||
expect(res.reloaded.username).to.equal(username);
|
||||
});
|
||||
|
||||
Then('le compte {string} possède trois documents de périmètre distincts', function (this: FestipodWorld, _username: string) {
|
||||
const r = (this as any).shimResult?.reloaded;
|
||||
expect(r, 'reloaded account should exist').to.exist;
|
||||
const docs = [r.docPublic, r.docProtected, r.docPrivate];
|
||||
for (const d of docs) {
|
||||
expect(d, 'each scope doc should be a string').to.be.a('string');
|
||||
expect((d as string).length, 'each scope doc NURI should be non-empty').to.be.greaterThan(0);
|
||||
}
|
||||
expect(new Set(docs).size, 'the three scope docs must be distinct').to.equal(3);
|
||||
});
|
||||
|
||||
// --- Scenario 3: per-entity granularity + multi-graph fan-out ---
|
||||
|
||||
Given('deux comptes ayant chacun un document d\'événement indexé', async function (this: FestipodWorld) {
|
||||
const res = await this.appFrame!.evaluate(async () => await (window as any).__testData.setupFanout());
|
||||
(this as any).fanout = res;
|
||||
expect(res.docA, 'event doc A').to.be.a('string');
|
||||
expect(res.docB, 'event doc B').to.be.a('string');
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => (window as any).__fanout?.ready === true,
|
||||
null,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
});
|
||||
|
||||
When('j\'écris un événement dans chacun de ces deux documents', async function (this: FestipodWorld) {
|
||||
const { docA, docB } = (this as any).fanout;
|
||||
await this.appFrame!.evaluate(
|
||||
([a, b]: [string, string]) => {
|
||||
(window as any).__fanout.addEventTo(a, 'FanA');
|
||||
(window as any).__fanout.addEventTo(b, 'FanB');
|
||||
},
|
||||
[docA, docB] as [string, string],
|
||||
);
|
||||
});
|
||||
|
||||
Then('un abonnement multi-graphes lit les deux événements ensemble', async function (this: FestipodWorld) {
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => (window as any).__fanout.count() >= 2,
|
||||
null,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
const titles = await this.appFrame!.evaluate(() => (window as any).__fanout.titles());
|
||||
expect(titles, 'fan-out should read event from doc A').to.include('FanA');
|
||||
expect(titles, 'fan-out should read event from doc B').to.include('FanB');
|
||||
});
|
||||
|
||||
Then('l\'index public liste les deux documents', function (this: FestipodWorld) {
|
||||
const { docA, docB, listed } = (this as any).fanout;
|
||||
expect(listed, 'public index should list doc A').to.include(docA);
|
||||
expect(listed, 'public index should list doc B').to.include(docB);
|
||||
});
|
||||
@@ -5,8 +5,6 @@
|
||||
import { HomeScreen } from '../modules/home/screens/HomeScreen';
|
||||
import { SettingsScreen } from '../modules/home/screens/SettingsScreen';
|
||||
|
||||
// Auth module
|
||||
import { LoginScreen } from '../modules/auth/screens/LoginScreen';
|
||||
import { WelcomeScreen } from '../modules/auth/screens/WelcomeScreen';
|
||||
|
||||
// Event module
|
||||
@@ -34,7 +32,6 @@ export interface Screen {
|
||||
|
||||
export const screens: Screen[] = [
|
||||
{ id: 'welcome', name: 'Bienvenue', path: '/', component: WelcomeScreen },
|
||||
{ id: 'login', name: 'Connexion', path: '/login', component: LoginScreen },
|
||||
{ id: 'home', name: 'Accueil', path: '/home', component: HomeScreen },
|
||||
{ id: 'events', name: 'Découvrir', path: '/events', component: EventsScreen },
|
||||
{ id: 'create-event', name: 'Relayer événement', path: '/events/new', component: CreateEventScreen },
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -17,18 +17,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 };
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -11,7 +11,8 @@ import React, { useEffect, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { NextGraphProvider, useNextGraph } from '../context/NextGraphContext';
|
||||
import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataContext';
|
||||
import { useShape } from '@ng-eventually/client';
|
||||
// useShape + ng routed through the lib (SDK-identical surface); caps from /polyfill.
|
||||
import { useShape, ng } from '@ng-eventually/client';
|
||||
import { getCaps, setCurrentUser, resetCaps } from '@ng-eventually/client/polyfill';
|
||||
import type { DeepSignalSet } from '@ng-eventually/client';
|
||||
import {
|
||||
@@ -71,6 +72,11 @@ function ConnectedHarness() {
|
||||
// Read-filter validation: once a ReadCap policy is active, <FilterProbe> mounts
|
||||
// a useShape that returns the read-filtered VIEW.
|
||||
const [filterActive, setFilterActive] = 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
|
||||
@@ -151,6 +157,8 @@ function ConnectedHarness() {
|
||||
return bootstrapWallet(events as any, users as any, participations as any);
|
||||
},
|
||||
|
||||
// --- ReadCap read-filter validation (see decision_2026-06-17_eventually-library) ---
|
||||
|
||||
/** The document (repo NURI) all wallet entities live in (mono-store). */
|
||||
documentNuri: privateNuri,
|
||||
|
||||
@@ -172,6 +180,54 @@ function ConnectedHarness() {
|
||||
setUser(user: string) {
|
||||
setCurrentUser(user);
|
||||
},
|
||||
|
||||
// --- 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,
|
||||
@@ -187,6 +243,8 @@ function ConnectedHarness() {
|
||||
<>
|
||||
<div id="harness-status">{bridgeReady ? 'READY' : 'LOADING_SHAPES'}</div>
|
||||
{filterActive && privateNuri && <FilterProbe privateNuri={privateNuri} />}
|
||||
{smokeDoc && <SmokeProbe docNuri={smokeDoc} />}
|
||||
{fanoutGraphs.length > 0 && <FanoutProbe graphs={fanoutGraphs} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -211,6 +269,63 @@ function FilterProbe({ privateNuri }: { privateNuri: string }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Bootstrap
|
||||
// ============================================================================
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -60,6 +60,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user