Ng eventually #1

Open
Sylvain wants to merge 110 commits from ng-eventually into main
8 changed files with 99 additions and 23 deletions
Showing only changes of commit e326bebd42 - Show all commits
+40
View File
@@ -0,0 +1,40 @@
# Festipod — variables d'environnement (exemple)
#
# Copier en `.env` (chargé automatiquement par Bun) et renseigner les valeurs.
# En dev (`bun run dev`) ET en prod (`bun run start`), l'app sert depuis src/ et
# lit ces variables au RUNTIME (via l'endpoint /festipod-config.json de src/index.ts).
# Sans elles, l'app tombe en mode dégradé : la barrière d'accès n'affiche que le
# champ identifiant, sans l'assistance de chargement du portefeuille partagé.
# ── Portefeuille partagé (stopgap staging) ─────────────────────────────────
# Mot de passe du portefeuille partagé.
# VIDE => hasSharedWallet() faux => la barrière n'affiche QUE le champ identifiant
# (pas les 3 étapes « télécharger + importer le portefeuille »). REQUIS en staging
# pour l'onboarding d'un appareil qui n'a pas encore de wallet.
FESTIPOD_SHARED_WALLET_PASSWORD=
# Chemin ABSOLU vers le fichier portefeuille partagé (.ngw). Servi en
# téléchargement à /shared-wallet.ngw depuis la barrière d'accès.
FESTIPOD_SHARED_WALLET_FILE=/chemin/absolu/vers/festipod-wallet.ngw
# ── Seed automatique (opt-in) ──────────────────────────────────────────────
# Non vide => l'app amorce des données de démo dans un wallet VIDE au 1er login.
# OFF par défaut : laisser vide en usage normal.
FESTIPOD_AUTO_SEED=
# ── Serveur ────────────────────────────────────────────────────────────────
# Port HTTP du serveur (défaut 3000).
PORT=3000
# NODE_ENV=production bascule `bun run start` (pas de HMR). En dev, laisser vide.
NODE_ENV=
# ── Outillage dev (facultatif) ─────────────────────────────────────────────
# Override du chemin local du polyfill @ng-eventually/client pour `pnpm run
# link:polyfill` (lien local réactif). Défaut = ../nextgraph/ng-eventually-js/packages/client.
NG_EVENTUALLY_LOCAL=
# ── Build only (build.ts / `bun run build`, PAS le runtime) ────────────────
# ACCESS_GATE_DISABLED=1 => build SANS barrière d'accès (l'app démarre directement).
# Réservé à un build de démo/no-gate ; ne pas utiliser pour un déploiement réel.
ACCESS_GATE_DISABLED=
+20 -6
View File
@@ -1,15 +1,22 @@
/**
* 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 username).
* 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 "pick a username" screen.
* 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
@@ -17,8 +24,8 @@
* dead-end. We therefore HAND the user the shared wallet FILE (download) + the
* shared password and guide a one-time import on nextgraph.eu ("Import a Wallet
* File"), BEFORE they click "Entrer". The wallet FILE is the correct static
* primitive — a TextCode is a transient 5-min transfer, unusable to embed. Shown
* only when a shared wallet is configured (FESTIPOD_SHARED_WALLET_PASSWORD).
* 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';
@@ -58,7 +65,7 @@ export function AccessGateScreen({ status, error, initialIdentifier, onEnter }:
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 username). Entered HERE, at wallet access, so a
// 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.
@@ -107,7 +114,14 @@ export function AccessGateScreen({ status, error, initialIdentifier, onEnter }:
<Title style={{ textAlign: 'center', fontSize: 30, marginBottom: 4 }}>Festipod</Title>
<Text style={{ textAlign: 'center', marginBottom: 24, color: '#888' }}>Espace de test</Text>
{hasSharedWallet() && status !== 'connected' ? (
{!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.
@@ -5,8 +5,8 @@ import type { FestipodWorld } from '../../../../shared/support/world';
// --- Setup ---
Given('le portefeuille est vide', async function (this: FestipodWorld) {
// Each @data scenario runs under a UNIQUE username (see hooks.ts
// freshScenarioUsername), so the shim hands it a FRESH, EMPTY virtual wallet:
// 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
@@ -8,14 +8,35 @@
*
* 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 { AccessGateScreen } from '../../screens/AccessGateScreen';
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;
@@ -34,6 +55,7 @@ function stateFor(world: object): GateState {
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(
+3 -3
View File
@@ -49,9 +49,9 @@ 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). Changed from the historical 'festipod.account.username' → any
// pre-existing stored "logins" under the old key are dropped (acceptable: this
// is a stopgap test env; the URL param carries identity anyway).
// 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. */
+3 -3
View File
@@ -343,17 +343,17 @@ 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 (`username`) via `listMyEntityDocs` — a user's own
* 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).
*/
export async function countUserParticipations(
username: string,
identifier: string,
eventId: string,
userId: string,
): Promise<number> {
const sid = (await sessionPromise).session_id;
const docs_ = await listMyEntityDocs(username, 'protected');
const docs_ = await listMyEntityDocs(identifier, 'protected');
let total = 0;
for (const g of docs_) {
total += await countParticipations(sid, g, eventId, userId).catch(() => 0);
+7 -7
View File
@@ -22,7 +22,7 @@ setDefaultTimeout(90000);
// 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 freshScenarioUsername(): string {
function freshScenarioIdentifier(): string {
scenarioSeq += 1;
return `test-${RUN_NONCE}-${scenarioSeq}`;
}
@@ -580,7 +580,7 @@ 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 freshScenarioUsername above). Set a
// 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
@@ -588,11 +588,11 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
// 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 freshUser = freshScenarioUsername();
(this as any).freshUser = freshUser;
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 */ }
}, freshUser);
}, freshIdentifier);
// Capture console for debugging AND collect into the World so smoke
// scenarios can assert no runtime error was emitted during the connected
@@ -622,8 +622,8 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
);
// NO per-scenario registry/wallet reset needed anymore (was T03.j
// resetDataState). Each @data scenario now runs under a UNIQUE username
// (freshScenarioUsername, set into localStorage above), so the shim hands it
// 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.
+1 -1
View File
@@ -82,7 +82,7 @@ export async function login() {
* REAL NextGraph logout — stops the session of the SHARED wallet.
*
* STOPGAP: must stay HIDDEN (Settings/debug only). The everyday "Déconnexion"
* is the FAUX one (AccountContext.logout, clears the username only). Calling
* is the FAUX one (AccountContext.logout, clears the identifier only). Calling
* this forces a new broker redirect on the next access — see
* decision_2026-06-15_shared-wallet-login-flow.
*/