Ng eventually #1

Open
Sylvain wants to merge 110 commits from ng-eventually into main
40 changed files with 2224 additions and 360 deletions
Showing only changes of commit 266e33556d - Show all commits
+2
View File
@@ -41,3 +41,5 @@ playwright/.auth/
*storybook.log
storybook-static
dist-staging/
*.ngw
+2 -1
View File
@@ -2,7 +2,7 @@
type: _overview
summary: BDD Cucumber/Gherkin en français sur 3 couches (@ui, @data, @e2e) — setup, contrat de couches (quoi tester où), harness broker réel, et le piège des vestiges source-grep
triggers:
keywords: [cucumber, gherkin, bdd, feature, scenario, scénario, step, steps, "@ui", "@data", "@e2e", playwright, broker, harness, wallet, world, hooks, renderHelper]
keywords: [cucumber, gherkin, bdd, feature, scenario, scénario, step, steps, "@ui", "@data", "@e2e", playwright, broker, harness, wallet, world, hooks, renderHelper, multibrowser, multi-navigateur, "@multibrowser", "@private-wallet", "@shared-wallet", storageState, "@wip"]
paths: ["src/modules/*/features/**", "src/modules/*/steps/**", "src/shared/steps/**", "src/shared/support/**", "src/shared/test-harness/**", "cucumber.json"]
---
@@ -30,6 +30,7 @@ Tests BDD **Cucumber/Gherkin en français** (`Etant donné`, `Quand`, `Alors`) s
- [[knowledge_ui-layer]] — couche `@ui` : render helper, fixtures, bons/anti patterns
- [[knowledge_data-layer-broker]] — couche `@data` : harness broker, cycle de vie wallet, bridge
- [[knowledge_e2e-layer]] — couche `@e2e` : app réelle dans l'iframe
- [[knowledge_multibrowser-harness]] — plusieurs navigateurs isolés × modèle de wallet (private/shared), injection storageState
- [[decision_2026-03-12_headless-wallet-creation]] — pourquoi le wallet de test est créé en UI headless
- [[caveat_source-grep-vestiges]] — vestiges de l'ère « analyse de source » dans `world.ts`
- [[cookbook_add-scenario]] — ajouter un scénario/step (couches, piège de sérialisation `evaluate`, `@wip`)
@@ -0,0 +1,71 @@
---
type: knowledge
summary: Harness multi-navigateur sur DEUX axes orthogonaux — nombre de navigateurs (machinerie, contextes frais isolés via un freshBrowser non-persistant) ET modèle de wallet (own/@private-wallet vs shared/@shared-wallet) ; shared provisionné par injection storageState (test) ; e2e @humain qui valide le mécanisme produit RÉEL via la vraie app staging (fichier .ngw téléchargé depuis l'écran → import nextgraph.eu « Import a Wallet File » → Entrer → connecté) ; convention @wip exclue via cucumber.json
last_checked: 2026-06-16
---
# Harness multi-navigateur (private-wallet vs shared-wallet)
Capacité du harness `@data`/`@e2e` à piloter **plusieurs navigateurs isolés** dans un même scénario, sous **deux axes orthogonaux**. Sert à tester le stopgap wallet partagé (cf. concept `nextgraph-platform``brief_2026-06-15_shared-wallet-shim`) **et** le modèle cible (chacun son wallet).
## Les deux axes (orthogonaux)
| Axe | Ce qu'il décide | Exprimé par |
|---|---|---|
| **Nombre de navigateurs** (machinerie) | 1..N contextes nommés isolés | `openBrowser(name, …)` + steps `… dans le navigateur "X"` |
| **Modèle de wallet** | identité NG distincte vs partagée | **phrasing du step + tag** (voir ci-dessous) |
Ne **pas** confondre `@multibrowser` (plusieurs navigateurs) avec `@shared-wallet` (même wallet) : on fait du multibrowser **en private** (utile dès que NextGraph livrera la lecture cross-wallet — le modèle cible) **et en shared** (stopgap), et on compare les deux setups avec les **mêmes** steps de comportement.
## Modèle de wallet : phrasing + tags
- `Étant donné un navigateur "A" avec son propre wallet` → modèle **own**, tag `@private-wallet`.
- `Étant donné un navigateur "A" avec le wallet partagé` → modèle **shared**, tag `@shared-wallet`.
- Tag umbrella `@multibrowser` (feature entière).
## Architecture (où vit quoi)
- **`src/shared/support/browserPool.ts`** — état partagé + fabrique. Hors du contexte Chromium **persistant** porteur du wallet partagé (legacy mono-navigateur `@data`/`@e2e`, **inchangé**, cf. [[knowledge_data-layer-broker]]), le harness lance un navigateur **non-persistant** `freshBrowser` (`chromium.launch`) qui mint des contextes frais et isolés à la demande (`spawnContext(wallet)`). Module importé par `hooks.ts` (cycle de vie) et `world.ts` (usage par scénario) — pas de cycle d'import.
- **`world.ts`** — API : `openBrowser(name, wallet)`, `browser(name)`, `loadAppInBrowser(name, 'app'|'harness')`, `closeBrowsers()` ; registre `browsers: Map<name, NamedBrowser>`. Navigateurs nommés fermés en `After`, `freshBrowser` en `AfterAll`.
- **`hooks.ts`** — un scénario taggé `@multibrowser` **ne reçoit pas** la page unique legacy ; les steps ouvrent les navigateurs. Exige le mode broker réel (`freshBrowser` indispo en fallback mock).
## Provisioning du wallet
- **own** : `newContext()` vide → identité NG distincte / pas de wallet.
- **shared** : `newContext({ storageState })`, où `storageState` est **capturé une fois** au `BeforeAll` depuis le profil persistant (warm-up via `setupBrokerPage` puis `browserContext.storageState()`), exposé par `pool.sharedWalletState`. **Vérifié empiriquement (2026-06-16)** : les origines `nextgraph.eu` + `nextgraph.net` round-trippent dans les contextes frais, et deux navigateurs **shared** atteignent tous deux l'app **connectée** à NextGraph (`window.__testData.ready`) **sans login manuel**.
> Ce provisioning est **de test** — distinct du mécanisme **produit** (import assisté par FICHIER). Le scénario shared-wallet par storageState **court-circuite l'import** ; pour valider le mécanisme RÉEL, voir l'e2e `@humain` ci-dessous.
## Parcours humain — e2e du mécanisme produit (vert)
Scénario `@humain` : valide le flux RÉEL de distribution du wallet **de bout en bout, via la vraie app**, pas l'injection de test (cf. concept `nextgraph-platform``decision_2026-06-17_assisted-wallet-import`). Un navigateur vierge ouvre l'app staging → l'`AccessGateScreen` propose le **fichier** + le **mot de passe** → on télécharge le fichier **depuis l'écran**, on vérifie que le mot de passe affiché **égale** celui du wallet → import sur `nextgraph.eu` « Import a Wallet File » → retour → clic « Entrer » → app connectée (`ConnexionScreen`).
- **Wallet e2e** : un fichier `.ngw` (`festipod-e2e-tests`, mot de passe = identifiant) placé **à la racine du worktree** ; `findE2eWalletFile()` le localise (`*.ngw`). Gitignoré → chaque environnement doit l'ajouter (sinon erreur claire).
- `pool.ensureStagingApp()` (`hooks.ts`) — build **isolé** `bun run build.ts --outdir=dist-staging` (barrière d'accès **ON par défaut** ; mot de passe gravé + **fichier copié** en `/shared-wallet.ngw`, cf. `build.ts`), servi statiquement. Mémoïsé, lazy (seul `@humain` le paie).
- **Bypass de la barrière pour `@e2e`** : le harness fait `browserContext.addInitScript` sur le **contexte persistant** pour poser `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ = true` (s'applique à l'iframe app avant ses scripts) → `@e2e` voit l'app directement, pas la barrière. Les contextes frais (`@humain`) n'y touchent pas → barrière ON (cf. `decision_2026-06-17`). L'ancien `LoginScreen` `/login` a été retiré.
- `pool.importWalletViaFile(page, filePath, password)``nextgraph.eu/#/wallet/login``setInputFiles('input[type=file]')` (attendre que la SPA rende, sinon `EncryptionError`) → champ password → unlock.
- `pool.completeBrokerLogin(page, appUrl, walletPassword?)` — moitié « login broker » extraite de `setupBrokerPage`. **Attente robuste** : après le redirect (multi-hop), attend l'iframe app OU le lien « Click here to login with your wallet », puis déverrouille avec le mot de passe. La session broker n'étant **pas** persistée entre lancements, ce login wallet est requis à chaque run (warm-up + `@e2e` + `@humain`).
> **C'est l'e2e qui garantit que ça marche pour un humain réel** : Festipod fournit le BON fichier + mot de passe, et ce fichier importé donne un wallet fonctionnel sur un device vierge. Le scénario `@shared-wallet` (storageState) reste un raccourci de provisioning de test, il ne valide pas l'import.
## Isolation (garantie à 3 niveaux, prouvée par les scénarios)
1. `freshBrowser` est un **process séparé** du profil persistant porteur du wallet → un navigateur **own** démarre **sans wallet**.
2. Chaque `newContext()` est une **partition de stockage hermétique** (garantie Playwright).
3. Isolation prouvée non seulement sur l'origine **locale** (`127.0.0.1`) mais aussi sur l'**origine broker** `nextgraph.net` **où vit réellement le wallet** (sonde localStorage écrite dans A absente de B).
## Fichiers
- Feature : `src/modules/workshop/features/multibrowser-harness.feature`.
- Steps : `src/modules/workshop/steps/data/multibrowser.steps.ts`.
- Route `/blank` ajoutée au serveur harness (`hooks.ts`) : page minimale **sans stack NG**, pour les checks d'isolation localStorage.
## Convention `@wip` (désormais appliquée)
`cucumber.json` (profile `default`) porte `"tags": "not @wip"`. Le `cookbook_add-scenario` prescrivait `@wip` pour le non-implémenté mais ce n'était **exclu nulle part** ; maintenant `not @wip` s'**AND** avec les filtres CLI (ex. `--tags @data``(not @wip) and @data`, vérifié).
## Liens
- [[knowledge_data-layer-broker]] — la couche `@data` mono-navigateur (profil persistant) que cette capability étend.
- [[cookbook_add-scenario]] — convention `@wip`, pièges de steps.
- Concept `nextgraph-platform``brief_2026-06-15_shared-wallet-shim` — le stopgap wallet partagé que ce harness sert à tester.
@@ -2,8 +2,8 @@
type: _overview
summary: NextGraph comme système EXTERNE (stores, permissions, inbox, modèle d'intégration iframe, limites SDK) + les 4 briefs prospectifs qui dérivent la structure de données cible et le chemin multi-user de Festipod
triggers:
keywords: [nextgraph-rs, store, group store, dialog store, protected_store, public_store, inbox, capability, nuri, fork, ngd, broker, verifier, multi-store, multi-user, sharedWalletShim, storeRegistry, permission, social_query, OpenRepo]
paths: ["src/shared/utils/ngGraph.ts", "src/shared/hooks/useShapeWithDefaults.ts", "scripts/build-ng-packages.sh"]
keywords: [nextgraph-rs, store, group store, dialog store, protected_store, public_store, inbox, capability, nuri, fork, ngd, broker, verifier, multi-store, multi-user, sharedWalletShim, storeRegistry, permission, social_query, OpenRepo, wallet, auto-import, textcode, wallet import, AccessGateScreen]
paths: ["src/shared/utils/ngGraph.ts", "src/shared/hooks/useShapeWithDefaults.ts", "scripts/build-ng-packages.sh", "src/modules/auth/sharedWallet.ts", "src/modules/auth/screens/AccessGateScreen.tsx"]
---
# NextGraph platform
@@ -23,9 +23,14 @@ Le repo `nextgraph-rs` est cloné en `/home/sylvain/projects/nextgraph/nextgraph
- [[knowledge_stores-permissions]] — 5 types de stores, document/repo, capabilities/Nuri, inbox, exposition SDK JS
- [[knowledge_integration-model]] — paquets JS, modèle iframe, où tourne le verifier, broker `ngd`, déploiement, reciblage build-time
- [[knowledge_broker-import-constraint]] — le broker hébergé n'autorise pas l'auto-import d'un wallet par une web-app tierce (vérifié 2026-06-17)
## Briefs (chantiers prospectifs)
- [[brief_2026-05-17_multi-store-refactor]] — passer du mono-store actuel à une structure par entité
- [[brief_2026-06-15_shared-wallet-shim]] — stopgap staging : wallet partagé unique + `storeRegistry`
- [[brief_2026-05-21_fork-nextgraph-inbox]] — forker `nextgraph-rs` pour exposer l'inbox au SDK JS
## Décisions
- [[decision_2026-06-17_assisted-wallet-import]] — distribution du wallet partagé par import assisté (l'auto-import zéro-touche étant impossible)
@@ -1,139 +1,183 @@
---
type: brief
summary: Stopgap staging multi-user — un wallet partagé unique + couche storeRegistry (Piste A), comptes/login Festipod simulés (username seul), 1 document par (utilisateur × périmètre) via doc_create, filtre d'isolation applicatif ; structure préfigurant l'infra cible, sharedWalletShim jetable à la migration
last_updated: 2026-06-15
summary: Stopgap staging multi-user — deux visions cadrées. Lointaine (cible) — multi-wallet, 3 stores natifs par utilisateur + Dialog, 1 document par entité (événement/PdR), partage par capabilities, inbox native du PdR. Adaptée (stopgap) — UN wallet partagé, 1 document par entité dans son private_store, périmètre = métadonnée logique + index, filtre d'isolation applicatif, login simulé. Obstacles NextGraph — pas de lecture cross-wallet (OpenRepo TODO, ReadCap ignoré), capabilities/inbox non exposées au SDK, login non programmable. sharedWalletShim + filtre = jetables à la migration.
last_updated: 2026-06-16
---
# Stopgap multi-user : wallet partagé unique (`sharedWalletShim`)
**Status:** En cours — couche compte/login + isolation livrées et vérifiées ; couche multi-document livrée derrière flag, à valider sur broker
**Last updated:** 2026-06-15
**Status:** En cours — compte/login + isolation livrés et vérifiés ; **granularité 1 document par entité implémentée** ; primitives + **fan-out multi-documents validés sur broker** (`@data`, 3 scénarios). Reste : reactivity in-app de la création (best-effort) + seeding multi-doc.
## Context
## Objectif & posture
NextGraph ne permet **aucun partage de données entre wallets** aujourd'hui. Vérifié dans `nextgraph-rs` (2026-06-15) :
Mettre Festipod en **staging** avec des **utilisateurs amicaux**, **sans enjeu de sécurité**, branché sur le vrai NextGraph, et **structuré au plus près de l'infra cible** pour qu'une migration soit un simple changement de résolveur, pas une réécriture.
- une session de verifier ne contient que ses **3 stores** dans `self.repos` ;
- un NURI étranger lève `RepoNotFound` (`engine/verifier/src/request_processor.rs`, `resolve_target`) ;
- `OpenRepo` est un **TODO non implémenté** côté broker (`engine/verifier/src/verifier.rs:1423`) ;
- le champ `access`/`ReadCap` du NURI **n'est jamais inspecté** → les capabilities sont ignorées.
Trois choses doivent rester nettes pour ne pas dériver, et structurent ce brief :
Donc lire le store d'un autre utilisateur — **même son `public_store`** — est impossible via le SDK. Cela élimine toute la famille « chacun garde son wallet, les autres lisent son public » (piste C ci-dessous).
1. la **vision lointaine** — ce qu'on aura quand NextGraph offrira lecture cross-wallet, capabilities et inbox ;
2. les **obstacles NextGraph** — ce qui, aujourd'hui, empêche cette vision ;
3. la **vision adaptée** (stopgap) — au plus proche de la lointaine, compte tenu des obstacles.
**Objectif :** mettre Festipod en **staging** avec des **utilisateurs amicaux**, **sans enjeu de sécurité**, tout en branchant l'app sur le vrai NextGraph et en **préfigurant l'infra cible** (structure dérivée dans [[brief_2026-05-18_authorization-matrix]]).
> **Invariant directeur** : tout ce que fait la vision adaptée doit avoir une **correspondance 1:1** explicite avec la vision lointaine (table en fin de section adaptée). Si un choix du stopgap n'a pas d'image claire dans la cible, c'est un signal de dérive.
**Décision retenue :** Piste A (wallet partagé unique) + couche `storeRegistry`, broker **`nextgraph.net`**.
---
## What We Know
## 1. Vision lointaine (cible finale)
### Les trois familles de contournement (et pourquoi A)
Dérivée de [[brief_2026-05-18_authorization-matrix]] et [[knowledge_stores-permissions]]. Périmètre **validé** (hors communautés / listes curées / suivi, encore hors périmètre).
### Identité & login
- **1 utilisateur = 1 wallet NextGraph.** Le wallet **est** l'identité ; pas de compte applicatif séparé.
- **Login = ouvrir son propre wallet** (redirect broker). C'est un vrai login par-utilisateur.
### Stores & granularité documents
Modèle natif : `1 document = 1 repo = 1 frontière de permission = 1 inbox`. Un **store** est un document-conteneur qui regroupe et permissionne d'autres documents. Chaque utilisateur a **3 stores natifs** ; les entités sont des **documents individuels** dedans (pas un gros graphe par store).
| Entité | Store (propriétaire) | Granularité | Notes |
|---|---|---|---|
| Événement | `public_store` du déclarant | **1 document / événement** | adressable par NURI (utile pour la déduplication) |
| Point de rencontre (PdR) | `public_store` de l'hôte | **1 document / PdR** | **possède son inbox native** (reçoit les inscriptions) |
| Profil réseau | `protected_store` | 1 document | nom, avatar, bio, ville, intérêts |
| Participation / Inscription | `protected_store` de l'inscrit | 1 document / inscription | + dépôt d'un lien dans l'**inbox du PdR** |
| Profil privé (settings, email) | `private_store` | 1 document | soi seul |
| Connexion A↔B | **Dialog store** A↔B | doc connexion (+ messagerie) | deux écrivains |
| Index des connexions | `protected_store` | 1 document | liste les NURIs des Dialog stores |
**Aucun Group store** sur le périmètre validé (les 3 stores + Dialog + inboxes suffisent).
### Partage & visibilité
- Par **capabilities** : on transmet un Nuri portant un read/write cap. **Public** = lisible par tous sans cap. **Protected** = cap obtenue via la connexion. **Privé** = soi.
- Ajout de permission asynchrone ; retrait synchrone (SyncSignature).
### Inbox & notifications
- L'**inbox native du document PdR** reçoit les dépôts d'inscription (lien DID cap). `from` optionnel ⇒ **identifié si connexion de l'hôte, anonyme sinon**, gratuitement.
### Découverte
- **Pas d'annuaire central.** On découvre via les `public_store` et le graphe de connexions (et plus tard `social_query`).
---
## 2. Obstacles côté NextGraph (ce qui empêche la vision lointaine aujourd'hui)
Vérifiés dans `nextgraph-rs` (2026-06-15).
| Élément de la cible | Obstacle actuel | Preuve |
|---|---|---|
| Lire le store d'un **autre** utilisateur | `OpenRepo` **non implémenté** ; un NURI étranger lève `RepoNotFound` ; une session ne contient que ses 3 stores dans `self.repos` | `engine/verifier/src/verifier.rs:1423`, `request_processor.rs` `resolve_target` |
| Partager une **capability** (Nuri + droits) | non exposé au SDK ; le champ `access`/`ReadCap` du NURI **n'est jamais inspecté** | [[knowledge_stores-permissions]] |
| **Inbox** d'un document (notif d'inscription) | pas exposée au SDK JS (nécessite un fork moteur) | [[brief_2026-05-21_fork-nextgraph-inbox]] |
| **Login per-utilisateur** fluide | login **non programmable** (redirect web vers le broker) | [[decision_2026-06-15_shared-wallet-login-flow]] |
**Conséquence centrale** : tant que la lecture cross-wallet n'existe pas, **aucune donnée ne franchit la frontière entre deux wallets**. Bob ne peut pas lire le `public_store` d'Alice. Toute approche « chacun son wallet » est donc bloquée à la racine.
---
## 3. Vision adaptée (stopgap) — au plus proche de la cible
### Principe : UN wallet partagé
Tous les utilisateurs amicaux ouvrent **le même** wallet. NextGraph ne voit qu'une identité → **tout est techniquement lisible** (on contourne l'absence de lecture cross-wallet en supprimant la frontière). Le « multi-utilisateur » devient une **fiction applicative**.
### Granularité documents — **identique à la cible** : 1 document par entité
Pour rester fidèle, on reproduit les **deux niveaux** de la cible (conteneur → documents) :
- chaque **événement** et chaque **PdR** = **son propre document** (`doc_create`), tous physiquement dans le `private_store` de l'unique wallet partagé ;
- le **périmètre** (public/protected/private) est une **métadonnée logique** portée par le document, **pas** un store physique ;
- un **document-index par (utilisateur × périmètre)** liste les NURIs des entités de ce périmètre — il **joue le rôle du futur store-conteneur** (`docPublic` ≈ futur `public_store`, etc.).
Garder la granularité « 1 doc par entité » est ce qui rend la migration 1:1 **et** ce qui rendra l'**inbox du PdR** possible plus tard sans refonte (l'inbox est un attribut de document).
| Entité | Périmètre logique | Document stopgap | Indexé dans |
|---|---|---|---|
| Événement | public | 1 doc / événement | `docPublic` du déclarant |
| PdR | public | 1 doc / PdR | `docPublic` de l'hôte |
| Profil réseau | protected | doc profil réseau | `docProtected` |
| Participation | protected | 1 doc / participation (ou groupé) | `docProtected` de l'inscrit |
| Profil privé | private | doc settings | `docPrivate` |
| Connexion A↔B | dialog | doc connexion | index de connexions |
### `sharedWalletShim` (échafaudage, sans équivalent cible)
Index des **comptes** simulés → leurs documents-index : `username → profileId → { docPublic, docProtected, docPrivate }`. Ancré dans le `private_store` du wallet partagé (`session.private_store_id`, toujours connu → ancre de bootstrap). Rend possibles le **login cross-device** et le **picker d'utilisateurs**. **N'a aucun équivalent cible** (la cible n'a pas d'annuaire central) → **jetable**.
### Partage simulé : filtre d'isolation applicatif
Un seul wallet ⇒ tout lisible. Pour **se comporter** comme la cible, la couche données filtre les lectures par `currentAccountId` + connexions : `private` → propriétaire ; `protected` → propriétaire + connexions ; `public` → tous. Remplace les **capabilities** (pas appliqué par la crypto, mais honoré par l'app) → **jetable**.
### Identité & login simulés
- **Couche réelle (technique, invisible)** : le redirect broker du wallet partagé, présenté comme **barrière d'accès à l'environnement** (pas un login). Cf. [[decision_2026-06-15_shared-wallet-login-flow]].
- **Couche applicative (le login perçu)** : écran « Connexion » = **username seul** (déclaratif, sans mot de passe) → `localStorage`. « Déconnexion » = efface le username, sans toucher NG. Vrai logout planqué.
### Inbox / notification d'inscription
**Hors périmètre du stopgap** (nécessite le fork). Mais la granularité « 1 doc/PdR » est le **pré-requis** qui la rendra branchable plus tard sans refonte.
### Correspondance stopgap → cible (l'invariant 1:1)
| Stopgap | Vision lointaine | Migration |
|---|---|---|
| doc événement (dans le wallet partagé) | doc événement dans le `public_store` du déclarant | déplacer le doc + appliquer cap publique |
| doc PdR | doc PdR dans le `public_store` de l'hôte **+ inbox** | déplacer + brancher l'inbox |
| `docPublic`/`docProtected`/`docPrivate` (index) | `public_store`/`protected_store`/`private_store` | l'index devient le store natif |
| filtre d'isolation applicatif | capabilities (read caps via connexion) | retirer le filtre, poser les caps |
| login applicatif (username) | login = ouvrir son wallet | retirer la couche compte |
| `sharedWalletShim` | — (rien) | supprimer |
| wallet partagé unique | un wallet par utilisateur | éclater par propriétaire |
**Migration = swap du résolveur `storeRegistry`** (de « doc dans le wallet partagé » vers « doc dans le vrai store du propriétaire ») + déplacement des documents + pose des capabilities + suppression de l'échafaudage. **Les écrans ne changent pas.**
---
## Familles de contournement (pourquoi A)
| Famille | Idée | Verdict |
|---|---|---|
| **A — wallet partagé** | un seul wallet pour tous, multi-user simulé côté app | **retenue** : livrable vite, zéro travail moteur, local-first préservé |
| B — NG comme backend | un backend Bun détient un wallet, clients en HTTP | écartée : abandonne le local-first, plus lourd |
| C — lecture cross-wallet | chacun son wallet, on lit le public des autres | **infaisable** (cf. Context) sans fork moteur |
| D — fork moteur | patcher `OpenRepo` + capabilities | hors stopgap : chemin cible réel, lourd (cf. [[brief_2026-05-21_fork-nextgraph-inbox]]) |
| **A — wallet partagé** | un seul wallet, multi-user simulé côté app | **retenue** : livrable vite, zéro travail moteur, local-first préservé |
| B — NG comme backend | un backend Bun détient un wallet, clients HTTP | écartée : abandonne le local-first, plus lourd |
| C — lecture cross-wallet | chacun son wallet, lecture du public des autres | **infaisable** (obstacle §2) sans fork moteur |
| D — fork moteur (`OpenRepo` + capabilities) | rendre la cible réelle | hors stopgap : c'est le chemin cible, lourd (cf. [[brief_2026-05-21_fork-nextgraph-inbox]]) |
### Architecture en trois couches
---
```
┌─ Couche COMPTE (simulée — UX cible, jetable à la migration) ────────┐
│ signup / login Festipod · currentAccountId en localStorage │
├─ Couche STORES VIRTUELS (fidèle — survit à la migration) ───────────┤
│ storeRegistry : (appUser, scope) → NURI de document │
│ 1 document par (utilisateur × périmètre), créé via doc_create │
├─ Couche NEXTGRAPH (réelle mais invisible) ──────────────────────────┤
│ UN wallet partagé, mêmes credentials pour tous │
└─────────────────────────────────────────────────────────────────────┘
```
## État d'implémentation (2026-06-16)
1. **NextGraph (réelle mais invisible)** — un wallet partagé, mêmes credentials. Le login NextGraph **n'est pas programmable** (redirect web vers `nextgraph.net/redir`, cf. `NextGraphContext`, concept `data-layer`) ; il est donc présenté comme une **barrière technique d'accès** avant l'app, pas comme un login (flux arrêté dans [[decision_2026-06-15_shared-wallet-login-flow]]). Session **persistante** côté iframe broker → ouverture **une fois par device** dans une même session navigateur.
2. **Stores virtuels (fidèle, survit à la migration)****1 document par (utilisateur × périmètre)** via `doc_create`. Vérifié : `doc_create` retourne un NURI `did:ng:o:…`, le repo est **inséré immédiatement** dans `self.repos` (`verifier.rs:2900`), et `orm_start_graph`/`sparql_update` l'acceptent **sans pin explicite** (`sdk/rust/src/tests/sparql_regressions.rs:136-200`). À la migration : **swap du résolveur** `storeRegistry` vers les vrais stores, sans réécrire les écrans.
3. **Compte/login simulés (UX, jetable)**signup/login Festipod, **username seul** (pas de mot de passe), `currentAccountId` en `localStorage`.
### `sharedWalletShim`
Nom **volontairement explicite** du mapping temporaire (hack) : comptes simulés → NURIs des stores virtuels. Ancré dans le **`private_store` du wallet partagé** (`session.private_store_id`, toujours présent → ancre de bootstrap). **Seul artefact sans équivalent cible** (l'infra cible n'a **pas** d'index central : la découverte y passe par les connexions et les `public_store`). Rend possibles le **login cross-device** et le **picker d'utilisateurs**. **À supprimer à la migration.**
Contenu par compte : `username → profileId → { docPublic, docProtected, docPrivate }`. Chaîne de bootstrap d'un device : session → `private_store_id` → lire le `sharedWalletShim` → comptes + NURIs par périmètre.
### Placement des entités
Identique à la dérivation de [[brief_2026-05-18_authorization-matrix]], au mapping `document ↔ store` près :
| Entité | Périmètre | Doc aujourd'hui | Store cible |
|---|---|---|---|
| Événement déclaré par U | public | `U/public` | `public_store` de U |
| PdR hébergé par U | public | `U/public` | `public_store` de U |
| Profil réseau de U | protected | `U/protected` | `protected_store` de U |
| Participation de U | protected | `U/protected` | `protected_store` de U |
| Index des connexions de U | protected | `U/protected` | `protected_store` de U |
| Profil privé de U (settings, email) | private | `U/private` | `private_store` de U |
| Connexion A↔B | dialog | `dialog/A∙B` | Dialog store A↔B |
### Filtre d'isolation (retenu)
Un seul wallet ⇒ tout lisible par tous. Pour que le staging se **comporte** comme la cible, la couche données filtre les lectures par `currentAccountId` + connexions : `private` → propriétaire seul ; `protected` → propriétaire + connexions ; `public` → tous. Isolation **pas appliquée par la crypto** mais **honorée** par l'app (démo réaliste, bugs de conception attrapés tôt). **Supprimé à la migration** (la crypto prend le relais).
### Ce qui survit vs ce qui est jetable
- **Survit** : mapping entité→périmètre, abstraction `storeRegistry`, séparation par documents, docs dialog, forme UX signup/login.
- **Jetable** : le wallet partagé unique, le `sharedWalletShim`, le filtre d'isolation. (Pas de mots de passe applicatifs — **username seul**.)
### Code impacté
- `src/shared/utils/ngGraph.ts``ensureGraphNuri()` remplacé par `storeRegistry`.
- `src/shared/hooks/useShapeWithDefaults.ts``storeNuri` résolu par (entité, compte).
- `src/shared/context/FestipodDataContext.tsx` — câbler `joinEvent`/`leaveEvent` (no-op aujourd'hui) ; appliquer le filtre d'isolation.
- `CURRENT_USER_ID` constant → `currentAccountId` sélectionnable (persisté `localStorage`).
- `src/shared/utils/ngBootstrap.ts` — seed réparti **par documents**.
## État d'implémentation (2026-06-15)
Deux drapeaux de build, tous deux **OFF par défaut** (le mono-store validé reste le défaut ; dev/`@ui`/`@e2e` inchangés) :
- **`FESTIPOD_STAGING=1`** — active le flux login option 2 (gate technique → écran « Connexion »). Découplé de `NODE_ENV` exprès, pour qu'un `@e2e` en build production ne soit pas bloqué par le gate.
- **`FESTIPOD_MULTISTORE=1`** — active la couche multi-document (storeRegistry).
Deux drapeaux de build, **OFF par défaut** (le mono-store validé reste le défaut ; dev/`@ui`/`@e2e` inchangés) :
- **`FESTIPOD_STAGING=1`** — flux login option 2 (découplé de `NODE_ENV` pour ne pas bloquer `@e2e`).
- **`FESTIPOD_MULTISTORE=1`** — couche multi-document (storeRegistry).
| Pièce | Fichier | État |
|---|---|---|
| Couche compte (faux login, localStorage) | `src/shared/context/AccountContext.tsx` | ✅ livré, vérifié (build + `@ui`) |
| Gate technique + écran « Connexion » + orchestrateur | `src/modules/auth/screens/{AccessGateScreen,ConnexionScreen}.tsx`, `src/app/AuthGate.tsx` | ✅ livré |
| Vrai logout planqué | `ngSession.ts:logoutNg`, `SettingsScreen.tsx` | ✅ livré |
| Filtre d'isolation (mode connecté) | `src/shared/utils/isolation.ts` + `FestipodDataContext` (useNgData) | ✅ livré, pur, vérifié |
| storeRegistry + sharedWalletShim (doc_create, SPARQL shim, entité→périmètre) | `src/shared/utils/storeRegistry.ts` | ⚠️ livré, **compile**, runtime NG **à valider sur broker** |
| Câblage multi-document (reads `{graphs}` + writes par périmètre) | `FestipodDataContext` (useNgData) derrière `MULTISTORE` | ⚠️ livré, à valider sur broker |
| Filtre d'isolation (mode connecté) | `src/shared/utils/isolation.ts` + `FestipodDataContext` | ✅ livré, pur, vérifié |
| storeRegistry + sharedWalletShim | `src/shared/utils/storeRegistry.ts` | ✅ **1 doc/entité** (`createEntityDoc`/`listEntityDocs` + index par périmètre) ; primitives **validées broker** |
| Câblage multi-document (reads fan-out `{graphs}` + write per-entité `createEntityDoc`) | `FestipodDataContext` (useNgData) derrière `MULTISTORE` | ✅ lecture fan-out validée ; ⚠️ reactivity de la création in-app = **best-effort** (le nouveau doc est ajouté au fan-out, l'`@id` peut être en attente jusqu'au re-subscribe) |
| Validation broker (`@data`) | `src/modules/workshop/{features/multistore-stopgap.feature, steps/data/multistore.steps.ts}` | ✅ **3 scénarios verts** (ORM-sur-doc-créé, shim r/w, **fan-out par entité**) |
**Pourquoi le flag** : le runtime NextGraph (doc_create, shim SPARQL, abonnement multi-graphes) ne peut pas être validé sans broker live. Conformément au « mode mono-store parallèle » endossé par [[brief_2026-05-17_multi-store-refactor]], il ship OFF — rien de fonctionnel n'est cassé.
**Granularité = 1 document par entité (fait)** : public (événements/PdR) → un `doc_create` **par entité**, NURI ajouté à l'**index** du périmètre (le futur store-conteneur) ; protected (profil, participations) → **groupé** dans l'index protected. Lecture publique = `listEntityDocs('public')``useShape({graphs:[…]})`. Mono-store (défaut) inchangé.
**Étapes de validation broker** (pour passer `MULTISTORE` ON) :
1. Sur `nextgraph.net`, vérifier que `doc_create(session, "Graph", "data:graph", "store", undefined)` retourne un NURI utilisable comme `@graph` ORM (le test rust `sparql_regressions.rs:136-200` le suggère, à confirmer côté SDK JS).
2. Vérifier l'écriture/lecture du shim via `sparql_update`/`sparql_query` sur le NURI du private store (et le format de retour de `sparql_query` `readBindings()` est tolérant mais à confirmer).
3. Vérifier l'abonnement `useShape(shape, { graphs: [...] })` sur plusieurs documents et sa réactivité quand la liste grandit.
4. Décider du seeding multi-document (le dev auto-seed est neutralisé en `MULTISTORE`).
**Validation broker (2026-06-16, `@data` contre `nextgraph.net`, 3 scénarios verts)** :
1. `doc_create("Graph","data:graph","store",undefined)` NURI utilisable comme `@graph` ORM (write+read d'une `Participation` via `useShape({graphs:[nuri]})`). On n'est **pas** limité au `private_store` comme scope (cf. [[rule_private-store-scope]]).
2. ✅ Shim r/w : 3 docs créés + `sparql_update`, rechargés via `sparql_query` (`readBindings` tolérant, OK en pratique).
3. **Fan-out par entité** : 2 comptes × 1 doc-événement (`createEntityDoc` + indexé), un `useShape({graphs:[docA,docB]})` lit **les deux** événements, l'index public liste les deux docs.
4. **Reste** : reactivity de la création in-app (best-effort, à itérer sur broker) + seeding multi-document (auto-seed neutralisé en `MULTISTORE`).
## Open Questions
- ~~**Login NextGraph invisible**~~ → **tranché** : login non programmable, présenté comme barrière technique d'accès ; session persistante. Voir [[decision_2026-06-15_shared-wallet-login-flow]].
- **Création des documents au signup** : `doc_create` ×3 synchrone, ou paresseux au premier write par périmètre ?
- **Picker d'utilisateurs** : UX pour l'écran « Connexion » (saisie libre vs liste des comptes du `sharedWalletShim`) ?
## Possible Approaches
Posture retenue : **A + `storeRegistry` maintenant**, structuré pour la migration. Introduire dès à présent l'indirection `storeRegistry` (esquissée dans [[brief_2026-05-17_multi-store-refactor]]) — chaque entité *déclare* le store où elle *devrait* vivre, le résolveur renvoyant aujourd'hui vers le document du périmètre dans le wallet partagé. Le jour du vrai multi-user (fork moteur D ou solution upstream), on **bascule le résolveur** sans réécrire les écrans.
- **Modèle d'écriture de l'événement** (propriétaire / wiki / immuable) — *ouvert dans la matrice*. Propriétaire/immuable → événement = doc dans le `public_store` du déclarant (granularité par entité exacte). **Wiki** → exigerait un Group store (hors périmètre) et **changerait la cible**.
- **Création des documents** : `doc_create` à la création de l'entité (retenu par la granularité par entité) ; création paresseuse des index de périmètre.
- **Picker d'utilisateurs** : saisie libre vs liste des comptes du `sharedWalletShim`.
## Out of Scope
- Le **vrai multi-user** (lecture cross-wallet) : suspendu à un **fork moteur** (`OpenRepo` + capabilities) voir [[brief_2026-05-21_fork-nextgraph-inbox]].
- L'**auto-hébergement** du broker/ng-app (le staging tourne sur `nextgraph.net`).
- Toute **sécurité réelle** (credential partagé, mots de passe, chiffrement par utilisateur).
- Le **vrai multi-user** (lecture cross-wallet) fork moteur (`OpenRepo` + capabilities), voir [[brief_2026-05-21_fork-nextgraph-inbox]].
- L'**inbox du PdR** (notif d'inscription) — même fork.
- L'**auto-hébergement** du broker/ng-app (staging sur `nextgraph.net`).
- Toute **sécurité réelle** (credential partagé, pas de mot de passe, pas de chiffrement par utilisateur).
## Starting Points
- [[brief_2026-05-18_authorization-matrix]] — les périmètres repris exactement
- [[brief_2026-05-17_multi-store-refactor]] — l'indirection `storeRegistry` y est esquissée
- [[brief_2026-05-21_fork-nextgraph-inbox]] — le chemin cible réel (hors stopgap)
- Concept `data-layer` — état actuel mono-store ; [[knowledge_stores-permissions]] — limites SDK, inbox
- `src/shared/utils/ngGraph.ts`, `src/shared/hooks/useShapeWithDefaults.ts`, `src/shared/context/FestipodDataContext.tsx`, `src/shared/utils/ngBootstrap.ts`
- Source `nextgraph-rs` : `sdk/rust/src/tests/sparql_regressions.rs:136-200` (preuve multi-document), `engine/verifier/src/verifier.rs` (TODO `OpenRepo`)
- [[brief_2026-05-18_authorization-matrix]] — périmètres et partition de la vision lointaine
- [[brief_2026-05-17_multi-store-refactor]] — l'indirection `storeRegistry`
- [[brief_2026-05-21_fork-nextgraph-inbox]] — le chemin cible réel (cross-wallet + inbox)
- [[decision_2026-06-15_shared-wallet-login-flow]] — flux login/logout
- [[knowledge_stores-permissions]] — stores, capabilities, inbox, limites SDK
- `src/shared/utils/storeRegistry.ts`, `src/shared/context/FestipodDataContext.tsx`, `src/app/AuthGate.tsx`, `src/shared/utils/isolation.ts`
- Source `nextgraph-rs` : `sdk/rust/src/tests/sparql_regressions.rs:136-200` (multi-document), `engine/verifier/src/verifier.rs:1423` (TODO `OpenRepo`)
@@ -0,0 +1,53 @@
---
type: decision
summary: Distribution du wallet partagé par IMPORT ASSISTÉ PAR FICHIER (.ngw) — l'auto-import zéro-touche étant impossible (broker hébergé), Festipod sert le FICHIER du wallet (téléchargement) + le mot de passe et guide un import unique sur nextgraph.eu « Import a Wallet File » depuis l'AccessGateScreen. Le TextCode a d'abord été retenu puis CORRIGÉ (transfert temporaire 5 min, inutilisable à embarquer). Barrière d'accès ON par défaut (ACCESS_GATE_DISABLED=1 pour bypass tests/dev), ancien LoginScreen retiré. Alternatives écartées (auto-import app, lien magique, self-host) ; provisioning de test (storageState) distinct
last_updated: 2026-06-29
---
# Décision 2026-06-17 — Distribution du wallet partagé par import assisté
Comment un utilisateur récupère le wallet partagé sur un nouveau navigateur, dans le stopgap [[brief_2026-06-15_shared-wallet-shim]]. Frozen.
## Contrainte de départ
L'auto-import zéro-touche par l'app est **impossible** avec le broker hébergé (fait vérifié : [[knowledge_broker-import-constraint]]). Le wallet doit préexister dans le navigateur, importé sur `nextgraph.eu` (cross-origin, non pilotable par Festipod). La question n'est donc pas « comment auto-importer » mais « comment **minimiser la friction de récupération** » — le problème initial étant que l'utilisateur devait d'abord *se procurer* le wallet.
## Décision : import assisté par FICHIER
Festipod **sert le FICHIER `.ngw`** du wallet partagé (téléchargement) et **affiche le mot de passe** dans l'`AccessGateScreen` (la barrière d'accès, cf. [[decision_2026-06-15_shared-wallet-login-flow]]), avec un guide en 3 étapes :
1. Télécharger le fichier du wallet partagé (bouton de téléchargement).
2. Ouvrir `https://nextgraph.eu/#/wallet/login` (nouvel onglet) → « Import a Wallet File » → choisir le fichier → saisir le mot de passe affiché.
3. Revenir et cliquer « Entrer » (redirect broker → demande de déverrouiller le wallet → mot de passe → app).
Festipod **fournit** ainsi le wallet (fin de la friction de récupération) ; l'**import lui-même reste un geste manuel unique par device**, incompressible avec le broker hébergé. Posture **zéro-sécurité, credential partagé** assumée (cf. brief) → embarquer le fichier + le mot de passe est cohérent.
> **Correction 2026-06-25 (le TextCode était une fausse piste)** : la 1ʳᵉ version embarquait le **TextCode**. Or le TextCode est un **transfert temporaire** (5 min, deux devices en ligne, usage unique — cf. [[knowledge_broker-import-constraint]]), donc **inutilisable embarqué** (un testeur arrivant plus tard aurait un code mort). Le test e2e passait quand même car il génère+importe le code dans la foulée. La primitive correcte est le **FICHIER statique**.
## Pourquoi (alternatives écartées)
- **(a) Auto-import embarqué par l'app** — *impossible* : le broker ne laisse aucune fenêtre d'exécution avant son gate wallet ([[knowledge_broker-import-constraint]]).
- **(b) TextCode embarqué** — *cassé* : transfert temporaire 5 min, non réutilisable (cf. correction ci-dessus).
- **(c) Lien magique pré-rempli** vers le broker — pas de route d'import par URL côté broker hébergé.
- **(d) Self-host / fork du ng-app** — seule voie vers le **vrai zéro-touche**, mais lourde ; track séparé ([[brief_2026-05-21_fork-nextgraph-inbox]]). Non retenu pour le stopgap.
## Conséquences côté code (Festipod)
- `src/modules/auth/sharedWallet.ts``SHARED_WALLET_PASSWORD` lu depuis un **global gravé au build** `globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__` (`define` dans `build.ts`, depuis `FESTIPOD_SHARED_WALLET_PASSWORD`) ; `SHARED_WALLET_FILE_URL = /shared-wallet.ngw` ; `hasSharedWallet()` (mot de passe non vide) pilote l'affichage. Vide par défaut → la barrière retombe sur le flux simple.
- `build.ts` — copie le fichier (`FESTIPOD_SHARED_WALLET_FILE`) dans le bundle en `/shared-wallet.ngw` + grave le mot de passe.
- `src/modules/auth/screens/AccessGateScreen.tsx` — section assistée (téléchargement du fichier + mot de passe + guide) affichée si `hasSharedWallet()`.
- `src/app/AuthGate.tsx` — la barrière est **ON PAR DÉFAUT** (« Festipod ne fonctionne jamais sans NextGraph »). **Révision 2026-06-29** : drapeau **inversé** — la barrière n'est désactivée que si `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true` (gravé par `build.ts` depuis `ACCESS_GATE_DISABLED=1`, ou injecté par le harness via `context.addInitScript` pour `@e2e`). Absent → barrière ON. (Remplace l'ancien `FESTIPOD_STAGING`/`__FESTIPOD_REQUIRE_NG__`, qui était OFF par défaut.)
- **Ancien `LoginScreen` retiré** (`/login`, bouton « Se connecter avec NextGraph » + login démo email/mdp) : obsolète puisque l'`AccessGateScreen` précède le routeur. Évite le dead-end « connecter sans wallet ». Route `/login` supprimée.
- **Atterrissage post-login** : après le choix du pseudo, `ConnexionScreen` navigue vers `/home` (la redirection vers l'accueil que faisait l'ancien `LoginScreen` avait disparu avec lui → on retombait sur l'onboarding `WelcomeScreen` à `/`). Filet pour les retours : `WelcomeScreen` redirige vers `/home` si déjà connecté. L'e2e `@humain` va désormais jusqu'à l'accueil pour couvrir ça.
- L'admin exporte le fichier une fois (nextgraph.eu : menu wallet → Download/Export Wallet File) et l'injecte au build (`FESTIPOD_SHARED_WALLET_FILE=<chemin.ngw> FESTIPOD_SHARED_WALLET_PASSWORD=<mdp> bun run build` ; barrière ON par défaut, pas de drapeau à poser).
## À distinguer du provisioning de test
Le harness multi-navigateur provisionne le wallet partagé par **injection storageState** (niveau navigateur, sans la contrainte broker) — concept `bdd-testing``knowledge_multibrowser-harness`. Cela **prouve** « wallet partagé → app connectée » mais **court-circuite l'import**. Le mécanisme RÉEL est validé **de bout en bout par la vraie app** (e2e `@humain`) : un navigateur vierge ouvre l'app staging, **télécharge le fichier proposé par l'`AccessGateScreen`** (et vérifie que le mot de passe affiché est celui du wallet), l'importe via le vrai flux `nextgraph.eu` « Import a Wallet File », revient, clique « Entrer » et atteint l'app connectée. Les deux ne se confondent pas.
## See Also
- [[knowledge_broker-import-constraint]] — le fait technique qui force cette décision
- [[brief_2026-06-15_shared-wallet-shim]] — le stopgap
- [[decision_2026-06-15_shared-wallet-login-flow]] — le flux d'accès dont l'AccessGateScreen est la barrière
- [[knowledge_stores-permissions]] — wallet, capabilities, limites SDK
@@ -0,0 +1,69 @@
---
type: knowledge
summary: Le broker NextGraph hébergé n'autorise pas l'auto-import d'un wallet par une web-app tierce — init() top-level redirige, toute méthode ng.* exige d'être déjà loggé dans l'iframe, et le broker renvoie un device sans wallet vers nextgraph.eu (cross-origin, non pilotable). Un wallet doit préexister dans le navigateur. Des 4 méthodes d'import nextgraph.eu, seul le FICHIER .ngw est statique/réutilisable ; le TextCode/QR sont des transferts temporaires (5 min, deux devices en ligne) inutilisables à embarquer.
last_checked: 2026-06-25
---
# Contrainte : pas d'auto-import de wallet par une web-app tierce
Fait technique **vérifié empiriquement (2026-06-17)** : avec le broker NextGraph **hébergé** (`nextgraph.net`), une web-app tierce (Festipod) **ne peut pas** provisionner/importer un wallet par programme. Le wallet doit **préexister** dans le navigateur avant que le redirect d'authentification puisse réussir.
## Pourquoi (mécanisme du proxy `@ng-org/web`)
Lecture de `ngweb.js` (dist de `@ng-org/web`) :
- **`init()` top-level REDIRIGE** : si `window.self === window.top`, il fait `window.location.href = https://nextgraph.net/redir/#/?o=<url>`. Le code de l'app ne tourne plus.
- **Toute méthode `ng.*` est relayée** par `parent.postMessage` vers `nextgraph.net`, et le handler **lève `"you must call init() first"` tant que la session n'est pas établie** (garde interne `d !== false`). Cela inclut `wallet_import_from_code`, `add_in_memory_wallet`, `session_in_memory_start`.
- L'app tierce ne s'exécute **dans l'iframe qu'APRÈS** que le broker a déjà ouvert un wallet et établi la session. **Il n'existe aucune fenêtre** où notre code tourne *avant* le gate wallet du broker → **rien à quoi accrocher un auto-import**.
> Vérifier : `node_modules/@ng-org/web/dist/ngweb.js` — fonction `init` (redirect / postMessage selon top-vs-iframe) et le handler `apply` du Proxy `ng`. Cohérent avec [[knowledge_integration-model]] (le verifier tourne dans l'iframe du ng-app, le proxy ne fait que relayer).
## Ce que montre le broker (probe Playwright, navigateur frais sans wallet)
Sur `https://nextgraph.net/redir/#/?o=...`, le broker affiche **littéralement** :
> « We could not find a wallet in your browser. For now, creating a new wallet while a Web App is authenticating, is not implemented. Please create or import your wallet in a new tab. »
…et renvoie vers `https://nextgraph.eu/` (app wallet **standalone**). L'import standalone (`/#/wallet/login`) propose **4 méthodes**, mais elles ne sont **PAS équivalentes** :
| Méthode | Nature | Embarquable / réutilisable ? |
|---|---|---|
| **Import a Wallet File** (`.ngw`) | **fichier statique** (export portable, hors-ligne) | ✅ **oui** — statique, sans expiration, sans device source |
| Import with TextCode | **transfert temporaire** device↔device via leurs serveurs : **5 min**, **les deux appareils en ligne**, usage unique | ❌ non |
| Import with QR-Code | transfert live (même famille que TextCode) | ❌ non |
| Import via Username | récupération liée à un compte | à étudier |
> **Piège vérifié (2026-06-25)** : le **TextCode n'est PAS un export statique** — l'écran nextgraph.eu le dit (« temporarily stored on our servers for up to 5 minutes », « both devices need to be online »). L'embarquer dans l'app est **inutilisable** : il expire / est à usage unique. Un test automatisé qui génère ET importe le code dans la foulée **passe** (live), masquant le problème — d'où une fausse piste initiale. **La primitive correcte pour un wallet partagé embarqué = le FICHIER `.ngw`.**
## Logique du redirect nextgraph.net (broker discovery) + ORDRE critique
`init()` redirige vers `nextgraph.net/redir`. Là, nextgraph.net regarde dans le **localStorage** s'il connaît un **broker** (le domaine du broker, posé lors d'un import/login wallet antérieur) :
- **trouvé** → redirige vers le broker (`nextgraph.eu/auth/#/wallet/login` → « Click here to login with your wallet <nom> » → mot de passe) → app. **Cas qui marche.**
- **absent** → message « We could not find a wallet in your browser… Please create or import your wallet in a new tab by clicking here ». Le lien ouvre `nextgraph.eu` (page **Welcome**) → **Login****Import a Wallet File** → on rejoint l'import.
> **Ordre critique** : il faut **importer le wallet AVANT** d'atteindre nextgraph.net. Le guide de l'`AccessGateScreen` impose cet ordre (télécharger + importer, *puis* « Entrer »).
**Deux routes vers l'import** : **A** = lien direct `nextgraph.eu/#/wallet/login` (celui de Festipod + des tests — saute la page Welcome/Login) ; **B** = fallback nextgraph.net « no wallet → clicking here » → Welcome → **Login** → Import (si on atteint nextgraph.net sans wallet).
**Pourquoi l'e2e `@humain` ne bute pas sur le « no wallet »** : il importe le fichier (route A) **avant** le « Entrer », donc nextgraph.net trouve déjà le broker. Le test **ne couvre pas** la route B (message « no wallet » + page Welcome/Login de nextgraph.eu) — ce sont des comportements nextgraph, pas Festipod, mais un humain cliquant « Entrer » en premier y tombe.
> **Atténuation côté Festipod (2026-06-29)** : la barrière `AccessGateScreen` (qui **fournit** le fichier wallet + le mot de passe + le guide) est désormais l'écran d'entrée **par défaut** (cf. [[decision_2026-06-17_assisted-wallet-import]]) — l'ancien `LoginScreen` « Se connecter avec NextGraph » (qui menait directement au redirect sans fournir le wallet) a été retiré. Un utilisateur ne peut donc plus atteindre nextgraph.net **sans** que Festipod lui ait d'abord proposé le wallet. La route B reste possible s'il clique « Entrer » avant d'importer, mais il a le wallet sous les yeux pour le faire.
> **Contrainte UX irréductible + dé-piégeage** : « Entrer » fait une **redirection pleine-page**. Si on clique AVANT d'importer → message « no wallet » → l'import se fait dans un **autre onglet** sans retour auto vers Festipod (le broker hébergé ne sait pas revenir). Il faut donc **importer d'abord, PUIS Entrer** (guide de l'`AccessGateScreen`, ordre du `@humain`). Piège corrigé (2026-06-29) : au retour (back) après un « Entrer » prématuré, la page standalone était restaurée du bfcache avec l'état figé sur `connecting` → bouton « Accès en cours » bloqué ; `NextGraphContext` écoute `pageshow.persisted` (sans session) et réinitialise sur `disconnected` pour permettre de réessayer.
## Pistes d'élimination du va-et-vient — testées, ÉCARTÉES (2026-06-30)
Deux idées pour éviter le 2ᵉ onglet ; les deux **infaisables** sans fork :
1. **Embarquer `nextgraph.eu` en iframe** dans Festipod pour guider l'import sur le même écran. nextgraph.eu **n'a pas** d'en-tête anti-framing (embed possible, import OK dans l'iframe), **MAIS** le wallet importé atterrit dans le stockage **partitionné** `(top: festipod, frame: nextgraph.eu)` — invisible du login top-level → « no wallet ». **Confirmé en vrai navigateur (Chrome).** ⚠️ **Le Chromium de Playwright N'applique PAS ce partitioning → faux positif** : un probe Playwright montrait le wallet « transmis », alors que le vrai Chrome bloque. Ne jamais valider une question d'**isolation de stockage** via Playwright ; tester en vrai navigateur.
2. **Déclencher l'écriture cross-origin sur nextgraph.net** : le mécanisme existe (pont `/auth` iframe+postMessage qui écrit `ng_bootstrap` sur nextgraph.net pendant le login nextgraph.eu) mais ce sont **les pages de NextGraph** qui l'orchestrent ; un tiers ne peut pas écrire le localStorage d'une autre origine (same-origin policy), et ça ne couvrirait que la découverte du broker, pas le wallet.
→ Seule élimination réelle = self-host/fork du ng-app ([[brief_2026-05-21_fork-nextgraph-inbox]]). Le flow stopgap reste l'import **en onglet séparé** (top-level `nextgraph.eu`, première-partie → pas de partitioning → fonctionne).
## Conséquences
- Le wallet doit être importé **sur `nextgraph.eu` (cross-origin)** — Festipod ne peut **pas** piloter ce flux ni pré-remplir l'import (pas de route d'import par URL côté broker hébergé).
- Le **vrai zéro-touche** exigerait de **self-host/forker le ng-app** (territoire de [[brief_2026-05-21_fork-nextgraph-inbox]]).
- Distribution produit retenue, vu cette contrainte : **import assisté par FICHIER** — Festipod fournit le `.ngw` (téléchargement) + le mot de passe et guide l'import — voir [[decision_2026-06-17_assisted-wallet-import]].
- À ne pas confondre avec le **provisioning de TEST** (injection storageState dans le harness), qui n'a pas cette contrainte car il agit au niveau navigateur — concept `bdd-testing``knowledge_multibrowser-harness`.
+17
View File
@@ -133,10 +133,27 @@ const result = await Bun.build({
sourcemap: "linked",
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
// Access gate (ON by default) + shared wallet password, baked into the
// browser bundle as globals (see src/app/AuthGate.tsx, sharedWallet.ts). The
// wallet FILE is copied into the outdir below (served at /shared-wallet.ngw).
"globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__": JSON.stringify(
process.env.ACCESS_GATE_DISABLED === "1",
),
"globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__": JSON.stringify(
process.env.FESTIPOD_SHARED_WALLET_PASSWORD ?? "",
),
},
...cliConfig,
});
// Staging: copy the shared wallet FILE into the bundle so the access gate can
// offer it for download (served at /shared-wallet.ngw). See sharedWallet.ts.
if (process.env.FESTIPOD_SHARED_WALLET_FILE) {
const { copyFileSync } = await import("fs");
copyFileSync(process.env.FESTIPOD_SHARED_WALLET_FILE, path.join(outdir, "shared-wallet.ngw"));
console.log(`📦 Copied shared wallet → ${path.join(outdir, "shared-wallet.ngw")}`);
}
const end = performance.now();
const outputTable = result.outputs.map(output => ({
+1
View File
@@ -12,6 +12,7 @@
"html:reports/cucumber-report.html"
],
"language": "fr",
"tags": "not @wip",
"formatOptions": {
"snippetInterface": "async-await"
},
+14 -10
View File
@@ -1,12 +1,13 @@
import { RouterProvider, useRouter } from './router';
import { ThemeProvider } from '../shared/context/ThemeContext';
import { NextGraphProvider } from '../shared/context/NextGraphContext';
import { AccountProvider } from '../shared/context/AccountContext';
import { FestipodDataProvider } from '../shared/context/FestipodDataContext';
import { AuthGate } from './AuthGate';
import { ToastContainer } from '../shared/components/sketchy';
// Auth
import { WelcomeScreen } from '../modules/auth/screens/WelcomeScreen';
import { LoginScreen } from '../modules/auth/screens/LoginScreen';
// Home
import { HomeScreen } from '../modules/home/screens/HomeScreen';
@@ -34,7 +35,6 @@ function AppContent() {
switch (route.page) {
case 'welcome': return <WelcomeScreen />;
case 'login': return <LoginScreen />;
case 'home': return <HomeScreen />;
case 'events': return <EventsScreen />;
case 'create-event': return <CreateEventScreen />;
@@ -57,14 +57,18 @@ export function App() {
return (
<ThemeProvider>
<NextGraphProvider>
<FestipodDataProvider>
<RouterProvider>
<div className="app-container">
<AppContent />
<ToastContainer />
</div>
</RouterProvider>
</FestipodDataProvider>
<AccountProvider>
<FestipodDataProvider>
<RouterProvider>
<div className="app-container">
<AuthGate>
<AppContent />
</AuthGate>
<ToastContainer />
</div>
</RouterProvider>
</FestipodDataProvider>
</AccountProvider>
</NextGraphProvider>
</ThemeProvider>
);
+48
View File
@@ -0,0 +1,48 @@
/**
* AuthGate — the stopgap access flow (see decision_2026-06-15_shared-wallet-login-flow):
* 1. Technical access barrier (AccessGateScreen) → opens the SHARED wallet via
* the broker redirect (with the wallet file + guide it hands the user).
* 2. Perceived app login (ConnexionScreen) → pick a username.
* 3. The app.
*
* The gate is ON BY DEFAULT (Festipod never functions without NextGraph). It is
* disabled only when `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true` —
* injected by `build.ts` (from ACCESS_GATE_DISABLED=1) for a no-gate build, or
* by the test harness via `context.addInitScript` for @e2e (which exercises the
* screens, not the auth flow). Absent → gate ON.
*/
import type { ReactNode } from 'react';
import { useNextGraph } from '../shared/context/NextGraphContext';
import { useAccount } from '../shared/context/AccountContext';
import { AccessGateScreen } from '../modules/auth/screens/AccessGateScreen';
import { ConnexionScreen } from '../modules/auth/screens/ConnexionScreen';
declare global {
// eslint-disable-next-line no-var
var __FESTIPOD_ACCESS_GATE_DISABLED__: boolean | undefined;
}
const GATE_DISABLED = globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true;
export function AuthGate({ children }: { children: ReactNode }) {
const { status, error, connect } = useNextGraph();
const { username } = useAccount();
// Gate explicitly disabled (no-gate build / @e2e harness) → straight to app.
if (GATE_DISABLED) {
return <>{children}</>;
}
// 1. Technical access barrier (real NG login) — until the shared wallet opens.
if (status !== 'connected') {
return <AccessGateScreen status={status} error={error} onEnter={connect} />;
}
// 2. Perceived app login — until a username is chosen.
if (!username) {
return <ConnexionScreen />;
}
// 3. The app.
return <>{children}</>;
}
-3
View File
@@ -6,7 +6,6 @@ import React, { createContext, useContext, useState, useEffect, useCallback } fr
type Route =
| { page: 'welcome' }
| { page: 'login' }
| { page: 'home' }
| { page: 'events' }
| { page: 'create-event' }
@@ -38,7 +37,6 @@ function parsePath(pathname: string): Route {
const path = pathname.replace(/\/+$/, '') || '/';
if (path === '/' || path === '') return { page: 'welcome' };
if (path === '/login') return { page: 'login' };
if (path === '/home') return { page: 'home' };
if (path === '/events') return { page: 'events' };
if (path === '/events/new') return { page: 'create-event' };
@@ -73,7 +71,6 @@ function parsePath(pathname: string): Route {
export function routeToPath(route: Route): string {
switch (route.page) {
case 'welcome': return '/';
case 'login': return '/login';
case 'home': return '/home';
case 'events': return '/events';
case 'create-event': return '/events/new';
@@ -6,29 +6,10 @@ Fonctionnalité: Connexion NextGraph et chargement des données
Et charger les données de test dans mon portefeuille
Afin d'utiliser l'application avec mes propres données
# --- UI layer: écran de connexion ---
@ui
Scénario: L'écran de connexion affiche le bouton NextGraph
Étant donné je suis sur la page "connexion"
Alors l'écran contient un bouton "Se connecter avec NextGraph"
@ui @wip
# Behavioral: requires simulating an NG status change. Better tested at the
# @e2e layer where a real connected session triggers the redirect.
Scénario: L'écran de connexion redirige automatiquement quand connecté
Étant donné je suis sur la page "connexion"
Alors l'écran gère la redirection automatique après connexion
@ui
Scénario: L'état initial est "en cours" quand une connexion est en attente
Étant donné je suis sur la page "connexion"
Alors l'écran gère l'état de connexion en cours
@ui
Scénario: Aucune donnée de démonstration n'est visible pendant la connexion
Étant donné je suis sur la page "connexion"
Alors l'écran n'importe pas de données de démonstration
# NB : l'ancien écran /login (LoginScreen) a été retiré l'accès NextGraph
# passe désormais par l'AccessGateScreen (barrière ON par défaut), cf.
# decision_2026-06-17_assisted-wallet-import. Les scénarios @ui qui testaient
# le LoginScreen ont été supprimés en conséquence.
# --- Data layer: comportement du portefeuille ---
@@ -59,11 +40,6 @@ Fonctionnalité: Connexion NextGraph et chargement des données
# --- E2E layer: comportement réel dans le navigateur ---
@e2e
Scénario: L'écran de connexion redirige vers l'accueil si déjà connecté
Quand l'utilisateur navigue vers l'écran "login"
Alors l'application affiche l'écran "home"
@e2e
Scénario: La navigation interne met à jour l'URL
Quand l'utilisateur navigue vers l'écran "events"
@@ -0,0 +1,137 @@
/**
* AccessGateScreen — the *technical access barrier* of the stopgap.
*
* STOPGAP (see decision_2026-06-15_shared-wallet-login-flow). This is the
* REAL NextGraph login, shown before the app renders. Because it precedes the
* app, the user reads it as "access to the test environment", not as an app
* login. Clicking "Entrer" triggers `connect()`, which redirects to the broker
* to open the SHARED wallet. After return (inside the broker iframe) NG
* auto-connects and the app shows the perceived login (ConnexionScreen).
*
* ASSISTED IMPORT (see the broker-import constraint, concept nextgraph-platform
* + decision_2026-06-17). The hosted broker can't import a wallet inline during
* web-app auth: a first-time device has no wallet, so the broker redirect would
* dead-end. We therefore HAND the user the shared wallet FILE (download) + the
* shared password and guide a one-time import on nextgraph.eu ("Import a Wallet
* File"), BEFORE they click "Entrer". The wallet FILE is the correct static
* primitive — a TextCode is a transient 5-min transfer, unusable to embed. Shown
* only when a shared wallet is configured (FESTIPOD_SHARED_WALLET_PASSWORD).
*/
import { useState, type ReactNode } from 'react';
import { Button, Title, Text } from '../../../shared/components/sketchy';
import { SHARED_WALLET_PASSWORD, SHARED_WALLET_FILE_URL, WALLET_IMPORT_URL, hasSharedWallet } from '../sharedWallet';
interface AccessGateScreenProps {
status: 'disconnected' | 'connecting' | 'connected' | 'error';
error?: string;
onEnter: () => void;
}
// One numbered step: a badge + a title + the action for that step.
function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) {
return (
<div style={{ display: 'flex', gap: 12, marginBottom: 18 }}>
<div style={{
flexShrink: 0, width: 26, height: 26, borderRadius: '50%', background: '#E8590C',
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700, fontSize: 14,
}}>{n}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text style={{ margin: '2px 0 8px', fontWeight: 600, fontSize: 14 }}>{title}</Text>
{children}
</div>
</div>
);
}
export function AccessGateScreen({ status, error, onEnter }: AccessGateScreenProps) {
const connecting = status === 'connecting';
const [copied, setCopied] = useState(false);
const copyPassword = async () => {
try {
await navigator.clipboard.writeText(SHARED_WALLET_PASSWORD);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// clipboard may be blocked — the password stays selectable
}
};
const entrer = (
<Button
variant="primary"
onClick={onEnter}
disabled={connecting}
style={{ width: '100%', opacity: connecting ? 0.6 : 1 }}
>
{connecting ? 'Accès en cours…' : 'Entrer'}
</Button>
);
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<Title style={{ textAlign: 'center', fontSize: 30, marginBottom: 4 }}>Festipod</Title>
<Text style={{ textAlign: 'center', marginBottom: 24, color: '#888' }}>Espace de test</Text>
{hasSharedWallet() ? (
<>
<Text style={{ textAlign: 'center', fontSize: 14, color: '#666', margin: '0 0 20px', lineHeight: 1.5 }}>
Première connexion sur cet appareil ?<br />Chargez le portefeuille partagé, une seule fois.
</Text>
<Step n={1} title="Téléchargez le portefeuille">
<a
data-testid="shared-wallet-download"
href={SHARED_WALLET_FILE_URL}
download="festipod-wallet.ngw"
style={{
display: 'block', textAlign: 'center', textDecoration: 'none',
padding: 10, borderRadius: 10, background: '#E8590C', color: '#fff', fontWeight: 600, fontSize: 14,
}}
>
Télécharger le portefeuille
</a>
</Step>
<Step n={2} title="Importez-le sur NextGraph">
<Text style={{ margin: '0 0 8px', fontSize: 13, lineHeight: 1.6, color: '#666' }}>
<a href={WALLET_IMPORT_URL} target="_blank" rel="noopener noreferrer" style={{ color: '#E8590C', fontWeight: 600 }}>
Ouvrir la page d'import
</a>{' '}(nouvel onglet) → « Import a Wallet File » → choisissez le fichier → mot de passe :
</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<code
data-testid="shared-wallet-password"
style={{ flex: 1, padding: '6px 10px', background: '#fff', border: '1px solid #eee', borderRadius: 8, fontSize: 13, userSelect: 'all' }}
>
{SHARED_WALLET_PASSWORD}
</code>
<Button variant="accent-outline" onClick={copyPassword} style={{ padding: '6px 10px', fontSize: 12 }}>
{copied ? 'Copié ' : 'Copier'}
</Button>
</div>
</Step>
<Step n={3} title="Revenez sur cet onglet et entrez">
{entrer}
</Step>
</>
) : (
entrer
)}
{status === 'error' && (
<Text style={{ textAlign: 'center', fontSize: 12, color: '#c92a2a', marginTop: 12 }}>
{error || "Accès à l'environnement impossible. Réessayez."}
</Text>
)}
</div>
<Text style={{ textAlign: 'center', fontSize: 12, color: '#bbb' }}>
Version beta
</Text>
</div>
);
}
@@ -0,0 +1,92 @@
/**
* ConnexionScreen — the *perceived* login of the stopgap.
*
* STOPGAP (see decision_2026-06-15_shared-wallet-login-flow). The real NG
* login (AccessGateScreen) already happened and is not perceived as a login;
* THIS screen is what the user experiences as "logging in": they pick a
* username (no password — declarative). The username is persisted by
* AccountContext (localStorage) and resolved against the accounts living in
* the shared wallet.
*/
import { useState } from 'react';
import { Button, Input, Title, Text, Avatar } from '../../../shared/components/sketchy';
import { useAccount } from '../../../shared/context/AccountContext';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { useNavigate } from '../../../app/router';
export function ConnexionScreen() {
const { login } = useAccount();
const { users } = useFestipodData();
const navigate = useNavigate();
const [value, setValue] = useState('');
// Choosing a username completes the login → land on the app (not the '/'
// welcome/onboarding screen, which the access gate has replaced upstream).
const doLogin = (name: string) => {
login(name);
navigate('/home');
};
const submit = () => {
if (value.trim()) doLogin(value);
};
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<Title style={{ textAlign: 'center', fontSize: 32, marginBottom: 8 }}>Connexion</Title>
<Text style={{ textAlign: 'center', marginBottom: 32, color: '#888' }}>
Choisissez votre nom d'utilisateur
</Text>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 24 }}>
<Input
placeholder="@votrepseudo"
value={value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setValue(e.target.value)}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter') submit();
}}
/>
<Button variant="primary" onClick={submit} disabled={!value.trim()} style={{ width: '100%' }}>
Se connecter
</Button>
</div>
{users.length > 0 && (
<>
<Text style={{ textAlign: 'center', fontSize: 13, color: '#888', margin: '8px 0 12px' }}>
ou reprenez un compte existant
</Text>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{users.map(u => (
<button
key={u.id}
onClick={() => doLogin(u.username)}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: 10,
border: '1px solid #eee',
borderRadius: 12,
background: '#fff',
cursor: 'pointer',
textAlign: 'left',
}}
>
<Avatar initials={u.initials} color="#E8590C" size="sm" />
<span>
<Text style={{ margin: 0, fontWeight: 600 }}>{u.name}</Text>
<Text style={{ margin: 0, fontSize: 12, color: '#888' }}>{u.username}</Text>
</span>
</button>
))}
</div>
</>
)}
</div>
</div>
);
}
@@ -1,14 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { LoginScreen } from './LoginScreen';
import { withProviders } from '../../../../.storybook/decorators';
const meta: Meta<typeof LoginScreen> = {
title: 'Screens/Auth/LoginScreen',
component: LoginScreen,
decorators: [withProviders],
};
export default meta;
type Story = StoryObj<typeof LoginScreen>;
export const Default: Story = {};
-95
View File
@@ -1,95 +0,0 @@
import { useEffect } from 'react';
import { Button, Input, Title, Text, Divider } from '../../../shared/components/sketchy';
import { useNextGraph } from '../../../shared/context/NextGraphContext';
import { useNavigate } from '../../../app/router';
export function LoginScreen() {
const navigate = useNavigate();
const { status, connect } = useNextGraph();
useEffect(() => {
if (status === 'connected') {
navigate('/home');
}
}, [status]);
const handleNgLogin = () => {
if (status === 'connected') {
navigate('/home');
} else {
connect();
}
};
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<Title style={{ textAlign: 'center', fontSize: 32, marginBottom: 8 }}>Festipod</Title>
<Text style={{ textAlign: 'center', marginBottom: 32, color: '#888' }}>Créez et rejoignez des événements entre amis</Text>
{/* NextGraph login */}
<div style={{ marginBottom: 24 }}>
{status === 'connected' ? (
<div style={{ textAlign: 'center', marginBottom: 8 }}>
<Text style={{ color: '#22543D', fontWeight: 'bold', margin: '0 0 8px 0' }}>
Connecté via NextGraph
</Text>
<Button variant="primary" onClick={() => navigate('/home')} style={{ width: '100%' }}>
Continuer vers l'accueil
</Button>
</div>
) : status === 'connecting' ? (
<Button disabled style={{ width: '100%', opacity: 0.6 }}>
Connexion NextGraph en cours...
</Button>
) : (
<div>
<Button
variant="primary"
onClick={handleNgLogin}
style={{ width: '100%' }}
>
Se connecter avec NextGraph
</Button>
{status === 'error' && (
<Text style={{ textAlign: 'center', fontSize: 12, color: '#888', marginTop: 8 }}>
NextGraph non disponible — mode démonstration
</Text>
)}
</div>
)}
</div>
<Divider />
<Text style={{ textAlign: 'center', fontSize: 14, color: '#888', margin: '16px 0' }}>
ou connexion classique (démo)
</Text>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<Text style={{ marginBottom: 4, fontSize: 13, color: '#888' }}>Email</Text>
<Input type="email" placeholder="vous@exemple.com" />
</div>
<div>
<Text style={{ marginBottom: 4, fontSize: 13, color: '#888' }}>Mot de passe</Text>
<Input type="password" placeholder="••••••••" />
</div>
<Button variant="primary" onClick={() => navigate('/home')}>
Se connecter
</Button>
<Text style={{ textAlign: 'center', fontSize: 14, color: '#E8590C' }}>
Mot de passe oublié ?
</Text>
</div>
</div>
<Text style={{ textAlign: 'center', fontSize: 14, color: '#888' }}>
Pas encore de compte ? <span style={{ color: '#E8590C', cursor: 'pointer' }}>S'inscrire</span>
</Text>
</div>
);
}
+12 -2
View File
@@ -1,8 +1,18 @@
import { useEffect } from 'react';
import { Button, Title, Text } from '../../../shared/components/sketchy';
import { useNavigate } from '../../../app/router';
import { useNextGraph } from '../../../shared/context/NextGraphContext';
export function WelcomeScreen() {
const navigate = useNavigate();
const { status } = useNextGraph();
// Onboarding is for NOT-connected users. A connected user landing on '/'
// (e.g. a returning tester past the access gate) goes straight to the app.
useEffect(() => {
if (status === 'connected') navigate('/home');
}, [status]);
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
@@ -41,12 +51,12 @@ export function WelcomeScreen() {
</div>
</div>
<Button variant="primary" onClick={() => navigate('/login')} style={{ marginBottom: 12 }}>
<Button variant="primary" onClick={() => navigate('/home')} style={{ marginBottom: 12 }}>
Rejoindre la communauté
</Button>
<Text style={{ textAlign: 'center', fontSize: 13, color: '#888' }}>
Déjà membre ? <span onClick={() => navigate('/login')} style={{ color: '#E8590C', cursor: 'pointer', fontWeight: 600 }}>Connexion</span>
Déjà membre ? <span onClick={() => navigate('/home')} style={{ color: '#E8590C', cursor: 'pointer', fontWeight: 600 }}>Connexion</span>
</Text>
</div>
+40
View File
@@ -0,0 +1,40 @@
/**
* Shared wallet material for the staging stopgap.
*
* STOPGAP (see brief_2026-06-15_shared-wallet-shim + the broker-import
* constraint, concept nextgraph-platform): the hosted broker can't auto-import
* a wallet, so Festipod HANDS the user the shared wallet and guides a one-time
* import on nextgraph.eu.
*
* The correct primitive is the **wallet FILE** (.ngw), NOT a TextCode: a
* TextCode is a transient device-to-device transfer (5 min, source device
* online, single use) — useless to embed. A wallet file is STATIC and reusable.
* So Festipod serves the file (download) + shows the shared password; the user
* imports it via nextgraph.eu → "Import a Wallet File".
*
* ZERO-SECURITY shared credential (friendly users) → embedding the file +
* password is consistent with the posture.
*
* `build.ts` copies the file (from FESTIPOD_SHARED_WALLET_FILE) to the bundle as
* `/shared-wallet.ngw`, and `define`s the password global from
* FESTIPOD_SHARED_WALLET_PASSWORD. Empty password → no shared wallet configured
* → the gate falls back to the plain flow.
*/
// Build-injected global (not `process.env`, absent in the browser); any path
// that doesn't inject it reads `undefined` → '' safely (no ReferenceError).
declare global {
// eslint-disable-next-line no-var
var __FESTIPOD_SHARED_WALLET_PASSWORD__: string | undefined;
}
export const SHARED_WALLET_PASSWORD: string = globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__ ?? '';
/** URL of the shared wallet file in the bundle (copied by build.ts). */
export const SHARED_WALLET_FILE_URL = '/shared-wallet.ngw';
/** Standalone NextGraph wallet app — where the import actually happens. */
export const WALLET_IMPORT_URL = 'https://nextgraph.eu/#/wallet/login';
/** Whether Festipod has a shared wallet to hand over (drives the assisted UI). */
export const hasSharedWallet = (): boolean => SHARED_WALLET_PASSWORD.trim().length > 0;
@@ -13,7 +13,6 @@ import type { FestipodWorld } from '../../../../shared/support/world';
const SCREEN_MARKERS: Record<string, string> = {
'home': 'Festipod',
'events': 'Découvrir',
'login': 'connecter',
'profile': 'Mon profil',
'create-event': "Relayer un événement",
'settings': 'Paramètres',
@@ -35,7 +34,6 @@ function pathForScreen(screenId: string): string {
case 'home': return '/home';
case 'events': return '/events';
case 'create-event': return '/events/new';
case 'login': return '/login';
case 'profile': return '/profile';
case 'edit-profile': return '/profile/edit';
case 'friends-list': return '/profile/friends';
@@ -1,27 +0,0 @@
import { Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
Then('l\'écran gère la redirection automatique après connexion', async function (this: FestipodWorld) {
// Behavioral — covered by the @e2e scenario
// "L'écran de connexion redirige vers l'accueil si déjà connecté".
// At the @ui layer we only verify the screen mounts cleanly.
expect(this.currentScreenId).to.equal('login');
expect(this.renderedDoc, 'Login screen should render').to.not.be.null;
});
Then('l\'écran gère l\'état de connexion en cours', async function (this: FestipodWorld) {
const source = this.getRenderedText();
const hasConnectingState =
source.includes("status === 'connecting'") ||
source.includes("Connexion NextGraph en cours");
expect(hasConnectingState, 'LoginScreen should handle connecting state').to.be.true;
});
Then('l\'écran n\'importe pas de données de démonstration', async function (this: FestipodWorld) {
const source = this.getRenderedText();
const importsSeedData = source.includes('seedData') || source.includes('seedEvents');
const usesFestipodData = source.includes('useFestipodData');
expect(importsSeedData, 'LoginScreen should not import seed data').to.be.false;
expect(usesFestipodData, 'LoginScreen should not use FestipodData context').to.be.false;
});
@@ -74,12 +74,12 @@ export function CreateEventScreen() {
setStep((step - 1) as Step);
};
const submit = () => {
const submit = async () => {
const dateLabel = startDate
? (endDate ? `${startDate} - ${endDate}` : startDate)
: 'Date à définir';
const newEvent = createEvent({
const newEvent = await createEvent({
title: name || 'Nouvel événement',
date: dateLabel,
startDate,
+26 -1
View File
@@ -2,13 +2,31 @@ import { useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Header, Text, ListItem, Toggle, Divider, BottomNav } from '../../../shared/components/sketchy';
import { useNavigate } from '../../../app/router';
import { useAccount } from '../../../shared/context/AccountContext';
import { logoutNg } from '../../../shared/utils/ngSession';
export function SettingsScreen() {
const navigate = useNavigate();
const { logout } = useAccount();
const [notifications, setNotifications] = useState(true);
const [darkMode, setDarkMode] = useState(false);
const [location, setLocation] = useState(true);
// Faux logout: clears the perceived login (username) only — the shared
// wallet stays open underneath. In staging this returns to ConnexionScreen.
const handleLogout = () => {
logout();
navigate('/');
};
// Real logout (HIDDEN): stops the shared wallet session — forces a broker
// redirect on next access. Stopgap-only escape hatch.
const handleLeaveEnvironment = async () => {
await logoutNg();
logout();
if (typeof window !== 'undefined') window.location.reload();
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
@@ -74,9 +92,16 @@ export function SettingsScreen() {
<Divider />
<ListItem onClick={() => navigate('/login')}>
<ListItem onClick={handleLogout}>
<Text style={{ margin: 0, color: '#E53E3E' }}>Se déconnecter</Text>
</ListItem>
{/* Stopgap escape hatch — real wallet logout, kept discreet. */}
<ListItem onClick={handleLeaveEnvironment}>
<Text style={{ margin: 0, fontSize: 12, color: '#bbb' }}>
Quitter l'environnement de test
</Text>
</ListItem>
</div>
<BottomNav active="profile" />
@@ -0,0 +1,68 @@
# language: fr
@data @multibrowser
Fonctionnalité: Harness multi-navigateur — modèles private-wallet et shared-wallet
Pour comparer sereinement les deux modèles de wallet (chacun le sien vs partagé)
En tant que développeur du stopgap puis de la cible NextGraph
Le harness e2e doit piloter plusieurs navigateurs isolés dans un seul scénario,
sous l'un OU l'autre modèle de wallet deux axes orthogonaux.
# --- Axe machinerie : isolation des contextes (modèle private-wallet) ---
@private-wallet
Scénario: Deux navigateurs avec leur propre wallet ont des stockages locaux indépendants
Étant donné un navigateur "A" avec son propre wallet
Et un navigateur "B" avec son propre wallet
Quand j'écris "valeur-A" sous la clé "sonde" dans le navigateur "A"
Alors la clé "sonde" vaut "valeur-A" dans le navigateur "A"
Et la clé "sonde" est absente dans le navigateur "B"
# Le wallet NextGraph vit sur l'origine du broker (nextgraph.net). Ce scénario
# prouve l'isolation du stockage LÀ, pas seulement sur l'origine locale.
@private-wallet
Scénario: Sur l'origine du broker, deux navigateurs private-wallet restent isolés
Étant donné un navigateur "A" avec son propre wallet
Et un navigateur "B" avec son propre wallet
Quand le navigateur "A" charge l'origine du broker
Et le navigateur "B" charge l'origine du broker
Et j'écris "faux-wallet" sous la clé "ng_probe" dans le navigateur "A"
Alors la clé "ng_probe" vaut "faux-wallet" dans le navigateur "A"
Et la clé "ng_probe" est absente dans le navigateur "B"
# --- Axe wallet : provisioning shared-wallet (injection storageState) ---
# Deux navigateurs distincts portent LE MÊME wallet partagé (injecté au niveau
# harness). Tous deux atteignent l'app connectée à NextGraph sans login manuel.
@shared-wallet
Scénario: Deux navigateurs partageant le wallet se connectent tous deux à NextGraph
Étant donné un navigateur "A" avec le wallet partagé
Et un navigateur "B" avec le wallet partagé
Quand le navigateur "A" charge l'application via le broker
Et le navigateur "B" charge l'application via le broker
Alors le navigateur "A" est connecté à NextGraph
Et le navigateur "B" est connecté à NextGraph
# --- Distribution produit : import ASSISTÉ (pas d'auto-import zéro-touche) ---
#
# L'auto-import zéro-touche par l'app est PROUVÉ IMPOSSIBLE avec le broker
# hébergé : il n'implémente pas l'import inline pendant l'auth web-app et
# renvoie vers nextgraph.eu (cross-origin, non pilotable par Festipod). Voir
# concept nextgraph-platform → knowledge_broker-import-constraint et
# decision_2026-06-17_assisted-wallet-import.
#
# PARCOURS HUMAIN COMPLET — exerce la VRAIE app (staging, gate ON) de bout en
# bout : Festipod propose le FICHIER du portefeuille → l'humain le télécharge et
# l'importe sur nextgraph.eu (« Import a Wallet File » + mot de passe) → revient
# → « Entrer » → connecté. Le FICHIER est la primitive correcte (statique,
# réutilisable) — le TextCode est un transfert temporaire 5 min, inutilisable à
# embarquer (cf. nextgraph-platform → knowledge_broker-import-constraint).
# (≠ scénario @shared-wallet ci-dessus, qui INJECTE le wallet via storageState
# et court-circuite donc l'import — provisioning de TEST, pas le flux produit.)
@shared-wallet @assisted-import @humain
Scénario: Parcours humain — le testeur importe le portefeuille fourni par Festipod et se connecte
Étant donné un nouveau testeur ouvre Festipod en staging sur un navigateur vierge
Alors Festipod affiche l'écran d'accès avec le portefeuille à télécharger
Quand le testeur télécharge le portefeuille et l'importe sur nextgraph.eu
Et le testeur revient sur Festipod et clique « Entrer »
Alors Festipod est connecté et propose de choisir un nom d'utilisateur
Quand le testeur choisit un nom d'utilisateur
Alors il arrive sur l'accueil de l'application
@@ -0,0 +1,28 @@
# language: fr
@WORKSHOP @priority-1
Fonctionnalité: Stopgap multi-store — primitives de données
En tant que développeur
Je veux valider, contre le vrai broker NextGraph, les primitives du stopgap
wallet partagé (création de documents, ORM sur un document créé, aller-retour
du sharedWalletShim) avant d'activer le mode multi-document.
# --- Data (broker réel) ---
@data
Scénario: L'ORM lit et écrit dans un document créé par doc_create
Étant donné un nouveau document de graphe est créé dans le wallet partagé
Quand j'écris une participation dans ce document via l'ORM
Alors la participation est lisible dans ce document
@data
Scénario: Le sharedWalletShim fait l'aller-retour par le wallet
Étant donné un compte "@smoketest" est enregistré dans le shim
Alors le compte "@smoketest" est retrouvé après rechargement du shim
Et le compte "@smoketest" possède trois documents de périmètre distincts
@data
Scénario: Lecture fan-out sur plusieurs documents d'entité (1 doc par entité)
Étant donné deux comptes ayant chacun un document d'événement indexé
Quand j'écris un événement dans chacun de ces deux documents
Alors un abonnement multi-graphes lit les deux événements ensemble
Et l'index public liste les deux documents
@@ -0,0 +1,147 @@
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
import { pool } from '../../../../shared/support/browserPool';
// Multi-browser harness steps. Two ORTHOGONAL axes:
// - browser count : several named, isolated browsers in one scenario;
// - wallet model : 'own' (its own/no wallet) vs 'shared' (THE shared wallet).
// The wallet model is carried explicitly by the Given phrasing.
// See brief_2026-06-15_shared-wallet-shim.
Given('un navigateur {string} avec son propre wallet', async function (this: FestipodWorld, name: string) {
const handle = await this.openBrowser(name, 'own');
// Land on the local harness origin (no NG stack) so localStorage is available.
await handle.page.goto(`${pool.harnessUrl}/blank`, { waitUntil: 'domcontentloaded' });
});
Given('un navigateur {string} avec le wallet partagé', async function (this: FestipodWorld, name: string) {
const handle = await this.openBrowser(name, 'shared');
await handle.page.goto(`${pool.harnessUrl}/blank`, { waitUntil: 'domcontentloaded' });
});
// --- Parcours HUMAIN complet (e2e fidèle) ---
// Ouvre la VRAIE app en staging (gate ON), lit le code À L'ÉCRAN, l'importe sur
// nextgraph.eu, revient, clique « Entrer » → app connectée. C'est la garantie
// que Festipod remet à l'humain un code qui marche. Browser fixe "H".
Given('un nouveau testeur ouvre Festipod en staging sur un navigateur vierge', async function (this: FestipodWorld) {
const url = await pool.ensureStagingApp();
(this as any).stagingUrl = url;
const handle = await this.openBrowser('H', 'own'); // vierge, AUCUN wallet
await handle.page.goto(url, { waitUntil: 'domcontentloaded' });
// L'écran d'accès (AccessGateScreen) doit s'afficher.
await handle.page.getByText('Entrer', { exact: true }).waitFor({ state: 'visible', timeout: 15000 });
});
Then('Festipod affiche l\'écran d\'accès avec le portefeuille à télécharger', async function (this: FestipodWorld) {
const page = this.browser('H').page;
// Le fichier du portefeuille est proposé au téléchargement…
const downloadVisible = await page.locator('[data-testid=shared-wallet-download]').isVisible();
expect(downloadVisible, 'le bouton de téléchargement du portefeuille doit être affiché').to.equal(true);
// …et le mot de passe partagé est affiché (c'est bien CELUI du wallet e2e).
const pwd = (await page.locator('[data-testid=shared-wallet-password]').innerText()).trim();
expect(pwd, 'le mot de passe affiché doit être celui du wallet partagé').to.equal(pool.sharedWalletPassword);
(this as any).displayedPassword = pwd;
});
When('le testeur télécharge le portefeuille et l\'importe sur nextgraph.eu', async function (this: FestipodWorld) {
const page = this.browser('H').page;
// Télécharge le fichier DEPUIS l'écran Festipod (le vrai geste humain)…
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('[data-testid=shared-wallet-download]').click(),
]);
const filePath = await download.path();
// …et l'importe via "Import a Wallet File" avec le mot de passe lu à l'écran.
await pool.importWalletViaFile(page, filePath!, (this as any).displayedPassword);
});
When('le testeur revient sur Festipod et clique « Entrer »', async function (this: FestipodWorld) {
const handle = this.browser('H');
await handle.page.goto((this as any).stagingUrl, { waitUntil: 'domcontentloaded' });
const entrer = handle.page.getByText('Entrer', { exact: true });
await entrer.waitFor({ state: 'visible', timeout: 15000 });
await entrer.click(); // déclenche le redirect vers le broker
await handle.page.waitForURL('**nextgraph**', { timeout: 20000 }).catch(() => {});
// Le broker demande de déverrouiller le wallet fraîchement importé → son mot de
// passe (completeBrokerLogin attend la page de login wallet de façon robuste).
handle.appFrame = await pool.completeBrokerLogin(handle.page, (this as any).stagingUrl, pool.sharedWalletPassword);
});
Then('Festipod est connecté et propose de choisir un nom d\'utilisateur', async function (this: FestipodWorld) {
const handle = this.browser('H');
expect(handle.appFrame, 'l\'app doit être chargée dans l\'iframe broker').to.not.equal(null);
// Connecté → AuthGate passe l'AccessGateScreen et montre ConnexionScreen.
await handle.appFrame!.waitForFunction(
() => /Choisissez votre nom d'utilisateur|Connexion/.test(document.body?.innerText ?? ''),
{ timeout: 30000 },
);
});
When('le testeur choisit un nom d\'utilisateur', async function (this: FestipodWorld) {
const frame = this.browser('H').appFrame!;
await frame.locator('input[placeholder="@votrepseudo"]').fill('@testeur');
await frame.getByRole('button', { name: 'Se connecter' }).click();
});
Then('il arrive sur l\'accueil de l\'application', async function (this: FestipodWorld) {
const frame = this.browser('H').appFrame!;
// On atterrit sur /home (l'app), PAS sur l'onboarding hors-connexion ('/'
// WelcomeScreen « Rejoindre la communauté »).
await frame.waitForFunction(
() => window.location.pathname.endsWith('/home') &&
!(document.body?.innerText ?? '').includes('Rejoindre la communauté'),
{ timeout: 15000 },
);
});
When('le navigateur {string} charge l\'application via le broker', async function (this: FestipodWorld, name: string) {
// Drive the named browser through the broker into the NG harness iframe.
// A 'shared' browser carries the wallet (storageState) → the broker recognises
// it; an 'own' browser has none → the broker cannot reach the app.
await this.loadAppInBrowser(name, 'harness');
});
Then('le navigateur {string} est connecté à NextGraph', async function (this: FestipodWorld, name: string) {
const handle = this.browser(name);
expect(handle.appFrame, `le navigateur ${name} doit avoir chargé l'app`).to.not.equal(null);
// __testData.ready flips true only once the NG session is connected.
await handle.appFrame!.waitForFunction(
() => (window as any).__testData?.ready === true,
{ timeout: 30000 },
);
});
When('le navigateur {string} charge l\'origine du broker', async function (this: FestipodWorld, name: string) {
// Navigate top-level to the broker origin (nextgraph.net) — the exact origin
// where the NG wallet localStorage lives. Each fresh context has its own
// storage partition there too.
await this.browser(name).page.goto(pool.brokerOrigin, { waitUntil: 'domcontentloaded' });
});
When(
'j\'écris {string} sous la clé {string} dans le navigateur {string}',
async function (this: FestipodWorld, value: string, key: string, name: string) {
await this.browser(name).page.evaluate(
([k, v]) => localStorage.setItem(k, v),
[key, value] as [string, string],
);
},
);
Then(
'la clé {string} vaut {string} dans le navigateur {string}',
async function (this: FestipodWorld, key: string, expected: string, name: string) {
const actual = await this.browser(name).page.evaluate((k) => localStorage.getItem(k), key);
expect(actual, `localStorage["${key}"] dans le navigateur ${name}`).to.equal(expected);
},
);
Then(
'la clé {string} est absente dans le navigateur {string}',
async function (this: FestipodWorld, key: string, name: string) {
const actual = await this.browser(name).page.evaluate((k) => localStorage.getItem(k), key);
expect(actual, `localStorage["${key}"] dans le navigateur ${name} doit être isolé`).to.equal(null);
},
);
@@ -0,0 +1,109 @@
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
// Data-layer validation of the shared-wallet multi-store stopgap, against the
// REAL broker. Exercises the exact app mechanisms: doc_create, a real
// useShape({graphs}) on the created doc (window.__smoke, via <SmokeProbe>), and
// the sharedWalletShim round-trip (window.__testData.validateShim).
// See brief_2026-06-15_shared-wallet-shim.
// --- Scenario 1: ORM on a doc_create'd document ---
Given('un nouveau document de graphe est créé dans le wallet partagé', async function (this: FestipodWorld) {
const nuri = await this.appFrame!.evaluate(async () => {
return await (window as any).__testData.createSmokeDoc();
});
expect(nuri, 'doc_create should return a NURI').to.be.a('string');
expect((nuri as string).length, 'doc_create NURI should be non-empty').to.be.greaterThan(0);
// Wait for <SmokeProbe> to mount the useShape({graphs}) and expose __smoke.
await this.appFrame!.waitForFunction(
() => (window as any).__smoke?.ready === true,
null,
{ timeout: 15000 },
);
});
When('j\'écris une participation dans ce document via l\'ORM', async function (this: FestipodWorld) {
await this.appFrame!.evaluate(() => (window as any).__smoke.add());
});
Then('la participation est lisible dans ce document', async function (this: FestipodWorld) {
await this.appFrame!.waitForFunction(
() => (window as any).__smoke.count() >= 1,
null,
{ timeout: 15000 },
);
const items = await this.appFrame!.evaluate(() => (window as any).__smoke.items());
expect(items.length, 'participation should be readable via ORM on the created doc').to.be.greaterThan(0);
});
// --- Scenario 2: sharedWalletShim round-trip ---
Given('un compte {string} est enregistré dans le shim', async function (this: FestipodWorld, username: string) {
const res = await this.appFrame!.evaluate(
async (u) => await (window as any).__testData.validateShim(u),
username,
);
(this as any).shimResult = res;
expect(res?.created, 'ensureAccount should return a record').to.exist;
});
Then('le compte {string} est retrouvé après rechargement du shim', function (this: FestipodWorld, username: string) {
const res = (this as any).shimResult;
expect(res?.reloaded, `account ${username} should reload from the wallet shim`).to.exist;
expect(res.reloaded.username).to.equal(username);
});
Then('le compte {string} possède trois documents de périmètre distincts', function (this: FestipodWorld, _username: string) {
const r = (this as any).shimResult?.reloaded;
expect(r, 'reloaded account should exist').to.exist;
const docs = [r.docPublic, r.docProtected, r.docPrivate];
for (const d of docs) {
expect(d, 'each scope doc should be a string').to.be.a('string');
expect((d as string).length, 'each scope doc NURI should be non-empty').to.be.greaterThan(0);
}
expect(new Set(docs).size, 'the three scope docs must be distinct').to.equal(3);
});
// --- Scenario 3: per-entity granularity + multi-graph fan-out ---
Given('deux comptes ayant chacun un document d\'événement indexé', async function (this: FestipodWorld) {
const res = await this.appFrame!.evaluate(async () => await (window as any).__testData.setupFanout());
(this as any).fanout = res;
expect(res.docA, 'event doc A').to.be.a('string');
expect(res.docB, 'event doc B').to.be.a('string');
await this.appFrame!.waitForFunction(
() => (window as any).__fanout?.ready === true,
null,
{ timeout: 15000 },
);
});
When('j\'écris un événement dans chacun de ces deux documents', async function (this: FestipodWorld) {
const { docA, docB } = (this as any).fanout;
await this.appFrame!.evaluate(
([a, b]: [string, string]) => {
(window as any).__fanout.addEventTo(a, 'FanA');
(window as any).__fanout.addEventTo(b, 'FanB');
},
[docA, docB] as [string, string],
);
});
Then('un abonnement multi-graphes lit les deux événements ensemble', async function (this: FestipodWorld) {
await this.appFrame!.waitForFunction(
() => (window as any).__fanout.count() >= 2,
null,
{ timeout: 15000 },
);
const titles = await this.appFrame!.evaluate(() => (window as any).__fanout.titles());
expect(titles, 'fan-out should read event from doc A').to.include('FanA');
expect(titles, 'fan-out should read event from doc B').to.include('FanB');
});
Then('l\'index public liste les deux documents', function (this: FestipodWorld) {
const { docA, docB, listed } = (this as any).fanout;
expect(listed, 'public index should list doc A').to.include(docA);
expect(listed, 'public index should list doc B').to.include(docB);
});
-3
View File
@@ -5,8 +5,6 @@
import { HomeScreen } from '../modules/home/screens/HomeScreen';
import { SettingsScreen } from '../modules/home/screens/SettingsScreen';
// Auth module
import { LoginScreen } from '../modules/auth/screens/LoginScreen';
import { WelcomeScreen } from '../modules/auth/screens/WelcomeScreen';
// Event module
@@ -34,7 +32,6 @@ export interface Screen {
export const screens: Screen[] = [
{ id: 'welcome', name: 'Bienvenue', path: '/', component: WelcomeScreen },
{ id: 'login', name: 'Connexion', path: '/login', component: LoginScreen },
{ id: 'home', name: 'Accueil', path: '/home', component: HomeScreen },
{ id: 'events', name: 'Découvrir', path: '/events', component: EventsScreen },
{ id: 'create-event', name: 'Relayer événement', path: '/events/new', component: CreateEventScreen },
+92
View File
@@ -0,0 +1,92 @@
/**
* AccountContext the *simulated* application-level login.
*
* STOPGAP part of the shared-wallet shim (see
* .project/concepts/nextgraph-platform/brief_2026-06-15_shared-wallet-shim.md
* and decision_2026-06-15_shared-wallet-login-flow.md).
*
* The real NextGraph login (a redirect to the broker, opening the single
* SHARED wallet) is perceived by the user as a *technical access barrier*,
* NOT as a login. THIS context is what the user perceives as the login:
* they pick a username (no password declarative), which is persisted in
* localStorage so the "session" survives reloads and a different device,
* re-opening the same shared wallet, lands on the same accounts.
*
* `login()` / `logout()` here are FAUX: they only read/write the username in
* localStorage. They must NEVER call NextGraph (ng.session_stop /
* wallet_close) the shared wallet stays open underneath. The real logout
* lives, hidden, in Settings.
*
* Default value is non-null so `useAccount()` never throws outside a provider
* (the @ui render harness wraps screens without this provider).
*/
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
const STORAGE_KEY = 'festipod.account.username';
export interface AccountContextValue {
/** App-level identity (the perceived "login"). null = not connected. */
username: string | null;
/** Faux login — persists the username. No NextGraph call. */
login: (username: string) => void;
/** Faux logout — clears the username only. No NextGraph call. */
logout: () => void;
}
function readStored(): string | null {
if (typeof window === 'undefined') return null;
try {
return window.localStorage.getItem(STORAGE_KEY);
} catch {
return null;
}
}
const AccountContext = createContext<AccountContextValue>({
username: null,
login: () => {},
logout: () => {},
});
export function AccountProvider({ children }: { children: ReactNode }) {
const [username, setUsername] = useState<string | null>(() => readStored());
const login = useCallback((name: string) => {
const clean = name.trim();
if (!clean) return;
try {
window.localStorage.setItem(STORAGE_KEY, clean);
} catch {
/* ignore — staging, no security */
}
setUsername(clean);
}, []);
const logout = useCallback(() => {
try {
window.localStorage.removeItem(STORAGE_KEY);
} catch {
/* ignore */
}
setUsername(null);
}, []);
return (
<AccountContext.Provider value={{ username, login, logout }}>
{children}
</AccountContext.Provider>
);
}
export function useAccount(): AccountContextValue {
return useContext(AccountContext);
}
/**
* Normalise a username for matching (case-insensitive, optional leading `@`).
* Lets the perceived login accept "marie", "@marie", "Marie" interchangeably.
*/
export function normalizeUsername(username: string | null | undefined): string {
return (username ?? '').trim().replace(/^@+/, '').toLowerCase();
}
+110 -23
View File
@@ -15,7 +15,19 @@ import {
seedFriendships,
} from '../data/seedData';
import { useNextGraph } from './NextGraphContext';
import { useShapeWithDefaults } from '../hooks/useShapeWithDefaults';
import { useAccount, normalizeUsername } from './AccountContext';
import { applyIsolation } from '../utils/isolation';
import { ensureAccount, resolveReadGraphs, resolveWriteGraph, createEntityDoc, listEntityDocs } from '../utils/storeRegistry';
import { useShapeWithDefaults, type ShapeScope } from '../hooks/useShapeWithDefaults';
// Multi-document mode (storeRegistry): one document per (account × scope),
// mirroring the target per-user stores. Default OFF — the validated mono-store
// path stays the default until the multi-document path is broker-validated.
// Flip via FESTIPOD_MULTISTORE=1 at build time. See brief_2026-06-15_shared-wallet-shim.
// Browser-safe env read: the bundler inlines process.env.NODE_ENV but NOT
// custom vars, so a bare `process.env.FESTIPOD_MULTISTORE` throws
// "process is not defined" in the browser harness. Guard it.
const MULTISTORE = typeof process !== 'undefined' && process?.env?.FESTIPOD_MULTISTORE === '1';
import {
FpEventShapeType,
FpUserProfileShapeType,
@@ -53,7 +65,7 @@ interface FestipodDataContextValue {
setSelectedUserId(id: string): void;
selectedUser: FpUserData | undefined;
createEvent(event: Omit<FpEventData, 'id'>): FpEventData;
createEvent(event: Omit<FpEventData, 'id'>): Promise<FpEventData>;
updateEvent(id: string, updates: Partial<FpEventData>): void;
joinEvent(eventId: string, userId?: string): void;
leaveEvent(eventId: string, userId?: string): void;
@@ -161,6 +173,7 @@ function buildQueries(
// ============================================================================
function useLocalData(empty?: boolean): FestipodDataContextValue {
const { username } = useAccount();
const [selectedEventId, setSelectedEventId] = useState<string>(empty ? '' : 'event-1');
const [selectedUserId, setSelectedUserId] = useState<string>('');
@@ -170,7 +183,12 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
const meetingPoints = empty ? [] : seedMeetingPoints;
const friendships = empty ? [] : seedFriendships;
const currentUserId = empty ? '' : CURRENT_USER_ID;
// Resolve current user from the chosen account username; fall back to the
// demo default so @ui tests and standalone dev keep working unchanged.
const accountUser = username
? users.find(u => normalizeUsername(u.username) === normalizeUsername(username))
: undefined;
const currentUserId = empty ? '' : (accountUser?.id ?? CURRENT_USER_ID);
const currentUser = users.find(u => u.id === currentUserId);
const selectedEvent = events.find(e => e.id === selectedEventId);
const selectedUser = users.find(u => u.id === selectedUserId);
@@ -182,7 +200,7 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
'| selectedEvent:', selectedEvent?.title ?? '(none)');
// Local mode: mutations are no-ops (static defaults)
const createEvent = useCallback((event: Omit<FpEventData, 'id'>): FpEventData => {
const createEvent = useCallback(async (event: Omit<FpEventData, 'id'>): Promise<FpEventData> => {
console.log('[FestipodData] createEvent (local, no-op):', event.title);
return { ...event, id: nextId('event') };
}, []);
@@ -226,18 +244,56 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
function useNgData(): FestipodDataContextValue {
const { session } = useNextGraph();
// Use private store NURI as scope (same as expense-tracker-rdf).
// This opens the store repo in the verifier, enabling both reads and writes.
const privateNuri = session && `did:ng:${session.private_store_id}`;
const { username } = useAccount();
// Mono-store fallback scope: the shared wallet's private store NURI. Opens the
// repo in the verifier (enables reads + writes), per the data-layer rule.
const privateNuri = session ? `did:ng:${session.private_store_id}` : undefined;
// Multi-document state (storeRegistry): read fan-out (all accounts' docs per
// scope) and the current account's write docs. Populated by the effect below.
const [readGraphs, setReadGraphs] = useState<{ public: string[]; protected: string[] }>({ public: [], protected: [] });
const [writeGraphs, setWriteGraphs] = useState<{ protected?: string }>({});
useEffect(() => {
if (!MULTISTORE || !privateNuri || !username) return;
let cancelled = false;
(async () => {
try {
await ensureAccount(username); // create this account's index docs on first sight
// Public = per-entity documents (events/PdR) listed by the public index.
// Protected = grouped in each account's protected index document.
const [pub, prot, wProt] = await Promise.all([
listEntityDocs('public'),
resolveReadGraphs('protected'),
resolveWriteGraph(username, 'protected'),
]);
if (cancelled) return;
setReadGraphs({ public: pub, protected: prot });
setWriteGraphs({ protected: wProt });
} catch (err) {
console.error('[FestipodData] storeRegistry init failed:', err);
}
})();
return () => { cancelled = true; };
}, [privateNuri, username]);
// Scope per entity: events live in the PUBLIC docs, profiles + participations
// in the PROTECTED docs. Mono-store mode collapses all to the private store.
const publicScope: ShapeScope = MULTISTORE
? (readGraphs.public.length ? { graphs: readGraphs.public } : undefined)
: privateNuri;
const protectedScope: ShapeScope = MULTISTORE
? (readGraphs.protected.length ? { graphs: readGraphs.protected } : undefined)
: privateNuri;
// useShapeWithDefaults: show EMPTY data until NG populates (no seed defaults)
const emptyEvents: FpEventData[] = [];
const emptyUsers: FpUserData[] = [];
const emptyParticipations: FpParticipationData[] = [];
const eventsShape = useShapeWithDefaults(FpEventShapeType, privateNuri, emptyEvents, mapEvent, true);
const usersShape = useShapeWithDefaults(FpUserProfileShapeType, privateNuri, emptyUsers, mapUser, true);
const participationsShape = useShapeWithDefaults(FpParticipationShapeType, privateNuri, emptyParticipations, mapParticipation, true);
const eventsShape = useShapeWithDefaults(FpEventShapeType, publicScope, emptyEvents, mapEvent, true);
const usersShape = useShapeWithDefaults(FpUserProfileShapeType, protectedScope, emptyUsers, mapUser, true);
const participationsShape = useShapeWithDefaults(FpParticipationShapeType, protectedScope, emptyParticipations, mapParticipation, true);
const events = eventsShape.items;
const users = usersShape.items;
@@ -252,8 +308,9 @@ function useNgData(): FestipodDataContextValue {
// Auto-select first event when data appears from NG
useEffect(() => {
if (!selectedEventId && events.length > 0) {
setSelectedEventId(events[0].id);
const first = events[0];
if (!selectedEventId && first) {
setSelectedEventId(first.id);
}
}, [events.length, selectedEventId]);
@@ -264,6 +321,7 @@ function useNgData(): FestipodDataContextValue {
const hasTriedAutoSeed = useRef(false);
useEffect(() => {
if (process.env.NODE_ENV === 'production') return;
if (MULTISTORE) return; // seed targets the private store; multi-doc seeding is a separate concern
if (hasTriedAutoSeed.current) return;
if (!privateNuri) return;
const t = setTimeout(() => {
@@ -283,25 +341,51 @@ function useNgData(): FestipodDataContextValue {
}, [privateNuri, eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet]);
// --- Derived ---
const currentUser = users.find(u => u.username === '@mariedupont') || users[0];
// Resolve current user from the chosen account username (the perceived login);
// fall back to the legacy default while the account layer hydrates.
const currentUser =
(username ? users.find(u => normalizeUsername(u.username) === normalizeUsername(username)) : undefined)
|| users.find(u => u.username === '@mariedupont')
|| users[0];
const currentUserId = currentUser?.id || '';
const selectedEvent = events.find(e => e.id === selectedEventId);
const selectedUser = users.find(u => u.id === selectedUserId);
const queries = buildQueries(events, users, participations, meetingPoints, friendships, currentUserId);
// Isolation (staging realism): the app honors the matrix in connected mode —
// participations/connections narrowed to self + connections. See isolation.ts.
const isolated = applyIsolation(
{ events, users, participations, meetingPoints, friendships },
currentUserId,
);
const queries = buildQueries(
events, users, isolated.participations, meetingPoints, isolated.friendships, currentUserId,
);
console.log('[FestipodData] Render — NG | events:', events.length,
'| users:', users.length, '| participations:', participations.length,
'| selectedEvent:', selectedEvent?.title ?? '(none)');
// --- Mutations (NG) ---
// privateNuri is both the useShape scope AND the @graph for writes
const graph = privateNuri || '';
// Participations stay GROUPED in the account's protected index document.
// Mono-store mode collapses everything to the private store.
const protectedGraph = (MULTISTORE ? writeGraphs.protected : undefined) || privateNuri || '';
const createEvent = useCallback((event: Omit<FpEventData, 'id'>): FpEventData => {
const createEvent = useCallback(async (event: Omit<FpEventData, 'id'>): Promise<FpEventData> => {
console.log('[FestipodData] createEvent (NG):', event.title);
// Per-entity: in multistore each event is its OWN document. Mono-store: the
// private store. (Multistore create reactivity is best-effort — the new doc
// is appended to the read fan-out so it shows after re-subscribe.)
const eventGraph = MULTISTORE
? await createEntityDoc(username || '', 'public')
: (privateNuri || '');
if (MULTISTORE && eventGraph) {
setReadGraphs(prev =>
prev.public.includes(eventGraph) ? prev : { ...prev, public: [...prev.public, eventGraph] },
);
}
eventsShape.ngSet.add({
"@graph": graph, "@type": "http://festipod.org/Event", "@id": "",
"@graph": eventGraph, "@type": "http://festipod.org/Event", "@id": "",
title: event.title, description: event.description, date: event.date,
location: event.location, distance: event.distance,
participantCount: event.participantCount || 1,
@@ -310,13 +394,13 @@ function useNgData(): FestipodDataContextValue {
const addedEvent = [...eventsShape.ngSet].find(e => e.title === event.title);
if (addedEvent && currentUserId) {
participationsShape.ngSet.add({
"@graph": graph, "@type": "http://festipod.org/Participation", "@id": "",
"@graph": protectedGraph, "@type": "http://festipod.org/Participation", "@id": "",
event: addedEvent["@id"], user: currentUserId, isConfirmed: true,
} as FpParticipation);
setSelectedEventId(addedEvent["@id"]);
}
return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` };
}, [graph, eventsShape.ngSet, participationsShape.ngSet, currentUserId]);
}, [protectedGraph, eventsShape.ngSet, participationsShape.ngSet, currentUserId, privateNuri, username]);
const updateEvent = useCallback((id: string, updates: Partial<FpEventData>) => {
console.log('[FestipodData] updateEvent (NG):', id, updates);
@@ -340,14 +424,14 @@ function useNgData(): FestipodDataContextValue {
return;
}
participationsShape.ngSet.add({
"@graph": graph, "@type": "http://festipod.org/Participation", "@id": "",
"@graph": protectedGraph, "@type": "http://festipod.org/Participation", "@id": "",
event: eventId, user: uid, isConfirmed: true,
} as FpParticipation);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
if (ngEvent) {
ngEvent.participantCount = ngEvent.participantCount + 1;
}
}, [graph, participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
}, [protectedGraph, participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
const leaveEvent = useCallback((eventId: string, userId?: string) => {
const uid = userId || currentUserId;
@@ -401,7 +485,10 @@ function useNgData(): FestipodDataContextValue {
return {
currentUserId, currentUser,
events, users, participations, meetingPoints, friendships,
events, users,
participations: isolated.participations,
meetingPoints,
friendships: isolated.friendships,
selectedEventId, setSelectedEventId, selectedEvent,
selectedUserId, setSelectedUserId, selectedUser,
...queries,
+17
View File
@@ -55,6 +55,23 @@ export function NextGraphProvider({ children }: { children: ReactNode }) {
});
}, []);
// Un-stick the gate after a broker redirect that didn't complete (e.g. "no
// wallet": the user imports in another tab, comes back via the back button).
// The standalone page is restored from bfcache with status frozen on
// 'connecting' → "Entrer" stays disabled. Reset it so they can retry.
useEffect(() => {
if (isInsideBroker) return;
const onPageShow = (e: PageTransitionEvent) => {
if (e.persisted && !session) {
ngInitStarted = false;
setStatus('disconnected');
setError(undefined);
}
};
window.addEventListener('pageshow', onPageShow);
return () => window.removeEventListener('pageshow', onPageShow);
}, []);
// connect(): called by the user clicking "Se connecter".
// When outside the broker, initNgWeb() will redirect to the broker.
const connect = useCallback(() => {
+12 -5
View File
@@ -18,18 +18,25 @@ export interface ShapeWithDefaults<NgT extends BaseType, AppT> {
ngSet: DeepSignalSet<NgT>;
}
/**
* `scope` is either a single store/document NURI (mono-store mode) or a
* `{ graphs }` set of document NURIs (multi-document mode storeRegistry).
* `useShape` accepts both natively.
*/
export type ShapeScope = string | { graphs: string[] } | undefined;
export function useShapeWithDefaults<NgT extends BaseType, AppT>(
shapeType: ShapeType<NgT>,
storeNuri: string | undefined,
storeNuri: ShapeScope,
defaults: AppT[],
mapFromNg: (item: NgT) => AppT,
shapesReady: boolean,
): ShapeWithDefaults<NgT, AppT> {
// Use private store NURI as scope (like expense-tracker-rdf).
// This opens the store repo in the verifier, enabling writes.
const ngSet = useShape(shapeType, storeNuri) as DeepSignalSet<NgT>;
// Mono-store: a single store NURI opens the repo in the verifier (enables
// writes). Multi-document: a { graphs } scope subscribes to several docs.
const ngSet = useShape(shapeType, storeNuri as any) as DeepSignalSet<NgT>;
const usingDefaults = !shapesReady;
const items = usingDefaults ? defaults : [...ngSet].map(mapFromNg);
const items = usingDefaults ? defaults : [...ngSet].map(item => mapFromNg(item as unknown as NgT));
return { items, ngSet };
}
-1
View File
@@ -22,7 +22,6 @@ const screenNameMap: Record<string, string> = {
'profil': 'profile',
'profil utilisateur': 'user-profile',
'profil d\'un utilisateur': 'user-profile',
'connexion': 'login',
'paramètres': 'settings',
'réglages': 'settings',
'points de rencontre': 'meeting-points',
+140
View File
@@ -0,0 +1,140 @@
import type { Browser, BrowserContext, Page, Frame } from 'playwright';
/**
* Shared browser state for the BDD harness, owned by the lifecycle hooks
* (hooks.ts) and consumed per-scenario by the World (world.ts).
*
* The harness runs TWO kinds of browser:
*
* - the **wallet context** a single persistent Chromium profile that holds
* the shared NextGraph wallet (created once by ensureAuth). Legacy single-
* browser @data/@e2e scenarios run here, already logged-in.
*
* - **fresh contexts** ephemeral, fully isolated contexts spun up on demand
* from a non-persistent `freshBrowser`. Each has its own storage partition
* (its own localStorage, hence NO wallet). This is what lets a single
* scenario drive several browsers and test that a fresh browser can acquire
* the shared wallet (auto-import / textcode / QR / rendezvous).
*
* Extracting this into a module (rather than module-level `let`s in hooks.ts)
* gives both hooks.ts and world.ts a single, live source of truth without an
* import cycle.
*/
export interface BrowserPool {
/** Non-persistent launcher used to mint fresh isolated contexts. */
freshBrowser: Browser | null;
/** Persistent profile carrying the shared wallet (legacy single-browser path). */
walletContext: BrowserContext | null;
/** Local URL of the NG test harness (window.__testData), real-broker mode only. */
harnessUrl: string;
/** Local URL of the real app server, @e2e mode only. */
appUrl: string;
/** Top-level origin of the NextGraph broker (where the wallet localStorage lives). */
brokerOrigin: string;
/** True when the real broker + wallet are available (vs. mock fallback). */
useRealBroker: boolean;
/** Context-level permissions granted to every context (avoids prompts). */
permissions: string[];
/**
* Storage state captured once from the persistent wallet profile, injected
* into fresh contexts to provision the SHARED wallet across several browsers
* (test-level provisioning, distinct from the in-app auto-import). Null when
* capture failed / mock mode.
*/
sharedWalletState: Awaited<ReturnType<BrowserContext['storageState']>> | null;
/** Password of the e2e shared wallet file (festipod-e2e-tests) — for assertions. */
sharedWalletPassword: string;
/**
* Navigate a page through the NG broker to load `appUrl` in its iframe and
* return the app's Frame. Set by hooks.ts (closes over the broker login flow).
*/
setupBrokerPage: (page: Page, appUrl: string) => Promise<Frame>;
/** Finish the broker login once the page is already on the broker (post-redirect). */
completeBrokerLogin: (page: Page, appUrl: string, walletPassword?: string) => Promise<Frame>;
/**
* Drive the standalone nextgraph.eu "Import a Wallet File" flow on `page`:
* upload the .ngw file, unlock with `password`. The wallet FILE is the static,
* reusable assisted-import primitive (a TextCode is a transient 5-min transfer,
* unusable to embed). After this the page's context holds the wallet.
*/
importWalletViaFile: (page: Page, filePath: string, password: string) => Promise<void>;
/**
* Build (once) a STAGING bundle of the real app gate ON + the shared wallet
* TextCode baked in serve it statically, and return its URL. Used by the
* human-flow e2e to exercise the real AccessGateScreen. Memoised.
*/
ensureStagingApp: () => Promise<string>;
}
export const pool: BrowserPool = {
freshBrowser: null,
walletContext: null,
harnessUrl: '',
appUrl: '',
brokerOrigin: 'https://nextgraph.net',
useRealBroker: false,
permissions: [],
sharedWalletState: null,
sharedWalletPassword: '',
setupBrokerPage: async () => {
throw new Error('browserPool not initialized — did BeforeAll run?');
},
completeBrokerLogin: async () => {
throw new Error('browserPool not initialized — did BeforeAll run?');
},
importWalletViaFile: async () => {
throw new Error('browserPool not initialized — did BeforeAll run?');
},
ensureStagingApp: async () => {
throw new Error('browserPool not initialized — did BeforeAll run?');
},
};
/**
* Wallet model for a named browser an axis ORTHOGONAL to "how many browsers":
* - 'own' fresh isolated context, its own (or no) wallet distinct NG
* identity. The target model (each user their own wallet), usable
* for multi-browser tests of the future cross-wallet sharing.
* - 'shared' context pre-loaded with THE shared wallet (storageState
* injection) same NG identity across browsers. The current
* stopgap model.
*/
export type WalletModel = 'own' | 'shared';
/** A named browser participating in a multi-browser scenario. */
export interface NamedBrowser {
name: string;
wallet: WalletModel;
context: BrowserContext;
page: Page;
/** The app's iframe Frame once loaded through the broker (null until loaded). */
appFrame: Frame | null;
}
/**
* Mint a fresh, fully isolated browser context under the given wallet model.
* - 'own' empty storage partition (no wallet).
* - 'shared' seeded with the captured shared-wallet storageState.
* Throws if the fresh browser launcher is not available (mock mode), or if a
* 'shared' context is requested but the wallet state could not be captured.
*/
export async function spawnContext(wallet: WalletModel): Promise<BrowserContext> {
if (!pool.freshBrowser) {
throw new Error(
'Fresh browser not launched — multi-browser scenarios require real-broker mode.',
);
}
if (wallet === 'shared') {
if (!pool.sharedWalletState) {
throw new Error(
'Shared wallet storageState not captured — cannot provision a shared-wallet browser.',
);
}
return pool.freshBrowser.newContext({
permissions: pool.permissions,
storageState: pool.sharedWalletState,
});
}
return pool.freshBrowser.newContext({ permissions: pool.permissions });
}
+222 -28
View File
@@ -5,11 +5,29 @@ import * as http from 'http';
import * as fs from 'fs';
import * as path from 'path';
import type { FestipodWorld } from './world';
import { pool } from './browserPool';
setDefaultTimeout(90000);
let browser: Browser;
let browserContext: BrowserContext;
// Non-persistent launcher for fresh, isolated contexts (multi-browser scenarios).
let freshBrowser: Browser | null = null;
// Context-level permissions granted to every context (avoids prompts).
const CONTEXT_PERMISSIONS = ['notifications', 'clipboard-read', 'clipboard-write', 'geolocation'];
// Launch args: disable Private Network Access so the broker (nextgraph.eu) can
// load our local harness at http://127.0.0.1:{port} inside an iframe.
const LAUNCH_ARGS = [
'--disable-features=PrivateNetworkAccessRespectPreflightResults,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessForWorkers,PrivateNetworkAccessForNavigations',
'--allow-insecure-localhost',
'--disable-web-security',
];
// Full Chrome binary (not chrome-headless-shell) so localStorage persists.
function resolveChromePath(): string | undefined {
const p = chromium.executablePath().replace('chrome-headless-shell', 'chrome').replace('chromium_headless_shell', 'chromium');
return p.includes('headless') ? undefined : p;
}
// Harness paths
const HARNESS_ENTRY = 'src/shared/test-harness/harness.tsx';
@@ -27,9 +45,26 @@ let useRealBroker = false;
let appServerProcess: ChildProcess | null = null;
let appPort = 0;
// Human-flow e2e: a STAGING build of the app (gate ON + shared wallet baked in),
// served statically. Built lazily (only when the human-flow scenario runs).
const STAGING_OUTDIR = path.resolve('dist-staging');
let stagingServer: http.Server | null = null;
let stagingAppUrl = '';
const WALLET_NAME = 'festipod-tests';
const WALLET_PASSWORD = 'festipod-tests';
// The SHARED wallet for the assisted-import e2e: a static .ngw file placed at the
// worktree root + its password (identifier = password, per the e2e wallet setup).
const E2E_WALLET_PASSWORD = 'festipod-e2e-tests';
function findE2eWalletFile(): string {
const f = fs.readdirSync(process.cwd()).find((x) => x.endsWith('.ngw'));
if (!f) {
throw new Error('No .ngw wallet file at the worktree root — add the festipod-e2e-tests wallet file.');
}
return path.resolve(f);
}
/**
* Navigate through the NG broker to load an app in its iframe.
* Handles wallet login and returns the app's Frame.
@@ -37,25 +72,48 @@ const WALLET_PASSWORD = 'festipod-tests';
async function setupBrokerPage(page: Page, appUrl: string): Promise<Frame> {
const brokerRedirect = `https://nextgraph.net/redir/#/?o=${encodeURIComponent(appUrl)}`;
await page.goto(brokerRedirect, { waitUntil: 'domcontentloaded' });
return completeBrokerLogin(page, appUrl);
}
// Automate wallet login if needed
/**
* Finish the broker flow once the page is ALREADY on the broker (e.g. after the
* app's own "Entrer" button redirected there): pick the saved wallet and unlock
* it if a login is shown, then return the app's iframe Frame. Reused by
* setupBrokerPage (persistent wallet, festipod-tests) and by the human-flow e2e
* (freshly imported wallet pass its password).
*/
async function completeBrokerLogin(page: Page, appUrl: string, walletPassword: string = WALLET_PASSWORD): Promise<Frame> {
// Broker landing may show a "Login" button first → click it to reach the
// wallet-login page. (When the wallet session is already active, neither this
// nor the wallet link below appears, and we go straight to the app iframe.)
const loginButton = page.getByText('Login', { exact: true });
if (await loginButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await loginButton.click();
await page.waitForURL('**/wallet/login', { timeout: 5000 }).catch(() => {});
}
const walletLink = page.getByText('Click here to login with your wallet');
await walletLink.waitFor({ state: 'visible', timeout: 5000 });
// The broker redirect is multi-hop. Wait until EITHER the app iframe is already
// present (active wallet session) OR the wallet-login link appears (re-login
// needed — the session isn't persisted across browser launches).
const hasAppFrame = () => page.frames().some((f) => f.url().includes('127.0.0.1'));
const walletLink = page.getByText('Click here to login with your wallet', { exact: false });
const loginDeadline = Date.now() + 25000;
while (Date.now() < loginDeadline && !hasAppFrame() && !(await walletLink.isVisible().catch(() => false))) {
await page.waitForTimeout(500);
}
// On the wallet-login page ("Click here to login with your wallet <name>"):
// select the saved wallet and unlock it with its password.
if (!hasAppFrame() && await walletLink.isVisible().catch(() => false)) {
await walletLink.click();
await page.waitForTimeout(1000);
const passwordInput = page.locator('input[type="password"]');
await passwordInput.waitFor({ state: 'visible', timeout: 5000 });
await passwordInput.fill(WALLET_PASSWORD);
await passwordInput.press('Enter');
// Wait for login to complete and app iframe to load
await page.waitForTimeout(3000);
if (await passwordInput.isVisible({ timeout: 8000 }).catch(() => false)) {
await passwordInput.fill(walletPassword);
await passwordInput.press('Enter');
await page.waitForTimeout(3000);
}
}
// Verify iframe loaded after login
@@ -98,6 +156,74 @@ async function setupBrokerPage(page: Page, appUrl: string): Promise<Frame> {
return appFrame;
}
/**
* Drive the standalone nextgraph.eu "Import a Wallet File" flow: upload the .ngw
* file and unlock with the password. The wallet FILE is the STATIC, reusable
* assisted-import primitive (a TextCode is a transient 5-min device-to-device
* transfer unusable to embed; see knowledge_broker-import-constraint). After
* this, the page's context holds the wallet.
*/
async function importWalletViaFile(page: Page, filePath: string, password: string): Promise<void> {
await page.goto('https://nextgraph.eu/#/wallet/login', { waitUntil: 'domcontentloaded' });
// Let the SPA render and the file input attach before uploading (uploading too
// early yields an EncryptionError — the wallet doesn't load).
await page.waitForTimeout(3000);
await page.locator('input[type=file]').waitFor({ state: 'attached', timeout: 15000 });
await page.setInputFiles('input[type=file]', filePath);
// A password prompt appears to unlock the wallet ("Enter your password").
const passwordInput = page.locator('input[type=password]').first();
await passwordInput.waitFor({ state: 'visible', timeout: 15000 });
await passwordInput.fill(password);
await passwordInput.press('Enter');
const confirm = page.getByRole('button', { name: /Confirm/i });
if (await confirm.isVisible({ timeout: 2000 }).catch(() => false)) await confirm.click().catch(() => {});
await page.waitForTimeout(8000); // unlock + verifier bootstrap from the broker
}
/**
* Build (once) a STAGING bundle of the real app gate ON + the shared wallet
* FILE + password baked in and serve it statically. Returns its URL. Lets the
* human-flow e2e exercise the real AccessGateScreen (which only renders in a
* staging build). Memoised; the build is cheap (~100-300ms).
*/
async function ensureStagingApp(): Promise<string> {
if (stagingAppUrl) return stagingAppUrl;
// Build into a SEPARATE outdir so it never collides with the harness bundles.
// Gate is ON by default (no ACCESS_GATE_DISABLED). The build copies the .ngw to
// dist-staging/shared-wallet.ngw + bakes the password.
execSync('bun run build.ts --outdir=dist-staging', {
env: {
...process.env,
FESTIPOD_SHARED_WALLET_FILE: findE2eWalletFile(),
FESTIPOD_SHARED_WALLET_PASSWORD: E2E_WALLET_PASSWORD,
},
stdio: 'pipe',
});
const mime: Record<string, string> = {
'.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css',
'.svg': 'image/svg+xml', '.map': 'application/json', '.json': 'application/json',
'.ico': 'image/x-icon', '.png': 'image/png', '.woff2': 'font/woff2',
};
stagingServer = http.createServer((req, res) => {
const urlPath = (req.url || '/').split('?')[0]!;
let filePath = path.join(STAGING_OUTDIR, urlPath === '/' ? 'index.html' : urlPath);
if (!filePath.startsWith(STAGING_OUTDIR) || !fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
filePath = path.join(STAGING_OUTDIR, 'index.html'); // SPA fallback
}
res.writeHead(200, { 'Content-Type': mime[path.extname(filePath)] || 'application/octet-stream' });
fs.createReadStream(filePath).pipe(res);
});
const port = await new Promise<number>((resolve) => {
stagingServer!.listen(0, '127.0.0.1', () => resolve((stagingServer!.address() as { port: number }).port));
});
stagingAppUrl = `http://127.0.0.1:${port}`;
console.log(`[Staging] App (gate ON, wallet baked) on ${stagingAppUrl}`);
return stagingAppUrl;
}
/**
* Automated wallet creation + login on nextgraph.eu.
* Flow:
@@ -227,6 +353,11 @@ BeforeAll({ timeout: 10 * 60 * 1000 }, async function () {
if (req.url === '/harness.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
res.end(harnessBundle);
} else if (req.url?.startsWith('/blank')) {
// Minimal page on the harness origin (no NG stack) — used by
// multi-browser isolation checks that only need localStorage.
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end('<!DOCTYPE html><html><head><meta charset="utf-8"><title>blank</title></head><body><div id="root"></div></body></html>');
} else {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(harnessHtml);
@@ -239,23 +370,30 @@ BeforeAll({ timeout: 10 * 60 * 1000 }, async function () {
});
console.log(`[Harness] HTTP server on http://127.0.0.1:${harnessPort}`);
// Launch Chromium with the same persistent profile (has the wallet).
// - Use full Chrome binary (not chrome-headless-shell) so localStorage persists
// - Grant permissions to avoid prompts
// - Disable Private Network Access (broker at nextgraph.eu needs to load
// our local harness at http://127.0.0.1:{port} in an iframe)
const chromePath = chromium.executablePath().replace('chrome-headless-shell', 'chrome').replace('chromium_headless_shell', 'chromium');
// Launch Chromium with the persistent profile (has the shared wallet).
const chromeExe = resolveChromePath();
browserContext = await chromium.launchPersistentContext(PLAYWRIGHT_PROFILE, {
headless: true,
executablePath: chromePath.includes('headless') ? undefined : chromePath,
permissions: ['notifications', 'clipboard-read', 'clipboard-write', 'geolocation'],
args: [
'--disable-features=PrivateNetworkAccessRespectPreflightResults,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessForWorkers,PrivateNetworkAccessForNavigations',
'--allow-insecure-localhost',
'--disable-web-security',
],
executablePath: chromeExe,
permissions: CONTEXT_PERMISSIONS,
args: LAUNCH_ARGS,
});
console.log('[Hooks] Real broker mode ready');
// The persistent context drives @data/@e2e (which exercise the screens, not
// the access gate). Disable the gate there so the real app renders directly.
// Fresh contexts (@humain/@multibrowser) don't get this → gate ON by default.
await browserContext.addInitScript(() => {
(globalThis as Record<string, unknown>).__FESTIPOD_ACCESS_GATE_DISABLED__ = true;
});
// Launch a non-persistent browser to mint fresh, isolated contexts on
// demand — each with its own storage partition (no wallet). This is what
// lets a single scenario drive several browsers (multi-browser).
freshBrowser = await chromium.launch({
headless: true,
executablePath: chromeExe,
args: LAUNCH_ARGS,
});
console.log('[Hooks] Real broker mode ready (persistent wallet + fresh-context launcher)');
// Start real app server for @e2e tests
appPort = await new Promise<number>((resolve) => {
@@ -285,12 +423,52 @@ BeforeAll({ timeout: 10 * 60 * 1000 }, async function () {
check();
});
console.log(`[E2E] App server on http://127.0.0.1:${appPort}`);
// Publish the live harness state to the pool for the World to consume.
pool.freshBrowser = freshBrowser;
pool.walletContext = browserContext;
pool.harnessUrl = `http://127.0.0.1:${harnessPort}`;
pool.appUrl = `http://127.0.0.1:${appPort}`;
pool.useRealBroker = true;
pool.permissions = CONTEXT_PERMISSIONS;
pool.setupBrokerPage = setupBrokerPage;
pool.completeBrokerLogin = completeBrokerLogin;
pool.importWalletViaFile = importWalletViaFile;
pool.ensureStagingApp = ensureStagingApp;
pool.sharedWalletPassword = E2E_WALLET_PASSWORD;
// Warm up the persistent wallet profile through the broker, then capture its
// storage state. Injecting this into fresh contexts provisions the SHARED
// wallet across several browsers (shared-wallet multi-browser tests). This
// is test-level provisioning — distinct from the assisted import. The broker
// login can flake, so retry a couple of times before giving up.
for (let attempt = 1; attempt <= 3 && !pool.sharedWalletState; attempt++) {
const warmPage = await browserContext.newPage();
try {
await setupBrokerPage(warmPage, `http://127.0.0.1:${harnessPort}`);
const state = await browserContext.storageState();
// Require the broker origin (where the wallet lives) — else it's incomplete.
if (state.origins.some((o) => o.origin.includes('nextgraph'))) {
pool.sharedWalletState = state;
console.log(`[Hooks] Captured shared wallet storageState — origins: ${state.origins.map((o) => o.origin).join(', ')}`);
} else {
console.warn(`[Hooks] storageState capture attempt ${attempt}: no nextgraph origin yet, retrying`);
}
} catch (e) {
console.warn(`[Hooks] storageState capture attempt ${attempt} failed:`, (e as Error).message);
} finally {
await warmPage.close();
}
}
if (!pool.sharedWalletState) console.warn('[Hooks] Could not capture shared wallet storageState after 3 attempts');
} catch (err) {
console.warn(`[Hooks] NG harness build/auth failed, falling back to mock: ${err}`);
useRealBroker = false;
browser = await chromium.launch({ headless: true });
browserContext = await browser.newContext();
console.log('[Hooks] Mock mode (no broker)');
pool.useRealBroker = false;
pool.permissions = CONTEXT_PERMISSIONS;
console.log('[Hooks] Mock mode (no broker) — multi-browser scenarios unavailable');
}
});
@@ -304,9 +482,16 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
this.screenSourceContent = '';
this.currentScreen = null;
// Launch Playwright page for @data and @e2e scenarios
// Multi-browser scenarios drive their own isolated browsers via steps
// (this.openBrowser). They must NOT get the legacy single shared page.
const tags = scenario.pickle.tags.map(t => t.name);
const needsPlaywright = tags.includes('@data') || tags.includes('@e2e');
const multiBrowser = tags.includes('@multibrowser');
if (multiBrowser && !useRealBroker) {
throw new Error('@multibrowser scenarios require real broker mode (fresh-context launcher).');
}
// Launch a single Playwright page for legacy @data and @e2e scenarios.
const needsPlaywright = (tags.includes('@data') || tags.includes('@e2e')) && !multiBrowser;
if (needsPlaywright) {
this.page = await browserContext.newPage();
@@ -318,7 +503,7 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
});
}
if (tags.includes('@data')) {
if (tags.includes('@data') && !multiBrowser) {
if (useRealBroker) {
const harnessUrl = `http://127.0.0.1:${harnessPort}`;
this.appFrame = await setupBrokerPage(this.page!, harnessUrl);
@@ -341,7 +526,7 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
}
}
if (tags.includes('@e2e')) {
if (tags.includes('@e2e') && !multiBrowser) {
if (!useRealBroker || !appPort) {
throw new Error('@e2e scenarios require real broker mode (NG harness + app server)');
}
@@ -381,12 +566,16 @@ After({ timeout: 10000 }, async function (this: FestipodWorld, scenario) {
this.appFrame = null;
}
// Close any named browsers opened by multi-browser scenarios
await this.closeBrowsers();
// Clean up UI-layer
this.cleanup();
});
AfterAll(async function () {
if (browserContext) await browserContext.close();
if (freshBrowser) await freshBrowser.close();
if (browser) await browser.close();
if (harnessServer) {
await new Promise<void>((resolve) => harnessServer!.close(() => resolve()));
@@ -395,5 +584,10 @@ AfterAll(async function () {
appServerProcess.kill();
appServerProcess = null;
}
if (stagingServer) {
await new Promise<void>((resolve) => stagingServer!.close(() => resolve()));
stagingServer = null;
}
if (fs.existsSync(STAGING_OUTDIR)) await fs.promises.rm(STAGING_OUTDIR, { recursive: true, force: true });
console.log('Festipod BDD tests completed.');
});
+61 -10
View File
@@ -4,6 +4,7 @@ import type { Page, Frame } from 'playwright';
import * as fs from 'fs';
import * as path from 'path';
import { renderScreen as renderUiScreen, unmountRender } from '../test-harness/renderHelper';
import { pool, spawnContext, type NamedBrowser, type WalletModel } from './browserPool';
export interface FestipodWorld extends World {
currentRoute: string;
@@ -23,6 +24,14 @@ export interface FestipodWorld extends World {
page: Page | null;
appFrame: Frame | null;
// Multi-browser (named, isolated contexts) — for cross-browser wallet tests.
// The wallet model (own vs shared) is an axis orthogonal to browser count.
browsers: Map<string, NamedBrowser>;
openBrowser(name: string, wallet: WalletModel): Promise<NamedBrowser>;
browser(name: string): NamedBrowser;
loadAppInBrowser(name: string, target?: 'app' | 'harness'): Promise<NamedBrowser>;
closeBrowsers(): Promise<void>;
navigateTo(route: string): Promise<void>;
getFormField(name: string): { required: boolean; value: string } | undefined;
getCurrentScreenFields(): string[];
@@ -42,7 +51,6 @@ export interface FestipodWorld extends World {
// Map screen IDs to their source file paths (relative to project root)
const screenFileMap: Record<string, string> = {
'home': 'src/modules/home/screens/HomeScreen.tsx',
'login': 'src/modules/auth/screens/LoginScreen.tsx',
'profile': 'src/modules/user/screens/ProfileScreen.tsx',
'update-profile': 'src/modules/user/screens/UpdateProfileScreen.tsx',
'user-profile': 'src/modules/user/screens/UserProfileScreen.tsx',
@@ -129,11 +137,6 @@ export const screenExpectedContent: Record<string, string[]> = {
'Confidentialité',
'Localisation',
],
'login': [
'Email',
'Mot de passe',
'Se connecter',
],
'event-detail': [
'Participants',
'À propos',
@@ -191,10 +194,6 @@ export const screenRequiredFields: Record<string, string[]> = {
'Confidentialité',
'Rayon de notification',
],
'login': [
'Email',
'Mot de passe',
],
'event-detail': [
'Titre',
'Date',
@@ -240,10 +239,62 @@ class CustomWorld extends World implements FestipodWorld {
page: Page | null = null;
appFrame: Frame | null = null;
// Multi-browser (named, isolated contexts)
browsers: Map<string, NamedBrowser> = new Map();
constructor(options: IWorldOptions) {
super(options);
}
/**
* Open a fresh, fully isolated browser under `name` with the given wallet
* model ('own' = no wallet / its own; 'shared' = pre-loaded with THE shared
* wallet). The page is created but not navigated drive it via
* `this.browser(name).page` or load the app via `loadAppInBrowser(name)`.
*/
async openBrowser(name: string, wallet: WalletModel): Promise<NamedBrowser> {
if (this.browsers.has(name)) return this.browsers.get(name)!;
const context = await spawnContext(wallet);
const page = await context.newPage();
page.on('pageerror', (err) => console.error(`[Browser ${name} error]`, err.message));
page.on('console', (msg) => {
if (msg.type() === 'error') console.error(`[Browser ${name} console]`, msg.text());
});
const handle: NamedBrowser = { name, wallet, context, page, appFrame: null };
this.browsers.set(name, handle);
return handle;
}
browser(name: string): NamedBrowser {
const handle = this.browsers.get(name);
if (!handle) throw new Error(`Browser "${name}" not opened — call openBrowser("${name}") first.`);
return handle;
}
/**
* Navigate a named browser through the NG broker to load the app (or the NG
* test harness) in its iframe, recording the app Frame on the handle.
* NOTE: a fresh browser has no wallet, so the broker login can only succeed
* once the wallet-acquisition path (auto-import / textcode / ) is wired.
*/
async loadAppInBrowser(name: string, target: 'app' | 'harness' = 'app'): Promise<NamedBrowser> {
const handle = this.browser(name);
const url = target === 'harness' ? pool.harnessUrl : pool.appUrl;
handle.appFrame = await pool.setupBrokerPage(handle.page, url);
return handle;
}
async closeBrowsers(): Promise<void> {
for (const handle of this.browsers.values()) {
try {
await handle.context.close();
} catch {
// context may already be gone
}
}
this.browsers.clear();
}
async navigateTo(route: string): Promise<void> {
this.navigationHistory.push(route);
this.currentRoute = route;
+118 -1
View File
@@ -12,6 +12,7 @@ import { createRoot } from 'react-dom/client';
import { NextGraphProvider, useNextGraph } from '../context/NextGraphContext';
import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataContext';
import { useShape } from '@ng-org/orm/react';
import { ng } from '@ng-org/web';
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
import {
FpEventShapeType,
@@ -67,6 +68,11 @@ function ConnectedHarness() {
const participations = useShape(FpParticipationShapeType, privateNuri) as DeepSignalSet<FpParticipation>;
const [bridgeReady, setBridgeReady] = useState(false);
// Stopgap multi-store validation: a doc created on demand via doc_create,
// mounted into a real useShape({graphs}) by <SmokeProbe>.
const [smokeDoc, setSmokeDoc] = useState<string | null>(null);
// Per-entity fan-out validation: several entity docs read together.
const [fanoutGraphs, setFanoutGraphs] = useState<string[]>([]);
useEffect(() => {
// Small delay for useShape to populate
@@ -146,6 +152,54 @@ function ConnectedHarness() {
loadTestData() {
return bootstrapWallet(events as any, users as any, participations as any);
},
// --- Stopgap multi-store validation (see brief_2026-06-15_shared-wallet-shim) ---
/**
* Create a fresh graph document via doc_create and mount it into a real
* useShape({graphs}) subscription (<SmokeProbe>). Returns the NURI.
* Validates: doc_create returns a usable graph NURI.
*/
async createSmokeDoc() {
const nuri = await ng.doc_create(session.session_id, 'Graph', 'data:graph', 'store', undefined);
setSmokeDoc(nuri);
return nuri;
},
/**
* Round-trip the sharedWalletShim through the wallet: create an account
* (3 docs + SPARQL INSERT), drop the cache, reload from the wallet via
* SPARQL SELECT. Validates: doc_create ×3 + shim sparql_update/query.
*/
async validateShim(username: string) {
const reg = await import('../utils/storeRegistry');
reg.resetRegistryCache();
const created = await reg.ensureAccount(username);
reg.resetRegistryCache();
const reloaded = (await reg.allAccounts()).find(
a => a.username === username,
) ?? null;
return { created, reloaded };
},
/**
* Per-entity granularity + fan-out: 2 accounts, one event document each
* (via createEntityDoc indexed), then mount a multi-graph useShape over
* both (<FanoutProbe>). Returns the two doc NURIs and the index listing.
* Validates: 1-doc-per-entity, index append/read, fan-out across N docs.
*/
async setupFanout() {
const reg = await import('../utils/storeRegistry');
reg.resetRegistryCache();
await reg.ensureAccount('@fan-a');
await reg.ensureAccount('@fan-b');
const docA = await reg.createEntityDoc('@fan-a', 'public');
const docB = await reg.createEntityDoc('@fan-b', 'public');
reg.resetRegistryCache();
const listed = await reg.listEntityDocs('public');
setFanoutGraphs([docA, docB]);
return { docA, docB, listed };
},
};
console.log('[HarnessNG] Ready — events:', events.size, 'users:', users.size,
@@ -157,7 +211,70 @@ function ConnectedHarness() {
return () => clearTimeout(timer);
}, [events, users, participations, ngCtx, appData]);
return <div id="harness-status">{bridgeReady ? 'READY' : 'LOADING_SHAPES'}</div>;
return (
<>
<div id="harness-status">{bridgeReady ? 'READY' : 'LOADING_SHAPES'}</div>
{smokeDoc && <SmokeProbe docNuri={smokeDoc} />}
{fanoutGraphs.length > 0 && <FanoutProbe graphs={fanoutGraphs} />}
</>
);
}
// ============================================================================
// FanoutProbe — real useShape({graphs}) over SEVERAL entity documents.
// Exposes window.__fanout for the per-entity fan-out @data scenario.
// ============================================================================
function FanoutProbe({ graphs }: { graphs: string[] }) {
const set = useShape(FpEventShapeType, { graphs } as any) as DeepSignalSet<FpEvent>;
useEffect(() => {
(window as any).__fanout = {
ready: true,
graphs,
addEventTo(docNuri: string, title: string) {
set.add({
'@graph': docNuri,
'@type': 'http://festipod.org/Event',
'@id': '',
title,
participantCount: 1,
} as FpEvent);
},
count() { return set.size; },
titles() { return [...set].map(e => e.title); },
};
}, [set, graphs]);
return null;
}
// ============================================================================
// SmokeProbe — real useShape({graphs}) on a doc_create'd document.
// Exposes window.__smoke for the multi-store @data validation scenario.
// ============================================================================
function SmokeProbe({ docNuri }: { docNuri: string }) {
const set = useShape(FpParticipationShapeType, { graphs: [docNuri] } as any) as DeepSignalSet<FpParticipation>;
useEffect(() => {
(window as any).__smoke = {
ready: true,
docNuri,
add() {
set.add({
'@graph': docNuri,
'@type': 'http://festipod.org/Participation',
'@id': '',
event: 'urn:smoke:event',
user: 'urn:smoke:user',
isConfirmed: true,
} as FpParticipation);
},
count() { return set.size; },
items() {
return [...set].map(p => ({ '@id': p['@id'], event: p.event, user: p.user }));
},
};
}, [set, docNuri]);
return null;
}
// ============================================================================
+65
View File
@@ -0,0 +1,65 @@
/**
* isolation app-level enforcement of the authorization matrix.
*
* STOPGAP (see brief_2026-06-15_shared-wallet-shim): one shared wallet means
* everything is physically readable. To make staging *behave* like the target
* infra, the app HONORS the matrix by filtering reads by owner + connections:
*
* - public (events, meeting points) visible to everyone
* - protected (participations, connections) owner + connections
* - private (settings) owner only
*
* This is NOT crypto-enforced it's a deliberate, removable scaffold (the real
* crypto isolation arrives with per-user wallets). Applied in CONNECTED mode
* only; demo/@ui mode keeps full seed data.
*
* Pure functions no NextGraph, no React. Trivially testable.
*/
import type {
FpEventData,
FpUserData,
FpParticipationData,
FpMeetingPointData,
FpFriendshipData,
} from '../data/types';
export interface IsolatableData {
events: FpEventData[];
users: FpUserData[];
participations: FpParticipationData[];
meetingPoints: FpMeetingPointData[];
friendships: FpFriendshipData[];
}
/** The set the current user may see protected data for: self + direct connections. */
export function connectionIds(currentUserId: string, friendships: FpFriendshipData[]): Set<string> {
const set = new Set<string>([currentUserId]);
for (const f of friendships) {
if (f.userId === currentUserId) set.add(f.friendId);
else if (f.friendId === currentUserId) set.add(f.userId);
}
return set;
}
/**
* Narrow data to what `currentUserId` is allowed to see.
*
* - events / meeting points: untouched (public).
* - users: untouched names/avatars are referenced (denormalized) by public
* events and by visible participations; full profile-level isolation is a
* later refinement (matrix open question on host identity).
* - participations: only the user's own and their connections'.
* - friendships: only links involving the user or one of their connections.
*/
export function applyIsolation<T extends IsolatableData>(data: T, currentUserId: string): T {
// No identity yet → don't hide everything (e.g. during hydration).
if (!currentUserId) return data;
const visible = connectionIds(currentUserId, data.friendships);
return {
...data,
participations: data.participations.filter(p => visible.has(p.userId)),
friendships: data.friendships.filter(f => visible.has(f.userId) || visible.has(f.friendId)),
};
}
+19
View File
@@ -50,6 +50,25 @@ export async function login() {
await ng.login();
}
/**
* REAL NextGraph logout stops the session of the SHARED wallet.
*
* STOPGAP: must stay HIDDEN (Settings/debug only). The everyday "Déconnexion"
* is the FAUX one (AccountContext.logout, clears the username only). Calling
* this forces a new broker redirect on the next access see
* decision_2026-06-15_shared-wallet-login-flow.
*/
export async function logoutNg(): Promise<void> {
const userId = session && (session as Record<string, unknown>).user;
if (!userId) return;
try {
await (ng as unknown as { session_stop: (u: unknown) => Promise<void> }).session_stop(userId);
console.log('[NG session] session_stop done');
} catch (error) {
console.error('[NG session] logout error:', error);
}
}
export interface NextGraphSession {
ng: typeof NG;
session_id: string;
+273
View File
@@ -0,0 +1,273 @@
/**
* storeRegistry resolves (account, scope) document NURI.
*
* STOPGAP heart of the shared-wallet shim (see
* .project/concepts/nextgraph-platform/brief_2026-06-15_shared-wallet-shim.md).
*
* Everyone shares ONE wallet. To *mirror the target infra* (where each user
* has their own public/protected/private stores), we create one document per
* (account × scope) INSIDE the shared wallet, via `doc_create`. Because there
* is a single wallet and isolation is enforced in the app layer (not crypto),
* all these documents physically live in the shared wallet's private store
* the scope (public/protected/private) is a LOGICAL attribute we track here,
* not a physical NextGraph store.
*
* The mapping (account its 3 document NURIs) is the `sharedWalletShim`,
* persisted as RDF in the shared wallet's private store (the anchor, always
* known from the session). That makes login cross-device: another device
* opening the same wallet reads the same shim and finds the same accounts.
*
* MIGRATION: when real per-user wallets / cross-wallet reads land, only the
* resolver below changes (account, scope) maps to the user's REAL store
* NURI instead of a document in the shared wallet. Screens don't change.
*
* NOTE: the NextGraph runtime path (doc_create, SPARQL shim r/w) is built
* against the verified SDK surface but must be validated against a live broker.
*/
import { ng } from '@ng-org/web';
import { sessionPromise } from './ngSession';
import { normalizeUsername } from '../context/AccountContext';
export type Scope = 'public' | 'protected' | 'private';
/** Domain entity kinds and the scope (= future store) each one lives in. */
export type EntityKind = 'event' | 'meetingPoint' | 'profile' | 'profilePrivate' | 'participation' | 'connectionIndex';
/** Maps a domain entity to its scope, exactly per the authorization matrix. */
export function entityScope(kind: EntityKind): Scope {
switch (kind) {
case 'event': // declared by the user → their public store
case 'meetingPoint': // hosted by the user → their public store
return 'public';
case 'profile': // network profile → protected store
case 'participation': // participation → protected store
case 'connectionIndex': // connections index → protected store
return 'protected';
case 'profilePrivate': // settings, email → private store
return 'private';
}
}
// --- sharedWalletShim model ----------------------------------------------
export interface AccountRecord {
username: string;
docPublic: string;
docProtected: string;
docPrivate: string;
}
const SHIM = 'urn:festipod:shim';
const P = {
type: `${SHIM}:Account`,
username: `${SHIM}:username`,
docPublic: `${SHIM}:docPublic`,
docProtected: `${SHIM}:docProtected`,
docPrivate: `${SHIM}:docPrivate`,
contains: `${SHIM}:contains`, // index → entity document NURI
};
// Fixed subject of the per-(account×scope) index document. The index doc plays
// the role of the future store-container: it lists the NURIs of the entity
// documents (one per event/PdR) that live "in" that scope.
const INDEX_SUBJECT = `${SHIM}:index`;
function accountSubject(username: string): string {
return `${SHIM}:account:${normalizeUsername(username)}`;
}
// In-memory cache of the shim, keyed by normalized username.
let cache: Map<string, AccountRecord> | null = null;
/** The shim lives in the shared wallet's private store (always-known anchor). */
async function anchorNuri(): Promise<string> {
const session = await sessionPromise;
return `did:ng:${session.private_store_id}`;
}
/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */
function readBindings(result: unknown): Array<Record<string, { value: string }>> {
if (!result) return [];
const anyRes = result as any;
if (Array.isArray(anyRes)) return anyRes;
if (anyRes?.results?.bindings) return anyRes.results.bindings;
return [];
}
function bindingValue(row: Record<string, { value: string }>, key: string): string {
return row[key]?.value ?? '';
}
/** Load all accounts from the shim into the cache. */
export async function loadShim(): Promise<Map<string, AccountRecord>> {
if (cache) return cache;
const session = await sessionPromise;
const anchor = await anchorNuri();
const query = `
SELECT ?username ?docPublic ?docProtected ?docPrivate WHERE {
GRAPH <${anchor}> {
?acc a <${P.type}> ;
<${P.username}> ?username ;
<${P.docPublic}> ?docPublic ;
<${P.docProtected}> ?docProtected ;
<${P.docPrivate}> ?docPrivate .
}
}`;
const map = new Map<string, AccountRecord>();
try {
const result = await ng.sparql_query(session.session_id, query, undefined, anchor);
for (const row of readBindings(result)) {
const username = bindingValue(row, 'username');
if (!username) continue;
map.set(normalizeUsername(username), {
username,
docPublic: bindingValue(row, 'docPublic'),
docProtected: bindingValue(row, 'docProtected'),
docPrivate: bindingValue(row, 'docPrivate'),
});
}
} catch (error) {
console.error('[storeRegistry] loadShim failed:', error);
}
cache = map;
return map;
}
/** Create one graph document in the shared wallet (→ a NURI). */
async function createDoc(): Promise<string> {
const session = await sessionPromise;
// crdt="Graph" (RDF/SPARQL/ORM), class="data:graph", destination="store",
// store_repo=undefined → shared wallet's private store. (Verified SDK surface.)
const nuri = await (ng as unknown as {
doc_create: (s: unknown, crdt: string, cls: string, dest: string, store?: unknown) => Promise<string>;
}).doc_create(session.session_id, 'Graph', 'data:graph', 'store', undefined);
return nuri;
}
/**
* Ensure an account exists in the shim, creating its 3 scope documents on
* first sight. Idempotent returns the existing record if already present.
*/
export async function ensureAccount(username: string): Promise<AccountRecord> {
const map = await loadShim();
const key = normalizeUsername(username);
const existing = map.get(key);
if (existing) return existing;
const [docPublic, docProtected, docPrivate] = await Promise.all([
createDoc(),
createDoc(),
createDoc(),
]);
const record: AccountRecord = { username, docPublic, docProtected, docPrivate };
const session = await sessionPromise;
const anchor = await anchorNuri();
const subj = accountSubject(username);
const update = `
INSERT DATA {
GRAPH <${anchor}> {
<${subj}> a <${P.type}> ;
<${P.username}> "${username}" ;
<${P.docPublic}> "${docPublic}" ;
<${P.docProtected}> "${docProtected}" ;
<${P.docPrivate}> "${docPrivate}" .
}
}`;
try {
await ng.sparql_update(session.session_id, update, anchor);
} catch (error) {
console.error('[storeRegistry] ensureAccount persist failed:', error);
}
map.set(key, record);
return record;
}
/** The index document NURI of an account for a scope (the store-container). */
function indexDocOf(record: AccountRecord, scope: Scope): string {
return scope === 'public' ? record.docPublic
: scope === 'protected' ? record.docProtected
: record.docPrivate;
}
/**
* NURI of the document where `username` writes GROUPED entities of `scope`
* (e.g. participations, profile no per-entity document / no inbox needed).
* For per-entity scopes (events, PdR) use {@link createEntityDoc} instead.
*/
export async function resolveWriteGraph(username: string, scope: Scope): Promise<string> {
const record = await ensureAccount(username);
return indexDocOf(record, scope);
}
/**
* Create a dedicated document for ONE entity (event, PdR) mirrors the target,
* where each such entity is its own document/repo (addressable, future inbox).
* The new document's NURI is appended to the account's scope index document
* (the store-container). Returns the entity document NURI (use it as `@graph`).
*/
export async function createEntityDoc(username: string, scope: Scope): Promise<string> {
const record = await ensureAccount(username);
const indexDoc = indexDocOf(record, scope);
const entityNuri = await createDoc();
const session = await sessionPromise;
try {
await ng.sparql_update(
session.session_id,
`INSERT DATA { GRAPH <${indexDoc}> { <${INDEX_SUBJECT}> <${P.contains}> "${entityNuri}" } }`,
indexDoc,
);
} catch (error) {
console.error('[storeRegistry] createEntityDoc index append failed:', error);
}
return entityNuri;
}
/**
* Every entity document NURI of `scope`, across all accounts the read
* fan-out for per-entity scopes (events, PdR). Reads each account's scope index
* document and unions the contained NURIs. Use as `useShape(shape, { graphs })`.
*/
export async function listEntityDocs(scope: Scope): Promise<string[]> {
const accounts = await allAccounts();
const session = await sessionPromise;
const out: string[] = [];
for (const a of accounts) {
const indexDoc = indexDocOf(a, scope);
try {
const res = await ng.sparql_query(
session.session_id,
`SELECT ?e WHERE { GRAPH <${indexDoc}> { <${INDEX_SUBJECT}> <${P.contains}> ?e } }`,
undefined,
indexDoc,
);
for (const row of readBindings(res)) {
const v = bindingValue(row, 'e');
if (v) out.push(v);
}
} catch (error) {
console.error('[storeRegistry] listEntityDocs read failed:', error);
}
}
return out;
}
/** All known accounts (from the shim). */
export async function allAccounts(): Promise<AccountRecord[]> {
return [...(await loadShim()).values()];
}
/** NURIs of every account's document for `scope` (read fan-out). */
export async function resolveReadGraphs(scope: Scope): Promise<string[]> {
const accounts = await allAccounts();
return accounts.map(a =>
scope === 'public' ? a.docPublic
: scope === 'protected' ? a.docProtected
: a.docPrivate,
);
}
/** Reset cache (e.g. after switching the shared wallet). Mostly for tests. */
export function resetRegistryCache(): void {
cache = null;
}