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