diff --git a/.gitignore b/.gitignore index 73ce01a..e560329 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,5 @@ playwright/.auth/ *storybook.log storybook-static +dist-staging/ +*.ngw diff --git a/.project/concepts/bdd-testing/_overview.md b/.project/concepts/bdd-testing/_overview.md index f78bdea..43c9f93 100644 --- a/.project/concepts/bdd-testing/_overview.md +++ b/.project/concepts/bdd-testing/_overview.md @@ -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`) diff --git a/.project/concepts/bdd-testing/knowledge_multibrowser-harness.md b/.project/concepts/bdd-testing/knowledge_multibrowser-harness.md new file mode 100644 index 0000000..53d2323 --- /dev/null +++ b/.project/concepts/bdd-testing/knowledge_multibrowser-harness.md @@ -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`. 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. diff --git a/.project/concepts/nextgraph-platform/_overview.md b/.project/concepts/nextgraph-platform/_overview.md index e012781..c4f7acb 100644 --- a/.project/concepts/nextgraph-platform/_overview.md +++ b/.project/concepts/nextgraph-platform/_overview.md @@ -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) diff --git a/.project/concepts/nextgraph-platform/brief_2026-06-15_shared-wallet-shim.md b/.project/concepts/nextgraph-platform/brief_2026-06-15_shared-wallet-shim.md index a93b720..07cb1d9 100644 --- a/.project/concepts/nextgraph-platform/brief_2026-06-15_shared-wallet-shim.md +++ b/.project/concepts/nextgraph-platform/brief_2026-06-15_shared-wallet-shim.md @@ -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`) diff --git a/.project/concepts/nextgraph-platform/decision_2026-06-17_assisted-wallet-import.md b/.project/concepts/nextgraph-platform/decision_2026-06-17_assisted-wallet-import.md new file mode 100644 index 0000000..203ad56 --- /dev/null +++ b/.project/concepts/nextgraph-platform/decision_2026-06-17_assisted-wallet-import.md @@ -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= FESTIPOD_SHARED_WALLET_PASSWORD= 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 diff --git a/.project/concepts/nextgraph-platform/knowledge_broker-import-constraint.md b/.project/concepts/nextgraph-platform/knowledge_broker-import-constraint.md new file mode 100644 index 0000000..46e04c2 --- /dev/null +++ b/.project/concepts/nextgraph-platform/knowledge_broker-import-constraint.md @@ -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=`. 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 » → 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`. diff --git a/build.ts b/build.ts index edd61f3..7d7bb12 100644 --- a/build.ts +++ b/build.ts @@ -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 => ({ diff --git a/cucumber.json b/cucumber.json index eb4fb3a..3edcc78 100644 --- a/cucumber.json +++ b/cucumber.json @@ -12,6 +12,7 @@ "html:reports/cucumber-report.html" ], "language": "fr", + "tags": "not @wip", "formatOptions": { "snippetInterface": "async-await" }, diff --git a/src/app/App.tsx b/src/app/App.tsx index 0deebec..bd1ec53 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -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 ; - case 'login': return ; case 'home': return ; case 'events': return ; case 'create-event': return ; @@ -57,14 +57,18 @@ export function App() { return ( - - -
- - -
-
-
+ + + +
+ + + + +
+
+
+
); diff --git a/src/app/AuthGate.tsx b/src/app/AuthGate.tsx new file mode 100644 index 0000000..9eff32e --- /dev/null +++ b/src/app/AuthGate.tsx @@ -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 ; + } + + // 2. Perceived app login — until a username is chosen. + if (!username) { + return ; + } + + // 3. The app. + return <>{children}; +} diff --git a/src/app/router.tsx b/src/app/router.tsx index 5320454..e81a54f 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -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'; diff --git a/src/modules/auth/features/connexion-nextgraph.feature b/src/modules/auth/features/connexion-nextgraph.feature index f1c9959..bdedb5e 100644 --- a/src/modules/auth/features/connexion-nextgraph.feature +++ b/src/modules/auth/features/connexion-nextgraph.feature @@ -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" diff --git a/src/modules/auth/screens/AccessGateScreen.tsx b/src/modules/auth/screens/AccessGateScreen.tsx new file mode 100644 index 0000000..cb9dc8f --- /dev/null +++ b/src/modules/auth/screens/AccessGateScreen.tsx @@ -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 ( +
+
{n}
+
+ {title} + {children} +
+
+ ); +} + +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 = ( + + ); + + return ( +
+
+ Festipod + Espace de test + + {hasSharedWallet() ? ( + <> + + Première connexion sur cet appareil ?
Chargez le portefeuille partagé, une seule fois. +
+ + + + ⬇ Télécharger le portefeuille + + + + + + + Ouvrir la page d'import + {' '}(nouvel onglet) → « Import a Wallet File » → choisissez le fichier → mot de passe : + +
+ + {SHARED_WALLET_PASSWORD} + + +
+
+ + + {entrer} + + + ) : ( + entrer + )} + + {status === 'error' && ( + + {error || "Accès à l'environnement impossible. Réessayez."} + + )} +
+ + + Version beta + +
+ ); +} diff --git a/src/modules/auth/screens/ConnexionScreen.tsx b/src/modules/auth/screens/ConnexionScreen.tsx new file mode 100644 index 0000000..c9d48cf --- /dev/null +++ b/src/modules/auth/screens/ConnexionScreen.tsx @@ -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 ( +
+
+ Connexion + + Choisissez votre nom d'utilisateur + + +
+ ) => setValue(e.target.value)} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter') submit(); + }} + /> + +
+ + {users.length > 0 && ( + <> + + ou reprenez un compte existant + +
+ {users.map(u => ( + + ))} +
+ + )} +
+
+ ); +} diff --git a/src/modules/auth/screens/LoginScreen.stories.tsx b/src/modules/auth/screens/LoginScreen.stories.tsx deleted file mode 100644 index b701298..0000000 --- a/src/modules/auth/screens/LoginScreen.stories.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-webpack5'; -import { LoginScreen } from './LoginScreen'; -import { withProviders } from '../../../../.storybook/decorators'; - -const meta: Meta = { - title: 'Screens/Auth/LoginScreen', - component: LoginScreen, - decorators: [withProviders], -}; -export default meta; - -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/src/modules/auth/screens/LoginScreen.tsx b/src/modules/auth/screens/LoginScreen.tsx deleted file mode 100644 index 4504e83..0000000 --- a/src/modules/auth/screens/LoginScreen.tsx +++ /dev/null @@ -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 ( -
-
- Festipod - Créez et rejoignez des événements entre amis - - {/* NextGraph login */} -
- {status === 'connected' ? ( -
- - ✓ Connecté via NextGraph - - -
- ) : status === 'connecting' ? ( - - ) : ( -
- - {status === 'error' && ( - - NextGraph non disponible — mode démonstration - - )} -
- )} -
- - - - - ou connexion classique (démo) - - -
-
- Email - -
- -
- Mot de passe - -
- - - - - Mot de passe oublié ? - -
-
- - - Pas encore de compte ? S'inscrire - -
- ); -} diff --git a/src/modules/auth/screens/WelcomeScreen.tsx b/src/modules/auth/screens/WelcomeScreen.tsx index 1ac2241..6681f54 100644 --- a/src/modules/auth/screens/WelcomeScreen.tsx +++ b/src/modules/auth/screens/WelcomeScreen.tsx @@ -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 (
@@ -41,12 +51,12 @@ export function WelcomeScreen() {
- - Déjà membre ? navigate('/login')} style={{ color: '#E8590C', cursor: 'pointer', fontWeight: 600 }}>Connexion + Déjà membre ? navigate('/home')} style={{ color: '#E8590C', cursor: 'pointer', fontWeight: 600 }}>Connexion diff --git a/src/modules/auth/sharedWallet.ts b/src/modules/auth/sharedWallet.ts new file mode 100644 index 0000000..2fc8c84 --- /dev/null +++ b/src/modules/auth/sharedWallet.ts @@ -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; diff --git a/src/modules/auth/steps/e2e/connexion.steps.ts b/src/modules/auth/steps/e2e/connexion.steps.ts index 32e7bff..d02e7e3 100644 --- a/src/modules/auth/steps/e2e/connexion.steps.ts +++ b/src/modules/auth/steps/e2e/connexion.steps.ts @@ -13,7 +13,6 @@ import type { FestipodWorld } from '../../../../shared/support/world'; const SCREEN_MARKERS: Record = { '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'; diff --git a/src/modules/auth/steps/ui/connexion.steps.ts b/src/modules/auth/steps/ui/connexion.steps.ts deleted file mode 100644 index 8228360..0000000 --- a/src/modules/auth/steps/ui/connexion.steps.ts +++ /dev/null @@ -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; -}); diff --git a/src/modules/event/screens/CreateEventScreen.tsx b/src/modules/event/screens/CreateEventScreen.tsx index 5267d6f..a921496 100644 --- a/src/modules/event/screens/CreateEventScreen.tsx +++ b/src/modules/event/screens/CreateEventScreen.tsx @@ -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, diff --git a/src/modules/home/screens/SettingsScreen.tsx b/src/modules/home/screens/SettingsScreen.tsx index 51ec963..35eab45 100644 --- a/src/modules/home/screens/SettingsScreen.tsx +++ b/src/modules/home/screens/SettingsScreen.tsx @@ -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 (
- navigate('/login')}> + Se déconnecter + + {/* Stopgap escape hatch — real wallet logout, kept discreet. */} + + + Quitter l'environnement de test + +
diff --git a/src/modules/workshop/features/multibrowser-harness.feature b/src/modules/workshop/features/multibrowser-harness.feature new file mode 100644 index 0000000..97af721 --- /dev/null +++ b/src/modules/workshop/features/multibrowser-harness.feature @@ -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 diff --git a/src/modules/workshop/features/multistore-stopgap.feature b/src/modules/workshop/features/multistore-stopgap.feature new file mode 100644 index 0000000..627f889 --- /dev/null +++ b/src/modules/workshop/features/multistore-stopgap.feature @@ -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 diff --git a/src/modules/workshop/steps/data/multibrowser.steps.ts b/src/modules/workshop/steps/data/multibrowser.steps.ts new file mode 100644 index 0000000..0c04a17 --- /dev/null +++ b/src/modules/workshop/steps/data/multibrowser.steps.ts @@ -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); + }, +); diff --git a/src/modules/workshop/steps/data/multistore.steps.ts b/src/modules/workshop/steps/data/multistore.steps.ts new file mode 100644 index 0000000..5443a70 --- /dev/null +++ b/src/modules/workshop/steps/data/multistore.steps.ts @@ -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 ), 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 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); +}); diff --git a/src/screens/index.ts b/src/screens/index.ts index e63e478..b8d7eb1 100644 --- a/src/screens/index.ts +++ b/src/screens/index.ts @@ -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 }, diff --git a/src/shared/context/AccountContext.tsx b/src/shared/context/AccountContext.tsx new file mode 100644 index 0000000..eeea818 --- /dev/null +++ b/src/shared/context/AccountContext.tsx @@ -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({ + username: null, + login: () => {}, + logout: () => {}, +}); + +export function AccountProvider({ children }: { children: ReactNode }) { + const [username, setUsername] = useState(() => 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 ( + + {children} + + ); +} + +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(); +} diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index 4f17cec..f095772 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -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; + createEvent(event: Omit): Promise; updateEvent(id: string, updates: Partial): 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(empty ? '' : 'event-1'); const [selectedUserId, setSelectedUserId] = useState(''); @@ -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 => { + const createEvent = useCallback(async (event: Omit): Promise => { 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 => { + const createEvent = useCallback(async (event: Omit): Promise => { 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) => { 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, 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, diff --git a/src/shared/context/NextGraphContext.tsx b/src/shared/context/NextGraphContext.tsx index 6f109da..28e1f54 100644 --- a/src/shared/context/NextGraphContext.tsx +++ b/src/shared/context/NextGraphContext.tsx @@ -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(() => { diff --git a/src/shared/hooks/useShapeWithDefaults.ts b/src/shared/hooks/useShapeWithDefaults.ts index 9d43234..cf1667e 100644 --- a/src/shared/hooks/useShapeWithDefaults.ts +++ b/src/shared/hooks/useShapeWithDefaults.ts @@ -18,18 +18,25 @@ export interface ShapeWithDefaults { ngSet: DeepSignalSet; } +/** + * `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( shapeType: ShapeType, - storeNuri: string | undefined, + storeNuri: ShapeScope, defaults: AppT[], mapFromNg: (item: NgT) => AppT, shapesReady: boolean, ): ShapeWithDefaults { - // 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; + // 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; 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 }; } diff --git a/src/shared/steps/ui/navigation.steps.ts b/src/shared/steps/ui/navigation.steps.ts index f98c5ef..a4b2464 100644 --- a/src/shared/steps/ui/navigation.steps.ts +++ b/src/shared/steps/ui/navigation.steps.ts @@ -22,7 +22,6 @@ const screenNameMap: Record = { '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', diff --git a/src/shared/support/browserPool.ts b/src/shared/support/browserPool.ts new file mode 100644 index 0000000..9ed746b --- /dev/null +++ b/src/shared/support/browserPool.ts @@ -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> | 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; + /** Finish the broker login once the page is already on the broker (post-redirect). */ + completeBrokerLogin: (page: Page, appUrl: string, walletPassword?: string) => Promise; + /** + * 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; + /** + * 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; +} + +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 { + 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 }); +} diff --git a/src/shared/support/hooks.ts b/src/shared/support/hooks.ts index e870d76..65ac27a 100644 --- a/src/shared/support/hooks.ts +++ b/src/shared/support/hooks.ts @@ -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 { 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 { + // 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 "): + // 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 { 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 { + 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 { + 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 = { + '.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((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('blank
'); } 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).__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((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((resolve) => harnessServer!.close(() => resolve())); @@ -395,5 +584,10 @@ AfterAll(async function () { appServerProcess.kill(); appServerProcess = null; } + if (stagingServer) { + await new Promise((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.'); }); diff --git a/src/shared/support/world.ts b/src/shared/support/world.ts index e55b2a5..b98c49d 100644 --- a/src/shared/support/world.ts +++ b/src/shared/support/world.ts @@ -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; + openBrowser(name: string, wallet: WalletModel): Promise; + browser(name: string): NamedBrowser; + loadAppInBrowser(name: string, target?: 'app' | 'harness'): Promise; + closeBrowsers(): Promise; + navigateTo(route: string): Promise; 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 = { '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 = { 'Confidentialité', 'Localisation', ], - 'login': [ - 'Email', - 'Mot de passe', - 'Se connecter', - ], 'event-detail': [ 'Participants', 'À propos', @@ -191,10 +194,6 @@ export const screenRequiredFields: Record = { '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 = 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 { + 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 { + 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 { + 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 { this.navigationHistory.push(route); this.currentRoute = route; diff --git a/src/shared/test-harness/harness-ng.tsx b/src/shared/test-harness/harness-ng.tsx index dbb9b36..5b714b0 100644 --- a/src/shared/test-harness/harness-ng.tsx +++ b/src/shared/test-harness/harness-ng.tsx @@ -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; const [bridgeReady, setBridgeReady] = useState(false); + // Stopgap multi-store validation: a doc created on demand via doc_create, + // mounted into a real useShape({graphs}) by . + const [smokeDoc, setSmokeDoc] = useState(null); + // Per-entity fan-out validation: several entity docs read together. + const [fanoutGraphs, setFanoutGraphs] = useState([]); 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 (). 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 (). 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
{bridgeReady ? 'READY' : 'LOADING_SHAPES'}
; + return ( + <> +
{bridgeReady ? 'READY' : 'LOADING_SHAPES'}
+ {smokeDoc && } + {fanoutGraphs.length > 0 && } + + ); +} + +// ============================================================================ +// 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; + 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; + 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; } // ============================================================================ diff --git a/src/shared/utils/isolation.ts b/src/shared/utils/isolation.ts new file mode 100644 index 0000000..9e4c258 --- /dev/null +++ b/src/shared/utils/isolation.ts @@ -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 { + const set = new Set([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(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)), + }; +} diff --git a/src/shared/utils/ngSession.ts b/src/shared/utils/ngSession.ts index 7c134b9..0e1f6c9 100644 --- a/src/shared/utils/ngSession.ts +++ b/src/shared/utils/ngSession.ts @@ -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 { + const userId = session && (session as Record).user; + if (!userId) return; + try { + await (ng as unknown as { session_stop: (u: unknown) => Promise }).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; diff --git a/src/shared/utils/storeRegistry.ts b/src/shared/utils/storeRegistry.ts new file mode 100644 index 0000000..1b0ce43 --- /dev/null +++ b/src/shared/utils/storeRegistry.ts @@ -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 | null = null; + +/** The shim lives in the shared wallet's private store (always-known anchor). */ +async function anchorNuri(): Promise { + 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> { + 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, key: string): string { + return row[key]?.value ?? ''; +} + +/** Load all accounts from the shim into the cache. */ +export async function loadShim(): Promise> { + 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(); + 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 { + 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; + }).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 { + 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 { + 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 { + 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 { + 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 { + return [...(await loadShim()).values()]; +} + +/** NURIs of every account's document for `scope` (read fan-out). */ +export async function resolveReadGraphs(scope: Scope): Promise { + 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; +}