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:
Sylvain Duchesne
2026-08-16 12:33:14 +02:00
parent 47af46fd09
commit 53c0e095cf
108 changed files with 2741 additions and 3850 deletions
+10 -13
View File
@@ -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
View File
@@ -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 na pas réussi à vous connecter. Rien ne peut safficher tant que
la connexion na 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
View File
@@ -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-leveliframe (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>
);
}
+10 -9
View File
@@ -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
+30 -19
View File
@@ -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');
}
};
+13 -33
View File
@@ -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;
+5 -19
View File
@@ -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);
});
-149
View File
@@ -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);
}
+212 -165
View File
@@ -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
+36 -55
View File
@@ -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>
);
+13 -12
View File
@@ -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;
File diff suppressed because one or more lines are too long
+41 -52
View File
@@ -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)}" ;
+1 -1
View File
@@ -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/';
+36
View File
@@ -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')}`,
);
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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';
-22
View File
@@ -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
View File
@@ -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.');
});
+131 -574
View File
@@ -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
// ============================================================================
+4 -35
View File
@@ -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> {
+28 -30
View File
@@ -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);
}
}
}
+61
View File
@@ -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);
}
+15
View File
@@ -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();
}
+25 -26
View File
@@ -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)');
+65 -32
View File
@@ -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;
+23 -57
View File
@@ -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;