Code against the polyfill's published contract, and nothing else
The data layer is now reached through one pulled, version-pinned engagement (`.project/concepts/data-layer/contract_polyfill-surface.md`, @1ecf511e9d). That copy is the only reference: the provider's sources are never opened, and what the contract does not answer is a gap raised with it, never worked around here. Surface - `@ng-eventually/sdk` -> `@ng-eventually/polyfill`, one entry point. - `configure` loses `getSession`, `normalizeId`, `currentUser`; the session belongs to the package and its own `init` captures it. - Placement is named by scope alone -- a session is one user, so the app no longer passes an identity it had no way to obtain. This removes a constant that made every user collide on one owner's document. - `init(...)` then `await ensureIdentity()`, in that order, as one sequence: React runs child effects first, so the two calls sat in the wrong order and the contract now makes that throw. - `sessionId` relayed as `string | number`, `materialize` -> `read`. A rejection means "unknown", never "absent" Four places treated a caught error as an empty result. The worst wrote a duplicate participation: an unknown count read as zero defeated the idempotence guard of `joinEvent`. Also fixed: a per-document count, a silently dropped notification shown optimistically anyway, and a failed listing that left the owned-event set empty and disabled the materializer for the whole session. Shared identity is not a Festipod notion A browser context is one user. The per-scenario identity plant is deleted at its source and its five sites; what stays is the deployment's wallet file, which the contract requires an application to serve. Documentation The doctrine no longer describes how the data layer works underneath: five leaves whose subject was internals are gone, a dozen more are re-founded on the contract's own words, and two frozen arbitrations about a deleted screen were removed rather than left to mislead a future session. Test harness It can sign in at last: cucumber runs under node, which does not load `.env`, so the harness never received the wallet material and every scenario silently fell back to an empty local mode. A failed sign-in is now loud on both sides. The suite also releases what it opens and exits on its own -- runs were still resident hours after reporting, holding a browser and two servers. Known red: `@data` cannot be measured. The served wallet accumulates and nothing resets it; moving the browser profile aside does not, since the data lives in the wallet file, not the profile.
This commit is contained in:
+10
-13
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
@@ -57,18 +56,16 @@ export function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<NextGraphProvider>
|
||||
<AccountProvider>
|
||||
<FestipodDataProvider>
|
||||
<RouterProvider>
|
||||
<div className="app-container">
|
||||
<AuthGate>
|
||||
<AppContent />
|
||||
</AuthGate>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</RouterProvider>
|
||||
</FestipodDataProvider>
|
||||
</AccountProvider>
|
||||
<FestipodDataProvider>
|
||||
<RouterProvider>
|
||||
<div className="app-container">
|
||||
<AuthGate>
|
||||
<AppContent />
|
||||
</AuthGate>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</RouterProvider>
|
||||
</FestipodDataProvider>
|
||||
</NextGraphProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
+82
-70
@@ -1,89 +1,101 @@
|
||||
/**
|
||||
* AuthGate — the stopgap access flow (see decision_2026-06-15_shared-wallet-login-flow):
|
||||
* 1. Access barrier + identifier (AccessGateScreen) → the user names their
|
||||
* virtual space (an identifier) and opens the SHARED wallet via the broker
|
||||
* redirect (with the wallet file + guide it hands the user). Naming the
|
||||
* space and opening it are ONE act.
|
||||
* 2. The app.
|
||||
* AuthGate — the ONE await the application makes before it renders.
|
||||
*
|
||||
* 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.
|
||||
* Signing in is `ensureIdentity()` and nothing else: it takes no identifier,
|
||||
* resolves who we are and does the connection work (restoring what others shared
|
||||
* with us, draining our inboxes). Whatever a user has to see or do while that
|
||||
* resolves — opening the wallet, loading it onto a first-time device — belongs to
|
||||
* the SDK: it mounts a full-screen barrier of its own on every top-level load and
|
||||
* takes it down itself, and it owns the return from the broker round-trip (the
|
||||
* barrier comes back prefilled, and confirming it hands the page over a second
|
||||
* time; our page is never reloaded and nothing outside the barrier is touched).
|
||||
* Festipod renders no access screen of its own and re-drives nothing.
|
||||
*
|
||||
* TWO CALLS, IN ORDER, AND THE ORDER IS CONTRACTUAL: start the session (`init`),
|
||||
* then await `ensureIdentity()`. A session arrives only through `init`, and
|
||||
* `ensureIdentity()` awaited before it has been called THROWS. The order is a fact
|
||||
* of the statement sequence below, not of React's effect ordering — which would
|
||||
* get it wrong: this gate's effect runs BEFORE its parent provider's.
|
||||
*
|
||||
* It hands back WHO WE ARE, and this is the app's only upstream answer to that
|
||||
* question — everything else it knows about the user it has to read first. The
|
||||
* value is published for display (`shared/utils/currentPrincipal`) and goes
|
||||
* nowhere near a data call: no call takes an identity, because the session
|
||||
* already belongs to one user.
|
||||
*
|
||||
* Nothing of the app renders before that await settles: a screen mounted earlier
|
||||
* would read as an identity that is not yet settled.
|
||||
*
|
||||
* AND NOTHING RENDERS IF IT FAILS. A rejected `ensureIdentity()` is not a mode the
|
||||
* app degrades through: an app that could not sign in but still shows its screens
|
||||
* is indistinguishable from an app whose user simply owns nothing — a total
|
||||
* failure wearing the face of an empty account. So the rejection is SHOWN, and the
|
||||
* children stay unmounted, which is also what keeps the data layer from settling
|
||||
* on its empty stand-in provider for the rest of the session.
|
||||
*/
|
||||
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { useNextGraph } from '../shared/context/NextGraphContext';
|
||||
import { useAccount, normalizeIdentifier } from '../shared/context/AccountContext';
|
||||
import { AccessGateScreen } from '../modules/auth/screens/AccessGateScreen';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { ensureIdentity } from '@ng-eventually/polyfill';
|
||||
import { startNgSession } from '../shared/utils/ngSession';
|
||||
import { setCurrentPrincipal } from '../shared/utils/currentPrincipal';
|
||||
import { useRouter, useNavigate } from './router';
|
||||
|
||||
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 { identifier, login } = useAccount();
|
||||
const { route } = useRouter();
|
||||
const navigate = useNavigate();
|
||||
// Whether the ONE identity await has resolved.
|
||||
const [identityReady, setIdentityReady] = useState(false);
|
||||
// Why it did NOT resolve. Set once, never cleared: signing in is attempted once.
|
||||
const [signInError, setSignInError] = useState<string | null>(null);
|
||||
|
||||
// Once connected AND identified, leave the disconnected welcome screen for the
|
||||
// app home. The identifier is now set at the barrier (before the broker
|
||||
// round-trip), so on return the app can land on '/' with a session already
|
||||
// open; the removed ConnexionScreen used to do this navigate on login.
|
||||
useEffect(() => {
|
||||
if (!GATE_DISABLED && status === 'connected' && identifier && route.page === 'welcome') {
|
||||
let cancelled = false;
|
||||
// FIRST — a session arrives only through the SDK's `init`. Idempotent, so the
|
||||
// provider above may have started it already; what matters is that it has been
|
||||
// called before the await below, or the await throws.
|
||||
void startNgSession();
|
||||
void ensureIdentity()
|
||||
.then(principal => {
|
||||
// Publish who we are BEFORE anything renders — the identity is a fact of
|
||||
// the session, not state of this component, so it is recorded even if the
|
||||
// effect was torn down in between.
|
||||
setCurrentPrincipal(principal);
|
||||
if (!cancelled) setIdentityReady(true);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[Auth] ensureIdentity failed:', err);
|
||||
if (!cancelled) setSignInError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Once identified, leave the disconnected welcome screen for the app home.
|
||||
useEffect(() => {
|
||||
if (identityReady && route.page === 'welcome') {
|
||||
navigate('/home');
|
||||
}
|
||||
}, [status, identifier, route.page, navigate]);
|
||||
}, [identityReady, route.page, navigate]);
|
||||
|
||||
// Gate explicitly disabled (no-gate build / @e2e harness) → straight to app.
|
||||
if (GATE_DISABLED) {
|
||||
return <>{children}</>;
|
||||
// Signing in FAILED — say so. The app has nothing legitimate to show, and
|
||||
// showing it anyway would pass a broken session off as an empty one.
|
||||
if (signInError) {
|
||||
return (
|
||||
<div id="auth-error" role="alert" className="app-card" style={{ margin: '2rem 1rem' }}>
|
||||
<h1 className="app-title">Connexion impossible</h1>
|
||||
<p className="app-text">
|
||||
Festipod n’a pas réussi à vous connecter. Rien ne peut s’afficher tant que
|
||||
la connexion n’a pas abouti — les écrans seraient vides sans le dire.
|
||||
</p>
|
||||
<p className="app-text" data-testid="auth-error-detail">{signInError}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Access barrier — shown until BOTH the wallet is open AND the space is named.
|
||||
// "Entrer" records the identifier (persisted to localStorage AND written into
|
||||
// the `?id=` URL param, which is what actually survives the broker redirect
|
||||
// across the partitioned frontier) and, if the wallet isn't open yet, triggers
|
||||
// the connect.
|
||||
//
|
||||
// On return (reload / broker round-trip) the identifier is already stored, so
|
||||
// we PREFILL the field with it (`initialIdentifier`) — the user never sees a
|
||||
// bare empty prompt they must re-type. It is captured ONCE, at first access.
|
||||
if (status !== 'connected' || !identifier) {
|
||||
const onEnter = (entered: string) => {
|
||||
login(entered);
|
||||
// Carry the identifier across the broker frontier via the URL. localStorage
|
||||
// is partitioned by top-level site, so the value written here (127.0.0.1)
|
||||
// is NOT what the app reads inside the broker iframe (nextgraph.net). The
|
||||
// `@ng-org/web` redirect embeds the FULL app URL (query included) in the
|
||||
// broker `o=`, which is reloaded in the iframe — so writing the normalized
|
||||
// id into `?id=` BEFORE connect() makes it travel. `history.replaceState`
|
||||
// (not push) keeps a single history entry. See AccountContext resolution.
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('id', normalizeIdentifier(entered));
|
||||
window.history.replaceState(window.history.state, '', url.toString());
|
||||
} catch {
|
||||
/* URL construction can't fail for a real page URL; ignore defensively */
|
||||
}
|
||||
}
|
||||
if (status !== 'connected') connect();
|
||||
};
|
||||
return (
|
||||
<AccessGateScreen
|
||||
status={status}
|
||||
error={error}
|
||||
initialIdentifier={identifier ?? ''}
|
||||
onEnter={onEnter}
|
||||
/>
|
||||
);
|
||||
// Signing in is not settled yet. Render NOTHING — the SDK's own full-screen
|
||||
// barrier is what is on screen, it put it there and it takes it down. Anything
|
||||
// of ours here would be a second thing competing with it.
|
||||
if (!identityReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The app.
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ const server = serve({
|
||||
autoSeed: process.env.FESTIPOD_AUTO_SEED ?? "",
|
||||
}),
|
||||
|
||||
// The shared wallet file (download target of the access barrier), when configured.
|
||||
// The shared wallet file — the `fileUrl` the app hands the SDK, when configured.
|
||||
"/shared-wallet.ngw": async () => {
|
||||
const p = process.env.FESTIPOD_SHARED_WALLET_FILE;
|
||||
if (p) {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# language: fr
|
||||
@AUTH @priority-1
|
||||
Fonctionnalité: Barrière d'accès — l'identifiant se saisit une seule fois
|
||||
En tant qu'utilisateur qui revient dans Festipod
|
||||
Je veux retrouver l'identifiant que j'ai déjà choisi, pré-rempli
|
||||
Afin de ne jamais avoir à le retaper à l'arrivée
|
||||
|
||||
# Garde-fou contre la régression rapportée : au retour (rechargement / round-trip
|
||||
# broker) la barrière re-demandait un identifiant NU et VIDE alors qu'il était
|
||||
# déjà stocké. L'identifiant est capturé UNE FOIS au premier accès, persisté,
|
||||
# puis pré-rempli. Voir AuthGate + AccessGateScreen.
|
||||
|
||||
@ui
|
||||
Scénario: Le champ identifiant est pré-rempli avec la valeur déjà stockée
|
||||
Étant donné que la barrière d'accès s'affiche avec l'identifiant stocké "alice"
|
||||
Alors le champ identifiant contient "alice"
|
||||
|
||||
@ui
|
||||
Scénario: Un premier accès sans identifiant stocké affiche un champ vide
|
||||
Étant donné que la barrière d'accès s'affiche sans identifiant stocké
|
||||
Alors le champ identifiant est vide
|
||||
|
||||
@ui
|
||||
Scénario: Entrer remonte l'identifiant saisi
|
||||
Étant donné que la barrière d'accès s'affiche avec l'identifiant stocké "alice"
|
||||
Quand je clique sur "Entrer" dans la barrière
|
||||
Alors l'identifiant remonté à l'application est "alice"
|
||||
@@ -6,10 +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
|
||||
|
||||
# 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.
|
||||
# NB : Festipod n'affiche plus d'écran d'accès à lui. Se connecter, c'est le
|
||||
# SEUL `ensureIdentity()` attendu par AuthGate ; ce qu'un utilisateur voit ou
|
||||
# fait pendant cette attente appartient au SDK, qui le montre. Aucun scénario
|
||||
# ici ne pilote donc une barrière d'accès.
|
||||
|
||||
# --- Data layer: comportement du portefeuille ---
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# language: fr
|
||||
@AUTH @priority-1
|
||||
Fonctionnalité: Résolution de l'identifiant — le param d'URL prime sur localStorage
|
||||
En tant qu'application relancée dans l'iframe du broker après le round-trip
|
||||
Je veux résoudre l'identifiant depuis le param d'URL "?id="
|
||||
Afin qu'il traverse la frontière top-level↔iframe (que localStorage ne franchit pas)
|
||||
|
||||
# Le flux wallet-partagé fait tourner l'app dans DEUX contextes avec DEUX
|
||||
# partitions localStorage distinctes (top-level 127.0.0.1 vs iframe
|
||||
# nextgraph.net). localStorage ne traverse pas la frontière ; le param "?id="
|
||||
# embarqué dans le redirect broker (o=) la traverse. AccountContext résout donc
|
||||
# dans l'ordre : (1) param d'URL "?id=" (source de vérité) ; (2) sinon
|
||||
# localStorage (préremplissage même-partition). Voir AccountContext + AuthGate.
|
||||
|
||||
@ui
|
||||
Scénario: Le param d'URL est la source de vérité quand il est présent
|
||||
Étant donné que localStorage contient l'identifiant "alice"
|
||||
Et que l'URL porte le param id "bob"
|
||||
Quand le contexte de compte résout l'identifiant
|
||||
Alors l'identifiant résolu est "bob"
|
||||
|
||||
@ui
|
||||
Scénario: Le param d'URL prime même sur une valeur localStorage différente et est persisté
|
||||
Étant donné que localStorage contient l'identifiant "alice"
|
||||
Et que l'URL porte le param id "carol"
|
||||
Quand le contexte de compte résout l'identifiant
|
||||
Alors l'identifiant résolu est "carol"
|
||||
Et localStorage contient désormais l'identifiant "carol"
|
||||
|
||||
@ui
|
||||
Scénario: Sans param d'URL, localStorage sert de repli
|
||||
Étant donné que localStorage contient l'identifiant "dave"
|
||||
Et que l'URL ne porte aucun param id
|
||||
Quand le contexte de compte résout l'identifiant
|
||||
Alors l'identifiant résolu est "dave"
|
||||
|
||||
@ui
|
||||
Scénario: Le param d'URL est normalisé (minuscules, @ retiré)
|
||||
Étant donné que localStorage ne contient aucun identifiant
|
||||
Et que l'URL porte le param id "@Erin"
|
||||
Quand le contexte de compte résout l'identifiant
|
||||
Alors l'identifiant résolu est "erin"
|
||||
@@ -1,183 +0,0 @@
|
||||
/**
|
||||
* AccessGateScreen — the *technical access barrier* of the stopgap.
|
||||
*
|
||||
* SHARED WALLET IS THE SOLE SUPPORTED MODE. Festipod does not function without
|
||||
* the shared wallet (the SDK polyfill runs on it). "No shared wallet configured"
|
||||
* is therefore NOT an offered flow — it is a loud MISCONFIGURATION error
|
||||
* (`!hasSharedWallet()` → a config-error block, no functional form). Configure it
|
||||
* via FESTIPOD_SHARED_WALLET_PASSWORD.
|
||||
*
|
||||
* 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. The user also types an IDENTIFIER here — the id that names their
|
||||
* virtual space (a technical id, a pseudo in practice, NOT a Festipod profile
|
||||
* handle like `@mariedupont`).
|
||||
* Clicking "Entrer" records that identifier and triggers `connect()`, which
|
||||
* redirects to the broker to open the SHARED wallet. After return the identity
|
||||
* is already set (persisted before the redirect), so NG auto-connects straight
|
||||
* into the app — there is no separate "choose a handle" screen.
|
||||
*
|
||||
* ASSISTED IMPORT (see 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. This
|
||||
* assisted flow is the default whenever the shared wallet is open pending.
|
||||
*/
|
||||
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Button, Input, 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;
|
||||
/**
|
||||
* The identifier already stored for this space (the persisted one), used to
|
||||
* PREFILL the field so a returning user never re-types it. Empty on a truly
|
||||
* first access. Normalized upstream; shown verbatim.
|
||||
*/
|
||||
initialIdentifier?: string;
|
||||
/** Enter the space: the raw identifier the user typed (normalized upstream). */
|
||||
onEnter: (identifier: string) => 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, initialIdentifier, onEnter }: AccessGateScreenProps) {
|
||||
const connecting = status === 'connecting';
|
||||
const [copied, setCopied] = useState(false);
|
||||
// The identifier that names this virtual space (a technical id — a pseudo in
|
||||
// practice, but not a Festipod profile handle). Entered HERE, at wallet access, so a
|
||||
// single act both names the space and opens it. Normalized (lowercased) upstream.
|
||||
// PREFILLED from the stored identifier so a returning user (reload / broker
|
||||
// round-trip) sees the value they already chose and never re-types it.
|
||||
const [identifier, setIdentifier] = useState(initialIdentifier ?? '');
|
||||
|
||||
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 canEnter = !connecting && identifier.trim().length > 0;
|
||||
const enter = () => { if (canEnter) onEnter(identifier); };
|
||||
|
||||
// Identifier field + Entrer: naming the space and opening it are one act.
|
||||
const entrer = (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<Input
|
||||
data-testid="identifier-input"
|
||||
placeholder="votre identifiant"
|
||||
value={identifier}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setIdentifier(e.target.value)}
|
||||
onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter') enter(); }}
|
||||
/>
|
||||
<Text style={{ margin: 0, fontSize: 12, color: '#999' }}>
|
||||
Il identifie votre espace (mis en minuscules).
|
||||
</Text>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={enter}
|
||||
disabled={!canEnter}
|
||||
style={{ width: '100%', opacity: canEnter ? 1 : 0.6 }}
|
||||
>
|
||||
{connecting ? 'Accès en cours…' : 'Entrer'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
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() ? (
|
||||
// SOLE-MODE guard: Festipod cannot run without the shared wallet, so a
|
||||
// missing one is a misconfiguration, NOT a functional login form.
|
||||
<Text style={{ textAlign: 'center', fontSize: 14, color: '#c92a2a', lineHeight: 1.5, margin: '0 0 12px' }}>
|
||||
Portefeuille partagé non configuré. Festipod ne fonctionne pas sans
|
||||
(définir <code>FESTIPOD_SHARED_WALLET_PASSWORD</code>).
|
||||
</Text>
|
||||
) : status !== 'connected' ? (
|
||||
<>
|
||||
<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 ici, choisissez un identifiant 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>
|
||||
);
|
||||
}
|
||||
@@ -5,18 +5,15 @@ import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
// --- Setup ---
|
||||
|
||||
Given('le portefeuille est vide', async function (this: FestipodWorld) {
|
||||
// Each @data scenario runs under a UNIQUE identifier (see hooks.ts
|
||||
// freshScenarioIdentifier), so the shim hands it a FRESH, EMPTY virtual wallet:
|
||||
// "le portefeuille est vide" is trivially true on entry. So this is a fast
|
||||
// INSTANT CHECK — assert the reactive read already shows nothing — NOT the old
|
||||
// `clearWallet` per-entity-doc fan-out (a full physical-wallet enumeration that
|
||||
// was itself slow). No mutation, no polling: a fresh wallet has no docs to scan.
|
||||
// A fast INSTANT CHECK — assert the reactive read already shows nothing — NOT
|
||||
// the old `clearWallet` per-entity-doc fan-out (a full wallet enumeration that
|
||||
// was itself slow). No mutation, no polling.
|
||||
const counts = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return { events: td.events.size, users: td.users.size };
|
||||
});
|
||||
expect(counts.events, 'Fresh virtual wallet should have no events').to.equal(0);
|
||||
expect(counts.users, 'Fresh virtual wallet should have no users').to.equal(0);
|
||||
expect(counts.events, 'An empty wallet should have no events').to.equal(0);
|
||||
expect(counts.users, 'An empty wallet should have no users').to.equal(0);
|
||||
});
|
||||
|
||||
Given('le portefeuille contient déjà des événements', async function (this: FestipodWorld) {
|
||||
@@ -30,9 +27,12 @@ Given('le portefeuille contient déjà des événements', async function (this:
|
||||
const td = (window as any).__testData;
|
||||
td.loadTestData();
|
||||
});
|
||||
// Wait for data to propagate
|
||||
// Wait for data to propagate. `waitForFunction(fn, arg, options)` — the
|
||||
// timeout goes in the THIRD slot; passed second it is silently taken as the
|
||||
// predicate's argument and the wait runs on the 30s default instead.
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => (window as any).__testData.events.size > 0,
|
||||
undefined,
|
||||
{ timeout: 75000 },
|
||||
);
|
||||
}
|
||||
@@ -72,6 +72,7 @@ When('je charge les données de test', async function (this: FestipodWorld) {
|
||||
const td = (window as any).__testData;
|
||||
return td.events.size > 0 && td.users.size > 0;
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 75000 },
|
||||
).catch(() => {
|
||||
// Timeout tolerated — the assertions below surface the real failure with a
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* @ui steps for the access barrier (AccessGateScreen).
|
||||
*
|
||||
* These render the prop-driven AccessGateScreen directly (via renderElement) —
|
||||
* it is NOT a registry/route screen, its state comes from props (status,
|
||||
* initialIdentifier, onEnter). We assert on the rendered DOM: the identifier
|
||||
* field is PREFILLED from the stored value, and "Entrer" reports the identifier.
|
||||
*
|
||||
* Guards the reported regression: on return the barrier used to re-ask for a
|
||||
* bare, empty identifier despite one being stored. See AuthGate.tsx.
|
||||
*
|
||||
* SHARED WALLET IS THE SOLE SUPPORTED MODE (see AccessGateScreen header): the
|
||||
* identifier field lives INSIDE the assisted-import flow, which renders only when
|
||||
* a shared wallet is configured; otherwise the barrier shows a config-error with
|
||||
* NO field. Production always configures one, but the @ui node harness does not
|
||||
* inject the build global, so we set it HERE — before the screen module is first
|
||||
* imported, so `sharedWallet.ts` captures it at module-eval — and lazy-import the
|
||||
* screen. This file is the only @ui module that reaches sharedWallet.ts, so this
|
||||
* ordering is deterministic.
|
||||
*/
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import React from 'react';
|
||||
import { renderElement } from '../../../../shared/test-harness/renderHelper';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__ = 'test-shared-wallet';
|
||||
|
||||
// Lazy so sharedWallet.ts evaluates AFTER the global above is set (a static
|
||||
// import would hoist above it, capturing an empty password → config-error).
|
||||
type Gate = typeof import('../../screens/AccessGateScreen')['AccessGateScreen'];
|
||||
let gateComponent: Gate | null = null;
|
||||
async function loadGate(): Promise<Gate> {
|
||||
if (!gateComponent) {
|
||||
gateComponent = (await import('../../screens/AccessGateScreen')).AccessGateScreen;
|
||||
}
|
||||
return gateComponent;
|
||||
}
|
||||
|
||||
// Local per-scenario state (kept off the World to avoid touching its type).
|
||||
interface GateState {
|
||||
doc: Document | null;
|
||||
entered: string | null;
|
||||
}
|
||||
const gateStates = new WeakMap<object, GateState>();
|
||||
function stateFor(world: object): GateState {
|
||||
let s = gateStates.get(world);
|
||||
if (!s) {
|
||||
s = { doc: null, entered: null };
|
||||
gateStates.set(world, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
async function renderGate(world: object, initialIdentifier?: string): Promise<void> {
|
||||
const s = stateFor(world);
|
||||
s.entered = null;
|
||||
const AccessGateScreen = await loadGate();
|
||||
// 'connecting' would disable the button; 'disconnected' is the returning-user
|
||||
// state (session not yet restored) — the exact case that re-prompted before.
|
||||
s.doc = await renderElement(
|
||||
React.createElement(AccessGateScreen, {
|
||||
status: 'disconnected',
|
||||
initialIdentifier,
|
||||
onEnter: (id: string) => {
|
||||
s.entered = id;
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Given(
|
||||
'la barrière d\'accès s\'affiche avec l\'identifiant stocké {string}',
|
||||
async function (this: FestipodWorld, identifier: string) {
|
||||
await renderGate(this, identifier);
|
||||
},
|
||||
);
|
||||
|
||||
Given(
|
||||
'la barrière d\'accès s\'affiche sans identifiant stocké',
|
||||
async function (this: FestipodWorld) {
|
||||
await renderGate(this, '');
|
||||
},
|
||||
);
|
||||
|
||||
function identifierField(world: object): HTMLInputElement {
|
||||
const s = stateFor(world);
|
||||
expect(s.doc, 'The access barrier should be rendered').to.not.be.null;
|
||||
const input = s.doc!.querySelector('[data-testid="identifier-input"]') as HTMLInputElement | null;
|
||||
expect(input, 'The identifier field should be present').to.not.be.null;
|
||||
return input!;
|
||||
}
|
||||
|
||||
Then(
|
||||
'le champ identifiant contient {string}',
|
||||
function (this: FestipodWorld, expected: string) {
|
||||
expect(identifierField(this).value).to.equal(expected);
|
||||
},
|
||||
);
|
||||
|
||||
Then('le champ identifiant est vide', function (this: FestipodWorld) {
|
||||
expect(identifierField(this).value).to.equal('');
|
||||
});
|
||||
|
||||
When('je clique sur {string} dans la barrière', function (this: FestipodWorld, _label: string) {
|
||||
const s = stateFor(this);
|
||||
const input = identifierField(this);
|
||||
// Submit via Enter on the field (canEnter is satisfied by the prefilled value).
|
||||
const KeyboardEventCtor = (globalThis as { KeyboardEvent?: typeof KeyboardEvent }).KeyboardEvent;
|
||||
const evt = KeyboardEventCtor
|
||||
? new KeyboardEventCtor('keydown', { key: 'Enter', bubbles: true })
|
||||
: Object.assign(new (globalThis as { Event: typeof Event }).Event('keydown', { bubbles: true }), { key: 'Enter' });
|
||||
input.dispatchEvent(evt);
|
||||
expect(s.doc, 'The access barrier should be rendered').to.not.be.null;
|
||||
});
|
||||
|
||||
Then(
|
||||
'l\'identifiant remonté à l\'application est {string}',
|
||||
function (this: FestipodWorld, expected: string) {
|
||||
const s = stateFor(this);
|
||||
expect(s.entered, 'onEnter should have been called with the identifier').to.equal(expected);
|
||||
},
|
||||
);
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* @ui steps for AccountContext identifier resolution.
|
||||
*
|
||||
* Guards the cross-frontier fix: the shared-wallet flow runs the app in TWO
|
||||
* localStorage partitions (top-level 127.0.0.1 vs broker iframe nextgraph.net),
|
||||
* so localStorage does NOT cross. The `?id=` URL param — embedded in the broker
|
||||
* redirect `o=` — DOES cross. AccountContext resolution therefore PRIORITIZES the
|
||||
* URL param over localStorage, and (when present) persists it to localStorage for
|
||||
* same-partition convenience. Normalization (trim, `@`-strip, lowercase) applies.
|
||||
*
|
||||
* These render a tiny probe inside a real AccountProvider (via renderElement),
|
||||
* having first seeded window.location.search and window.localStorage through the
|
||||
* happy-dom harness — so the resolution logic runs for real, not mocked.
|
||||
*/
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import React from 'react';
|
||||
import {
|
||||
renderElement,
|
||||
setRenderUrl,
|
||||
setRenderLocalStorage,
|
||||
getRenderLocalStorage,
|
||||
} from '../../../../shared/test-harness/renderHelper';
|
||||
import { AccountProvider, useAccount } from '../../../../shared/context/AccountContext';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
const STORAGE_KEY = 'festipod.account.identifier';
|
||||
|
||||
// Per-scenario intent (kept off the World type via a WeakMap).
|
||||
interface ResolveState {
|
||||
storageSeed: string | null;
|
||||
url: string;
|
||||
doc: Document | null;
|
||||
}
|
||||
const states = new WeakMap<object, ResolveState>();
|
||||
function stateFor(world: object): ResolveState {
|
||||
let s = states.get(world);
|
||||
if (!s) {
|
||||
s = { storageSeed: null, url: 'http://localhost/', doc: null };
|
||||
states.set(world, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Probe: renders the resolved identifier so the DOM can be asserted.
|
||||
function IdentifierProbe(): React.ReactElement {
|
||||
const { identifier } = useAccount();
|
||||
return React.createElement('div', { 'data-testid': 'resolved-identifier' }, identifier ?? '');
|
||||
}
|
||||
|
||||
Given(
|
||||
'localStorage contient l\'identifiant {string}',
|
||||
function (this: FestipodWorld, value: string) {
|
||||
stateFor(this).storageSeed = value;
|
||||
},
|
||||
);
|
||||
|
||||
Given('localStorage ne contient aucun identifiant', function (this: FestipodWorld) {
|
||||
stateFor(this).storageSeed = null;
|
||||
});
|
||||
|
||||
Given('l\'URL porte le param id {string}', function (this: FestipodWorld, id: string) {
|
||||
const s = stateFor(this);
|
||||
const url = new URL('http://localhost/');
|
||||
url.searchParams.set('id', id);
|
||||
s.url = url.toString();
|
||||
});
|
||||
|
||||
Given('l\'URL ne porte aucun param id', function (this: FestipodWorld) {
|
||||
stateFor(this).url = 'http://localhost/';
|
||||
});
|
||||
|
||||
When('le contexte de compte résout l\'identifiant', async function (this: FestipodWorld) {
|
||||
const s = stateFor(this);
|
||||
// Seed the happy-dom window (URL + localStorage) BEFORE mounting the provider,
|
||||
// so the provider's init-time resolution reads exactly this state.
|
||||
await setRenderUrl(s.url);
|
||||
await setRenderLocalStorage(STORAGE_KEY, s.storageSeed);
|
||||
s.doc = await renderElement(
|
||||
React.createElement(AccountProvider, null, React.createElement(IdentifierProbe)),
|
||||
);
|
||||
});
|
||||
|
||||
function resolved(world: object): string {
|
||||
const s = stateFor(world);
|
||||
expect(s.doc, 'The probe should be rendered').to.not.be.null;
|
||||
const el = s.doc!.querySelector('[data-testid="resolved-identifier"]');
|
||||
expect(el, 'The resolved-identifier probe should be present').to.not.be.null;
|
||||
return el!.textContent ?? '';
|
||||
}
|
||||
|
||||
Then('l\'identifiant résolu est {string}', function (this: FestipodWorld, expected: string) {
|
||||
expect(resolved(this)).to.equal(expected);
|
||||
});
|
||||
|
||||
Then(
|
||||
'localStorage contient désormais l\'identifiant {string}',
|
||||
async function (this: FestipodWorld, expected: string) {
|
||||
// The URL-param → localStorage persistence runs in a mount useEffect, which
|
||||
// React flushes AFTER the render's first microtask. Yield a few macrotask
|
||||
// ticks (bounded, no polling of any live resource) so the effect has run
|
||||
// before asserting — otherwise the read races the effect and flakes.
|
||||
let stored: string | null = null;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
stored = await getRenderLocalStorage(STORAGE_KEY);
|
||||
if (stored === expected) break;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
expect(stored).to.equal(expected);
|
||||
},
|
||||
);
|
||||
@@ -60,8 +60,7 @@ Fonctionnalité: Validation e2e multi-navigateurs des nouvelles features (T02.f)
|
||||
# vise pas « sans reload ».
|
||||
#
|
||||
# FIX (appliqué) : la matérialisation du propriétaire lit l'inbox APRÈS sa barrière
|
||||
# de sync (`inbox.readSynced` → `ensureRepoOpen` + `read`, lib
|
||||
# `@ng-eventually/client`), de sorte qu'un dépôt de l'inscrit déjà synchronisé au
|
||||
# de sync (`inbox.readSynced`, SDK `@ng-eventually/polyfill`), de sorte qu'un dépôt de l'inscrit déjà synchronisé au
|
||||
# broker EST vu (plus de « 0 prématuré » mémoïsé) ; le materializer est lancé
|
||||
# directement à la connexion ([ready, ownedKey]), pas seulement sur un push
|
||||
# d'inbox. Source unique du NOMBRE : `event.participantCount` (le littéral local
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
# language: fr
|
||||
@EVENT @priority-1 @data
|
||||
# SUSPENDU (@wip) — comportement Festipod bien réel, mais inexprimable aujourd'hui : il faut DEUX contextes navigateur réellement séparés, chacun se connectant pour lui-même via `ensureIdentity()`, ce que le support multi-navigateur n'offre pas encore.
|
||||
@EVENT @priority-1 @data @wip
|
||||
Fonctionnalité: Isolation entre deux identités sur le wallet partagé
|
||||
En tant qu'utilisateur qui nomme son espace virtuel par un identifiant à la
|
||||
barrière d'accès, sur le MÊME wallet physique partagé,
|
||||
En tant qu'utilisateur qui se connecte pour lui-même, sur le MÊME wallet
|
||||
physique partagé qu'un autre utilisateur,
|
||||
Je ne dois voir NI l'inscription NI l'accueil d'une autre identité
|
||||
Afin que les participations restent privées à leur propriétaire.
|
||||
|
||||
# Régression du leak d'isolation : une identité A crée un événement et le
|
||||
# rejoint ; une identité fraîche B arrive sur le même wallet (faux-logout +
|
||||
# re-login sous un autre identifiant, sans reload — le stopgap wallet-partagé).
|
||||
# rejoint ; une identité fraîche B arrive sur le même wallet physique, dans son
|
||||
# propre contexte navigateur, et se connecte pour elle-même.
|
||||
# B ne doit PAS voir la participation de A : ni sur son accueil
|
||||
# (getUserEvents(B)), ni via isParticipating(E, B), ni dans son set de
|
||||
# participations réactif. Le mécanisme : le changement d'identifiant est traité
|
||||
# comme une session fraîche (reset du jeu de lecture + caps + registre), sinon
|
||||
# les docs PROTECTED de A survivent dans le jeu de lecture de B et fuient par la
|
||||
# lecture union. Voir data-layer/knowledge_context-internals § « Changement
|
||||
# d'identité = session fraîche ».
|
||||
# participations réactif. Ce qui est sous test, c'est le comportement de
|
||||
# Festipod : rien de ce que l'app affiche à B ne provient des documents
|
||||
# PROTECTED de A, quand bien même les deux identités vivent sur un seul wallet
|
||||
# physique.
|
||||
|
||||
@data
|
||||
Scénario: Une identité fraîche ne voit pas la participation d'une autre
|
||||
|
||||
@@ -79,25 +79,36 @@ export function CreateEventScreen() {
|
||||
? (endDate ? `${startDate} - ${endDate}` : startDate)
|
||||
: 'Date à définir';
|
||||
|
||||
const newEvent = await createEvent({
|
||||
title: name || 'Nouvel événement',
|
||||
date: dateLabel,
|
||||
startDate,
|
||||
endDate,
|
||||
startTime,
|
||||
endTime,
|
||||
location: location || 'Lieu à définir',
|
||||
description,
|
||||
// Option B: the creator does NOT auto-participate (no host notion). The count
|
||||
// starts at 0 and is DERIVED by the owner-materializer from real inbox
|
||||
// deposits (|active registrations|) — never a local literal. Passing 1 here was
|
||||
// the "local number" bug: the creator's own view showed 1 while the derived
|
||||
// truth (and every other viewer) was 0.
|
||||
participantCount: 0,
|
||||
themes: ['Social'],
|
||||
hostName: 'Moi',
|
||||
hostInitials: 'MD',
|
||||
});
|
||||
// A creation that cannot be recorded FAILS — it never hands back an event
|
||||
// that would read empty forever. So the success toast and the navigation
|
||||
// belong AFTER the create resolves, and a failure must say so rather than
|
||||
// send the user to a page for an event that does not exist.
|
||||
let newEvent;
|
||||
try {
|
||||
newEvent = await createEvent({
|
||||
title: name || 'Nouvel événement',
|
||||
date: dateLabel,
|
||||
startDate,
|
||||
endDate,
|
||||
startTime,
|
||||
endTime,
|
||||
location: location || 'Lieu à définir',
|
||||
description,
|
||||
// Option B: the creator does NOT auto-participate (no host notion). The count
|
||||
// starts at 0 and is DERIVED by the owner-materializer from real inbox
|
||||
// deposits (|active registrations|) — never a local literal. Passing 1 here was
|
||||
// the "local number" bug: the creator's own view showed 1 while the derived
|
||||
// truth (and every other viewer) was 0.
|
||||
participantCount: 0,
|
||||
themes: ['Social'],
|
||||
hostName: 'Moi',
|
||||
hostInitials: 'MD',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[CreateEvent] createEvent failed:', err);
|
||||
showToast("L'événement n'a pas pu être relayé", 'error');
|
||||
return;
|
||||
}
|
||||
showToast('Événement relayé', 'success');
|
||||
navigate(`/events/${newEvent.id}`);
|
||||
};
|
||||
|
||||
@@ -40,11 +40,22 @@ export function EventDetailScreen() {
|
||||
|
||||
const handleToggleJoin = () => {
|
||||
if (!eventId) return;
|
||||
// The optimistic toast stays immediate (the overlay already reflects the
|
||||
// change), but the write can genuinely FAIL — a participation document that
|
||||
// cannot be recorded throws instead of reading empty forever, and an
|
||||
// unconfirmed withdrawal throws too. Surface it rather than leave the user
|
||||
// with a success message and nothing written.
|
||||
const failed = (message: string) => (err: unknown) => {
|
||||
console.error('[EventDetail] participation write failed:', err);
|
||||
showToast(message, 'error');
|
||||
};
|
||||
if (joined) {
|
||||
leaveEvent(eventId);
|
||||
void Promise.resolve(leaveEvent(eventId))
|
||||
.catch(failed("La désinscription n'a pas pu être enregistrée"));
|
||||
showToast('Participation annulée', 'info');
|
||||
} else {
|
||||
joinEvent(eventId);
|
||||
void Promise.resolve(joinEvent(eventId))
|
||||
.catch(failed("L'inscription n'a pas pu être enregistrée"));
|
||||
showToast('Tu participes à cet événement', 'success');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { Given, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
import { pool } from '../../../../shared/support/browserPool';
|
||||
|
||||
// Two-identity isolation (@data, real broker). Identity A (the fresh per-scenario
|
||||
// identifier set in localStorage) creates an event E and joins it; a genuinely-
|
||||
// different identity B is brought up on the SAME wallet; B must read NONE of A's
|
||||
// protected participation, E must not be on B's home, isParticipating(E,B) false.
|
||||
// Isolation entre deux utilisateurs (@data, broker réel). A crée un événement et
|
||||
// s'y inscrit ; B ne doit rien lire de la participation PROTECTED de A.
|
||||
//
|
||||
// B is brought up via a FRESH PAGE on the SAME persistent wallet context with B's
|
||||
// identifier in localStorage — the closest analogue to the real app's re-enter-
|
||||
// gate / reload path (a brand-new NgDataProvider mount, identifier=B, on a wallet
|
||||
// that already holds A's docs). This exercises the identity-switch reset that
|
||||
// keeps A's protected docs out of B's read set.
|
||||
// LE « QUAND » DE CE SCÉNARIO N'EXISTE PLUS ICI, et c'est délibéré : faire venir B
|
||||
// demande son PROPRE contexte navigateur avec son PROPRE wallet — deux personnes
|
||||
// sur deux appareils. Un contexte navigateur est UN utilisateur, et `ensureIdentity()`
|
||||
// dit lequel ; rien ne le choisit. Le scénario est donc suspendu (@wip dans
|
||||
// isolation-deux-identites.feature) jusqu'à ce que le harness sache monter un second
|
||||
// contexte avec son wallet à lui. Le Given ci-dessous reste : il est réutilisé par
|
||||
// les scénarios de reconnexion. Les Alors restent aussi — les assertions sont
|
||||
// intactes, c'est le montage de B qui manque.
|
||||
|
||||
Given('l\'identité A crée l\'événement {string} et s\'y inscrit', { timeout: 180000 }, async function (this: FestipodWorld, title: string) {
|
||||
const out = await this.appFrame!.evaluate(async (title) => {
|
||||
@@ -37,28 +37,8 @@ Given('l\'identité A crée l\'événement {string} et s\'y inscrit', { timeout:
|
||||
(this as any).isoAId = out.aId;
|
||||
});
|
||||
|
||||
When('une identité fraîche B arrive sur le même wallet partagé', { timeout: 120000 }, async function (this: FestipodWorld) {
|
||||
const bId = `iso-b-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
(this as any).isoBId = bId;
|
||||
const ctx = this.page!.context();
|
||||
const bPage = await ctx.newPage();
|
||||
await bPage.addInitScript((u: string) => {
|
||||
try { window.localStorage.setItem('festipod.account.identifier', u); } catch { /* opaque */ }
|
||||
}, bId);
|
||||
await bPage.addInitScript(() => {
|
||||
(globalThis as Record<string, unknown>).__FESTIPOD_ACCESS_GATE_DISABLED__ = true;
|
||||
});
|
||||
bPage.on('console', (msg) => { if (msg.type() === 'error') console.error('[Bpage console]', msg.text()); });
|
||||
const bFrame = await pool.setupBrokerPage!(bPage, pool.harnessUrl!);
|
||||
await bFrame.waitForFunction(() => (window as any).__testData?.ready === true, { timeout: 60000 });
|
||||
// Let B's listing effect + union read run (rebuilds the read set bounded to B).
|
||||
await bFrame.evaluate(async () => {
|
||||
const td = (window as any).__testData;
|
||||
await td.ensureCurrentUser();
|
||||
await new Promise(r => setTimeout(r, 6000));
|
||||
});
|
||||
(this as any).isoBFrame = bFrame;
|
||||
});
|
||||
// Le « Quand une identité fraîche B arrive… » n'a pas d'équivalent : il faut à B son
|
||||
// propre contexte navigateur avec son propre wallet (voir l'en-tête de ce fichier).
|
||||
|
||||
Then('l\'événement {string} n\'est pas sur l\'accueil de B', async function (this: FestipodWorld, title: string) {
|
||||
const bFrame = (this as any).isoBFrame;
|
||||
|
||||
@@ -31,8 +31,6 @@ When(
|
||||
'un navigateur frais non-persistant recharge pour la MÊME identité A avec le wallet partagé',
|
||||
{ timeout: 120000 },
|
||||
async function (this: FestipodWorld) {
|
||||
// SAME identity A: the per-scenario virtual identifier set by the Before hook.
|
||||
const aIdentifier = (this as any).freshIdentifier as string;
|
||||
if (!pool.sharedWalletState) {
|
||||
throw new Error(
|
||||
'sharedWalletState non capturé au BeforeAll — impossible de provisionner un ' +
|
||||
@@ -41,25 +39,16 @@ When(
|
||||
);
|
||||
}
|
||||
|
||||
// FRESH, non-persistent, hermetic context seeded with ONLY the shared wallet
|
||||
// storageState (captured before A's event existed). Separate storage partition
|
||||
// from the persistent write page → no shared IndexedDB, no local copy of A's
|
||||
// just-created event. Its ONLY source for A's event is the broker.
|
||||
// FRESH, non-persistent, hermetic context seeded with ONLY the deployment
|
||||
// wallet's storageState (captured before A's event existed). Separate storage
|
||||
// partition from the persistent write page → no shared IndexedDB, no local copy
|
||||
// of A's just-created event. Its ONLY source for A's event is the broker.
|
||||
// It comes up as the SAME person because it opens the SAME wallet — that is
|
||||
// what makes this a reconnect, and nothing here names an identity.
|
||||
const ctx = await spawnContext('shared');
|
||||
(this as any).recoColdCtx = ctx; // closed by freshBrowser.close() in AfterAll
|
||||
const freshPage = await ctx.newPage();
|
||||
|
||||
// SAME identity A: inject A's app-level identifier on every origin BEFORE any
|
||||
// script (incl. the harness iframe on 127.0.0.1), so the shim keys to the SAME
|
||||
// virtual account A — a reconnect, not an identity switch. Identical injection
|
||||
// to isolation.steps.ts / reconnexion.steps.ts, but into a FRESH context.
|
||||
await freshPage.addInitScript((u: string) => {
|
||||
try { window.localStorage.setItem('festipod.account.identifier', u); } catch { /* opaque */ }
|
||||
}, aIdentifier);
|
||||
await freshPage.addInitScript(() => {
|
||||
(globalThis as Record<string, unknown>).__FESTIPOD_ACCESS_GATE_DISABLED__ = true;
|
||||
});
|
||||
|
||||
// Broad console capture (ALL types) so the SDK diagnostic lines
|
||||
// (BARRIER synced|timed-out, OUTBOX, readScopeIndex → N, CONNECTION ESTABLISHED,
|
||||
// REPLAY TOPIC NOT FOUND, …) surface verbatim to stdout as the verdict's proof.
|
||||
|
||||
@@ -3,23 +3,18 @@ import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
import { pool } from '../../../../shared/support/browserPool';
|
||||
|
||||
// RECONNECTION of the SAME identity on the SAME persistent wallet (@data, real
|
||||
// broker). Distinct from the two-identity isolation scenario: here the fresh page
|
||||
// re-enters under the SAME identifier A (same virtual wallet), on a NEW broker
|
||||
// login (fresh verifier session). The defect under test: the fresh page reads its
|
||||
// OWN data EMPTY (home=[], isParticipating=false, authCount=0) because the anchored
|
||||
// listing path (listMyEntityDocs → readScopeIndex, then readUnion/readDoc) queries
|
||||
// repos not yet in `self.repos` at cold-start and silently returns 0 rows.
|
||||
// RECONNECTION of the same user on the same persistent wallet (@data, real
|
||||
// broker). The fresh page re-enters on a NEW broker login (fresh verifier
|
||||
// session). The defect under test: the fresh page reads its OWN data EMPTY
|
||||
// (home=[], isParticipating=false, authCount=0) because the anchored listing path
|
||||
// (listMyEntityDocs → readScopeIndex, then readUnion/readDoc) queries repos not
|
||||
// yet in `self.repos` at cold-start and silently returns 0 rows.
|
||||
//
|
||||
// Identity A is the scenario's fresh virtual-wallet identifier (this.freshIdentifier, set
|
||||
// by the Before hook into localStorage on every origin). A creates E via the REAL
|
||||
// app path (createEventReal) and joins it (appJoinEvent) on the MAIN page. Then a
|
||||
// FRESH PAGE is brought up on the SAME persistent wallet context with the SAME
|
||||
// identifier A in localStorage BEFORE any script — the closest analogue to the real
|
||||
// app's re-enter-gate / reload path (a brand-new NgDataProvider mount + a fresh
|
||||
// broker session that must re-open A's own repos). Montage identical to
|
||||
// isolation.steps.ts, except the fresh page reuses this.freshIdentifier (SAME A) rather
|
||||
// than minting a new identifier B.
|
||||
// A creates E via the REAL app path (createEventReal) and joins it (appJoinEvent)
|
||||
// on the MAIN page. Then a FRESH PAGE is opened on the SAME browser context — one
|
||||
// context is one user, so that page comes up as the same person, and it is the
|
||||
// closest analogue to the real app's re-entry / reload path (a brand-new
|
||||
// NgDataProvider mount + a fresh broker session that must re-open A's own repos).
|
||||
|
||||
// The Given "l'identité A crée l'événement {string} et s'y inscrit" is REUSED from
|
||||
// isolation.steps.ts (same wording, same behavior — A creates E and joins on the
|
||||
@@ -106,18 +101,11 @@ Then('l\'événement {string} finit par apparaître sur la page fraîche A en la
|
||||
});
|
||||
|
||||
When('une page fraîche pour la MÊME identité A recharge sur le même wallet', { timeout: 120000 }, async function (this: FestipodWorld) {
|
||||
// SAME identity A as the main page: reuse the scenario's fresh virtual-wallet
|
||||
// identifier (set by the Before hook). NOT a new identifier — this is a reconnect,
|
||||
// not an identity switch.
|
||||
const aIdentifier = (this as any).freshIdentifier as string;
|
||||
// SAME user as the main page — by construction, not by declaration: the new page
|
||||
// is opened on the SAME browser context, and a browser context is one user. This
|
||||
// is a reconnect, and there is nothing to select.
|
||||
const ctx = this.page!.context();
|
||||
const freshPage = await ctx.newPage();
|
||||
await freshPage.addInitScript((u: string) => {
|
||||
try { window.localStorage.setItem('festipod.account.identifier', u); } catch { /* opaque */ }
|
||||
}, aIdentifier);
|
||||
await freshPage.addInitScript(() => {
|
||||
(globalThis as Record<string, unknown>).__FESTIPOD_ACCESS_GATE_DISABLED__ = true;
|
||||
});
|
||||
// Broadened to ALL console types (not just 'error') for the pause-reproduction
|
||||
// investigation: the SDK's diagnostic lines (logStage: BARRIER/OUTBOX/
|
||||
// readScopeIndex) are emitted via console.log, not console.error, and would
|
||||
|
||||
@@ -6,14 +6,13 @@ import { pool } from '../../../../shared/support/browserPool';
|
||||
// RECONNECTION-PERSISTENCE at the @e2e layer (REAL app, real broker).
|
||||
//
|
||||
// Mirrors reconnexion.steps.ts (@data) but drives the REAL app (pool.appUrl),
|
||||
// NOT the harness. The main page is booted by the @e2e Before hook: identity =
|
||||
// this.freshIdentifier (set into localStorage['festipod.account.identifier'] on every
|
||||
// origin by the hook), gate disabled → the real app boots directly on that
|
||||
// identity. We create an event via the REAL create form (same DOM path the app
|
||||
// user takes), verify it appears, then open a SECOND page in the SAME persistent
|
||||
// wallet context with the SAME identifier + gate disabled, and a FRESH broker
|
||||
// login → a fresh verifier session (empty memory) that must re-read everything
|
||||
// from the broker. That is the faithful analogue of "close and reopen".
|
||||
// NOT the harness. The main page is booted by the @e2e Before hook; who it comes
|
||||
// up as is `ensureIdentity()`'s answer, and nothing here selects it. We create an
|
||||
// event via the REAL create form (same DOM path the app user takes), verify it
|
||||
// appears, then open a SECOND page in the SAME browser context — one context is
|
||||
// one user, so it is the same person — with a FRESH broker login → a fresh
|
||||
// verifier session (empty memory) that must re-read everything from the broker.
|
||||
// That is the faithful analogue of "close and reopen".
|
||||
//
|
||||
// The step captures console logs from BOTH pages and reports (via cucumber
|
||||
// attachments) the timing of `WRITE`-ish lines vs `CONNECTION ESTABLISHED`, and
|
||||
@@ -171,18 +170,11 @@ Given('l\'événement {string} apparaît sur l\'accueil de l\'utilisateur', { ti
|
||||
// --- Step 2: reconnect faithfully — a fresh page/session for the SAME identity ---
|
||||
|
||||
When('l\'utilisateur ferme et rouvre l\'app sous la même identité dans une session broker fraîche', { timeout: 180000 }, async function (this: FestipodWorld) {
|
||||
const identifier = (this as any).freshIdentifier as string;
|
||||
// SAME browser context → same wallet → same person. The reopened app asks
|
||||
// `ensureIdentity()` who it is, exactly as the first page did.
|
||||
const ctx = this.page!.context();
|
||||
|
||||
const freshPage = await ctx.newPage();
|
||||
// SAME identity in localStorage BEFORE any script (what the reopened app reads,
|
||||
// gate disabled) + gate disabled so the real app boots straight onto that id.
|
||||
await freshPage.addInitScript((u: string) => {
|
||||
try { window.localStorage.setItem('festipod.account.identifier', u); } catch { /* opaque */ }
|
||||
}, identifier);
|
||||
await freshPage.addInitScript(() => {
|
||||
(globalThis as Record<string, unknown>).__FESTIPOD_ACCESS_GATE_DISABLED__ = true;
|
||||
});
|
||||
|
||||
const freshLogs: StampedLog[] = [];
|
||||
(this as any).recoFreshLogs = freshLogs;
|
||||
|
||||
@@ -2,29 +2,19 @@ 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 current identifier only — the shared wallet stays
|
||||
// open underneath. In staging this returns to the access barrier (identifier
|
||||
// prompt), since the gate shows until an identifier is set again.
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
// Real logout (HIDDEN): stops the shared wallet session — forces a broker
|
||||
// redirect on next access. Stopgap-only escape hatch.
|
||||
// The ONLY logout: stop the session of the wallet this deployment serves, which
|
||||
// forces a broker redirect on the next access. There is nothing app-side to sign
|
||||
// out of — the app never names an identity, so it holds none to drop.
|
||||
const handleLeaveEnvironment = async () => {
|
||||
await logoutNg();
|
||||
logout();
|
||||
if (typeof window !== 'undefined') window.location.reload();
|
||||
};
|
||||
|
||||
@@ -93,13 +83,9 @@ export function SettingsScreen() {
|
||||
|
||||
<Divider />
|
||||
|
||||
<ListItem onClick={handleLogout}>
|
||||
<Text style={{ margin: 0, color: '#E53E3E' }}>Se déconnecter</Text>
|
||||
</ListItem>
|
||||
|
||||
{/* Stopgap escape hatch — real wallet logout, kept discreet. */}
|
||||
{/* Stopgap escape hatch — the real wallet logout. */}
|
||||
<ListItem onClick={handleLeaveEnvironment}>
|
||||
<Text style={{ margin: 0, fontSize: 12, color: '#bbb' }}>
|
||||
<Text style={{ margin: 0, color: '#E53E3E' }}>
|
||||
Quitter l'environnement de test
|
||||
</Text>
|
||||
</ListItem>
|
||||
|
||||
@@ -1,32 +1,10 @@
|
||||
# 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é)
|
||||
Fonctionnalité: Harness multi-navigateur — modèle shared-wallet
|
||||
Pour valider le provisioning du wallet partagé sur plusieurs navigateurs
|
||||
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"
|
||||
tous porteurs du wallet partagé.
|
||||
|
||||
# --- Axe wallet : provisioning shared-wallet (injection storageState) ---
|
||||
|
||||
@@ -40,34 +18,3 @@ Fonctionnalité: Harness multi-navigateur — modèles private-wallet et shared-
|
||||
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.)
|
||||
#
|
||||
# EXCLU DU RUN PAR DÉFAUT (cucumber.json : "not @wip and not @humain", T02.f) :
|
||||
# ce scénario pilote nextgraph.eu EN DIRECT (import du fichier wallet sur un site
|
||||
# externe non maîtrisé par Festipod) → non déterministe en CI et, en cas d'échec
|
||||
# réseau, il ferme le contexte navigateur et faisait CASCADER les scénarios @data
|
||||
# suivants. C'est une validation de FIDÉLITÉ HUMAINE, à lancer explicitement
|
||||
# (`--tags @humain`), pas un test automatisé du run par défaut.
|
||||
@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, saisit un identifiant et clique « Entrer »
|
||||
Alors il arrive sur l'accueil de l'application
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# language: fr
|
||||
@WORKSHOP @priority-1
|
||||
Fonctionnalité: Isolation protégée par connexions (ng-eventually)
|
||||
En tant que développeur
|
||||
Je veux valider, contre le vrai broker, que l'isolation est ACTIVE via le SDK :
|
||||
un compte ne lit PAS l'entité PROTÉGÉE d'un autre compte tant qu'ils ne sont
|
||||
pas connectés, la lit une fois qu'ils se connectent, et lit toujours l'entité
|
||||
PUBLIQUE de cet autre compte — le tout appliqué par le SDK (filtre ReadCap +
|
||||
déclaration de connexions), pas par un filtre applicatif.
|
||||
|
||||
@data
|
||||
Scénario: Un compte non connecté ne lit pas l'entité protégée d'un autre, puis la lit après connexion
|
||||
Étant donné le wallet contient l'entité protégée du compte "alice"
|
||||
Et le compte "bob" est courant sans connexion à "alice"
|
||||
Alors "bob" ne voit aucune entité protégée d'"alice"
|
||||
Mais "bob" voit l'entité publique d'"alice"
|
||||
Quand l'app déclare la connexion entre "alice" et "bob"
|
||||
Alors "bob" voit l'entité protégée d'"alice"
|
||||
Et "bob" voit toujours l'entité publique d'"alice"
|
||||
@@ -1,20 +0,0 @@
|
||||
# language: fr
|
||||
@WORKSHOP @priority-1
|
||||
Fonctionnalité: Store protected natif — ouverture et aller-retour (axe A)
|
||||
En tant que développeur
|
||||
Je veux vérifier, contre le vrai broker NextGraph, que le store natif protected
|
||||
(`did:ng:${protected_store_id}`) s'ouvre pour lecture ET écriture comme le store
|
||||
private, avant de basculer les entités du domaine vers lui (T02.h, axe A).
|
||||
|
||||
# --- Data (broker réel) — ÉTAPE GATING ---
|
||||
|
||||
@data
|
||||
Scénario: L'ORM lit et écrit dans le store protected natif (aller-retour)
|
||||
Étant donné le store protected natif est souscrit via l'ORM
|
||||
Quand j'écris une participation dans le store protected via l'ORM
|
||||
Alors la participation est lisible dans le store protected
|
||||
|
||||
@data
|
||||
Scénario: SPARQL fait l'aller-retour dans le store protected natif
|
||||
Quand j'écris puis relis un triplet dans le store protected via SPARQL
|
||||
Alors le triplet est retrouvé dans le store protected sans RepoNotFound
|
||||
@@ -1,17 +0,0 @@
|
||||
# language: fr
|
||||
@WORKSHOP @priority-1
|
||||
Fonctionnalité: Filtre ReadCap (ng-eventually)
|
||||
En tant que développeur
|
||||
Je veux valider, contre le vrai broker, que le filtre de lecture de la lib
|
||||
applique les ReadCap au niveau du DOCUMENT (le repo où vit chaque item) sur le
|
||||
vrai set réactif de l'ORM : on ne voit un document que si on détient sa ReadCap.
|
||||
En mono-store (tout dans un seul repo) c'est donc tout-ou-rien sur ce document
|
||||
— le comportement fidèle de NextGraph.
|
||||
|
||||
@data
|
||||
Scénario: On ne voit un document que si on détient sa ReadCap
|
||||
Étant donné le wallet contient des participations dans un document
|
||||
Quand je gouverne ce document par une ReadCap accordée à un autre utilisateur
|
||||
Alors l'utilisateur courant ne voit aucune participation de ce document
|
||||
Quand l'utilisateur courant obtient la ReadCap du document
|
||||
Alors il voit toutes les participations du document
|
||||
@@ -9,81 +9,11 @@ import { pool } from '../../../../shared/support/browserPool';
|
||||
// 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, saisit un identifiant et clique « Entrer »', async function (this: FestipodWorld) {
|
||||
const handle = this.browser('H');
|
||||
await handle.page.goto((this as any).stagingUrl, { waitUntil: 'domcontentloaded' });
|
||||
// Saisit son identifiant (il nomme l'espace virtuel) — « Entrer » reste désactivé
|
||||
// tant qu'il est vide. Naming the space and opening the wallet are one act.
|
||||
const idf = handle.page.locator('[data-testid=identifier-input]');
|
||||
await idf.waitFor({ state: 'visible', timeout: 15000 });
|
||||
await idf.fill('testeur');
|
||||
const entrer = handle.page.getByText('Entrer', { exact: true });
|
||||
await entrer.click(); // enregistre l'identifiant PUIS déclenche le redirect 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('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
|
||||
@@ -100,36 +30,3 @@ Then('le navigateur {string} est connecté à NextGraph', async function (this:
|
||||
{ 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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
// Proves ISOLATION IS ACTIVE through the SDK (not a mere app filter): a PROTECTED
|
||||
// ENTITY DOCUMENT owned by `alice` is hidden from an unconnected `bob`, revealed
|
||||
// once the app declares the alice↔bob connection (declareConnections — the domain
|
||||
// sharing act), while alice's PUBLIC document stays readable for bob regardless.
|
||||
// Runs on the REAL ORM set via <FilterProbe> against the broker.
|
||||
//
|
||||
// The probed document is a real per-entity document (`createEntityDoc(owner,
|
||||
// 'protected')`, rule_document-per-entity), NOT the protected STORE document: the
|
||||
// unit of sharing is the document, and `declareConnections` hands over entity
|
||||
// documents' keys. Handing over a store's key would give away everything it holds,
|
||||
// present and future — the model refuses that, so a store-level probe can only ever
|
||||
// read 0 after connecting.
|
||||
//
|
||||
// `alice` and `bob` are two GENUINELY DISTINCT identities: each is derived from the
|
||||
// scenario's fresh identifier, so each gets its own account, its own scope stores,
|
||||
// its own inbox and its own set of held keys. `bob` holds nothing of `alice`'s until
|
||||
// a key is delivered to its inbox.
|
||||
|
||||
/** The scenario's identity for a Gherkin handle ("alice"/"bob") — distinct per
|
||||
* scenario, so nothing accumulates in the shared test wallet across runs. */
|
||||
function identityFor(world: FestipodWorld, handle: string): string {
|
||||
return `${(world as any).freshIdentifier}-${handle}`;
|
||||
}
|
||||
|
||||
Given('le wallet contient l\'entité protégée du compte {string}', async function (this: FestipodWorld, owner: string) {
|
||||
const ownerId = identityFor(this, owner);
|
||||
// `owner` creates its OWN protected entity document and writes its entity into
|
||||
// it — the creator is the one holding the key, no declaration involved.
|
||||
const { total } = await this.appFrame!.evaluate(
|
||||
(id: string) => (window as any).__testData.setupProtectedEntity(id),
|
||||
ownerId,
|
||||
);
|
||||
(this as any).pc = { owner: ownerId, total };
|
||||
expect(total, 'the protected entity document holds an entity').to.be.greaterThan(0);
|
||||
// <FilterProbe> mounts over that document; wait for the reactive set to carry the
|
||||
// entity while the OWNER is still the connected identity (it holds the key, so it
|
||||
// reads its own document). Observes the pushed reactive state — no broker re-read.
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => (window as any).__readFilter?.ready === true,
|
||||
null,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
await this.appFrame!.waitForFunction(
|
||||
(expected: number) => (window as any).__readFilter.snapshot().count === expected,
|
||||
total,
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
});
|
||||
|
||||
Given('le compte {string} est courant sans connexion à {string}', async function (this: FestipodWorld, reader: string, owner: string) {
|
||||
const readerId = identityFor(this, reader);
|
||||
const ownerId = identityFor(this, owner);
|
||||
(this as any).pc = { ...(this as any).pc, reader: readerId, owner: ownerId };
|
||||
// `owner` publishes its public probe and hands the link to `reader`, who becomes
|
||||
// the connected identity — holding no key of the protected entity document.
|
||||
await this.appFrame!.evaluate(
|
||||
(args: { owner: string; reader: string }) =>
|
||||
(window as any).__testData.governProtected(args.owner, args.reader),
|
||||
{ owner: ownerId, reader: readerId },
|
||||
);
|
||||
});
|
||||
|
||||
Then('{string} ne voit aucune entité protégée d\'{string}', async function (this: FestipodWorld, _reader: string, _owner: string) {
|
||||
const snap = await this.appFrame!.evaluate(() => (window as any).__readFilter.snapshot());
|
||||
expect(snap.count, 'an unconnected reader sees none of the protected entity document').to.equal(0);
|
||||
});
|
||||
|
||||
Then('{string} voit l\'entité publique d\'{string}', async function (this: FestipodWorld, _reader: string, _owner: string) {
|
||||
const canRead = await this.appFrame!.evaluate(() => (window as any).__testData.canReadPublicProbe());
|
||||
expect(canRead, 'the public entity is readable regardless of connection').to.equal(true);
|
||||
});
|
||||
|
||||
When('l\'app déclare la connexion entre {string} et {string}', async function (this: FestipodWorld, a: string, b: string) {
|
||||
await this.appFrame!.evaluate(
|
||||
(args: { a: string; b: string }) => (window as any).__testData.connect(args.a, args.b),
|
||||
{ a: identityFor(this, a), b: identityFor(this, b) },
|
||||
);
|
||||
});
|
||||
|
||||
Then('{string} voit l\'entité protégée d\'{string}', async function (this: FestipodWorld, _reader: string, _owner: string) {
|
||||
const { total } = (this as any).pc;
|
||||
const snap = await this.appFrame!.evaluate(() => (window as any).__readFilter.snapshot());
|
||||
expect(snap.count, 'a connected reader sees the whole protected entity document').to.equal(total);
|
||||
});
|
||||
|
||||
Then('{string} voit toujours l\'entité publique d\'{string}', async function (this: FestipodWorld, _reader: string, _owner: string) {
|
||||
const canRead = await this.appFrame!.evaluate(() => (window as any).__testData.canReadPublicProbe());
|
||||
expect(canRead, 'the public entity stays readable after connecting').to.equal(true);
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
// T02.h GATING — validate, against the REAL broker, that the native protected
|
||||
// store (`did:ng:${protected_store_id}`) opens for ORM reads/writes AND SPARQL
|
||||
// round-trips the same way private does (decision_2026-03-17). If either path
|
||||
// hits RepoNotFound, the domain-scope switch MUST NOT happen (blocker).
|
||||
|
||||
// --- Scenario 1: ORM round-trip on the protected store ---
|
||||
|
||||
Given('le store protected natif est souscrit via l\'ORM', async function (this: FestipodWorld) {
|
||||
await this.appFrame!.evaluate(() => (window as any).__testData.mountProtectedProbe());
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => (window as any).__protected?.ready === true,
|
||||
null,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
});
|
||||
|
||||
When('j\'écris une participation dans le store protected via l\'ORM', async function (this: FestipodWorld) {
|
||||
await this.appFrame!.evaluate(() => (window as any).__protected.add());
|
||||
});
|
||||
|
||||
Then('la participation est lisible dans le store protected', async function (this: FestipodWorld) {
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => (window as any).__protected.count() >= 1,
|
||||
null,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
const items = await this.appFrame!.evaluate(() => (window as any).__protected.items());
|
||||
expect(items.length, 'participation should be readable via ORM on the protected store').to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
// --- Scenario 2: SPARQL round-trip on the protected store ---
|
||||
|
||||
When('j\'écris puis relis un triplet dans le store protected via SPARQL', async function (this: FestipodWorld) {
|
||||
const res = await this.appFrame!.evaluate(
|
||||
async () => await (window as any).__testData.protectedSparqlRoundTrip(),
|
||||
);
|
||||
(this as any).protectedRoundTrip = res;
|
||||
});
|
||||
|
||||
Then('le triplet est retrouvé dans le store protected sans RepoNotFound', function (this: FestipodWorld) {
|
||||
const r = (this as any).protectedRoundTrip;
|
||||
expect(r, 'round-trip result should exist').to.exist;
|
||||
expect(r.protectedNuri, 'session should carry a protected_store_id').to.be.a('string');
|
||||
expect(
|
||||
r.insertError,
|
||||
`SPARQL INSERT into the protected store should not error (got: ${r.insertError})`,
|
||||
).to.equal(null);
|
||||
expect(
|
||||
r.queryError,
|
||||
`SPARQL SELECT from the protected store should not error (got: ${r.queryError})`,
|
||||
).to.equal(null);
|
||||
expect(r.count, 'the inserted triple should be read back from the protected store').to.be.greaterThan(0);
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
// Validates ng-eventually's READ FILTER (ReadCap) on the REAL ORM set, against
|
||||
// the broker. The filter is per-DOCUMENT (an item's @graph = the repo it lives
|
||||
// in): you see a document only if you hold its read cap. In mono-store, every
|
||||
// participation shares one document, so governing it is all-or-nothing — the
|
||||
// faithful NextGraph behavior. Two synthetic users discriminate cap possession,
|
||||
// NOT the participation's own `user` field.
|
||||
|
||||
Given('le wallet contient des participations dans un document', async function (this: FestipodWorld) {
|
||||
// Deterministic: ensure ≥1 participation exists in the wallet document.
|
||||
// joinEvent is idempotent on (event,user), so this doesn't accumulate.
|
||||
await this.appFrame!.evaluate(async () => {
|
||||
const td = (window as any).__testData;
|
||||
// Store-root document (the one FilterProbe/governDocument govern) — use the
|
||||
// RAW path so the participations land in `documentNuri`, not per-entity docs.
|
||||
td.rawJoin('urn:rf:event', 'urn:rf:p1');
|
||||
td.rawJoin('urn:rf:event', 'urn:rf:p2');
|
||||
});
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => {
|
||||
const ps = [...(window as any).__testData.rawParticipations];
|
||||
return ps.some((p: any) => p.user === 'urn:rf:p1') && ps.some((p: any) => p.user === 'urn:rf:p2');
|
||||
},
|
||||
null,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
const data = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
// Raw set (no policy yet) → true total in the document.
|
||||
return { total: [...td.rawParticipations].length, documentNuri: td.documentNuri };
|
||||
});
|
||||
(this as any).rf = { ...data, reader: 'urn:rf:alice', other: 'urn:rf:bob' };
|
||||
expect(data.total, 'the document holds participations').to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
When('je gouverne ce document par une ReadCap accordée à un autre utilisateur', async function (this: FestipodWorld) {
|
||||
const { reader, other } = (this as any).rf;
|
||||
// Grant the document's read cap to `reader`; current user is `other` (no cap).
|
||||
await this.appFrame!.evaluate(
|
||||
(args: { reader: string; user: string }) => (window as any).__testData.governDocument(args.reader, args.user),
|
||||
{ reader, user: other },
|
||||
);
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => (window as any).__readFilter?.ready === true,
|
||||
null,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
});
|
||||
|
||||
Then('l\'utilisateur courant ne voit aucune participation de ce document', async function (this: FestipodWorld) {
|
||||
const snap = await this.appFrame!.evaluate(() => (window as any).__readFilter.snapshot());
|
||||
expect(snap.count, 'a user without the read cap sees nothing of the document').to.equal(0);
|
||||
});
|
||||
|
||||
When('l\'utilisateur courant obtient la ReadCap du document', async function (this: FestipodWorld) {
|
||||
const { reader } = (this as any).rf;
|
||||
await this.appFrame!.evaluate((u: string) => (window as any).__testData.setUser(u), reader);
|
||||
});
|
||||
|
||||
Then('il voit toutes les participations du document', async function (this: FestipodWorld) {
|
||||
const { total } = (this as any).rf;
|
||||
const snap = await this.appFrame!.evaluate(() => (window as any).__readFilter.snapshot());
|
||||
expect(snap.count, 'the cap holder sees every participation of the document').to.equal(total);
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* AccountContext — the current identity of the stopgap.
|
||||
*
|
||||
* STOPGAP (see decision_2026-06-15_shared-wallet-login-flow.md).
|
||||
*
|
||||
* The user names their virtual space with an IDENTIFIER at the access barrier
|
||||
* (AccessGateScreen), in the same act that opens the SHARED wallet — there is no
|
||||
* separate app login. The identifier is a technical id (a pseudo in practice,
|
||||
* not a Festipod display name): it is normalized (trimmed, `@`-stripped,
|
||||
* lowercased) and carried across the broker round-trip.
|
||||
*
|
||||
* IDENTIFIER RESOLUTION — the `?id=` URL param is the SOURCE OF TRUTH.
|
||||
* The shared-wallet flow runs the app in TWO contexts with TWO separate
|
||||
* localStorage partitions: the top-level page (127.0.0.1) and the broker
|
||||
* iframe (nextgraph.net) — the browser partitions storage by top-level site,
|
||||
* so a value written top-level is NOT the value the iframe reads. localStorage
|
||||
* cannot cross that boundary. But the `@ng-org/web` redirect embeds the FULL
|
||||
* app URL (query included) in the broker `o=`, which is reloaded in the iframe
|
||||
* — so a URL param DOES cross. Hence resolution priority:
|
||||
* (1) `?id=<value>` in the URL — wins whenever present (crosses the frontier);
|
||||
* (2) else localStorage — same-partition convenience / prefill only.
|
||||
* When the param is present it also gets persisted to localStorage (same
|
||||
* partition, convenience) so a plain reload without the param still prefills.
|
||||
*
|
||||
* `login()` / `logout()` here only read/write that identifier in localStorage;
|
||||
* they NEVER call NextGraph (ng.session_stop / wallet_close) — the shared wallet
|
||||
* stays open underneath. The real logout lives, hidden, in Settings.
|
||||
*
|
||||
* The stored value IS the identity id handed to the SDK
|
||||
* (`setCurrentUser(identifier)`); it is the key the caps and the shim account
|
||||
* are keyed on. It holds this normalized identifier, not a mixed-case display
|
||||
* handle.
|
||||
*
|
||||
* 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, useMemo, useEffect, type ReactNode } from 'react';
|
||||
// The SDK's framework-agnostic IdentityStore persists the current identity id
|
||||
// (localStorage-backed). This file keeps the React Context/Provider glue and the
|
||||
// Festipod identifier handle; `normalizeIdentifier` (the handle → id mapping) is
|
||||
// the app's own choice. See decision_2026-06-17_eventually-library.
|
||||
import { accounts } from '@ng-eventually/client';
|
||||
// Set the current identity on the SDK: the app tells NextGraph WHO is reading, so
|
||||
// the SDK returns only the data this identity is authorized to see (isolation is
|
||||
// the SDK's job — see knowledge_trust-model). This is the SDK's "current
|
||||
// identity" call, not an access rule the app enforces itself.
|
||||
import { setCurrentUser } from '@ng-eventually/client/polyfill';
|
||||
|
||||
// Festipod localStorage key for the current identifier (same-partition
|
||||
// prefill/convenience only — never the cross-frontier carrier; that's the URL
|
||||
// param). Renamed to `.identifier` from a historical key that mislabeled this
|
||||
// account id → any pre-existing stored logins under the old key are dropped
|
||||
// (acceptable: this is a stopgap test env; the URL param carries identity anyway).
|
||||
const STORAGE_KEY = 'festipod.account.identifier';
|
||||
|
||||
/** Name of the URL param that carries the identifier across the broker frontier. */
|
||||
const ID_PARAM = 'id';
|
||||
|
||||
/** Normalise an identifier handle into the identity id the SDK is given. */
|
||||
export function normalizeIdentifier(identifier: string | null | undefined): string {
|
||||
return (identifier ?? '').trim().replace(/^@+/, '').toLowerCase();
|
||||
}
|
||||
|
||||
/** Read the `?id=` URL param (source of truth), normalized. Null when absent. */
|
||||
function identifierFromUrl(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const raw = new URLSearchParams(window.location.search).get(ID_PARAM);
|
||||
if (raw == null) return null;
|
||||
const norm = normalizeIdentifier(raw);
|
||||
return norm.length > 0 ? norm : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AccountContextValue {
|
||||
/** App-level identity (the perceived "login"). null = not connected. */
|
||||
identifier: string | null;
|
||||
/** Faux login — persists the identifier. No NextGraph call. */
|
||||
login: (identifier: string) => void;
|
||||
/** Faux logout — clears the identifier only. No NextGraph call. */
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
/** Browser-safe storage (null in SSR → the store degrades to non-persisting). */
|
||||
function makeStore(): accounts.IdentityStore {
|
||||
const ls = typeof window !== 'undefined' ? window.localStorage : null;
|
||||
return new accounts.IdentityStore(ls, STORAGE_KEY);
|
||||
}
|
||||
|
||||
const AccountContext = createContext<AccountContextValue>({
|
||||
identifier: null,
|
||||
login: () => {},
|
||||
logout: () => {},
|
||||
});
|
||||
|
||||
export function AccountProvider({ children }: { children: ReactNode }) {
|
||||
const store = useMemo(() => makeStore(), []);
|
||||
// Resolution priority at init: (1) URL param `?id=` (source of truth, crosses
|
||||
// the top-level↔iframe frontier), then (2) localStorage (same-partition
|
||||
// convenience). The URL param wins whenever present.
|
||||
const [identifier, setIdentifier] = useState<string | null>(() => {
|
||||
return identifierFromUrl() ?? store.get();
|
||||
});
|
||||
|
||||
// If the URL param is present, it is authoritative: persist it to localStorage
|
||||
// (same partition, convenience for a subsequent plain reload without the param)
|
||||
// so the store and the resolved identity agree. Runs once at mount.
|
||||
useEffect(() => {
|
||||
const fromUrl = identifierFromUrl();
|
||||
if (fromUrl && fromUrl !== store.get()) {
|
||||
store.set(fromUrl);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Tell the SDK who the current identity is, on mount and whenever the account
|
||||
// changes (login/logout). The SDK uses it to gate reads to what this identity
|
||||
// may see; the app performs no access check of its own. Normalize the
|
||||
// identifier handle into the identity id everything else uses.
|
||||
useEffect(() => {
|
||||
setCurrentUser(identifier ? normalizeIdentifier(identifier) : null);
|
||||
}, [identifier]);
|
||||
|
||||
const login = useCallback((name: string) => {
|
||||
// The identifier is normalized (trimmed, `@`-stripped, lowercased) at the
|
||||
// door, so the stored value IS the identity id — the same key the SDK, the
|
||||
// caps and the shim account are keyed on. No mixed-case handle to reconcile.
|
||||
const next = store.set(normalizeIdentifier(name));
|
||||
if (next) setIdentifier(next);
|
||||
}, [store]);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
store.clear();
|
||||
setIdentifier(null);
|
||||
}, [store]);
|
||||
|
||||
return (
|
||||
<AccountContext.Provider value={{ identifier, login, logout }}>
|
||||
{children}
|
||||
</AccountContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAccount(): AccountContextValue {
|
||||
return useContext(AccountContext);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
FpNotificationData,
|
||||
} from '../data/types';
|
||||
import {
|
||||
hostInboxNuri,
|
||||
depositRegistration,
|
||||
depositLeave,
|
||||
buildNotification,
|
||||
@@ -19,8 +18,8 @@ import {
|
||||
countUserParticipations,
|
||||
canonicalEventId,
|
||||
} from '../data/registration';
|
||||
import { inbox, isNuri } from '@ng-eventually/client';
|
||||
import type { Nuri } from '@ng-eventually/client';
|
||||
import { inbox } from '@ng-eventually/polyfill';
|
||||
import type { Nuri } from '@ng-eventually/polyfill';
|
||||
import {
|
||||
CURRENT_USER_ID,
|
||||
seedEvents,
|
||||
@@ -30,12 +29,16 @@ import {
|
||||
seedFriendships,
|
||||
} from '../data/seedData';
|
||||
import { useNextGraph } from './NextGraphContext';
|
||||
import { useAccount, normalizeIdentifier } from './AccountContext';
|
||||
import { normalizeIdentifier } from '../utils/identifier';
|
||||
// Relationship is a Festipod concept: the app keeps its own bilateral registry
|
||||
// and shares its own documents' keys with its neighbours (see shared/utils/connections).
|
||||
import { declareConnections } from '../utils/connections';
|
||||
import { listMyEntityDocs, createEntityDoc, resetRegistryCache } from '../utils/storeRegistry';
|
||||
import { resetCaps } from '@ng-eventually/client/polyfill';
|
||||
import {
|
||||
listMyEntityDocs,
|
||||
createEntityDoc,
|
||||
openDocumentInbox,
|
||||
} from '../utils/storeRegistry';
|
||||
import { useCurrentPrincipal } from '../utils/currentPrincipal';
|
||||
import { useShapeQuery } from '../data/useShapeQuery';
|
||||
import { adaptEvents, adaptUsers, adaptParticipations } from '../data/shapeAdapters';
|
||||
import {
|
||||
@@ -54,6 +57,13 @@ import { autoSeedEnabled, shouldAutoSeed } from '../utils/autoSeed';
|
||||
interface FestipodDataContextValue {
|
||||
currentUserId: string;
|
||||
currentUser: FpUserData | undefined;
|
||||
/**
|
||||
* WHO THE SESSION SIGNED IN AS — the identity `ensureIdentity()` returned,
|
||||
* known before any document is read. For DISPLAY (and log attribution) only:
|
||||
* it is a different id space from `currentUserId` (a profile document NURI),
|
||||
* it is never written into an entity, and no data call takes it.
|
||||
*/
|
||||
currentPrincipal: string;
|
||||
|
||||
events: FpEventData[];
|
||||
users: FpUserData[];
|
||||
@@ -99,15 +109,16 @@ function nextId(prefix: string): string {
|
||||
return `${prefix}-${++idCounter}`;
|
||||
}
|
||||
|
||||
// The STABLE user-principal prefix. A Participation stores its user (`fp:user`) as
|
||||
// this principal derived from the login identifier — `urn:festipod:user:<key>` —
|
||||
// NOT as the UserProfile's `did:ng:` document NURI. `currentUserId` is minted with
|
||||
// the SAME prefix below, so a participation keyed on it stays consistent with the
|
||||
// identity the SDK/caps derive. The single source of truth for the prefix, shared
|
||||
// by the WRITE (currentUserId) and the READ (resolveParticipantUser) so they never
|
||||
// drift.
|
||||
// LEGACY, READ ONLY. Participations written by an older version key their user
|
||||
// (`fp:user`) as `urn:festipod:user:<key>` instead of the UserProfile's `did:ng:`
|
||||
// document NURI. Nothing MINTS this any more — today's writes carry the profile
|
||||
// NURI (`currentUserId`) — but `resolveParticipantUser` must still recognize the
|
||||
// old form, or those participations render as "participant inconnu".
|
||||
const USER_PRINCIPAL_PREFIX = 'urn:festipod:user:';
|
||||
|
||||
/** Waits between attempts at resolving the owned-event set (see its effect). */
|
||||
const OWNED_RETRY_BACKOFF_MS = [500, 1500, 4000];
|
||||
|
||||
/**
|
||||
* Resolve a Participation's `fp:user` to its UserProfile across the TWO id spaces
|
||||
* that meet at this join (the root cause of the "unknown participant" bug):
|
||||
@@ -198,7 +209,6 @@ function buildQueries(
|
||||
// ============================================================================
|
||||
|
||||
function useLocalData(empty?: boolean): FestipodDataContextValue {
|
||||
const { identifier } = useAccount();
|
||||
const [selectedEventId, setSelectedEventId] = useState<string>(empty ? '' : 'event-1');
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||
|
||||
@@ -208,15 +218,16 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
|
||||
const meetingPoints = empty ? [] : seedMeetingPoints;
|
||||
const friendships = empty ? [] : seedFriendships;
|
||||
|
||||
// Resolve current user from the chosen account identifier; fall back to the
|
||||
// demo default so @ui tests and standalone dev keep working unchanged.
|
||||
const accountUser = identifier
|
||||
? users.find(u => normalizeIdentifier(u.username) === normalizeIdentifier(identifier))
|
||||
: undefined;
|
||||
const currentUserId = empty ? '' : (accountUser?.id ?? CURRENT_USER_ID);
|
||||
// Identity-first log prefix: current user id when resolved, else the bare
|
||||
// `[app][data]` form (e.g. the transient `empty` connecting state).
|
||||
const logPrefix = currentUserId ? `[${currentUserId}][app][data]` : '[app][data]';
|
||||
// Demo mode has a single fixture user — the app names no identity of its own.
|
||||
const currentUserId = empty ? '' : CURRENT_USER_ID;
|
||||
// Who signed in, when the barrier has settled. Demo mode reads no document, so
|
||||
// this is the only real identity it can attribute a log line to.
|
||||
const currentPrincipal = useCurrentPrincipal();
|
||||
// Identity-first log prefix: the fixture user when there is one, else the
|
||||
// signed-in principal, else the bare `[app][data]` form.
|
||||
const logPrefix = currentUserId || currentPrincipal
|
||||
? `[${currentUserId || currentPrincipal}][app][data]`
|
||||
: '[app][data]';
|
||||
const currentUser = users.find(u => u.id === currentUserId);
|
||||
const selectedEvent = events.find(e => e.id === selectedEventId);
|
||||
const selectedUser = users.find(u => u.id === selectedUserId);
|
||||
@@ -256,7 +267,7 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
|
||||
}, []);
|
||||
|
||||
return {
|
||||
currentUserId, currentUser,
|
||||
currentUserId, currentUser, currentPrincipal,
|
||||
events, users, participations, meetingPoints, friendships,
|
||||
notifications: [],
|
||||
selectedEventId, setSelectedEventId, selectedEvent,
|
||||
@@ -273,7 +284,6 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
|
||||
|
||||
function useNgData(): FestipodDataContextValue {
|
||||
const { session } = useNextGraph();
|
||||
const { identifier } = useAccount();
|
||||
// The app speaks ONLY in logical scopes — it holds no store id and builds no
|
||||
// `did:ng:${…}` NURI. It creates ONE document PER ENTITY in its scope
|
||||
// (`createEntityDoc(scope)`, the SDK create) and READS via the SDK's reactive,
|
||||
@@ -380,39 +390,15 @@ function useNgData(): FestipodDataContextValue {
|
||||
const readReady =
|
||||
eventQuery.isSuccess && userQuery.isSuccess && partQuery.isSuccess;
|
||||
|
||||
// IDENTITY SWITCH = FRESH SESSION (isolation). The shared-wallet stopgap keeps
|
||||
// ONE React tree across a faux logout + re-login under a DIFFERENT identifier (no
|
||||
// page reload — see AuthGate/AccountContext). `watchShape` re-resolves its scope
|
||||
// to the new `getCurrentUser()` on the next container/index push, but the
|
||||
// emulated caps + registry cache and the app-side owned-events set must be reset
|
||||
// so nothing from the old identity lingers. Ref-guarded so it fires only on a
|
||||
// real change, not on the first mount.
|
||||
// Session-local map `${eventId}|${userId}` → the join deposit's uid, so a leave
|
||||
// in the SAME session can carry `regUid` for a precise cancellation. Absent it
|
||||
// (cross-session leave), the owner's materializer falls back to (event, user)
|
||||
// matching — so this is an optimization, not a correctness dependency.
|
||||
//
|
||||
// Nothing ever clears this map, and nothing needs to: a browser context is one
|
||||
// user for its whole life (`ensureIdentity()` answers once, before anything
|
||||
// renders), so there is never anyone else's leftovers in it.
|
||||
const joinUidsRef = useRef<Map<string, string>>(new Map());
|
||||
const prevOwnerRef = useRef<string | null | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (prevOwnerRef.current === undefined) {
|
||||
prevOwnerRef.current = identifier;
|
||||
return;
|
||||
}
|
||||
if (prevOwnerRef.current === identifier) return;
|
||||
prevOwnerRef.current = identifier;
|
||||
// Fresh session for the new identity: reset the emulated isolation state and
|
||||
// the owned-events set. `watchShape` re-resolves reads for the new identity on
|
||||
// its own (scope re-resolution keyed on `getCurrentUser()`).
|
||||
setOwnedEventIds([]);
|
||||
joinUidsRef.current.clear();
|
||||
// Drop the optimistic overlay too: it belongs to the OLD identity's session
|
||||
// and must not bleed into the new identity's reads (isolation).
|
||||
setPendingAddEvents([]);
|
||||
setPendingAddParticipations([]);
|
||||
setPendingRemoveIds(new Set());
|
||||
resetCaps();
|
||||
resetRegistryCache();
|
||||
}, [identifier]);
|
||||
|
||||
// OPTION B — the set of event docs the CURRENT identity OWNS (its own public
|
||||
// event docs). Each such NURI IS the event `@id` (writeEntity uses the doc NURI
|
||||
@@ -420,26 +406,65 @@ function useNgData(): FestipodDataContextValue {
|
||||
// and writes `participantCount` on THAT (owned) doc — never on someone else's.
|
||||
const [ownedEventIds, setOwnedEventIds] = useState<Nuri[]>([]);
|
||||
|
||||
/**
|
||||
* Fold documents THIS SESSION just created into the owned set.
|
||||
*
|
||||
* The backfill below runs once, on connection, so it only ever knows what
|
||||
* existed at mount. Everything created afterwards has to announce itself, or it
|
||||
* is owned in fact and unowned as far as this tree is concerned — and the
|
||||
* owner-materializer, which only ever looks at this set, never runs on it.
|
||||
*/
|
||||
const claimOwnedEventDocs = useCallback((created: Nuri[]) => {
|
||||
if (created.length === 0) return;
|
||||
setOwnedEventIds(prev => [...new Set([...prev, ...created])]);
|
||||
}, []);
|
||||
|
||||
// Resolve the CURRENT identity's owned public event docs for the materializer
|
||||
// ONLY (decoupled from the read — `watchShape` resolves reads itself). Bounded to
|
||||
// the current account (`listMyEntityDocs(owner, 'public')`, NO cross-account
|
||||
// fan-out). Runs on (re)login to backfill events owned before this mount;
|
||||
// `createEvent` appends freshly-created events directly. This is NOT a read path
|
||||
// (it feeds no `events`/`users`/`participations`), only the owner-count derivation.
|
||||
// this session's own documents (`listMyEntityDocs('public')` — "mine" needs no
|
||||
// identity, the session is one user's). Runs on connection to backfill events
|
||||
// owned before this mount; `createEvent` and the seed (`loadTestData` /
|
||||
// auto-seed) claim what they create through `claimOwnedEventDocs`. This is NOT a
|
||||
// read path (it feeds no `events`/`users`/`participations`), only the
|
||||
// owner-count derivation.
|
||||
//
|
||||
// A REJECTION HERE MEANS "UNKNOWN", NEVER "THIS SESSION OWNS NOTHING". The two
|
||||
// are indistinguishable downstream — both leave the set empty — but only one of
|
||||
// them is true, and taking the wrong one silently disables the owner
|
||||
// materializer, so every event this session hosts stops converging. So the
|
||||
// failure is RETRIED, and if it still will not answer, it is said loudly instead
|
||||
// of leaving a plausible-looking empty set behind.
|
||||
useEffect(() => {
|
||||
if (!ready || !identifier) return;
|
||||
if (!ready) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const myPublic = await listMyEntityDocs(identifier, 'public');
|
||||
if (cancelled) return;
|
||||
setOwnedEventIds(prev => [...new Set([...prev, ...myPublic])]);
|
||||
} catch (err) {
|
||||
console.error(`${logPrefix} owned-events resolution failed:`, err);
|
||||
for (let attempt = 0; !cancelled; attempt++) {
|
||||
try {
|
||||
const myPublic = await listMyEntityDocs('public');
|
||||
if (cancelled) return;
|
||||
setOwnedEventIds(prev => [...new Set([...prev, ...myPublic])]);
|
||||
return;
|
||||
} catch (err) {
|
||||
const wait = OWNED_RETRY_BACKOFF_MS[attempt];
|
||||
if (wait === undefined) {
|
||||
console.error(
|
||||
`${logPrefix} owned-events resolution FAILED after ${OWNED_RETRY_BACKOFF_MS.length + 1} ` +
|
||||
`attempts — the owned set is UNKNOWN, not empty, and events hosted by this session ` +
|
||||
`will not converge until it is known:`,
|
||||
err,
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.warn(
|
||||
`${logPrefix} owned-events resolution failed (attempt ${attempt + 1}) — retrying in ${wait}ms:`,
|
||||
err,
|
||||
);
|
||||
await new Promise(r => setTimeout(r, wait));
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [ready, identifier]);
|
||||
}, [ready]);
|
||||
|
||||
// Not in SHEX shapes yet
|
||||
const [meetingPoints, setMeetingPoints] = useState<FpMeetingPointData[]>([]);
|
||||
@@ -484,35 +509,57 @@ function useNgData(): FestipodDataContextValue {
|
||||
// Enabled AND synced-empty → a real empty wallet. Seed once.
|
||||
hasTriedAutoSeed.current = true;
|
||||
console.log(`${logPrefix} Auto-seed (FESTIPOD_AUTO_SEED): wallet empty (synced), bootstrapping…`);
|
||||
bootstrapWallet(false, createEntityDoc, identifier || undefined)
|
||||
bootstrapWallet(false, createEntityDoc)
|
||||
// The seed's public event docs were created BY THIS SESSION — they are mine.
|
||||
.then(result => claimOwnedEventDocs(result.createdDocs.public))
|
||||
.catch(err => console.error(`${logPrefix} Auto-seed failed:`, err));
|
||||
// The reactive `watchShape` reads pick the seeded per-entity docs up on their
|
||||
// own (each createEntityDoc appends to the scope index → the container-index
|
||||
// subscription re-resolves → the new docs enter the read). No registerDoc/relist.
|
||||
}, [ready, readReady, events.length, users.length, identifier]);
|
||||
}, [ready, readReady, events.length, users.length, claimOwnedEventDocs]);
|
||||
|
||||
// --- Derived ---
|
||||
// Resolve current user from the chosen account identifier (the perceived
|
||||
// login); fall back to the legacy default while the account layer hydrates.
|
||||
// WHO AM I — answered in TWO id spaces that must not be confused.
|
||||
//
|
||||
// (1) `currentPrincipal` — what signing in returned. Known as soon as the one
|
||||
// identity await settles, i.e. before any document has been read. It names
|
||||
// a PERSON. It is for display and log attribution; no data call takes it,
|
||||
// and it is never written into an entity.
|
||||
// (2) `currentUserId` — the app's own entity space: the `@id` of the profile
|
||||
// DOCUMENT read back in the protected scope (a doc NURI). This is what a
|
||||
// Participation's `fp:user` carries and what `resolveParticipantUser`
|
||||
// matches directly, so it is the only value a mutation may write. It stays
|
||||
// empty until the protected read lands — mutations that need it refuse
|
||||
// rather than write an entity the read would drop.
|
||||
//
|
||||
// THE JOIN between the two is explicit and lives HERE, in one place: a profile
|
||||
// belongs to the signed-in person when its username normalizes to the
|
||||
// principal — the same bridge `resolveParticipantUser` uses for the legacy
|
||||
// `urn:festipod:user:` space. Nothing merges the spaces: the principal selects
|
||||
// a profile, it never stands in for one.
|
||||
const currentPrincipal = useCurrentPrincipal();
|
||||
const currentUser =
|
||||
(identifier ? users.find(u => normalizeIdentifier(u.username) === normalizeIdentifier(identifier)) : undefined)
|
||||
(currentPrincipal
|
||||
? users.find(u => u.username && normalizeIdentifier(u.username) === currentPrincipal)
|
||||
: undefined)
|
||||
// No profile answers to the signed-in person (the wallet holds fixtures, or
|
||||
// the profile read has not landed): fall back to the demo-seed pick. KNOWN
|
||||
// HAZARD — this GUESSES a profile, so the app can show the wrong person as
|
||||
// "you" while the real answer has simply not been read yet. Note what is and
|
||||
// is not guessed: the identity itself never is (it is exactly what
|
||||
// `ensureIdentity()` returned); only the profile it selects can be wrong.
|
||||
|| users.find(u => u.username === '@mariedupont')
|
||||
|| users[0];
|
||||
// The current user's PRINCIPAL. When logged in, this is a STABLE
|
||||
// identifier-derived id (`urn:festipod:user:<normalized-identifier>`) —
|
||||
// available IMMEDIATELY (no dependency on the protected profile read, which can
|
||||
// lag) and INVARIANT (it never flips from a fallback to the profile IRI
|
||||
// mid-session, which would desync a participation written under one value from
|
||||
// a check under the other). It is the SAME principal the SDK identity
|
||||
// (`setCurrentUser`) and the cap owner derive from the identifier, so
|
||||
// participations keyed on it are consistent with reads and isolation. Falls
|
||||
// back to the read profile's IRI only when there is no login (dev/demo).
|
||||
const currentUserId =
|
||||
(identifier ? `${USER_PRINCIPAL_PREFIX}${normalizeIdentifier(identifier)}` : (currentUser?.id || ''));
|
||||
const currentUserId = currentUser?.id || '';
|
||||
// Identity-first log prefix, reused by every DATA log below (including the
|
||||
// closures defined earlier in this function body — they only execute after
|
||||
// this render has finished, by which point `logPrefix` is initialized).
|
||||
const logPrefix = currentUserId ? `[${currentUserId}][app][data]` : '[app][data]';
|
||||
// this render has finished, by which point `logPrefix` is initialized). Several
|
||||
// browser contexts can be tailed at once, so a line must say WHOSE it is: the
|
||||
// profile NURI once it is read, and the signed-in principal before that — which
|
||||
// is now from the very first render, instead of the anonymous `[app][data]`.
|
||||
const logPrefix = currentUserId || currentPrincipal
|
||||
? `[${currentUserId || currentPrincipal}][app][data]`
|
||||
: '[app][data]';
|
||||
const selectedEvent = events.find(e => e.id === selectedEventId);
|
||||
// DISPLAY READ — log participantCount exactly as currently exposed for
|
||||
// rendering. Compared against the owner-materializer's WRITE logs below, this
|
||||
@@ -534,11 +581,8 @@ function useNgData(): FestipodDataContextValue {
|
||||
// writing the event doc: the joiner only deposits; the owner counts.
|
||||
//
|
||||
// Reactive, no polling: subscribe the inbox document via `inbox.watch` (a
|
||||
// `doc_subscribe` push). Today all events share ONE inbox anchor (`hostInboxNuri`
|
||||
// ignores the eventId → `resolveInboxAnchor()`), so ONE subscription serves all
|
||||
// my owned events; each push re-materializes every owned event from the full
|
||||
// deposit list. At per-event-inbox migration this fans to one watch per owned
|
||||
// event (still one doc each).
|
||||
// `doc_subscribe` push). One watch PER owned event: each event has its OWN
|
||||
// inbox, whose address its owner obtains with `openDocumentInbox`.
|
||||
//
|
||||
// IDEMPOTENCE / CONVERGENCE: the count is DERIVED from the SET of distinct
|
||||
// active registrations (`materializeAttendance`: distinct join uids MINUS
|
||||
@@ -586,9 +630,10 @@ function useNgData(): FestipodDataContextValue {
|
||||
);
|
||||
const notifs: FpNotificationData[] = [];
|
||||
for (const evId of owned) {
|
||||
// Each event has its OWN inbox document (`documentInbox`), resolved from
|
||||
// the event doc I own. There is no anchor common to every event any more.
|
||||
const targetInbox = await hostInboxNuri(evId);
|
||||
// Each event has its OWN inbox, and only its OWNER can open it. This
|
||||
// call returns the address the owner reads and watches; a depositor
|
||||
// never sees it (they name the document instead).
|
||||
const targetInbox = await openDocumentInbox(evId);
|
||||
// BEFORE — the event's readable detail (short id + title) and the
|
||||
// participantCount value as currently READ/exposed (the app-side `events`
|
||||
// state), captured before this cycle's derive+write. Comparing this to the
|
||||
@@ -683,7 +728,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
const unsubscribes: Array<() => void> = [];
|
||||
(async () => {
|
||||
for (const evId of owned) {
|
||||
const targetInbox = await hostInboxNuri(evId);
|
||||
const targetInbox = await openDocumentInbox(evId);
|
||||
if (cancelled) return;
|
||||
unsubscribes.push(inbox.watch(targetInbox, () => void materialize('inbox-push')));
|
||||
}
|
||||
@@ -702,18 +747,15 @@ function useNgData(): FestipodDataContextValue {
|
||||
// the resulting per-document grants. No store id, no document NURI crosses here.
|
||||
useEffect(() => {
|
||||
if (!ready || !currentUserId) return;
|
||||
// Connection ids must be the SAME key space as the cap owners: each doc is
|
||||
// opened with `normalizeIdentifier(owner)`, and the reader identity is set via
|
||||
// `setCurrentUser(normalizeIdentifier(identifier))`. The app models
|
||||
// friendships with user IRIs, so map each peer IRI → its id key before
|
||||
// declaring, and assert AS the current user's id key. Peers with no known id
|
||||
// are skipped (can't be keyed). This is what makes "protected = my bilateral
|
||||
// connections" actually discriminate in @data.
|
||||
// `inbox.share(doc, toUser)` names a PERSON, so the app models both sides of
|
||||
// a relationship in ONE key space: the normalized profile username. It maps
|
||||
// each peer IRI → that key before declaring, and asserts AS its own key.
|
||||
// Peers with no known profile are skipped (they cannot be named).
|
||||
const idKeyOf = (userIri: string): string | undefined => {
|
||||
const u = users.find(x => x.id === userIri);
|
||||
return u?.username ? normalizeIdentifier(u.username) : undefined;
|
||||
};
|
||||
const selfKey = identifier ? normalizeIdentifier(identifier) : idKeyOf(currentUserId);
|
||||
const selfKey = idKeyOf(currentUserId);
|
||||
if (!selfKey) return;
|
||||
const myPeers = friendships
|
||||
.filter(f => f.userId === currentUserId || f.friendId === currentUserId)
|
||||
@@ -724,7 +766,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
// fire-and-forget from this effect — nothing downstream waits on it.
|
||||
void declareConnections(myPeers, selfKey)
|
||||
.catch(err => console.error(`${logPrefix} declareConnections failed:`, err));
|
||||
}, [ready, friendships, currentUserId, users, identifier]);
|
||||
}, [ready, friendships, currentUserId, users]);
|
||||
|
||||
const queries = buildQueries(
|
||||
events, users, participations, meetingPoints, friendships, currentUserId,
|
||||
@@ -744,18 +786,23 @@ function useNgData(): FestipodDataContextValue {
|
||||
|
||||
const createEvent = useCallback(async (event: Omit<FpEventData, 'id'>): Promise<FpEventData> => {
|
||||
console.log(`${logPrefix} createEvent (NG):`, event.title);
|
||||
// Owner principal = the account identifier (what setCurrentUser declares). The
|
||||
// SDK create returns THIS entity's OWN public document and declares its
|
||||
// ReadCap policy (public → world-readable). Fall back to a generic account
|
||||
// label when no login is present (dev/demo).
|
||||
const owner = identifier || currentUserId || 'anon';
|
||||
// The SDK create returns THIS entity's OWN public document and declares its
|
||||
// access policy (public → world-readable). Placement is named by SCOPE
|
||||
// alone: the session belongs to one user, so there is no owner to name.
|
||||
// A creation that cannot be recorded THROWS — it never hands back a
|
||||
// reference that would read empty forever — so this rejects the whole
|
||||
// `createEvent` rather than returning a half-made event.
|
||||
// Create the event's OWN document in the PUBLIC scope (one doc per entity),
|
||||
// then WRITE the event RDF DIRECTLY into that document (writeEntity) — not via
|
||||
// the scope-coupled `ngSet.add`, which can't write into a not-yet-subscribed
|
||||
// per-entity doc against the real broker. The doc's NURI is appended to the
|
||||
// public scope index, which `watchShape('public')` subscribes → the event
|
||||
// enters the reactive read on the push. The written subject IRI is the `@id`.
|
||||
const eventGraph = await createEntityDoc(owner, 'public');
|
||||
const eventGraph = await createEntityDoc('public');
|
||||
// An event is a document people SIGN UP TO, so its owner opens its inbox
|
||||
// here, at creation. A document only HAS one if its owner opened it; without
|
||||
// this, a registrant's deposit would have nothing to reach.
|
||||
await openDocumentInbox(eventGraph);
|
||||
const eventId = await writeEntity(eventGraph, ENTITY_TYPE.event, {
|
||||
title: str(event.title), description: str(event.description), date: str(event.date),
|
||||
location: str(event.location), distance: flt(event.distance),
|
||||
@@ -793,7 +840,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
// work. Until it lands, a created event is reachable by its creator only.
|
||||
}
|
||||
return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` };
|
||||
}, [currentUserId, identifier]);
|
||||
}, [currentUserId]);
|
||||
|
||||
const updateEvent = useCallback(async (id: string, updates: Partial<FpEventData>) => {
|
||||
console.log(`${logPrefix} updateEvent (NG):`, id, updates);
|
||||
@@ -801,13 +848,8 @@ function useNgData(): FestipodDataContextValue {
|
||||
// it is both the write graph and the subject. Persist each provided mutable
|
||||
// field DIRECTLY via SPARQL (the durable write); `watchShape` re-reads on the
|
||||
// resulting broker push (the doc is already subscribed) — no manual re-query.
|
||||
// `id` reaches us as a plain domain string (it was read back off a document),
|
||||
// so narrow it here — the boundary where an untyped string becomes a NURI.
|
||||
// Anything that is not one names no document and cannot be written to.
|
||||
if (!isNuri(id)) {
|
||||
console.error(`${logPrefix} updateEvent: "${id}" is not a document NURI — nothing to write.`);
|
||||
return;
|
||||
}
|
||||
// No pre-flight NURI guard: every SDK entry takes the reference as it stands
|
||||
// and validates at its own door.
|
||||
const graph = id;
|
||||
const persists: Promise<void>[] = [];
|
||||
if (updates.participantCount !== undefined) {
|
||||
@@ -837,18 +879,24 @@ function useNgData(): FestipodDataContextValue {
|
||||
// The reactive participation set can lag a just-written participation, so a
|
||||
// second join checking only the set would write a DUPLICATE (breaking "exactly
|
||||
// one participation"). The broker query sees the real state regardless of lag.
|
||||
const already = await countUserParticipations(identifier || uid || 'anon', eventId, uid).catch(() => 0);
|
||||
//
|
||||
// A FAILED count is UNKNOWN, not zero, so it is NOT caught here: reading it as
|
||||
// "not participating yet" is precisely how a duplicate gets written. The
|
||||
// rejection propagates out of `joinEvent`, and the screen that called it says
|
||||
// the sign-up could not be recorded — which is the truth.
|
||||
const already = await countUserParticipations(eventId, uid);
|
||||
if (already > 0) {
|
||||
console.log(`${logPrefix} Already participating (broker-confirmed), skipping`);
|
||||
return;
|
||||
}
|
||||
// 1) Persist the Participation as its OWN document in the PROTECTED scope
|
||||
// (one doc per entity). Owner = the account identifier (setCurrentUser key).
|
||||
// Its NURI is appended to the protected scope index, which
|
||||
// `watchShape('protected')` subscribes → the participation enters the
|
||||
// reactive read on the push.
|
||||
const owner = identifier || uid || 'anon';
|
||||
const partGraph = await createEntityDoc(owner, 'protected');
|
||||
// (one doc per entity). Its NURI is appended to the protected scope index,
|
||||
// which `watchShape('protected')` subscribes → the participation enters
|
||||
// the reactive read on the push.
|
||||
// A creation that cannot be recorded THROWS, so a failure here rejects
|
||||
// `joinEvent` instead of depositing a registration for a participation
|
||||
// document that would read empty forever.
|
||||
const partGraph = await createEntityDoc('protected');
|
||||
// WRITE the participation RDF DIRECTLY into its own document (writeEntity) —
|
||||
// not via the scope-coupled `ngSet.add` (can't write a not-yet-subscribed
|
||||
// per-entity doc against the real broker). The written subject is the
|
||||
@@ -883,14 +931,8 @@ function useNgData(): FestipodDataContextValue {
|
||||
// we key the host inbox/notification on the eventId (the host of THAT
|
||||
// event). This is the domain injection the generic lib deliberately omits.
|
||||
const recipientId = eventId;
|
||||
// The event's `@id` IS its document NURI, and that document is what carries
|
||||
// the inbox. `eventId` arrives as a plain string (a caller's argument), so
|
||||
// narrow it here — the boundary. Throwing lands in this block's own catch,
|
||||
// which is already the "deposit is best-effort" contract.
|
||||
if (!isNuri(eventId)) {
|
||||
throw new Error(`event id "${eventId}" is not a document NURI — no event inbox to deposit into`);
|
||||
}
|
||||
const targetInbox = await hostInboxNuri(eventId);
|
||||
// The event's `@id` IS its document NURI, and a deposit NAMES that document
|
||||
// — the joiner resolves no inbox and holds no address.
|
||||
// Carry the joiner's participation-doc NURI so the owner (if a connection)
|
||||
// could read it in clear; the count itself does not depend on reading it.
|
||||
console.log(
|
||||
@@ -898,15 +940,18 @@ function useNgData(): FestipodDataContextValue {
|
||||
`event=${canonicalEventId(eventId)} user=${uid} (count now moves via the OWNER ` +
|
||||
`materializing this deposit on its own doc, at its next connection)`,
|
||||
);
|
||||
const { ts, uid: depositUid } = await depositRegistration(targetInbox, eventId, registrantId, partGraph);
|
||||
const { ts, uid: depositUid } = await depositRegistration(eventId, registrantId, partGraph);
|
||||
// Remember the join uid so a same-session leave can cancel it precisely.
|
||||
joinUidsRef.current.set(`${eventId}|${uid}`, depositUid);
|
||||
const notif = buildNotification(recipientId, eventId, registrantId, ts);
|
||||
// The host FpNotification is its OWN document in the PROTECTED scope (one
|
||||
// doc per entity). Best-effort — the inbox materialization is the source of
|
||||
// truth; this direct write only pre-warms the reactive read.
|
||||
const notifGraph = await createEntityDoc(owner, 'protected');
|
||||
await insertNotification(notifGraph, notif).catch(() => { /* data-level best-effort */ });
|
||||
// doc per entity). The inbox materialization remains the source of truth;
|
||||
// this direct write only pre-warms the reactive read — but a FAILED write is
|
||||
// not swallowed: it used to be dropped silently, and the line below then
|
||||
// surfaced a notification nothing had recorded. The rejection reaches the
|
||||
// catch under this block, which names it.
|
||||
const notifGraph = await createEntityDoc('protected');
|
||||
await insertNotification(notifGraph, notif);
|
||||
// Surface immediately in reactive state (materialization also refreshes it).
|
||||
// Use the stable per-deposit uid for the id (F5 dedup) so it matches the
|
||||
// notification id from the inbox and same-ms/anon deposits never collide.
|
||||
@@ -914,7 +959,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
} catch (err) {
|
||||
console.error(`${logPrefix} joinEvent inbox/notify failed:`, err);
|
||||
}
|
||||
}, [events, currentUserId, identifier]);
|
||||
}, [events, currentUserId]);
|
||||
|
||||
const leaveEvent = useCallback(async (eventId: string, userId?: string) => {
|
||||
const uid = userId || currentUserId;
|
||||
@@ -929,13 +974,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
// removes the Participation server-side so it does NOT resurrect after re-sync.
|
||||
// The delete targets the participation's own document (part.id) and is
|
||||
// identified by its OWN subject IRI (part.id), not a string-match on object IRIs.
|
||||
// `part.id` was read back off a document, so narrow it here (the boundary) —
|
||||
// a value that names no document cannot be the delete's anchor.
|
||||
const graphNuri = part.id;
|
||||
if (!isNuri(graphNuri)) {
|
||||
console.error(`${logPrefix} leaveEvent: participation id "${graphNuri}" is not a document NURI — refusing to delete.`);
|
||||
return;
|
||||
}
|
||||
const subjectIri = part.id;
|
||||
let result;
|
||||
try {
|
||||
@@ -979,12 +1018,8 @@ function useNgData(): FestipodDataContextValue {
|
||||
// is derived from the SET of distinct active registrations, not from −1).
|
||||
try {
|
||||
const registrantId = uid || null;
|
||||
// Same boundary as `joinEvent`: narrow the event id before resolving the
|
||||
// document's inbox; the throw lands in this block's own best-effort catch.
|
||||
if (!isNuri(eventId)) {
|
||||
throw new Error(`event id "${eventId}" is not a document NURI — no event inbox to deposit into`);
|
||||
}
|
||||
const targetInbox = await hostInboxNuri(eventId);
|
||||
// Same as `joinEvent`: the leave marker NAMES the event document; no inbox
|
||||
// address is resolved on the depositor's side.
|
||||
// Carry the join uid when this session minted it (precise cancellation);
|
||||
// otherwise the owner falls back to (eventId, userId) matching.
|
||||
const regUid = joinUidsRef.current.get(`${eventId}|${uid}`);
|
||||
@@ -992,7 +1027,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
`${logPrefix} leaveEvent — depositing participation leave marker into event inbox: ` +
|
||||
`event=${canonicalEventId(eventId)} user=${uid} regUid=${regUid ?? '(none)'}`,
|
||||
);
|
||||
await depositLeave(targetInbox, eventId, registrantId, regUid);
|
||||
await depositLeave(eventId, registrantId, regUid);
|
||||
joinUidsRef.current.delete(`${eventId}|${uid}`);
|
||||
} catch (err) {
|
||||
console.error(`${logPrefix} leaveEvent inbox deposit failed:`, err);
|
||||
@@ -1001,7 +1036,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
// DELETE pushes → the reactive read drops it (`isParticipating` reflects it).
|
||||
// The count itself follows the owner's materialization of the leave marker
|
||||
// (reactive, cross-session).
|
||||
}, [participations, events, currentUserId, identifier]);
|
||||
}, [participations, events, currentUserId]);
|
||||
|
||||
const addMeetingPoint = useCallback((mp: Omit<FpMeetingPointData, 'id'>) => {
|
||||
setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]);
|
||||
@@ -1022,13 +1057,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
// The current user's profile is its own document (subject IRI = doc NURI).
|
||||
const target = currentUser ?? users[0];
|
||||
if (!target) return;
|
||||
// Same boundary as `updateEvent`: the profile `@id` comes back as a plain
|
||||
// string from the read, so narrow it before it is used as a write target.
|
||||
const graph = target.id;
|
||||
if (!isNuri(graph)) {
|
||||
console.error(`${logPrefix} updateProfile: "${graph}" is not a document NURI — nothing to write.`);
|
||||
return;
|
||||
}
|
||||
const persists: Promise<void>[] = [];
|
||||
if (updates.name !== undefined) persists.push(updateEntityField(graph, graph, 'name', str(updates.name)));
|
||||
if (updates.initials !== undefined) persists.push(updateEntityField(graph, graph, 'initials', str(updates.initials)));
|
||||
@@ -1045,15 +1074,20 @@ function useNgData(): FestipodDataContextValue {
|
||||
// the window where the auto-seed effect could also fire on a still-empty read).
|
||||
hasTriedAutoSeed.current = true;
|
||||
const walletHasData = events.length > 0 || users.length > 0;
|
||||
const result = await bootstrapWallet(walletHasData, createEntityDoc, identifier || undefined);
|
||||
const result = await bootstrapWallet(walletHasData, createEntityDoc);
|
||||
// The seeded per-entity docs are appended to their scope indices, which
|
||||
// `watchShape` subscribes → they enter the reactive reads on the push. No
|
||||
// manual registration / re-list.
|
||||
// OWNERSHIP is a different question from READING, and it is not answered by
|
||||
// the push: these public event docs were created by THIS session, so claim
|
||||
// them — otherwise the owner-materializer never opens their inboxes and their
|
||||
// `participantCount` is never derived.
|
||||
claimOwnedEventDocs(result.createdDocs.public);
|
||||
return result;
|
||||
}, [events.length, users.length, identifier]);
|
||||
}, [events.length, users.length, claimOwnedEventDocs]);
|
||||
|
||||
return {
|
||||
currentUserId, currentUser,
|
||||
currentUserId, currentUser, currentPrincipal,
|
||||
events, users,
|
||||
participations,
|
||||
meetingPoints,
|
||||
@@ -1083,15 +1117,28 @@ function NgDataProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
export function FestipodDataProvider({ children }: { children: ReactNode }) {
|
||||
const { status } = useNextGraph();
|
||||
// No identity resolved at this level (only NG connection status is known here) —
|
||||
// identity-first prefix falls back to the bare `[app][data]` form.
|
||||
console.log('[app][data] Provider — NG status:', status);
|
||||
// SIGNING IN MUST HAVE SETTLED BEFORE THE DATA PROVIDER STARTS. Every call the
|
||||
// NG provider makes addresses "MY documents" — placement by scope
|
||||
// (`createEntityDoc` / `listMyEntityDocs`) and the reactive scope reads alike —
|
||||
// and there is no "my" until `ensureIdentity()` has answered. Being CONNECTED is
|
||||
// not being SIGNED IN: the session opens first, the identity settles after. A
|
||||
// provider mounted in between fires its reads and its owned-documents
|
||||
// resolution against no identity — they fail once, at mount, and the memoized
|
||||
// observables never retry, so the screens stay empty for the whole session
|
||||
// while the writes that come later succeed.
|
||||
//
|
||||
// The order is carried HERE, by which provider is mounted, rather than by a
|
||||
// guard repeated in every effect — the invariant cannot then be forgotten by
|
||||
// the next call site.
|
||||
const signedIn = useCurrentPrincipal() !== '';
|
||||
console.log('[app][data] Provider — NG status:', status, '| signed in:', signedIn);
|
||||
|
||||
if (status === 'connected') {
|
||||
if (status === 'connected' && signedIn) {
|
||||
return <NgDataProvider>{children}</NgDataProvider>;
|
||||
}
|
||||
if (status === 'connecting') {
|
||||
// NG initializing: show empty state (no misleading seed data flash)
|
||||
if (status === 'connecting' || status === 'connected') {
|
||||
// NG initializing, or connected but not signed in yet: show empty state (no
|
||||
// misleading seed data flash, and no read issued before there is a "my").
|
||||
return <LocalDataProvider empty>{children}</LocalDataProvider>;
|
||||
}
|
||||
// Disconnected or error: demo mode with seed data
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
|
||||
import { session, sessionPromise, init as initNg, type NextGraphSession } from '../utils/ngSession';
|
||||
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
|
||||
import { session, sessionPromise, startNgSession, type NextGraphSession } from '../utils/ngSession';
|
||||
|
||||
type NgStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
@@ -7,17 +7,15 @@ interface NextGraphContextValue {
|
||||
status: NgStatus;
|
||||
session: NextGraphSession | undefined;
|
||||
error: string | undefined;
|
||||
connect: () => void;
|
||||
}
|
||||
|
||||
const NextGraphContext = createContext<NextGraphContextValue>({
|
||||
status: 'disconnected',
|
||||
session: undefined,
|
||||
error: undefined,
|
||||
connect: () => {},
|
||||
});
|
||||
|
||||
// Track whether initNg() has been called (module-level to survive re-renders)
|
||||
// Track whether the session start has been triggered (module-level to survive re-renders)
|
||||
let ngInitStarted = false;
|
||||
|
||||
// Detect if we're running inside the NG broker iframe
|
||||
@@ -28,22 +26,37 @@ export function NextGraphProvider({ children }: { children: ReactNode }) {
|
||||
const [ngSession, setNgSession] = useState<NextGraphSession | undefined>(session);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
|
||||
// Auto-init ONLY when running inside the broker iframe.
|
||||
// Outside the broker, initNgWeb() would redirect the page — wait for explicit connect().
|
||||
useEffect(() => {
|
||||
if (!isInsideBroker || ngInitStarted) return;
|
||||
// Start the session ALWAYS, inside the broker iframe and standalone alike.
|
||||
//
|
||||
// It used to be iframe-only, because outside the broker starting the session
|
||||
// REDIRECTS the page and that had to stay a deliberate act — the user clicked
|
||||
// "Entrer" on the app's own access screen. That screen is gone: signing in is
|
||||
// `ensureIdentity()`, and the SDK shows whatever a user must see. Nothing is
|
||||
// left to click, so the redirect must fire on its own.
|
||||
//
|
||||
// And it is no longer optional: a session arrives ONLY through the SDK's `init`,
|
||||
// and `ensureIdentity()` awaited before that call has been made throws. An
|
||||
// unconditional start is what the contract asks for, in every context.
|
||||
//
|
||||
// `startNgSession` is itself idempotent (AuthGate calls it too, right before its
|
||||
// await, so the order does not depend on React's effect ordering). What this
|
||||
// provider adds on top is the CONNECTION STATUS the app displays.
|
||||
const startSession = useCallback(() => {
|
||||
if (ngInitStarted) return;
|
||||
ngInitStarted = true;
|
||||
|
||||
console.log('[NG] Inside broker iframe — auto-init');
|
||||
console.log(`[NG] session start (${isInsideBroker ? 'broker iframe' : 'standalone → redirect'})`);
|
||||
setStatus('connecting');
|
||||
initNg();
|
||||
setError(undefined);
|
||||
startNgSession();
|
||||
|
||||
sessionPromise
|
||||
.then((s) => {
|
||||
// The session (incl. native store ids) is handed to the SDK at the
|
||||
// sanctioned injection point (ngSession/storeRegistry). The app context
|
||||
// itself does not surface or manipulate store ids — it only tracks the
|
||||
// connection status and the opaque session handle.
|
||||
// Nothing is handed to the SDK here: the session is the SDK's, captured by
|
||||
// its own `init`, and no call takes one. What this context keeps is the
|
||||
// opaque handle the app reads a `session_id` off for the `docs`
|
||||
// primitives, plus the connection status the screens display. No store id
|
||||
// crosses this boundary — placement is named by scope alone.
|
||||
console.log('[NG] Session obtained — connected');
|
||||
setNgSession(s);
|
||||
setStatus('connected');
|
||||
@@ -55,49 +68,17 @@ 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);
|
||||
}, []);
|
||||
useEffect(() => { startSession(); }, [startSession]);
|
||||
|
||||
// connect(): called by the user clicking "Se connecter".
|
||||
// When outside the broker, initNgWeb() will redirect to the broker.
|
||||
const connect = useCallback(() => {
|
||||
if (status === 'connecting' || status === 'connected') return;
|
||||
|
||||
console.log('[NG] connect() called, current status:', status);
|
||||
setStatus('connecting');
|
||||
setError(undefined);
|
||||
|
||||
ngInitStarted = true;
|
||||
initNg();
|
||||
|
||||
sessionPromise
|
||||
.then((s) => {
|
||||
setNgSession(s);
|
||||
setStatus('connected');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[NG] Connection failed:', err);
|
||||
setError(err?.message || 'Connexion NextGraph impossible');
|
||||
setStatus('error');
|
||||
});
|
||||
}, [status]);
|
||||
// NO retry on the return from the broker round-trip. That journey belongs to the
|
||||
// SDK: coming back to the page finds its barrier live again and prefilled, and
|
||||
// confirming it hands the page over a second time — our page is never reloaded
|
||||
// and nothing outside the barrier is touched. A `pageshow` handler of ours that
|
||||
// re-started the session would be a second actor driving the same journey, and
|
||||
// it would redirect out from under the barrier the SDK just put up.
|
||||
|
||||
return (
|
||||
<NextGraphContext.Provider value={{ status, session: ngSession, error, connect }}>
|
||||
<NextGraphContext.Provider value={{ status, session: ngSession, error }}>
|
||||
{children}
|
||||
</NextGraphContext.Provider>
|
||||
);
|
||||
|
||||
@@ -28,9 +28,10 @@
|
||||
* round-trip through the shape.
|
||||
*/
|
||||
|
||||
import { docs, escapeLiteral, assertNuri } from '@ng-eventually/client';
|
||||
import type { Nuri } from '@ng-eventually/client';
|
||||
import { docs } from '@ng-eventually/polyfill';
|
||||
import type { Nuri, NuriLike } from '@ng-eventually/polyfill';
|
||||
import { sessionPromise } from '../utils/ngSession';
|
||||
import { escapeLiteral, escapeIri } from './sparqlEscape';
|
||||
|
||||
/** The RDF `@type` IRIs of the Festipod entities written per-document. */
|
||||
export const ENTITY_TYPE = {
|
||||
@@ -70,9 +71,10 @@ function renderTerm(t: EntityTerm): string | null {
|
||||
case 'boolean':
|
||||
return `"${t.value ? 'true' : 'false'}"^^<${XSD}boolean>`;
|
||||
case 'iri':
|
||||
// The reference IRIs are trusted-shaped NURIs (entity subject IRIs coming
|
||||
// back from a prior write / the ORM) → validate as a NURI, embed as `<…>`.
|
||||
return `<${assertNuri(String(t.value))}>`;
|
||||
// The reference IRIs are entity subject IRIs (coming back from a prior
|
||||
// write / the ORM). No pre-flight guard: the SDK validates at its own
|
||||
// door, and here the value only has to be a well-formed IRIREF body.
|
||||
return `<${escapeIri(String(t.value))}>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,22 +87,21 @@ function renderTerm(t: EntityTerm): string | null {
|
||||
* the re-read consistent. `subject` is the entity `@id` (= its document NURI).
|
||||
*/
|
||||
export async function updateEntityField(
|
||||
graphNuri: Nuri,
|
||||
graphNuri: NuriLike,
|
||||
subject: string,
|
||||
field: string,
|
||||
term: EntityTerm,
|
||||
): Promise<void> {
|
||||
const sid = (await sessionPromise).session_id;
|
||||
const s = assertNuri(subject);
|
||||
const s = escapeIri(subject);
|
||||
const pred = `${FP}${field}`;
|
||||
const obj = renderTerm(term);
|
||||
// NO explicit `GRAPH <…>` wrapper: anchored to `graphNuri`, both the DELETE and
|
||||
// the INSERT target that doc's anchored DEFAULT graph — the exact graph the
|
||||
// anchored read queries. This no-GRAPH default-graph form is the CANONICAL SDK
|
||||
// write shape (same as writeEntity / registration.ts); SDK graph details live in
|
||||
// `@ng-eventually/client`, not here. `assertNuri(graphNuri)` is done implicitly
|
||||
// by `docs.sparqlUpdate`'s anchor handling — validate `subject` here as it lands
|
||||
// in an IRI position.
|
||||
// `@ng-eventually/polyfill`, not here. `docs.sparqlUpdate` validates the anchor at
|
||||
// its own door — `subject` only needs escaping, as it lands in an IRI position.
|
||||
const del = `DELETE WHERE { <${s}> <${pred}> ?o }`;
|
||||
await docs.sparqlUpdate(sid, del, graphNuri);
|
||||
if (obj !== null) {
|
||||
@@ -137,10 +138,10 @@ export async function writeEntity(
|
||||
// that doc's anchored DEFAULT graph — the exact graph the anchored read queries.
|
||||
// This is the CANONICAL SDK write shape (anchor scopes the write, no GRAPH clause;
|
||||
// same as updateEntityField / registration.ts); SDK graph details live in
|
||||
// `@ng-eventually/client`, not here.
|
||||
// `@ng-eventually/polyfill`, not here.
|
||||
const update = `
|
||||
INSERT DATA {
|
||||
<${assertNuri(subject)}> ${triples.join(' ;\n ')} .
|
||||
<${escapeIri(subject)}> ${triples.join(' ;\n ')} .
|
||||
}`;
|
||||
await docs.sparqlUpdate(sid, update, graphNuri);
|
||||
return subject;
|
||||
|
||||
+367
-298
File diff suppressed because one or more lines are too long
@@ -1,12 +1,11 @@
|
||||
/**
|
||||
* Registration domain glue — the FESTIPOD interpretation layered on top of the
|
||||
* GENERIC `@ng-eventually/client` `inbox` mechanism (T02.b) and the low-level
|
||||
* GENERIC `@ng-eventually/polyfill` `inbox` mechanism (T02.b) and the low-level
|
||||
* `docs` SPARQL primitives.
|
||||
*
|
||||
* The lib stays domain-agnostic: it knows only "deposit an opaque payload into
|
||||
* an inbox document NURI" and "run a SPARQL update against the real injected
|
||||
* ng". THIS module supplies the Festipod domain:
|
||||
* - how to derive a meeting-point / host inbox NURI (`hostInboxNuri`),
|
||||
* The SDK stays domain-agnostic: it knows only "deposit an opaque payload for a
|
||||
* document" and "run a SPARQL update against the real injected ng". THIS module
|
||||
* supplies the Festipod domain:
|
||||
* - the shape of the deposit payload (`RegistrationPayload`),
|
||||
* - how a deposit becomes a host-facing `FpNotification` (`buildNotification`),
|
||||
* - the SPARQL DELETE-WHERE that DURABLY removes a Participation server-side
|
||||
@@ -14,13 +13,14 @@
|
||||
* bug (see caveat_participation-deletion).
|
||||
*
|
||||
* Importable by `shared/` and by domain modules (meeting/notification) — it never
|
||||
* imports a module, only the lib. See T02.a (shapes) / T02.b (inbox).
|
||||
* imports a module, only the SDK. See T02.a (shapes) / T02.b (inbox).
|
||||
*/
|
||||
|
||||
import { inbox, docs, escapeLiteral, escapeIri, assertNuri } from '@ng-eventually/client';
|
||||
import type { Nuri } from '@ng-eventually/client';
|
||||
import { inbox, docs } from '@ng-eventually/polyfill';
|
||||
import type { Nuri, NuriLike } from '@ng-eventually/polyfill';
|
||||
import { sessionPromise } from '../utils/ngSession';
|
||||
import { documentInbox, listMyEntityDocs } from '../utils/storeRegistry';
|
||||
import { listMyEntityDocs } from '../utils/storeRegistry';
|
||||
import { escapeLiteral, escapeIri } from './sparqlEscape';
|
||||
import type { FpNotificationData } from './types';
|
||||
|
||||
/** Notification IRI/type constants (mirror the SHEX Notification shape). */
|
||||
@@ -113,25 +113,6 @@ export function canonicalEventId(id: string): string {
|
||||
return i === -1 ? id : id.slice(0, i);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the inbox document NURI for a meeting point / host.
|
||||
*
|
||||
* Preference order: the explicit MeetingPoint `inbox` NURI (SHEX field, T02.a)
|
||||
* when known → else THE EVENT DOCUMENT'S OWN INBOX (`documentInbox(eventDoc)`).
|
||||
*
|
||||
* An inbox BELONGS to someone — there is no inbox common to every identity — so
|
||||
* the deposit target is the inbox of the document the deposit is ABOUT. `eventId`
|
||||
* is the event's document NURI (one document per entity: the entity's `@id` IS
|
||||
* its document), which is exactly what `documentInbox` takes. The owner reads it
|
||||
* back at its next connection; a depositor that is not the owner must have been
|
||||
* GIVEN the inbox NURI — that is `explicitInbox` (the MeetingPoint `fp:inbox`
|
||||
* field), the only cross-identity path.
|
||||
*/
|
||||
export async function hostInboxNuri(eventId: Nuri, explicitInbox?: Nuri): Promise<Nuri> {
|
||||
if (explicitInbox) return explicitInbox;
|
||||
return documentInbox(eventId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the host-facing notification from a registration deposit. The recipient
|
||||
* is the event host; `ref` points at the event; the payload carries the raw
|
||||
@@ -154,9 +135,12 @@ export function buildNotification(
|
||||
}
|
||||
|
||||
/**
|
||||
* Deposit a registration into the host's inbox (generic lib `inbox.post`) +
|
||||
* Deposit a registration FOR THE EVENT DOCUMENT (`inbox.postToDocument`) +
|
||||
* return the deposit ts so the caller can mint a matching notification.
|
||||
*
|
||||
* A deposit names the DOCUMENT it is about, never an address: the depositor
|
||||
* holds no inbox address and needs none. Only the event's owner reads it back.
|
||||
*
|
||||
* The registrant identity travels in the PAYLOAD (`userId`), which the host
|
||||
* materializer reads. The transport-level `from` is left ANONYMOUS (`null`): the
|
||||
* SDK binds `from` to the depositor's own identity and rejects a mismatched one
|
||||
@@ -164,13 +148,15 @@ export function buildNotification(
|
||||
* domain identity belongs in the payload, not in the transport `from`.
|
||||
*/
|
||||
export async function depositRegistration(
|
||||
targetInbox: Nuri,
|
||||
eventId: string,
|
||||
eventDoc: NuriLike,
|
||||
registrantId: string | null,
|
||||
participationDoc?: string,
|
||||
): Promise<{ ts: number; uid: string }> {
|
||||
const ts = Date.now();
|
||||
const uid = mintDepositUid();
|
||||
// One document per entity: the event's `@id` IS its document, so the deposit
|
||||
// target and the payload's event key are the same value.
|
||||
const eventId: string = eventDoc;
|
||||
const payload: RegistrationPayload = {
|
||||
kind: NOTIF_TYPE_NEW_PARTICIPANT,
|
||||
eventId,
|
||||
@@ -178,7 +164,7 @@ export async function depositRegistration(
|
||||
uid,
|
||||
participationDoc,
|
||||
};
|
||||
await inbox.post(targetInbox, { from: null, payload, ts });
|
||||
await inbox.postToDocument(eventDoc, { from: null, payload, ts });
|
||||
console.log(
|
||||
`[Attendance] deposit new-participant → inbox for event=${canonicalEventId(eventId)} ` +
|
||||
`user=${registrantId ?? '(anon)'} uid=${uid}`,
|
||||
@@ -187,7 +173,7 @@ export async function depositRegistration(
|
||||
}
|
||||
|
||||
/**
|
||||
* Deposit a LEAVE marker into the event's inbox (Option B, symmetric to
|
||||
* Deposit a LEAVE marker FOR THE EVENT DOCUMENT (Option B, symmetric to
|
||||
* `depositRegistration`). The owner materializes it to remove the matching
|
||||
* registration from the active set. `regUid` (the join deposit's uid) lets the
|
||||
* owner cancel exactly that registration; when unknown, the owner falls back to
|
||||
@@ -195,13 +181,13 @@ export async function depositRegistration(
|
||||
* re-synced leave removes an already-removed registration → no double-decrement.
|
||||
*/
|
||||
export async function depositLeave(
|
||||
targetInbox: Nuri,
|
||||
eventId: string,
|
||||
eventDoc: NuriLike,
|
||||
registrantId: string | null,
|
||||
regUid?: string,
|
||||
): Promise<{ ts: number; uid: string }> {
|
||||
const ts = Date.now();
|
||||
const uid = mintDepositUid();
|
||||
const eventId: string = eventDoc;
|
||||
const payload: RegistrationPayload = {
|
||||
kind: NOTIF_TYPE_LEAVE_PARTICIPANT,
|
||||
eventId,
|
||||
@@ -209,7 +195,7 @@ export async function depositLeave(
|
||||
uid,
|
||||
regUid,
|
||||
};
|
||||
await inbox.post(targetInbox, { from: null, payload, ts });
|
||||
await inbox.postToDocument(eventDoc, { from: null, payload, ts });
|
||||
console.log(
|
||||
`[Attendance] deposit leave-participant → inbox for event=${canonicalEventId(eventId)} ` +
|
||||
`user=${registrantId ?? '(anon)'} uid=${uid} regUid=${regUid ?? '(none)'}`,
|
||||
@@ -347,20 +333,23 @@ export async function readRegistrationNotifications(
|
||||
* just-written participation, so a second join checking only the reactive set would
|
||||
* write a duplicate. Querying the broker sees the real state regardless of read lag.
|
||||
*
|
||||
* Scoped to the CURRENT account (`identifier`) via `listMyEntityDocs` — a user's own
|
||||
* participations live in their own account, so there is NO need to fan out over all
|
||||
* accounts (which would open/sync other accounts' unsynced docs → the ~75s hang).
|
||||
* Scoped to MY OWN protected documents via `listMyEntityDocs` — a user's own
|
||||
* participations live in their own documents, so there is NO need to fan out over
|
||||
* anyone else's (which would open/sync unsynced docs → the ~75s hang).
|
||||
*/
|
||||
export async function countUserParticipations(
|
||||
identifier: string,
|
||||
eventId: string,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
const sid = (await sessionPromise).session_id;
|
||||
const docs_ = await listMyEntityDocs(identifier, 'protected');
|
||||
const docs_ = await listMyEntityDocs('protected');
|
||||
let total = 0;
|
||||
for (const g of docs_) {
|
||||
total += await countParticipations(sid, g, eventId, userId).catch(() => 0);
|
||||
// NO `.catch(() => 0)` here. A failed count means UNKNOWN, never zero: this
|
||||
// number decides whether a participation already exists, and answering "none"
|
||||
// when we could not find out is what writes a duplicate. The rejection travels
|
||||
// to the caller, which surfaces it.
|
||||
total += await countParticipations(sid, g, eventId, userId);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
@@ -387,8 +376,8 @@ export interface DeleteParticipationResult {
|
||||
* serialized these fields — this is a COUNT (read-only), so tolerance here is
|
||||
* safe (unlike a DELETE, it can never over-remove). */
|
||||
async function countParticipations(
|
||||
sid: string,
|
||||
graphNuri: Nuri,
|
||||
sid: string | number,
|
||||
graphNuri: NuriLike,
|
||||
eventId: string,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
@@ -398,7 +387,7 @@ async function countParticipations(
|
||||
// into the anchored DEFAULT graph (one doc per entity), so this count reads that
|
||||
// same anchored default graph — anchored to `graphNuri`, no `GRAPH` clause. This
|
||||
// is the CANONICAL SDK read/write shape (write and read the same anchored default
|
||||
// graph); SDK graph details live in `@ng-eventually/client`, not here.
|
||||
// graph); SDK graph details live in `@ng-eventually/polyfill`, not here.
|
||||
const query = `
|
||||
SELECT (COUNT(DISTINCT ?s) AS ?n) WHERE {
|
||||
?s a <${P.partType}> ;
|
||||
@@ -446,7 +435,7 @@ async function countParticipations(
|
||||
* state for the immediate UI, AND only once `remaining === 0`.
|
||||
*/
|
||||
export async function deleteParticipation(
|
||||
graphNuri: Nuri,
|
||||
graphNuri: NuriLike,
|
||||
eventId: string,
|
||||
userId: string,
|
||||
subjectIri?: string,
|
||||
@@ -487,7 +476,7 @@ export async function deleteParticipation(
|
||||
// graph — anchored to `graphNuri`, no `GRAPH` clause. Write, read and delete all
|
||||
// use the one CANONICAL anchored-default-graph shape so they stay consistent (a
|
||||
// mismatched target here would no-op the delete → the F2 resurrection). SDK graph
|
||||
// details live in `@ng-eventually/client`, not here.
|
||||
// details live in `@ng-eventually/polyfill`, not here.
|
||||
const sweep = `
|
||||
DELETE { ?s ?p ?o }
|
||||
WHERE {
|
||||
@@ -506,8 +495,8 @@ export async function deleteParticipation(
|
||||
// known. This catches the residual case where an object-form drift makes the
|
||||
// (event, user) sweep miss a subject we nonetheless hold the id for — exact,
|
||||
// bound as an IRI, cannot no-op on drift.
|
||||
if (hasSubject) {
|
||||
const s = assertNuri(subjectIri!);
|
||||
if (subjectIri && hasSubject) {
|
||||
const s = escapeIri(subjectIri);
|
||||
// Anchored default-graph (no `GRAPH` clause), like the sweep above.
|
||||
const bySubject = `
|
||||
DELETE { <${s}> ?p ?o }
|
||||
@@ -535,7 +524,7 @@ export async function insertNotification(
|
||||
// recipient/ref are bare domain ids ("user-1", "event-1"), not absolute IRIs;
|
||||
// store them as string literals to keep the INSERT valid (the raw shape read
|
||||
// is not the primary surfacing path — the inbox read is). Every literal is
|
||||
// escaped via the lib's escapeLiteral (guards \ " \n \r \t — SPARQL injection).
|
||||
// escaped via the app's escapeLiteral (guards \ " \n \r \t — SPARQL injection).
|
||||
const refTriple = notif.ref ? `\n <${P.ref}> "${escapeLiteral(notif.ref)}" ;` : '';
|
||||
const payloadTriple = notif.payload
|
||||
? `\n <${P.payload}> "${escapeLiteral(notif.payload)}" ;`
|
||||
@@ -543,10 +532,10 @@ export async function insertNotification(
|
||||
// NO explicit `GRAPH <…>` wrapper: anchored to `graphNuri`, the INSERT lands in
|
||||
// that doc's anchored DEFAULT graph — the CANONICAL SDK write shape, consistent
|
||||
// with every other per-entity write (writeEntity / updateEntityField). SDK graph
|
||||
// details live in `@ng-eventually/client`, not here.
|
||||
// details live in `@ng-eventually/polyfill`, not here.
|
||||
const update = `
|
||||
INSERT DATA {
|
||||
<${assertNuri(subject)}> a <${NOTIF_TYPE_IRI}> ;
|
||||
<${escapeIri(subject)}> a <${NOTIF_TYPE_IRI}> ;
|
||||
<${P.recipient}> "${escapeLiteral(notif.recipientId)}" ;
|
||||
<${P.type}> "${escapeLiteral(notif.type)}" ;${refTriple}${payloadTriple}
|
||||
<${P.timestamp}> "${escapeLiteral(notif.timestamp)}" ;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* mapping remains, extracted here.
|
||||
*/
|
||||
|
||||
import type { UnionSubject } from '@ng-eventually/client';
|
||||
import type { UnionSubject } from '@ng-eventually/polyfill';
|
||||
import type { FpEventData, FpUserData, FpParticipationData } from './types';
|
||||
|
||||
const FP = 'http://festipod.org/';
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* SPARQL escaping for the app's own raw-query paths — the ONE place Festipod
|
||||
* escapes a value it splices into a SPARQL string.
|
||||
*
|
||||
* The SDK publishes no escaper: every SDK entry takes a value and validates it
|
||||
* at the door. But `docs.sparqlQuery` / `docs.sparqlUpdate` take a query STRING
|
||||
* the app builds itself (entityWrites, registration), so the app owns the
|
||||
* escaping of anything it interpolates into that string. Kept minimal and used
|
||||
* from both call sites rather than duplicated.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Escape a value landing inside a `"…"` SPARQL literal: backslash and quote
|
||||
* first (so the added escapes are not re-escaped), then the control characters
|
||||
* that would otherwise terminate the literal.
|
||||
*/
|
||||
export function escapeLiteral(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(/\t/g, '\\t');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a value landing inside a `<…>` SPARQL IRIREF: percent-encode every
|
||||
* character an IRIREF may not contain (the delimiters, plus the C0 controls and
|
||||
* space). A well-formed document reference passes through unchanged.
|
||||
*/
|
||||
export function escapeIri(value: string): string {
|
||||
return value.replace(
|
||||
/[<>"{}|^`\\\u0000-\u0020]/g,
|
||||
c => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`,
|
||||
);
|
||||
}
|
||||
+551
-152
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
import { watchShape, type ShapeQuery, type ShapeObservable, type UnionSubject } from '@ng-eventually/client';
|
||||
import { watchShape, type ShapeQuery, type ShapeObservable, type UnionSubject } from '@ng-eventually/polyfill';
|
||||
import { beginQuery, resolveQuery } from './pendingQueries';
|
||||
import { recordSet, shapeLabel, totalsSummary, totalSets } from './dataStats';
|
||||
|
||||
|
||||
@@ -43,8 +43,6 @@ export interface BrowserPool {
|
||||
* 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).
|
||||
@@ -52,19 +50,6 @@ export interface BrowserPool {
|
||||
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 = {
|
||||
@@ -76,19 +61,12 @@ export const pool: BrowserPool = {
|
||||
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?');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+223
-144
@@ -1,31 +1,22 @@
|
||||
import { Before, After, BeforeAll, AfterAll, Status, setDefaultTimeout } from '@cucumber/cucumber';
|
||||
import { chromium, type Browser, type BrowserContext, type Page, type Frame } from 'playwright';
|
||||
import { execSync, spawn, type ChildProcess } from 'child_process';
|
||||
import { execSync, execFileSync, spawn, type ChildProcess } from 'child_process';
|
||||
import * as http from 'http';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { FestipodWorld } from './world';
|
||||
import { pool } from './browserPool';
|
||||
// The path the app asks the SDK to fetch its wallet from — ONE constant, shared
|
||||
// with the app, so the harness server and the bundle can never disagree on it.
|
||||
import { SHARED_WALLET_FILE_URL } from '../utils/sharedWallet';
|
||||
|
||||
setDefaultTimeout(90000);
|
||||
|
||||
// PER-SCENARIO FRESH VIRTUAL WALLET (T03.k). The shim keys each emulated account
|
||||
// (its own private virtual wallet) by the NORMALIZED app-level identifier read
|
||||
// from localStorage['festipod.account.identifier'] on the harness origin. When
|
||||
// every @data scenario logs in as the SAME fixed user, that ONE virtual wallet
|
||||
// accumulates every doc any prior scenario/run ever wrote → per-doc anchored
|
||||
// reads fan out over hundreds of docs → 90s timeouts. Giving each scenario a
|
||||
// UNIQUE identifier hands it a FRESH, EMPTY virtual wallet, so reads stay O(what
|
||||
// THIS scenario provisions) and are fast + independent. A monotonic counter +
|
||||
// per-run nonce guarantees uniqueness within and across runs; it normalizes to
|
||||
// itself (lowercase, `@`-free) and is disjoint from the reserved `@index`
|
||||
// account (whose shim key uses a sentinel prefix `normalizeIdentifier` can't emit).
|
||||
const RUN_NONCE = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
let scenarioSeq = 0;
|
||||
function freshScenarioIdentifier(): string {
|
||||
scenarioSeq += 1;
|
||||
return `test-${RUN_NONCE}-${scenarioSeq}`;
|
||||
}
|
||||
// A BROWSER CONTEXT IS ONE USER, and nothing here names or selects that user:
|
||||
// signing in is `init(…)` then `await ensureIdentity()`, and `ensureIdentity()`
|
||||
// is what says who you are. The harness provisions the deployment's wallet and
|
||||
// opens pages; who those pages come up as is the SDK's answer, never the
|
||||
// harness's. Two users means two browser contexts, each with its own wallet.
|
||||
|
||||
let browser: Browser;
|
||||
let browserContext: BrowserContext;
|
||||
@@ -64,12 +55,6 @@ async function launchWalletContext(): Promise<BrowserContext> {
|
||||
permissions: CONTEXT_PERMISSIONS,
|
||||
args: LAUNCH_ARGS,
|
||||
});
|
||||
// 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 ctx.addInitScript(() => {
|
||||
(globalThis as Record<string, unknown>).__FESTIPOD_ACCESS_GATE_DISABLED__ = true;
|
||||
});
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@@ -119,6 +104,29 @@ const HARNESS_NG_OUT = path.join('dist', 'test-harness-ng.js');
|
||||
// Persistent Chromium profile for NG wallet (not the user's daily browser)
|
||||
const PLAYWRIGHT_PROFILE = path.resolve('.playwright-profile');
|
||||
|
||||
// `.env` IS how this project declares the two wallet variables (see .env.example),
|
||||
// and everything that serves the app runs under BUN, which loads that file by
|
||||
// itself. Cucumber does not: it runs under NODE (see the `cucumber:run` script),
|
||||
// which loads nothing — so a suite reading `process.env` alone finds the wallet
|
||||
// material missing while the very same variables are configured and working for
|
||||
// the app. Load the file the way Bun would. The real environment WINS (Node's
|
||||
// loader never overwrites an already-set variable), so an operator exporting the
|
||||
// variables still overrides the file, exactly as for the app.
|
||||
try {
|
||||
process.loadEnvFile();
|
||||
} catch {
|
||||
// No .env here — the environment is then the only source, which is fine.
|
||||
}
|
||||
|
||||
// THE DEPLOYMENT'S WALLET MATERIAL — read from the SAME two environment variables
|
||||
// the app build reads (see build.ts): the password `define`d into the browser
|
||||
// bundle, and the wallet file served at SHARED_WALLET_FILE_URL. The harness is a
|
||||
// deployment of the app's own code, so it has to hand its bundle the same two
|
||||
// things; without them `configure()` receives no `sharedWallet`, `ensureIdentity()`
|
||||
// rejects, and NO scenario ever signs in.
|
||||
const SHARED_WALLET_PASSWORD_ENV = process.env.FESTIPOD_SHARED_WALLET_PASSWORD ?? '';
|
||||
const SHARED_WALLET_FILE_ENV = process.env.FESTIPOD_SHARED_WALLET_FILE ?? '';
|
||||
|
||||
let harnessServer: http.Server | null = null;
|
||||
let harnessPort = 0;
|
||||
let useRealBroker = false;
|
||||
@@ -127,26 +135,9 @@ 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.
|
||||
@@ -238,74 +229,6 @@ async function completeBrokerLogin(page: Page, appUrl: string, walletPassword: s
|
||||
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:
|
||||
@@ -413,8 +336,40 @@ BeforeAll({ timeout: 10 * 60 * 1000 }, async function () {
|
||||
|
||||
// Try to build the real broker harness
|
||||
try {
|
||||
execSync(`bun build ${HARNESS_NG_ENTRY} --outfile ${HARNESS_NG_OUT} --bundle`, { stdio: 'pipe' });
|
||||
// The harness bundle needs the SAME wallet material as the app bundle — the
|
||||
// password as a build-time global, the file served by the harness's own HTTP
|
||||
// server. Missing either one means no sign-in is possible at all, so say so
|
||||
// instead of building a bundle that can only fail later, opaquely.
|
||||
if (!SHARED_WALLET_PASSWORD_ENV.trim() || !SHARED_WALLET_FILE_ENV.trim()) {
|
||||
throw new Error(
|
||||
'Shared wallet material missing — set FESTIPOD_SHARED_WALLET_PASSWORD and ' +
|
||||
'FESTIPOD_SHARED_WALLET_FILE (see .env.example). Without them the harness ' +
|
||||
'bundle has no wallet to open: ensureIdentity() rejects and no @data/@e2e ' +
|
||||
'scenario can sign in.',
|
||||
);
|
||||
}
|
||||
const walletFilePath = path.resolve(SHARED_WALLET_FILE_ENV);
|
||||
if (!fs.existsSync(walletFilePath)) {
|
||||
throw new Error(
|
||||
`Shared wallet file not found: ${walletFilePath} (FESTIPOD_SHARED_WALLET_FILE).`,
|
||||
);
|
||||
}
|
||||
const walletFileBytes = fs.readFileSync(walletFilePath);
|
||||
|
||||
// `--define` mirrors build.ts exactly (same global name, same JSON encoding).
|
||||
// execFileSync, not a shell string: the password is passed as an argv entry,
|
||||
// so no quoting of the value can ever mangle or leak it.
|
||||
execFileSync(
|
||||
'bun',
|
||||
[
|
||||
'build', HARNESS_NG_ENTRY, '--outfile', HARNESS_NG_OUT, '--bundle',
|
||||
'--define',
|
||||
`globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__=${JSON.stringify(SHARED_WALLET_PASSWORD_ENV)}`,
|
||||
],
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
console.log(`[Harness] Built NG (${(fs.statSync(HARNESS_NG_OUT).size / 1024).toFixed(0)} KB)`);
|
||||
console.log(`[Harness] Shared wallet: ${walletFilePath} → ${SHARED_WALLET_FILE_URL}`);
|
||||
|
||||
// Ensure wallet exists in persistent profile (opens browser if needed)
|
||||
await ensureAuth();
|
||||
@@ -432,9 +387,16 @@ BeforeAll({ timeout: 10 * 60 * 1000 }, async function () {
|
||||
</body>
|
||||
</html>`;
|
||||
harnessServer = http.createServer((req, res) => {
|
||||
const urlPath = req.url?.split('?')[0];
|
||||
if (req.url === '/harness.js') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
|
||||
res.end(harnessBundle);
|
||||
} else if (urlPath === SHARED_WALLET_FILE_URL) {
|
||||
// The wallet FILE, at the very path the bundle hands the SDK
|
||||
// (`SHARED_WALLET_FILE_URL`) — the harness origin serves its own wallet,
|
||||
// exactly as `src/index.ts` does for the app origin.
|
||||
res.writeHead(200, { 'Content-Type': 'application/octet-stream' });
|
||||
res.end(walletFileBytes);
|
||||
} 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.
|
||||
@@ -473,7 +435,19 @@ BeforeAll({ timeout: 10 * 60 * 1000 }, async function () {
|
||||
env: { ...process.env, PORT: String(appPort), NODE_ENV: 'production' },
|
||||
stdio: 'pipe',
|
||||
cwd: process.cwd(),
|
||||
// OWN PROCESS GROUP, so teardown can signal the whole tree. `bun` on PATH
|
||||
// resolves to a shell shim that launches the real binary as ITS child and
|
||||
// merely waits — no `exec` — so signalling the direct child kills the shim
|
||||
// only and leaves the server running, re-parented to init. Detaching gives
|
||||
// the pair a group id (= this pid) that `process.kill(-pid)` can reach.
|
||||
detached: true,
|
||||
});
|
||||
// DRAIN the pipes. `stdio: 'pipe'` gives each stream a 64 KB kernel buffer;
|
||||
// nobody reads them, so a chatty server fills one mid-run and then BLOCKS on
|
||||
// its next write — the suite would hang with no error and no output to explain
|
||||
// it. Flowing mode discards what we do not need instead of accumulating it.
|
||||
appServerProcess.stdout?.resume();
|
||||
appServerProcess.stderr?.resume();
|
||||
// Wait for app server to respond
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const deadline = Date.now() + 15000;
|
||||
@@ -499,9 +473,6 @@ BeforeAll({ timeout: 10 * 60 * 1000 }, async function () {
|
||||
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
|
||||
@@ -580,20 +551,6 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
|
||||
// the run self-heals instead of cascading failures across the rest.
|
||||
this.page = await newWalletPageResilient();
|
||||
|
||||
// FRESH VIRTUAL WALLET per scenario (see freshScenarioIdentifier above). Set a
|
||||
// UNIQUE app-level identifier into localStorage['festipod.account.identifier']
|
||||
// on EVERY origin (the init script runs in each frame before its scripts do —
|
||||
// including the harness iframe on 127.0.0.1). At mount the harness's
|
||||
// IdentityStore.get() then reads THIS fresh identifier, so `if (!identifier)
|
||||
// login(DEFAULT_HARNESS_USER)` is skipped and the scenario runs on a fresh,
|
||||
// empty virtual wallet. Overwrites any value persisted in the Chromium profile
|
||||
// (init scripts run on each navigation), so no accumulated wallet leaks in.
|
||||
const freshIdentifier = freshScenarioIdentifier();
|
||||
(this as any).freshIdentifier = freshIdentifier;
|
||||
await this.page.addInitScript((u: string) => {
|
||||
try { window.localStorage.setItem('festipod.account.identifier', u); } catch { /* opaque origin */ }
|
||||
}, freshIdentifier);
|
||||
|
||||
// Capture console for debugging AND collect into the World so smoke
|
||||
// scenarios can assert no runtime error was emitted during the connected
|
||||
// boot (guards the "page blanche once connected" render-crash class).
|
||||
@@ -615,18 +572,34 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
|
||||
const harnessUrl = `http://127.0.0.1:${harnessPort}`;
|
||||
this.appFrame = await setupBrokerPage(this.page!, harnessUrl);
|
||||
|
||||
// Wait for NG session + useShape + bridge
|
||||
await this.appFrame.waitForFunction(
|
||||
// Wait for NG session + useShape + bridge — OR for the harness to publish a
|
||||
// terminal error. A harness that cannot sign in renders an observable
|
||||
// `#harness-status[data-harness-error]` (see harness-ng.tsx); racing it
|
||||
// against the bridge turns a total sign-in failure into an immediate,
|
||||
// named failure instead of an anonymous 30s timeout.
|
||||
const bridgeReady = this.appFrame.waitForFunction(
|
||||
() => (window as any).__testData?.ready === true,
|
||||
undefined,
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
const harnessError = this.appFrame
|
||||
.waitForSelector('#harness-status[data-harness-error]', { timeout: 30000 })
|
||||
.then(
|
||||
async (el) => {
|
||||
const detail = await el?.getAttribute('data-harness-error');
|
||||
const state = await el?.textContent();
|
||||
throw new Error(`Harness never became usable (${state}): ${detail}`);
|
||||
},
|
||||
// No error state within the window: let the bridge wait decide.
|
||||
() => new Promise<never>(() => { /* never settles */ }),
|
||||
);
|
||||
await Promise.race([bridgeReady, harnessError]);
|
||||
|
||||
// NO per-scenario registry/wallet reset needed anymore (was T03.j
|
||||
// resetDataState). Each @data scenario now runs under a UNIQUE identifier
|
||||
// (freshScenarioIdentifier, set into localStorage above), so the shim hands it
|
||||
// a FRESH, EMPTY virtual wallet whose account registry starts empty by
|
||||
// construction — nothing to purge. This also drops the ≤10s reset cost that
|
||||
// shared the Before hook's budget with the (slow) broker login.
|
||||
// NO per-scenario reset. The suite does not purge the wallet between
|
||||
// scenarios: the old `clearWallet` fan-out enumerated every entity document
|
||||
// and cost up to 10s out of the Before hook's budget, which it shared with
|
||||
// the (slow) broker login. A scenario provisions what it needs and asserts
|
||||
// on that, rather than on the wallet being empty.
|
||||
} else {
|
||||
// Mock mode: load harness directly
|
||||
await this.page!.setContent('<!DOCTYPE html><html><body><div id="root"></div></body></html>');
|
||||
@@ -687,6 +660,93 @@ After({ timeout: 10000 }, async function (this: FestipodWorld, scenario) {
|
||||
this.cleanup();
|
||||
});
|
||||
|
||||
/**
|
||||
* Await `work`, but never longer than `ms`. A close() that never settles must not
|
||||
* stop the REMAINING resources from being released — teardown continues and the
|
||||
* unreleased handle is named in the log instead of silently stalling the process.
|
||||
* The timer is always cleared, so this guard never becomes a handle of its own.
|
||||
*/
|
||||
async function withDeadline(label: string, ms: number, work: Promise<unknown>): Promise<void> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const expired = Symbol('expired');
|
||||
const deadline = new Promise<typeof expired>((resolve) => { timer = setTimeout(() => resolve(expired), ms); });
|
||||
try {
|
||||
const outcome = await Promise.race([work.then(() => undefined), deadline]);
|
||||
if (outcome === expired) console.warn(`[Teardown] ${label} did not settle within ${ms}ms — moving on`);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the E2E app server and RELEASE ITS PIPES.
|
||||
*
|
||||
* `spawn(..., { stdio: 'pipe' })` opens three socketpairs whose ends belong to
|
||||
* THIS process, plus the child handle. `kill()` alone releases none of them
|
||||
* synchronously, and nothing ever reads the child's stdout/stderr, so those two
|
||||
* sockets never reach EOF: they stay ref'd on the event loop and the suite can
|
||||
* never exit on its own. So: signal the whole PROCESS GROUP (the shim waiting on
|
||||
* the real server is the direct child — see the `detached` note at the spawn),
|
||||
* WAIT for it to actually die (that is what retires its process handle), escalate
|
||||
* if it ignores SIGTERM, then destroy the three stdio sockets we own.
|
||||
*/
|
||||
async function stopAppServer(child: ChildProcess): Promise<void> {
|
||||
const hasExited = () => child.exitCode !== null || child.signalCode !== null;
|
||||
const exited: Promise<void> = hasExited()
|
||||
? Promise.resolve()
|
||||
: new Promise<void>((resolve) => {
|
||||
child.once('exit', () => resolve());
|
||||
child.once('error', () => resolve());
|
||||
});
|
||||
// Negative pid = the whole group (shim + server). Falls back to the direct
|
||||
// child if the group is already gone or was never created.
|
||||
const signalTree = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
if (child.pid === undefined) return;
|
||||
process.kill(-child.pid, signal);
|
||||
} catch {
|
||||
try { child.kill(signal); } catch { /* already reaped */ }
|
||||
}
|
||||
};
|
||||
// The shim exits the moment it is signalled, so ITS `exit` event says nothing
|
||||
// about the server behind it. Signal 0 probes the group instead: it is alive
|
||||
// for as long as any member — i.e. the server — is.
|
||||
const groupAlive = (): boolean => {
|
||||
if (child.pid === undefined) return false;
|
||||
try { process.kill(-child.pid, 0); return true; } catch { return false; }
|
||||
};
|
||||
const waitForGroupToDie = async (ms: number): Promise<void> => {
|
||||
const deadline = Date.now() + ms;
|
||||
while (groupAlive() && Date.now() < deadline) {
|
||||
await new Promise<void>((resolve) => { setTimeout(resolve, 100); });
|
||||
}
|
||||
};
|
||||
|
||||
signalTree('SIGTERM');
|
||||
await waitForGroupToDie(5000);
|
||||
if (groupAlive()) {
|
||||
console.warn('[Teardown] app server ignored SIGTERM — SIGKILL');
|
||||
signalTree('SIGKILL');
|
||||
await waitForGroupToDie(2000);
|
||||
}
|
||||
if (groupAlive()) console.warn('[Teardown] app server process group still alive after SIGKILL');
|
||||
// Let Node reap the direct child, so its process handle leaves the event loop.
|
||||
await withDeadline('appServer child reap', 2000, exited);
|
||||
if (!hasExited()) console.warn('[Teardown] app server child not reaped');
|
||||
for (const stream of [child.stdin, child.stdout, child.stderr]) stream?.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the harness HTTP server. `close()` only stops accepting and then waits
|
||||
* for every established connection to end — a keep-alive socket left behind by a
|
||||
* browser that went away would hold it (and the process) open forever. Drop those
|
||||
* sockets explicitly first, then wait for the listener itself.
|
||||
*/
|
||||
async function stopHarnessServer(server: http.Server): Promise<void> {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
|
||||
AfterAll(async function () {
|
||||
// Teardown must be fully defensive: a Playwright context/browser can already be
|
||||
// closed by the time we get here (multi-browser scenarios that closed their own
|
||||
@@ -695,15 +755,34 @@ AfterAll(async function () {
|
||||
// flush, losing the whole report and masking the real pass/fail. Each step is
|
||||
// isolated so a flake in one never blocks the rest. This turns the documented
|
||||
// "browserContext already closed" teardown flake into a non-fatal event.
|
||||
const safe = async (label: string, fn: () => Promise<void> | void) => {
|
||||
try { await fn(); } catch (e) { console.warn(`[Teardown] ${label} failed (non-fatal):`, (e as Error).message); }
|
||||
//
|
||||
// ORDER = REVERSE OF ACQUISITION (harness server → wallet context → fresh
|
||||
// browser → app server), so nothing is torn down while something that talks to
|
||||
// it is still alive. In particular the harness server goes LAST: the browsers
|
||||
// hold keep-alive connections to it, and closing a server still has to wait for
|
||||
// its connections.
|
||||
const safe = async (label: string, ms: number, fn: () => Promise<unknown> | unknown) => {
|
||||
try {
|
||||
await withDeadline(label, ms, Promise.resolve(fn()));
|
||||
} catch (e) {
|
||||
console.warn(`[Teardown] ${label} failed (non-fatal):`, (e as Error).message);
|
||||
}
|
||||
};
|
||||
await safe('browserContext.close', () => browserContext?.close());
|
||||
await safe('freshBrowser.close', () => freshBrowser?.close());
|
||||
await safe('browser.close', () => browser?.close());
|
||||
await safe('harnessServer.close', () => new Promise<void>((resolve) => harnessServer ? harnessServer.close(() => resolve()) : resolve()));
|
||||
await safe('appServer.kill', () => { if (appServerProcess) { appServerProcess.kill(); appServerProcess = null; } });
|
||||
await safe('stagingServer.close', () => new Promise<void>((resolve) => stagingServer ? stagingServer.close(() => resolve()) : resolve()));
|
||||
await safe('stagingOutdir.rm', () => fs.existsSync(STAGING_OUTDIR) ? fs.promises.rm(STAGING_OUTDIR, { recursive: true, force: true }) : undefined);
|
||||
if (appServerProcess) {
|
||||
const child = appServerProcess;
|
||||
appServerProcess = null;
|
||||
await safe('appServer.stop', 10000, () => stopAppServer(child));
|
||||
}
|
||||
await safe('freshBrowser.close', 30000, () => freshBrowser?.close());
|
||||
freshBrowser = null;
|
||||
pool.freshBrowser = null;
|
||||
await safe('browserContext.close', 30000, () => browserContext?.close());
|
||||
pool.walletContext = null;
|
||||
await safe('browser.close', 30000, () => browser?.close());
|
||||
if (harnessServer) {
|
||||
const server = harnessServer;
|
||||
harnessServer = null;
|
||||
await safe('harnessServer.close', 10000, () => stopHarnessServer(server));
|
||||
}
|
||||
console.log('Festipod BDD tests completed.');
|
||||
});
|
||||
|
||||
@@ -7,97 +7,88 @@
|
||||
*
|
||||
* Exposes window.__testData for Playwright-driven Cucumber steps.
|
||||
*/
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { NextGraphProvider, useNextGraph } from '../context/NextGraphContext';
|
||||
import { AccountProvider, useAccount } from '../context/AccountContext';
|
||||
import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataContext';
|
||||
// useShape routed through the lib (SDK-identical surface); caps from /polyfill.
|
||||
import { useShape, docs, inbox as docsInbox, isNuri } from '@ng-eventually/client';
|
||||
import type { Nuri } from '@ng-eventually/client';
|
||||
import { getCaps, getCurrentUser, setCurrentUser, resetCaps, connectedUser } from '@ng-eventually/client/polyfill';
|
||||
// Relationship is an app concept: directed grants come from the app's own module.
|
||||
import { declareConnections, resetConnections } from '../utils/connections';
|
||||
import { hostInboxNuri as regInboxNuri } from '../data/registration';
|
||||
// The write side of one-document-per-entity: an entity's RDF goes straight into
|
||||
// its OWN document (rule_document-per-entity), never into a store-level document.
|
||||
import { writeEntity, ENTITY_TYPE, iri, bool } from '../data/entityWrites';
|
||||
import type { DeepSignalSet } from '@ng-eventually/client';
|
||||
// doc_create goes through the lib's `docs` primitive (T01.a): it calls the REAL
|
||||
// injected `ng` directly (never the public proxy), so postMessage marshaling
|
||||
// stays intact (no DataCloneError). See decision_2026-06-17_eventually-library.
|
||||
// Everything NextGraph-shaped comes from the ONE published SDK entry.
|
||||
import { useShape, docs, inbox as sdkInbox, ensureIdentity } from '@ng-eventually/polyfill';
|
||||
import type { Nuri, DeepSignalSet } from '@ng-eventually/polyfill';
|
||||
// Placement — taken from the app's own module, so the harness enumerates exactly
|
||||
// what the app enumerates. Placement is named by scope alone: "my documents" are
|
||||
// the signed-in session's, and there is no identity to name.
|
||||
import { listMyEntityDocs, openDocumentInbox, resolveScopeGraph } from '../utils/storeRegistry';
|
||||
import { setCurrentPrincipal } from '../utils/currentPrincipal';
|
||||
import { materializeAttendance, NOTIF_TYPE_NEW_PARTICIPANT } from '../data/registration';
|
||||
import type { RegistrationPayload } from '../data/registration';
|
||||
import {
|
||||
FpEventShapeType,
|
||||
FpUserProfileShapeType,
|
||||
FpParticipationShapeType,
|
||||
} from '../shapes/orm/festipodShapes.shapeTypes';
|
||||
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
|
||||
import { normalizeIdentifier } from '../context/AccountContext';
|
||||
|
||||
// ============================================================================
|
||||
// App — uses real providers (same tree as the real app)
|
||||
// ============================================================================
|
||||
|
||||
// Default @data identity — the seed owner. The harness has no login UI, so we
|
||||
// establish a default account (as the real app would after login) so the SDK
|
||||
// knows WHO is reading. Without a current identity the per-document ReadCap
|
||||
// filter passes only PUBLIC documents, so the current user's own PROTECTED
|
||||
// entities (profile, participations) would be hidden and never round-trip.
|
||||
const DEFAULT_HARNESS_USER = '@mariedupont';
|
||||
|
||||
/**
|
||||
* Step boundary: an event id crosses from a Cucumber step as a plain string. An
|
||||
* event IS its own document (rule_document-per-entity), so its id is a document
|
||||
* NURI — anything else names no document and has no inbox. Narrow here rather than
|
||||
* let a bad id reach the SDK as a silent no-op.
|
||||
*/
|
||||
/** The stand-in PUBLIC document of the T03.b probe — a literal, so it is a NURI
|
||||
* by construction with nothing to narrow. */
|
||||
const PUBLIC_PROBE: Nuri = 'did:ng:o:public-probe';
|
||||
|
||||
function asEventDoc(eventId: string): Nuri {
|
||||
if (!isNuri(eventId)) {
|
||||
throw new Error(`[HarnessNG] "${eventId}" is not a document NURI — an event id is its document.`);
|
||||
}
|
||||
return eventId;
|
||||
}
|
||||
|
||||
function DataHarnessNG() {
|
||||
return (
|
||||
<NextGraphProvider>
|
||||
<AccountProvider>
|
||||
<HarnessLogin />
|
||||
<FestipodDataProvider>
|
||||
<HarnessRouter />
|
||||
</FestipodDataProvider>
|
||||
</AccountProvider>
|
||||
<FestipodDataProvider>
|
||||
<HarnessRouter />
|
||||
</FestipodDataProvider>
|
||||
</NextGraphProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Establish the default @data identity once, so `setCurrentUser` fires (via the
|
||||
* AccountProvider effect) and the current user can read their own protected
|
||||
* entities. Mirrors the real app's post-login state. */
|
||||
function HarnessLogin() {
|
||||
const { identifier, login } = useAccount();
|
||||
useEffect(() => {
|
||||
if (!identifier) login(DEFAULT_HARNESS_USER);
|
||||
}, [identifier, login]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Wait for NG connection before exposing the test bridge
|
||||
// Wait for the NG connection AND for the ONE identity await before exposing the
|
||||
// test bridge — the same order the real app's AuthGate imposes. `ensureIdentity()`
|
||||
// IS signing in: it takes no identifier, resolves who we are and does the
|
||||
// connection work, so nothing may read before it resolves.
|
||||
function HarnessRouter() {
|
||||
const { status } = useNextGraph();
|
||||
const [identityReady, setIdentityReady] = useState(false);
|
||||
// Why signing in did NOT settle. A harness that failed to sign in must look
|
||||
// FAILED, never "still waiting": the difference between the two is the whole
|
||||
// difference between a broken suite and a slow one.
|
||||
const [identityError, setIdentityError] = useState<string | null>(null);
|
||||
|
||||
if (status === 'connected') {
|
||||
return <ConnectedHarness />;
|
||||
useEffect(() => {
|
||||
if (status !== 'connected' || identityReady || identityError) return;
|
||||
let cancelled = false;
|
||||
void ensureIdentity()
|
||||
.then(principal => {
|
||||
// Same as the app's AuthGate: signing in RETURNS who we are, and the
|
||||
// harness publishes it the same way, so the tree under test sees exactly
|
||||
// what the app's tree sees.
|
||||
setCurrentPrincipal(principal);
|
||||
if (!cancelled) setIdentityReady(true);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[HarnessNG] ensureIdentity failed:', err);
|
||||
if (!cancelled) setIdentityError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [status, identityReady, identityError]);
|
||||
|
||||
// TERMINAL STATES — each observable to a Playwright locator, and none of them
|
||||
// exposes the test bridge: a scenario must never read through a tree that never
|
||||
// signed in. `data-harness-error` carries the reason so the failure names itself.
|
||||
if (identityError) {
|
||||
return (
|
||||
<div id="harness-status" data-harness-error={identityError}>IDENTITY_ERROR</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return <div id="harness-status">ERROR</div>;
|
||||
}
|
||||
|
||||
if (status === 'connected' && identityReady) {
|
||||
return <ConnectedHarness />;
|
||||
}
|
||||
|
||||
return <div id="harness-status">WAITING_FOR_SESSION</div>;
|
||||
}
|
||||
|
||||
@@ -105,18 +96,48 @@ function HarnessRouter() {
|
||||
// Connected harness — exposes window.__testData through real providers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Resolve, ONCE, the graph each scope names — then mount the reads on them.
|
||||
*
|
||||
* The raw ORM sets below are anchored per SCOPE, and an entity's scope is a
|
||||
* domain fact (rule_document-per-entity): EVENTS are public; user profiles and
|
||||
* participations are protected. The app holds no store id and builds no
|
||||
* `did:ng:` NURI of its own — `resolveScopeGraph(scope)` is the SDK's answer to
|
||||
* "which graph is this scope", and the only one the harness is entitled to.
|
||||
*
|
||||
* The reads mount only once both graphs are known, so no shape set is ever
|
||||
* anchored on `undefined`.
|
||||
*/
|
||||
function ConnectedHarness() {
|
||||
const [graphs, setGraphs] = useState<{ publicGraph: Nuri; protectedGraph: Nuri } | null>(null);
|
||||
const [scopeError, setScopeError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void Promise.all([resolveScopeGraph('public'), resolveScopeGraph('protected')])
|
||||
.then(([publicGraph, protectedGraph]) => {
|
||||
if (!cancelled) setGraphs({ publicGraph, protectedGraph });
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[HarnessNG] scope resolution failed:', err);
|
||||
if (!cancelled) setScopeError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Terminal — same contract as the identity failure: observable, and no bridge.
|
||||
if (scopeError) {
|
||||
return <div id="harness-status" data-harness-error={scopeError}>SCOPE_ERROR</div>;
|
||||
}
|
||||
if (!graphs) {
|
||||
return <div id="harness-status">RESOLVING_SCOPES</div>;
|
||||
}
|
||||
return <ScopedHarness publicGraph={graphs.publicGraph} protectedGraph={graphs.protectedGraph} />;
|
||||
}
|
||||
|
||||
function ScopedHarness({ publicGraph, protectedGraph }: { publicGraph: Nuri; protectedGraph: Nuri }) {
|
||||
const ngCtx = useNextGraph();
|
||||
const appData = useFestipodData();
|
||||
// Identity switch (two-identity isolation): the app has no page reload on a
|
||||
// faux-logout+re-login (shared-wallet stopgap), so switching identity here
|
||||
// means calling AccountContext.login() with a new identifier — which drives the
|
||||
// `prevOwnerRef` reset effect in FestipodDataContext. Exposed to steps so a @data
|
||||
// scenario can bring up identity A, then a genuinely-different identity B on the
|
||||
// SAME wallet and assert B is isolated.
|
||||
const account = useAccount();
|
||||
const accountRef = useRef(account);
|
||||
accountRef.current = account;
|
||||
// The bridge is built once inside an effect (below) and its getters close over
|
||||
// `appData`. `appData` is a NEW object every render (its `events`/`users` reflect
|
||||
// the latest per-entity reads), so a captured snapshot goes STALE — after
|
||||
@@ -126,29 +147,14 @@ function ConnectedHarness() {
|
||||
const appDataRef = useRef(appData);
|
||||
appDataRef.current = appData;
|
||||
|
||||
// Private store NURI — the inbox shim anchor + the ReadCap-governed document.
|
||||
const privateNuri: Nuri | undefined = ngCtx.session && `did:ng:${ngCtx.session.private_store_id}`;
|
||||
// Protected store NURI — T02.h (axe A): the shareable DOMAIN entities (events,
|
||||
// users, participations) now live in the real protected native store, so the
|
||||
// harness's raw ORM sets subscribe there too (matching FestipodDataContext).
|
||||
const protectedNuri: Nuri | undefined = ngCtx.session && `did:ng:${ngCtx.session.protected_store_id}`;
|
||||
const events = useShape(FpEventShapeType, protectedNuri) as DeepSignalSet<FpEvent>;
|
||||
const users = useShape(FpUserProfileShapeType, protectedNuri) as DeepSignalSet<FpUserProfile>;
|
||||
const participations = useShape(FpParticipationShapeType, protectedNuri) as DeepSignalSet<FpParticipation>;
|
||||
// Each raw ORM set is anchored on the graph of the scope ITS OWN entity lives
|
||||
// in — events public, profiles and participations protected — matching
|
||||
// FestipodDataContext's `useShapeQuery(shape, scope)` reads.
|
||||
const events = useShape(FpEventShapeType, publicGraph) as DeepSignalSet<FpEvent>;
|
||||
const users = useShape(FpUserProfileShapeType, protectedGraph) as DeepSignalSet<FpUserProfile>;
|
||||
const participations = useShape(FpParticipationShapeType, protectedGraph) as DeepSignalSet<FpParticipation>;
|
||||
|
||||
const [bridgeReady, setBridgeReady] = useState(false);
|
||||
// Read-filter validation: <FilterProbe> mounts a useShape over THIS document and
|
||||
// returns the read-filtered VIEW of it. Which document depends on the probe: the
|
||||
// store-root one for the mono-store read-filter scenario, a real per-entity
|
||||
// document for the protected-connections one.
|
||||
const [filterDoc, setFilterDoc] = useState<Nuri | null>(null);
|
||||
// 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[]>([]);
|
||||
// T02.h gating: mount a useShape(protectedNuri) to open the protected repo.
|
||||
const [protectedActive, setProtectedActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Small delay for useShape to populate
|
||||
@@ -197,19 +203,6 @@ function ConnectedHarness() {
|
||||
get currentUserId() { return AD().currentUserId || currentUserId; },
|
||||
session,
|
||||
|
||||
// --- IDENTITY SWITCH (two-identity isolation) ----------------------
|
||||
/** Faux-logout + re-login under a NEW identifier on the SAME wallet (no
|
||||
* page reload), exactly as the real app's AccessGate/Settings flow does.
|
||||
* Drives AccountContext.login → setCurrentUser + the FestipodDataContext
|
||||
* `prevOwnerRef` reset. Returns the normalized id now in effect. */
|
||||
switchIdentity(identifier: string) {
|
||||
accountRef.current.login(identifier);
|
||||
return normalizeIdentifier(identifier);
|
||||
},
|
||||
/** The current app-level identifier (localStorage-backed). */
|
||||
currentIdentifier() {
|
||||
return accountRef.current.identifier;
|
||||
},
|
||||
/** Titles of the events the CURRENT user PARTICIPATES in — exactly what the
|
||||
* HOME screen shows (`getUserEvents(currentUserId)`). Used by the
|
||||
* two-identity isolation test to assert a fresh identity's home is empty. */
|
||||
@@ -252,28 +245,6 @@ function ConnectedHarness() {
|
||||
await AD().leaveEvent(eventId, userId);
|
||||
},
|
||||
|
||||
// --- RAW store-root path (the mono-store read-filter probe ONLY) -------
|
||||
// `read-filter.feature` states the filter's all-or-nothing behaviour on ONE
|
||||
// document holding SEVERAL items — the mono-store layout — so it governs the
|
||||
// STORE-ROOT protected document (`documentNuri` = protectedNuri) via
|
||||
// <FilterProbe> and needs participations written into THAT document. These
|
||||
// raw helpers keep that probe on the exact document it governs. Nothing else
|
||||
// may use them: the app (and the protected-connections probe) writes one
|
||||
// document per entity (rule_document-per-entity).
|
||||
get rawParticipations() { return participations; },
|
||||
rawJoin(eventId: string, userId: string) {
|
||||
const already = [...participations].some(p => p.event === eventId && p.user === userId);
|
||||
if (already) return;
|
||||
participations.add({
|
||||
'@graph': protectedNuri,
|
||||
'@type': 'http://festipod.org/Participation',
|
||||
'@id': '',
|
||||
event: eventId,
|
||||
user: userId,
|
||||
isConfirmed: true,
|
||||
} as FpParticipation);
|
||||
},
|
||||
|
||||
// --- Real app-path registration (T02.c) ----------------------------
|
||||
// These go through the REAL FestipodDataContext mutations (appData), so
|
||||
// the @data scenario faces the same inbox-deposit + notification +
|
||||
@@ -330,19 +301,26 @@ function ConnectedHarness() {
|
||||
const uid = AD().currentUserId || userAdapter()[0]?.['@id'] || '';
|
||||
return AD().isParticipating(eventId, uid);
|
||||
},
|
||||
/** The host inbox NURI for an event (domain glue, T02.c). An event id IS its
|
||||
* document NURI; a step that passes anything else has no inbox to name. */
|
||||
async eventInboxNuri(eventId: string) {
|
||||
return regInboxNuri(asEventDoc(eventId));
|
||||
/** The inbox address of an event, as ITS OWNER obtains it. An event id IS
|
||||
* its document NURI (rule_document-per-entity) — hence the `Nuri` type,
|
||||
* which is what `openDocumentInbox` takes. Only the owner opens that
|
||||
* document's inbox: a depositor never sees the address, it names the
|
||||
* document instead. */
|
||||
async eventInboxNuri(eventId: Nuri) {
|
||||
return openDocumentInbox(eventId);
|
||||
},
|
||||
/** Materialize the raw registration deposits for an event (curator). The
|
||||
* event's inbox may also carry other kinds, so filter to this event. */
|
||||
/** The raw registration deposits addressed to an event's document (read by
|
||||
* its owner). The event's inbox may also carry other kinds, so filter to
|
||||
* this event. */
|
||||
async readInboxDeposits(eventId: string) {
|
||||
const target = await regInboxNuri(asEventDoc(eventId));
|
||||
const deposits = await docsInbox.read(target);
|
||||
return deposits.filter(
|
||||
(d: any) => d?.payload?.kind === 'new-participant' && d?.payload?.eventId === eventId,
|
||||
);
|
||||
const deposits = await sdkInbox.readForDocument(eventId);
|
||||
return deposits.filter(d => {
|
||||
// A deposit's payload is opaque to the SDK — the Festipod domain shape
|
||||
// is `RegistrationPayload`, exactly as `readRegistrationNotifications`
|
||||
// reads it on the app side.
|
||||
const p = d.payload as Partial<RegistrationPayload> | null;
|
||||
return p?.kind === NOTIF_TYPE_NEW_PARTICIPANT && p?.eventId === eventId;
|
||||
});
|
||||
},
|
||||
/** OPTION B — the owner's DERIVED active-registration set for an event
|
||||
* (`materializeAttendance`), matched on the CANONICAL event-id form. Used
|
||||
@@ -350,10 +328,9 @@ function ConnectedHarness() {
|
||||
* is in the active set) rather than an absolute count — an inbox accumulates
|
||||
* deposits across the wallet's life, so |active| is not bounded to one
|
||||
* scenario, but "contains this uid" IS deterministic. */
|
||||
async activeRegistrationUsers(eventId: string) {
|
||||
const regmod = await import('../data/registration');
|
||||
const target = await regmod.hostInboxNuri(asEventDoc(eventId));
|
||||
const active = await regmod.materializeAttendance(target, eventId);
|
||||
async activeRegistrationUsers(eventId: Nuri) {
|
||||
const target = await openDocumentInbox(eventId);
|
||||
const active = await materializeAttendance(target, eventId);
|
||||
return active.map(r => r.userId);
|
||||
},
|
||||
/** Host-facing notifications currently surfaced by the data context. */
|
||||
@@ -373,23 +350,15 @@ function ConnectedHarness() {
|
||||
v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
|
||||
// Participations are ONE DOCUMENT PER ENTITY (protected scope), not the
|
||||
// store root — so re-query the broker across the protected per-entity
|
||||
// documents rather than the store-root graph. This stays authoritative
|
||||
// (bypasses the reactive set): it counts the (event,user) triples actually
|
||||
// persisted in the broker.
|
||||
const reg = await import('../utils/storeRegistry');
|
||||
// Enumerate the CURRENT account's own protected docs — the read-by-need
|
||||
// path the APP uses (registration.countUserParticipations →
|
||||
// listMyEntityDocs). Each @data scenario runs under a FRESH virtual account
|
||||
// (freshScenarioIdentifier in localStorage), whose participation docs live
|
||||
// ONLY in that account's protected scope index. There is no cross-account
|
||||
// enumeration any more (a directory would be discovery, which does not
|
||||
// exist): with no identity there is nothing this session may enumerate, so
|
||||
// fall back to the SDK's connected identity and otherwise count nothing.
|
||||
let currentUser = '';
|
||||
try { currentUser = window.localStorage.getItem('festipod.account.identifier') || ''; } catch { /* opaque origin */ }
|
||||
const holder = currentUser || getCurrentUser() || '';
|
||||
const protectedDocs = holder ? await reg.listMyEntityDocs(holder, 'protected') : [];
|
||||
// store root — so re-query the broker across MY OWN protected per-entity
|
||||
// documents, the exact read-by-need path the APP uses
|
||||
// (`registration.countUserParticipations` → `listMyEntityDocs(
|
||||
// 'protected')`). There is no cross-account enumeration (a directory
|
||||
// would be discovery, which does not exist), and "mine" needs no
|
||||
// identity — the session is one user's.
|
||||
// This stays authoritative (bypasses the reactive set): it counts the
|
||||
// (event,user) triples actually persisted in the broker.
|
||||
const protectedDocs = await listMyEntityDocs('protected');
|
||||
let total = 0;
|
||||
for (const g of protectedDocs) {
|
||||
// Anchored default-graph (no `GRAPH` clause): participations are
|
||||
@@ -457,19 +426,12 @@ function ConnectedHarness() {
|
||||
* a persistent broker, a real empty state needs the docs' CONTENT cleared
|
||||
* (the store-root delete of the old model no longer applies). Bounded: on a
|
||||
* freshly-provisioned wallet there are only a handful of entity docs.
|
||||
* Scoped to the CONNECTED identity's own documents — enumerating another
|
||||
* identity's is no longer possible, and clearing them was never this
|
||||
* harness's business. */
|
||||
* Scoped to MY OWN documents — enumerating anyone else's is not possible,
|
||||
* and clearing them was never this harness's business. */
|
||||
async clearWallet() {
|
||||
const reg = await import('../utils/storeRegistry');
|
||||
reg.resetRegistryCache();
|
||||
let holder = '';
|
||||
try { holder = window.localStorage.getItem('festipod.account.identifier') || ''; } catch { /* opaque origin */ }
|
||||
holder = holder || getCurrentUser() || '';
|
||||
if (!holder) return { cleared: 0 };
|
||||
const [pub, prot] = await Promise.all([
|
||||
reg.listMyEntityDocs(holder, 'public'),
|
||||
reg.listMyEntityDocs(holder, 'protected'),
|
||||
listMyEntityDocs('public'),
|
||||
listMyEntityDocs('protected'),
|
||||
]);
|
||||
const all = [...new Set([...pub, ...prot])];
|
||||
await Promise.all(all.map(g =>
|
||||
@@ -487,257 +449,6 @@ function ConnectedHarness() {
|
||||
return { cleared: all.length };
|
||||
},
|
||||
|
||||
/**
|
||||
* PER-SCENARIO STATE ISOLATION (T03.j). The @data suite runs against ONE
|
||||
* persistent broker-backed wallet, so the emulated account registry (the
|
||||
* `urn:ng-eventually:shim:Account` triples in the private-store anchor
|
||||
* graph) ACCUMULATES every account any scenario/run ever provisioned. The
|
||||
* read path is a fan-out: `allAccounts()` → one SPARQL SELECT per account
|
||||
* for `listEntityDocs`. As the registry grows unbounded across runs, that
|
||||
* fan-out gets slow and flaky (same class as the T03.d Chromium saturation).
|
||||
*
|
||||
* This gives each @data scenario a CLEAN registry: a SINGLE SPARQL DELETE
|
||||
* on the ONE private-store anchor graph removes every accumulated Account
|
||||
* record, so `allAccounts()` collapses to empty and the fan-out is bounded
|
||||
* to whatever the CURRENT scenario re-provisions (accounts are lazily
|
||||
* re-created by `ensureAccount` on first use). It is O(1) on ONE graph — NOT
|
||||
* a fan-out delete (which saturated the browser before, see T03.i's removed
|
||||
* `authClearParticipation`). Orphaned per-entity docs are simply never
|
||||
* enumerated once their owning Account record is gone.
|
||||
*
|
||||
* Test-infra ONLY: touches the emulation's registry anchor, never the
|
||||
* product model, the app read path, or the boundary. The lib is untouched;
|
||||
* this reuses the same anchor NURI (`did:ng:${private_store_id}`) and shim
|
||||
* vocabulary the lib's `loadShim`/`ensureAccount` use.
|
||||
*/
|
||||
async resetDataState() {
|
||||
const reg = await import('../utils/storeRegistry');
|
||||
const priv: Nuri = `did:ng:${session.private_store_id}`;
|
||||
const SHIM = 'urn:ng-eventually:shim';
|
||||
const t0 = Date.now();
|
||||
// Delete every Account record (and its identity/doc* predicates) from the
|
||||
// anchor graph. `?p ?o` with the `a shim:Account` guard scopes the delete
|
||||
// strictly to registry triples, leaving anything else in the private
|
||||
// store intact.
|
||||
const del = `
|
||||
DELETE { GRAPH <${priv}> { ?acc ?p ?o } }
|
||||
WHERE {
|
||||
GRAPH <${priv}> {
|
||||
?acc a <${SHIM}:Account> ;
|
||||
?p ?o .
|
||||
}
|
||||
}`;
|
||||
try {
|
||||
await docs.sparqlUpdate(session.session_id, del, priv);
|
||||
} catch { /* best-effort — a broker flake must not fail the scenario */ }
|
||||
// Drop the in-memory account cache so the next registry call re-reads the
|
||||
// now-empty anchor (else a stale cache would keep the old accounts alive).
|
||||
reg.resetRegistryCache();
|
||||
return { resetMs: Date.now() - t0 };
|
||||
},
|
||||
|
||||
/** ONE-TIME CLEANUP (T03.i): the private store accumulated thousands of
|
||||
* historical inbox-deposit triples across test runs (the old inbox anchor
|
||||
* = private store), making `loadShim` a 60s+ full-graph scan. Delete every
|
||||
* inbox Deposit triple from the private store so the shim query is fast
|
||||
* again. Idempotent; safe (deposits are transient test cruft). New deposits
|
||||
* now land in a dedicated inbox document (lib fix), so this won't re-grow. */
|
||||
async cleanPrivateInbox() {
|
||||
const priv: Nuri = `did:ng:${session.private_store_id}`;
|
||||
const t0 = Date.now();
|
||||
const del = `
|
||||
DELETE { GRAPH <${priv}> { ?s ?p ?o } }
|
||||
WHERE {
|
||||
GRAPH <${priv}> {
|
||||
?s a <urn:ng-eventually:inbox:Deposit> ;
|
||||
?p ?o .
|
||||
}
|
||||
}`;
|
||||
await docs.sparqlUpdate(session.session_id, del, priv);
|
||||
return { deleteMs: Date.now() - t0 };
|
||||
},
|
||||
|
||||
// --- ReadCap read-filter validation (see decision_2026-06-17_eventually-library) ---
|
||||
|
||||
/** The document (repo NURI) the shareable domain entities live in. After
|
||||
* T02.h this is the PROTECTED native store (was private) — the ReadCap
|
||||
* read-filter test governs the document that actually holds the
|
||||
* participations, so it must track the domain scope. */
|
||||
documentNuri: protectedNuri,
|
||||
|
||||
/**
|
||||
* Put the domain document under a ReadCap regime: `reader` — and only
|
||||
* `reader` — HOLDS its key, then the current user becomes `user`. Reading is
|
||||
* possession, so "who holds it" is established by filing the key WHILE that
|
||||
* identity is the connected one; there is no grant addressed to a third
|
||||
* party. The read filter is per-DOCUMENT, so this is all-or-nothing on that
|
||||
* document — the faithful NextGraph behavior in a mono-store layout.
|
||||
* <FilterProbe> then exposes window.__readFilter.snapshot() over the view.
|
||||
*/
|
||||
governDocument(reader: string, user: string) {
|
||||
if (!protectedNuri) throw new Error('no protected_store_id in session');
|
||||
resetCaps();
|
||||
setCurrentUser(reader);
|
||||
getCaps().open(protectedNuri, 'protected');
|
||||
setCurrentUser(user);
|
||||
setFilterDoc(protectedNuri);
|
||||
},
|
||||
|
||||
/** Switch the current user (does the user now hold the document's cap?). */
|
||||
setUser(user: string) {
|
||||
setCurrentUser(user);
|
||||
},
|
||||
|
||||
// --- PROTECTED + connections isolation (T03.b) ----------------------
|
||||
// Prove, through the SDK's ReadCap filter on the REAL ORM set, that a
|
||||
// PROTECTED ENTITY DOCUMENT owned by `owner` is:
|
||||
// - hidden from an UNCONNECTED principal (only owner holds its key);
|
||||
// - revealed once the app declares the connection owner↔reader;
|
||||
// - a PUBLIC document stays readable throughout.
|
||||
// The unit of sharing is the DOCUMENT, and each entity is its own document
|
||||
// (rule_document-per-entity) — so the probe exercises a real per-entity
|
||||
// document from `createEntityDoc(owner, 'protected')`, which is exactly what
|
||||
// `declareConnections` hands over. The PUBLIC probe is published as a repo
|
||||
// LINK and that link is then handed to the reader: public means "whoever has
|
||||
// the link reads", not "everyone reads regardless of keys". <FilterProbe>
|
||||
// exposes the read-filtered VIEW over the owner's protected entity document.
|
||||
// `connect` calls the app's declareConnections — the domain sharing act.
|
||||
|
||||
/**
|
||||
* Create `owner`'s PROTECTED ENTITY document and write its entity into it,
|
||||
* as `owner` — the creator is the one who holds the key, so possession is
|
||||
* established by CREATING, not by declaring anything. Mounts <FilterProbe>
|
||||
* over THAT document. Returns its NURI and how many entities were written
|
||||
* (the count the reader must end up seeing).
|
||||
*
|
||||
* `resetCaps` runs FIRST: it also clears the enforcement flag, so the very
|
||||
* next mint (this document's) is what arms the read filter.
|
||||
*/
|
||||
async setupProtectedEntity(owner: string) {
|
||||
const reg = await import('../utils/storeRegistry');
|
||||
resetCaps();
|
||||
resetConnections(); // clear the app's relationship registry too
|
||||
setCurrentUser(owner);
|
||||
const doc = await reg.createEntityDoc(owner, 'protected');
|
||||
await writeEntity(doc, ENTITY_TYPE.participation, {
|
||||
event: iri('urn:pc:event'),
|
||||
user: iri('urn:pc:p1'),
|
||||
isConfirmed: bool(true),
|
||||
});
|
||||
setFilterDoc(doc);
|
||||
// One entity, one document — so one item is the whole document.
|
||||
return { doc, total: 1 };
|
||||
},
|
||||
|
||||
/**
|
||||
* Bring up the UNCONNECTED reader: `owner` publishes the public probe as a
|
||||
* repo link and hands it to `reader`, who becomes the connected identity.
|
||||
* No cap of the protected entity document is handed over — that is what the
|
||||
* connection is for. Runs AFTER `setupProtectedEntity` and deliberately does
|
||||
* NOT reset caps: the owner's key on its own document must survive.
|
||||
*/
|
||||
governProtected(owner: string, reader: string) {
|
||||
setCurrentUser(owner);
|
||||
// A public entity document, published as a shareable repo link.
|
||||
const publicLink = getCaps().publishRepoLink(PUBLIC_PROBE);
|
||||
setCurrentUser(reader);
|
||||
// The reader was handed that link — which is all "public" means here.
|
||||
getCaps().learn(publicLink);
|
||||
},
|
||||
/**
|
||||
* Declare a bilateral owner↔reader connection (domain sharing act) the way
|
||||
* two real sessions would: each side asserts from ITS OWN session, because
|
||||
* sharing a key requires HOLDING it and `capFor` answers for the connected
|
||||
* identity alone. Reader asserts first (nothing to share yet), then the owner
|
||||
* asserts back — that second call is the one that finds a two-sided link and
|
||||
* hands its protected documents' keys to the reader's inbox. Finally the
|
||||
* reader reconnects and `connectedUser()` drains that inbox, which is where
|
||||
* the key actually lands among what the reader holds.
|
||||
*/
|
||||
async connect(owner: string, reader: string) {
|
||||
const reg = await import('../utils/storeRegistry');
|
||||
// The reader's own account + inbox, provisioned from the READER's session
|
||||
// so what belongs to it is filed under it.
|
||||
setCurrentUser(reader);
|
||||
await reg.ensureAccount(reader);
|
||||
await reg.walletInbox(reader);
|
||||
await declareConnections([owner], reader); // reader asserts owner
|
||||
setCurrentUser(owner);
|
||||
await declareConnections([reader], owner); // bilateral → owner shares its keys
|
||||
setCurrentUser(reader);
|
||||
await connectedUser(); // the reader drains its inbox → it now holds the key
|
||||
},
|
||||
/** Does the CURRENT user hold the public entity document's key — the only
|
||||
* question the model can answer — regardless of the protected one? */
|
||||
canReadPublicProbe() {
|
||||
return getCaps().capFor(PUBLIC_PROBE) !== undefined;
|
||||
},
|
||||
|
||||
// --- 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 docs.docCreate(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(identifier: string) {
|
||||
const reg = await import('../utils/storeRegistry');
|
||||
reg.resetRegistryCache();
|
||||
const created = await reg.ensureAccount(identifier);
|
||||
reg.resetRegistryCache();
|
||||
// Re-read THIS account back from the wallet (there is no all-accounts
|
||||
// enumeration any more — a directory is discovery, and discovery does not
|
||||
// exist). `resolveAccount` reads without provisioning, which is exactly
|
||||
// what a round-trip check needs.
|
||||
const reloaded = await reg.resolveAccount(identifier);
|
||||
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');
|
||||
// The index-append (which makes docA/docB show up in the scope index) can
|
||||
// lag behind createEntityDoc on the broker — poll until BOTH are listed
|
||||
// (bounded) so the "index lists both docs" assertion isn't flaky. Each
|
||||
// account's own index is read separately: there is no cross-account
|
||||
// enumeration any more, and the fan-out under test is the READ over both
|
||||
// documents, not the listing.
|
||||
let listed: Nuri[] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
reg.resetRegistryCache();
|
||||
const [a, b] = await Promise.all([
|
||||
reg.listMyEntityDocs('@fan-a', 'public'),
|
||||
reg.listMyEntityDocs('@fan-b', 'public'),
|
||||
]);
|
||||
listed = [...new Set([...a, ...b])];
|
||||
if (listed.includes(docA) && listed.includes(docB)) break;
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
}
|
||||
setFanoutGraphs([docA, docB]);
|
||||
return { docA, docB, listed };
|
||||
},
|
||||
|
||||
// --- Public discovery: REMOVED ------------------------------------
|
||||
// The two probes that lived here (publishPublicEventAs /
|
||||
// discoverPublicEventsAs) exercised the SDK's global discovery index.
|
||||
@@ -746,46 +457,6 @@ function ConnectedHarness() {
|
||||
// served is @wip until Festipod publishes a directory document of its
|
||||
// own — at which point the probes come back, reading the directory
|
||||
// instead of an SDK index.
|
||||
|
||||
// --- T02.h GATING: protected native store openability -----------------
|
||||
// Does the REAL protected store (`did:ng:${protected_store_id}`) open for
|
||||
// ORM reads AND writes the same way private does? Private was chosen
|
||||
// (decision_2026-03-17) precisely because it opened without RepoNotFound.
|
||||
// Before switching the domain scope to protected, prove empirically that
|
||||
// a write scoped to protectedNuri is READABLE back (round-trip). Mounting
|
||||
// <ProtectedProbe> subscribes a useShape(protectedNuri) — that
|
||||
// orm_start_graph call is what opens the repo in the verifier.
|
||||
protectedNuri,
|
||||
mountProtectedProbe() {
|
||||
setProtectedActive(true);
|
||||
},
|
||||
/** Authoritative round-trip: SPARQL INSERT a marker triple into the
|
||||
* protected store graph, then SPARQL SELECT it back — bypassing the ORM
|
||||
* set entirely, so a RepoNotFound surfaces as a thrown error here. */
|
||||
async protectedSparqlRoundTrip() {
|
||||
if (!protectedNuri) throw new Error('no protected_store_id in session');
|
||||
const subj = `did:ng:o:probe${Date.now().toString(36)}`;
|
||||
const g = protectedNuri.replace(/^did:ng:/, 'did:ng:');
|
||||
const insert = `INSERT DATA { GRAPH <${protectedNuri}> { <urn:probe:s> <urn:probe:p> "hit" } }`;
|
||||
let insertError: string | null = null;
|
||||
try {
|
||||
await docs.sparqlUpdate(session.session_id, insert, protectedNuri);
|
||||
} catch (e: any) {
|
||||
insertError = String(e?.message ?? e);
|
||||
}
|
||||
void subj; void g;
|
||||
let count = 0;
|
||||
let queryError: string | null = null;
|
||||
try {
|
||||
const q = `SELECT (COUNT(*) AS ?n) WHERE { GRAPH <${protectedNuri}> { <urn:probe:s> <urn:probe:p> ?o } }`;
|
||||
const res: any = await docs.sparqlQuery(session.session_id, q, undefined, protectedNuri);
|
||||
const rows = Array.isArray(res) ? res : res?.results?.bindings ?? [];
|
||||
count = parseInt(rows[0]?.n?.value ?? '0', 10) || 0;
|
||||
} catch (e: any) {
|
||||
queryError = String(e?.message ?? e);
|
||||
}
|
||||
return { insertError, queryError, count, protectedNuri };
|
||||
},
|
||||
};
|
||||
|
||||
console.log('[HarnessNG] Ready — events:', events.size, 'users:', users.size,
|
||||
@@ -800,124 +471,10 @@ function ConnectedHarness() {
|
||||
return (
|
||||
<>
|
||||
<div id="harness-status">{bridgeReady ? 'READY' : 'LOADING_SHAPES'}</div>
|
||||
{filterDoc && <FilterProbe documentNuri={filterDoc} />}
|
||||
{smokeDoc && <SmokeProbe docNuri={smokeDoc} />}
|
||||
{fanoutGraphs.length > 0 && <FanoutProbe graphs={fanoutGraphs} />}
|
||||
{protectedActive && protectedNuri && <ProtectedProbe protectedNuri={protectedNuri} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ProtectedProbe (T02.h gating) — subscribes an ORM set scoped to the REAL
|
||||
// protected native store, so `orm_start_graph` opens that repo in the verifier
|
||||
// (the same mechanism that made private work — decision_2026-03-17). Exposes
|
||||
// window.__protected: an ORM add() + read-back, to prove the protected store
|
||||
// round-trips writes the way private does (or surfaces RepoNotFound if not).
|
||||
// ============================================================================
|
||||
|
||||
function ProtectedProbe({ protectedNuri }: { protectedNuri: string }) {
|
||||
const set = useShape(FpParticipationShapeType, protectedNuri) as DeepSignalSet<FpParticipation>;
|
||||
useEffect(() => {
|
||||
(window as any).__protected = {
|
||||
ready: true,
|
||||
protectedNuri,
|
||||
add() {
|
||||
set.add({
|
||||
'@graph': protectedNuri,
|
||||
'@type': 'http://festipod.org/Participation',
|
||||
'@id': '',
|
||||
event: 'urn:protected:event',
|
||||
user: 'urn:protected:user',
|
||||
isConfirmed: true,
|
||||
} as FpParticipation);
|
||||
},
|
||||
count() { return set.size; },
|
||||
items() {
|
||||
return [...set].map(p => ({ '@id': p['@id'], event: p.event, user: p.user }));
|
||||
},
|
||||
};
|
||||
}, [set, protectedNuri]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FilterProbe — subscribes participations AFTER a ReadCap policy is active, so
|
||||
// useShape returns the read-filtered VIEW. Exposes window.__readFilter.snapshot()
|
||||
// (evaluated lazily → reflects the CURRENT user) for the @data scenario that
|
||||
// validates the per-document read filter on the real ORM set.
|
||||
// ============================================================================
|
||||
|
||||
function FilterProbe({ documentNuri }: { documentNuri: string }) {
|
||||
const set = useShape(FpParticipationShapeType, documentNuri) as DeepSignalSet<FpParticipation>;
|
||||
useEffect(() => {
|
||||
(window as any).__readFilter = {
|
||||
ready: true,
|
||||
// Lazy: the filtered view reads the current user at access time, so calling
|
||||
// snapshot() after setUser() reflects the new cap holder without remount.
|
||||
snapshot: () => ({ count: set.size, users: [...set].map(p => p.user) }),
|
||||
};
|
||||
}, [set]);
|
||||
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
|
||||
// ============================================================================
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
*/
|
||||
|
||||
import { Window } from 'happy-dom';
|
||||
import React from 'react';
|
||||
import { getScreen } from '../../screens/index';
|
||||
import { LocalDataProvider } from '../context/FestipodDataContext';
|
||||
import { RouterProvider } from '../../app/router';
|
||||
@@ -104,37 +103,6 @@ export async function renderScreen(screenId: string, path?: string): Promise<Doc
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an arbitrary React element (a prop-driven component) into the same
|
||||
* happy-dom container. Unlike {@link renderScreen}, this does NOT go through the
|
||||
* screen registry or wrap in the data/router providers — use it for standalone,
|
||||
* prop-driven components such as the AccessGateScreen (the access barrier), which
|
||||
* take their state as props rather than reading it from context. Returns the
|
||||
* rendered document for DOM assertions.
|
||||
*/
|
||||
export async function renderElement(element: React.ReactElement): Promise<Document> {
|
||||
await ensureDomGlobals();
|
||||
if (!window) throw new Error('DOM globals not installed');
|
||||
|
||||
if (root) {
|
||||
root.unmount();
|
||||
root = null;
|
||||
}
|
||||
|
||||
const doc = window.document as unknown as Document;
|
||||
doc.body.innerHTML = '<div id="root"></div>';
|
||||
const container = doc.getElementById('root')!;
|
||||
|
||||
root = createRoot(container);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
root.render(element);
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a registry path with `:id` placeholders to a concrete URL using the
|
||||
* first seed event/user when applicable. Tests can override via the explicit
|
||||
@@ -154,9 +122,10 @@ function defaultPathFor(registryPath: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the happy-dom window's URL (so `window.location.search` reflects a given
|
||||
* query string) BEFORE a subsequent render. Used to drive context that resolves
|
||||
* state from the URL — e.g. AccountContext reads the `?id=` param. Idempotent:
|
||||
* Set the happy-dom window's URL (so `window.location.search` / `.pathname`
|
||||
* reflect a given URL) BEFORE a subsequent render. For a screen whose state comes
|
||||
* from the URL rather than from props. No caller today — the screens under @ui
|
||||
* take their route through renderScreen()'s `path` argument. Idempotent:
|
||||
* installs the DOM globals first if needed.
|
||||
*/
|
||||
export async function setRenderUrl(url: string): Promise<void> {
|
||||
|
||||
@@ -2,21 +2,21 @@
|
||||
* connections (Festipod glue) — the app owns the relationship concept.
|
||||
*
|
||||
* "Connected" is a Festipod domain fact (an accepted, two-sided friendship), not
|
||||
* something the data SDK models: the SDK models reading as key POSSESSION, so the
|
||||
* only act available is `shareCap(cap, toInbox)` — hand ONE document's key to ONE
|
||||
* recipient, addressed by their inbox. So the app keeps its own bilateral
|
||||
* relationship registry here and, once a link is two-sided, SHARES the keys of its
|
||||
* own protected documents with that neighbour. The app carries no access CHECK
|
||||
* (that stays the SDK's job — see knowledge_trust-model); it only shares what
|
||||
* follows from its own relationship graph.
|
||||
* something the data SDK models: the SDK models reading as key POSSESSION, and
|
||||
* giving someone that key is ONE act — `inbox.share(doc, toUser)`, naming the
|
||||
* document and the person. So the app keeps its own bilateral relationship
|
||||
* registry here and, once a link is two-sided, SHARES its own protected
|
||||
* documents with that neighbour. The app carries no access CHECK (that stays the
|
||||
* SDK's job — see knowledge_trust-model); it only shares what follows from its
|
||||
* own relationship graph, and it never handles a key or an inbox address.
|
||||
*
|
||||
* A link between `a` and `b` is live only when BOTH `a → b` and `b → a` have been
|
||||
* asserted. A reader who unilaterally self-declares a link to an owner gets
|
||||
* nothing: the owner never asserted them back, so nothing is shared with them.
|
||||
*/
|
||||
|
||||
import { capFor, shareCap } from '@ng-eventually/client/polyfill';
|
||||
import { listMyEntityDocs, walletInbox } from './storeRegistry';
|
||||
import { inbox } from '@ng-eventually/polyfill';
|
||||
import { listMyEntityDocs } from './storeRegistry';
|
||||
|
||||
/** Accumulates directed assertions and exposes the bilateral neighbourhood. */
|
||||
class RelationshipRegistry {
|
||||
@@ -60,22 +60,25 @@ const registry = new RelationshipRegistry();
|
||||
/**
|
||||
* Declare the connections a session asserts, as `self`, to each id in `peers`,
|
||||
* then share what follows. For every bilateral link (both sides asserted), the
|
||||
* session shares the key of each of ITS OWN protected documents
|
||||
* (`listMyEntityDocs(self, 'protected')` + `capFor`) into that neighbour's inbox
|
||||
* (`walletInbox(neighbour)` + `shareCap`). Re-callable whenever the relationship
|
||||
* graph changes.
|
||||
* session shares each of ITS OWN protected documents with that neighbour —
|
||||
* `inbox.share(doc, neighbour)`, one act naming the document and the person.
|
||||
* Re-callable whenever the relationship graph changes.
|
||||
*
|
||||
* `self` is the id of the asserting identity (its normalized-id key, the same key
|
||||
* its documents are recorded under). A session only ever asserts its own side —
|
||||
* and now it can only SHARE its own side too: sharing a key requires holding it,
|
||||
* and `capFor` answers for the connected identity alone. Where the previous
|
||||
* version re-derived grants for every asserter (possible while reading was an ACL
|
||||
* one process could edit for everyone), each identity now shares its own
|
||||
* documents from its own session.
|
||||
* `self` is the id of the asserting identity; a session only ever asserts — and
|
||||
* only ever shares — its own side. Sharing requires holding the document, and a
|
||||
* session holds only what it created.
|
||||
*
|
||||
* Idempotent in EFFECT, not in traffic: `addLink` on the receiving side ignores a
|
||||
* key it already holds, so re-sharing changes nothing for the recipient — but each
|
||||
* call appends a fresh deposit to their inbox document (see the caller's note).
|
||||
* A NEIGHBOUR MUST BE SOMEONE WHO EXISTS: `inbox.share` names a person, and it
|
||||
* refuses a recipient nobody has signed in as rather than creating them. So the
|
||||
* keys handed here must come from the relationship graph of REAL accounts — a
|
||||
* key derived from a profile that is only a fixture names nobody, and the share
|
||||
* rejects. Nothing downstream waits on this call (the caller is
|
||||
* fire-and-forget), so such a rejection surfaces as a logged error, never as a
|
||||
* silent grant.
|
||||
*
|
||||
* Idempotent in EFFECT, not in traffic: the recipient applies a key it already
|
||||
* holds to no effect, so re-sharing changes nothing for them — but each call is
|
||||
* a fresh deposit (see the caller's note).
|
||||
*/
|
||||
export async function declareConnections(peers: Iterable<string>, self: string): Promise<void> {
|
||||
if (!self) return;
|
||||
@@ -85,16 +88,11 @@ export async function declareConnections(peers: Iterable<string>, self: string):
|
||||
if (neighbours.size === 0) return;
|
||||
// My own protected documents. Resolved once: the set is the same for every
|
||||
// neighbour, and each resolution is a broker read.
|
||||
const myDocs = await listMyEntityDocs(self, 'protected');
|
||||
const myDocs = await listMyEntityDocs('protected');
|
||||
if (myDocs.length === 0) return;
|
||||
for (const neighbour of neighbours) {
|
||||
const inbox = await walletInbox(neighbour);
|
||||
for (const doc of myDocs) {
|
||||
const cap = capFor(doc);
|
||||
// No key held → nothing to share. A document I cannot read is not mine to
|
||||
// hand over, and no key is ever derived from a bare reference.
|
||||
if (!cap) continue;
|
||||
await shareCap(cap, inbox);
|
||||
await inbox.share(doc, neighbour);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* currentPrincipal — WHO the session signed in as, as `ensureIdentity()` hands it
|
||||
* back.
|
||||
*
|
||||
* Signing in is one await (`ensureIdentity()`), and it now RETURNS the identity.
|
||||
* That value is the app's only upstream answer to "who am I": it is known the
|
||||
* moment the barrier settles, before any document has been read.
|
||||
*
|
||||
* WHAT IT IS FOR — display and logging, nothing else. **No call takes it**: a
|
||||
* session belongs to one user, so placement is named by scope alone
|
||||
* (`createEntityDoc(scope)`, `listMyEntityDocs(scope)`). Handing this value back
|
||||
* to the data layer would re-create the parameter the surface deliberately
|
||||
* removed, so it must never travel into an SDK call.
|
||||
*
|
||||
* WHAT IT IS NOT — it is NOT the id space the app's own entities live in. A
|
||||
* UserProfile's id is its document NURI, and a Participation's `fp:user` carries
|
||||
* that NURI; this principal is a third space. The join between the two is
|
||||
* explicit and lives in one place (`FestipodDataContext`, where the principal
|
||||
* selects the current user's profile through `normalizeIdentifier(username)`).
|
||||
* Never compare this value to an entity id directly.
|
||||
*
|
||||
* WHY A MODULE STORE and not a React context: `AuthGate` — the component that
|
||||
* makes the await — is mounted INSIDE `FestipodDataProvider`, so a context it
|
||||
* published would be invisible to the very consumer that needs it. A plain
|
||||
* module singleton observed with `useSyncExternalStore` is independent of
|
||||
* provider order, exactly like `data/pendingQueries.ts`.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import type { PrincipalId } from '@ng-eventually/polyfill';
|
||||
|
||||
/** `''` means "signing in has not settled yet" — never "no identity". */
|
||||
let principal: string = '';
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
/** Record the identity `ensureIdentity()` returned. Idempotent per value. */
|
||||
export function setCurrentPrincipal(id: PrincipalId): void {
|
||||
if (principal === id) return;
|
||||
principal = id;
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
/** The signed-in principal, or `''` while the one identity await is pending. */
|
||||
export function getCurrentPrincipal(): string {
|
||||
return principal;
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe a component to the signed-in principal. The snapshot is a primitive
|
||||
* string, stable between real changes — no render loop.
|
||||
*/
|
||||
export function useCurrentPrincipal(): string {
|
||||
return useSyncExternalStore(subscribe, getCurrentPrincipal, getCurrentPrincipal);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Identifier normalization — Festipod's OWN handle → key mapping.
|
||||
*
|
||||
* Nothing here is lent to the SDK. Normalizing identities is the SDK's own affair
|
||||
* (`@Alice`, `alice ` and `ALICE` are one person to it, and it needs no hook from
|
||||
* us to say so). What remains is a Festipod comparison: a profile's `username` is
|
||||
* an app field, typed by a human, and the app matches it against the signed-in
|
||||
* principal and against peer keys in its own relationship graph. That is the sole
|
||||
* remaining caller (`FestipodDataContext`).
|
||||
*/
|
||||
|
||||
/** Trim, strip leading `@`, lowercase — the canonical form of a handle. */
|
||||
export function normalizeIdentifier(identifier: string | null | undefined): string {
|
||||
return (identifier ?? '').trim().replace(/^@+/, '').toLowerCase();
|
||||
}
|
||||
@@ -11,8 +11,8 @@
|
||||
* them to the live subscription set (reactivity).
|
||||
*/
|
||||
|
||||
import type { Nuri } from '@ng-eventually/client';
|
||||
import { normalizeIdentifier } from '../context/AccountContext';
|
||||
import type { Nuri } from '@ng-eventually/polyfill';
|
||||
import { openDocumentInbox } from './storeRegistry';
|
||||
import {
|
||||
seedEvents,
|
||||
seedUsers,
|
||||
@@ -21,7 +21,8 @@ import { writeEntity, ENTITY_TYPE, str, int, flt, bool } from '../data/entityWri
|
||||
|
||||
/** Scope of a seed entity + how to create its own document (SDK create). */
|
||||
export type Scope = 'public' | 'protected' | 'private';
|
||||
export type CreateEntityDoc = (owner: string, scope: Scope) => Promise<Nuri>;
|
||||
/** Placement is named by SCOPE ALONE — the session that seeds owns what it seeds. */
|
||||
export type CreateEntityDoc = (scope: Scope) => Promise<Nuri>;
|
||||
|
||||
export interface BootstrapResult {
|
||||
seeded: boolean;
|
||||
@@ -43,7 +44,6 @@ export interface BootstrapResult {
|
||||
export async function bootstrapWallet(
|
||||
walletHasData: boolean,
|
||||
createEntityDoc: CreateEntityDoc,
|
||||
owner?: string,
|
||||
): Promise<BootstrapResult> {
|
||||
const createdDocs: { public: Nuri[]; protected: Nuri[] } = { public: [], protected: [] };
|
||||
// Already has data → returning user, nothing to seed
|
||||
@@ -54,20 +54,13 @@ export async function bootstrapWallet(
|
||||
|
||||
console.log('[Bootstrap] First time for this wallet — seeding per-entity docs...');
|
||||
|
||||
// OWNER: all seed entities are owned by the SINGLE seed owner account — the
|
||||
// CURRENT logged-in account (`owner`), so "load test data into MY wallet" makes
|
||||
// the current user the owner. This matters for PROTECTED entities (seed user
|
||||
// profiles, participations): per-document isolation grants a protected doc's
|
||||
// ReadCap to its OWNER (+ connections), so if the seed owned them as someone
|
||||
// ELSE (e.g. the fixture's `mariedupont`) they'd be correctly HIDDEN from the
|
||||
// current fresh-scenario user and never round-trip. Owning them as the current
|
||||
// user makes them readable. PUBLIC events are world-readable regardless of owner.
|
||||
// The seed users are FIXTURES, not real login accounts — minting a full owner
|
||||
// account per seed user would be dozens of broker round-trips (unusably slow)
|
||||
// with no product meaning. One account (the current user) owns them; each entity
|
||||
// is still ITS OWN document (per-document isolation unchanged — only the cap
|
||||
// OWNER is shared). Falls back to the fixture username when no login is present.
|
||||
const seedOwner = owner ?? (seedUsers[0] ? normalizeIdentifier(seedUsers[0].username) : 'seed');
|
||||
// OWNERSHIP: "load test data into MY wallet" — every seeded entity is created
|
||||
// in a SCOPE of the signed-in session, which therefore owns it. That is what
|
||||
// makes the PROTECTED fixtures (user profiles, participations) round-trip:
|
||||
// reading is possession of the key, and the creator holds what it created.
|
||||
// The seed users are FIXTURES, not accounts anyone signs in as — they exist to
|
||||
// be read, never to be addressed as a person. PUBLIC events are readable by
|
||||
// whoever reaches them, ownership notwithstanding.
|
||||
|
||||
// SEED FOOTPRINT (perf). Each entity is its OWN document, and `docCreate` is a
|
||||
// round-trip that does not overlap with the next one, so the seed cost grows
|
||||
@@ -83,16 +76,18 @@ export async function bootstrapWallet(
|
||||
const SEED_USER_LIMIT = 3;
|
||||
const usersToSeed = seedUsers.slice(0, SEED_USER_LIMIT);
|
||||
|
||||
// Establish the owner account ONCE, serially, BEFORE any create: the first
|
||||
// `createEntityDoc(seedOwner, …)` creates the owner account and
|
||||
// caches it, so later concurrent calls don't race to re-create the account.
|
||||
const firstUserGraph = await createEntityDoc(seedOwner, 'protected');
|
||||
// Serialize the FIRST create, then fan out: it warms whatever the scope needs
|
||||
// to exist before a document can be filed in it, so the concurrent creates that
|
||||
// follow don't race to establish the same thing twice. A creation that cannot
|
||||
// be recorded THROWS (it never returns a reference that would read empty
|
||||
// forever), so a failure here aborts the seed instead of half-seeding it.
|
||||
const firstUserGraph = await createEntityDoc('protected');
|
||||
|
||||
// Users — one PROTECTED document each. The written subject IRI is the entity's
|
||||
// stable `@id`, kept in the id map.
|
||||
const userIdMap = new Map<string, string>();
|
||||
await Promise.all(usersToSeed.map(async (u, i) => {
|
||||
const graph = i === 0 ? firstUserGraph : await createEntityDoc(seedOwner, 'protected');
|
||||
const graph = i === 0 ? firstUserGraph : await createEntityDoc('protected');
|
||||
createdDocs.protected.push(graph);
|
||||
const id = await writeEntity(graph, ENTITY_TYPE.user, {
|
||||
name: str(u.name), initials: str(u.initials), username: str(u.username),
|
||||
@@ -105,7 +100,11 @@ export async function bootstrapWallet(
|
||||
// Events — one PUBLIC document each (all of them: looked up by title in @data).
|
||||
const eventIdMap = new Map<string, string>();
|
||||
await Promise.all(seedEvents.map(async (e) => {
|
||||
const graph = await createEntityDoc(seedOwner, 'public');
|
||||
const graph = await createEntityDoc('public');
|
||||
// An event is a document people SIGN UP TO, so its owner opens its inbox —
|
||||
// a document only has one if its owner did. Without this, a registrant's
|
||||
// deposit has nothing to reach. Only events get one (not every entity).
|
||||
await openDocumentInbox(graph);
|
||||
createdDocs.public.push(graph);
|
||||
const id = await writeEntity(graph, ENTITY_TYPE.event, {
|
||||
title: str(e.title), description: str(e.description), date: str(e.date),
|
||||
@@ -115,8 +114,8 @@ export async function bootstrapWallet(
|
||||
});
|
||||
eventIdMap.set(e.id, id);
|
||||
// The seeded event is NOT announced anywhere: there is no discovery index to
|
||||
// submit it to. A fresh virtual user therefore does not see the seeded events
|
||||
// — it sees its own. Restoring that requires the directory document.
|
||||
// submit it to. Someone who did not run this seed therefore does not see these
|
||||
// events — they see their own. Restoring that requires the directory document.
|
||||
}));
|
||||
console.log('[Bootstrap] Seeded', eventIdMap.size, 'events (participations created live)');
|
||||
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
// Injection point — the ONLY app module that imports the real @ng-org SDK, to
|
||||
// inject it into @ng-eventually/client. Every other Festipod module gets
|
||||
// its NextGraph surface from @ng-eventually/client. Removed at migration.
|
||||
// inject it into @ng-eventually/polyfill. Every other Festipod module gets its
|
||||
// NextGraph surface from @ng-eventually/polyfill. Removed at migration.
|
||||
import { ng as realNg, init as realInit } from "@ng-org/web";
|
||||
import type { NG } from "@ng-eventually/client";
|
||||
import type { NG } from "@ng-eventually/polyfill";
|
||||
import { initNg as realInitNg } from "@ng-org/orm";
|
||||
import { useShape as realUseShape } from "@ng-org/orm/react";
|
||||
import { configure } from "@ng-eventually/client/polyfill";
|
||||
|
||||
// SDK-shaped surface used by ngSession itself — taken from the lib, not @ng-org.
|
||||
import { ng, init as initNgWeb, initNg as initNgSignals } from "@ng-eventually/client";
|
||||
// SDK-shaped surface used by ngSession itself — taken from the SDK, not @ng-org.
|
||||
import { configure, ng, init as initNgWeb, initNg as initNgSignals } from "@ng-eventually/polyfill";
|
||||
// The deployment's wallet — ONE copy of these values in the app, shared with the
|
||||
// access barrier (modules/auth re-exports this module).
|
||||
import {
|
||||
SHARED_WALLET_FILE_URL,
|
||||
SHARED_WALLET_PASSWORD,
|
||||
WALLET_IMPORT_URL,
|
||||
hasSharedWallet,
|
||||
} from "./sharedWallet";
|
||||
|
||||
// DIAGNOSTIC (shared-wallet isolation): the SDK access log SEES every read/write
|
||||
// prefixed by the active identity — the way to catch a doc read under the wrong
|
||||
@@ -23,13 +30,6 @@ function accessLogEnabled(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
const __accessLogOn = accessLogEnabled();
|
||||
console.log("[NG session] access-log:", __accessLogOn ? "ON" : "OFF (localStorage festipod.debug.accessLog=0)");
|
||||
configure({
|
||||
ng: realNg, useShape: realUseShape, init: realInit, initNg: realInitNg,
|
||||
debugAccessLog: __accessLogOn,
|
||||
});
|
||||
|
||||
export let session: NextGraphSession | undefined;
|
||||
|
||||
let resolveSessionPromise: (
|
||||
@@ -44,15 +44,47 @@ export let sessionPromise: Promise<NextGraphSession> = new Promise(
|
||||
}
|
||||
);
|
||||
|
||||
// --- The ONE bootstrap call -------------------------------------------------
|
||||
// `configure` is the single bootstrap entry, and it now injects ONE thing: the
|
||||
// real @ng-org surface plus what this deployment hands its users. It carries no
|
||||
// consumer wiring any more — THE SESSION IS THE SDK'S, not ours. We never build
|
||||
// one and no call takes one: the SDK captures the session itself when `init` (its
|
||||
// own, called below) fires, and it normalizes identities itself. The app reads
|
||||
// the `session_id` off the same `init` event, for the `docs` primitives only.
|
||||
const __accessLogOn = accessLogEnabled();
|
||||
console.log("[NG session] access-log:", __accessLogOn ? "ON" : "OFF (localStorage festipod.debug.accessLog=0)");
|
||||
console.log("[NG session] shared wallet:", hasSharedWallet() ? "configured" : "absent");
|
||||
configure({
|
||||
ng: realNg, useShape: realUseShape, init: realInit, initNg: realInitNg,
|
||||
debugAccessLog: __accessLogOn,
|
||||
// What THIS deployment hands its users: the wallet file it serves and the
|
||||
// password it publishes. The SDK reads no environment variable — the app
|
||||
// resolves the values (build-time `define` or the runtime config endpoint)
|
||||
// and passes them. No shared wallet configured → nothing to pass.
|
||||
sharedWallet: hasSharedWallet()
|
||||
? {
|
||||
fileUrl: SHARED_WALLET_FILE_URL,
|
||||
password: SHARED_WALLET_PASSWORD,
|
||||
importUrl: WALLET_IMPORT_URL,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
let initPromise: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Register the initNgWeb callback. Idempotent — returns the same promise on repeated calls.
|
||||
* Does NOT trigger login by itself. Call login() separately to open the wallet login page.
|
||||
* START THE SESSION — the SDK's own `init`, which is the ONLY way a session comes
|
||||
* into being. It captures the session itself and then calls this callback with the
|
||||
* same event untouched; the app reads `session_id` off it, because the `docs`
|
||||
* primitives are the one part of the surface that still names a session.
|
||||
*
|
||||
* ORDER IS CONTRACTUAL: this call must have happened before anything awaits
|
||||
* `ensureIdentity()`, which throws otherwise. Idempotent — repeated calls return
|
||||
* the same promise, so every entry point may (and should) call it before awaiting.
|
||||
*/
|
||||
export function init(): Promise<void> {
|
||||
export function startNgSession(): Promise<void> {
|
||||
if (initPromise) return initPromise;
|
||||
console.log('[NG session] init() called — registering callback');
|
||||
console.log('[NG session] startNgSession() — registering the init callback');
|
||||
initPromise = (initNgWeb(
|
||||
async (event: any) => {
|
||||
session = event.session;
|
||||
@@ -70,27 +102,25 @@ export function init(): Promise<void> {
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger the wallet login page. Must call init() first.
|
||||
*/
|
||||
export async function login() {
|
||||
console.log('[NG session] login() called — opening wallet login');
|
||||
await ng.login();
|
||||
}
|
||||
// NO `login()` HERE ANY MORE. Opening the wallet page is not something the app
|
||||
// drives: `ensureIdentity()` mounts its own barrier and owns the round-trip that
|
||||
// follows. Nothing called this, and the surface publishes no such call.
|
||||
|
||||
/**
|
||||
* 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 identifier only). Calling
|
||||
* this forces a new broker redirect on the next access — see
|
||||
* STOPGAP: must stay HIDDEN (Settings/debug 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;
|
||||
// `session.user` comes through the event's open index signature, so narrow it
|
||||
// rather than assert it: `session_stop` names a user id, and there is nothing
|
||||
// to stop if the session never carried one.
|
||||
const userId = session?.user;
|
||||
if (typeof userId !== 'string' || !userId) return;
|
||||
try {
|
||||
await (ng as unknown as { session_stop: (u: unknown) => Promise<void> }).session_stop(userId);
|
||||
await ng.session_stop(userId);
|
||||
console.log('[NG session] session_stop done');
|
||||
} catch (error) {
|
||||
console.error('[NG session] logout error:', error);
|
||||
@@ -98,11 +128,14 @@ export async function logoutNg(): Promise<void> {
|
||||
}
|
||||
|
||||
export interface NextGraphSession {
|
||||
// `NG` IS the type of the SDK object (re-exported by @ng-eventually/client) —
|
||||
// `NG` IS the type of the SDK object (re-exported by @ng-eventually/polyfill) —
|
||||
// not a value whose type we could take. `typeof NG` was a type-level error that
|
||||
// the broken typecheck gate hid.
|
||||
ng: NG;
|
||||
session_id: string;
|
||||
// RELAYED, never converted: the `docs` primitives declare `string | number`
|
||||
// because that is what a session carries, and handing them a stringified one
|
||||
// fails. Declaring it `string` here would invite exactly that coercion.
|
||||
session_id: string | number;
|
||||
protected_store_id: string;
|
||||
private_store_id: string;
|
||||
public_store_id: string;
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
/**
|
||||
* Shared wallet material for the staging stopgap.
|
||||
* Shared wallet material for the staging stopgap — the deployment's wallet.
|
||||
*
|
||||
* STOPGAP: 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.
|
||||
* What this deployment HAS to hand over: the wallet file it serves, the password
|
||||
* it publishes, and where a wallet is imported. The app does not use these to
|
||||
* render anything — it passes all three to the SDK through the ONE `configure()`
|
||||
* bootstrap (`shared/utils/ngSession`), which owns what a user sees while signing
|
||||
* in. This module is the single consumer-facing copy of that material.
|
||||
*
|
||||
* 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.
|
||||
@@ -16,7 +17,7 @@
|
||||
* `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.
|
||||
* → nothing is passed to `configure`.
|
||||
*/
|
||||
|
||||
// Build-injected global (not `process.env`, absent in the browser); any path
|
||||
@@ -34,5 +35,5 @@ 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). */
|
||||
/** Whether this deployment has a shared wallet to hand the SDK. */
|
||||
export const hasSharedWallet = (): boolean => SHARED_WALLET_PASSWORD.trim().length > 0;
|
||||
@@ -1,18 +1,16 @@
|
||||
/**
|
||||
* storeRegistry (Festipod glue) — the lib owns placement; the app maps
|
||||
* storeRegistry (Festipod glue) — the SDK owns placement; the app maps
|
||||
* entity → scope. This file keeps ONLY the Festipod domain mapping (entity kind
|
||||
* → scope) and injects the consumer wiring the lib needs (session + identifier
|
||||
* normalization) via `configureStoreRegistry(...)`. The app re-exports the lib
|
||||
* surface so existing callers stay unchanged.
|
||||
* → scope) and re-exports the SDK registry surface so existing callers stay
|
||||
* unchanged. The consumer wiring the SDK needs (session + id normalization) is
|
||||
* declared in the ONE `configure` call, in `./ngSession`.
|
||||
*/
|
||||
|
||||
import {
|
||||
storeRegistry as libStoreRegistry,
|
||||
type AccountRecord as LibAccountRecord,
|
||||
} from '@ng-eventually/client';
|
||||
import { configureStoreRegistry } from '@ng-eventually/client/polyfill';
|
||||
import { sessionPromise } from './ngSession';
|
||||
import { normalizeIdentifier } from '../context/AccountContext';
|
||||
// Side-effect import: `configure` runs at ngSession's module evaluation, and it
|
||||
// must have run before ANY registry call. Importing it here makes that ordering
|
||||
// a fact of the module graph rather than a convention.
|
||||
import './ngSession';
|
||||
import { storeRegistry as sdkStoreRegistry } from '@ng-eventually/polyfill';
|
||||
|
||||
export type Scope = 'public' | 'protected' | 'private';
|
||||
|
||||
@@ -34,54 +32,22 @@ export function entityScope(kind: EntityKind): Scope {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Consumer wiring injected into the lib's storeRegistry ---
|
||||
// The lib is Festipod-agnostic: it reaches the session and the identity-id
|
||||
// normalization through these injected deps. Idempotent module-load side effect
|
||||
// (the app imports storeRegistry before any registry call).
|
||||
configureStoreRegistry({
|
||||
getSession: async () => {
|
||||
const session = await sessionPromise;
|
||||
// Sanctioned injection point: the session is handed to the lib HERE and
|
||||
// nowhere else. The lib owns placement and resolves scope internally; the
|
||||
// rest of the app speaks only in logical scopes.
|
||||
return {
|
||||
sessionId: session.session_id,
|
||||
privateStoreId: session.private_store_id,
|
||||
protectedStoreId: session.protected_store_id,
|
||||
publicStoreId: session.public_store_id,
|
||||
};
|
||||
},
|
||||
// The app maps its identifier handle to the identity id the lib keys on.
|
||||
normalizeId: normalizeIdentifier,
|
||||
// Pointer micro-guard (real broker): the account records now live in a
|
||||
// subscribable doc-shim reached through a well-known write-once pointer triple in
|
||||
// the store-root. The account read is barrier-authoritative (no account retry).
|
||||
// The only residual store-root sync-lag is the pointer read itself — this bounded
|
||||
// guard re-reads JUST that one triple a few times on a cold reconnect. It can never
|
||||
// provision or fork an account. Bounded (never an open-ended broker poll).
|
||||
pointerGuard: { attempts: 8, baseMs: 150, maxStepMs: 2000 },
|
||||
});
|
||||
|
||||
// --- Re-export the lib's account record + registry surface (unchanged API) ---
|
||||
export type AccountRecord = LibAccountRecord;
|
||||
|
||||
// --- Re-export the SDK's registry surface -----------------------------------
|
||||
// Placement is named by SCOPE ALONE: a session belongs to one user, so no call
|
||||
// here takes an identity. "My documents" means the signed-in session's, and the
|
||||
// app has no way — and no need — to name whose they are.
|
||||
export const {
|
||||
ensureAccount,
|
||||
resolveAccount,
|
||||
resolveWriteGraph,
|
||||
listMyEntityDocs,
|
||||
resetRegistryCache,
|
||||
// SDK-shaped scope resolvers — the app asks by scope, the lib resolves
|
||||
// placement (no store-id ever crosses the boundary).
|
||||
// SDK-shaped scope resolvers — the app asks by scope, the SDK resolves
|
||||
// placement (no store id ever crosses the boundary).
|
||||
resolveScopeGraph,
|
||||
// Inboxes — an inbox BELONGS to someone: `walletInbox(id)` is an identity's own
|
||||
// inbox, `documentInbox(doc)` the inbox of a document its owner holds. There is
|
||||
// no inbox common to every wallet any more.
|
||||
walletInbox,
|
||||
documentInbox,
|
||||
// Per-entity document creation. The lib itself files the creator's ReadCap on
|
||||
// create (and publishes the repo link for a `public` one), so the app declares
|
||||
// NO cap policy here: reading is possession, and the creator holds what it
|
||||
// created. Re-exported straight through so callers stay unchanged.
|
||||
// A document only HAS an inbox if its owner opened one. The app opens one on
|
||||
// the documents meant to RECEIVE deposits (its events), and the address this
|
||||
// returns is what the owner reads and watches.
|
||||
openDocumentInbox,
|
||||
// Per-entity document creation. The SDK itself files the creator's key on
|
||||
// create, so the app declares NO access policy here: reading is possession,
|
||||
// and the creator holds what it created.
|
||||
createEntityDoc,
|
||||
} = libStoreRegistry;
|
||||
} = sdkStoreRegistry;
|
||||
|
||||
Reference in New Issue
Block a user