Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a21d9b0735 | |||
| f6fc3c262e | |||
| 62693667a8 | |||
| 82004a30b0 | |||
| 0958d70132 | |||
| c0fd69344b | |||
| 39b67feea0 | |||
| c7e924abe7 | |||
| 13da2d9e03 | |||
| 7302936502 | |||
| 4ffa055d62 | |||
| 0fc8479e22 | |||
| 7dab6e44e2 | |||
| 91ee3567aa | |||
| f366ee29a7 | |||
| 04a2de0b17 | |||
| 9e62bdea53 | |||
| 38266d96f8 | |||
| 2295af610a | |||
| 4c80ada3de | |||
| 517045c257 | |||
| c07150cb27 | |||
| 005c052bc6 | |||
| c869c56a17 | |||
| 65bd67cc20 | |||
| 0f164300f0 | |||
| 767a18e98c | |||
| 22487ed575 | |||
| cd2a45c254 | |||
| e62a17e5a2 | |||
| 4e96659bd7 | |||
| 84bc87d13c | |||
| af58667b4f | |||
| 01d65238ce | |||
| 6ceec5e161 | |||
| 3dfd549af3 | |||
| 17543f04c3 | |||
| 25b1c033d9 | |||
| e951eaaf96 | |||
| 0911b1f9de | |||
| 02cda056b8 | |||
| 8ca79c6d16 | |||
| 8bb19b687b | |||
| eafb4403b9 | |||
| 966ba9855c | |||
| 3ad06dfaec | |||
| 82c2cb5f27 | |||
| bc3d270bd4 | |||
| a436c3bd79 | |||
| 337a1e000d | |||
| 619b94ac0e | |||
| db9eb1cf47 | |||
| aabb2b77f7 | |||
| 83604cbc16 | |||
| aacc2ec3ee | |||
| 555c670b22 | |||
| 3f47ea886f | |||
| a54c119b4d | |||
| d69fd7a5f9 | |||
| 685f6d379d | |||
| c52e581e4f | |||
| 98c796054e | |||
| 073150ef61 | |||
| aec338441c | |||
| e270cc6063 | |||
| 9af128cb22 | |||
| 3ca2d10c49 |
@@ -43,3 +43,4 @@ playwright/.auth/
|
||||
storybook-static
|
||||
dist-staging/
|
||||
*.ngw
|
||||
.tasks/
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Doc-debt — app-architecture
|
||||
|
||||
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
|
||||
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
|
||||
|
||||
## Raw markers (consolidate into blocks, then delete)
|
||||
- TOUCHED src/shared/context/FestipodDataContext.tsx @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/auth/screens/AccessGateScreen.tsx @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/shared/context/AccountContext.tsx @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
@@ -30,9 +30,9 @@ Utilisé notamment par Storybook (voir concept `tech-stack`) pour parcourir les
|
||||
- **home/** : `welcome`, `home`, `settings`
|
||||
- **event/** : `events`, `event-detail`, `create-event`, `update-event`, `invite`, `participants-list`, `meeting-points`
|
||||
- **user/** : `profile`, `update-profile`, `user-profile`, `friends-list`, `share-profile`
|
||||
- **auth/** : `login`
|
||||
- **auth/** : `AccessGateScreen` — la **barrière d'accès** (login NextGraph + saisie de l'identifiant), rendue par `src/app/AuthGate.tsx`, **hors registre/routing** (ce n'est pas un écran routé). Les anciens `LoginScreen` puis `ConnexionScreen` ont été retirés (cf. concept `app-security`, [[knowledge_authentication]]).
|
||||
|
||||
> Le mapping path → écran est dans [[knowledge_routing]]. La plupart des écrans consomment `useFestipodData()` (concept `data-layer`) ; exceptions : `LoginScreen`/`WelcomeScreen`.
|
||||
> Le mapping path → écran est dans [[knowledge_routing]]. La plupart des écrans consomment `useFestipodData()` (concept `data-layer`) ; exceptions : `WelcomeScreen` et la barrière `AccessGateScreen`.
|
||||
|
||||
## Piège : registre incomplet
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Doc-debt — app-security
|
||||
|
||||
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
|
||||
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
|
||||
|
||||
## Raw markers (consolidate into blocks, then delete)
|
||||
- TOUCHED src/modules/auth/screens/AccessGateScreen.tsx @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/auth/steps/ui/barriere-acces.steps.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/auth/steps/data/connexion.steps.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
@@ -1,23 +1,21 @@
|
||||
---
|
||||
type: _overview
|
||||
summary: Sécurité & confidentialité de Festipod — posture ACTUELLE (mono-store, confiance broker, aucun contrôle d'accès côté app) et modèle d'autorisations CIBLE (incubation) ; authentification par wallet NextGraph
|
||||
summary: Sécurité & confidentialité de Festipod — l'isolation entre périmètres est assurée par le SDK de données, l'app lui fait confiance et ne porte aucune logique d'autorisation dans les écrans ; authentification par wallet ; matrice d'autorisations cible en incubation
|
||||
triggers:
|
||||
keywords: [sécurité, security, confidentialité, privacy, accès, "access control", contrôle d'accès, trust, confiance, authz, autorisation, permission, wallet, auth, authentification, anonyme, identité, login]
|
||||
keywords: [sécurité, security, confidentialité, privacy, accès, "access control", contrôle d'accès, trust, confiance, authz, autorisation, permission, wallet, auth, authentification, anonyme, identité, login, scope, isolation]
|
||||
paths: ["src/modules/auth/**", "src/shared/context/NextGraphContext.tsx"]
|
||||
---
|
||||
|
||||
# App security
|
||||
|
||||
Le modèle de **sécurité, confidentialité et autorisations** de Festipod. Le pilier se lit en deux temps :
|
||||
Le modèle de **sécurité, confidentialité et autorisations** de Festipod.
|
||||
|
||||
- **Actuel** — ce que le code applique aujourd'hui : voir [[knowledge_trust-model]]. Résumé brutal : **aucun contrôle d'accès côté app**, l'app affiche le `private_store` de l'utilisateur connecté et fait confiance au broker. Mono-user de fait.
|
||||
- **Cible** — le modèle d'autorisations dérivé (qui peut faire quoi, données personnelles = réseau, anonymat via inbox) : [[brief_2026-05-18_authorization-matrix]]. **Incubation, non implémenté.** Il graduera en `rule_`/`behavior_` quand le multi-user atterrira (chantiers data dans le concept `nextgraph-platform`).
|
||||
|
||||
L'écart entre les deux est volontaire : tant que l'app est mono-store (cf. concept `data-layer`), il n'y a rien à autoriser côté app.
|
||||
- **Modèle appliqué** — l'**isolation entre périmètres** (public / protected / private) est **assurée par le SDK de données** (`@ng-eventually/client`), qui n'expose à chaque utilisateur que ce à quoi il a droit. L'app **fait confiance** au SDK : aucun écran ne porte de logique d'autorisation. Voir [[knowledge_trust-model]].
|
||||
- **Matrice d'autorisations cible** — le détail *qui peut faire quoi* par acteur × verbe (données personnelles = réseau, anonymat via inbox de notification) : [[brief_2026-05-18_authorization-matrix]]. **Incubation.** Graduera en `rule_`/`behavior_` à mesure que le produit se cale.
|
||||
|
||||
## Liens
|
||||
|
||||
- [[knowledge_trust-model]] — posture de sécurité actuelle (mono-store, confiance broker, pas d'enforcement app)
|
||||
- [[knowledge_authentication]] — auth par wallet NextGraph, tous authentifiés, pas d'accès anonyme
|
||||
- [[brief_2026-05-18_authorization-matrix]] — modèle d'autorisations cible (incubation)
|
||||
- `nextgraph-platform` — les primitives (stores, capabilities, inbox) et les chantiers data qui porteront la cible
|
||||
- [[knowledge_trust-model]] — l'app délègue l'isolation au SDK, pas de contrôle d'accès dans les écrans
|
||||
- [[knowledge_authentication]] — auth par wallet, tous authentifiés, pas d'accès anonyme
|
||||
- [[brief_2026-05-18_authorization-matrix]] — matrice d'autorisations cible (incubation)
|
||||
- Concept `functional-domain` → [[knowledge_data-scopes-and-discovery]] — quel scope pour quelle entité (fait produit)
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
---
|
||||
type: brief
|
||||
summary: Matrice d'autorisations par type de donnée (PdR, inscription, événement, profil, connexion) ; dérive que 3 stores natifs par utilisateur + Dialog stores suffisent, aucun Group store sur le périmètre validé ; questions ouvertes sur modèle d'écriture événement et identité de l'hôte
|
||||
summary: Matrice d'autorisations cible par type de donnée (PdR, inscription, événement, profil, connexion) exprimée en scopes public/protected/private + dialog ; décisions cadre acquises (tous authentifiés, PdR publics, données personnelles = réseau, notification par inbox identifiée-ou-anonyme) ; questions ouvertes sur modèle d'écriture événement et identité de l'hôte
|
||||
last_updated: 2026-05-18
|
||||
---
|
||||
|
||||
# Matrice d'autorisations et inventaire des requêtes
|
||||
|
||||
**Status:** Incubating — analyse en cours
|
||||
**Last updated:** 2026-05-18
|
||||
**Status:** Incubating — modèle cible, non figé en règles.
|
||||
|
||||
## Context
|
||||
|
||||
Préalable au refactor multi-store ([[brief_2026-05-17_multi-store-refactor]]) et à toute évolution multi-user. La structure de stores NextGraph cible doit être *dérivée* de : (1) une matrice d'autorisations ; (2) un inventaire des requêtes par écran ; (3) les partitions naturelles qui en découlent (données partageant autorisations *et* schéma d'accès).
|
||||
|
||||
C'est aussi le **modèle de confidentialité/sécurité** de Festipod (pilier sécurité), non encore implémenté.
|
||||
Le modèle **cible** de qui-peut-quoi. La confidentialité de Festipod se dérive de : (1) une matrice d'autorisations par acteur × verbe ; (2) l'inventaire des requêtes par écran ; (3) les **périmètres** (scopes) qui en découlent — données partageant à la fois autorisation *et* schéma d'accès. Le placement concret entité → scope est un fait produit : concept `functional-domain` → [[knowledge_data-scopes-and-discovery]]. L'isolation est **assurée par le SDK de données** ([[knowledge_trust-model]]).
|
||||
|
||||
## Cadre
|
||||
|
||||
@@ -33,7 +30,7 @@ C'est aussi le **modèle de confidentialité/sécurité** de Festipod (pilier s
|
||||
- **Hôte = détenteur des droits d'écriture** sur un PdR (1 hôte, le créateur ; le fait d'être hôte est public).
|
||||
- **Informations personnelles = réservées au réseau.** Visibles seulement au titulaire et à ses connexions : participations, intégralité du profil, liste de connexions, et tout état déclaratif dont la divulgation serait une fuite. Statut « public » (PdR, événement) et « personnel » (profil, participations, connexions) coexistent dans le même utilisateur.
|
||||
- **Connexion bilatérale.** Existe après acceptation des deux côtés. Deux objets : `DemandeDeConnexion` (unilatérale, transitoire) et `Connexion` (bilatérale, persistante).
|
||||
- **Notification d'inscription via l'inbox NextGraph du PdR.** L'acte « s'inscrire » est composite : (a) écriture d'un objet `Inscription` dans le `protected_store` de l'inscrit, (b) dépôt d'un lien (DID cap) dans l'**inbox** du document PdR. Identification du sender par résolution du DID contre le graphe de connexions de l'hôte : connexion → inscription complète visible ; sinon → lien opaque (« quelqu'un (DID…) s'est inscrit »). Anonymat partiel **natif aux capabilities** (cf. [[knowledge_stores-permissions]] §Inbox).
|
||||
- **Notification d'inscription via l'inbox du PdR.** L'acte « s'inscrire » est composite : (a) écriture d'un objet `Inscription` dans le périmètre *protected* de l'inscrit, (b) dépôt d'un lien dans l'**inbox** du document PdR. L'expéditeur est **identifié si connexion de l'hôte, anonyme sinon** — propriété du modèle de données.
|
||||
- **Adhésion à une communauté / suivi : hors périmètre actuel.**
|
||||
|
||||
## Matrice par type de donnée
|
||||
@@ -63,7 +60,7 @@ Notes : pas de différenciation `C` (les connexions sont un filtre d'affichage U
|
||||
| modifier | ? **à trancher** (selon champs) | ✗ | ✗ | ✗ | ✗ |
|
||||
| supprimer | ✓ (se désinscrire ; retirer le lien de l'inbox si possible) | ✗ | cond : modération inbox seule (ne supprime pas l'objet) | ✗ | ✗ |
|
||||
|
||||
**Visibilité hôte : résolue** (identifiée si connecté, anonyme sinon — natif). **Questions ouvertes :** champs modifiables d'une inscription (booléen seul ou +commentaire/statut/accompagnants ?) ; **suppression côté inbox** — un déposant peut-il retirer son lien d'un doc qu'il ne contrôle pas ? (à vérifier au protocole).
|
||||
**Visibilité hôte : résolue** (identifiée si connecté, anonyme sinon). **Questions ouvertes :** champs modifiables d'une inscription (booléen seul ou +commentaire/statut/accompagnants ?) ; **suppression côté inbox** — un déposant peut-il retirer son lien d'un doc qu'il ne contrôle pas ?
|
||||
|
||||
### Événement
|
||||
|
||||
@@ -74,7 +71,7 @@ Notes : pas de différenciation `C` (les connexions sont un filtre d'affichage U
|
||||
| modifier | ? **à trancher** | ? **à trancher** | ? **à trancher** |
|
||||
| supprimer | ? **à trancher** | ✗ | ✗ |
|
||||
|
||||
**Questions ouvertes :** qui peut **modifier** un événement déclaré — déclarant seul (propriétaire) ? tout utilisateur (wiki) ? personne (immuable) ? Central pour la déduplication (cf. concept `functional-domain`, [[brief_2026-06-15_event-deduplication]] côté functional-domain). Qui peut **supprimer**, et que deviennent les PdR greffés (orphelins/cascade/marqué supprimé) ?
|
||||
**Questions ouvertes :** qui peut **modifier** un événement déclaré — déclarant seul (propriétaire) ? tout utilisateur (wiki) ? personne (immuable) ? Central pour la déduplication (concept `functional-domain`, [[brief_2026-06-15_event-deduplication]]). Qui peut **supprimer**, et que deviennent les PdR greffés (orphelins/cascade/marqué supprimé) ?
|
||||
|
||||
### Profil utilisateur
|
||||
|
||||
@@ -89,7 +86,7 @@ Notes : pas de différenciation `C` (les connexions sont un filtre d'affichage U
|
||||
| modifier | ✓ | ✗ | ✗ |
|
||||
| supprimer (compte) | ✓ | ✗ | ✗ |
|
||||
|
||||
**Tension à résoudre :** un PdR est lisible par tous, mais son hôte ne devrait pas être identifiable par un lambda. Trois positions : (i) **pseudonyme par DID seul** (nom/avatar résolus seulement aux connexions) ; (ii) **identité dénormalisée dans l'offre** (l'hôte choisit une « carte de visite » par PdR, vivant dans l'objet PdR, profil fermé) ; (iii) **anonymat de l'hôte** (identité révélée seulement aux connexions). À trancher. Autres : composition champ-par-champ de chaque périmètre ; statut du `username` (public/réseau/supprimé ?).
|
||||
**Tension à résoudre :** un PdR est lisible par tous, mais son hôte ne devrait pas être identifiable par un lambda. Trois positions : (i) **pseudonyme par identité seule** (nom/avatar résolus seulement aux connexions) ; (ii) **identité dénormalisée dans l'offre** (l'hôte choisit une « carte de visite » par PdR, vivant dans l'objet PdR, profil fermé) ; (iii) **anonymat de l'hôte** (identité révélée seulement aux connexions). À trancher. Autres : composition champ-par-champ de chaque périmètre ; statut du `username` (public/réseau/supprimé ?).
|
||||
|
||||
### Connexion (lien d'amitié)
|
||||
|
||||
@@ -105,31 +102,18 @@ Bilatérale. `DemandeDeConnexion` (unilatérale, en attente) → `Connexion` (bi
|
||||
|
||||
**Questions ouvertes :** granularité côté Bob (voit-il toute la liste d'Alice ou juste A↔B ? — conséquence du principe : toute la liste) ; découvrabilité « amis d'amis » (Alice voit-elle Bob↔Carole ? — non, sauf si Carole ∈ connexions(Alice)).
|
||||
|
||||
## Partitions naturelles dérivées
|
||||
## Périmètres dérivés
|
||||
|
||||
Heuristique : même store si (a) même cellule d'autorisation en écriture *et* (b) accédées ensemble. À partir des seuls points validés, **trois périmètres** émergent — qui correspondent **presque parfaitement aux 3 stores natifs**.
|
||||
Heuristique : même périmètre si (a) même cellule d'autorisation en écriture *et* (b) accédées ensemble. Trois **scopes** émergent, plus le cas bilatéral :
|
||||
|
||||
| Périmètre | Écriture | Lecture | Données validées |
|
||||
| Périmètre | Écriture | Lecture | Données |
|
||||
|---|---|---|---|
|
||||
| **Public** ↔ `public_store` | Alice seule | Tous | PdR hébergés par Alice ; événements déclarés *(sous réserve du modèle d'écriture)* |
|
||||
| **Réseau** ↔ `protected_store` | Alice seule | Alice + connexions | Profil réseau ; participations ; index des connexions |
|
||||
| **Privé** ↔ `private_store` | Alice seule | Alice seule | Profil privé (settings, email, préférences) |
|
||||
| **public** | Alice seule | Tous | PdR hébergés par Alice ; événements déclarés *(sous réserve du modèle d'écriture)* |
|
||||
| **protected** (réseau) | Alice seule | Alice + connexions | Profil réseau ; participations ; index des connexions |
|
||||
| **private** | Alice seule | Alice seule | Profil privé (settings, email, préférences) |
|
||||
| **dialog** (A↔B) | Alice et Bob | Alice et Bob | La `Connexion` bilatérale (+ matière à messagerie future) |
|
||||
|
||||
### Cas particulier : la Connexion bilatérale
|
||||
|
||||
Donnée à *deux* écrivains → ne tient dans aucun store individuel. Primitive native : le **Dialog store**. Modèle : **une `Connexion` A↔B = un Dialog store** (contient l'objet + matière à messagerie future) ; l'**index « toutes les connexions d'Alice »** vit dans le `protected_store` d'Alice (liste les NURIs des Dialog stores). La `DemandeDeConnexion` : soit dans un Dialog store provisoire, soit dans le `public_store` du destinataire (à trancher selon le SDK).
|
||||
|
||||
### Inbox du document PdR
|
||||
|
||||
Le doc PdR (dans le `public_store` de l'hôte) a une **inbox** native : reçoit les dépôts d'inscription (liens DID cap), plus tard commentaires/signaux. **Pas un store séparé**, attribut du document. Pas d'impact sur les partitions.
|
||||
|
||||
### Ce qui ne demande aucun Group store
|
||||
|
||||
Sur le périmètre validé, **aucune donnée ne demande de Group store**. Tout tient dans : 3 stores natifs par utilisateur + Dialog stores + inboxes natives. Les Group stores ne deviennent nécessaires que si le modèle d'écriture événement est « wiki », ou si communautés/suivi/collaboration multi-hôte reviennent dans le périmètre.
|
||||
|
||||
### Implication pour [[brief_2026-05-17_multi-store-refactor]]
|
||||
|
||||
Ce brief y propose une structure à 4 niveaux de Group stores. **Cette analyse dérive une structure différente** (3 stores natifs + Dialog, sans Group) parce que les concepts qui justifient les Group stores ont été mis hors périmètre. À reconcilier à l'exécution.
|
||||
La **`Connexion` bilatérale** a *deux* écrivains → périmètre **dialog** dédié à la paire ; l'**index « toutes les connexions d'Alice »** vit en *protected* (liste les références des connexions). L'**inbox du PdR** est un attribut du document public, pas un périmètre séparé.
|
||||
|
||||
## Inventaire des requêtes par écran
|
||||
|
||||
@@ -137,7 +121,6 @@ Ce brief y propose une structure à 4 niveaux de Group stores. **Cette analyse d
|
||||
|
||||
## See Also
|
||||
|
||||
- [[brief_2026-05-17_multi-store-refactor]] — consommateur principal
|
||||
- [[brief_2026-06-15_shared-wallet-shim]] — stopgap reprenant ces périmètres
|
||||
- `README.md §Modèle fonctionnel` / concept `functional-domain` — source des acteurs
|
||||
- Concept `data-layer` — état actuel mono-store
|
||||
- Concept `functional-domain` → [[knowledge_data-scopes-and-discovery]] — placement entité → scope + découverte
|
||||
- [[knowledge_trust-model]] — l'isolation est assurée par le SDK
|
||||
- `README.md §Modèle fonctionnel` — source des acteurs
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
type: decision
|
||||
summary: L'identifiant de l'espace virtuel se saisit à la barrière d'accès (AccessGateScreen), dans le même acte que l'ouverture du wallet ; l'écran de « login perçu » séparé (ConnexionScreen, « choisissez un nom d'utilisateur ») est retiré ; l'identifiant est un id technique normalisé en minuscules, pas un username Festipod
|
||||
---
|
||||
|
||||
# Décision (2026-07-06) : identifiant saisi à la barrière d'accès
|
||||
|
||||
## Contexte
|
||||
|
||||
Le flux stopgap de [[decision_2026-06-15_shared-wallet-login-flow]] enchaînait **deux
|
||||
écrans** : (1) `AccessGateScreen`, la barrière d'accès (vrai login NextGraph, ouverture du
|
||||
wallet partagé) ; (2) `ConnexionScreen`, un « login perçu » où l'utilisateur choisissait un
|
||||
**nom d'utilisateur**. Cette identité applicative était en réalité la clé du **wallet virtuel**
|
||||
(clé du compte shim / cap owner), pas un username produit — le cadrage « nom d'utilisateur »
|
||||
était donc trompeur (logique `setUsername` confuse).
|
||||
|
||||
## Décision
|
||||
|
||||
L'utilisateur saisit son **identifiant** directement dans `AccessGateScreen`, **dans le même
|
||||
acte** qui ouvre le wallet (« Entrer » enregistre l'identifiant puis déclenche `connect()`).
|
||||
`ConnexionScreen` est **supprimé**. L'identifiant :
|
||||
|
||||
- est un **id technique** qui nomme l'espace virtuel (un pseudo en pratique, **pas** un
|
||||
username Festipod) ;
|
||||
- est **normalisé** à la saisie (trim, `@` retiré, **minuscules**) et persisté avant la
|
||||
redirection broker (donc il survit au round-trip) ;
|
||||
- **est** l'id d'identité remis au SDK (`setCurrentUser`), et la clé des caps et du compte
|
||||
shim — plus de handle à casse mixte à réconcilier.
|
||||
|
||||
`AuthGate` affiche donc la barrière tant que le wallet n'est pas ouvert **ou** que l'identifiant
|
||||
n'est pas posé, puis l'app directement — sans écran intermédiaire.
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
- **Garder les deux écrans** : le second écran « nom d'utilisateur » perpétuait la confusion
|
||||
entre identité-produit et identifiant-de-wallet, et ajoutait une étape sans valeur.
|
||||
- **Dériver l'identifiant du wallet** (pas de saisie) : impossible ici — le wallet partagé est
|
||||
unique ; l'identifiant est précisément ce qui distingue les espaces virtuels au sein de ce
|
||||
wallet (émulation, cf. concept `data-layer` et le SDK `@ng-eventually/client`).
|
||||
|
||||
## Portée
|
||||
|
||||
Supersede la partie « écran 2 / login perçu » de [[decision_2026-06-15_shared-wallet-login-flow]]
|
||||
(l'ouverture du wallet partagé via broker reste inchangée). État courant du flux :
|
||||
[[knowledge_authentication]].
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
type: decision
|
||||
summary: Le wallet partagé est le SEUL mode de fonctionnement (le polyfill @ng-eventually/client en dépend comme backend de données) ; le repli « sans wallet partagé » est retiré — mauvaise config → écran d'erreur franc, plus de formulaire nu. Réaffirme que l'identifiant de la barrière = id du wallet/espace, distinct du username du profil.
|
||||
---
|
||||
|
||||
# Décision (2026-07-20) — le wallet partagé est l'unique mode ; identifiant ≠ username du profil
|
||||
|
||||
## Contexte
|
||||
|
||||
Régression observée : à l'ouverture, l'app tombait sur un **formulaire nu demandant un identifiant**, sans l'assistance de chargement du portefeuille. Cause : `FESTIPOD_SHARED_WALLET_PASSWORD` non défini dans l'environnement du serveur → `hasSharedWallet()` faux → `AccessGateScreen` basculait sur son mode replié. Or ce mode est une **impasse** : un appareil sans wallet ne peut pas se connecter une fois l'assistance d'import masquée. En parallèle, l'ancienne notion de « username » traînait encore pour désigner l'**identité du wallet**, ce qui la confondait avec le vrai username du profil.
|
||||
|
||||
## Décision
|
||||
|
||||
1. **Le wallet partagé est le seul mode supporté.** Festipod ne fonctionne pas sans lui — le polyfill `@ng-eventually/client` s'en sert comme backend de données (voir [[knowledge_authentication]], `rule_app-uses-sdk-surface-only`). `hasSharedWallet() === false` n'est donc **pas un mode fonctionnel** : c'est une **mauvaise configuration** → `AccessGateScreen` affiche un **écran d'erreur franc** (« Portefeuille partagé non configuré, définir `FESTIPOD_SHARED_WALLET_PASSWORD` »), jamais le formulaire nu en impasse.
|
||||
|
||||
2. **L'identifiant de la barrière ≠ le username du profil.** L'identifiant saisi à `AccessGateScreen` est l'**id technique du wallet/espace** (normalisé en minuscules, porté par le param d'URL `?id=`), pas un username. Le **username** est un concept distinct qui vit dans `UserProfile` (`@handle`, prédicat `http://festipod.org/username`). Le code et les tests ne doivent plus étiqueter l'identité du wallet « username/user » (renommé en `identifier`). Réaffirme et prolonge [[decision_2026-07-06_identifier-at-access-barrier]].
|
||||
|
||||
## Conséquences
|
||||
|
||||
- `AccessGateScreen` : rendu 3-branches (erreur config / flux d'import assisté quand non connecté / champ identifiant seul quand déjà connecté).
|
||||
- Renommage `username → identifier` de l'identité du wallet dans l'infra de test (`freshScenarioIdentifier`, `freshIdentifier`), `registration.ts`, `ngSession`, + commentaires ; **`UserProfile.username` intact** (profil, seed, affichage, SHEX).
|
||||
- `.env.example` ajouté à la racine pour rendre la config explicite (dont `FESTIPOD_SHARED_WALLET_PASSWORD`, `FESTIPOD_SHARED_WALLET_FILE`).
|
||||
|
||||
## Alternative écartée
|
||||
|
||||
Garder le repli sans-wallet comme futur « flux wallet-propre » : écarté **pour l'instant** — aucun flux wallet-propre à court terme, et le repli silencieux créait une impasse trompeuse. À réintroduire **explicitement** le jour où un mode wallet-propre (chaque utilisateur avec son propre wallet NextGraph) existera, hors stopgap.
|
||||
|
||||
## Liens
|
||||
|
||||
- Stopgap wallet partagé : `decision_2026-06-15_shared-wallet-login-flow` (référencé par `AccessGateScreen`/`AccountContext`).
|
||||
- [[decision_2026-07-06_identifier-at-access-barrier]] — l'identifiant à la barrière.
|
||||
- [[knowledge_authentication]], [[knowledge_trust-model]].
|
||||
@@ -1,20 +1,22 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Authentification = possession d'un wallet NextGraph ; tous les utilisateurs sont authentifiés (pas d'accès anonyme) ; l'auth passe par le redirect/iframe broker, et l'app n'auto-connecte que dans l'iframe
|
||||
summary: L'identité d'un utilisateur = son wallet NextGraph ; tous les utilisateurs sont authentifiés (pas d'accès anonyme) ; l'auth est déléguée au SDK, l'app n'a pas de comptes/mots de passe applicatifs
|
||||
---
|
||||
|
||||
# Authentification
|
||||
|
||||
**L'identité d'un utilisateur = son wallet NextGraph.** Il n'y a **pas d'accès anonyme** à l'app : tout utilisateur est authentifié (cf. concept `functional-domain`). Il n'y a pas de système de comptes/mots de passe applicatif — l'auth est déléguée à NextGraph.
|
||||
**L'identité d'un utilisateur = son wallet NextGraph.** Il n'y a **pas d'accès anonyme** à l'app : tout utilisateur est authentifié (cf. concept `functional-domain`). Il n'y a **pas de système de comptes/mots de passe applicatif** — l'authentification est **déléguée au SDK de données** (`@ng-eventually/client`) : ouvrir sa session, c'est ouvrir son wallet.
|
||||
|
||||
## Flux
|
||||
|
||||
- `LoginScreen` (`src/modules/auth/screens/`) déclenche la connexion via `useNextGraph()` (ne consomme pas `useFestipodData`).
|
||||
- Le flux standard `@ng-org/web` est un **redirect vers le broker** (`nextgraph.net/redir/`) qui recharge l'app dans une **iframe** après authentification (détail dans concept `nextgraph-platform`, [[knowledge_integration-model]] côté nextgraph-platform).
|
||||
- **L'app n'auto-connecte que dans l'iframe broker** (`window.self !== window.top`) — sinon `initNgWeb()` redirigerait toute la page. Cette règle vit côté data-layer ([[rule_conditional-ng-init]]) car elle concerne le cycle `NextGraphContext`, mais elle a une conséquence sécurité directe : **hors iframe, aucune session n'est ouverte sans action explicite** de l'utilisateur.
|
||||
- La **barrière d'accès** (`AccessGateScreen`, rendue par `src/app/AuthGate.tsx`) est le vrai login NextGraph : elle ouvre le wallet partagé via la redirection broker. **Dans le même acte**, l'utilisateur saisit un **identifiant** qui nomme son espace virtuel (`onEnter`). Il n'y a **plus d'écran « login perçu » séparé** (l'ancien `ConnexionScreen` « choisissez un nom d'utilisateur » a été retiré — cf. [[decision_2026-07-06_identifier-at-access-barrier]] ; supersede le flux à deux écrans de [[decision_2026-06-15_shared-wallet-login-flow]]).
|
||||
- Cet **identifiant est un id technique** (un pseudo en pratique, **pas** un username Festipod) : il est **normalisé** (trim, `@` retiré, **minuscules**) puis persisté (`AccountContext` → `IdentityStore`), donc un rechargement — ou un autre appareil rouvrant le même wallet partagé — retombe sur le même espace. C'est cet id qui est donné au SDK (`setCurrentUser`) et sur lequel les caps et le compte shim sont clés.
|
||||
- **Porté cross-frontière par un PARAM D'URL `?id=`** (source de vérité), PAS par localStorage. L'app tourne dans deux contextes — **top-level** (`127.0.0.1:3000` direct, `window.self === window.top`, où s'affiche la barrière) et **iframe** (embarquée sous `nextgraph.net` après le round-trip broker, `window.self !== window.top`). Le navigateur **partitionne le storage par site top-level** : le localStorage du top-level et celui de l'iframe sont **deux partitions distinctes** → localStorage NE PEUT PAS porter l'identité d'un contexte à l'autre (symptôme observé : deux valeurs divergentes selon le contexte). Le SDK redirige via `location.href = broker + encodeURIComponent(window.location.href)` (embarque l'URL app complète, query comprise, dans le `o=` rechargé en iframe), donc un **param d'URL traverse**. `AuthGate` écrit `?id=<identifiant>` (`history.replaceState`) **avant** `connect()` ; `AccountContext` résout l'identifiant par priorité **(1) `?id=` de l'URL** puis **(2) localStorage** (préremplissage/convenance same-partition uniquement). Clé localStorage : `festipod.account.identifier`.
|
||||
- **Saisi UNE SEULE FOIS au premier accès + prérempli au retour.** Au rechargement top-level, la session NG n'est pas restaurée d'office (`NextGraphContext` repart en `disconnected`) : `AuthGate` réaffiche la barrière tant que `status !== 'connected'`, mais le champ d'`AccessGateScreen` est **prérempli** (prop `initialIdentifier`) — jamais un champ nu et vide. Régressions gardées par `src/modules/auth/features/{barriere-acces-identifiant,identifiant-resolution}.feature` (@ui) — d'autant plus utiles que le flux de barrière est **désactivé** dans les tests @e2e (`__FESTIPOD_ACCESS_GATE_DISABLED__`), donc invisible à cette couche.
|
||||
- Une fois la session ouverte, l'utilisateur courant et son accès aux stores par scope sont fournis par `NextGraphContext`.
|
||||
|
||||
## Le wallet de test
|
||||
|
||||
Les tests `@data`/`@e2e` créent/ouvrent un wallet réel (`festipod-tests`/`festipod-tests`, profil persistant) — voir concept `bdd-testing`. Ce sont des **credentials de test en clair**, sans enjeu de sécurité, dédiés au staging (cohérent avec la posture « utilisateurs amicaux » du stopgap, concept `nextgraph-platform`).
|
||||
Les tests `@data`/`@e2e` ouvrent un wallet réel (`festipod-tests`, profil persistant) — voir concept `bdd-testing`. Ce sont des **credentials de test en clair**, sans enjeu de sécurité, dédiés au staging.
|
||||
|
||||
> Le modèle d'autorisations qui s'appuiera sur cette identité (connexions bilatérales, données personnelles = réseau, anonymat hôte) est en incubation : [[brief_2026-05-18_authorization-matrix]].
|
||||
> Le modèle d'autorisations qui s'appuiera sur cette identité (connexions bilatérales, données personnelles = réseau, anonymat de l'hôte) est en incubation : [[brief_2026-05-18_authorization-matrix]].
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Posture de sécurité actuelle — aucun contrôle d'accès côté app, l'app lit/affiche le private_store de l'utilisateur connecté et fait confiance au broker NextGraph pour ne retourner que des données autorisées ; mono-user de fait
|
||||
last_checked: 2026-06-15
|
||||
summary: L'isolation entre périmètres (public/protected/private) est assurée par le SDK de données ; l'app lui fait confiance et n'affiche que ce qu'il retourne — aucun contrôle d'accès dans les écrans, toute la confidentialité repose sur le SDK
|
||||
last_checked: 2026-07-06
|
||||
---
|
||||
|
||||
# Modèle de confiance actuel
|
||||
# Modèle de confiance
|
||||
|
||||
**Posture observée dans `src/shared/context/FestipodDataContext.tsx` (`useNgData`) :** l'app lit tout ce que les subscriptions ORM retournent depuis le `private_store` de l'utilisateur connecté et l'affiche **sans aucun filtre d'autorisation côté app**.
|
||||
**Posture :** l'app lit les données via les subscriptions ORM du SDK `@ng-eventually/client` et les affiche **sans logique d'autorisation côté app** (`src/shared/context/FestipodDataContext.tsx`, `useNgData`).
|
||||
|
||||
Conséquences (à connaître avant de raisonner sécurité) :
|
||||
Principes :
|
||||
|
||||
1. **Aucun contrôle d'accès applicatif.** Pas de vérification « l'utilisateur a-t-il le droit de voir cette donnée ». L'app suppose que **le broker/NextGraph ne retourne que ce que l'utilisateur peut voir**. Toute la confidentialité repose sur cette confiance dans la couche NextGraph, pas sur du code Festipod.
|
||||
2. **Mono-store, donc mono-user de fait.** Tout (events, profils, participations) vit dans le `private_store` de l'utilisateur connecté (cf. concept `data-layer`, [[decision_2026-03-17_private-store-nuri-scope]] côté data-layer). Un autre utilisateur ne voit rien — par construction, le `private_store` n'est pas partageable. Il n'y a donc rien à « autoriser » : chacun ne voit que ses propres données.
|
||||
3. **Pas de séparation de périmètres.** Le découpage public / réseau / privé du modèle cible ([[brief_2026-05-18_authorization-matrix]]) **n'existe pas encore** dans le code : aucun `protected_store`/`public_store` n'est utilisé pour le métier.
|
||||
1. **L'isolation est déléguée au SDK.** Chaque entité vit dans le store de son **scope** (public / protected / private, cf. concept `functional-domain` → [[knowledge_data-scopes-and-discovery]]) ; le SDK **n'expose à l'utilisateur courant que ce à quoi il a droit**. L'app suppose que ce qu'elle reçoit est déjà autorisé — la confidentialité repose sur le SDK, pas sur du code Festipod.
|
||||
2. **Les écrans ne portent aucune règle d'accès.** Pas de vérification « cet utilisateur a-t-il le droit de voir cette donnée » dans les composants ni dans le contexte de données. La séparation public / réseau / privé est une propriété du **placement par scope**, pas d'un filtre applicatif.
|
||||
3. **La relation entre utilisateurs (« connexions ») est une notion applicative, pas une primitive du SDK.** NextGraph n'a pas de primitive de connexion/amitié bilatérale ; côté SDK il n'existe qu'un **grant de lecture dirigé** vers une identité. L'app **possède** donc son graphe de relations (`src/shared/utils/connections.ts`) et le **traduit** en grants dirigés par document remis au SDK — elle ne délègue pas la notion de relation au SDK, seulement l'**application** de l'isolation qui en découle. Ce que l'app déclare au SDK reste minimal : **son identité** (l'identifiant, cf. [[knowledge_authentication]]) et **ces grants** ; elle ne porte toujours aucune logique d'accès dans les écrans.
|
||||
|
||||
## Le piège pour la suite
|
||||
## Le point de vigilance
|
||||
|
||||
Le jour où le multi-user arrive (lecture cross-wallet, voir les briefs de `nextgraph-platform`), cette **absence d'enforcement applicatif devient un risque** : si la séparation reste portée seulement par la crypto/capabilities NextGraph et que l'app continue d'afficher « tout ce qu'elle reçoit », une fuite de capability = une fuite de données. Le stopgap `shared-wallet-shim` (concept `nextgraph-platform`) prévoit d'ailleurs un **filtre d'isolation applicatif** explicite parce que, dans ce mode, un seul wallet rend tout physiquement lisible.
|
||||
Parce que l'app **affiche tout ce qu'elle reçoit**, la confidentialité tient entièrement à ce que le SDK n'expose que le légitime. C'est un choix assumé (l'app reste mince), mais il implique de **ne jamais réintroduire côté écran une donnée que le scope n'aurait pas dû laisser passer**.
|
||||
|
||||
> À vérifier si on doute : `useNgData` dans `FestipodDataContext.tsx` ne contient aucune branche de filtrage par identité ; les seuls IDs manipulés sont ceux du wallet courant.
|
||||
> À vérifier si on doute : `useNgData` dans `FestipodDataContext.tsx` ne contient aucune branche de filtrage par identité — c'est intentionnel, l'isolation vient d'en dessous.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Doc-debt — bdd-testing
|
||||
|
||||
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
|
||||
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
|
||||
|
||||
## Raw markers (consolidate into blocks, then delete)
|
||||
- TOUCHED src/modules/event/features/reconnexion-persistance-e2e.feature @2026-07-13 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/steps/e2e/reconnexion-persistance.steps.ts @2026-07-13 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/shared/test-harness/harness-ng.tsx @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/shared/test-harness/harness.tsx @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/features/reconnexion-socket-mort.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/steps/data/reconnexion-socket-mort.steps.ts @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/features/reconnexion-meme-identite.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/steps/data/reconnexion.steps.ts @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/features/reconnexion-froide-sans-local.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/steps/data/reconnexion-froide-sans-local.steps.ts @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/auth/steps/ui/barriere-acces.steps.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/shared/support/hooks.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/steps/data/isolation.steps.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/auth/steps/data/connexion.steps.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: Le wallet de test partagé (.playwright-profile) accumule des données à chaque run ; passé un seuil, les sparql_query ancrées au private store hangent (>15s) et toute la suite @data échoue au setup — repartir d'un profil frais restaure des lectures ~1s
|
||||
last_checked: 2026-07-06
|
||||
---
|
||||
|
||||
# Piège : le wallet de test se gonfle et fait *hang* les lectures @data
|
||||
|
||||
Le profil Chromium persistant `.playwright-profile` (racine du working tree) porte le **wallet
|
||||
partagé** ouvert par toute la suite `@data`/`@e2e`. Ce wallet **accumule des données à chaque
|
||||
run** : comptes shim (un par scénario, via l'identifiant frais `freshScenarioUsername`), docs
|
||||
d'entités seedés, dépôts d'inbox historiques… Le private store est le **point d'ancrage du shim**
|
||||
(résolution de compte) et est interrogé par **toute** lecture/écriture (`resolveAccount`,
|
||||
`listMyEntityDocs`, …).
|
||||
|
||||
**Symptôme.** Passé un certain volume (observé ~99 Mo de profil), une `sparql_query` **ancrée au
|
||||
private store** ne revient plus sous 15 s — elle *hang*. Comme la résolution de compte est sur le
|
||||
chemin de **chaque** read/write, **toute la suite @data échoue au setup** (0 événement chargé,
|
||||
timeouts), sans erreur explicite. Diagnostic vérifié : sur un wallet frais la même requête revient
|
||||
en **~1,5 s** et le seed complète normalement.
|
||||
|
||||
**Contournement.** Mettre le profil gonflé de côté et laisser le hook d'auth (beforeAll) en
|
||||
recréer un frais :
|
||||
|
||||
```bash
|
||||
mv .playwright-profile /tmp/festipod-bloated-$(date +%s)
|
||||
```
|
||||
|
||||
L'identifiant frais par scénario (`freshScenarioUsername`) borne le *registre* des comptes mais
|
||||
**pas** la croissance physique du private store partagé — d'où la récurrence. Une hygiène durable
|
||||
(purge périodique / wallet jetable par run) reste à mettre en place ; en attendant, si les
|
||||
`resolveAccount failed`/timeouts réapparaissent, repartir d'un profil frais.
|
||||
|
||||
> Le *pourquoi* côté broker (comment une requête ancrée touche le repo du private store) appartient
|
||||
> au SDK `@ng-eventually/client`, pas ici — ce caveat ne décrit que la conséquence côté tests.
|
||||
@@ -23,11 +23,11 @@ Steps **partagés** (cross-domaine) dans `src/shared/steps/ui/` :
|
||||
|
||||
Les noms français des écrans (`"accueil"`, `"détail événement"`, `"mon profil"`…) mappent vers les IDs d'écran via `screenNameMap`.
|
||||
|
||||
Tags de scénario : `@ui` / `@data` / `@e2e` (couche) + **`@wip`** pour un scénario dont les steps ne sont pas encore implémentés. Un `Contexte` (Background) fréquent — « Étant donné que je suis connecté » — ne fait que poser un flag `isAuthenticated`, pas d'auth réelle en `@ui`.
|
||||
Tags de scénario : `@ui` / `@data` / `@e2e` (couche) + **`@wip`** pour un scénario dont les steps ne sont pas encore implémentés **ou dont le comportement applicatif n'est pas encore fiable** (usage : marquer un attendu réel qui échoue à cause d'un bug produit, pas un test obsolète — ex. historique : la désinscription qui ne se reflétait pas dans l'UI, `@wip` **levé** depuis sa résolution T02.c, cf [[caveat_participation-deletion]]). **`@wip` est EXCLU du run par défaut** (`cucumber.json: "tags": "not @wip"`) : ces scénarios documentent un attendu sans casser la suite ; retirer le `@wip` quand c'est fiable. Un `Contexte` (Background) fréquent — « Étant donné que je suis connecté » — ne fait que poser un flag `isAuthenticated`, pas d'auth réelle en `@ui`.
|
||||
|
||||
## Config
|
||||
|
||||
`cucumber.json` : `import` de `src/shared/support/**`, `src/shared/steps/**`, `src/modules/*/steps/**` ; `paths` = `src/modules/*/features/**`; `language: fr`. **Runner = Node + tsx** (`node --import tsx/esm node_modules/.bin/cucumber-js`), pas Bun — les plugins (Playwright, happy-dom) ne chargent pas en import Bun natif. Ne pas « bunifier » `cucumber:run`/`test:data`.
|
||||
`cucumber.json` : `import` de `src/shared/support/**`, `src/shared/steps/**`, `src/modules/*/steps/**` ; `paths` = `src/modules/*/features/**`; `tags: "not @wip"` (exclut les scénarios WIP) ; `language: fr`. **Runner = Node + tsx** (`node --import tsx/esm node_modules/.bin/cucumber-js`), pas Bun — les plugins (Playwright, happy-dom) ne chargent pas en import Bun natif. Ne pas « bunifier » `cucumber:run`/`test:data`.
|
||||
|
||||
## Le harness de test est buildé à la demande
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Couche @data — Playwright pilote Chromium (profil persistant) qui s'authentifie au broker NextGraph réel chargeant harness-ng.tsx en iframe ; cycle de vie wallet automatisé (création + login bootstrap), bridge window.__testData, fallback mock
|
||||
last_checked: 2026-07-05
|
||||
---
|
||||
|
||||
# Couche `@data` (broker réel)
|
||||
@@ -21,7 +22,7 @@ Cucumber → Playwright (Chromium, profil persistant)
|
||||
|
||||
## Cycle de vie du wallet (automatisé, CI-ready)
|
||||
|
||||
- **Premier run** : pas de marker `.wallet-ready` → Chromium headless crée le wallet (`nextgraph.eu` → Create Wallet → ToS sur `account.nextgraph.eu` → username/password → submit), **puis se logge** — ce login déclenche le bootstrap du verifier depuis le broker distant (peuple `self.repos`, sauvé en localStorage). **Sans ce login initial, toutes les écritures échoueraient en `RepoNotFound`.** Marker écrit.
|
||||
- **Premier run** : pas de marker `.wallet-ready` → Chromium headless crée le wallet (`nextgraph.eu` → Create Wallet → ToS sur `account.nextgraph.eu` → username/password → submit), **puis se logge** — ce login initial est requis pour amorcer la session (sauvé en localStorage) ; sans lui, les écritures ne passeraient pas. Marker écrit.
|
||||
- **Runs suivants** : marker trouvé → login automatisé (click Login → wallet → password → submit) → harness en iframe → `window.__testData.ready`.
|
||||
- Credentials wallet : `festipod-tests` / `festipod-tests`.
|
||||
|
||||
@@ -32,5 +33,34 @@ Cucumber → Playwright (Chromium, profil persistant)
|
||||
- **Flags Chromium** (`--disable-web-security`, `--allow-insecure-localhost`, désactivation de Private Network Access) : nécessaires car le broker public charge un harness `http://127.0.0.1` en iframe.
|
||||
- **Profil persistant** `.playwright-profile/` (gitignored, wallet en localStorage) — exige le vrai binaire Chrome, pas `chrome-headless-shell`.
|
||||
- **Serveur HTTP** lancé en `BeforeAll` (port auto), sert le HTML + `/harness.js` (fichiers séparés — le script inline casse à cause de caractères spéciaux du bundle).
|
||||
- **Subscriptions ORM** : les 3 shapes avec scope `did:ng:${session.private_store_id}` (cf. concept `data-layer`).
|
||||
- **Bridge `window.__testData`** : `events`/`users`/`participations` (sets live), `currentUserId`, lookups (`getEvent`, `getEventByTitle`), mutations (`joinEvent`, `leaveEvent`, `updateEvent`), requêtes (`isParticipating`, `getEventParticipants`).
|
||||
- **Bridge = le vrai chemin app (per-entité).** Depuis le passage à *un document par entité*
|
||||
(concept `data-layer`, [[rule_document-per-entity]]), le bridge `window.__testData`
|
||||
(`events`/`users`/`participations`, `joinEvent`/`leaveEvent`/`isParticipating`/
|
||||
`getEventParticipants`, `loadTestData`) **délègue au contexte de données de l'app**
|
||||
(`appData` via `FestipodDataProvider`) — c'est le chemin per-entité réel des écrans, pas une
|
||||
lecture au niveau du store-racine. Le harness monte donc l'**`AccountProvider`** et se logge
|
||||
par défaut (`@mariedupont`) pour établir l'identité courante (sans quoi le filtre ReadCap ne
|
||||
laisserait passer que le public). Il lit `appData` via une **ref vivante** (un snapshot capturé
|
||||
devient périmé après un re-rendu de seed).
|
||||
- Chemins probes de bas niveau conservés (scope store-racine `protectedNuri`) pour les
|
||||
scénarios ReadCap/isolation qui *gouvernent* ce document : `rawJoin`/`rawParticipations`,
|
||||
`governDocument`/`governProtected`/`documentNuri`, `FilterProbe`/`FanoutProbe`.
|
||||
- **Identité avant écriture.** Une `Participation` a un `fp:user` obligatoire ; comme la lecture
|
||||
du profil peut retarder derrière les events publics, les steps attendent
|
||||
`ensureCurrentUser()` avant `joinEvent` (sinon participation écrite sans user → jetée en
|
||||
lecture, ne fait jamais l'aller-retour) et attendent (`waitForFunction`) que la participation
|
||||
soit relue.
|
||||
- **Caveat wallet persistant + isolation par scénario (T03.j)** : le wallet partagé **accumule**
|
||||
le registre de comptes émulé et les docs per-entité à chaque scénario/run. Le fan-out de lecture
|
||||
(`listEntityDocs` = `allAccounts()` → 1 SELECT/compte) parcourt tous les docs de tous les comptes
|
||||
→ ralentit et fait *timeouter* les steps quand le wallet est pollué. Ce registre vit **côté
|
||||
broker** : supprimer `.playwright-profile/` ne le nettoie PAS (re-sync depuis le broker) et force
|
||||
une re-auth lente — mauvais levier. À la place, le `Before` @data appelle
|
||||
`window.__testData.resetDataState()` : **UN** SPARQL DELETE sur le graphe ancre (private-store)
|
||||
qui efface tous les records `urn:ng-eventually:shim:Account` → `allAccounts()` s'effondre à vide →
|
||||
le fan-out se **borne** à ce que le scénario courant reprovisionne (comptes recréés paresseusement
|
||||
par `ensureAccount`). O(1) sur UN graphe — **pas** un delete en fan-out (qui saturait le navigateur,
|
||||
cf. T03.i `authClearParticipation` retiré). Borné à ≤10s (`Promise.race`) pour ne pas disputer le
|
||||
budget 60s du `Before` (login broker déjà lent). Infra de test uniquement — ne touche ni la lib ni
|
||||
le modèle produit ni le chemin de lecture applicatif. Le seed connecté reste **allégé** (peu de
|
||||
docs) car chaque `docCreate` est un aller-retour broker sériel ~2s.
|
||||
|
||||
@@ -42,6 +42,26 @@ Navigation : `window.history.pushState` + dispatch `popstate` (routing path-base
|
||||
|
||||
> **Ne pas re-vérifier en `@e2e` ce que `@ui` couvre déjà** — `@e2e` doit casser quand la *collaboration* entre couches casse, pas quand une icône change (cf. [[rule_test-layer-contracts]]).
|
||||
|
||||
## Smoke `@smoke` — garde la classe « page blanche une fois connecté »
|
||||
|
||||
`@e2e @smoke` (`src/modules/home/features/accueil-connecte-rend.feature`) garde une
|
||||
CLASSE de régression : un crash de rendu qui ne survient QUE une fois l'app connectée
|
||||
et montée sur des données réelles (symptôme : seul le bandeau de l'iframe broker
|
||||
s'affiche, `#root` reste vide). Le smoke réutilise le boot du hook `Before` @e2e,
|
||||
navigue vers l'accueil connecté et asserte DEUX choses :
|
||||
1. **HomeScreen a réellement monté** — présence de marqueurs forts (`.app-navbar` +
|
||||
bouton `[aria-label="Relayer un événement"]`), absents d'un spinner / du bandeau
|
||||
broker. Un `throw` dans un composant/provider monté après connexion démonte l'arbre
|
||||
(aucun `ErrorBoundary`) → ces marqueurs disparaissent → rouge.
|
||||
2. **Zéro erreur runtime** — `this.pageErrors` (voir ci-dessous) doit être vide.
|
||||
|
||||
Le hook `Before` @e2e **collecte** désormais dans le World les `pageerror` +
|
||||
`console.error` de la page app (champ `pageErrors`, réinitialisé par scénario) — c'est
|
||||
ce qui rend l'assertion « pas d'erreur » possible. Le run par défaut de `bun run
|
||||
validate` exécute `@smoke and not @wip` (pas tout `@e2e`, pour rester rapide).
|
||||
**Preuve de détection** : un `throw` en tête de `HomeScreen` fait virer le smoke au
|
||||
rouge ; sans lui, vert.
|
||||
|
||||
## Fichiers clés
|
||||
|
||||
`src/shared/support/hooks.ts` (lifecycle Playwright), `world.ts` (champs `page`/`appFrame`), `scripts/debug-browser.ts` (debug headed), `.playwright-profile{,-debug}/` (gitignored).
|
||||
`src/shared/support/hooks.ts` (lifecycle Playwright + collecte `pageErrors`), `world.ts` (champs `page`/`appFrame`/`pageErrors`), `scripts/debug-browser.ts` (debug headed), `.playwright-profile{,-debug}/` (gitignored).
|
||||
|
||||
@@ -6,7 +6,7 @@ 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).
|
||||
Capacité du harness `@data`/`@e2e` à piloter **plusieurs navigateurs isolés** dans un même scénario, sous **deux axes orthogonaux**. Permet de tester à la fois le modèle « chacun son wallet » (`@private-wallet`) et le modèle « wallet partagé entre navigateurs » (`@shared-wallet`).
|
||||
|
||||
## Les deux axes (orthogonaux)
|
||||
|
||||
@@ -15,7 +15,7 @@ Capacité du harness `@data`/`@e2e` à piloter **plusieurs navigateurs isolés**
|
||||
| **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.
|
||||
Ne **pas** confondre `@multibrowser` (plusieurs navigateurs) avec `@shared-wallet` (même wallet) : on fait du multibrowser **en private** (chacun son wallet) **et en shared** (wallet partagé), et on compare les deux setups avec les **mêmes** steps de comportement.
|
||||
|
||||
## Modèle de wallet : phrasing + tags
|
||||
|
||||
@@ -38,11 +38,11 @@ Ne **pas** confondre `@multibrowser` (plusieurs navigateurs) avec `@shared-walle
|
||||
|
||||
## 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`).
|
||||
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. 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 → on **saisit un identifiant** puis clic « Entrer » (nommer l'espace et ouvrir le wallet = un seul acte, cf. concept `app-security` [[decision_2026-07-06_identifier-at-access-barrier]]) → app connectée, arrivée directe sur l'accueil (plus d'écran « nom d'utilisateur » séparé).
|
||||
|
||||
- **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é.
|
||||
- **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. 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`).
|
||||
|
||||
@@ -68,4 +68,3 @@ Scénario `@humain` : valide le flux RÉEL de distribution du wallet **de bout e
|
||||
|
||||
- [[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.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
type: rule
|
||||
summary: Ne JAMAIS poller le broker (re-lire en boucle « c'est là ? »). NextGraph est par abonnement — la donnée arrive par PUSH, et le 1er `State` d'un `doc_subscribe` est la barrière de sync déterministe (après lui : présence garantie / absence définitive). Tests ET app attendent le push / l'état réactif settlé, jamais une boucle de re-lecture broker.
|
||||
last_checked: 2026-07-09
|
||||
---
|
||||
|
||||
# Ne jamais poller le broker — attendre l'abonnement
|
||||
|
||||
NextGraph est **par abonnement (réactif)**. Une lecture n'est PAS « interroge en
|
||||
boucle jusqu'à ce que ça apparaisse » ; c'est « abonne-toi, réagis au push ». Le
|
||||
**1er `State`** d'un `doc_subscribe` marque la fin de la synchronisation initiale
|
||||
(barrière synchrone) : après lui, la **présence** d'une donnée est **garantie** et
|
||||
l'**absence** est **définitive**. Contrat vérifié empiriquement côté SDK
|
||||
(`@ng-eventually/client`, test e2e « CONTRAT 3 »).
|
||||
|
||||
## L'anti-pattern à bannir
|
||||
|
||||
```
|
||||
for (i = 0; i < N; i++) { if (await authParticipationCount(...) === X) break; sleep(500); }
|
||||
```
|
||||
|
||||
Toute boucle qui **re-interroge le broker** (`authParticipationCount`,
|
||||
`listMyEntityDocs`, `sparql_query` répétés) pour « attendre » une donnée est
|
||||
proscrite : elle masque le vrai mécanisme, fragilise le test (timeout deviné), et
|
||||
contredit frontalement le modèle NextGraph. C'est la remarque qui a fait supprimer
|
||||
l'ancien caveat qui, à tort, érigeait le polling en pratique.
|
||||
|
||||
## Ce qu'il faut faire
|
||||
|
||||
Attendre le **push réactif**. En pratique (app ET test) : l'état réactif
|
||||
(`AD().*` alimenté par `subscribeDoc` dans le contexte de données) se met à jour
|
||||
**au push**. On attend que CET état reflète l'attendu — on **observe l'état réactif
|
||||
settlé**, on ne ré-émet PAS de lecture broker. Le mécanisme de données est
|
||||
l'abonnement ; l'attente ne fait qu'**observer le résultat réactif**.
|
||||
|
||||
- App : l'écran est déjà réactif (`subscribeDoc` → re-render au push) — pas de poll
|
||||
applicatif, pas de spinner piloté par timeout deviné (si un état d'attente est
|
||||
voulu, il vient de la barrière d'abonnement native, pas d'un signal ajouté).
|
||||
- Test : **un helper qui attend le push/la barrière de façon fiable est bienvenu**
|
||||
(fiabilise sans fragiliser). Ce qui est banni, c'est la **boucle de re-lecture**,
|
||||
pas l'attente d'un signal.
|
||||
- **Fallback pragmatique** : si attendre strictement le push/signal s'avère fragile
|
||||
d'une manière ou d'une autre, un **intervalle court** (`setInterval` / re-check
|
||||
rapproché) qui **observe l'état réactif DÉJÀ mis à jour** (l'état local alimenté
|
||||
par l'abonnement — PAS une re-lecture broker) est acceptable : c'est au plus près
|
||||
de ce que vit l'utilisateur, qui **attend** simplement que l'écran (réactif) se
|
||||
mette à jour. La ligne rouge est invariante : **ne jamais re-interroger le broker
|
||||
en boucle** ; observer l'état réactif settlé, oui.
|
||||
|
||||
Voir aussi [[caveat_wallet-bloat-hang]] (autre source de flakiness @data,
|
||||
orthogonale). Le mécanisme non-polling côté lib (`open-repo` : subscribe + attendre
|
||||
le 1er State + lire) vit dans le repo `@ng-eventually/client`, pas ici.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Doc-debt — data-layer
|
||||
|
||||
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
|
||||
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
|
||||
|
||||
## Raw markers (consolidate into blocks, then delete)
|
||||
- TOUCHED src/shared/context/FestipodDataContext.tsx @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/shared/utils/ngSession.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
@@ -1,35 +1,33 @@
|
||||
---
|
||||
type: _overview
|
||||
summary: Couche données NextGraph telle qu'utilisée AUJOURD'HUI (mono-store) — stack ORM/SHEX, modes connected/demo, entités, seed, et 3 règles d'écriture critiques
|
||||
summary: Comment Festipod persiste ses données via le SDK @ng-eventually/client — entités stockées comme documents par scope, écriture SPARQL directe + lecture par modèle union, stack SHEX, modes connected/demo, seed
|
||||
triggers:
|
||||
keywords: [nextgraph, useShape, ORM, SHEX, shape, store, private_store, "@graph", NURI, sparql, sparql_update, seed, wallet, RepoNotFound, FestipodData, ngGraph, bootstrap]
|
||||
paths: ["src/shared/shapes/**", "src/shared/hooks/useShape*", "src/shared/context/NextGraphContext.tsx", "src/shared/context/FestipodDataContext.tsx", "src/shared/utils/ng*", "src/shared/data/seedData.ts"]
|
||||
keywords: [nextgraph, "@ng-eventually", union, readUnion, readEntities, SHEX, shape, scope, "@graph", NURI, sparql, seed, wallet, FestipodData, ngSession, ngGraph, bootstrap, document, entité, déconnexion, reconnexion, durabilité, outbox, SerializationError]
|
||||
paths: ["src/shared/shapes/**", "src/shared/data/readEntities.ts", "src/shared/data/entityWrites.ts", "src/shared/context/NextGraphContext.tsx", "src/shared/context/FestipodDataContext.tsx", "src/shared/utils/ng*", "src/shared/data/seedData.ts"]
|
||||
---
|
||||
|
||||
# Data layer
|
||||
|
||||
Comment Festipod **persiste ses données aujourd'hui** via NextGraph (P2P, local-first, chiffré). État actuel : **mono-store** — tout atterrit dans le `private_store` de l'utilisateur connecté.
|
||||
Comment Festipod **persiste ses données** via NextGraph (P2P, local-first, chiffré de bout en bout). Le SDK de données est **`@ng-eventually/client`** : on le traite comme un SDK NextGraph fini — chaque entité est un **document** placé dans le store de son **scope** (public / protected / private). L'**écriture** est un SPARQL direct dans le document de l'entité ; la **lecture** est le **modèle union** (résoudre les documents par besoin → ouvrir/sync → **une** requête `sparql_query` sans ancre sur l'union → re-query sur signal), et non un abonnement ORM réactif en fan-out (qui *hang*). Voir [[rule_document-per-entity]]. Le mapping *quelle entité → quel scope* est un fait **produit** (concept `functional-domain`, [[knowledge_data-scopes-and-discovery]]) ; ce concept décrit la **mécanique de persistance**.
|
||||
|
||||
> Distinction importante : ce concept décrit le **code actuel**. Le modèle *cible* (multi-store, multi-user, autorisations) est de la doctrine **prospective** qui vit dans le concept `nextgraph-platform` (briefs). NextGraph comme **système externe** (stores, permissions, inbox, SDK) y est aussi documenté.
|
||||
|
||||
**À lire avant de toucher aux écritures :** les 3 règles ci-dessous — chacune corrige un bug réel (`RepoNotFound`, suppression non persistée, redirect intempestif).
|
||||
|
||||
## Règles d'écriture (chacune adossée à une décision)
|
||||
|
||||
- [[rule_private-store-scope]] ← [[decision_2026-03-17_private-store-nuri-scope]]
|
||||
- [[rule_conditional-ng-init]] ← [[decision_2026-03-13_conditional-ng-init-broker-detection]]
|
||||
|
||||
## Pièges (lire avant de toucher au contexte / aux suppressions / aux champs d'event)
|
||||
|
||||
- [[knowledge_context-internals]] — currentUser `@mariedupont`, auto-seed dev, `participantCount` cache, IRI vide, no-op local
|
||||
- [[caveat_participation-deletion]] — `leaveEvent` via `ngSet.delete()` (décision SPARQL annulée), persistance possiblement partielle
|
||||
- [[caveat_event-fields-not-persisted]] — `startTime`/`themes`… perdus en connecté (SHEX incomplet)
|
||||
> **Frontière SDK.** Le SDK de données de Festipod est `@ng-eventually/client` — initialisé/injecté **une seule fois** via `ngSession.configure(...)`. On l'écrit comme un SDK NextGraph **fini** : ne jamais documenter ici l'état courant de NextGraph (contraintes, contournements, internes broker) — cela vit dans le repo `@ng-eventually/client`. Voir [[knowledge_nextgraph-stack]].
|
||||
|
||||
## Modèle & données
|
||||
|
||||
- [[knowledge_nextgraph-stack]] — paquets `@ng-org/*`, SHEX, ORM, `build:orm`
|
||||
- [[knowledge_data-modes]] — connected vs disconnected/demo, providers selon le statut NG
|
||||
- [[knowledge_entities]] — types `Fp*` et shapes
|
||||
- [[knowledge_nextgraph-stack]] — SDK `@ng-eventually/client`, shapes SHEX, ORM réactif, `build:orm`, injection via `ngSession`
|
||||
- [[knowledge_data-modes]] — connected (SDK) vs disconnected/demo (état local seedé), choix du provider
|
||||
- [[knowledge_entities]] — types `Fp*` et leurs shapes SHEX
|
||||
- [[knowledge_seed-data]] — données de seed, `CURRENT_USER_ID`
|
||||
- [[knowledge_context-internals]] — pièges de `FestipodDataContext` (currentUser, auto-seed dev, `participantCount` cache, no-op local)
|
||||
|
||||
> Sécurité/confidentialité (mono-store, confiance broker) : concept `app-security`.
|
||||
## Règles d'écriture
|
||||
|
||||
- [[rule_document-per-entity]] — chaque entité = **son propre document** (par scope), jamais au niveau du store ; c'est ce qui rend l'isolation par-document du SDK possible
|
||||
|
||||
## Pièges (lire avant de toucher aux suppressions / aux champs d'event)
|
||||
|
||||
- [[caveat_participation-deletion]] — la désinscription doit être **autoritative** et ne pas réapparaître
|
||||
- [[caveat_event-fields-not-persisted]] — `startTime`/`themes`… non couverts par la shape Event → perdus en connecté
|
||||
- [[caveat_write-durability-across-disconnect]] — une écriture juste avant une inactivité/chute de socket peut être **perdue** (non durable broker) ; compte survit. Incident ouvert → post-mortem dans le polyfill
|
||||
|
||||
> Confidentialité (isolation par scope, confiance dans le SDK) : concept `app-security`. Périmètres produit par entité + découverte : concept `functional-domain`.
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
---
|
||||
type: brief
|
||||
summary: Design d'implémentation — rendre les lectures RÉACTIVES cross-session via doc_subscribe (par-document, sans fan-out ORM qui hang) et remplacer le participantCount muté-en-place par le flux Option-B (l'inscrit dépose dans l'inbox de l'événement, le propriétaire matérialise et incrémente son propre doc) ; plan de test 2-browsers réel sans polling
|
||||
---
|
||||
|
||||
# Reactive reads + participant-count correct (Option B)
|
||||
|
||||
Brief d'implémentation, ancré dans le code courant. Objectif : deux évolutions couplées de la couche données Festipod (mode connected / `@ng-eventually/client`).
|
||||
|
||||
1. **Lectures réactives cross-session** — remplacer le one-shot `readUnion` + `bumpRead` (re-query manuel, local-only) par une réactivité réelle poussée par le broker, **sans jamais poller** et **sans le fan-out ORM qui hang**.
|
||||
2. **Compteur de participants correct (Option B)** — supprimer la violation d'isolation actuelle (l'inscrit écrit `participantCount` sur le doc de l'événement qui ne lui appartient pas) et la remplacer par le flux dépôt-inbox → matérialisation-propriétaire.
|
||||
|
||||
Ce brief décrit **quoi construire et dans quel ordre**. Aucune modification de code n'est faite ici.
|
||||
|
||||
Références transverses : [[knowledge_context-internals]], [[rule_document-per-entity]], [[caveat_participation-deletion]], `functional-domain/knowledge_data-scopes-and-discovery`, `app-security/knowledge_trust-model`, et le contrat SDK `@ng-eventually/client` (`docs/sdk-reference.md`, `docs/read-model.md`, `docs/nextgraph-current-state.md`).
|
||||
|
||||
---
|
||||
|
||||
## 0. État courant (le point de départ, fichier:fonction)
|
||||
|
||||
### Lecture (one-shot, re-query manuel)
|
||||
`src/shared/context/FestipodDataContext.tsx` → `useNgData()` :
|
||||
- Le jeu de docs à lire **par besoin** est deux `useState` : `publicDocs` / `protectedDocs` (l.232-233). Il est alimenté par (a) l'effet de listing (l.302-332) qui appelle `listMyEntityDocs(owner, 'public'|'protected')` (borné à mon compte) + `readDiscoveredEvents()` (l'index global), et (b) `registerDoc(scope, nuri)` (l.251-255) qui ajoute un doc fraîchement créé.
|
||||
- La **lecture réelle** (l.347-364) : `readEntities(allReadDocs)` → `readModel.readUnion(docs)` (un `sparql_query` ancré par doc, en parallèle, tolérant par-doc). Elle **re-tourne** quand `allReadDocs` change **ou** quand `readTick` change.
|
||||
- `readTick`/`bumpRead` (l.236-237) = **signal de re-query manuel**, bumpé après chaque mutation. **Il n'y a AUCUN signal venant du broker** : une écriture faite par une AUTRE session n'incrémente jamais `readTick` de cette session → **pas de réactivité cross-session**. C'est le trou que ce brief comble.
|
||||
- `listTick`/`relist` (l.246-247) rejoue l'effet de listing après un seed.
|
||||
|
||||
### Écriture du compteur (la violation à retirer)
|
||||
- `joinEvent` (l.597-668) : après avoir écrit sa propre `Participation` (doc protected, l.621-631), il fait `updateEntityField(eventId, eventId, 'participantCount', int(next))` sur **le doc de l'événement** (l.635-640) — or ce doc appartient au **propriétaire de l'événement**, pas à l'inscrit. C'est un write hors-scope. Il dépose *aussi* dans l'inbox via `depositRegistration` (l.652) — ce dépôt-là est le bon canal ; c'est l'écriture directe du `participantCount` qui est à supprimer.
|
||||
- `leaveEvent` (l.670-712) : symétriquement, décrémente `participantCount` sur le doc de l'événement (l.705-710) après le DELETE autoritatif de la participation.
|
||||
- `caveat_participation-deletion` : le DELETE de participation doit rester **autoritatif** (SPARQL DELETE-WHERE via `deleteParticipation`, `src/shared/data/registration.ts` l.260-334, vérifié `remaining === 0`) — ce brief ne change pas ce contrat.
|
||||
- [[knowledge_context-internals]] documente déjà que `participantCount` est un **cache muté en place**, jamais recalculé, et « pas une source de vérité ». Option B en fait une valeur **dérivée et possédée par le propriétaire**.
|
||||
|
||||
### Affichage (déjà « compte + anonyme », à conserver)
|
||||
`src/modules/event/screens/EventDetailScreen.tsx` :
|
||||
- `joined = isParticipating(eventId)` (l.20).
|
||||
- `participants = getEventParticipants(eventId)` (l.21) → dans le contexte, `getEventParticipants` (FestipodDataContext l.108-111) filtre les `participations` connues par `eventId` et joint les `users` **lisibles** (donc seulement mes connexions, cf. cap protected).
|
||||
- `knownParticipants = participants.filter(p => p.id !== currentUserId)` (l.33).
|
||||
- Le libellé **« Participants ({event.participantCount}) »** (l.146) affiche le **compte dérivé**, et `knownParticipants.length < event.participantCount` rend les **placeholders « voir tous les participants »** (l.163-170) — c'est exactement le modèle « compte + anonymes » voulu. **Cet affichage ne change pas** : Option B ne fait que rendre `participantCount` correct et réactif, et les `knownParticipants` restent gouvernés par le cap de lecture protected.
|
||||
|
||||
### Les watchers polling de la lib (à remplacer)
|
||||
Confirmé par lecture de la lib (`packages/client/src/`) :
|
||||
- `inbox.watch(target, onDeposits, {intervalMs=1000})` (`inbox.ts:195-223`) = **`setInterval` polling**, se déclenche uniquement sur changement de `deposits.length`.
|
||||
- `discovery.watchIndex(onEntries, {intervalMs=1000})` (`discovery.ts:163-187`) = **`setInterval` polling** identique.
|
||||
- `useShape` (`use-shape.ts:12`) EST poussé/réactif, mais **seulement sûr sur UN seul document déjà ouvert** — le fan-out `graphs:[…]` hang (§2).
|
||||
- **Aucun wrapper `doc_subscribe` n'est exposé aujourd'hui** dans `docs.ts` (qui n'expose que `docCreate` / `sparqlUpdate` / `sparqlQuery`). Le primitif `ng.doc_subscribe` est atteignable *untyped* via le proxy `ng` (`ng-proxy.ts:54-56` passthrough), mais il n'y a **pas de couche typée** → **la lib doit en ajouter une** (§A).
|
||||
|
||||
---
|
||||
|
||||
## 1. Les primitives plateforme (nextgraph-rs, vérifié)
|
||||
|
||||
- `doc_subscribe(repo_o: String, session_id, callback)` (`sdk/js/lib-wasm/src/lib.rs:1907`) est **par-document** : un seul NURI de repo, un callback. Il monte une souscription sur **une branche** du doc (`verifier.rs:352` `create_branch_subscription`), pousse d'abord un `TabInfo` + `State` initial (`verifier.rs:470-477`), puis un flux de `Patch` à chaque commit.
|
||||
- Le push : à chaque transaction vérifiée sur une branche B, le vérifieur appelle `push_app_response(&B, AppResponse::…)` (`verifier.rs:252`) sur le `Sender` enregistré dans `branch_subscriptions[B]` (`verifier.rs:115`). **Unité de souscription = une branche d'un doc.**
|
||||
- Le **fan-out ORM** vit ailleurs : `orm_start_graph(scope.graphs[], …)` (un seul appel sur un tableau). Là, un **seul** repo non-synchronisé dans le tableau fait que `open_for_target → resolve_target` retourne `RepoNotFound` (`request_processor.rs:147-171`, et surtout la boucle `initialize.rs:125-128` où le `?` **avorte toute la souscription**). Le `readyPromise` ne se résout jamais → **hang ~75s** (`nextgraph-current-state.md` § *The ORM fan-out hang*, cité dans `read-model.md:93-98` et l'en-tête de `read-model.ts:24-31`). **Corollaire : `doc_subscribe` par-doc n'a PAS ce défaut** — il ne subit pas de fan-out, donc un doc absent ne casse que sa propre souscription.
|
||||
- **Write membership-bound, pas d'append** (confirmé, `repo.rs:584` `verify_permission` : auteur non-membre → `PermissionDenied` ; `commit.rs` : une transaction exige `WriteAsync`/`WriteSync`, obtenus uniquement par grant du propriétaire ; **aucune variante `Append` dans `PermissionV0`**). ⇒ **Option A est impossible** : un inscrit ne peut pas écrire/incrémenter un compteur sur le doc public d'un autre. D'où Option B via l'inbox.
|
||||
- **Inbox = primitif plateforme réel** (`server_broker.rs:826` `inbox_post` : aucun contrôle de membership sur l'émetteur ; message scellé à la clé de l'inbox, lisible seulement par les *readers* enregistrés). C'est exactement le canal « n'importe qui dépose, seul le propriétaire dépile ». Aujourd'hui la lib l'émule sur le wallet partagé (`inbox.ts` post/read RDF), le natif étant différé.
|
||||
|
||||
---
|
||||
|
||||
## A. Lectures réactives — le design
|
||||
|
||||
### Principe : `doc_subscribe` par-doc comme **signal de changement**, `readUnion` reste le lecteur
|
||||
On **ne** rend **pas** `readUnion` réactif et on **n'introduit pas** de fan-out ORM. On garde le pattern documenté (`read-model.md:100-110`) :
|
||||
|
||||
> une souscription réactive légère (`doc_subscribe`, ou l'ORM sur un seul store déjà ouvert — jamais un fan-out par-entité) sur les docs synchronisés ; sur son signal de changement, re-jouer le jeu borné de `sparql_query` par-doc (`readUnion`).
|
||||
|
||||
Concrètement :
|
||||
|
||||
1. **La lib expose un wrapper typé `doc_subscribe`.** Il n'existe pas aujourd'hui. Ajouter dans `packages/client/src/docs.ts` (ou un nouveau `subscribe.ts`) une fonction, p.ex. :
|
||||
```ts
|
||||
// renvoie un unsubscribe ; onChange appelé au State initial puis à chaque Patch
|
||||
export function subscribeDoc(nuri: Nuri, onChange: (r: AppResponse) => void): () => void
|
||||
```
|
||||
qui wrappe `ng.doc_subscribe(nuri, sessionId, cb)` et normalise l'AppResponse (initial + patches) + la fermeture du flux. C'est **par-document** (un NURI), donc immunisé au hang du fan-out.
|
||||
- Exposer aussi un helper pour souscrire **un ensemble** de docs en montant **une souscription par doc** (map `nuri → unsubscribe`), avec **isolation par-doc des erreurs** : un `RepoNotFound` / doc non-synchronisé ne fait échouer QUE sa propre souscription (retry/skip), jamais les autres. C'est le point-clé qui évite de reproduire le fan-out. Le contrat SDK (`sdk-reference.md`) devra documenter ce wrapper.
|
||||
|
||||
2. **Le contexte data (FestipodDataContext) monte une souscription par-doc sur le jeu qu'il lit déjà.** Le jeu `allReadDocs` (union `publicDocs` ∪ `protectedDocs`) est déjà borné et par-besoin. Nouvel effet dans `useNgData()` :
|
||||
```
|
||||
useEffect(() => {
|
||||
const unsubs = allReadDocs.map(nuri => subscribeDoc(nuri, () => bumpRead()));
|
||||
return () => unsubs.forEach(u => u());
|
||||
}, [allReadDocs]);
|
||||
```
|
||||
→ sur **tout** patch d'un des docs abonnés (écrit par CETTE session OU une autre), `bumpRead()` re-déclenche le `readUnion` existant (l.347-364). **`readTick`/`bumpRead` restent** — ils cessent d'être « manuel après ma mutation » pour devenir « poussé par le broker ». La forme du contexte (valeurs `events`/`users`/`participations` en `useState`) **ne change pas** ; les écrans continuent de lire via `useFestipodData()` sans modification.
|
||||
|
||||
3. **Entrée de NOUVEAUX docs dans le jeu abonné, sans fan-out hang :**
|
||||
- **Nouvel événement découvert** : la découverte réactive remplace `discovery.watchIndex` (setInterval) par une **souscription `doc_subscribe` sur le doc d'index global** (l'inbox d'index, un seul doc — `resolveInboxAnchor`-style). À chaque patch de l'index → re-lire `readDiscoveredEvents()` → les nouveaux `doc` NURIs entrent dans `publicDocs` (via `setPublicDocs`), ce qui **agrandit `allReadDocs`**, ce qui **remonte la souscription par-doc** (nouveau `useEffect` ci-dessus) → le nouvel événement est lu ET désormais abonné. Pas de fan-out : chaque doc est abonné **individuellement**, quand il entre.
|
||||
- **Nouveau dépôt d'inbox** (nouveau participant, notification hôte) : idem, remplacer `inbox.watch` (setInterval) par une **souscription `doc_subscribe` sur le doc-inbox** concerné (un seul doc). Un patch → re-matérialiser (§B).
|
||||
- **Doc que je viens de créer** : `registerDoc` continue de l'ajouter à `publicDocs`/`protectedDocs` → il entre dans `allReadDocs` → il est abonné. (`bumpRead` immédiat garde la latence perçue nulle localement.)
|
||||
|
||||
4. **La lib remplace ses watchers polling** : `inbox.watch` et `discovery.watchIndex` deviennent des wrappers `doc_subscribe` sur le doc-inbox / doc-index respectif (un doc chacun — pas de fan-out). Signature publique conservée (callback + unsubscribe) pour ne pas casser les appelants ; l'implémentation passe de `setInterval(read)` à `subscribeDoc(anchor, () => read().then(onX))`.
|
||||
|
||||
### Ce qui NE change pas
|
||||
- `readUnion` reste one-shot, par-doc, tolérant (un doc en échec → `[]`, jamais d'abort).
|
||||
- Le mapping `readEntities` (`src/shared/data/readEntities.ts`) est inchangé.
|
||||
- **Aucun `useShape({graphs:[…]})` par-entité n'est introduit** — le seul `useShape` restant est le `FanoutProbe` du harness de test (qui sert justement à *démontrer* le hang), pas un chemin applicatif.
|
||||
|
||||
---
|
||||
|
||||
## B. Compteur de participants — Option B (dépôt → matérialisation propriétaire)
|
||||
|
||||
### Les documents / inboxes impliqués
|
||||
- **Doc de participation de l'inscrit** : protected, **possédé par l'inscrit** (déjà créé par `joinEvent`, `createEntityDoc(owner,'protected')` + `writeEntity(ENTITY_TYPE.participation, …)`). Lisible en clair par les **connexions** de l'inscrit uniquement (cap protected + `declareConnections`).
|
||||
- **Inbox de l'événement** : résolue par `hostInboxNuri(eventId)` → `resolveInboxAnchor()` (aujourd'hui une anchor unique ; à migration, un doc-inbox par événement — `hostInboxNuri` réserve déjà le param `eventId`). C'est là que l'inscrit **dépose le lien de participation**.
|
||||
- **Doc de l'événement** : public, **possédé par le propriétaire**. C'est **le propriétaire** qui y écrit `participantCount` — jamais l'inscrit.
|
||||
- **(référence) enregistrée par le propriétaire** : une entrée reliant le compte incrémenté au dépôt (idempotence + audit) ; peut vivre dans le doc de l'événement (référence de dépôt déjà matérialisé) ou un doc protected du propriétaire.
|
||||
|
||||
### Le flux (qui écrit quoi)
|
||||
1. **Inscrit — `joinEvent`** (modifié) :
|
||||
- Écrit sa propre `Participation` (protected, à lui) — **inchangé**.
|
||||
- **Dépose dans l'inbox de l'événement** un payload `{ kind:'new-participant', eventId, participationDoc, participantId, uid }` via `depositRegistration` (aujourd'hui `inbox.post(target, {from:null, payload})`, `registration.ts:110-125`). `from` reste anonyme au transport (le SDK lie `from` à l'identité et rejette un spoof — cf. `registration.ts:106-108`) ; l'identité domaine voyage dans le payload. **Le dépôt porte le NURI du doc de participation** (`participationDoc`) pour que le propriétaire, s'il est une connexion, puisse le lire en clair.
|
||||
- **SUPPRIME l'écriture de `participantCount` sur le doc de l'événement** (l.635-640 actuelles). L'inscrit n'écrit plus jamais sur le doc d'un autre.
|
||||
2. **Propriétaire — matérialisation (quand connecté)** : la session du propriétaire est abonnée (`doc_subscribe`, §A.3) au doc-inbox de son événement. Sur un nouveau dépôt `new-participant` :
|
||||
- dédup via `uid` (idempotence : ne pas re-compter un dépôt déjà matérialisé — vérifier la (référence) enregistrée) ;
|
||||
- **incrémente `participantCount` sur SON PROPRE doc d'événement** (`updateEntityField(eventDoc, eventDoc, 'participantCount', int(next))`) — **c'est le propriétaire qui écrit son propre doc**, pas un privilège de lecture ni un write hors-scope ;
|
||||
- enregistre la **(référence)** du dépôt matérialisé (marqueur d'idempotence).
|
||||
- Cette logique remplace/prolonge l'effet de **matérialisation des notifications** existant (FestipodDataContext l.443-479, `readRegistrationNotifications`) : aujourd'hui il ne fait que surfacer des notifications ; il devient aussi le point où le compteur est incrémenté. Le déclencheur passe du polling implicite à la souscription `doc_subscribe` sur l'inbox.
|
||||
3. **Autres sessions voient le compte changer** : le doc de l'événement est **public**, donc **toute** session qui l'a dans son `allReadDocs` y est abonnée (§A). L'écriture du propriétaire produit un patch → `bumpRead()` → `readUnion` re-lit → `event.participantCount` mis à jour → `EventDetailScreen` re-rend « Participants (N) » **sans reload ni action**. C'est le chemin réactif complet, cross-session.
|
||||
|
||||
### Désinscription (symétrique, autoritative)
|
||||
- `leaveEvent` : garde le **DELETE autoritatif** de la participation (`deleteParticipation`, vérifié `remaining === 0`) — [[caveat_participation-deletion]] intact (ne doit pas ressusciter).
|
||||
- **Retire la décrémentation directe** de `participantCount` par l'inscrit (l.705-710). À la place, l'inscrit **dépose un `leave`** (`{ kind:'leave-participant', eventId, uid }`) dans l'inbox de l'événement ; le propriétaire matérialise → **décrémente son propre doc** (idempotent via `uid`, `max(0, n-1)`, et refuse de re-décrémenter un `uid` déjà traité pour ne pas « ressusciter » un compte faux).
|
||||
- **Cas propriétaire hors-ligne = comportement éventuel ACCEPTÉ** : si le propriétaire n'est pas connecté, le dépôt reste dans l'inbox ; le compte n'est **pas** mis à jour tant qu'il ne se reconnecte pas et ne matérialise pas. **C'est un comportement accepté** (cohérence à terme, local-first). Les autres voient le compte se corriger quand le propriétaire revient. À énoncer tel quel dans le contrat produit.
|
||||
|
||||
### Identité (C)
|
||||
- Un participant est montré **par son nom** uniquement si le viewer est une **connexion** du participant : le doc de participation + le profil du participant sont protected, donc lisibles en clair seulement via le cap accordé par `declareConnections` (`src/shared/utils/connections.ts` → `grantRead(protectedDocsOf(owner), neighbour)`). Sinon le doc reste illisible → le participant n'apparaît **pas** dans `getEventParticipants` (qui joint sur les `users`/`participations` lus) → il tombe dans les **placeholders « inconnu »** de `EventDetailScreen` (l.163-170), le compte dérivé restant visible via `participantCount`.
|
||||
- **Aucune lecture privilégiée de l'hôte** : le propriétaire ne lit pas les participations ; il ne fait que **compter des dépôts** et écrire son propre compteur. Il ne voit un participant nommé que s'il en est une connexion — exactement comme n'importe quel viewer. C'est conforme à `functional-domain/knowledge_data-scopes-and-discovery` (« identifié si connu, anonyme sinon ») et à `app-security/knowledge_trust-model` (pas de contrôle d'accès applicatif, l'isolation est par-document déléguée au SDK).
|
||||
|
||||
---
|
||||
|
||||
## D. Plan de test (e2e réel, sans polling)
|
||||
|
||||
### D.1 — POLYFILL bas-niveau : `doc_subscribe` réagit vraiment
|
||||
But : prouver que la primitive réactive fonctionne, indépendamment de Festipod.
|
||||
- Emplacement : test unité/intégration de la lib (`packages/client`) — ou un `@data` Festipod si le harness broker est requis.
|
||||
- Setup : deux « vues » du **même** doc (deux souscriptions, ou une souscription + une écriture par un autre chemin). Monter `subscribeDoc(nuri, onChange)`, écrire dans le doc via `sparqlUpdate`.
|
||||
- **Assertion** : `onChange` est appelé (State initial) **puis** re-appelé après l'écriture, **sans polling** (aucun `setInterval` ; l'assertion attend un event, pas un timeout). Vérifier qu'une écriture sur un **autre** doc ne déclenche PAS `onChange` (isolation par-branche). Vérifier qu'un doc non-synchronisé qui échoue **n'avorte pas** les autres souscriptions (par-doc).
|
||||
|
||||
### D.2 — FESTIPOD app-level : 2 navigateurs réels, sans reload ni action de A
|
||||
But : B s'inscrit → l'`EventDetailScreen` de A montre `participantCount` incrémenté **et** un « participant inconnu », **sans que A recharge ni n'agisse**.
|
||||
- Étendre `src/modules/event/features/e2e-multibrowser.feature` (`@multibrowser @shared-wallet`) et `src/modules/event/steps/e2e/multibrowser-features.steps.ts`.
|
||||
- Nouveau scénario (esquisse Gherkin FR) :
|
||||
```
|
||||
Scénario: Un participant apparaît réactivement dans l'autre navigateur sans reload
|
||||
Étant donné un navigateur "A" avec le wallet partagé
|
||||
Et un navigateur "B" avec le wallet partagé
|
||||
Et le navigateur "A" charge l'application via le broker
|
||||
Et le navigateur "B" charge l'application via le broker
|
||||
Et le navigateur "A" est connecté à NextGraph
|
||||
Et le navigateur "B" est connecté à NextGraph
|
||||
Et le navigateur "A" crée l'événement "Apéro réactif"
|
||||
Et le navigateur "A" ouvre le détail de l'événement "Apéro réactif"
|
||||
Et le compteur de participants affiché dans "A" pour "Apéro réactif" vaut 1
|
||||
Quand le navigateur "B" s'inscrit à l'événement "Apéro réactif"
|
||||
Alors sans recharger, le compteur de participants affiché dans "A" pour "Apéro réactif" passe à 2
|
||||
Et le navigateur "A" affiche un participant "inconnu" pour "Apéro réactif"
|
||||
```
|
||||
- **Assertions exactes** :
|
||||
1. `participantCount` **côté A** passe de 1 à 2 — assert via `frame.waitForFunction` sur l'état réactif du contexte (`__testData.events` → l'event → `participantCount === 2`) **puis** confirmé sur le DOM rendu (le libellé « Participants (2) » de `EventDetailScreen`), **sans appel de `loadAppInBrowser`/reload** entre le join de B et l'assertion de A.
|
||||
2. **Placeholder inconnu** : `knownParticipants.length < participantCount` → assert présence du bloc « Voir tous les participants » (ou un compteur d'anonymes = `participantCount − knownParticipants.length ≥ 1`), le participant B n'étant PAS une connexion de A → non nommé.
|
||||
3. **Négatif no-polling** : le passage 1→2 arrive via souscription (event-driven) ; le test attend l'event, il ne doit pas dépendre d'un `waitForTimeout` fixe comme *source* de la mise à jour (un timeout de garde reste toléré pour laisser la sync broker, comme dans le scénario désinscription existant l.131).
|
||||
- **Helpers harness nécessaires** (dans `harness-ng.tsx`, exposés sur `window.__testData`, et répliqués dans les DEUX harness — cf. `bdd-testing/cookbook_add-scenario`) :
|
||||
- un getter du `participantCount` réactif pour un event (déjà accessible via `__testData.events`).
|
||||
- un accès au **rendu** `EventDetailScreen` de A **sans navigation manuelle** : soit monter l'app réelle sur la route détail (chemin @e2e), soit exposer `knownParticipants` / le compte d'anonymes. Réutiliser `createEventReal` (l.232), `appJoinEvent` (l.245), `readInboxDeposits` (l.283), `authParticipationCount` (l.302).
|
||||
- un hook « le propriétaire a matérialisé » : comme A est le propriétaire ET connecté, sa souscription inbox doit incrémenter son propre doc — le test observe le résultat (count 2) sans piloter la matérialisation à la main.
|
||||
- **Symétrie désinscription** : étendre le scénario existant « la désinscription ne ressuscite pas » (l.36-48) d'une assertion réactive : après le leave de B, `participantCount` côté A **repasse à 1 sans reload**, et `authParticipationCount === 0` (déjà couvert).
|
||||
|
||||
---
|
||||
|
||||
## E. Risques / questions ouvertes
|
||||
|
||||
1. **Le hang du fan-out** (le risque n°1). Le design l'évite **par construction** : souscription **par-document** (`doc_subscribe`), jamais `orm_start_graph(graphs:[…])`. À garder comme invariant : tout nouveau doc entre via une souscription **individuelle** avec isolation d'erreur par-doc — un doc non-synchronisé ne doit jamais pouvoir avorter les autres souscriptions ni bloquer le `readUnion` (qui reste tolérant par-doc). Risque résiduel : le **volume** de souscriptions par-doc (une par doc lu) — à valider sur le broker réel ; sinon, plafonner/prioriser les docs abonnés (event courant + son inbox + mes docs) plutôt que l'union entière.
|
||||
|
||||
2. **Compte propriétaire hors-ligne = éventuel — DÉCIDÉ (2026-07-06).** Tant que le propriétaire n'est pas connecté, aucun dépôt n'est matérialisé → `participantCount` reste périmé pour les autres (la participation elle-même est persistée côté broker — rien n'est perdu, seul l'agrégat attend la reconnexion de l'hôte). Accepté pour la V1. **Plus tard, un SERVICE prendra le relai** quand le propriétaire est déconnecté (le paquet différé `@ng-eventually/service` — le « curateur » évoqué dans les docs inbox de la lib) : un acteur toujours disponible matérialisera l'inbox à la place de l'hôte. Pas de fallback « N+ en attente » en V1.
|
||||
|
||||
3. **`doc_subscribe` par-doc — FAIT (lib `c0498a6`).** La lib expose désormais `subscribeDoc`/`subscribeDocs` (isolation d'erreur par-doc, pas de fan-out ORM), `inbox.watch`/`discovery.watchIndex` sont passés en `doc_subscribe` (plus de polling), et le contrat est dans `sdk-reference.md`. Validé broker réel (le callback traverse le RPC iframe et fire sur changement). Reste : brancher la souscription dans le chemin de lecture app (P3).
|
||||
|
||||
> **Hooks réactifs du SDK** (précision) : l'adaptateur React de NextGraph expose `useShape` (shapes RDF réactives) et `useDiscrete` (docs CRDT discrets) — pas de `useQuery`. La lib ré-expose `useShape`. Pour la lecture UNION de N docs (le cas de Festipod), `useShape`/l'ORM en fan-out *hangue* ; le chemin réactif app passe donc par `subscribeDocs` (par-doc) + re-`readUnion`, éventuellement enveloppé en un hook de lecture réactive côté lib (à décider en P3).
|
||||
|
||||
Autres points à trancher :
|
||||
|
||||
> ⚠️ **RECADRÉ + CORRIGÉ (2026-07-13).** L'affirmation ci-dessous « Prouvé par l'e2e D.2 … sans reload » était **FAUSSE** (le « vert » venait d'un wallet bloaté). Mais surtout le **cadrage « réactif / sans reload / push cross-session » était un SUR-CADRAGE** : la spec réelle est **« le propriétaire traite son inbox de façon fiable à sa PROCHAINE CONNEXION »** (pas de notification live temps-réel entre deux utilisateurs connectés). Bug corrigé sous ce cadrage : le materializer lisait l'inbox **avant sa sync** (→ 0 mémoïsé). Fix = lecture inbox **gated sur barrière** (`inbox.readSynced` = `ensureRepoOpen` + `read`) + déclenchement à la connexion + source unique `event.participantCount`. Scénario `event/e2e-multibrowser.feature` **reframé « à la prochaine connexion » et dé-`@wip`, VERT sur profil frais** (une reconnexion/re-matérialisation de A est le mécanisme accepté). Détail : [[knowledge_context-internals]] §participantCount. Le plan de phasage ci-dessous doit être relu à cette lumière (le « sans reload » n'est plus l'exigence).
|
||||
|
||||
- **Ordre de phasage :** ~~(P1) lib : `subscribeDoc` + variante multi-doc + tests D.1~~ **FAIT (`c0498a6`)** ; ~~(P2) lib : remplacer `inbox.watch`/`discovery.watchIndex` par `doc_subscribe`~~ **FAIT (`c0498a6`)** ; ~~(P3) app : brancher la souscription par-doc dans `useNgData` (bumpRead poussé) + découverte réactive~~ **FAIT (branche `ng-eventually`, non commité)** — `useNgData` monte un effet `subscribeDocs(allReadDocs, …)` clé sur un join trié des NURIs (`readDocKey`, anti-boucle : un patch → `bumpRead` → re-`readUnion` ne change pas le set → pas de re-souscription ; le reset d'identité `prevOwnerRef` vide le set → `readDocKey=''` → cleanup unsubscribe, puis re-listing → re-souscription sur le set reconstruit) + un effet de découverte réactive `watchDiscoveredEvents()` (wrapper app sur `discovery.watchIndex`, déjà `doc_subscribe`) → `relist()`. `readUnion` reste le lecteur one-shot tolérant. **Prouvé par l'e2e D.2** (`e2e-multibrowser.feature`, scénario « Un participant apparaît réactivement… », @multibrowser @shared-wallet, 12 steps verts en isolation) : B s'inscrit → A voit `participantCount === 2` + un participant « inconnu » **sans reload ni action**, via `doc_subscribe` sur le doc public de l'événement (le join en P3 écrit encore ce compteur, cf. §B.5 — c'est ce qui valide P3 avant P4). ; (P4) app : Option B join (retirer le write compteur de l'inscrit, matérialisation propriétaire) ; (P5) app : Option B leave symétrique ; ~~(P6) e2e D.2~~ **FAIT avec P3** (le scénario réactif ci-dessus ; la symétrie désinscription réactive reste à ajouter avec P5). P1→P3 livrent la réactivité ; P4→P6 le compteur correct. On peut livrer P1–P3 avant P4–P6.
|
||||
- **Idempotence de la matérialisation** : le `uid` par-dépôt (`RegistrationPayload.uid`, `registration.ts:56`) est le pivot ; la (référence) enregistrée par le propriétaire doit être consultée avant tout incrément/décrément pour ne jamais double-compter (rejeu de sync) ni « ressusciter » un compte.
|
||||
- **Migration inbox natif** : aujourd'hui l'inbox est émulée sur le wallet partagé (`inbox.ts` post/read RDF). À la migration vers l'inbox broker natif (`inbox_post`/`inbox_pop_for_user`, scellé), le flux Option B **reste valide** (dépôt non-membre autorisé, lecture réservée aux *readers* = propriétaire), mais le wrapper `subscribeDoc` sur l'inbox devra viser le mécanisme natif de notification de dépôt. À vérifier au moment de la migration.
|
||||
@@ -10,8 +10,8 @@ Le type app `FpEventData` (`src/shared/data/types.ts`) et le seed (`seedData.ts`
|
||||
|
||||
## Conséquence
|
||||
|
||||
En **mode connected** (NextGraph), le mapping (`mapEvent` dans `FestipodDataContext.tsx`) ne lit/écrit que les champs de la shape. Les champs hors-shape sont **silencieusement perdus** : remplis par des defaults ou vides. Or des écrans **les affichent** (ex. `startTime`/`endTime` dans `EventDetailScreen`) — donc en mode démo (seed local) ils apparaissent, mais en connecté ils disparaissent. Décalage observable seulement à l'usage.
|
||||
En **mode connected** (SDK), le mapping (`mapEvent` dans `FestipodDataContext.tsx`) ne lit/écrit que les champs de la shape. Les champs hors-shape sont **silencieusement perdus** : remplis par des defaults ou vides. Or des écrans **les affichent** (ex. `startTime`/`endTime` dans `EventDetailScreen`) — donc en mode démo (seed local) ils apparaissent, mais en connecté ils disparaissent. Décalage observable seulement à l'usage.
|
||||
|
||||
## Pour corriger (si on veut les persister)
|
||||
|
||||
Ajouter les champs à `festipodShapes.shex` puis `bun run build:orm`, et étendre `mapEvent`. C'est aussi un prérequis de la modélisation complète du point de rencontre (cf. concept `nextgraph-platform`, [[brief_2026-05-21_fork-nextgraph-inbox]] §Couche 3). Tant que ce n'est pas fait, **ne pas se fier aux champs date/heure/thèmes en mode connecté**.
|
||||
Ajouter les champs à `festipodShapes.shex` puis `bun run build:orm`, et étendre `mapEvent`. Tant que ce n'est pas fait, **ne pas se fier aux champs date/heure/thèmes en mode connecté**.
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: La suppression de Participation (leaveEvent) se fait via ngSet.delete() — le bug de non-persistance qui avait motivé SPARQL DELETE est en grande partie corrigé, mais la persistance peut rester partielle ; vérifier après refresh
|
||||
last_checked: 2026-06-15
|
||||
summary: La désinscription à un point de rencontre doit être AUTORITATIVE — une fois la Participation supprimée, elle ne doit plus réapparaître ; vérifier après un vrai rafraîchissement que l'inscription a bien disparu côté données
|
||||
last_checked: 2026-07-03
|
||||
---
|
||||
|
||||
# Caveat : suppression de Participation via `ngSet.delete()`
|
||||
# Caveat : la désinscription doit être autoritative
|
||||
|
||||
**État actuel du code** (`src/shared/context/FestipodDataContext.tsx`, `leaveEvent` en mode NG) : la suppression d'une `Participation` se fait via **`participationsShape.ngSet.delete(ngPart)`** — pas via `ng.sparql_update()` DELETE WHERE.
|
||||
Contrat métier : quand un utilisateur **se désinscrit** d'un point de rencontre (`leaveEvent` dans `src/shared/context/FestipodDataContext.tsx`), la `Participation` doit être **supprimée durablement**. Elle ne doit **pas ressusciter** après une resynchronisation.
|
||||
|
||||
## Histoire (important)
|
||||
## Le piège
|
||||
|
||||
Une décision antérieure ([[decision_2026-03-17_sparql-delete-for-orm-objects]], **annulée le 2026-06-15**) imposait SPARQL DELETE car `ngSet.delete()` ne persistait pas (l'objet réapparaissait au refresh). Ce **bug du `@ng-org/orm` a depuis été en grande partie corrigé** : `ngSet.delete()` est redevenu le chemin utilisé.
|
||||
Refléter la suppression uniquement dans l'état réactif de l'UI ne suffit pas : l'inscription peut réapparaître si la suppression n'est pas **persistée** côté données. La désinscription doit donc être **autoritative** au niveau du document, pas seulement au niveau de l'affichage.
|
||||
|
||||
## Le piège (pourquoi un caveat et pas une règle)
|
||||
|
||||
La correction **semble partielle** : selon les cas, la suppression via `ngSet.delete()` peut ne **pas se propager complètement** au broker. Donc :
|
||||
|
||||
- **Ne pas tenir pour acquis** que `leaveEvent` persiste à coup sûr — **vérifier après un vrai refresh** que la participation a bien disparu côté wallet.
|
||||
- Si une suppression se révèle non persistée, le repli connu reste `ng.sparql_update()` avec `DELETE WHERE { GRAPH <…> { <…> ?p ?o } }` (le mécanisme décrit dans la décision annulée). **Ne pas combiner** les deux (conflit CRDT — c'était l'autre enseignement de la décision).
|
||||
- Re-tester ce point à chaque montée de version de `@ng-org/orm`.
|
||||
|
||||
> À valider : ouvrir `FestipodDataContext.tsx` → `leaveEvent` (mode NG, `console.log('Deleting participation via ngSet.delete()')`). Si le code est repassé à `sparql_update`, mettre ce caveat à jour ou le promouvoir en règle.
|
||||
**À vérifier après toute évolution de `leaveEvent`** : s'inscrire puis se désinscrire, faire un **vrai rafraîchissement**, et confirmer que la participation a bien disparu (le bouton ne doit pas rester « ✓ Je participe »). Couvert par le scénario `@e2e` « Se désinscrire d'un événement » (`src/modules/event/features/cycle-de-vie-evenement.feature`) et un `@data` « désinscription persistante » (`inscription-inbox.feature`).
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: Une entité écrite juste avant une inactivité/chute de socket peut être perdue silencieusement (jamais durable côté broker) ; le compte survit (pas de fork). Observé Firefox. Le SDK ne confirme pas la durabilité et ne se reconnecte pas seul.
|
||||
last_checked: 2026-07-14
|
||||
---
|
||||
|
||||
# Piège : une écriture juste avant une déconnexion n'est pas garantie durable
|
||||
|
||||
**Symptôme produit.** L'utilisateur crée une entité (un événement), ça semble réussir, puis une **période d'inactivité** survient ; au rechargement / à la reconnexion, l'entité a **disparu**. Le scope se relit **vide**. L'**identité/compte survit** — ce n'est PAS un fork, c'est une écriture non durable.
|
||||
|
||||
**Mécanisme (résumé, non tranché).** Le socket broker peut mourir spontanément pendant l'idle (`SOCKET IS CLOSED … SerializationError`). L'écriture était dans l'outbox local ; au retour, le replay échoue (`Err(TopicNotFound)`) et l'entité est abandonnée. **Observé Firefox uniquement** à ce jour. Un test @data à froid (2026-07-14) a par ailleurs montré qu'une session **fraîche** (aucun état local, même compte A) ne récupère **pas** le scope propre de A depuis le broker : le test de reconnexion @data qui « passait » relisait en fait l'IndexedDB **locale**. Reste à trancher : **perte à l'écriture** vs **échec de réhydratation à froid** (mécanismes distincts) — voir le post-mortem dans le polyfill.
|
||||
|
||||
**Pourquoi l'app ne le voit pas.** `NgStatus` est dérivé **une seule fois** de la session initiale → aveugle aux chutes en cours de session. Le canal `disconnections_subscribe` du SDK se déclenche sur la panne mais **n'est pas consommé** (ni polyfill ni app). Aucune API ne confirme qu'une écriture a atteint le broker.
|
||||
|
||||
**Ne pas documenter ici les internes NextGraph.** Frontière SDK (voir [[knowledge_nextgraph-stack]]) : cause racine, chaîne causale (socket, reconnexion en TODO) et pistes de correction vivent dans le repo `@ng-eventually/client` → `docs/incidents/2026-07-14-write-loss-on-disconnect.md`. Cette fiche ne garde que l'**impact consommateur** + le pointeur.
|
||||
|
||||
**Statut : ouvert, non traité (2026-07-14).** À revisiter quand le core/SDK adresse la reconnexion ou expose une confirmation de durabilité — ce caveat tombera alors. Voir aussi le débat lecture-à-froid vs perte réelle dans [[brief_2026-07-06_reactive-reads-and-attendance]] (le `BARRIER timed-out` de @data est une signature distincte, non confirmée comme ce bug).
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
---
|
||||
type: decision
|
||||
summary: Décision 2026-03-13 — auto-init NextGraph seulement quand dans l'iframe broker (window.self !== window.top), sinon initNgWeb() redirige la page et casse le dev/démo standalone
|
||||
---
|
||||
|
||||
# Conditional NextGraph Init Based on Broker Iframe Detection
|
||||
|
||||
**Date:** 2026-03-13 14:00
|
||||
**Status:** Accepted
|
||||
|
||||
## Context
|
||||
|
||||
`initNgWeb()` de `@ng-org/web` teste `window.self === window.top`. En standalone (hors iframe), il redirige toute la page vers `nextgraph.net/redir/` pour déclencher l'auth broker. Résultat : l'app redirigeait à chaque chargement — même en dev ou quand l'utilisateur n'avait pas cliqué « Se connecter ».
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Option A: toujours auto-init NG au mount
|
||||
- Plus simple (pas de branchement).
|
||||
- **Contre** : redirect immédiat vers le broker en standalone ; casse le workflow de dev ; l'utilisateur voit la page de login broker au lieu de l'app.
|
||||
|
||||
### Option B: auto-init conditionnel selon détection iframe
|
||||
- En iframe, le broker a déjà authentifié → auto-init sûr ; en standalone, l'utilisateur doit cliquer « Se connecter » ; préserve l'expérience démo/dev ; calque la propre logique de détection de `@ng-org/web`.
|
||||
- **Contre** : repose sur l'heuristique `window.self !== window.top` (théoriquement faillible si embarqué dans une iframe non-broker).
|
||||
|
||||
## Decision
|
||||
|
||||
**Option B.** `NextGraphContext` calcule `isInsideBroker = typeof window !== 'undefined' && window.self !== window.top` au niveau module. `useEffect` n'auto-appelle `initNg()` que si `isInsideBroker`. Le callback `connect()` reste disponible pour la connexion explicite. De plus, `FestipodDataContext` rend des données vides (pas le seed) pendant `connecting` pour éviter de flasher le contenu démo.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positif :** l'app charge sans rediriger (standalone dev/démo) ; en iframe broker, connexion fluide et automatique ; pas de flash de seed pendant la connexion.
|
||||
**Négatif :** aucun significatif.
|
||||
**Risque :** si `@ng-org/web` change sa logique de détection, notre garde peut diverger — les garder alignés.
|
||||
|
||||
> Règle dérivée : [[rule_conditional-ng-init]].
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
type: decision
|
||||
summary: Décision 2026-03-17 — utiliser private_store_id comme scope useShape ET @graph (calqué sur expense-tracker-rdf) pour que orm_start_graph ouvre le repo et que les écritures ne lèvent plus RepoNotFound
|
||||
---
|
||||
|
||||
# Use private_store_id as useShape scope and @graph
|
||||
|
||||
**Date:** 2026-03-17 16:00
|
||||
**Status:** Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Cliquer « Charger données de test » chargeait les données en mémoire (signaux ORM) mais produisait des `RepoNotFound` sur `doc_create` et `orm_frontend_update`. Les données disparaissaient au reload car les écritures SPARQL n'atteignaient jamais le broker. La HashMap `self.repos` du verifier ne contenait pas le repo du private store → `resolve_target()` échouait.
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Option A: `did:ng:i` scope + `doc_create` pour @graph
|
||||
- `did:ng:i` bien documenté comme scope d'abonnement, `doc_create` renvoie un vrai NURI.
|
||||
- **Contre** : `did:ng:i` passe par `NuriTargetV0::UserSite` qui n'ouvre pas les repos individuels ; `doc_create` appelle `resolve_target(PrivateStore)` qui exige le repo dans `self.repos` → échoue ; exige une logique de retry/timing complexe.
|
||||
|
||||
### Option B: `private_store_id` comme scope ET @graph
|
||||
- Calque exact de l'exemple `expense-tracker-rdf` qui fonctionne ; `orm_start_graph` avec le NURI du private store ouvre le repo dans `self.repos` ; les écritures `orm_frontend_update` trouvent ensuite le repo. Simple, sans retry.
|
||||
- **Contre** : un peu moins flexible que `did:ng:i` (scopé à un store) ; exige de passer la session à `useShapeWithDefaults`.
|
||||
|
||||
### Option C: `did:ng:i` scope + réutiliser le @graph d'une entité existante
|
||||
- Marche pour les users qui ont déjà des données.
|
||||
- **Contre** : échoue pour les wallets vides (aucune entité à réutiliser) ; retombe sur `doc_create` et le même `RepoNotFound`.
|
||||
|
||||
## Decision
|
||||
|
||||
**Option B** : `did:ng:${session.private_store_id}` comme scope `useShape` ET `@graph` d'écriture, exactement comme `expense-tracker-rdf`. `useShapeWithDefaults` accepte un `storeNuri` ; `FestipodDataContext.useNgData()` récupère la session via `useNextGraph()` et passe le NURI du private store. `ensureGraphNuri()` simplifié : entités existantes d'abord (optimisation), sinon fallback `private_store`.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positif :** écritures immédiates après connexion (sans retry) ; persistance au reload ; aligné sur les exemples officiels ; les 7 scénarios e2e passent (dont la persistance).
|
||||
**Négatif :** signature de `useShapeWithDefaults` modifiée (param `storeNuri`).
|
||||
**Risque :** si NextGraph change le comportement du private store, ça casse.
|
||||
|
||||
> Règle dérivée : [[rule_private-store-scope]]. Décision *remise en cause* par le futur multi-store : [[brief_2026-05-17_multi-store-refactor]].
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
type: decision
|
||||
summary: Décision 2026-03-17 — supprimer les objets ORM via ng.sparql_update (DELETE WHERE) seul, car ngSet.delete() ne persiste pas et les combiner crée un conflit CRDT
|
||||
---
|
||||
|
||||
# Use SPARQL DELETE instead of ORM ngSet.delete() for object removal
|
||||
|
||||
**Date:** 2026-03-17 18:00
|
||||
**Status:** ~~Accepted~~ → **Superseded (2026-06-15)**
|
||||
|
||||
> **Annulée le 2026-06-15.** Le bug de non-persistance de `ngSet.delete()` qui motivait cette décision a depuis été en grande partie corrigé côté `@ng-org/orm` : le code (`leaveEvent`) est repassé à `ngSet.delete()`. La persistance reste toutefois possiblement partielle — l'état courant et le repli SPARQL sont décrits dans [[caveat_participation-deletion]]. Décision conservée comme mémoire d'arbitrage (le conflit CRDT « ne pas combiner les deux » reste vrai).
|
||||
|
||||
## Context
|
||||
|
||||
Quitter un event exige de supprimer l'objet `Participation` du store NextGraph. `DeepSignalSet.delete()` met à jour l'état réactif local (UI immédiate) mais **ne persiste pas** au broker — après refresh, la participation réapparaît.
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Option A: ORM `ngSet.delete(item)`
|
||||
- API officielle (README ORM), update réactif local instantané.
|
||||
- **Contre** : ne persiste pas en pratique (`delete()` renvoie `true`, set local à jour, mais objet de retour après refresh) ; `graph_orm_update` semble mal gérer les patches "remove" pour objets de set top-level (bug moteur probable) ; échoue silencieusement.
|
||||
|
||||
### Option B: `ng.sparql_update()` avec SPARQL DELETE
|
||||
- `DELETE WHERE { GRAPH <graph> { <subject> ?p ?o } }` retire tous les triples RDF.
|
||||
- **Pour** : persiste (survit au refresh) ; le broker confirme via `GraphOrmUpdate` remove qui retire réactivement l'item du set ORM ; contrôle direct.
|
||||
- **Contre** : pas instantané (round-trip SPARQL + callback broker, ~50ms) ; ne doit pas être combiné avec `ngSet.delete()`.
|
||||
|
||||
### Option C: les deux ensemble
|
||||
- **Ne marche pas** : le patch ORM `.delete()` et le DELETE SPARQL entrent en conflit CRDT → ni UI ni persistance.
|
||||
|
||||
## Decision
|
||||
|
||||
**Option B : SPARQL DELETE seul.** Le broker renvoie un `GraphOrmUpdate` `op: "remove"` qui retire réactivement l'item du set ORM (UI à jour, juste pas synchrone). **Ne pas** appeler `ngSet.delete()` à côté.
|
||||
|
||||
```typescript
|
||||
// FestipodDataContext.tsx leaveEvent():
|
||||
const session = await sessionPromise;
|
||||
await ng.sparql_update(
|
||||
session.session_id,
|
||||
`DELETE WHERE { GRAPH <${partGraph}> { <${partId}> ?p ?o } }`,
|
||||
partGraph,
|
||||
);
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positif :** suppression persistée ; source de vérité unique (broker → ORM → UI).
|
||||
**Négatif :** léger délai UI (~50ms) ; diverge des exemples README ORM.
|
||||
**Risque :** si `ng.sparql_update` change, ça casse ; toute future suppression doit suivre le même pattern ; revisiter si `ngSet.delete()` est corrigé en montée de version.
|
||||
|
||||
> État courant (la règle a été retirée) : [[caveat_participation-deletion]].
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Pièges internes de FestipodDataContext — currentUser NG résolu par username '@mariedupont' (fallback users[0]), auto-seed dev-only après 3s sans retry, participantCount muté en place (cache), currentUserId vide → IRI invalide, mutations no-op en mode local malgré le toast
|
||||
last_checked: 2026-06-15
|
||||
summary: Pièges internes de FestipodDataContext — currentUserId = principal stable dérivé de l'identifiant, auto-seed OPT-IN (FESTIPOD_AUTO_SEED, OFF par défaut), participantCount dérivé Option-B (fiable à la connexion du propriétaire via lecture inbox gated sur barrière ; source unique = event.participantCount), instrumentation useShapeQuery (spinner+timing), mutations no-op en mode local malgré le toast
|
||||
last_checked: 2026-07-07
|
||||
---
|
||||
|
||||
# Internals & pièges de `FestipodDataContext`
|
||||
@@ -10,20 +10,71 @@ Comportements non évidents de `src/shared/context/FestipodDataContext.tsx` à c
|
||||
|
||||
## Résolution du `currentUser` (mode NG)
|
||||
|
||||
En mode connected, le currentUser n'est **pas** `CURRENT_USER_ID` ('user-1', qui ne vaut qu'en mode local). Il est résolu par **`users.find(u => u.username === '@mariedupont') || users[0]`** (vers ligne 286). Pièges :
|
||||
- **Fallback silencieux** sur `users[0]` si `@mariedupont` absent → currentUser arbitraire.
|
||||
- Si le wallet est **vide** (`users.length === 0`), `currentUserId` devient `''` → toute `Participation` créée a un `user: ''` (**IRI invalide**), sans alerte. Bug silencieux possible à la première connexion sur un wallet vierge.
|
||||
- L'IRI du currentUser diffère entre mode local (ID de seed statique) et mode NG (IRI NextGraph dynamique) — ne pas comparer les deux.
|
||||
En mode connected, le **principal** du currentUser (`currentUserId`) n'est **pas** `CURRENT_USER_ID` ('user-1', mode local) ni l'IRI du profil lu. Quand un identifiant est connecté, c'est un id **stable dérivé de l'identifiant** : `urn:festipod:user:<identifiant-normalisé>`, disponible immédiatement (sans dépendre de la lecture du profil protégé) et invariant sur la session — c'est la même clé que `setCurrentUser`, le cap owner et le compte shim (cf. [[rule_document-per-entity]], corollaire d'identité). Pièges restants :
|
||||
- L'objet `currentUser` (le profil affiché) est, lui, résolu par `users.find(u => normalizeIdentifier(u.username) === identifiant)` avec **fallback** `@mariedupont` puis `users[0]` — un fallback silencieux si l'identifiant ne correspond à aucun profil (l'identifiant est un id d'espace, pas forcément le `username` d'un profil seedé).
|
||||
- Sans identifiant connecté (dev/demo), `currentUserId` retombe sur l'IRI du profil lu (ou `''` si le wallet est vide → `Participation` avec `user: ''` invalide) : ne créer une participation qu'une fois le principal résolu.
|
||||
|
||||
## Lecture = `watchShape` (surface SDK), plus de machinerie bespoke
|
||||
|
||||
**Depuis 2026-07-10** : `useNgData` lit via `useShapeQuery(shape, scope)` (binding
|
||||
`useSyncExternalStore` sur `watchShape` du polyfill) — TROIS lectures useQuery-shaped
|
||||
(events/public, users/protected, participations/protected) + adaptateurs Fp
|
||||
(`shapeAdapters.ts`). Supprimés : `readEntities`, `subscribeDocs`+`bumpRead`+`readTick`,
|
||||
le listing manuel (`publicDocs`/`protectedDocs`/`registerDoc` pour la lecture),
|
||||
`relist`. `ready` = combinaison des `isSuccess`. Cf. [[rule_app-uses-sdk-surface-only]].
|
||||
|
||||
**Visibilité immédiate des mutations = overlay OPTIMISTE** (pas de `registerDoc`) :
|
||||
`createEvent`/`joinEvent`/`leaveEvent` alimentent `pendingAddEvents`/
|
||||
`pendingAddParticipations`/`pendingRemoveIds` ; l'état exposé = merge(réactif, adds)
|
||||
moins removes, dédupé par id (id = NURI du doc). Réconciliation auto au push
|
||||
(un add qui apparaît dans le réactif / un remove qui en disparaît est retiré) —
|
||||
jamais de poll ([[rule_no-broker-polling]]). Vidé au changement d'identité.
|
||||
|
||||
## Auto-seed de dev
|
||||
|
||||
Un auto-seed se déclenche (vers lignes 263-283) **uniquement hors production** (`process.env.NODE_ENV !== 'production'`), après un **`setTimeout` de ~3s**, si les sets events ET users sont vides. Pièges :
|
||||
- **Pas de retry** : `hasTriedAutoSeed` (useRef) est posé une fois ; si le seed échoue, jamais réessayé (écran vide, juste un `console.error`).
|
||||
- Le délai de 3s est **heuristique** : si l'hydratation ORM est lente, le seed peut partir alors que des données arrivent.
|
||||
**Depuis 2026-07-13, l'auto-seed est OPT-IN et OFF par défaut** : il ne se déclenche que si la variable d'env `FESTIPOD_AUTO_SEED` est définie (`=1`), plus sur `NODE_ENV`. Variable absente → **aucun seed automatique**, même en dev (`autoSeedEnabled()`/`shouldAutoSeed()`, `src/shared/utils/autoSeed.ts` ; livrée en dev via la route runtime `/festipod-config.json` + `define` compile-time dans `build.ts`, même mécanisme que le shared-wallet — cf. `tech-stack/knowledge_build-pipeline`). Le seed **explicite** (`loadTestData()`, tests @data) est inchangé. Motivation : le seed auto répété bloatait le wallet (lenteurs de lecture, cf. [[caveat_wallet-bloat-hang]]).
|
||||
|
||||
## `participantCount` muté en place
|
||||
Quand il est activé, l'auto-seed se déclenche si events ET users sont vides — **gardé sur `isSuccess`** (la readiness de `watchShape`),
|
||||
PLUS sur un `setTimeout` de 3s : on ne décide « wallet vide » qu'une fois la sync
|
||||
**confirmée** (`isSuccess`), sinon la lecture pas-encore-finie était prise pour un
|
||||
wallet vide → re-seed à chaque reconnexion (bug corrigé). Pièges restants :
|
||||
- **Un seul seed à la fois** : `loadTestData()` pose `hasTriedAutoSeed`, l'auto-seed le
|
||||
re-teste → un chargement explicite supprime l'auto-seed en attente (sinon deux
|
||||
`bootstrapWallet` concurrents écrivent en double).
|
||||
- Le seed est **possédé par l'identité courante** (`bootstrapWallet(…, owner)`) : les
|
||||
entités protégées seedées passent le cap de lecture par-document du propriétaire.
|
||||
- **Pas de retry** : si le seed échoue, écran vide + `console.error`.
|
||||
|
||||
`joinEvent`/`leaveEvent`/`updateEvent` **mutent directement** `ngEvent.participantCount` (`+1`/`-1`) — c'est un **cache** du nombre de `Participation`, pas une valeur recalculée. Il peut **désynchroniser** des objets `Participation` réels (ex. après un crash, un rejeu, ou la suppression partielle décrite dans [[caveat_participation-deletion]]). Ne pas s'y fier comme source de vérité du nombre de participants.
|
||||
## `participantCount` — dérivé et possédé par le propriétaire (Option B)
|
||||
|
||||
> ✅ **CORRIGÉ (2026-07-13).** L'exigence est **« fiable à la PROCHAINE CONNEXION du propriétaire »** (le créateur traite son inbox à sa connexion), PAS une notification live cross-utilisateur temps-réel. Le bug était : le owner-materializer matérialisait **trop tôt** (avant que le dépôt de l'inscrit soit synchronisé) → lisait `active=0` → écrivait 0 → **mémoïsait ce 0** → ne retraitait plus. Fix : (1) **lecture inbox gated sur barrière** — `inbox.readSynced` (= `ensureRepoOpen(doc)` attend le premier `State`, PUIS `read`, comme `discovery.readIndex`) au lieu de `inbox.read`, donc un dépôt déjà synchronisé EST vu à la connexion ; (2) le materializer se déclenche **directement à la connexion** (`[ready, ownedKey]`), plus seulement sur un push ; (3) `materializedCountRef` ne verrouille plus un 0 prématuré (son seul rôle = anti-boucle : n'écrire que si la valeur dérivée change) ; (4) **source unique du NOMBRE = `event.participantCount`** (le littéral `participantCount: 1` de `CreateEventScreen` est retiré → démarre à 0 ; l'affichage ne calcule plus de nombre local). Gardé VERT (profil frais) par `event/e2e-multibrowser.feature` « Le compteur converge chez le propriétaire à sa prochaine connexion » (dé-`@wip`). Pas de polling ([[rule_no-broker-polling]]).
|
||||
|
||||
**Depuis Option B (2026-07-07)** : `participantCount` n'est plus muté en place par l'inscrit. Le flux est dépôt-inbox → matérialisation-propriétaire :
|
||||
- `joinEvent`/`leaveEvent` n'écrivent **plus** `participantCount` sur le doc de l'événement (ce serait une violation d'isolation — l'inscrit écrirait le doc d'un autre ; le write NextGraph est membership-bound, pas d'append). L'inscrit écrit seulement son **propre** doc de participation (protected) puis **dépose** un marqueur dans l'inbox de l'événement (`depositRegistration` sur join, `depositLeave` sur leave, `src/shared/data/registration.ts`).
|
||||
- La session du **propriétaire** de l'événement matérialise : elle est abonnée (`inbox.watch`, `doc_subscribe`, sans polling) à l'inbox de ses events possédés (`ownedEventIds` = `listMyEntityDocs(owner,'public')` + les events fraîchement créés), et sur chaque dépôt **recalcule** `participantCount` sur **son propre** doc d'événement (`updateEntityField` sur son doc). C'est le seul écrivain du compteur.
|
||||
- **Le compteur est DÉRIVÉ, pas incrémenté** : `materializeAttendance` (registration.ts) lit l'inbox et calcule l'**ensemble** des inscriptions actives distinctes (dépôts `new-participant` dédupés par `uid`, MOINS ceux annulés par un `leave-participant` — par `regUid` exact ou fallback `(eventId, userId)`). `participantCount = |ensemble actif|` — **pas de base « hôte »** : le créateur ne participe pas automatiquement (pas de notion d'hôte, cf. concept `functional-domain`), donc le compteur démarre à **0** à la création et n'avance que sur des inscriptions réelles. `createEvent` **n'écrit plus** de participation à la création (elle écrivait une participation hôte + posait le compteur à 1) ; le créateur voit « J'y serai » et peut rejoindre/quitter son propre événement comme tout le monde. Comme c'est une **fonction pure de l'inbox**, un rejeu de sync broker converge — jamais de double-comptage ni de décrément fantôme (idempotence). L'écriture est gardée (n'écrit que si la valeur change), anti-boucle. Couvert par le scénario `@data` « Le créateur ne participe pas automatiquement à son événement » (us-13) : compteur 0 + `isParticipating(E)===false` à la création, puis join→true / leave→false.
|
||||
- **Propriétaire hors-ligne = éventuel** : seule la session du propriétaire matérialise ; déconnecté, le compteur n'avance pas pour les autres (les participations/dépôts restent persistés — rien n'est perdu ; un futur service matérialisera à sa place).
|
||||
- Le compteur reste néanmoins un **agrégat**, pas la liste des participants nommés : `getEventParticipants` (identité nommée) reste gouverné par le cap de lecture protected ([[caveat_participation-deletion]] pour la suppression autoritative, inchangée). Cf. le brief `brief_2026-07-06_reactive-reads-and-attendance` §B.
|
||||
|
||||
### Invariant id-form : apparier sur la forme CANONIQUE de l'event-id
|
||||
|
||||
Le `@id` d'un événement **est** son NURI de document (`did:ng:o:<repo>[:v:<overlay>]`). Le matérialiseur du propriétaire apparie les **dépôts** de l'inbox aux événements possédés **par l'event-id** : `ownedEventIds` (ce que le matérialiseur itère), la **clé de dépôt** (`payload.eventId`, ce sous quoi l'inscrit dépose) et la **cible d'écriture** du compteur doivent désigner le même événement.
|
||||
|
||||
**Constat mesuré (2026-07-07)** : sur l'arbre courant ces trois voies portent le **même** NURI (suffixe `:v:<overlay>` inclus) — create-time, `listMyEntityDocs` et le `@id` relu coïncident, parce que `readUnion` **épingle le subject au NURI d'entrée** (lib `read-model.ts`, `63ecfee`). L'appariement marche donc déjà, **y compris** pour un événement possédé atteint via `listMyEntityDocs` (validé par le scénario @data « …fait converger le compteur dérivé »). La canonicalisation ci-dessous est **défensive**, pas la correction d'un bug actif. (Le non-match qu'une investigation avait cru voir était l'artefact **seedé-mais-pas-possédé** : sur un wallet persistant, le seed appartenait à une identité `test-*` d'un run antérieur → la session courante l'atteint par découverte, pas par `ownedEventIds` — comportement correct.)
|
||||
|
||||
**Règle** : apparier l'event-id sur sa **forme canonique** — l'id de repo de base, en retirant tout suffixe `:v:<overlay>` (`canonicalEventId`, `src/shared/data/registration.ts`). Cette forme canonique est utilisée pour l'**appariement** dans `materializeAttendance` / `readRegistrationNotifications`, et pour **dédupliquer** `ownedEventIds` (`ownedKey`, FestipodDataContext) afin qu'un même événement atteint par deux voies ne soit pas matérialisé deux fois. **Attention** : seul l'**appariement** utilise la forme stripée ; le compteur est toujours **écrit** sur le vrai NURI possédé (un doc vivant, ouvrable) — un id stripé ne doit jamais servir de cible d'écriture / d'ancre. C'est un invariant **côté app** (pas un détail NextGraph) : quelle que soit la façon dont la lib fait varier l'overlay, l'app apparie sur la base commune.
|
||||
|
||||
## Changement d'identité = session fraîche (isolation)
|
||||
|
||||
Le jeu de lecture par besoin (`publicDocs`/`protectedDocs`) **accumule** les docs de scope de l'identité courante (pour ne pas perdre un doc juste créé avant la re-liste). Or le stopgap wallet-partagé garde **un seul arbre React** au travers d'un faux-logout + re-login sous un **autre identifiant** (pas de rechargement — `AccountContext.login` ne fait que réécrire l'identifiant en localStorage, `AuthGate` ne remonte rien). Sans réinitialisation, **les docs PROTECTED de l'identité précédente (ses participations) survivent dans le jeu de lecture de la nouvelle identité et fuient** via la lecture union : le cap gate ne peut pas les filtrer quand le registre de caps (en mémoire) ne gouverne pas ce doc *cette* session (doc persisté d'un run antérieur, ou chargement frais où les caps sont vides). Symptôme observé : un utilisateur B voyait la participation de A (et l'événement de A apparaissait sur l'**accueil** de B, car l'accueil = `getUserEvents(currentUserId)`, cf. concept `app-architecture`).
|
||||
|
||||
**Règle** : traiter **tout changement d'identifiant** comme une session fraîche — un `useEffect([identifier])` (ref-gardé pour ne pas tirer au premier mount) vide `publicDocs`/`protectedDocs`, appelle `resetCaps()` + `resetRegistryCache()`, puis bump le read tick ; l'effet de listing reconstruit le jeu **borné à la nouvelle identité**. L'isolation reste par-document/émulée (concept `app-security`, [[knowledge_trust-model]]) ; ce reset ne fait que supprimer le report d'état inter-identités.
|
||||
|
||||
**Mécanisme confirmé empiriquement (2026-07-07)** : le leak se reproduit UNIQUEMENT quand DEUX conditions coïncident — (a) le jeu de lecture porte encore le doc PROTECTED de A au travers du switch (pas de reset), ET (b) le registre de caps en mémoire ne gouverne pas ce doc (`resetCaps()` déjà tiré / caps vides pour un doc persisté d'une session antérieure au reload). Alors la participation de A traverse la lecture union de B (le filtre par-document n'a aucun cap à vérifier). Avec le reset ci-dessus tiré, `setProtectedDocs([])` retire le doc de A du jeu de lecture de B AVANT que la lecture cap-less ne l'expose → plus de fuite quel que soit l'état des caps. **Régression gardée** par le scénario `@data` « Une identité fraîche ne voit pas la participation d'une autre » (event/isolation-deux-identites.feature) : A crée E + s'y inscrit, B (page fraîche sur le même wallet, identifiant distinct) n'a NI E sur son accueil (`getUserEvents(B)`), NI `isParticipating(E,B)`, ET ne lit AUCUNE participation portant le principal de A. Le symptôme historique « B voit “Je participe” » survenait surtout quand B **réutilisait un identifiant déjà employé par A** (même principal normalisé) sur un wallet **bloaté** (docs persistés d'un run antérieur, caps vides).
|
||||
|
||||
## Instrumentation `useShapeQuery` — spinner global + timing
|
||||
|
||||
`useShapeQuery` (binding `useSyncExternalStore` sur `watchShape`) instrumente **chaque cycle de requête** : au début d'un cycle il s'enregistre dans un store module-level `src/shared/data/pendingQueries.ts` (`beginQuery`/`resolveQuery`, Set d'ids — idempotent, sûr sous StrictMode), et à la 1re transition `isPending → isSuccess|isError` (le « premier résultat », équivalent readPromise) il se résout ET logge le délai : `[FestipodData] <shape>/<scope> premier résultat en <N>ms (n=<len>)` (le délai des événements Event/public est donc visible nommément). Le `cycleId` est mémoïsé sur `[shapeKey, scope]` → un switch d'identité/scope recrée l'observable ET un nouveau cycle (re-`beginQuery`), et le cleanup résout au démontage (jamais bloqué). Le hook `usePendingQueries()` expose le nombre de requêtes en attente ; `HomeScreen` affiche un `Spinner` (sketchy, `.app-spinner` + `@keyframes app-spin` dans `index.css`) à côté du titre « Festipod » tant que le compte > 0 → il ne s'arrête que quand **toutes** les requêtes en cours ont reçu leur premier résultat. Toute future `useShapeQuery` y contribue automatiquement. La mesure vit côté app (délai perçu React), **pas** dans le polyfill.
|
||||
|
||||
## Mutations no-op en mode local
|
||||
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Deux modes (connected = NextGraph ORM, disconnected/demo = état local seedé) ; FestipodDataContext choisit le provider selon le statut NextGraphContext, tous les écrans passent par useFestipodData()
|
||||
summary: Deux modes (connected = SDK @ng-eventually/client, disconnected/demo = état local seedé) ; FestipodDataContext choisit le provider selon le statut de connexion, tous les écrans passent par useFestipodData()
|
||||
---
|
||||
|
||||
# Modes de données & contextes
|
||||
|
||||
L'app a **deux modes**, tous deux consommés via le hook `useFestipodData()` :
|
||||
|
||||
1. **Connected** — shapes ORM NextGraph (P2P, chiffré, local-first)
|
||||
1. **Connected** — shapes ORM du SDK `@ng-eventually/client` (P2P, chiffré, local-first)
|
||||
2. **Disconnected / Demo** — état React local seedé depuis `seedData.ts` (voir [[knowledge_seed-data]])
|
||||
|
||||
## NextGraphContext (`src/shared/context/NextGraphContext.tsx`)
|
||||
|
||||
- Cycle de connexion : `disconnected` → `connecting` → `connected` | `error`.
|
||||
- Fournit la session avec les IDs de stores (private, protected, public).
|
||||
- **Auto-init conditionnel** : voir [[rule_conditional-ng-init]] (n'auto-initialise que dans l'iframe broker).
|
||||
- Fournit la session (l'utilisateur courant et son accès aux stores par scope).
|
||||
|
||||
## FestipodDataContext (`src/shared/context/FestipodDataContext.tsx`)
|
||||
|
||||
- Enveloppe les shapes via `useShapeWithDefaults()`.
|
||||
- Expose `useFestipodData()` (consommé par tous les écrans) + CRUD (`createEvent`, `updateEvent`, etc.).
|
||||
- **Provider selon le statut NG** :
|
||||
- Expose `useFestipodData()` (consommé par tous les écrans) + CRUD (`createEvent`, `updateEvent`, `joinEvent`, `leaveEvent`, etc.).
|
||||
- **Provider selon le statut de connexion** :
|
||||
- `disconnected` → `LocalDataProvider` avec seed (démo)
|
||||
- `connecting` → `LocalDataProvider` **vide** (évite de flasher le seed avant le chargement du wallet)
|
||||
- `connected` → `NgDataProvider` (données réelles du wallet)
|
||||
- `error` → `LocalDataProvider` avec seed (fallback gracieux)
|
||||
|
||||
> Réserve : certaines mutations (`joinEvent`/`leaveEvent`) sont encore des **no-ops** (`console.log`) en attendant le chantier données — cf. [[brief_2026-05-21_fork-nextgraph-inbox]] §Couche 3.
|
||||
> Les mutations sont **réellement persistées** en mode connected (`joinEvent` écrit une Participation et notifie l'hôte du PdR, `leaveEvent` supprime de façon autoritative — cf. [[caveat_participation-deletion]]). En mode local/demo elles sont des no-ops (cf. [[knowledge_context-internals]]).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Types de données Fp* (Event, UserProfile, Participation persistés NextGraph ; MeetingPoint et Friendship encore local-only)
|
||||
summary: Types de données Fp* — Event, UserProfile, Participation, MeetingPoint et Notification sont persistés NextGraph (shapes SHEX + ORM) ; seul Friendship reste local-only (app-TS)
|
||||
last_checked: 2026-07-03
|
||||
---
|
||||
|
||||
# Entités de données
|
||||
@@ -9,12 +10,15 @@ summary: Types de données Fp* (Event, UserProfile, Participation persistés Nex
|
||||
|
||||
| Type | Persistance | Champs clés |
|
||||
|---|---|---|
|
||||
| `FpEventData` | NextGraph (shape Event) | id, title, date, location, distance, themes |
|
||||
| `FpUserData` | NextGraph (shape UserProfile) | id, name, username, bio, city, counts |
|
||||
| `FpParticipationData` | NextGraph (shape Participation) | eventId + userId + confirmed |
|
||||
| `FpMeetingPointData` | **local-only** | eventId, location, time, host |
|
||||
| `FpEventData` | SDK (shape Event) | id, title, date, location, distance, themes |
|
||||
| `FpUserData` | SDK (shape UserProfile) | id, name, username, bio, city, counts |
|
||||
| `FpParticipationData` | SDK (shape Participation) | eventId + userId + confirmed |
|
||||
| `FpMeetingPointData` | SDK (shape MeetingPoint) | eventId, location, time, host |
|
||||
| `FpNotificationData` | SDK (shape Notification) | kind, target, source |
|
||||
| `FpFriendshipData` | **local-only** | userId + friendId |
|
||||
|
||||
`MeetingPoint` et `Friendship` n'ont **pas encore de shape SHEX** ni de persistance NextGraph (cf. [[knowledge_nextgraph-stack]]). Les brancher au store est un prérequis du multi-user — voir les briefs du concept `nextgraph-platform`.
|
||||
`MeetingPoint` et `Notification` ont de vraies **shapes SHEX** (`src/shared/shapes/shex/festipodShapes.shex`) avec bindings ORM générés (`festipodShapes.shapeTypes.ts` : `FpMeetingPointShapeType`, `FpNotificationShapeType`) et **sont persistés**. `Notification` est notamment créée lors de l'inscription à un point de rencontre (`joinEvent`).
|
||||
|
||||
`Friendship` n'a **pas** de shape SHEX ni de persistance — il reste app-TS-only (cf. [[knowledge_nextgraph-stack]]).
|
||||
|
||||
> Piège : même pour `FpEvent` (persisté), plusieurs champs du type app ne sont **pas** dans la shape et sont perdus en connecté — voir [[caveat_event-fields-not-persisted]].
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Paquets @ng-org/* (web, orm, shex-orm, alien-deepsignals), shapes SHEX festipodShapes, bindings ORM générés, régénérés via build:orm
|
||||
summary: Le SDK de données est @ng-eventually/client (traité comme un SDK NextGraph fini) — injecté une seule fois via ngSession.configure ; ORM réactif useShape sur shapes SHEX festipodShapes, bindings régénérés via build:orm ; ne jamais documenter l'état courant de NextGraph ici
|
||||
---
|
||||
|
||||
# Stack NextGraph (côté app)
|
||||
# Stack de données (SDK `@ng-eventually/client`)
|
||||
|
||||
Festipod persiste via **`@ng-eventually/client`** — le SDK NextGraph que l'app consomme. On le traite comme un **SDK fini et mature** : documents par entité placés par scope, capabilities, inboxes, ORM réactif.
|
||||
|
||||
```
|
||||
@ng-org/web # Runtime navigateur (proxy postMessage vers l'iframe)
|
||||
@ng-org/orm # ORM réactif basé sur les shapes RDF (useShape…)
|
||||
@ng-org/shex-orm # Génération SHEX → TypeScript
|
||||
@ng-org/alien-deepsignals # Pont de signaux réactifs
|
||||
@ng-eventually/client # LE SDK de données de l'app (ORM réactif useShape, docs, scopes, inbox)
|
||||
```
|
||||
|
||||
Installés depuis npm (`@ng-org/*`, versions alpha). Pour développer contre un build local non publié de `nextgraph-rs`, `scripts/build-ng-packages.sh` pack le monorepo en tarballs et repointe `package.json` (cf. `nextgraph-platform` — le pattern d'origine du projet, réactivable pour un fork).
|
||||
## Frontière SDK (règle d'or)
|
||||
|
||||
## Shapes SHEX
|
||||
- L'app **ne dépend que de `@ng-eventually/client`** pour la donnée.
|
||||
- Le SDK est **initialisé/injecté une seule fois** via `ngSession.configure(...)` (`src/shared/utils/ngSession.ts`) — point d'injection unique. Le reste de l'app (data-plane, lifecycle, login, types) passe par la lib.
|
||||
- **Ne jamais documenter dans ce repo l'état courant de NextGraph** (contraintes du SDK sous-jacent, contournements, internes broker/verifier) : cela vit dans le repo `@ng-eventually/client`. Ici on décrit seulement **comment Festipod utilise ce SDK**.
|
||||
|
||||
## ORM & shapes SHEX
|
||||
|
||||
L'ORM réactif (`useShape`) s'appuie sur des **shapes SHEX** : `src/shared/shapes/shex/festipodShapes.shex` définit :
|
||||
|
||||
`src/shared/shapes/shex/festipodShapes.shex` définit :
|
||||
- **Event** — titre, description, dates, lieu, thèmes, participants
|
||||
- **UserProfile** — nom, username, bio, ville, visibilité
|
||||
- **Participation** — lie event + user, statut de confirmation
|
||||
- **MeetingPoint** — point de rencontre (lieu, horaire, hôte)
|
||||
- **Notification** — notification (créée notamment à l'inscription à un PdR)
|
||||
|
||||
Bindings ORM dans `src/shared/shapes/orm/` (`*.schema.ts`, `*.shapeTypes.ts`, `*.typings.ts`). **Régénérer** avec `bun run build:orm` après toute modif `.shex`.
|
||||
Bindings ORM générés dans `src/shared/shapes/orm/` (`*.schema.ts`, `*.shapeTypes.ts`, `*.typings.ts`). **Régénérer** avec `bun run build:orm` après toute modif `.shex`.
|
||||
|
||||
> Manque côté shapes : **pas de `MeetingPoint`** ni d'entité notification — le point de rencontre est aujourd'hui local-only côté types (voir [[knowledge_entities]]). Leur modélisation est un chantier de [[brief_2026-05-21_fork-nextgraph-inbox]].
|
||||
> **Lecture recommandée = le hook réactif du SDK.** La façon canonique de lire, c'est `useShape` : on s'abonne à une shape sur un scope, on obtient la valeur courante, et le composant se re-rend à chaque changement (local **ou** distant synchronisé) — abonnement/push, jamais de polling ; les lectures one-shot sont l'exception. La référence complète du SDK (contrat de lecture/réactivité + où l'émulation courante diverge encore) vit côté lib : `packages/client/docs/sdk-reference.md` dans `@ng-eventually/client`. Ne pas recopier les internes NextGraph ici.
|
||||
|
||||
> `Friendship` n'a **pas** de shape SHEX ni de persistance — il reste app-TS-only (cf. [[knowledge_entities]]).
|
||||
|
||||
@@ -14,4 +14,4 @@ summary: seedData.ts fournit des fixtures déterministes (10 users, events, part
|
||||
|
||||
Ces fixtures servent (a) le **mode démo** (`LocalDataProvider`, cf. [[knowledge_data-modes]]) et (b) les tests **`@ui`** qui rendent les écrans avec ces données prévisibles (`Marie Dupont`/`@mariedupont` = currentUser, `Jean Durand`/`@jeandurand` existe, etc. — voir concept `bdd-testing`).
|
||||
|
||||
> `bootstrapWallet()` (`src/shared/utils/ngBootstrap.ts`) seede ces données dans le wallet NG en mode connected — déclenché uniquement par action explicite de l'utilisateur (« Charger données de test »). Sa refonte par documents/périmètres est un point des briefs `nextgraph-platform`.
|
||||
> `bootstrapWallet()` (`src/shared/utils/ngBootstrap.ts`) seede ces données dans le wallet en mode connected — déclenché uniquement par action explicite de l'utilisateur (« Charger données de test »).
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
type: rule
|
||||
summary: L'app se comporte EXACTEMENT comme si NextGraph était fini et sans défaut — elle ne consomme QUE des surfaces SDK-shaped (`useShape`, `docs`, `inbox`…) et ne raisonne JAMAIS sur un problème courant de NextGraph (hang du fan-out ORM, cold-open, etc.). La raison d'être du polyfill est le WALLET VIRTUEL ; tout contournement interne (read-model union, subscribeDoc, open-repo…) vit DANS le polyfill, invisible à l'app.
|
||||
---
|
||||
|
||||
# L'app n'utilise que la surface SDK — jamais les internes du polyfill
|
||||
|
||||
## La règle
|
||||
|
||||
L'app Festipod traite `@ng-eventually/client` comme un **SDK NextGraph fini et sans
|
||||
défaut**. Concrètement :
|
||||
|
||||
1. **Lecture réactive = `useShape`** (la surface SDK-shaped, fournie par le polyfill,
|
||||
**scopée au wallet virtuel**). L'app ne lit PAS via des internes du polyfill
|
||||
(`readModel.readUnion`, `subscribeDoc`, un read-model maison…), et ne monte PAS sa
|
||||
propre réactivité (re-run sur signal).
|
||||
2. **L'app ne raisonne JAMAIS sur l'état courant de NextGraph** : pas de code ni de
|
||||
commentaire du type « on fait X parce que le fan-out ORM hang / parce que la lecture
|
||||
à froid rend 0 ». Ces problèmes n'existent pas du point de vue de l'app.
|
||||
|
||||
## La raison d'être du polyfill = le WALLET VIRTUEL
|
||||
|
||||
Le polyfill existe pour émuler le **wallet virtuel** (plusieurs identités sur un seul
|
||||
wallet physique), que NextGraph ne fournit pas encore nativement. **Ce n'est PAS**
|
||||
« parce que le fan-out ORM hang » — ça, c'est un simple **détail d'implémentation
|
||||
interne** de la façon dont le polyfill livre un `useShape` fonctionnel. Tous les
|
||||
contournements (read-model union à la place du fan-out ORM, `open-repo`, readiness
|
||||
miroir de `readyPromise`, émulation de caps…) sont **internes au polyfill** et
|
||||
n'apparaissent jamais dans l'app.
|
||||
|
||||
## État (déviation résolue)
|
||||
|
||||
**Résolu** : `FestipodDataContext` lit désormais via `useShapeQuery` (binding
|
||||
`useSyncExternalStore` sur `watchShape` du polyfill) + adaptateurs Fp
|
||||
(`src/shared/data/shapeAdapters.ts`). Sont **supprimés** : `readEntities.ts`, la
|
||||
réactivité bespoke (`subscribeDocs`+`bumpRead`+`readTick`), le listing manuel
|
||||
(`publicDocs`/`protectedDocs`/`registerDoc` pour la lecture), et les commentaires
|
||||
raisonnant sur le hang ORM. L'auto-seed est gardé sur `isSuccess` (plus de
|
||||
chronomètre 3 s). L'app ne consomme plus que la surface SDK.
|
||||
|
||||
**Cible (rappel du design)** : le polyfill expose un `useShape` **réactif, scopé au wallet virtuel**, dont
|
||||
la **forme suit TanStack `useQuery`** — `{ data, isPending/isLoading, isSuccess, isError,
|
||||
… }` — **en anticipation de la mise à jour PRÉVUE de `useShape` par NextGraph** (qui va
|
||||
adopter ce fonctionnement). Ce n'est donc pas une invention : c'est une API future de
|
||||
NextGraph, émulée d'avance, qui s'aligne quand NextGraph la livre. Elle **distingue
|
||||
nativement** `isPending` (sync en cours) de `isSuccess` + `data` vide (synchronisé,
|
||||
réellement vide) — exactement le besoin. En interne, le hook encapsule readUnion sur
|
||||
`subscribeDoc` + le scoping identité (invisible à l'app). L'app **supprime** sa
|
||||
machinerie bespoke (`readEntities`/`subscribeDocs`/`bumpRead`) et lit via ce hook.
|
||||
|
||||
Le bug d'auto-seed (chronomètre 3 s) est un **symptôme** : avec `isSuccess`, l'auto-seed
|
||||
décide « vide » seulement une fois la sync confirmée, au lieu de deviner un délai. Voir
|
||||
[[rule_no-broker-polling]] et [[knowledge_nextgraph-stack]].
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
type: rule
|
||||
summary: N'auto-initialiser NextGraph que dans l'iframe broker (window.self !== window.top) ; en standalone, initNgWeb() redirige toute la page — attendre un connect() explicite
|
||||
---
|
||||
|
||||
# Règle : auto-init NextGraph seulement dans l'iframe broker
|
||||
|
||||
`initNgWeb()` de `@ng-org/web` teste `window.self === window.top`. **Hors iframe** (app standalone), il **redirige toute la page** vers `nextgraph.net/redir/` pour déclencher l'auth broker.
|
||||
|
||||
Donc `NextGraphContext` calcule `isInsideBroker = window.self !== window.top` et **n'auto-appelle `initNg()` que si `isInsideBroker`**. En standalone, la connexion attend un `connect()` explicite (clic « Se connecter ») — sinon l'app redirige à chaque chargement et casse le dev/démo.
|
||||
|
||||
De plus, `FestipodDataContext` rend des données **vides** (pas le seed) pendant la phase `connecting`, pour éviter de flasher du contenu démo avant le chargement du wallet (voir [[knowledge_data-modes]]).
|
||||
|
||||
> Garder ce garde **aligné** sur la détection interne de `@ng-org/web` : si leur heuristique change, le nôtre doit suivre. Pourquoi + alternatives : [[decision_2026-03-13_conditional-ng-init-broker-detection]].
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
type: rule
|
||||
summary: Festipod persiste CHAQUE entité comme SON PROPRE document (via le SDK), placé dans son scope (public/protected/private) — jamais plusieurs entités écrites dans un document de niveau store. Le document est l'unité de partage et de droits : l'isolation du SDK est PAR-DOCUMENT, donc un document par entité est ce qui la rend possible.
|
||||
---
|
||||
|
||||
# Règle : un document par entité (jamais au niveau du store)
|
||||
|
||||
Quand Festipod crée une entité (événement, point de rencontre, profil, participation,
|
||||
notification), il l'écrit comme **son propre document**, via l'appel « créer un document » du
|
||||
SDK de données ([[knowledge_nextgraph-stack]]), en indiquant son **scope**
|
||||
(`public` / `protected` / `private`). L'entité est ensuite lue et écrite dans **ce** document.
|
||||
|
||||
**Ne jamais** écrire plusieurs entités dans un document partagé « de niveau store » (p. ex.
|
||||
tout mettre dans un seul document racine). C'est un anti-pattern qui casse l'isolation.
|
||||
|
||||
## Pourquoi
|
||||
|
||||
Le **document est l'unité de partage et de droits** du SDK : l'isolation (qui peut lire quoi)
|
||||
est appliquée **par document**. `private` → le propriétaire ; `protected` → le propriétaire +
|
||||
ses connexions ; `public` → tout le monde. Cette discrimination n'est possible **que si chaque
|
||||
entité a son propre document** : mettre plusieurs entités (voire plusieurs propriétaires) dans
|
||||
un même document rend le partage tout-ou-rien et défait l'isolation par périmètre.
|
||||
|
||||
L'isolation elle-même est **entièrement assurée par le SDK** ([[knowledge_trust-model]] du
|
||||
concept `app-security`) — l'app ne porte aucune logique d'accès ; elle déclare seulement son
|
||||
identité (au login) et ses connexions (acte de partage), puis fait confiance à ce que le SDK
|
||||
renvoie. La granularité « un document par entité » est la contrepartie côté écriture de cette
|
||||
confiance.
|
||||
|
||||
## Comment l'appliquer
|
||||
|
||||
- À la création : demander au SDK **un document pour l'entité, dans son scope**
|
||||
(`createEntityDoc(scope)`) ; y écrire l'entité. Ne pas réutiliser un document d'un autre
|
||||
périmètre ni un document de niveau store.
|
||||
- En lecture : passer par le SDK via le **modèle de lecture union** (voir plus bas) — l'app
|
||||
résout un jeu de documents *par besoin* (index de découverte pour les événements publics ;
|
||||
ses propres documents de scope pour ses entités) et le SDK ouvre/synchronise puis lit
|
||||
l'union en **une seule** requête ; pas de résolution de NURI ni de choix union/ancré côté app.
|
||||
- Le mapping *entité → scope* (événement/PdR → public, profil réseau/participation → protected,
|
||||
settings → private) est un fait produit (concept `functional-domain`,
|
||||
[[knowledge_data-scopes-and-discovery]]).
|
||||
|
||||
## Lecture : modèle union (open/sync + une requête ancrée-libre + re-query)
|
||||
|
||||
La **lecture** ne passe **PAS** par un abonnement ORM réactif en fan-out sur un jeu de documents
|
||||
par-entité (`useShape({ graphs: […] })`) : contre le vrai broker un document fraîchement créé /
|
||||
non-synchronisé dans ce fan-out fait avorter tout l'abonnement (`RepoNotFound`) → l'abonnement
|
||||
n'émet jamais son initial → **hang ~75 s**. À la place, la lecture est le **modèle union** du SDK
|
||||
([[knowledge_nextgraph-stack]], SDK `docs/read-model.md`) :
|
||||
|
||||
1. **résoudre par besoin** le jeu de NURIs à lire — événements publics via l'**index de découverte**
|
||||
(la seule énumération cross-comptes sanctionnée) ; « mes entités » (profil, participations) via
|
||||
**mes propres** documents de scope (`listMyEntityDocs(username, scope)`, borné à mon compte —
|
||||
jamais de fan-out sur tous les comptes) ;
|
||||
2. le SDK **ouvre/synchronise** ces documents puis exécute **UNE** requête `sparql_query`
|
||||
**sans ancre** sur l'union locale (`GRAPH ?g { … }`) et rend les triplets groupés par sujet
|
||||
(`src/shared/data/readEntities.ts` → `readModel.readUnion`) ;
|
||||
3. il n'y a **pas** de requête union réactive → la **réactivité = re-query** sur un signal de
|
||||
changement (un document créé/enregistré déclenche `bumpRead`).
|
||||
|
||||
Côté app, `FestipodDataContext` collecte les NURIs par besoin puis appelle `readEntities` ;
|
||||
un document fraîchement créé est aussi enregistré localement (`registerDoc`) pour apparaître
|
||||
immédiatement, avant que la re-liste ne le rattrape.
|
||||
|
||||
## Écriture directe (piège d'aller-retour)
|
||||
|
||||
L'**écriture** d'une entité se fait **directement dans son propre document** (via l'appel
|
||||
SPARQL du SDK — `src/shared/data/entityWrites.ts`, `writeEntity`), **pas** via l'ajout à un
|
||||
ensemble réactif. Raison : un ensemble réactif n'est *inscriptible* que si le document cible est
|
||||
**déjà** dans son scope d'abonnement ; or enregistrer le document fraîchement créé est un état
|
||||
React qui ne prend effet qu'au rendu **suivant** → on ne peut pas créer-puis-ajouter en une passe
|
||||
synchrone (boucle de seed, première création). Contre le vrai broker, un `add` sur un scope vide
|
||||
lève « Set is readonly because scope is empty » (les tests unitaires fake-ng ne l'attrapent pas).
|
||||
|
||||
Donc : **écriture = SPARQL direct dans le doc de l'entité** (immédiat, par-document) ;
|
||||
**lecture = union + re-query** (ci-dessus).
|
||||
|
||||
**Convention de graphe (écrire dans le graphe par défaut ancré).** L'écriture passe le NURI du
|
||||
document comme **ancre** de `docs.sparqlUpdate` et écrit le corps SPARQL **sans** clause
|
||||
`GRAPH <…>` explicite ; la lecture union interroge le même graphe par défaut ancré
|
||||
(`readEntities`/`readUnion`). C'est la forme **canonique et toujours sûre** — à conserver pour
|
||||
`writeEntity`, `updateEntityField` et `registration.ts`.
|
||||
|
||||
> **Correction (2026-07-06).** Un commentaire antérieur (et une version de ce paragraphe)
|
||||
> affirmaient qu'un corps `GRAPH <nuriDuDoc>` explicite écrit dans un graphe *nommé distinct* que
|
||||
> la lecture ancrée ne verrait pas → l'entité « disparaîtrait ». **C'est faux sur le broker
|
||||
> courant** (`@ng-org/web 0.1.2-alpha.13`) : le harness e2e réel de la lib
|
||||
> (`packages/client/e2e/`) vérifie qu'un `INSERT DATA { GRAPH <plainNuri> {…} }` **ancré** au doc
|
||||
> round-trippe (relu aussi bien en graphe par défaut qu'en `GRAPH <plainNuri>`). Le symptôme « 0
|
||||
> entité » qu'on avait attribué à ce « piège » venait en réalité du **hang de wallet gonflé** (cf.
|
||||
> `bdd-testing/caveat_wallet-bloat-hang`), pas d'un mismatch de graphe. La règle « sans wrapper
|
||||
> `GRAPH` » reste donc un choix de **simplicité/sûreté**, pas une nécessité de round-trip. (Le
|
||||
> *pourquoi* côté SDK vit dans `@ng-eventually/client`, pas ici.)
|
||||
|
||||
Idem pour la **mutation d'un champ** existant (p. ex. `participantCount`) : muter une valeur
|
||||
en mémoire ne tient pas — la re-query union relit la valeur **persistée** depuis le broker
|
||||
(retour à l'ancienne valeur) → persister via SPARQL (`updateEntityField` : DELETE puis
|
||||
INSERT du triplet) pour que le changement tienne et que la relecture concorde. Chaque champ est écrit avec le **bon terme RDF** selon la shape SHEX (xsd:integer /
|
||||
float / boolean, ou IRI pour les références `Participation.event`/`.user`) — un champ obligatoire
|
||||
manquant ou mal typé fait que la lecture **jette l'entité** (elle ne fait jamais
|
||||
l'aller-retour). Le **sujet** de l'entité = le **NURI de son document** (une entité = un document),
|
||||
ce qui donne un `@id` en `did:ng:…`.
|
||||
|
||||
Corollaire d'identité : une `Participation` porte un `fp:user` **obligatoire** — ne jamais
|
||||
l'écrire avec un principal vide (l'entité serait jetée en lecture). Le principal du user courant
|
||||
est **stable et dérivé du username** (`urn:festipod:user:<username-normalisé>`), disponible
|
||||
**immédiatement** après login (pas de dépendance à la lecture du profil protégé, qui peut
|
||||
retarder) et **invariant** (il ne bascule pas d'un fallback vers l'IRI de profil en cours de
|
||||
session, ce qui désynchroniserait une participation écrite sous une valeur d'une vérification
|
||||
sous l'autre). C'est le même principal que l'identité SDK (`setCurrentUser`) et le cap owner
|
||||
dérivent du username ; les connexions bilatérales (`declareConnections`) se déclarent avec ces
|
||||
mêmes clés username (pas des IRIs de profil) pour que « protégé = mes connexions » discrimine.
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
type: rule
|
||||
summary: Utiliser did:ng:${private_store_id} comme scope useShape ET comme @graph d'écriture ; ne jamais utiliser did:ng:i comme scope (casse toutes les écritures par RepoNotFound)
|
||||
---
|
||||
|
||||
# Règle : scope = `@graph` = private_store_id
|
||||
|
||||
Pour lire **et** écrire via l'ORM NextGraph :
|
||||
|
||||
- **Scope** : `useShape(shapeType, \`did:ng:${session.private_store_id}\`)`
|
||||
- **`@graph`** (cible des écritures) : `did:ng:${session.private_store_id}`
|
||||
|
||||
C'est critique : `orm_start_graph` avec le NURI du private_store **ouvre explicitement le repo** dans la HashMap `self.repos` du verifier. Sans ça, `orm_frontend_update` échoue en `RepoNotFound`.
|
||||
|
||||
## Interdit
|
||||
|
||||
**Ne pas utiliser `did:ng:i` comme scope.** Il s'abonne au site entier de l'utilisateur via un chemin de code spécial (`NuriTargetV0::UserSite`) qui **n'ouvre pas les repos individuels** → casse toutes les écritures.
|
||||
|
||||
## Fichiers porteurs
|
||||
|
||||
- `src/shared/hooks/useShapeWithDefaults.ts` — accepte un `storeNuri`, le passe à `useShape`.
|
||||
- `src/shared/utils/ngGraph.ts` — `ensureGraphNuri()` retourne le `@graph` (entités existantes d'abord, sinon fallback `private_store`).
|
||||
- `src/shared/utils/ngBootstrap.ts` — seede en utilisant `ensureGraphNuri()`.
|
||||
|
||||
> Le *pourquoi* complet et les alternatives écartées : [[decision_2026-03-17_private-store-nuri-scope]]. **Ce scope mono-store est précisément ce que le chantier multi-store viendra remplacer** — voir [[brief_2026-05-17_multi-store-refactor]].
|
||||
@@ -0,0 +1,10 @@
|
||||
# Doc-debt — functional-domain
|
||||
|
||||
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
|
||||
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
|
||||
|
||||
## Raw markers (consolidate into blocks, then delete)
|
||||
- TOUCHED src/modules/event/features/reconnexion-persistance-e2e.feature @2026-07-13 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/features/reconnexion-socket-mort.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/features/reconnexion-meme-identite.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/features/reconnexion-froide-sans-local.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
type: _overview
|
||||
summary: Modèle produit Festipod — le point de rencontre greffé sur un événement public comme unité de valeur, ses acteurs et ses concepts métier
|
||||
summary: Modèle produit Festipod — le point de rencontre greffé sur un événement public comme unité de valeur, ses acteurs, ses concepts métier, et les périmètres de confidentialité (public/protected/private) par entité
|
||||
triggers:
|
||||
keywords: [point de rencontre, rencontre, greffe, greffer, événement, déclarant, hôte, inscrit, inscription, communauté, connexion, festival, déduplication]
|
||||
keywords: [point de rencontre, rencontre, greffe, greffer, événement, déclarant, hôte, inscrit, inscription, communauté, connexion, festival, déduplication, découverte, périmètre, scope, public, protected, privé]
|
||||
paths: ["src/modules/*/features/**"]
|
||||
---
|
||||
|
||||
@@ -16,13 +16,14 @@ Le **domaine fonctionnel** de Festipod : ce que le produit promet et le vocabula
|
||||
|
||||
Festipod laisse les utilisateurs créer des **points de rencontre** qui se *greffent* sur des **événements publics** existants. L'événement (festival, conférence…) n'est qu'un *prétexte* et un point d'ancrage spatio-temporel ; la valeur produite, c'est le point de rencontre. **On s'inscrit à un point de rencontre, jamais à un événement.**
|
||||
|
||||
## Périmètre & sécurité
|
||||
## Périmètre & confidentialité
|
||||
|
||||
Le modèle d'**autorisations / confidentialité** (qui voit quoi : « données personnelles = réseau seulement », anonymat via inbox, capabilities) n'est pas encore implémenté — il vit aujourd'hui comme incubation dans [[brief_2026-05-18_authorization-matrix]] (concept `nextgraph-platform`). Il graduera en règles/`behavior_` quand le multi-user atterrira. C'est la raison pour laquelle il n'y a pas encore de concept `app-security` distinct.
|
||||
Le modèle produit de **qui voit quoi** — données personnelles réservées au réseau, événements/PdR publics, notification d'inscription identifiée-ou-anonyme — est un fait métier : voir [[knowledge_data-scopes-and-discovery]]. La matrice d'autorisations détaillée (acteur × verbe) et son incubation vivent dans le concept `app-security` ([[brief_2026-05-18_authorization-matrix]]).
|
||||
|
||||
## Liens
|
||||
|
||||
- [[knowledge_business-model]] — l'inversion événement / point de rencontre
|
||||
- [[knowledge_actors-and-concepts]] — référence des acteurs et concepts métier
|
||||
- [[knowledge_data-scopes-and-discovery]] — périmètres public/protected/private par entité + découverte
|
||||
- [[knowledge_roadmap]] — fonctionnalités actuelles vs évolutions à venir
|
||||
- [[brief_2026-06-15_event-deduplication]] — défi ouvert de déduplication des événements en P2P
|
||||
- `nextgraph-platform` — où vit la dérivation de la structure de données cible (authz matrix, multi-store)
|
||||
|
||||
@@ -13,7 +13,7 @@ Référence du vocabulaire. Tous les acteurs sont des spécialisations d'un **ut
|
||||
|---|---|
|
||||
| **Utilisateur** | Toute personne ayant un compte (un wallet NextGraph). Racine de tous les autres. |
|
||||
| **Connexion (« ami »)** | Un autre utilisateur avec qui je suis connecté. Sert à scoper les listes (« mes amis qui participent à… ») et la confiance. Bilatérale (acceptation des deux côtés). |
|
||||
| **Déclarant d'un événement** | L'utilisateur qui a inséré l'événement dans Festipod. *N'est pas forcément l'organisateur réel* : juste celui qui le référence. |
|
||||
| **Déclarant d'un événement** | L'utilisateur qui a inséré l'événement dans Festipod. *N'est pas forcément l'organisateur réel* : juste celui qui le référence. **Il n'y a PAS de notion d'« hôte d'événement »** : l'événement est public, simplement signalé par son déclarant, qui **n'est PAS obligé de participer** — à la création aucune participation n'est écrite, le compteur démarre à 0, et le déclarant peut rejoindre/quitter comme tout le monde (décision produit ; côté données cf. data-layer/[[knowledge_context-internals]] §participantCount). L'« hôte » reste un acteur au niveau du **point de rencontre** (ligne suivante), pas de l'événement. |
|
||||
| **Hôte d'un point de rencontre** | L'utilisateur qui a créé un point de rencontre rattaché à un événement. |
|
||||
| **Inscrit à un point de rencontre** | Un utilisateur inscrit à un point de rencontre ; de fait il devient participant à l'événement parent. |
|
||||
| **Membre d'une communauté d'intérêt** | Un utilisateur abonné à une communauté pour découvrir les événements qu'elle référence. |
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Modèle produit de confidentialité et de découverte — chaque entité vit dans un SCOPE (public / protected / private) selon qui doit la voir ; événements & points de rencontre = public, profil réseau & participations = protected (réseau), settings = private ; connexions bilatérales = scope dialog ; la découverte lit un index global d'événements
|
||||
---
|
||||
|
||||
# Périmètres de données et découverte
|
||||
|
||||
Le modèle **produit** de qui voit quoi, et comment on trouve les événements. C'est du **domaine** : le *comment* technique (documents, capabilities, index) est assuré par le SDK de données `@ng-eventually/client` — l'app décrit seulement **l'intention métier**.
|
||||
|
||||
## Trois périmètres (scopes) par donnée
|
||||
|
||||
Chaque entité est stockée dans le **scope** correspondant à qui doit pouvoir la lire :
|
||||
|
||||
| Entité | Scope | Qui lit |
|
||||
|---|---|---|
|
||||
| Événement (l'ancrage) | **public** | tout le monde |
|
||||
| Point de rencontre (PdR) | **public** | tout le monde |
|
||||
| Profil réseau (nom, avatar, bio, ville, intérêts) | **protected** | le titulaire + ses connexions |
|
||||
| Participation / inscription à un PdR | **protected** | l'inscrit + ses connexions |
|
||||
| Index des connexions | **protected** | le titulaire + ses connexions |
|
||||
| Profil privé (settings, email, préférences) | **private** | le titulaire seul |
|
||||
| Connexion A↔B (lien bilatéral, + messagerie future) | **dialog** | les deux utilisateurs |
|
||||
|
||||
Principe directeur : **le statut « public » (PdR, événement) et « personnel » (profil, participations, connexions) coexistent dans un même utilisateur.** Les informations personnelles sont réservées au **réseau** (connexions bilatérales), jamais visibles d'un utilisateur lambda.
|
||||
|
||||
- **PdR / événement = publics universels.** Tout utilisateur peut lire et s'abonner ; créer un PdR rend hôte, créer un événement rend déclarant (aucun prérequis).
|
||||
- **Hôte = seul détenteur des droits d'écriture** sur son PdR ; le déclarant n'a aucun droit particulier sur les PdR greffés sur son événement.
|
||||
- **Connexion bilatérale** : `DemandeDeConnexion` (unilatérale, transitoire) → `Connexion` (bilatérale, persistante) — cette dernière ouvre l'accès aux données *protected* de l'autre.
|
||||
|
||||
Festipod **place chaque entité dans le store de son scope** ; l'isolation entre scopes est **assurée par le SDK de données**, pas par du code applicatif (cf. concept `app-security`).
|
||||
|
||||
## Découverte des événements
|
||||
|
||||
Un utilisateur découvre les événements qu'il n'a pas créés via un **index global** : le SDK lit cet index, qui donne les références (NURIs) des documents-événements, puis synchronise et interroge en local. La découverte **primaire** passe par cet index ; un **axe secondaire** relationnel s'y superpose (les participations *protected* des connexions : « mes amis participent à… »).
|
||||
|
||||
> **Notification d'inscription (intention produit).** S'inscrire à un PdR notifie son hôte : identifié si l'inscrit fait partie des connexions de l'hôte, **anonyme sinon**. Ce « identifié si connu, anonyme sinon » est une propriété du modèle de données — l'app y compte, le mécanisme est fourni par le SDK.
|
||||
|
||||
## Questions ouvertes (métier)
|
||||
|
||||
- **Modèle d'écriture de l'événement** : propriétaire (déclarant seul) / wiki (tous) / immuable ? Central pour la déduplication ([[brief_2026-06-15_event-deduplication]]).
|
||||
- **Identité de l'hôte vis-à-vis d'un lambda** : un PdR est lisible par tous, mais faut-il que son hôte soit identifiable ? (pseudonyme par défaut, carte de visite par PdR, ou anonymat révélé aux seules connexions.)
|
||||
- **Champs modifiables d'une inscription** ; **découvrabilité « amis d'amis »**.
|
||||
|
||||
> La matrice d'autorisations détaillée par acteur × verbe vit dans le concept `app-security` ([[brief_2026-05-18_authorization-matrix]]).
|
||||
@@ -15,11 +15,11 @@ summary: Ce qui est implémenté aujourd'hui (cycle événement + point de renco
|
||||
- Profil utilisateur, mise à jour, partage de profil
|
||||
- Liste d'amis (connexions), profil d'un autre utilisateur
|
||||
|
||||
> Réserve : certaines actions de données restent des no-ops en l'état (ex. `joinEvent`/`leaveEvent` côté `FestipodDataContext` — détail dans [[brief_2026-05-21_fork-nextgraph-inbox]] §Couche 3). Le router et les écrans existent, mais le branchement données suit le chantier multi-store.
|
||||
> L'inscription/désinscription au point de rencontre est **réellement branchée** côté données : `joinEvent` persiste une Participation, notifie l'hôte du PdR et crée une Notification ; `leaveEvent` supprime la Participation de façon autoritative (cf. concept `data-layer`, [[caveat_participation-deletion]] côté data-layer). La découverte publique — un utilisateur voit un événement public d'un autre — fonctionne aussi.
|
||||
|
||||
## Évolutions identifiées (non implémentées)
|
||||
|
||||
- **Abonnement à une communauté d'intérêt** pour découvrir ses événements (discovery distribué).
|
||||
- **Abonnement à un utilisateur** pour suivre ses déclarations sans être ami.
|
||||
- **Listes curated** — créer/partager des sélections éditorialisées.
|
||||
- **Multi-utilisateurs collaboratif** : aujourd'hui chaque utilisateur a ses données isolées dans son wallet. Le passage collaboratif (un point de rencontre vu par plusieurs) suppose un refactor de la couche données — voir [[brief_2026-05-17_multi-store-refactor]].
|
||||
- **Multi-utilisateurs collaboratif** : le partage effectif d'un point de rencontre vu par plusieurs utilisateurs, appuyé sur les périmètres public/protected/private (cf. [[knowledge_data-scopes-and-discovery]]).
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
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, 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
|
||||
|
||||
Deux choses ici, distinctes du concept `data-layer` (qui décrit l'**usage actuel** de NextGraph par l'app) :
|
||||
|
||||
1. **Référence du système externe NextGraph** — ses primitives de stockage et de permission, son inbox, son modèle d'intégration/déploiement, et ce que son SDK JS expose (ou pas).
|
||||
2. **Briefs prospectifs** — la dérivation de la structure de données *cible* de Festipod et les chemins pour y arriver (stopgap wallet partagé, refactor multi-store, fork moteur pour l'inbox).
|
||||
|
||||
> Le code de l'app touché par ces chantiers : `src/shared/utils/ngGraph.ts`, `useShapeWithDefaults.ts`, `FestipodDataContext.tsx`, `ngBootstrap.ts` — les seams du futur multi-store. Le modèle de **confidentialité/autorisations** (qui peut faire quoi) vit dans le concept `app-security` ([[brief_2026-05-18_authorization-matrix]]) ; ces chantiers data en sont l'infrastructure.
|
||||
|
||||
## Source locale
|
||||
|
||||
Le repo `nextgraph-rs` est cloné en `/home/sylvain/projects/nextgraph/nextgraph-rs` (soit `../../nextgraph/nextgraph-rs` depuis la racine projet). À consulter pour vérifier ce qui est réellement exposé au protocole/SDK plutôt que la doc.
|
||||
|
||||
## Référence (système externe)
|
||||
|
||||
- [[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,84 +0,0 @@
|
||||
---
|
||||
type: brief
|
||||
summary: Passer du mono-store actuel (tout dans private_store) à une structure de stores par entité ; hardcoding dans ngGraph.ts + useShapeWithDefaults ; contrainte SDK bloquante (Group stores/inbox non exposés) ; refactor structurel possible avec placeholders en attendant l'API
|
||||
last_updated: 2026-05-17
|
||||
---
|
||||
|
||||
# Refactor multi-store NextGraph
|
||||
|
||||
**Status:** Incubating — aucun travail démarré
|
||||
**Last updated:** 2026-05-17
|
||||
|
||||
## Context
|
||||
|
||||
L'app est aujourd'hui *mono-store* : tout (events, profils, participations, friendships) atterrit dans le `private_store` de l'utilisateur connecté. Héritage de l'exemple expense-tracker-rdf, formalisé dans la décision du 2026-03-17 (concept `data-layer`, [[decision_2026-03-17_private-store-nuri-scope]]).
|
||||
|
||||
Ce choix bloque le multi-utilisateurs : le `private_store` est non partageable (*« not possible to share the documents of your private store »*, cf. [[knowledge_stores-permissions]]). Tant que tout y est, Bob ne verra jamais l'event d'Alice. Le modèle natif NextGraph est *multi-store par utilisateur* — Festipod doit s'y aligner avant de devenir collaboratif.
|
||||
|
||||
**Déclencheur :** discussion du 2026-05-17 — *poser le cap, exécuter plus tard*.
|
||||
|
||||
## What We Know
|
||||
|
||||
### État actuel du code
|
||||
|
||||
Deux fichiers concentrent le hardcoding du store unique :
|
||||
- `src/shared/utils/ngGraph.ts` — `ensureGraphNuri()` retourne `did:ng:${session.private_store_id}` pour TOUTES les entités.
|
||||
- `src/shared/hooks/useShapeWithDefaults.ts` — accepte un `storeNuri` mais l'appelant unique (`FestipodDataContext`) lui passe toujours le NURI du private_store.
|
||||
|
||||
Entités impactées (toutes mélangées) : `FpEvent` (→ store partagé), `FpUserProfile` (→ partie privée/publique), `FpParticipation` (→ avec son event), `FpMeetingPoint` (local-only aujourd'hui), `FpFriendship` (local-only, privée). Cf. concept `data-layer` §entités.
|
||||
|
||||
### Modèle cible proposé
|
||||
|
||||
> **Note (2026-05-19)** : [[brief_2026-05-18_authorization-matrix]] a depuis dérivé, à partir des seuls points validés, une structure différente — 3 stores natifs par utilisateur + Dialog stores, **sans Group store** dans le périmètre actuel. La structure à 4 niveaux ci-dessous reste pertinente pour le périmètre élargi (communautés, collaboration multi-hôte), aujourd'hui hors périmètre. À reconcilier à l'exécution.
|
||||
|
||||
Structure hiérarchique en **4 niveaux de Group stores** : index communautaire ⊃ communauté ⊃ event ⊃ meeting point.
|
||||
|
||||
| Entité | Store cible | Justification |
|
||||
|---|---|---|
|
||||
| Event (métadonnées) | Group « communauté » | La communauté possède l'event → contrôle qui le modifie |
|
||||
| Référence d'event (pointeur) | Group « index communautaire » | Discovery |
|
||||
| Participation | Group « event » | N'a de sens que dans son event |
|
||||
| MeetingPoint (métadonnées) | Group « event » | Le RDV appartient à l'event |
|
||||
| Participation à un MeetingPoint | Group « meeting point » | RSVP scopé au RDV |
|
||||
| UserProfile (partie publique) | public_store de l'utilisateur | Modèle natif |
|
||||
| Friendship | private_store de l'utilisateur | Purement personnelle |
|
||||
|
||||
### Contrainte SDK bloquante
|
||||
|
||||
Primitives présentes au protocole mais **non exposées dans `@ng-org/web`** (vérifié `0.1.2-alpha.13`) : création de Group stores + invitations/permissions ; **dépôt/lecture d'inbox** (mécanisme retenu pour la notif d'inscription, cf. [[brief_2026-05-18_authorization-matrix]]). `app_request_stream` est la méthode générique la plus susceptible de porter ce mécanisme une fois exposée (à confirmer côté Rust). Cf. [[knowledge_stores-permissions]] §Limites SDK.
|
||||
|
||||
**Implication :** le refactor *structurel* peut commencer sans attendre l'API, avec des placeholders (continuer à pointer `private_store_id` pour les Group stores impossibles). L'**aboutissement complet** (vrai multi-user) dépend de l'arrivée de l'API ou d'un contournement (voir [[brief_2026-05-21_fork-nextgraph-inbox]], [[brief_2026-06-15_shared-wallet-shim]]).
|
||||
|
||||
### Implications côté code
|
||||
|
||||
1. **Disparition de `ensureGraphNuri()`** comme helper unique → helpers par entité ou couche `storeRegistry` résolvant le NURI selon `(entité, contexte)`.
|
||||
2. **`useShapeWithDefaults` reste un wrapper** mais l'appelant choisit explicitement le store (N appelants demain).
|
||||
3. **Chaque entité déclare son store cible** (mapping centralisé ou convention shape→store).
|
||||
4. **`bootstrapWallet()`** (`src/shared/utils/ngBootstrap.ts`) revu : seed réparti, ou seed = données de l'utilisateur courant seulement.
|
||||
5. **`FestipodDataContext`** : hooks par entité, chacun avec son store résolu.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Quand crée-t-on un Group store de communauté (API absente) ? Acte explicite vs communauté par défaut ?
|
||||
2. Comment Bob connaît-il l'index communautaire d'Alice ? (possiblement via le public_store d'Alice)
|
||||
3. Faut-il vraiment 4 niveaux ? Le « meeting point = group store » mérite validation.
|
||||
4. Que devient le seed de démo quand les Group stores n'existent pas encore ?
|
||||
5. Migration des wallets de test existants (script / wipe-reseed / ignore) ?
|
||||
6. Bootstrap d'un user vierge : auto-créer un Group store « par défaut » ou attendre ?
|
||||
|
||||
## Possible Approaches
|
||||
|
||||
- **Refactor structurel d'abord, partage ensuite** (placeholders `private_store_id`).
|
||||
- **Registry centralisé** vs **résolution par convention**.
|
||||
- **Big-bang** vs **par entité** (commencer par Event).
|
||||
- **Maintenir un mode mono-store** parallèle pour dev/demo.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
Invitation effective (capability sharing), permissions par rôle, discovery cross-wallet, contournement de l'UI wallet, mode P2P direct sans broker. → second chantier multi-user dont ce refactor est le prérequis structurel.
|
||||
|
||||
## Starting Points
|
||||
|
||||
- Concept `data-layer` → [[decision_2026-03-17_private-store-nuri-scope]] (la décision qu'on viendra modifier), état du pattern d'écriture
|
||||
- `src/shared/utils/ngGraph.ts`, `src/shared/hooks/useShapeWithDefaults.ts`, `src/shared/context/FestipodDataContext.tsx`, `src/shared/utils/ngBootstrap.ts`
|
||||
- NextGraph docs : [Documents et Stores](https://docs.nextgraph.org/en/documents/), [Getting started](https://docs.nextgraph.org/en/getting-started/)
|
||||
@@ -1,96 +0,0 @@
|
||||
---
|
||||
type: brief
|
||||
summary: Forker temporairement nextgraph-rs pour exposer l'inbox au SDK JS (notif d'inscription, anonymat via from optionnel) — 3 couches : patch Rust (4 fichiers), auto-hébergement ngd+ng-app sur Coolify, intégration Festipod ; fork jetable abandonné quand l'upstream livrera sa solution
|
||||
last_updated: 2026-05-21
|
||||
---
|
||||
|
||||
# Forker NextGraph pour exposer l'inbox au SDK JS
|
||||
|
||||
**Status:** Incubating — aucun travail démarré
|
||||
**Last updated:** 2026-05-21
|
||||
|
||||
## Context
|
||||
|
||||
Festipod doit notifier l'hôte d'un PdR quand quelqu'un s'inscrit, avec **identification si connexion / anonyme sinon** (cf. décision cadre inbox dans [[brief_2026-05-18_authorization-matrix]]). L'**inbox** NextGraph est idéale (le `from` optionnel donne l'anonymat) **mais n'est pas exposée au SDK JS** (cf. [[knowledge_stores-permissions]] §Inbox). Ce brief évalue **forker/patcher `nextgraph-rs`** pour l'exposer.
|
||||
|
||||
### Posture stratégique (cadrée par l'utilisateur)
|
||||
|
||||
Le fork est **explicitement temporaire, non destiné à l'upstream**. Hypothèse : NextGraph finira par exposer sa **propre** solution d'inbox au SDK JS, **possiblement différente**. Quand elle arrivera, on **abandonne le fork et on adapte Festipod**. Tant que leur solution n'est pas là : maintenir le fork à jour (rebase sur `upstream/main`, qui bouge vite en `0.1.2-alpha`) ; **déployer broker + ng-app depuis le fork** ; surveiller l'upstream pour basculer dès que possible. On ne vise **pas** une PR.
|
||||
|
||||
## What We Know
|
||||
|
||||
Trois couches.
|
||||
|
||||
### Couche 1 — Le patch Rust : 4 fichiers (broker vanilla)
|
||||
|
||||
1. **`engine/net/src/types.rs`** — `InboxMsgContent::Link` est une variante **unit** (stub) ; lui donner un payload (ou variante `Notification`) portant le NURI du PdR + lien vers l'`Inscription`. Ajouter un builder `InboxPost::new_link(...)` calqué sur `new_contact_details`. `from = None` → anonymat.
|
||||
2. **`engine/verifier/src/request_processor.rs`** — ajouter le bras de commande manquant (pas de bras `InboxPost`). Idéalement une commande haut-niveau (`NotifyInbox`) construisant le post côté Rust (garde le scellement crypto en Rust). Calquer sur `SocialQueryStart`.
|
||||
3. **`sdk/js/lib-wasm/src/lib.rs`** — exposer `pub async fn inbox_post_link(session_id, to_inbox_nuri, to_profile_nuri, link, anonymous)`, calqué sur `social_query_start`.
|
||||
4. **`engine/verifier/src/inbox_processor.rs`** (`process_inbox`) — bras de réception qui **matérialise** le message en document dans le store de l'hôte (calquer sur le handler `ContactDetails`). L'app lit ensuite via ORM/SPARQL — pas de nouvelle API de lecture d'inbox.
|
||||
|
||||
**Résolution d'identité** (connu/anonyme) : gratuite via SPARQL côté app (JOIN du NURI d'inbox émetteur contre les docs `social:contact`). **Découverte de l'inbox de l'hôte** : embarquer le NURI d'inbox du `public_store` de l'hôte dans le doc PdR ou le profil public (le flux QR-code de partage de profil le porte déjà).
|
||||
|
||||
### Couche 2 — Déploiement (depuis le fork)
|
||||
|
||||
Détail dans [[knowledge_integration-model]]. Le verifier patché tourne **dans l'iframe ng-app** → **construire et auto-héberger le `ngd` + le ng-app** depuis le fork, puis rebuilder le `@ng-org/web` de Festipod avec `NG_REDIR_SERVER`/`NG_DEV*` pointant sur ce ng-app. **Aucune réécriture de l'intégration Festipod** (reste iframe). Le routage inbox du broker est déjà natif, mais comme on auto-héberge le ng-app patché, **on déploie toute la stack depuis le fork** (un seul arbre source).
|
||||
|
||||
- **Local** : `ngd` + ng-app du fork ; Festipod buildé avec `NG_DEV`/`NG_DEV_LOCAL_BROKER`.
|
||||
- **Serveur de test** : `ngd` + ng-app du fork sur notre domaine ; Festipod buildé avec `NG_REDIR_SERVER=notre-domaine`.
|
||||
|
||||
#### Hébergement sur Coolify — 3 pièces web
|
||||
|
||||
1. **`ngd`** — démon WebSocket **stateful** : conteneur avec **volume persistant** pour `--base-path` (RocksDB + clés + PeerId, jamais wipé), mode `--domain` derrière le Traefik de Coolify. Build : Dockerfiles officiels cassés → **écrire notre Dockerfile multi-stage Rust** (RocksDB exige llvm/clang). Premier démarrage **interactif** (lien d'invitation wallet admin) → scripter via `ngcli` ou faire une fois à la main puis persister dans le volume.
|
||||
2. **ng-app** (frontend iframe, wasm patché) — **build statique** (`pnpm webfilebuild`). Servi en statique (buildpack ou nginx).
|
||||
3. **Routage** : un même domaine sert le statique du ng-app ET proxifie le WebSocket vers ngd.
|
||||
|
||||
Plus **Festipod** lui-même (app Bun → skill `coolify-hosting` pour CELLE-CI, pas pour le `ngd` Rust). Drivers de complexité : build Rust+RocksDB sans Dockerfile prêt, conteneur stateful à volume critique, premier-run interactif, double-service (statique + WS).
|
||||
|
||||
### Couche 1 (libs JS) — paquets npm clients patchés
|
||||
|
||||
**On maintient des versions patchées des paquets clients, pas seulement le wasm.** Le forwarding générique permet *techniquement* d'atteindre une méthode wasm sans toucher le JS, mais c'est un **hack** (non typé, fragile) — test rapide seulement. À modifier réellement :
|
||||
|
||||
- **`@ng-org/web`** — modifié de toute façon (URL broker) → y ajouter `inbox_post_link` dans la **surface d'API typée + `.d.ts`**.
|
||||
- **Méthodes streamées** (si lecture inbox en *flux* un jour) — entrée des deux côtés (`E` + `streamed_api`). Pour la seule **écriture** (requête/réponse), inutile.
|
||||
- **`@ng-org/orm`** — à modifier **si** on intègre l'écriture inbox au flux ORM. Sinon (appel `ng.inbox_post_link` à côté), inutile.
|
||||
- **`@ng-org/alien-deepsignals`, `@ng-org/shex-orm`** — a priori inchangés.
|
||||
|
||||
#### Outillage existant : `scripts/build-ng-packages.sh`
|
||||
|
||||
`bun run build:ng` build les 4 paquets depuis `$NEXTGRAPH_RS/sdk/js/*` (défaut `../../nextgraph/nextgraph-rs`) → `pnpm pack` → `.tgz` dans `.ng-tarballs/` → `bun add` réécrit `package.json` vers les tarballs locaux. **Pattern d'origine du projet** : le commit `fd6d408` l'a abandonné quand les alphas ont été publiées sur npm. Pour repasser au custom : **réactiver `bun run build:ng`**. Nuances : `@ng-org/web` est TS pur (le script crée un *stub* `lib-wasm` ; le tarball porte l'API inbox typée + l'URL broker bakée, **pas** le wasm) ; pointer le script sur la **branche patchée** (retirer le `git pull --ff-only`) ; option recommandée : patcher `@ng-org/web` pour lire l'URL broker au **runtime** (évite de rebuilder par domaine).
|
||||
|
||||
### Couche 3 — Intégration dans Festipod
|
||||
|
||||
Exposer la méthode ne suffit pas. Chantiers (certains préexistent à l'inbox) :
|
||||
|
||||
- **Modéliser le PdR.** Les SHEX (`src/shared/shapes/shex/festipodShapes.shex`) ne définissent qu'`Event`/`UserProfile`/`Participation` — **pas de `MeetingPoint`** (local-only), ni d'entité notification. Ajouter les shapes + `bun run build:orm`.
|
||||
- **Implémenter l'inscription (aujourd'hui no-op).** Dans `FestipodDataContext.tsx`, `joinEvent`/`leaveEvent` sont des `console.log`. Le vrai flux : (a) écrire l'`Inscription` dans le `protected_store` de l'inscrit (via multi-store, [[brief_2026-05-17_multi-store-refactor]]), (b) appeler `ng.inbox_post_link(...)` pour notifier l'inbox du PdR.
|
||||
- **Porter le NURI d'inbox de l'hôte** sur le doc PdR (ou lookup profil).
|
||||
- **Lire et résoudre les notifications côté hôte** : lire les docs notification matérialisés (ORM/SPARQL), JOIN identité contre `social:contact`. UI : « N inscrits dont X identifiés ».
|
||||
- **Câblage session** via `src/shared/utils/ngSession.ts`.
|
||||
|
||||
**Dépendances** : présuppose (1) le fork SDK livré, (2) le refactor multi-store. **Surface jetable** : à l'arrivée de l'API officielle, migrer aussi ces points d'appel Festipod.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- `NotifyInbox` haut-niveau vs `InboxPost` brut ? (haut-niveau préféré, garde la crypto en Rust)
|
||||
- Où sourcer le NURI d'inbox de l'hôte (doc PdR vs lookup profil) ?
|
||||
- Forme de la matérialisation côté réception (quels triples) ?
|
||||
- Suppression côté inbox : un déposant peut-il retirer son dépôt ? (résiduelle, cf. [[brief_2026-05-18_authorization-matrix]])
|
||||
- Cadence de rebase du fork ? Critère de bascule vers la solution upstream ?
|
||||
- `@ng-org/web` : patch runtime vs tarball par domaine ?
|
||||
- `ngd` Coolify : automatiser le premier-run vs one-shot manuel persisté ? Un service (reverse-proxy maison) ou deux ?
|
||||
|
||||
## Possible Approaches
|
||||
|
||||
- **A. Fork temporaire + auto-hébergement (retenu comme stopgap)** — patch des 4 fichiers, déploiement depuis le fork. Vrai inbox, anonymat natif. Coût : maintenir le fork + héberger. Jetable.
|
||||
- **B. Contribution upstream — écartée** comme objectif.
|
||||
- **C. Pas de patch, détourner `social_query_start`** — repli, livrable tout de suite mais limité aux **contacts** (pas d'anonyme vers un hôte non-connecté).
|
||||
|
||||
> Voir aussi [[brief_2026-06-15_shared-wallet-shim]] : le vrai multi-user (lecture cross-wallet) suppose en plus un patch `OpenRepo` + capabilities, au-delà de l'inbox.
|
||||
|
||||
## Starting Points
|
||||
|
||||
- [[knowledge_integration-model]], [[knowledge_stores-permissions]]
|
||||
- [[brief_2026-05-18_authorization-matrix]] — la décision cadre inbox que ce patch sert
|
||||
- Repo local `nextgraph-rs` : `sdk/js/lib-wasm/src/lib.rs`, `engine/verifier/src/{request_processor,inbox_processor}.rs`, `engine/net/src/types.rs`
|
||||
- Remotes : `origin` = `git.nextgraph.org/slaivyn/nextgraph-rs` (fork perso), `upstream` = `git.nextgraph.org/NextGraph/nextgraph-rs`
|
||||
@@ -1,183 +0,0 @@
|
||||
---
|
||||
type: brief
|
||||
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 — 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.
|
||||
|
||||
## Objectif & posture
|
||||
|
||||
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.
|
||||
|
||||
Trois choses doivent rester nettes pour ne pas dériver, et structurent ce brief :
|
||||
|
||||
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.
|
||||
|
||||
> **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.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision lointaine (cible finale)
|
||||
|
||||
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, 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]]) |
|
||||
|
||||
---
|
||||
|
||||
## État d'implémentation (2026-06-16)
|
||||
|
||||
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` | ✅ 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é**) |
|
||||
|
||||
**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é.
|
||||
|
||||
**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
|
||||
|
||||
- **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) — 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]] — 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`)
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
type: decision
|
||||
summary: Flux login/logout du stopgap wallet partagé — le vrai login NextGraph (redirect broker) apparaît en premier, perçu comme une barrière technique d'accès à l'environnement ; l'écran applicatif « Connexion » (username seul → localStorage) EST le login perçu ; « Déconnexion » efface juste le username sans toucher NG ; vrai logout planqué
|
||||
last_updated: 2026-06-15
|
||||
---
|
||||
|
||||
# Décision 2026-06-15 — Flux de login/logout du stopgap wallet partagé
|
||||
|
||||
Arbitrage du flux d'authentification perçu pour le stopgap [[brief_2026-06-15_shared-wallet-shim]]. Frozen.
|
||||
|
||||
## Contrainte de départ
|
||||
|
||||
Le login NextGraph **n'est pas programmable** : c'est une **redirection web** vers la page du broker (`nextgraph.net`). Impossible d'ouvrir le wallet partagé en silence — il faut au minimum un passage par le redirect broker, au moins une fois par device. La question n'est donc pas *« comment éviter le redirect »* mais *« comment l'ordonner et le présenter »* pour que l'UX reste cohérente.
|
||||
|
||||
## Décision : option 2 — gate technique d'abord, « Connexion » applicative ensuite
|
||||
|
||||
Deux couches d'auth distinctes, présentées dans cet ordre :
|
||||
|
||||
1. **Couche réelle (technique, non perçue comme login)** — le redirect broker apparaît **immédiatement, avant tout rendu de l'app**. Comme il précède l'app, l'utilisateur le lit comme une **barrière technique d'accès à l'environnement de test** (type mur de beta), **pas** comme un login applicatif. Mêmes credentials partagés pour tous (donnés dans l'invitation, façon « code d'accès »). Une fois par device, puis persistant. **Jamais étiqueté « login ».** Un splash Festipod minimal précède le redirect pour donner du contexte.
|
||||
2. **Couche applicative (perçue comme LE login)** — écran **« Connexion »** = saisie du **username** (→ `localStorage`, `currentAccountId`). C'est le login *dans la perception* de l'utilisateur. **Sans mot de passe** (décision username-seul) → connexion **déclarative** : n'importe qui prend n'importe quel username (cohérent zéro-sécurité / amis). **« Déconnexion »** = efface **seulement** le username et revient à l'écran « Connexion » ; **n'appelle aucune fonction NG**.
|
||||
|
||||
Le **vrai logout** (`ng.session_stop` / `user_disconnect` / `wallet_close`) reste **planqué** (réglages/debug), car il force un nouveau redirect.
|
||||
|
||||
Le label **« Connexion »/« Déconnexion »** (et non « Changer de profil ») est un choix explicite : on assume de faire passer le username pour le login applicatif, puisque la barrière technique n'est pas perçue comme tel.
|
||||
|
||||
## Pourquoi (vs option 1 écartée)
|
||||
|
||||
**Option 1 écartée** — faux login d'abord (username), puis page d'avertissement « saisissez tel username/password », puis bouton *Continuer* déclenchant le redirect. Rejetée : workflow étrange, **double-login dissonant** (« je me suis déjà connecté, pourquoi je recommence ailleurs ? »), page d'avertissement qui **ressemble à une arnaque**, et le redirect **ressurgit en plein usage** à chaque expiration de session.
|
||||
|
||||
**Option 2 retenue** parce que :
|
||||
- **Cohérence du modèle mental** : la barrière technique n'étant pas perçue comme un login, la paire **Connexion/Déconnexion** applicative est complète et auto-cohérente — plus aucun mismatch sur le logout (se déconnecter ramène à l'écran de connexion, les deux dans la même couche).
|
||||
- **Dégradation gracieuse** : un re-gate après redémarrage navigateur (perte de `sessionStorage`) se lit comme « reconnexion à l'environnement », pas comme un bug.
|
||||
- **Implémentation plus simple** : `NextGraphContext` fait déjà le flux `connect`/redirect ; l'écran « Connexion » est un écran in-app normal ; pas de page d'avertissement bespoke.
|
||||
- **Similarité avec l'infra cible** (objectif directeur du stopgap) : la forme **« redirect broker → app »** est exactement le flux du vrai multi-wallet. À la migration, on **supprime l'écran « Connexion » username** et la **barrière technique devient le vrai login per-user** — la forme du flux ne change pas.
|
||||
|
||||
## Faits techniques vérifiés (`nextgraph-rs`, 2026-06-15)
|
||||
|
||||
- **Persistance de session : OUI.** Wallet mémorisé côté iframe broker (`localStorage` long-terme + `sessionStorage` pour la session active) ; au rechargement, `init()` retrouve la session **sans re-déclencher le redirect** tant que la session broker existe (`sdk/js/web/src/index.ts`, `sdk/js/api-web/main.ts`). Un **redémarrage complet du navigateur** (perte de `sessionStorage`) peut re-déclencher le gate.
|
||||
- **Logout réel exposé : OUI.** `ng.session_stop()`, `ng.user_disconnect()`, `ng.wallet_close()` (`sdk/js/lib-wasm/src/lib.rs`) ; arrêtent la session / effacent le wallet ; **forcent un nouveau redirect** ensuite → d'où le choix de **ne pas** les appeler dans la « Déconnexion » applicative et de planquer le vrai logout.
|
||||
|
||||
## Conséquences côté code (Festipod)
|
||||
|
||||
- `NextGraphContext` — déclencher le `connect`/redirect **au boot**, avant le rendu de l'app (+ splash pré-redirect).
|
||||
- Un écran applicatif **« Connexion »** (username → `localStorage` / `currentAccountId`), username résolu contre les comptes du `sharedWalletShim`.
|
||||
- Une **« Déconnexion »** qui efface seulement le username (aucun appel NG).
|
||||
- Vrai logout exposé seulement en réglages/debug.
|
||||
|
||||
## See Also
|
||||
|
||||
- [[brief_2026-06-15_shared-wallet-shim]] — le stopgap que cette décision complète
|
||||
- Concept `data-layer` — `NextGraphContext`, auto-init conditionnel, flux redirect broker
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,69 +0,0 @@
|
||||
---
|
||||
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`.
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Modèle d'intégration NextGraph — @ng-org/web est un proxy iframe (verifier tourne dans l'iframe ng-app, pas dans le broker), reciblable au build via NG_REDIR_SERVER/NG_DEV*, broker ngd stateful WebSocket ; modifier le verifier = rebuilder le ng-app, pas le broker
|
||||
last_checked: 2026-05-21
|
||||
---
|
||||
|
||||
# Modèle d'intégration et de déploiement NextGraph
|
||||
|
||||
Comment une app web tierce s'intègre à NextGraph, et **où tourne le moteur (verifier)**. Vérifié dans `nextgraph-rs` le 2026-05-21.
|
||||
|
||||
NextGraph s'utilise via un **proxy iframe** (`@ng-org/web`) : l'app tierce ne contient pas le moteur, elle délègue à un ng-app hébergé (défaut `nextgraph.net`) qui exécute le moteur dans une iframe.
|
||||
|
||||
## Les paquets JS
|
||||
|
||||
- **`@ng-org/web`** — paquet **publié**. Proxy postMessage léger (aucun wasm embarqué). **Le** chemin d'intégration tierce ; `@ng-org/orm` et tous les exemples en dépendent. **Festipod l'utilise.**
|
||||
- **`@ng-org/api-web`** — **privé** (non publié). Moteur navigateur complet (charge `@ng-org/lib-wasm` dans un Web Worker). Consommé uniquement par `app/nextgraph` (frontend ng-app) — **pas** une cible d'intégration tierce.
|
||||
- **`@ng-org/lib-wasm`** — moteur compilé wasm (contient le verifier). Source `sdk/js/lib-wasm/`.
|
||||
- **`nextgraph`** (npm) — API NodeJS (build `pkg-node`).
|
||||
- **`@ng-org/orm`** — ORM réactif (`useShape`…), bâti sur `@ng-org/web`.
|
||||
|
||||
## Où tourne le verifier
|
||||
|
||||
Dans le modèle web standard (iframe), le verifier tourne **dans l'iframe** : `app/nextgraph` charge `api-web` → `lib-wasm` dans un Web Worker, côté navigateur. Le broker (`ngd`) ne fait que **transport et stockage**.
|
||||
|
||||
**Conséquence** : modifier la logique du verifier (`request_processor`, `inbox_processor`) = reconstruire le **ng-app**, pas le broker.
|
||||
|
||||
## Le modèle iframe & reciblage build-time
|
||||
|
||||
`@ng-org/web` redirige vers le ng-app hébergé, qui recharge l'app tierce en iframe après auth, puis relaie par `postMessage`. **Reciblable au build** (`sdk/js/web/src/index.ts`, `import.meta.env`) :
|
||||
|
||||
| Variable | Cible |
|
||||
|---|---|
|
||||
| `NG_REDIR_SERVER` | défaut `nextgraph.net` |
|
||||
| `NG_DEV3` | `127.0.0.1:3033` |
|
||||
| `NG_DEV` | `localhost:14402`/`14404` |
|
||||
| `NG_DEV_LOCAL_BROKER` | `localhost:1421` |
|
||||
|
||||
**Pas d'override runtime** — `init()` ne prend pas d'URL broker. Pour pointer vers un ng-app auto-hébergé : **rebuilder `@ng-org/web`** (TS pur, sans wasm → build trivial).
|
||||
|
||||
## Plomberie proxy ↔ iframe ↔ worker (générique)
|
||||
|
||||
Le chemin d'appel d'une méthode est **entièrement générique** (aucune allowlist) : `@ng-org/web` est un `Proxy` JS qui relaie *n'importe quel* nom de méthode par `postMessage` ; `app/nextgraph` dispatch via `Reflect.apply(ng[method], …)`. **Conséquence** : une nouvelle fonction wasm en requête/réponse simple est *atteignable* sans toucher le JS — mais c'est un **hack** non typé (test rapide, pas un plan ; cf. [[brief_2026-05-21_fork-nextgraph-inbox]]). Cas **streamé** : exige une entrée des deux côtés (`E` dans `@ng-org/web` + `streamed_api` dans api-web ; méthodes streamées actuelles : `doc_subscribe`, `orm_start_graph`, `orm_start_discrete`, `file_get`, `app_request_stream`).
|
||||
|
||||
## Le broker (ngd)
|
||||
|
||||
- Supporte déjà nativement l'inbox (`inbox_post`, `inbox_register`, `inbox_pop_for_user` dans `engine/net/src/server_broker.rs`) — un `ngd` standard routerait l'inbox, **aucun patch broker nécessaire**.
|
||||
- Démon **WebSocket** (`async-tungstenite`), **stateful** : RocksDB sous `--base-path`, PeerId persisté (volume critique).
|
||||
- CLI : `--local PORT`, `--domain DOMAIN:PORT,LOCAL_PORT` (mode derrière reverse-proxy TLS-terminé type Traefik/Coolify).
|
||||
- **Ne sert pas de statique** : le ng-app frontend est un déploiement statique séparé (`pnpm webfilebuild`). Premier démarrage **interactif** (lien d'invitation wallet admin). Dockerfiles officiels **cassés**.
|
||||
|
||||
> Détail du déploiement depuis un fork : [[brief_2026-05-21_fork-nextgraph-inbox]] §Couche 2.
|
||||
@@ -1,60 +0,0 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Référence des 5 types de stores NextGraph et leurs droits, document=repo, granularité des permissions, capability/Nuri, inbox native (anonymat via from optionnel), et ce que le SDK @ng-org/web n'expose PAS
|
||||
last_checked: 2026-05-21
|
||||
---
|
||||
|
||||
# Stores NextGraph et droits d'accès
|
||||
|
||||
Référence des primitives de stockage et permission de NextGraph (**système externe**, pas le code de Festipod). Socle des briefs [[brief_2026-05-17_multi-store-refactor]] et [[brief_2026-05-18_authorization-matrix]].
|
||||
|
||||
Source : doc NextGraph officielle ([Documents & Stores](https://docs.nextgraph.org/en/documents/), [Getting started](https://docs.nextgraph.org/en/getting-started/)) vérifiée le 2026-05-21.
|
||||
|
||||
## Points d'entrée du code source local
|
||||
|
||||
Repo cloné en `../../nextgraph/nextgraph-rs` (cf. `_overview`) :
|
||||
- `sdk/js/lib-wasm/src/lib.rs` — API wasm effectivement exposée au JS.
|
||||
- `engine/net/src/app_protocol.rs` — enum `AppRequestCommandV0`, formats `NuriV0`.
|
||||
- `engine/verifier/src/request_processor.rs` — dispatch effectif des `app_request` (la vérité sur ce qui est *traité*).
|
||||
- `engine/net/src/types.rs` — types inbox (`InboxPost`, `InboxMsg`, `InboxMsgContent`).
|
||||
- `engine/verifier/src/inbox_processor.rs` — traitement des messages d'inbox.
|
||||
|
||||
## Les 5 types de stores
|
||||
|
||||
| Store | Lecture | Écriture | Création |
|
||||
|---|---|---|---|
|
||||
| **Private** | Titulaire seul | Titulaire seul | Par défaut |
|
||||
| **Protected** | Titulaire + détenteurs d'un lien + permission | Titulaire + collaborateurs permissionnés | Par défaut |
|
||||
| **Public** | Tout le monde, sans capability | Titulaire seul | Par défaut |
|
||||
| **Group** | Membres du groupe | Membres du groupe (collaboratif) | À la demande |
|
||||
| **Dialog** | Les deux utilisateurs uniquement | Les deux utilisateurs uniquement | À la demande |
|
||||
|
||||
Citations doc (verbatim) : Private — *« only you have access to … not possible to share »* ; Protected — *« share … but they will need a special link and permission »*, *« protected social profile »* ; Public — *« equivalent to your website … without the need for special permissions »* ; Group — *« each Group is a separate Store … documents inherit the permissions of the store »* ; Dialog — *« hold all the data you exchange with another user (and only with that other user) … You cannot add more users »*.
|
||||
|
||||
Tout wallet a d'office les **3 stores** private/protected/public (session : `private_store_id`, `protected_store_id`, `public_store_id`). Group et Dialog se créent à la demande.
|
||||
|
||||
## Concepts transverses
|
||||
|
||||
**Document vs Repo.** *« A Repo is the equivalent of an E2EE group for one and only one Document. »* **1 document = 1 repo** (commits + permissions). Identifiant : `did:ng:o:<RepoID>`. Un **store** est lui-même un document spécial qui regroupe et permissionne d'autres documents.
|
||||
|
||||
**Granularité.** Écriture gérée au niveau **Document (repo)**, pas branche/bloc. Lecture plus fine possible (par bloc/branche). Héritage : un Group store peut faire hériter ses permissions à ses documents.
|
||||
|
||||
**Capability / Nuri.** Le partage transmet un **Nuri** embarquant la capability crypto (lecture et/ou écriture). Pas d'ACL centralisée : posséder le Nuri = le droit. *« adding permissions can be done offline »* ; *« removing permissions … requires a SyncSignature »* (synchrone).
|
||||
|
||||
## Inbox
|
||||
|
||||
**Chaque document a une inbox native.** Un non-éditeur peut y **déposer un lien (DID cap)** sans être invité éditeur ; le propriétaire **modère**. NURI : `did:ng:d:<inbox_id>`. Contenu : enum `InboxMsgContent` (`ContactDetails`, `DialogRequest`, **`Link`**, `Patch`, `ServiceRequest`, `ExtRequest`, `RemoteQuery`, `SocialQuery`…). Message **scellé** (`crypto_box::seal`) vers la pubkey de l'inbox → seul le titulaire déchiffre. Champ `from` **optionnel** → expéditeur **anonyme** possible. C'est le « identifié si connu, anonyme sinon » voulu par Festipod, **natif au protocole** (mécanisme retenu pour la notification d'inscription, cf. [[brief_2026-05-18_authorization-matrix]]).
|
||||
|
||||
### L'inbox n'est PAS utilisable directement depuis le SDK JS
|
||||
|
||||
- `app_request(request)` est exposé, et `AppRequestCommandV0::InboxPost` + `AppRequest::inbox_post()` existent. **MAIS** le `request_processor` du verifier **n'a aucun bras `InboxPost`** (commandes traitées : `OrmStart(Discrete)`, `Fetch`, `FileGet`, `OrmUpdate`, `OrmDiscreteUpdate`, `SocialQueryStart`, `QrCodeProfile(Import)`, `Header`, `Create`, `FilePut`). Envoyer un `InboxPost` ne déclenche rien.
|
||||
- Construire un `InboxPost` exige le scellement crypto côté Rust ; **aucun helper wasm** ne l'expose.
|
||||
- Le dépôt en inbox n'est déclenché qu'**en interne** par `QrCodeProfileImport` (`post_to_inbox(new_contact_details)`) et `social_query_start` (propagation via inbox des **contacts**).
|
||||
|
||||
**Conséquence** : pas de moyen propre de « drop a Link » arbitraire dans l'inbox d'un PdR depuis le SDK JS aujourd'hui. → chantier [[brief_2026-05-21_fork-nextgraph-inbox]]. Piste connexe : `social_query_start` EST exposé (requête fédérée via inbox jusqu'à `degree` sauts) mais limité aux **contacts** (ne couvre pas la notif anonyme vers un hôte non-connecté).
|
||||
|
||||
## Limites du SDK JS
|
||||
|
||||
`@ng-org/web` (vérifié `0.1.2-alpha.13` = `upstream/main` au 2026-05-21, version installée) **n'expose pas** : création de Group/Dialog store ; partage de capability (Nuri avec droits) ; manipulation de permissions ; dépôt/lecture d'inbox.
|
||||
|
||||
Méthodes JS disponibles : `doc_create`, `doc_subscribe`, `sparql_query`, `sparql_update`, `orm_start_graph`, `orm_start_discrete`, `graph_orm_update`, `discrete_orm_update`, `file_get`, `app_request_stream`. La doc annonce *« An API will be provided for permission manipulation »* (sans date).
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: Firefox 151+ bloque (Local Network Access) le broker hébergé qui embarque l'app de dev locale dans son iframe → iframe blanche, zéro log app, aucune erreur. Ce n'est PAS un bug de code. Fix navigateur — about:config network.lna.enabled=false.
|
||||
last_checked: 2026-07-13
|
||||
---
|
||||
|
||||
# Firefox LNA bloque l'iframe app du broker en dev local
|
||||
|
||||
## Symptôme
|
||||
|
||||
En dev local, l'app tourne DANS l'iframe du broker hébergé (`nextgraph.eu`/`nextgraph.net`
|
||||
en HTTPS embarque `festipod.localhost` → `127.0.0.1`). Sur **Firefox 151+**, l'iframe reste
|
||||
**blanche** : **aucun log `[FestipodData]`/`[NG]`** (l'app JS n'est jamais exécutée), et
|
||||
**aucune erreur** rouge (le blocage est une décision de politique réseau, pas un throw). Facile
|
||||
à prendre pour un crash de rendu Festipod — ce n'en est PAS un.
|
||||
|
||||
## Cause
|
||||
|
||||
**Local Network Access (LNA)** : Firefox 151+ (activé par défaut, cf. rollout 149→151) interdit
|
||||
à un **site public** (le broker HTTPS) d'atteindre une **ressource du réseau local**
|
||||
(`127.0.0.1`) — y compris l'embarquer en iframe. Le log révélateur (console) :
|
||||
`Local Network Access detected: ... accessing target "…festipod.localhost…" (127.0.0.1) … prompt action: auto_deny`.
|
||||
|
||||
Deux corollaires qui trompent :
|
||||
- **Le top-level charge très bien** : ta navigation directe vers `https://festipod.localhost:1355`
|
||||
(la barrière AccessGateScreen) n'est PAS soumise à LNA. Seul l'**embarquement iframe** par le
|
||||
broker l'est. Donc « le cert est déjà accepté / l'app se lance » avant l'iframe ≠ l'iframe passera.
|
||||
- **HTTPS n'y change rien** : LNA vise l'**adresse locale cible**, pas le protocole. Passer
|
||||
`portless proxy start --https` (app en `https://festipod.localhost`) ne débloque pas.
|
||||
|
||||
## Fix (navigateur, pas code)
|
||||
|
||||
`about:config` → **`network.lna.enabled` = `false`** (drapeau maître : désactive tous les
|
||||
contrôles LNA). Alternative ciblée : **`network.lna.skip-domains`** avec `nextgraph.eu`,
|
||||
`nextgraph.net` (garde la protection ailleurs). Autres prefs LNA : `network.lna.blocking`,
|
||||
`network.lna.block_trackers`.
|
||||
|
||||
Ne PAS chasser un bug de rendu Festipod tant qu'il n'y a **aucun log `[FestipodData]`** dans la
|
||||
console : sans logs app, l'app n'a pas tourné → c'est l'environnement (LNA, cert non approuvé,
|
||||
serveur dev éteint), pas le code. Le smoke `@e2e` ne peut PAS attraper ça : Playwright n'applique
|
||||
pas LNA comme un vrai Firefox.
|
||||
@@ -14,7 +14,19 @@ Le serveur sert `src/index.html`, qui charge `src/app/frontend.tsx` (voir `app-a
|
||||
## Détails de `build.ts` et du serveur
|
||||
|
||||
- `build.ts` scanne `src/**/*.html` comme entrypoints (aujourd'hui un seul : `src/index.html`), `target: 'browser'`, minify + sourcemap linked, plugin `bun-plugin-tailwind`. Ajouter un 2e `.html` créerait un 2e bundle.
|
||||
- `src/index.ts` (`Bun.serve`) sert : `/reports/cucumber` (rapport HTML), des stubs `/api/hello*`, et un **catch-all `/*` → `src/index.html`** (routing SPA, doit rester en dernier). HMR si `NODE_ENV !== 'production'`, port via `PORT`.
|
||||
- `src/index.ts` (`Bun.serve`) sert : `/reports/cucumber` (rapport HTML), des stubs `/api/hello*`, `/festipod-config.json` + `/shared-wallet.ngw` (config runtime, voir ci-dessous), et un **catch-all `/*` → `src/index.html`** (routing SPA, doit rester en dernier). HMR si `NODE_ENV !== 'production'`, port via `PORT`.
|
||||
|
||||
## Globals de build vs config runtime (piège du wallet partagé)
|
||||
|
||||
`build.ts` injecte des **globals à la compilation** via `define` (p. ex. `__FESTIPOD_SHARED_WALLET_PASSWORD__` depuis `FESTIPOD_SHARED_WALLET_PASSWORD`, `__FESTIPOD_ACCESS_GATE_DISABLED__`, et `__FESTIPOD_AUTO_SEED__` depuis `FESTIPOD_AUTO_SEED` — l'auto-seed de dev, OFF si absent). **Piège** : le serveur `src/index.ts` (utilisé par `bun run dev` ET `bun run start`) bundle `index.html` via l'import HTML de Bun, qui **n'applique aucun `define`** — ni `bun --define` ni `process.env` ne s'y propagent (vérifié). Donc une variable d'env passée à `bun run dev` n'atteint pas le bundle frontend par ce chemin.
|
||||
|
||||
Pour ces chemins servis depuis `src/`, la config passe donc au **runtime** : `src/index.ts` expose `/festipod-config.json` (lu depuis l'env), et l'entrée `src/app/frontend.tsx` la **fetch d'abord**, pose le global, **puis importe l'app dynamiquement** (`await import('./App')`) — ainsi `sharedWallet.ts` lit la valeur à son évaluation. Dans un bundle `build.ts` la valeur est déjà inline par `define`, donc le fetch est court-circuité (`NODE_ENV === 'production'`). Conséquence pratique : pour exercer le flux « portefeuille partagé » en dev **de bout en bout** (téléchargement + import qui fonctionne), passer le VRAI mot de passe du wallet e2e **et** le fichier — le mot de passe affiché à l'écran doit correspondre au `.ngw` importé, sinon l'import échoue (une valeur factice comme `1` fait juste apparaître l'écran) :
|
||||
|
||||
```
|
||||
FESTIPOD_SHARED_WALLET_PASSWORD=festipod-e2e-tests \
|
||||
FESTIPOD_SHARED_WALLET_FILE=./festipod-e2e-tests.ngw \
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Le harness de test est buildé à part
|
||||
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Déploiement — Dockerfile multi-stage Bun Alpine qui lance `bun run start` depuis src/ (pas dist/), EXPOSE 3000, env PORT/NODE_ENV ; aucun CI/CD committé ; dev passe par le wrapper portless
|
||||
last_checked: 2026-06-15
|
||||
summary: Déploiement — Dockerfile multi-stage Bun Alpine ; install via pnpm (git+node dans l'image) mais runtime bun ; lance `bun run start` depuis src/ (pas dist/), EXPOSE 3000, env PORT/NODE_ENV ; aucun CI/CD committé ; dev passe par le wrapper portless
|
||||
last_checked: 2026-07-14
|
||||
---
|
||||
|
||||
# Déploiement & infra
|
||||
|
||||
## Dockerfile
|
||||
|
||||
Un `Dockerfile` existe (multi-stage Bun Alpine) :
|
||||
- `FROM oven/bun:1-alpine`, stages `install` (`bun install --frozen-lockfile` depuis `package.json` + `bun.lock`) puis `release` (copie `node_modules` + source).
|
||||
Un `Dockerfile` existe (multi-stage Bun Alpine). **L'installation passe par pnpm, mais le runtime/build/test restent bun** (cf. [[knowledge_stack-and-commands]]) :
|
||||
- `FROM oven/bun:1-alpine`, stage `install` : `apk add --no-cache git nodejs npm` puis `npm install -g pnpm@10.26.0` (l'image bun n'a ni Node ni pnpm ; l'`apk nodejs` d'Alpine n'embarque pas corepack), `COPY package.json pnpm-lock.yaml`, puis `pnpm install --frozen-lockfile`. `git` est requis car `@ng-eventually/client` est une dépendance **git+https** publique (Gitea, sans auth). Stage `release` : copie `node_modules` + source.
|
||||
- `ENV NODE_ENV=production`, `USER bun`, `EXPOSE 3000/tcp`, `ENTRYPOINT ["bun","run","start"]`.
|
||||
|
||||
**Piège `bun` peer** : `bun-plugin-tailwind` déclare `bun` en peerDependency → pnpm matérialise le paquet npm `bun` et **crée un shim `node_modules/.bin/bun`** qui shadow le `bun` du PATH sous `bun run`/`pnpm run`. Son postinstall est ignoré par défaut → shim cassé → `bun run start` échoue. Corrigé en approuvant le build : `pnpm.onlyBuiltDependencies: ["bun"]` dans `package.json` (le postinstall télécharge le vrai binaire). Sans ça, toute la migration pnpm casse le démarrage.
|
||||
|
||||
**Quirk** : `start` = `NODE_ENV=production bun src/index.ts` → le conteneur **exécute la source TypeScript directement** (Bun transpile à la volée), il **n'utilise pas `dist/`**. Le `bun run build` (→ `dist/`) n'est donc **pas** sur le chemin de prod par défaut. Si on veut servir le build, il faut changer l'entrypoint.
|
||||
|
||||
## CI/CD
|
||||
|
||||
**Aucun** pipeline committé (`.github/workflows/` absent, pas de config Coolify dans le repo). Angle mort assumé. Pour héberger l'app Bun, le skill `coolify-hosting` s'applique (mentionné aussi dans concept `nextgraph-platform` pour distinguer Festipod du `ngd` Rust).
|
||||
**Aucun** pipeline committé (`.github/workflows/` absent, pas de config Coolify dans le repo). Angle mort assumé. Pour héberger l'app Bun, le skill `coolify-hosting` s'applique.
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
@@ -26,3 +28,5 @@ Un `Dockerfile` existe (multi-stage Bun Alpine) :
|
||||
## Dev
|
||||
|
||||
`bun run dev` = **`portless festipod bun --hot src/index.ts`** — passe par le wrapper **`portless`** (outil externe de gestion de port), pas un `bun --hot` nu. HMR actif hors production.
|
||||
|
||||
**Lien local réactif du polyfill** : en prod la dépendance `@ng-eventually/client` vient de Gitea (git+https, figée par `pnpm-lock.yaml`). Pour éditer le polyfill localement et voir les changements en direct, `pnpm run link:polyfill` (script `scripts/link-polyfill.ts`, stratégie S2) remplace `node_modules/@ng-eventually/client` par une **copie réelle** de la source locale (`…/ng-eventually-js/packages/client`) — **sans** son propre `node_modules/@ng-org` — et resynchronise `src/` à chaque édition. C'est ce qui garantit **une seule instance `@ng-org/web`** (un seul verifier) : un symlink vers le checkout monorepo, lui, embarque son `@ng-org` → 2ᵉ instance → SDK cassé. Revenir à l'état committé : `pnpm install`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Composants de la stack (Bun, React, NextGraph, Storybook, Cucumber, Tailwind-dans-le-build) et liste réelle des scripts package.json, dont les quirks (cucumber via node+tsx, build:orm au chemin périmé, build:ng pour le fork local)
|
||||
summary: Composants de la stack (Bun runtime/build/test, install via pnpm, React, NextGraph, Storybook, Cucumber, Tailwind-dans-le-build) et liste réelle des scripts package.json, dont les quirks (cucumber via node+tsx, build:orm au chemin périmé, build:ng pour le fork local, link:polyfill pour le lien local réactif)
|
||||
---
|
||||
|
||||
# Stack & commandes
|
||||
@@ -10,6 +10,7 @@ summary: Composants de la stack (Bun, React, NextGraph, Storybook, Cucumber, Tai
|
||||
| Couche | Techno |
|
||||
|---|---|
|
||||
| Runtime / bundler / test | **Bun** (cf. [[rule_bun-first]]) |
|
||||
| **Installation des deps** | **pnpm** (`pnpm install`, `pnpm-lock.yaml`) — **seule** l'install passe à pnpm ; runtime/build/test restent bun. Motif : `@ng-eventually/client` est résolu depuis Gitea en **git+https** (pnpm gère proprement `git+…#main&path:/packages/client` + le dédoublonnage des peers `@ng-org`). Ne pas rebasculer l'install vers bun/npm. |
|
||||
| UI | **React** (mobile-first, largeur max 768px — style dans concept `app-architecture`) |
|
||||
| Données | **NextGraph** P2P local-first (concept `data-layer`) |
|
||||
| Build CSS | **Tailwind** (`tailwindcss` + `bun-plugin-tailwind`) — présent dans le build, mais les écrans stylent via `app-*`/inline, pas d'utilitaires Tailwind (cf. concept `app-architecture`) |
|
||||
@@ -31,7 +32,8 @@ summary: Composants de la stack (Bun, React, NextGraph, Storybook, Cucumber, Tai
|
||||
| `features:parse` | `bun scripts/parse-features.ts` → `features.ts` |
|
||||
| `steps:extract` | `bun scripts/extract-step-definitions.ts` |
|
||||
| `build:orm` | `rdf-orm build --input ./src/shapes/shex --output ./src/shapes/orm` |
|
||||
| `build:ng` | `bash scripts/build-ng-packages.sh` — rebuild des `@ng-org/*` depuis le fork local (concept `nextgraph-platform`) |
|
||||
| `build:ng` | `bash scripts/build-ng-packages.sh` — (re)build des paquets NextGraph depuis une source locale (outil optionnel) |
|
||||
| `link:polyfill` | `bun scripts/link-polyfill.ts` — lien local **réactif** du polyfill `@ng-eventually/client` (stratégie S2 : copie-overlay + watcher), préserve l'instance `@ng-org` unique. Détails dans [[knowledge_deployment]]. |
|
||||
| `storybook` / `build-storybook` | Storybook dev (6006) / build statique |
|
||||
|
||||
## Pièges
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
type: rule
|
||||
summary: Par défaut utiliser Bun et ses APIs natives, jamais les équivalents Node — bun au lieu de node/ts-node, bun install/test/build, bunx, et pas d'express/ws/pg/dotenv
|
||||
summary: Par défaut utiliser Bun et ses APIs natives, jamais les équivalents Node — bun au lieu de node/ts-node, bun test/build, bunx, et pas d'express/ws/pg/dotenv. EXCEPTION : l'installation des paquets passe par pnpm (les deux repos), pas bun install
|
||||
---
|
||||
|
||||
# Règle : Bun-first
|
||||
@@ -11,7 +11,7 @@ Par défaut, utiliser **Bun** et ses APIs natives plutôt que les équivalents N
|
||||
|---|---|
|
||||
| `node <file>`, `ts-node` | `bun <file>` |
|
||||
| `jest`, `vitest` | `bun test` |
|
||||
| `npm/yarn/pnpm install` | `bun install` |
|
||||
| `npm/yarn install`, `bun install` | **`pnpm install`** (voir exception ci-dessous) |
|
||||
| `npm run <script>` | `bun run <script>` |
|
||||
| `npx <pkg>` | `bunx <pkg>` |
|
||||
| `webpack`, `esbuild`, `vite` | `bun build` / bundler Bun (HTML imports) |
|
||||
@@ -26,6 +26,14 @@ Par défaut, utiliser **Bun** et ses APIs natives plutôt que les équivalents N
|
||||
|
||||
Détail des APIs : [[knowledge_bun-apis]].
|
||||
|
||||
## Pourquoi
|
||||
## Exception : l'installation des paquets passe par pnpm
|
||||
|
||||
Le projet est tout-Bun (runtime, bundler, test, serveur). Réintroduire un outil Node redondant ajoute une dépendance, divergerait des conventions du repo, et casse l'intégration native (HMR, transpilation TS automatique, chargement `.env`). C'est un choix de cohérence, pas une préférence cosmétique.
|
||||
**L'installation des dépendances se fait avec `pnpm install` — pas `bun install` — dans les DEUX repos** (Festipod *et* le polyfill `@ng-eventually/client`). Tout le reste reste Bun : **runtime, build, test, scripts** (`bun run dev`, `bun build`, `bun test`, `bunx`). Seule l'étape d'installation change de gestionnaire.
|
||||
|
||||
**Pourquoi.** Le polyfill est installé en prod depuis un dépôt Gitea comme dépendance git à **sous-répertoire** : `git+https://…/ng-eventually.git#main&path:/packages/client`. pnpm (≥ 10.26) résout ce format `#<ref>&path:/…` et garantit une **seule** instance de `@ng-org/*` (un seul verifier) ; `bun install` ne couvre pas ce workflow proprement. Le lockfile de référence est donc `pnpm-lock.yaml`, et le lien local réactif du polyfill passe par `pnpm run link:polyfill` (voir [[knowledge_deployment]]).
|
||||
|
||||
**Conséquence pratique.** Les scripts npm qui reposaient sur `node_modules/.bin/*` peuvent casser (pnpm y place des shims shell, pas des entrées JS) — appeler l'entrée JS réelle du paquet (ex. `node_modules/@cucumber/cucumber/bin/cucumber.js`) plutôt que le shim `.bin/`.
|
||||
|
||||
## Pourquoi (Bun pour tout le reste)
|
||||
|
||||
Le projet est tout-Bun (runtime, bundler, test, serveur). Réintroduire un outil Node redondant ajoute une dépendance, divergerait des conventions du repo, et casse l'intégration native (HMR, transpilation TS automatique, chargement `.env`). C'est un choix de cohérence, pas une préférence cosmétique. L'exception d'installation ci-dessus est le seul écart, et il est motivé par la dépendance git à sous-répertoire.
|
||||
|
||||
@@ -14,18 +14,21 @@ Web app mobile-first où les utilisateurs créent des **points de rencontre** qu
|
||||
- **Un module n'importe QUE depuis `shared/` — jamais d'un autre module.** C'est l'invariant qui rend l'archi réelle.
|
||||
- **Bun-first** : `bun` / `bun install` / `bun test` / `bun build`, jamais node/npm/vite/jest. `bun run dev` (port 3000).
|
||||
|
||||
## Frontière SDK NextGraph
|
||||
|
||||
Le SDK de données de Festipod est **`@ng-eventually/client`** — traité comme un **SDK NextGraph fini et mature** (documents par entité placés par scope public/protected/private, capabilities, inboxes). Il est injecté une seule fois via `ngSession.configure(...)`. **Ne jamais documenter dans ce repo l'état courant de NextGraph** (contraintes du SDK sous-jacent, contournements, internes broker/verifier) : cela vit dans le repo `@ng-eventually/client`. La doctrine Festipod décrit uniquement *comment Festipod utilise ce SDK* + le domaine + l'architecture + le contrat BDD.
|
||||
|
||||
## Doctrine du projet — concepts (livrée automatiquement)
|
||||
|
||||
La connaissance détaillée vit dans `.project/concepts/` (système *concept*) : fiches courtes, typées, **livrées par un hook quand tu touches leur territoire** — tu n'as pas à les charger d'avance. Les 6 concepts :
|
||||
|
||||
| Concept | Couvre |
|
||||
|---|---|
|
||||
| `functional-domain` | Modèle produit : point de rencontre, acteurs, concepts métier, défi déduplication |
|
||||
| `functional-domain` | Modèle produit : point de rencontre, acteurs, concepts métier, périmètres public/protected/private par entité, découverte, défi déduplication |
|
||||
| `app-architecture` | Modules, invariant d'imports, app shell, routing path-based, écrans |
|
||||
| `tech-stack` | Bun-first, APIs Bun, build pipeline, commandes |
|
||||
| `data-layer` | NextGraph actuel (mono-store), shapes, modes connected/demo, règles + pièges (suppression, champs perdus, internals) |
|
||||
| `data-layer` | Persistance via le SDK `@ng-eventually/client` : entités-documents par scope, shapes SHEX/ORM, modes connected/demo, pièges |
|
||||
| `bdd-testing` | Cucumber multi-couches FR, contrat `@ui`/`@data`/`@e2e`, harness broker, cookbook |
|
||||
| `app-security` | Posture de sécurité actuelle (mono-store, confiance broker), auth wallet, modèle d'autorisations cible |
|
||||
| `nextgraph-platform` | NextGraph système externe (stores, inbox, SDK) + briefs prospectifs (multi-store, fork, shim) |
|
||||
| `app-security` | Isolation déléguée au SDK (pas de contrôle d'accès dans les écrans), auth wallet, matrice d'autorisations cible |
|
||||
|
||||
Pour **documenter** un fait projet : `/concept document <sujet>` (ne pas écrire en libre dans `.project/`).
|
||||
|
||||
+12
-4
@@ -1,11 +1,19 @@
|
||||
# Use the official Bun image
|
||||
# Use the official Bun image (runtime stays Bun; only install moves to pnpm)
|
||||
FROM oven/bun:1-alpine AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
# Install dependencies with pnpm.
|
||||
# - git: the @ng-eventually/client polyfill is a git+https (public Gitea) dependency → no auth.
|
||||
# - nodejs + npm: pnpm is a Node CLI; we pin the exact pnpm version via `npm i -g`
|
||||
# (Alpine's nodejs package does not bundle corepack).
|
||||
# The `bun` npm peer (pulled by bun-plugin-tailwind) is approved to build in package.json
|
||||
# (pnpm.onlyBuiltDependencies) so node_modules/.bin/bun is a real binary — required because
|
||||
# `bun run start` puts node_modules/.bin ahead of PATH.
|
||||
FROM base AS install
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
RUN apk add --no-cache git nodejs npm \
|
||||
&& npm install -g pnpm@10.26.0
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source code and build assets
|
||||
FROM base AS release
|
||||
|
||||
@@ -142,6 +142,11 @@ const result = await Bun.build({
|
||||
"globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__": JSON.stringify(
|
||||
process.env.FESTIPOD_SHARED_WALLET_PASSWORD ?? "",
|
||||
),
|
||||
// Auto-seed gate (OFF by default): only seed a genuinely-empty wallet with
|
||||
// demo data when FESTIPOD_AUTO_SEED is set (see src/shared/utils/autoSeed.ts).
|
||||
"globalThis.__FESTIPOD_AUTO_SEED__": JSON.stringify(
|
||||
process.env.FESTIPOD_AUTO_SEED ?? "",
|
||||
),
|
||||
},
|
||||
...cliConfig,
|
||||
});
|
||||
|
||||
+1
-1
@@ -6,13 +6,13 @@
|
||||
"src/modules/*/steps/**/*.ts"
|
||||
],
|
||||
"paths": ["src/modules/*/features/**/*.feature"],
|
||||
"tags": "not @wip and not @humain",
|
||||
"format": [
|
||||
"progress-bar",
|
||||
"json:reports/cucumber-report.json",
|
||||
"html:reports/cucumber-report.html"
|
||||
],
|
||||
"language": "fr",
|
||||
"tags": "not @wip",
|
||||
"formatOptions": {
|
||||
"snippetInterface": "async-await"
|
||||
},
|
||||
|
||||
@@ -15,11 +15,14 @@
|
||||
"features:parse": "bun scripts/parse-features.ts",
|
||||
"steps:extract": "bun scripts/extract-step-definitions.ts",
|
||||
"build:orm": "rdf-orm build --input ./src/shapes/shex --output ./src/shapes/orm",
|
||||
"validate": "bun scripts/validate.ts",
|
||||
"build:ng": "bash scripts/build-ng-packages.sh",
|
||||
"link:polyfill": "bun scripts/link-polyfill.ts",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ng-eventually/client": "git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#main&path:/packages/client",
|
||||
"@ng-org/alien-deepsignals": "0.1.2-alpha.11",
|
||||
"@ng-org/orm": "0.1.2-alpha.18",
|
||||
"@ng-org/shex-orm": "0.1.2-alpha.8",
|
||||
@@ -56,5 +59,10 @@
|
||||
"@storybook/addon-a11y": "^10.3.5",
|
||||
"@storybook/addon-docs": "^10.3.5",
|
||||
"@storybook/addon-onboarding": "^10.3.5"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"bun"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+6408
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* link-polyfill.ts — Reactive local link for the @ng-eventually/client polyfill (S2).
|
||||
*
|
||||
* WHY S2 (copy-overlay) and not a symlink (S1):
|
||||
* The committed prod dependency installs @ng-eventually/client from Gitea (git+https)
|
||||
* into pnpm's store WITHOUT its own node_modules/@ng-org → @ng-org/web resolves up to
|
||||
* Festipod → ONE @ng-org instance (one verifier). The local polyfill CHECKOUT, however,
|
||||
* carries its own node_modules/@ng-org/* (symlinks into the ng-eventually-js monorepo
|
||||
* store). Symlinking node_modules/@ng-eventually/client to that checkout puts the
|
||||
* checkout's @ng-org in the resolution path → a SECOND @ng-org instance → broken SDK
|
||||
* (two verifiers). So we overlay a real directory that contains ONLY the polyfill's
|
||||
* source (no node_modules) and keep it in sync by copying — @ng-org still resolves to
|
||||
* Festipod, single instance preserved.
|
||||
*
|
||||
* WHAT IT DOES:
|
||||
* 1. Replaces node_modules/@ng-eventually/client (the pnpm store symlink) with a real
|
||||
* directory holding the local polyfill's package.json + src (NO node_modules).
|
||||
* 2. Asserts the single-instance invariant (same @ng-org/web realpath from Festipod and
|
||||
* from the overlay) — aborts if it would break.
|
||||
* 3. Watches the local polyfill src and copies each change into the overlay, so
|
||||
* `bun --hot` (bun run dev) reloads the edited file live.
|
||||
*
|
||||
* USAGE (reactive dev):
|
||||
* Terminal 1: pnpm run link:polyfill # overlays local source, then watches
|
||||
* Terminal 2: bun run dev # portless festipod bun --hot src/index.ts
|
||||
* Edit files under packages/client/src → they land in node_modules → bun --hot reloads.
|
||||
*
|
||||
* pnpm run link:polyfill --once # overlay + verify, no watch (CI / one-shot)
|
||||
* Return to the committed git-installed dependency: pnpm install
|
||||
*
|
||||
* Override the local checkout path with NG_EVENTUALLY_LOCAL=/path/to/packages/client.
|
||||
*/
|
||||
import { existsSync, lstatSync, mkdirSync, rmSync, cpSync, copyFileSync, realpathSync } from "node:fs";
|
||||
import { watch } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
|
||||
const FESTIPOD = realpathSync(join(import.meta.dir, ".."));
|
||||
const LOCAL =
|
||||
process.env.NG_EVENTUALLY_LOCAL ??
|
||||
"/home/sylvain/projects/nextgraph/ng-eventually-js/packages/client";
|
||||
const TARGET = join(FESTIPOD, "node_modules", "@ng-eventually", "client");
|
||||
const SRC_LOCAL = join(LOCAL, "src");
|
||||
const SRC_TARGET = join(TARGET, "src");
|
||||
const ONCE = process.argv.includes("--once");
|
||||
|
||||
function fail(msg: string): never {
|
||||
console.error(`✖ link:polyfill — ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!existsSync(join(LOCAL, "package.json"))) {
|
||||
fail(`local polyfill not found at ${LOCAL} (set NG_EVENTUALLY_LOCAL to override)`);
|
||||
}
|
||||
|
||||
// 1. Replace the pnpm store symlink with a real overlay dir (metadata + src, NO node_modules).
|
||||
console.log(`→ overlaying local polyfill: ${LOCAL}`);
|
||||
if (existsSync(TARGET) || lstatSync(TARGET, { throwIfNoEntry: false })) {
|
||||
rmSync(TARGET, { recursive: true, force: true });
|
||||
}
|
||||
mkdirSync(TARGET, { recursive: true });
|
||||
for (const meta of ["package.json", "tsconfig.json", "README.md"]) {
|
||||
const from = join(LOCAL, meta);
|
||||
if (existsSync(from)) copyFileSync(from, join(TARGET, meta));
|
||||
}
|
||||
// Copy src fresh (NEVER a node_modules dir — that is what guarantees single @ng-org instance).
|
||||
cpSync(SRC_LOCAL, SRC_TARGET, { recursive: true });
|
||||
|
||||
// 2. Assert the single-instance invariant.
|
||||
const fromFestipod = realpathSync(Bun.resolveSync("@ng-org/web", FESTIPOD));
|
||||
const overlayReal = realpathSync(TARGET);
|
||||
const fromPolyfill = realpathSync(Bun.resolveSync("@ng-org/web", overlayReal));
|
||||
console.log(` @ng-org/web (Festipod): ${fromFestipod}`);
|
||||
console.log(` @ng-org/web (overlay) : ${fromPolyfill}`);
|
||||
if (fromFestipod !== fromPolyfill) {
|
||||
fail(
|
||||
"single-instance invariant BROKEN — @ng-org/web resolves to two different realpaths.\n" +
|
||||
" The overlay must not contain its own node_modules/@ng-org. Aborting.",
|
||||
);
|
||||
}
|
||||
console.log("✓ single @ng-org/web instance preserved");
|
||||
|
||||
if (ONCE) {
|
||||
console.log("✓ overlay ready (--once, not watching)");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 3. Watch and copy on change so `bun --hot` sees live edits.
|
||||
console.log(`👀 watching ${SRC_LOCAL} → ${SRC_TARGET} (Ctrl-C to stop)`);
|
||||
watch(SRC_LOCAL, { recursive: true }, (_event, filename) => {
|
||||
if (!filename) return;
|
||||
const from = join(SRC_LOCAL, filename);
|
||||
const to = join(SRC_TARGET, filename);
|
||||
try {
|
||||
if (existsSync(from)) {
|
||||
mkdirSync(dirname(to), { recursive: true });
|
||||
copyFileSync(from, to);
|
||||
console.log(` ↻ ${filename}`);
|
||||
} else if (existsSync(to)) {
|
||||
rmSync(to, { force: true });
|
||||
console.log(` ✗ ${filename} (removed)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(` ! failed to sync ${filename}:`, err);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,490 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* validate.ts — Validation matrix (broker-level tests only, no @ui/@e2e).
|
||||
*
|
||||
* Default run (no flags) — key subset only, fast:
|
||||
* (a) Polyfill unit tests (@ng-eventually/client — bun test)
|
||||
* (b) Polyfill e2e real-broker (@ng-eventually/client — bun run e2e/run.ts)
|
||||
* (c) Festipod @data KEY SUBSET (cucumber --name regex covering terrain bugs)
|
||||
* (d) Festipod @multibrowser (cucumber --tags @multibrowser)
|
||||
* (e) Festipod @smoke (cucumber --tags @smoke — boot connecté rend)
|
||||
* (f) Festipod @wip [informational only, non-blocking]
|
||||
*
|
||||
* With --full flag:
|
||||
* (c) becomes full @data suite (cucumber --tags @data)
|
||||
*
|
||||
* Each step runs even if the previous one failed (--bail mode is OFF).
|
||||
* Exit code is non-zero if any non-informational step has failures.
|
||||
*
|
||||
* Profile rotation: both Playwright profiles are rotated before @data and
|
||||
* @multibrowser when their size exceeds BLOAT_THRESHOLD_MB (default 50 MB),
|
||||
* to avoid the sparql_query hang described in caveat_wallet-bloat-hang.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "child_process";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
// ─── Config ────────────────────────────────────────────────────────────────
|
||||
|
||||
const FESTIPOD_DIR = "/home/sylvain/projects/festipod/festipod";
|
||||
const POLYFILL_DIR =
|
||||
"/home/sylvain/projects/nextgraph/ng-eventually-js/packages/client";
|
||||
|
||||
const FESTIPOD_PROFILE = path.join(FESTIPOD_DIR, ".playwright-profile");
|
||||
const POLYFILL_PROFILE = path.join(
|
||||
POLYFILL_DIR,
|
||||
"e2e",
|
||||
".playwright-profile-lib",
|
||||
);
|
||||
|
||||
/** Rotate profile when it exceeds this many MB (caveat_wallet-bloat-hang). */
|
||||
const BLOAT_THRESHOLD_MB = 50;
|
||||
|
||||
/**
|
||||
* Per-step timeouts:
|
||||
* - polyfill unit/e2e: short steps, keep 10 min
|
||||
* - @data key subset: generous — BeforeAll + 8 scenarios, ~15 min margin
|
||||
* - @data full: full suite, ~35 min margin
|
||||
* - @multibrowser: 7 scenarios, ~10 min margin
|
||||
* - @wip: informational, 10 min
|
||||
*/
|
||||
const TIMEOUT_POLYFILL_UNIT_MS = 10 * 60 * 1000; // 10 min
|
||||
const TIMEOUT_POLYFILL_E2E_MS = 10 * 60 * 1000; // 10 min
|
||||
const TIMEOUT_DATA_KEY_MS = 15 * 60 * 1000; // 15 min (key subset)
|
||||
const TIMEOUT_DATA_FULL_MS = 35 * 60 * 1000; // 35 min (--full)
|
||||
const TIMEOUT_MULTIBROWSER_MS = 10 * 60 * 1000; // 10 min
|
||||
const TIMEOUT_SMOKE_MS = 10 * 60 * 1000; // 10 min (1 @e2e boot scenario)
|
||||
const TIMEOUT_WIP_MS = 10 * 60 * 1000; // 10 min
|
||||
|
||||
/**
|
||||
* Key-subset --name regex: matches exactly the 8 scenarios that cover the
|
||||
* known terrain bugs (inscription, désinscription, isolation, reconnexion,
|
||||
* compteur dérivé, créateur ne participe pas, auth vide, auth distinctes).
|
||||
*
|
||||
* Uses a single cucumber invocation so BeforeAll (broker login) runs once.
|
||||
*
|
||||
* French accent chars must be URL-safe in the regex — cucumber uses JS
|
||||
* RegExp, which handles unicode natively; we pass the literal string.
|
||||
*/
|
||||
const DATA_KEY_NAME_REGEX = [
|
||||
"S'inscrire à un événement",
|
||||
"Se désinscrire d'un événement$",
|
||||
"Une identité fraîche ne voit pas la participation d'une autre",
|
||||
"Une page fraîche pour la même identité relit ses propres données",
|
||||
"Le créateur ne participe pas automatiquement à son événement",
|
||||
"L'inscription fait converger le compteur dérivé du propriétaire",
|
||||
"Un portefeuille connecté est vide par défaut",
|
||||
"Les données du portefeuille sont distinctes des données par défaut",
|
||||
].join("|");
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function dirSizeMB(dir: string): number {
|
||||
if (!fs.existsSync(dir)) return 0;
|
||||
try {
|
||||
const result = spawnSync("du", ["-sm", dir], { encoding: "utf-8" });
|
||||
const line = result.stdout.trim().split("\n")[0] ?? "";
|
||||
return parseInt(line.split("\t")[0] ?? "0", 10);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any stale Chromium singleton files from `profilePath`. Chromium refuses
|
||||
* to launch (ProcessSingleton error) if SingletonLock, SingletonCookie, or
|
||||
* SingletonSocket are left over from a previous crashed run. Idempotent — safe to
|
||||
* call even when the profile does not exist yet.
|
||||
*/
|
||||
function cleanSingletons(profilePath: string, label: string): void {
|
||||
if (!fs.existsSync(profilePath)) return;
|
||||
const singletons = ["SingletonLock", "SingletonCookie", "SingletonSocket"];
|
||||
for (const name of singletons) {
|
||||
const p = path.join(profilePath, name);
|
||||
if (fs.existsSync(p)) {
|
||||
try {
|
||||
fs.rmSync(p, { force: true });
|
||||
console.log(`[rotate] ${label}: removed stale ${name}.`);
|
||||
} catch {
|
||||
// Non-fatal: if we can't remove it, launch will fail with a clear error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rotateProfile(profilePath: string, label: string): void {
|
||||
const sizeMB = dirSizeMB(profilePath);
|
||||
if (sizeMB > BLOAT_THRESHOLD_MB) {
|
||||
console.log(
|
||||
`[rotate] ${label}: ${sizeMB}MB > ${BLOAT_THRESHOLD_MB}MB — rotating profile...`,
|
||||
);
|
||||
try {
|
||||
fs.rmSync(profilePath, { recursive: true, force: true });
|
||||
console.log(`[rotate] ${label}: profile removed. Will be recreated.`);
|
||||
} catch (e) {
|
||||
console.warn(`[rotate] ${label}: failed to remove profile: ${e}`);
|
||||
}
|
||||
} else {
|
||||
// Even if we keep the profile, remove any stale Chromium singleton files left
|
||||
// by a previous crashed run — Chromium refuses to launch if they exist.
|
||||
cleanSingletons(profilePath, label);
|
||||
console.log(
|
||||
`[rotate] ${label}: ${sizeMB}MB — below threshold, keeping profile.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface StepResult {
|
||||
label: string;
|
||||
status: "passed" | "failed" | "error";
|
||||
/** Lines to show in the summary (failed scenario names, FAIL lines, etc.) */
|
||||
failures: string[];
|
||||
/** Raw exit code */
|
||||
exitCode: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command and capture its output. Returns the result with parsed
|
||||
* pass/fail summary. Never throws — all errors are captured in StepResult.
|
||||
*/
|
||||
function runStep(
|
||||
label: string,
|
||||
cmd: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
timeoutMs: number,
|
||||
extraEnv: Record<string, string> = {},
|
||||
): StepResult {
|
||||
const t0 = Date.now();
|
||||
console.log(`\n${"═".repeat(60)}`);
|
||||
console.log(`▶ ${label}`);
|
||||
console.log(` ${cmd} ${args.join(" ")} (cwd: ${cwd})`);
|
||||
console.log(` timeout: ${Math.round(timeoutMs / 60000)}min`);
|
||||
console.log(`${"═".repeat(60)}`);
|
||||
|
||||
const env = { ...process.env, ...extraEnv };
|
||||
|
||||
const result = spawnSync(cmd, args, {
|
||||
cwd,
|
||||
env,
|
||||
encoding: "utf-8",
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 20 * 1024 * 1024, // 20MB
|
||||
});
|
||||
|
||||
const durationMs = Date.now() - t0;
|
||||
const stdout = result.stdout ?? "";
|
||||
const stderr = result.stderr ?? "";
|
||||
const combined = stdout + "\n" + stderr;
|
||||
|
||||
// Print output in real-time equivalent (post-hoc since spawnSync)
|
||||
if (stdout) process.stdout.write(stdout);
|
||||
if (stderr) process.stderr.write(stderr);
|
||||
|
||||
if (result.error) {
|
||||
console.error(`[${label}] process error:`, result.error.message);
|
||||
return {
|
||||
label,
|
||||
status: "error",
|
||||
failures: [`Process error: ${result.error.message}`],
|
||||
exitCode: result.status ?? 1,
|
||||
durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
const exitCode = result.status ?? 1;
|
||||
const failures = extractFailures(combined, label);
|
||||
const status = exitCode === 0 ? "passed" : "failed";
|
||||
|
||||
return { label, status, failures, exitCode, durationMs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract meaningful failure lines from combined stdout+stderr.
|
||||
* Heuristics per step type (cucumber scenario names, FAIL lines, etc.).
|
||||
*/
|
||||
function extractFailures(output: string, label: string): string[] {
|
||||
const lines = output.split("\n");
|
||||
const failures: string[] = [];
|
||||
|
||||
if (label.includes("polyfill:unit")) {
|
||||
// bun test output: lines starting with "✗" or "FAIL" or "fail"
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (/^(✗|✕|FAIL|fail)\s/.test(l) || l.includes("tests failed")) {
|
||||
failures.push(l);
|
||||
}
|
||||
}
|
||||
// Also capture summary line "N passed, M failed"
|
||||
const summary = lines.find(
|
||||
(l) => l.includes("passed") && l.includes("failed"),
|
||||
);
|
||||
if (summary) failures.push(summary.trim());
|
||||
} else if (label.includes("polyfill:e2e")) {
|
||||
// e2e/run.ts output: lines starting with " [FAIL]"
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (l.startsWith("[FAIL]")) failures.push(l);
|
||||
}
|
||||
// Summary: "N passed / M failed" style
|
||||
const summary = lines.find(
|
||||
(l) => l.includes("passed") || l.includes("failed"),
|
||||
);
|
||||
if (summary && !failures.includes(summary.trim()))
|
||||
failures.push(summary.trim());
|
||||
} else {
|
||||
// Cucumber steps: look for "✗" scenario lines, "FAILED" scenario names,
|
||||
// or lines beginning with "✖" / "×" / "Scenario:" after a failure tag
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (
|
||||
/^(✗|✕|×|✖)\s/.test(l) ||
|
||||
l.startsWith("✘") ||
|
||||
l.includes("# Scénario:") ||
|
||||
l.includes("# Scenario:") ||
|
||||
(l.startsWith("F") && l.length === 1) // progress-bar failure tick
|
||||
) {
|
||||
if (l.length > 1) failures.push(l);
|
||||
}
|
||||
}
|
||||
// Cucumber "N scenarios (M failed)" summary
|
||||
const summary = lines.find((l) =>
|
||||
/\d+ sc[eé]narios?.*(failed|undefined)/.test(l),
|
||||
);
|
||||
if (summary) failures.push(summary.trim());
|
||||
// Individual scenario fail lines: "✗ Scenario name (features/...)"
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (l.startsWith("✗") || l.startsWith("✕")) {
|
||||
if (!failures.includes(l)) failures.push(l);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return failures.filter(Boolean);
|
||||
}
|
||||
|
||||
function fmtDuration(ms: number): string {
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
const m = Math.floor(ms / 60_000);
|
||||
const s = ((ms % 60_000) / 1000).toFixed(0);
|
||||
return `${m}m${s}s`;
|
||||
}
|
||||
|
||||
function printMatrix(
|
||||
steps: StepResult[],
|
||||
wipResult: StepResult | null,
|
||||
fullMode: boolean,
|
||||
): void {
|
||||
console.log("\n");
|
||||
console.log("╔══════════════════════════════════════════════════════════╗");
|
||||
console.log("║ VALIDATION MATRIX ║");
|
||||
if (fullMode) {
|
||||
console.log("║ (mode: --full, @data complet) ║");
|
||||
} else {
|
||||
console.log("║ (mode: défaut, sous-ensemble clé) ║");
|
||||
}
|
||||
console.log("╚══════════════════════════════════════════════════════════╝");
|
||||
console.log("");
|
||||
|
||||
const maxLabel = Math.max(...steps.map((s) => s.label.length));
|
||||
|
||||
for (const step of steps) {
|
||||
const icon = step.status === "passed" ? "✅" : step.status === "failed" ? "❌" : "⚠️ ";
|
||||
const pad = step.label.padEnd(maxLabel + 2);
|
||||
console.log(` ${icon} ${pad} [${fmtDuration(step.durationMs)}]`);
|
||||
for (const f of step.failures) {
|
||||
console.log(` ↳ ${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (wipResult) {
|
||||
console.log("");
|
||||
console.log(" ── @wip (informational, non-blocking) ──────────────────");
|
||||
const icon =
|
||||
wipResult.status === "passed"
|
||||
? "✅"
|
||||
: wipResult.status === "failed"
|
||||
? "❌"
|
||||
: "⚠️ ";
|
||||
const pad = wipResult.label.padEnd(maxLabel + 2);
|
||||
console.log(` ${icon} ${pad} [${fmtDuration(wipResult.durationMs)}]`);
|
||||
for (const f of wipResult.failures) {
|
||||
console.log(` ↳ ${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
const allPassed = steps.every((s) => s.status === "passed");
|
||||
const totalMs = steps.reduce((sum, s) => sum + s.durationMs, 0) +
|
||||
(wipResult?.durationMs ?? 0);
|
||||
if (allPassed) {
|
||||
console.log(" 🟢 ALL STEPS PASSED");
|
||||
} else {
|
||||
const failed = steps.filter((s) => s.status !== "passed");
|
||||
console.log(` 🔴 ${failed.length} STEP(S) FAILED: ${failed.map((s) => s.label).join(", ")}`);
|
||||
}
|
||||
console.log(` ⏱ Total: ${fmtDuration(totalMs)}`);
|
||||
if (!fullMode) {
|
||||
console.log(" ℹ️ Pour @data complet : bun run validate -- --full");
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// ─── Cucumber command builder ───────────────────────────────────────────────
|
||||
|
||||
function cucumberArgsByTags(tags: string): string[] {
|
||||
return [
|
||||
"--import",
|
||||
"tsx/esm",
|
||||
"node_modules/.bin/cucumber-js",
|
||||
"--config",
|
||||
"cucumber.json",
|
||||
"--tags",
|
||||
tags,
|
||||
];
|
||||
}
|
||||
|
||||
function cucumberArgsByName(nameRegex: string): string[] {
|
||||
return [
|
||||
"--import",
|
||||
"tsx/esm",
|
||||
"node_modules/.bin/cucumber-js",
|
||||
"--config",
|
||||
"cucumber.json",
|
||||
"--tags",
|
||||
"@data",
|
||||
"--name",
|
||||
nameRegex,
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Main ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const fullMode = args.includes("--full");
|
||||
|
||||
if (fullMode) {
|
||||
console.log("🔍 Festipod — Full Validation Run (--full : @data complet)");
|
||||
} else {
|
||||
console.log("🔍 Festipod — Validation Run (sous-ensemble clé)");
|
||||
console.log(" Pour @data complet : bun run validate -- --full");
|
||||
}
|
||||
console.log(` Festipod: ${FESTIPOD_DIR}`);
|
||||
console.log(` Polyfill: ${POLYFILL_DIR}`);
|
||||
console.log("");
|
||||
|
||||
// ── Profile rotation AVANT les étapes broker ──────────────────────────────
|
||||
console.log("── Profile rotation check (avant @data et @multibrowser) ────");
|
||||
rotateProfile(FESTIPOD_PROFILE, "festipod");
|
||||
rotateProfile(POLYFILL_PROFILE, "polyfill-lib");
|
||||
|
||||
const steps: StepResult[] = [];
|
||||
|
||||
// ── (a) Polyfill unit tests ───────────────────────────────────────────────
|
||||
steps.push(
|
||||
runStep(
|
||||
"polyfill:unit",
|
||||
"bun",
|
||||
["test"],
|
||||
POLYFILL_DIR,
|
||||
TIMEOUT_POLYFILL_UNIT_MS,
|
||||
),
|
||||
);
|
||||
|
||||
// ── (b) Polyfill e2e real broker ──────────────────────────────────────────
|
||||
// Clean singleton files immediately before launching Chromium — guards against
|
||||
// any file left by polyfill:unit (unlikely but defensive) or by a previous
|
||||
// interrupted run that the initial rotateProfile call ran before.
|
||||
cleanSingletons(POLYFILL_PROFILE, "polyfill-lib (pre-e2e)");
|
||||
steps.push(
|
||||
runStep(
|
||||
"polyfill:e2e",
|
||||
"bun",
|
||||
["run", "e2e/run.ts"],
|
||||
POLYFILL_DIR,
|
||||
TIMEOUT_POLYFILL_E2E_MS,
|
||||
),
|
||||
);
|
||||
|
||||
// ── (c) Festipod @data ────────────────────────────────────────────────────
|
||||
if (fullMode) {
|
||||
// --full : lance tout @data
|
||||
steps.push(
|
||||
runStep(
|
||||
"festipod:@data (complet)",
|
||||
"node",
|
||||
cucumberArgsByTags("@data"),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_DATA_FULL_MS,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// défaut : sous-ensemble clé en UNE invocation (BeforeAll partagé)
|
||||
steps.push(
|
||||
runStep(
|
||||
"festipod:@data (clé)",
|
||||
"node",
|
||||
cucumberArgsByName(DATA_KEY_NAME_REGEX),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_DATA_KEY_MS,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── (d) Festipod @multibrowser ────────────────────────────────────────────
|
||||
// Exclude @wip: a scenario tagged @wip @multibrowser (e.g. the reactive
|
||||
// cross-session scenario, blocked by the shared-wallet structural limit) must
|
||||
// not gate the baseline — it flows into the informational @wip pass below.
|
||||
steps.push(
|
||||
runStep(
|
||||
"festipod:@multibrowser",
|
||||
"node",
|
||||
cucumberArgsByTags("@multibrowser and not @wip"),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_MULTIBROWSER_MS,
|
||||
),
|
||||
);
|
||||
|
||||
// ── (e) Festipod @smoke — boot connecté rend / page blanche ───────────────
|
||||
// Un seul scénario @e2e : boote le VRAI App, se connecte au broker, et vérifie
|
||||
// que l'accueil connecté rend du contenu d'app réel SANS erreur runtime. Garde
|
||||
// la CLASSE « crash de rendu une fois connecté » (page blanche). On ne lance
|
||||
// QUE @smoke (pas tout @e2e) pour garder le run par défaut rapide.
|
||||
// Nettoie les singletons Chromium juste avant, comme les autres étapes broker.
|
||||
cleanSingletons(FESTIPOD_PROFILE, "festipod (pre-@smoke)");
|
||||
steps.push(
|
||||
runStep(
|
||||
"festipod:@smoke",
|
||||
"node",
|
||||
cucumberArgsByTags("@smoke and not @wip"),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_SMOKE_MS,
|
||||
),
|
||||
);
|
||||
|
||||
// ── (f) Festipod @wip [informational] ────────────────────────────────────
|
||||
console.log("\n── @wip informational pass (non-blocking) ──────────────────");
|
||||
const wipResult = runStep(
|
||||
"festipod:@wip",
|
||||
"node",
|
||||
cucumberArgsByTags("@wip"),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_WIP_MS,
|
||||
);
|
||||
|
||||
// ── Matrix ────────────────────────────────────────────────────────────────
|
||||
printMatrix(steps, wipResult, fullMode);
|
||||
|
||||
// ── Exit code ─────────────────────────────────────────────────────────────
|
||||
const anyFailed = steps.some((s) => s.status !== "passed");
|
||||
process.exit(anyFailed ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("validate.ts: unhandled error:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
+60
-17
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* 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.
|
||||
* 1. Access barrier + identifier (AccessGateScreen) → the user names their
|
||||
* virtual space (an identifier) and opens the SHARED wallet via the broker
|
||||
* redirect (with the wallet file + guide it hands the user). Naming the
|
||||
* space and opening it are ONE act.
|
||||
* 2. The app.
|
||||
*
|
||||
* The gate is ON BY DEFAULT (Festipod never functions without NextGraph). It is
|
||||
* disabled only when `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true` —
|
||||
@@ -12,11 +13,11 @@
|
||||
* screens, not the auth flow). Absent → gate ON.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { useNextGraph } from '../shared/context/NextGraphContext';
|
||||
import { useAccount } from '../shared/context/AccountContext';
|
||||
import { useAccount, normalizeIdentifier } from '../shared/context/AccountContext';
|
||||
import { AccessGateScreen } from '../modules/auth/screens/AccessGateScreen';
|
||||
import { ConnexionScreen } from '../modules/auth/screens/ConnexionScreen';
|
||||
import { useRouter, useNavigate } from './router';
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
@@ -26,23 +27,65 @@ const GATE_DISABLED = globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true;
|
||||
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const { status, error, connect } = useNextGraph();
|
||||
const { username } = useAccount();
|
||||
const { identifier, login } = useAccount();
|
||||
const { route } = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Once connected AND identified, leave the disconnected welcome screen for the
|
||||
// app home. The identifier is now set at the barrier (before the broker
|
||||
// round-trip), so on return the app can land on '/' with a session already
|
||||
// open; the removed ConnexionScreen used to do this navigate on login.
|
||||
useEffect(() => {
|
||||
if (!GATE_DISABLED && status === 'connected' && identifier && route.page === 'welcome') {
|
||||
navigate('/home');
|
||||
}
|
||||
}, [status, identifier, route.page, navigate]);
|
||||
|
||||
// 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} />;
|
||||
// Access barrier — shown until BOTH the wallet is open AND the space is named.
|
||||
// "Entrer" records the identifier (persisted to localStorage AND written into
|
||||
// the `?id=` URL param, which is what actually survives the broker redirect
|
||||
// across the partitioned frontier) and, if the wallet isn't open yet, triggers
|
||||
// the connect.
|
||||
//
|
||||
// On return (reload / broker round-trip) the identifier is already stored, so
|
||||
// we PREFILL the field with it (`initialIdentifier`) — the user never sees a
|
||||
// bare empty prompt they must re-type. It is captured ONCE, at first access.
|
||||
if (status !== 'connected' || !identifier) {
|
||||
const onEnter = (entered: string) => {
|
||||
login(entered);
|
||||
// Carry the identifier across the broker frontier via the URL. localStorage
|
||||
// is partitioned by top-level site, so the value written here (127.0.0.1)
|
||||
// is NOT what the app reads inside the broker iframe (nextgraph.net). The
|
||||
// `@ng-org/web` redirect embeds the FULL app URL (query included) in the
|
||||
// broker `o=`, which is reloaded in the iframe — so writing the normalized
|
||||
// id into `?id=` BEFORE connect() makes it travel. `history.replaceState`
|
||||
// (not push) keeps a single history entry. See AccountContext resolution.
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('id', normalizeIdentifier(entered));
|
||||
window.history.replaceState(window.history.state, '', url.toString());
|
||||
} catch {
|
||||
/* URL construction can't fail for a real page URL; ignore defensively */
|
||||
}
|
||||
}
|
||||
if (status !== 'connected') connect();
|
||||
};
|
||||
return (
|
||||
<AccessGateScreen
|
||||
status={status}
|
||||
error={error}
|
||||
initialIdentifier={identifier ?? ''}
|
||||
onEnter={onEnter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Perceived app login — until a username is chosen.
|
||||
if (!username) {
|
||||
return <ConnexionScreen />;
|
||||
}
|
||||
|
||||
// 3. The app.
|
||||
// The app.
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
+50
-15
@@ -3,24 +3,59 @@
|
||||
* element and renders the App component to the DOM.
|
||||
*
|
||||
* It is included in `src/index.html`.
|
||||
*
|
||||
* Before loading the app tree it pulls the RUNTIME shared-wallet config (dev
|
||||
* server + `bun run start`, which serve from src/ and so miss build.ts's
|
||||
* compile-time `define`), sets the global, then dynamically imports `App` so
|
||||
* `sharedWallet.ts` reads the value on evaluation. In a build.ts bundle the
|
||||
* password is already inlined via `define`, so this step is skipped (NODE_ENV).
|
||||
*/
|
||||
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
const elem = document.getElementById("root")!;
|
||||
const app = (
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
if (import.meta.hot) {
|
||||
// With hot module reloading, `import.meta.hot.data` is persisted.
|
||||
const root = (import.meta.hot.data.root ??= createRoot(elem));
|
||||
root.render(app);
|
||||
} else {
|
||||
// The hot module reloading API is not available in production.
|
||||
createRoot(elem).render(app);
|
||||
/** Fetch the runtime shared-wallet config and set the global (dev/start only). */
|
||||
async function loadRuntimeConfig(): Promise<void> {
|
||||
if (process.env.NODE_ENV === "production") return; // build.ts define provides it
|
||||
try {
|
||||
const res = await fetch("/festipod-config.json");
|
||||
if (!res.ok) return;
|
||||
const cfg = (await res.json()) as { sharedWalletPassword?: string; autoSeed?: string };
|
||||
// Bracket access so build.ts's `define` (which matches the dotted global)
|
||||
// never rewrites this assignment. Only set when the env actually carries one.
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
if (cfg.sharedWalletPassword && g["__FESTIPOD_SHARED_WALLET_PASSWORD__"] == null) {
|
||||
g["__FESTIPOD_SHARED_WALLET_PASSWORD__"] = cfg.sharedWalletPassword;
|
||||
}
|
||||
// Auto-seed gate (see src/shared/utils/autoSeed.ts): only set the global when
|
||||
// the env var carries a truthy value; absent → stays undefined → seed OFF.
|
||||
if (cfg.autoSeed && g["__FESTIPOD_AUTO_SEED__"] == null) {
|
||||
g["__FESTIPOD_AUTO_SEED__"] = cfg.autoSeed;
|
||||
}
|
||||
} catch {
|
||||
// No runtime config endpoint (static build) → rely on the compile-time define.
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await loadRuntimeConfig();
|
||||
// Dynamic import AFTER the global is set, so sharedWallet.ts reads it on eval.
|
||||
const { App } = await import("./App");
|
||||
const elem = document.getElementById("root")!;
|
||||
const app = (
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
if (import.meta.hot) {
|
||||
// With hot module reloading, `import.meta.hot.data` is persisted.
|
||||
const root = (import.meta.hot.data.root ??= createRoot(elem));
|
||||
root.render(app);
|
||||
} else {
|
||||
// The hot module reloading API is not available in production.
|
||||
createRoot(elem).render(app);
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
|
||||
@@ -333,6 +333,17 @@ body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Global data-query spinner (near the "Festipod" title) */
|
||||
@keyframes app-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.app-spinner {
|
||||
animation: app-spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Online indicator on avatar */
|
||||
.app-avatar .online-dot {
|
||||
position: absolute;
|
||||
|
||||
@@ -41,6 +41,28 @@ const server = serve({
|
||||
});
|
||||
},
|
||||
|
||||
// Shared-wallet config, exposed at RUNTIME for the dev server + `bun run start`
|
||||
// (both serve from src/, so they miss build.ts's compile-time `define`). The app
|
||||
// entry (frontend.tsx) fetches this before it loads the app tree, so
|
||||
// `sharedWallet.ts` sees the password. Empty env → '' → no shared wallet.
|
||||
"/festipod-config.json": () =>
|
||||
Response.json({
|
||||
sharedWalletPassword: process.env.FESTIPOD_SHARED_WALLET_PASSWORD ?? "",
|
||||
// Auto-seed gate (OFF by default): only set when the env var is present, so
|
||||
// the front seeds an empty wallet only on explicit opt-in (see autoSeed.ts).
|
||||
autoSeed: process.env.FESTIPOD_AUTO_SEED ?? "",
|
||||
}),
|
||||
|
||||
// The shared wallet file (download target of the access barrier), when configured.
|
||||
"/shared-wallet.ngw": async () => {
|
||||
const p = process.env.FESTIPOD_SHARED_WALLET_FILE;
|
||||
if (p) {
|
||||
const file = Bun.file(p);
|
||||
if (await file.exists()) return new Response(file);
|
||||
}
|
||||
return new Response("No shared wallet file configured.", { status: 404 });
|
||||
},
|
||||
|
||||
// Serve index.html for all unmatched routes (must be last)
|
||||
"/*": index,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# language: fr
|
||||
@AUTH @priority-1
|
||||
Fonctionnalité: Barrière d'accès — l'identifiant se saisit une seule fois
|
||||
En tant qu'utilisateur qui revient dans Festipod
|
||||
Je veux retrouver l'identifiant que j'ai déjà choisi, pré-rempli
|
||||
Afin de ne jamais avoir à le retaper à l'arrivée
|
||||
|
||||
# Garde-fou contre la régression rapportée : au retour (rechargement / round-trip
|
||||
# broker) la barrière re-demandait un identifiant NU et VIDE alors qu'il était
|
||||
# déjà stocké. L'identifiant est capturé UNE FOIS au premier accès, persisté,
|
||||
# puis pré-rempli. Voir AuthGate + AccessGateScreen.
|
||||
|
||||
@ui
|
||||
Scénario: Le champ identifiant est pré-rempli avec la valeur déjà stockée
|
||||
Étant donné que la barrière d'accès s'affiche avec l'identifiant stocké "alice"
|
||||
Alors le champ identifiant contient "alice"
|
||||
|
||||
@ui
|
||||
Scénario: Un premier accès sans identifiant stocké affiche un champ vide
|
||||
Étant donné que la barrière d'accès s'affiche sans identifiant stocké
|
||||
Alors le champ identifiant est vide
|
||||
|
||||
@ui
|
||||
Scénario: Entrer remonte l'identifiant saisi
|
||||
Étant donné que la barrière d'accès s'affiche avec l'identifiant stocké "alice"
|
||||
Quand je clique sur "Entrer" dans la barrière
|
||||
Alors l'identifiant remonté à l'application est "alice"
|
||||
@@ -0,0 +1,42 @@
|
||||
# language: fr
|
||||
@AUTH @priority-1
|
||||
Fonctionnalité: Résolution de l'identifiant — le param d'URL prime sur localStorage
|
||||
En tant qu'application relancée dans l'iframe du broker après le round-trip
|
||||
Je veux résoudre l'identifiant depuis le param d'URL "?id="
|
||||
Afin qu'il traverse la frontière top-level↔iframe (que localStorage ne franchit pas)
|
||||
|
||||
# Le flux wallet-partagé fait tourner l'app dans DEUX contextes avec DEUX
|
||||
# partitions localStorage distinctes (top-level 127.0.0.1 vs iframe
|
||||
# nextgraph.net). localStorage ne traverse pas la frontière ; le param "?id="
|
||||
# embarqué dans le redirect broker (o=) la traverse. AccountContext résout donc
|
||||
# dans l'ordre : (1) param d'URL "?id=" (source de vérité) ; (2) sinon
|
||||
# localStorage (préremplissage même-partition). Voir AccountContext + AuthGate.
|
||||
|
||||
@ui
|
||||
Scénario: Le param d'URL est la source de vérité quand il est présent
|
||||
Étant donné que localStorage contient l'identifiant "alice"
|
||||
Et que l'URL porte le param id "bob"
|
||||
Quand le contexte de compte résout l'identifiant
|
||||
Alors l'identifiant résolu est "bob"
|
||||
|
||||
@ui
|
||||
Scénario: Le param d'URL prime même sur une valeur localStorage différente et est persisté
|
||||
Étant donné que localStorage contient l'identifiant "alice"
|
||||
Et que l'URL porte le param id "carol"
|
||||
Quand le contexte de compte résout l'identifiant
|
||||
Alors l'identifiant résolu est "carol"
|
||||
Et localStorage contient désormais l'identifiant "carol"
|
||||
|
||||
@ui
|
||||
Scénario: Sans param d'URL, localStorage sert de repli
|
||||
Étant donné que localStorage contient l'identifiant "dave"
|
||||
Et que l'URL ne porte aucun param id
|
||||
Quand le contexte de compte résout l'identifiant
|
||||
Alors l'identifiant résolu est "dave"
|
||||
|
||||
@ui
|
||||
Scénario: Le param d'URL est normalisé (minuscules, @ retiré)
|
||||
Étant donné que localStorage ne contient aucun identifiant
|
||||
Et que l'URL porte le param id "@Erin"
|
||||
Quand le contexte de compte résout l'identifiant
|
||||
Alors l'identifiant résolu est "erin"
|
||||
@@ -4,12 +4,15 @@
|
||||
* 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).
|
||||
* login. The user also types an IDENTIFIER here — the id that names their
|
||||
* virtual space (a technical id, a pseudo in practice, not a Festipod username).
|
||||
* Clicking "Entrer" records that identifier and triggers `connect()`, which
|
||||
* redirects to the broker to open the SHARED wallet. After return the identity
|
||||
* is already set (persisted before the redirect), so NG auto-connects straight
|
||||
* into the app — there is no separate "pick a username" screen.
|
||||
*
|
||||
* ASSISTED IMPORT (see the broker-import constraint, concept nextgraph-platform
|
||||
* + decision_2026-06-17). The hosted broker can't import a wallet inline during
|
||||
* ASSISTED IMPORT (see decision_2026-06-17). The hosted broker can't import a
|
||||
* wallet inline during
|
||||
* web-app auth: a first-time device has no wallet, so the broker redirect would
|
||||
* dead-end. We therefore HAND the user the shared wallet FILE (download) + the
|
||||
* shared password and guide a one-time import on nextgraph.eu ("Import a Wallet
|
||||
@@ -19,13 +22,20 @@
|
||||
*/
|
||||
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Button, Title, Text } from '../../../shared/components/sketchy';
|
||||
import { Button, Input, Title, Text } from '../../../shared/components/sketchy';
|
||||
import { SHARED_WALLET_PASSWORD, SHARED_WALLET_FILE_URL, WALLET_IMPORT_URL, hasSharedWallet } from '../sharedWallet';
|
||||
|
||||
interface AccessGateScreenProps {
|
||||
status: 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
error?: string;
|
||||
onEnter: () => void;
|
||||
/**
|
||||
* The identifier already stored for this space (the persisted one), used to
|
||||
* PREFILL the field so a returning user never re-types it. Empty on a truly
|
||||
* first access. Normalized upstream; shown verbatim.
|
||||
*/
|
||||
initialIdentifier?: string;
|
||||
/** Enter the space: the raw identifier the user typed (normalized upstream). */
|
||||
onEnter: (identifier: string) => void;
|
||||
}
|
||||
|
||||
// One numbered step: a badge + a title + the action for that step.
|
||||
@@ -44,9 +54,15 @@ function Step({ n, title, children }: { n: number; title: string; children: Reac
|
||||
);
|
||||
}
|
||||
|
||||
export function AccessGateScreen({ status, error, onEnter }: AccessGateScreenProps) {
|
||||
export function AccessGateScreen({ status, error, initialIdentifier, onEnter }: AccessGateScreenProps) {
|
||||
const connecting = status === 'connecting';
|
||||
const [copied, setCopied] = useState(false);
|
||||
// The identifier that names this virtual space (a technical id — a pseudo in
|
||||
// practice, but not a Festipod username). Entered HERE, at wallet access, so a
|
||||
// single act both names the space and opens it. Normalized (lowercased) upstream.
|
||||
// PREFILLED from the stored identifier so a returning user (reload / broker
|
||||
// round-trip) sees the value they already chose and never re-types it.
|
||||
const [identifier, setIdentifier] = useState(initialIdentifier ?? '');
|
||||
|
||||
const copyPassword = async () => {
|
||||
try {
|
||||
@@ -58,15 +74,31 @@ export function AccessGateScreen({ status, error, onEnter }: AccessGateScreenPro
|
||||
}
|
||||
};
|
||||
|
||||
const canEnter = !connecting && identifier.trim().length > 0;
|
||||
const enter = () => { if (canEnter) onEnter(identifier); };
|
||||
|
||||
// Identifier field + Entrer: naming the space and opening it are one act.
|
||||
const entrer = (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onEnter}
|
||||
disabled={connecting}
|
||||
style={{ width: '100%', opacity: connecting ? 0.6 : 1 }}
|
||||
>
|
||||
{connecting ? 'Accès en cours…' : 'Entrer'}
|
||||
</Button>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<Input
|
||||
data-testid="identifier-input"
|
||||
placeholder="votre identifiant"
|
||||
value={identifier}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setIdentifier(e.target.value)}
|
||||
onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter') enter(); }}
|
||||
/>
|
||||
<Text style={{ margin: 0, fontSize: 12, color: '#999' }}>
|
||||
Il identifie votre espace (mis en minuscules).
|
||||
</Text>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={enter}
|
||||
disabled={!canEnter}
|
||||
style={{ width: '100%', opacity: canEnter ? 1 : 0.6 }}
|
||||
>
|
||||
{connecting ? 'Accès en cours…' : 'Entrer'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -75,7 +107,7 @@ export function AccessGateScreen({ status, error, onEnter }: AccessGateScreenPro
|
||||
<Title style={{ textAlign: 'center', fontSize: 30, marginBottom: 4 }}>Festipod</Title>
|
||||
<Text style={{ textAlign: 'center', marginBottom: 24, color: '#888' }}>Espace de test</Text>
|
||||
|
||||
{hasSharedWallet() ? (
|
||||
{hasSharedWallet() && status !== 'connected' ? (
|
||||
<>
|
||||
<Text style={{ textAlign: 'center', fontSize: 14, color: '#666', margin: '0 0 20px', lineHeight: 1.5 }}>
|
||||
Première connexion sur cet appareil ?<br />Chargez le portefeuille partagé, une seule fois.
|
||||
@@ -114,7 +146,7 @@ export function AccessGateScreen({ status, error, onEnter }: AccessGateScreenPro
|
||||
</div>
|
||||
</Step>
|
||||
|
||||
<Step n={3} title="Revenez sur cet onglet et entrez">
|
||||
<Step n={3} title="Revenez ici, choisissez un identifiant et entrez">
|
||||
{entrer}
|
||||
</Step>
|
||||
</>
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* 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,10 +1,8 @@
|
||||
/**
|
||||
* 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.
|
||||
* STOPGAP: the hosted broker can't auto-import a wallet, so Festipod HANDS the
|
||||
* user the shared wallet and guides a one-time import on nextgraph.eu.
|
||||
*
|
||||
* The correct primitive is the **wallet FILE** (.ngw), NOT a TextCode: a
|
||||
* TextCode is a transient device-to-device transfer (5 min, source device
|
||||
|
||||
@@ -2,31 +2,21 @@ import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
// Seed data matching what bootstrapWallet uses
|
||||
import { seedEvents, seedUsers } from '../../../../shared/data/seedData';
|
||||
|
||||
// --- Setup ---
|
||||
|
||||
Given('le portefeuille est vide', async function (this: FestipodWorld) {
|
||||
// Verify starting state: the harness graph should have its own seeded data.
|
||||
// We clear events/users/participations to simulate a truly empty wallet.
|
||||
await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
// Delete all events
|
||||
for (const e of [...td.events]) td.events.delete(e);
|
||||
// Delete all users
|
||||
for (const u of [...td.users]) td.users.delete(u);
|
||||
// Delete all participations
|
||||
for (const p of [...td.participations]) td.participations.delete(p);
|
||||
});
|
||||
|
||||
// Verify empty
|
||||
// Each @data scenario runs under a UNIQUE username (see hooks.ts
|
||||
// freshScenarioUsername), so the shim hands it a FRESH, EMPTY virtual wallet:
|
||||
// "le portefeuille est vide" is trivially true on entry. So this is a fast
|
||||
// INSTANT CHECK — assert the reactive read already shows nothing — NOT the old
|
||||
// `clearWallet` per-entity-doc fan-out (a full physical-wallet enumeration that
|
||||
// was itself slow). No mutation, no polling: a fresh wallet has no docs to scan.
|
||||
const counts = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return { events: td.events.size, users: td.users.size, participations: td.participations.size };
|
||||
return { events: td.events.size, users: td.users.size };
|
||||
});
|
||||
expect(counts.events, 'Events should be empty').to.equal(0);
|
||||
expect(counts.users, 'Users should be empty').to.equal(0);
|
||||
expect(counts.events, 'Fresh virtual wallet should have no events').to.equal(0);
|
||||
expect(counts.users, 'Fresh virtual wallet should have no users').to.equal(0);
|
||||
});
|
||||
|
||||
Given('le portefeuille contient déjà des événements', async function (this: FestipodWorld) {
|
||||
@@ -43,7 +33,7 @@ Given('le portefeuille contient déjà des événements', async function (this:
|
||||
// Wait for data to propagate
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => (window as any).__testData.events.size > 0,
|
||||
{ timeout: 10000 },
|
||||
{ timeout: 75000 },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -58,22 +48,36 @@ When('je charge les données de test', async function (this: FestipodWorld) {
|
||||
});
|
||||
(this as any)._eventCountBefore = countBefore;
|
||||
|
||||
await this.appFrame!.evaluate(() => {
|
||||
// AWAIT the seed's own promise (loadTestData returns a BootstrapResult promise)
|
||||
// and record whether it actually seeded — so the propagation wait below can tell
|
||||
// a genuinely-populated wallet (nothing to appear) from an empty one that must
|
||||
// seed. Fire-and-forget here would let the assertions race the async seed.
|
||||
const seededResult = await this.appFrame!.evaluate(async () => {
|
||||
const td = (window as any).__testData;
|
||||
td.loadTestData();
|
||||
const r = await td.loadTestData();
|
||||
return { seeded: r?.seeded ?? false };
|
||||
});
|
||||
(this as any)._loadSeeded = seededResult.seeded;
|
||||
|
||||
// Wait for data to propagate (if wallet was empty, data should appear)
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => {
|
||||
const td = (window as any).__testData;
|
||||
// Either data was already there, or it should appear after loading
|
||||
return td.events.size > 0 || td._loadResult?.seeded === false;
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
).catch(() => {
|
||||
// Timeout is OK if wallet was already populated (idempotent case)
|
||||
});
|
||||
// Wait for data to propagate. Events reach the read via the discovery index (a
|
||||
// fast, independent path); the seeded PROTECTED user docs reach it only through
|
||||
// the by-need re-list, which can lag the public read under load. So wait for BOTH
|
||||
// events AND users to settle (not just events) — otherwise `contient des
|
||||
// utilisateurs` asserts before the protected read lands and flakes to users:0.
|
||||
// On a wallet that already had data (seeded === false) there is nothing to wait
|
||||
// for. The assertions still verify the real counts; this only synchronizes.
|
||||
if (seededResult.seeded) {
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => {
|
||||
const td = (window as any).__testData;
|
||||
return td.events.size > 0 && td.users.size > 0;
|
||||
},
|
||||
{ timeout: 75000 },
|
||||
).catch(() => {
|
||||
// Timeout tolerated — the assertions below surface the real failure with a
|
||||
// clearer message than a raw waitForFunction timeout.
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// --- Assertions ---
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @ui steps for the access barrier (AccessGateScreen).
|
||||
*
|
||||
* These render the prop-driven AccessGateScreen directly (via renderElement) —
|
||||
* it is NOT a registry/route screen, its state comes from props (status,
|
||||
* initialIdentifier, onEnter). We assert on the rendered DOM: the identifier
|
||||
* field is PREFILLED from the stored value, and "Entrer" reports the identifier.
|
||||
*
|
||||
* Guards the reported regression: on return the barrier used to re-ask for a
|
||||
* bare, empty identifier despite one being stored. See AuthGate.tsx.
|
||||
*/
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import React from 'react';
|
||||
import { renderElement } from '../../../../shared/test-harness/renderHelper';
|
||||
import { AccessGateScreen } from '../../screens/AccessGateScreen';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
// Local per-scenario state (kept off the World to avoid touching its type).
|
||||
interface GateState {
|
||||
doc: Document | null;
|
||||
entered: string | null;
|
||||
}
|
||||
const gateStates = new WeakMap<object, GateState>();
|
||||
function stateFor(world: object): GateState {
|
||||
let s = gateStates.get(world);
|
||||
if (!s) {
|
||||
s = { doc: null, entered: null };
|
||||
gateStates.set(world, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
async function renderGate(world: object, initialIdentifier?: string): Promise<void> {
|
||||
const s = stateFor(world);
|
||||
s.entered = null;
|
||||
// 'connecting' would disable the button; 'disconnected' is the returning-user
|
||||
// state (session not yet restored) — the exact case that re-prompted before.
|
||||
s.doc = await renderElement(
|
||||
React.createElement(AccessGateScreen, {
|
||||
status: 'disconnected',
|
||||
initialIdentifier,
|
||||
onEnter: (id: string) => {
|
||||
s.entered = id;
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Given(
|
||||
'la barrière d\'accès s\'affiche avec l\'identifiant stocké {string}',
|
||||
async function (this: FestipodWorld, identifier: string) {
|
||||
await renderGate(this, identifier);
|
||||
},
|
||||
);
|
||||
|
||||
Given(
|
||||
'la barrière d\'accès s\'affiche sans identifiant stocké',
|
||||
async function (this: FestipodWorld) {
|
||||
await renderGate(this, '');
|
||||
},
|
||||
);
|
||||
|
||||
function identifierField(world: object): HTMLInputElement {
|
||||
const s = stateFor(world);
|
||||
expect(s.doc, 'The access barrier should be rendered').to.not.be.null;
|
||||
const input = s.doc!.querySelector('[data-testid="identifier-input"]') as HTMLInputElement | null;
|
||||
expect(input, 'The identifier field should be present').to.not.be.null;
|
||||
return input!;
|
||||
}
|
||||
|
||||
Then(
|
||||
'le champ identifiant contient {string}',
|
||||
function (this: FestipodWorld, expected: string) {
|
||||
expect(identifierField(this).value).to.equal(expected);
|
||||
},
|
||||
);
|
||||
|
||||
Then('le champ identifiant est vide', function (this: FestipodWorld) {
|
||||
expect(identifierField(this).value).to.equal('');
|
||||
});
|
||||
|
||||
When('je clique sur {string} dans la barrière', function (this: FestipodWorld, _label: string) {
|
||||
const s = stateFor(this);
|
||||
const input = identifierField(this);
|
||||
// Submit via Enter on the field (canEnter is satisfied by the prefilled value).
|
||||
const KeyboardEventCtor = (globalThis as { KeyboardEvent?: typeof KeyboardEvent }).KeyboardEvent;
|
||||
const evt = KeyboardEventCtor
|
||||
? new KeyboardEventCtor('keydown', { key: 'Enter', bubbles: true })
|
||||
: Object.assign(new (globalThis as { Event: typeof Event }).Event('keydown', { bubbles: true }), { key: 'Enter' });
|
||||
input.dispatchEvent(evt);
|
||||
expect(s.doc, 'The access barrier should be rendered').to.not.be.null;
|
||||
});
|
||||
|
||||
Then(
|
||||
'l\'identifiant remonté à l\'application est {string}',
|
||||
function (this: FestipodWorld, expected: string) {
|
||||
const s = stateFor(this);
|
||||
expect(s.entered, 'onEnter should have been called with the identifier').to.equal(expected);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @ui steps for AccountContext identifier resolution.
|
||||
*
|
||||
* Guards the cross-frontier fix: the shared-wallet flow runs the app in TWO
|
||||
* localStorage partitions (top-level 127.0.0.1 vs broker iframe nextgraph.net),
|
||||
* so localStorage does NOT cross. The `?id=` URL param — embedded in the broker
|
||||
* redirect `o=` — DOES cross. AccountContext resolution therefore PRIORITIZES the
|
||||
* URL param over localStorage, and (when present) persists it to localStorage for
|
||||
* same-partition convenience. Normalization (trim, `@`-strip, lowercase) applies.
|
||||
*
|
||||
* These render a tiny probe inside a real AccountProvider (via renderElement),
|
||||
* having first seeded window.location.search and window.localStorage through the
|
||||
* happy-dom harness — so the resolution logic runs for real, not mocked.
|
||||
*/
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import React from 'react';
|
||||
import {
|
||||
renderElement,
|
||||
setRenderUrl,
|
||||
setRenderLocalStorage,
|
||||
getRenderLocalStorage,
|
||||
} from '../../../../shared/test-harness/renderHelper';
|
||||
import { AccountProvider, useAccount } from '../../../../shared/context/AccountContext';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
const STORAGE_KEY = 'festipod.account.identifier';
|
||||
|
||||
// Per-scenario intent (kept off the World type via a WeakMap).
|
||||
interface ResolveState {
|
||||
storageSeed: string | null;
|
||||
url: string;
|
||||
doc: Document | null;
|
||||
}
|
||||
const states = new WeakMap<object, ResolveState>();
|
||||
function stateFor(world: object): ResolveState {
|
||||
let s = states.get(world);
|
||||
if (!s) {
|
||||
s = { storageSeed: null, url: 'http://localhost/', doc: null };
|
||||
states.set(world, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Probe: renders the resolved identifier so the DOM can be asserted.
|
||||
function IdentifierProbe(): React.ReactElement {
|
||||
const { identifier } = useAccount();
|
||||
return React.createElement('div', { 'data-testid': 'resolved-identifier' }, identifier ?? '');
|
||||
}
|
||||
|
||||
Given(
|
||||
'localStorage contient l\'identifiant {string}',
|
||||
function (this: FestipodWorld, value: string) {
|
||||
stateFor(this).storageSeed = value;
|
||||
},
|
||||
);
|
||||
|
||||
Given('localStorage ne contient aucun identifiant', function (this: FestipodWorld) {
|
||||
stateFor(this).storageSeed = null;
|
||||
});
|
||||
|
||||
Given('l\'URL porte le param id {string}', function (this: FestipodWorld, id: string) {
|
||||
const s = stateFor(this);
|
||||
const url = new URL('http://localhost/');
|
||||
url.searchParams.set('id', id);
|
||||
s.url = url.toString();
|
||||
});
|
||||
|
||||
Given('l\'URL ne porte aucun param id', function (this: FestipodWorld) {
|
||||
stateFor(this).url = 'http://localhost/';
|
||||
});
|
||||
|
||||
When('le contexte de compte résout l\'identifiant', async function (this: FestipodWorld) {
|
||||
const s = stateFor(this);
|
||||
// Seed the happy-dom window (URL + localStorage) BEFORE mounting the provider,
|
||||
// so the provider's init-time resolution reads exactly this state.
|
||||
await setRenderUrl(s.url);
|
||||
await setRenderLocalStorage(STORAGE_KEY, s.storageSeed);
|
||||
s.doc = await renderElement(
|
||||
React.createElement(AccountProvider, null, React.createElement(IdentifierProbe)),
|
||||
);
|
||||
});
|
||||
|
||||
function resolved(world: object): string {
|
||||
const s = stateFor(world);
|
||||
expect(s.doc, 'The probe should be rendered').to.not.be.null;
|
||||
const el = s.doc!.querySelector('[data-testid="resolved-identifier"]');
|
||||
expect(el, 'The resolved-identifier probe should be present').to.not.be.null;
|
||||
return el!.textContent ?? '';
|
||||
}
|
||||
|
||||
Then('l\'identifiant résolu est {string}', function (this: FestipodWorld, expected: string) {
|
||||
expect(resolved(this)).to.equal(expected);
|
||||
});
|
||||
|
||||
Then(
|
||||
'localStorage contient désormais l\'identifiant {string}',
|
||||
async function (this: FestipodWorld, expected: string) {
|
||||
// The URL-param → localStorage persistence runs in a mount useEffect, which
|
||||
// React flushes AFTER the render's first microtask. Yield a few macrotask
|
||||
// ticks (bounded, no polling of any live resource) so the effect has run
|
||||
// before asserting — otherwise the read races the effect and flakes.
|
||||
let stored: string | null = null;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
stored = await getRenderLocalStorage(STORAGE_KEY);
|
||||
if (stored === expected) break;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
expect(stored).to.equal(expected);
|
||||
},
|
||||
);
|
||||
@@ -47,20 +47,24 @@ Fonctionnalité: Cycle de vie d'un événement
|
||||
Et l'utilisateur clique sur le bouton "J'y serai" si visible
|
||||
Alors l'écran contient le texte "Je participe"
|
||||
|
||||
# ngSet.delete() updates UI but doesn't persist — NG ORM limitation.
|
||||
@e2e @wip
|
||||
# Auto-suffisant : on s'inscrit d'abord (précondition), puis on se désinscrit,
|
||||
# le tout dans la même session — ne dépend ni de l'état inter-scénarios ni de
|
||||
# la persistance après reconnexion.
|
||||
# FIX T02.c (2026-07-03) : le @wip est LEVÉ. La désinscription se reflète
|
||||
# désormais dans l'UI et est DURABLE — leaveEvent supprime la Participation
|
||||
# côté données via SPARQL DELETE-WHERE (docs.sparqlUpdate, le ng injecté), donc
|
||||
# l'item ne ressuscite plus via la sync broker (l'ancien bug CRDT décrit dans
|
||||
# caveat_participation-deletion). Prouvé : ce scénario @e2e passe + le scénario
|
||||
# @data « désinscription persistante » de inscription-inbox.feature. (La
|
||||
# validation multi-navigateur complète reste T02.f.)
|
||||
@e2e
|
||||
Scénario: Se désinscrire d'un événement
|
||||
Quand l'utilisateur navigue vers l'écran "events"
|
||||
Et l'utilisateur clique sur un événement de la liste
|
||||
Et l'utilisateur attend que l'écran "event-detail" soit affiché
|
||||
Et l'utilisateur clique sur le bouton "Je participe"
|
||||
Alors l'écran contient le texte "J'y serai"
|
||||
|
||||
@e2e @wip
|
||||
Scénario: La désinscription persiste après reconnexion
|
||||
Quand l'utilisateur navigue vers l'écran "events"
|
||||
Et l'utilisateur clique sur un événement de la liste
|
||||
Et l'utilisateur attend que l'écran "event-detail" soit affiché
|
||||
Et l'utilisateur clique sur le bouton "J'y serai" si visible
|
||||
Alors l'écran contient le texte "Je participe"
|
||||
Quand l'utilisateur clique sur le bouton "Je participe"
|
||||
Alors l'écran contient le texte "J'y serai"
|
||||
|
||||
# --- Modification ---
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# language: fr
|
||||
@EVENT @priority-1
|
||||
Fonctionnalité: Découverte publique via l'index global
|
||||
En tant qu'utilisateur
|
||||
Je veux découvrir les événements publics des autres comptes sans être connecté
|
||||
à eux, afin de trouver des points de rencontre à rejoindre au-delà de mon
|
||||
propre réseau.
|
||||
|
||||
# Découverte = lire l'INDEX GLOBAL. Publier un événement public, c'est soumettre
|
||||
# sa référence à l'index (submitEventToIndex) ; découvrir, c'est lire l'index
|
||||
# (readDiscoveredEvents) puis s'abonner aux documents référencés. L'app ne fait
|
||||
# AUCUN fan-out cross-comptes et ne connaît ni l'hôte de l'index ni sa
|
||||
# mécanique — le SDK possède l'index de bout en bout.
|
||||
|
||||
@data
|
||||
Scénario: Un compte découvre l'événement public d'un autre compte non connecté
|
||||
Étant donné le compte "@bob-public" publie un événement public "Concert au parc"
|
||||
Et le compte "@alice-public" n'est pas connecté à "@bob-public"
|
||||
Quand "@alice-public" découvre les événements publics
|
||||
Alors "@alice-public" voit l'événement public "Concert au parc"
|
||||
Et l'index global liste le document de l'événement
|
||||
@@ -0,0 +1,109 @@
|
||||
# language: fr
|
||||
@EVENT @priority-1 @multibrowser @shared-wallet
|
||||
Fonctionnalité: Validation e2e multi-navigateurs des nouvelles features (T02.f)
|
||||
En tant que développeur validant les fonctionnalités T02
|
||||
Je pilote DEUX navigateurs isolés (contextes Playwright distincts, sessions
|
||||
NextGraph indépendantes) portant le wallet partagé, chacun chargé dans l'app
|
||||
via le broker réel (nextgraph.net), afin de prouver que les comportements
|
||||
démontrables round-trippent réellement à travers le broker d'un navigateur à
|
||||
l'autre — pas seulement dans le set réactif d'une seule page.
|
||||
|
||||
# NB périmètre : l'isolation cryptographique réelle (ReadCap/write-guard actifs,
|
||||
# mono-document + setCurrentUser) est DIFFÉRÉE (mécanisme validé mais dormant,
|
||||
# cf. T02.d/T02.g). Ces scénarios ne prouvent donc PAS l'isolation crypto entre
|
||||
# navigateurs ; ils valident les COMPORTEMENTS démontrables cross-navigateur.
|
||||
|
||||
# --- Inscription + notification hôte (T02.c) ---
|
||||
# Navigateur A = l'hôte (crée l'événement/PdR) ; navigateur B = un second
|
||||
# compte qui s'inscrit. A reçoit la notification, matérialisée depuis l'inbox.
|
||||
|
||||
Scénario: Inscription dans un navigateur, notification hôte dans l'autre
|
||||
Étant donné un navigateur "A" avec le wallet partagé
|
||||
Et un navigateur "B" avec le wallet partagé
|
||||
Et le navigateur "A" charge l'application via le broker
|
||||
Et le navigateur "B" charge l'application via le broker
|
||||
Et le navigateur "A" est connecté à NextGraph
|
||||
Et le navigateur "B" est connecté à NextGraph
|
||||
Quand le navigateur "A" crée l'événement "Rencontre au kiosque"
|
||||
Et le navigateur "B" s'inscrit à l'événement "Rencontre au kiosque"
|
||||
Alors le navigateur "B" devient participant de l'événement "Rencontre au kiosque"
|
||||
Et l'inbox de l'événement "Rencontre au kiosque" reçoit un dépôt "new-participant"
|
||||
|
||||
# --- Désinscription persistante (T02.c, fix @wip) ---
|
||||
# B se désinscrit → la participation disparaît côté broker (SELECT autoritatif)
|
||||
# ET ne ressuscite pas après une re-sync du broker.
|
||||
|
||||
Scénario: La désinscription dans un navigateur ne ressuscite pas après re-sync
|
||||
Étant donné un navigateur "A" avec le wallet partagé
|
||||
Et un navigateur "B" avec le wallet partagé
|
||||
Et le navigateur "A" charge l'application via le broker
|
||||
Et le navigateur "B" charge l'application via le broker
|
||||
Et le navigateur "A" est connecté à NextGraph
|
||||
Et le navigateur "B" est connecté à NextGraph
|
||||
Et le navigateur "A" crée l'événement "Café des curieux"
|
||||
Et le navigateur "B" s'inscrit à l'événement "Café des curieux"
|
||||
Et le navigateur "B" devient participant de l'événement "Café des curieux"
|
||||
Quand le navigateur "B" se désinscrit de l'événement "Café des curieux"
|
||||
Alors le broker ne contient plus aucune participation à l'événement "Café des curieux" pour le navigateur "B"
|
||||
Et l'inscription de l'événement "Café des curieux" ne ressuscite pas dans le navigateur "B" après re-sync
|
||||
|
||||
# --- Découverte publique cross-comptes (T02.e) ---
|
||||
# Bob (navigateur B) publie un événement PUBLIC ; Alice (navigateur A) le
|
||||
# découvre SANS être connectée/amie avec Bob, via le fan-out public.
|
||||
|
||||
# --- Compteur fiable à la connexion du propriétaire (Option B, brief §D.2) ---
|
||||
# Cadrage (recadré 2026-07-13) : ce n'est PAS une exigence de push temps-réel
|
||||
# cross-session. Le compteur est STOCKÉ dans l'événement, SEUL le propriétaire
|
||||
# l'écrit ; l'inscrit DÉPOSE dans l'inbox de l'événement ; le propriétaire traite
|
||||
# ces dépôts à sa PROCHAINE CONNEXION (matérialisation fiable, barrière de sync de
|
||||
# l'inbox). Une reconnexion / re-matérialisation de A est donc ACCEPTÉE — on ne
|
||||
# vise pas « sans reload ».
|
||||
#
|
||||
# FIX (appliqué) : la matérialisation du propriétaire lit l'inbox APRÈS sa barrière
|
||||
# de sync (`inbox.readSynced` → `ensureRepoOpen` + `read`, lib
|
||||
# `@ng-eventually/client`), de sorte qu'un dépôt de l'inscrit déjà synchronisé au
|
||||
# broker EST vu (plus de « 0 prématuré » mémoïsé) ; le materializer est lancé
|
||||
# directement à la connexion ([ready, ownedKey]), pas seulement sur un push
|
||||
# d'inbox. Source unique du NOMBRE : `event.participantCount` (le littéral local
|
||||
# `participantCount: 1` du CreateEventScreen est retiré → départ à 0).
|
||||
Scénario: Le compteur converge chez le propriétaire à sa prochaine connexion
|
||||
Étant donné un navigateur "A" avec le wallet partagé
|
||||
Et un navigateur "B" avec le wallet partagé
|
||||
Et le navigateur "A" charge l'application via le broker
|
||||
Et le navigateur "B" charge l'application via le broker
|
||||
Et le navigateur "A" est connecté à NextGraph
|
||||
Et le navigateur "B" est connecté à NextGraph
|
||||
Et le navigateur "A" crée l'événement "Apéro réactif"
|
||||
Et le navigateur "A" ouvre le détail de l'événement "Apéro réactif"
|
||||
# Le créateur ne participe PAS automatiquement (pas de notion d'hôte) : à la
|
||||
# création le compteur démarre à 0 (|inscriptions actives| = 0).
|
||||
Et le compteur de participants réactif dans "A" pour "Apéro réactif" vaut 0
|
||||
Quand le navigateur "B" s'inscrit à l'événement "Apéro réactif"
|
||||
# À sa PROCHAINE CONNEXION, A traite l'inbox (barrière de sync) et matérialise le
|
||||
# dépôt de B sur son propre doc → participantCount passe à 1. Symétrie avec B :
|
||||
# après reconnexion, B lit le doc public mis à jour par A et voit 1 aussi.
|
||||
Et le navigateur "A" se reconnecte et matérialise l'inbox
|
||||
Alors le compteur de participants réactif dans "A" pour "Apéro réactif" vaut 1
|
||||
Et le navigateur "A" affiche un participant "inconnu" pour "Apéro réactif"
|
||||
Et le navigateur "B" se reconnecte et matérialise l'inbox
|
||||
Et le compteur de participants réactif dans "B" pour "Apéro réactif" vaut 1
|
||||
# Option B symétrique : B se désinscrit → à la prochaine connexion de A, A
|
||||
# matérialise le marqueur "leave" depuis l'inbox et RECALCULE participantCount
|
||||
# sur son propre doc → le compteur repasse à 0.
|
||||
Quand le navigateur "B" se désinscrit de l'événement "Apéro réactif"
|
||||
Et le navigateur "A" se reconnecte et matérialise l'inbox
|
||||
Alors le compteur de participants réactif dans "A" pour "Apéro réactif" vaut 0
|
||||
# Symétrie côté inscrit après désinscription : B voit aussi 0 après reconnexion.
|
||||
Et le navigateur "B" se reconnecte et matérialise l'inbox
|
||||
Et le compteur de participants réactif dans "B" pour "Apéro réactif" vaut 0
|
||||
|
||||
Scénario: Un navigateur découvre l'événement public publié dans l'autre
|
||||
Étant donné un navigateur "A" avec le wallet partagé
|
||||
Et un navigateur "B" avec le wallet partagé
|
||||
Et le navigateur "A" charge l'application via le broker
|
||||
Et le navigateur "B" charge l'application via le broker
|
||||
Et le navigateur "A" est connecté à NextGraph
|
||||
Et le navigateur "B" est connecté à NextGraph
|
||||
Quand le compte "@bob-mb" publie un événement public "Concert au kiosque" dans le navigateur "B"
|
||||
Et le compte "@alice-mb" découvre les événements publics dans le navigateur "A" sans être connecté à "@bob-mb"
|
||||
Alors le navigateur "A" voit l'événement public "Concert au kiosque"
|
||||
@@ -0,0 +1,27 @@
|
||||
# language: fr
|
||||
@EVENT @priority-1
|
||||
Fonctionnalité: Inscription réelle au point de rencontre via inbox (T02.c)
|
||||
En tant qu'utilisateur qui s'inscrit à un point de rencontre
|
||||
Mon inscription est persistée, l'hôte reçoit une notification par son inbox,
|
||||
Et ma désinscription est durable (elle ne ressuscite pas via la sync broker)
|
||||
|
||||
Contexte:
|
||||
Étant donné que je suis connecté en tant qu'utilisateur
|
||||
|
||||
@data
|
||||
Scénario: S'inscrire dépose dans l'inbox de l'hôte et crée une notification
|
||||
Étant donné un événement "Formation CNV" existe
|
||||
Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV" via l'app
|
||||
Quand l'utilisateur s'inscrit à l'événement "Formation CNV" via l'app
|
||||
Alors l'utilisateur devient participant de l'événement "Formation CNV"
|
||||
Et l'inbox de l'événement "Formation CNV" contient au moins un dépôt
|
||||
Et une notification "new-participant" est créée pour l'événement "Formation CNV"
|
||||
|
||||
@data
|
||||
Scénario: La désinscription est persistante (ne ressuscite pas)
|
||||
Étant donné un événement "Résidence Reconnexion" existe
|
||||
Et l'utilisateur est inscrit à l'événement "Résidence Reconnexion" via l'app
|
||||
Quand l'utilisateur se désinscrit de l'événement "Résidence Reconnexion" via l'app
|
||||
Alors l'utilisateur n'est plus participant de l'événement "Résidence Reconnexion"
|
||||
Et le broker ne contient plus aucune participation à l'événement "Résidence Reconnexion"
|
||||
Et l'utilisateur reste non-inscrit à l'événement "Résidence Reconnexion" après re-sync
|
||||
@@ -0,0 +1,26 @@
|
||||
# language: fr
|
||||
@EVENT @priority-1 @data
|
||||
Fonctionnalité: Isolation entre deux identités sur le wallet partagé
|
||||
En tant qu'utilisateur qui nomme son espace virtuel par un identifiant à la
|
||||
barrière d'accès, sur le MÊME wallet physique partagé,
|
||||
Je ne dois voir NI l'inscription NI l'accueil d'une autre identité
|
||||
Afin que les participations restent privées à leur propriétaire.
|
||||
|
||||
# Régression du leak d'isolation : une identité A crée un événement et le
|
||||
# rejoint ; une identité fraîche B arrive sur le même wallet (faux-logout +
|
||||
# re-login sous un autre identifiant, sans reload — le stopgap wallet-partagé).
|
||||
# B ne doit PAS voir la participation de A : ni sur son accueil
|
||||
# (getUserEvents(B)), ni via isParticipating(E, B), ni dans son set de
|
||||
# participations réactif. Le mécanisme : le changement d'identifiant est traité
|
||||
# comme une session fraîche (reset du jeu de lecture + caps + registre), sinon
|
||||
# les docs PROTECTED de A survivent dans le jeu de lecture de B et fuient par la
|
||||
# lecture union. Voir data-layer/knowledge_context-internals § « Changement
|
||||
# d'identité = session fraîche ».
|
||||
|
||||
@data
|
||||
Scénario: Une identité fraîche ne voit pas la participation d'une autre
|
||||
Étant donné que l'identité A crée l'événement "Événement privé de A" et s'y inscrit
|
||||
Quand une identité fraîche B arrive sur le même wallet partagé
|
||||
Alors l'événement "Événement privé de A" n'est pas sur l'accueil de B
|
||||
Et B n'est pas participant de l'événement "Événement privé de A"
|
||||
Et B ne lit aucune participation de A
|
||||
@@ -0,0 +1,36 @@
|
||||
# language: fr
|
||||
@EVENT @priority-1 @data
|
||||
Fonctionnalité: Reconnexion d'une même identité sur le wallet persistant
|
||||
En tant qu'utilisateur qui, sur le MÊME wallet physique, ouvre une PAGE FRAÎCHE
|
||||
(nouveau login broker, session fraîche) sous le MÊME identifiant qu'avant,
|
||||
Je dois relire MES PROPRES données (mon événement, ma participation)
|
||||
Afin que rien ne disparaisse à la reconnexion.
|
||||
|
||||
# Régression de RECONNEXION (distincte de l'isolation deux-identités). CORRIGÉ :
|
||||
# ce scénario passe désormais sous broker réel (2/2 vérifié).
|
||||
#
|
||||
# SYMPTÔME (broker réel) : une identité A crée E + s'y inscrit sur la page
|
||||
# principale ; une PAGE FRAÎCHE pour la MÊME identité A (même wallet, nouveau
|
||||
# login → session verifier fraîche) relisait VIDE (home=[], isParticipating=false,
|
||||
# count=0). La page fraîche retrouve maintenant E, sa participation, et un count
|
||||
# autoritatif de 1.
|
||||
#
|
||||
# CAUSE : à froid, plusieurs lecteurs (watchShape public/protected + effet owned-
|
||||
# events) appelaient `ensureAccount(A)` en parallèle AVANT sync du shim → chacun
|
||||
# lisait 0 ligne et PROVISIONNAIT un nouveau jeu de docs de scope (fork de compte
|
||||
# par lecteur). `canonicalDoc` faisait alors choisir au lecteur frais le docProtected
|
||||
# lexicographiquement-min ≠ celui où l'écrivain avait joué joinEvent → readScopeIndex
|
||||
# vide. FIX (lib) : `ensureAccount` dé-doublonne les provisions concurrentes (map
|
||||
# in-flight par clé de compte) → une seule provision, écrivain et lecteur convergent
|
||||
# sur le même docProtected canonique. Complété par le heal cold-start de
|
||||
# `discovery.readIndex` (ouverture du repo d'index avant lecture ancrée).
|
||||
# Mesuré (fresh page, broker réel) : partProt converge isSuccess n=1, pub isSuccess
|
||||
# n≥1 ; userProt reste isSuccess n=0 (aucun profil écrit dans ce scénario — attendu).
|
||||
|
||||
@data
|
||||
Scénario: Une page fraîche pour la même identité relit ses propres données
|
||||
Étant donné que l'identité A crée l'événement "Événement de reconnexion de A" et s'y inscrit
|
||||
Quand une page fraîche pour la MÊME identité A recharge sur le même wallet
|
||||
Alors l'événement "Événement de reconnexion de A" est sur l'accueil de la page fraîche A
|
||||
Et la page fraîche A est participante de l'événement "Événement de reconnexion de A"
|
||||
Et le compte autoritatif de participation de A à l'événement "Événement de reconnexion de A" est 1
|
||||
@@ -28,6 +28,22 @@ Fonctionnalité: US-13 Relayer/Modifier/Supprimer un événement
|
||||
Étant donné que je suis sur la page "relayer un événement"
|
||||
Alors je peux annuler et revenir à l'écran précédent
|
||||
|
||||
# --- Data : le créateur ne participe pas automatiquement (décision produit) ---
|
||||
#
|
||||
# Il n'y a PAS de notion d'hôte : l'événement est public, simplement signalé par
|
||||
# le créateur, qui n'est PAS obligé de participer. À la création, aucune
|
||||
# participation n'est écrite et le compteur démarre à 0. Le créateur voit « J'y
|
||||
# serai » et peut rejoindre/quitter son propre événement comme tout le monde.
|
||||
@data
|
||||
Scénario: Le créateur ne participe pas automatiquement à son événement
|
||||
Étant donné que le créateur relaie l'événement "Signalé par le créateur"
|
||||
Alors le créateur n'est pas participant de l'événement "Signalé par le créateur"
|
||||
Et le compteur de participants de l'événement "Signalé par le créateur" vaut 0
|
||||
Quand le créateur rejoint son événement "Signalé par le créateur"
|
||||
Alors le créateur est participant de l'événement "Signalé par le créateur"
|
||||
Quand le créateur quitte son événement "Signalé par le créateur"
|
||||
Alors le créateur n'est pas participant de l'événement "Signalé par le créateur"
|
||||
|
||||
Scénario: Modifier un événement
|
||||
* Scénario non implémenté
|
||||
|
||||
|
||||
@@ -24,41 +24,65 @@ Fonctionnalité: US-7 M'inscrire/me désinscrire à un événement
|
||||
Alors je peux voir la liste des événements
|
||||
|
||||
# --- Data ---
|
||||
#
|
||||
# Option B (participantCount dérivé et possédé par le propriétaire) : le compteur
|
||||
# n'est plus incrémenté par l'inscrit. L'inscrit écrit sa propre participation
|
||||
# (protected) + dépose un marqueur dans l'inbox de l'événement ; la session du
|
||||
# PROPRIÉTAIRE matérialise l'inbox et recalcule participantCount =
|
||||
# |inscriptions actives distinctes| sur SON propre doc (PAS de base « hôte » : le
|
||||
# créateur ne participe pas automatiquement), de façon RÉACTIVE et cross-session.
|
||||
# Ce que le @data mono-session prouve ici : la participation elle-même
|
||||
# (persistance, idempotence, désinscription AUTORITATIVE). La CONVERGENCE du
|
||||
# compteur dérivé (0→1 sans reload) est validée là où elle a du sens — le
|
||||
# scénario @multibrowser réactif (e2e-multibrowser.feature « Un participant apparaît
|
||||
# réactivement… »), avec un vrai propriétaire (A) et un vrai inscrit (B).
|
||||
|
||||
@data
|
||||
Scénario: S'inscrire à un événement
|
||||
Étant donné un événement "Formation CNV" existe
|
||||
Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV"
|
||||
Et l'événement "Formation CNV" a 8 participants au départ
|
||||
Quand l'utilisateur s'inscrit à l'événement "Formation CNV"
|
||||
Alors l'utilisateur est participant de l'événement "Formation CNV"
|
||||
Et l'utilisateur apparaît dans la liste des participants de l'événement "Formation CNV"
|
||||
Et l'événement "Formation CNV" compte 9 participants
|
||||
|
||||
@data
|
||||
Scénario: Se désinscrire d'un événement
|
||||
Étant donné un événement "Résidence Reconnexion" existe
|
||||
Et l'utilisateur est inscrit à l'événement "Résidence Reconnexion"
|
||||
Et l'événement "Résidence Reconnexion" a 12 participants au départ
|
||||
Quand l'utilisateur se désinscrit de l'événement "Résidence Reconnexion"
|
||||
Alors l'utilisateur n'est plus participant de l'événement "Résidence Reconnexion"
|
||||
Et l'utilisateur n'apparaît plus dans la liste des participants de l'événement "Résidence Reconnexion"
|
||||
Et l'événement "Résidence Reconnexion" compte 11 participants
|
||||
|
||||
@data
|
||||
Scénario: L'inscription est idempotente
|
||||
Étant donné un événement "Résidence Reconnexion" existe
|
||||
Et l'utilisateur est inscrit à l'événement "Résidence Reconnexion"
|
||||
Et l'événement "Résidence Reconnexion" a 12 participants au départ
|
||||
Quand l'utilisateur essaie de s'inscrire une seconde fois à l'événement "Résidence Reconnexion"
|
||||
Alors l'inscription est idempotente pour l'événement "Résidence Reconnexion"
|
||||
Et l'événement "Résidence Reconnexion" compte 12 participants
|
||||
|
||||
@data
|
||||
Scénario: Se désinscrire d'un événement auquel on n'est pas inscrit
|
||||
Étant donné un événement "Formation CNV" existe
|
||||
Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV"
|
||||
Et l'événement "Formation CNV" a 8 participants au départ
|
||||
Quand l'utilisateur se désinscrit de l'événement "Formation CNV"
|
||||
Alors l'utilisateur n'est plus participant de l'événement "Formation CNV"
|
||||
Et l'événement "Formation CNV" compte 8 participants
|
||||
|
||||
# --- Data : compteur dérivé (Option B) ---
|
||||
#
|
||||
# La matérialisation par le PROPRIÉTAIRE dérive l'ensemble actif de l'inbox de
|
||||
# l'événement. L'id d'événement est apparié sur sa forme CANONIQUE (id de repo de
|
||||
# base, en retirant tout suffixe `:v:<overlay>`) à travers ownedEventIds / la clé
|
||||
# de dépôt / le filtre du matérialiseur — donc le propriétaire matérialise les
|
||||
# dépôts d'un événement qu'il possède quelle que soit la voie d'id (create OU
|
||||
# listMyEntityDocs OU après reload). Ce scénario assère le DELTA : l'inscrit qui
|
||||
# vient de rejoindre EST dans l'ensemble actif dérivé du propriétaire (déterministe
|
||||
# même si l'inbox partagée accumule des dépôts, rendant le compteur ABSOLU
|
||||
# non-borné au scénario). La convergence 1→2 réactive absolue reste prouvée par le
|
||||
# scénario @multibrowser réactif (propriétaire A + inscrit B, e2e-multibrowser).
|
||||
# Voir data-layer/knowledge_context-internals § participantCount + le caveat id-form.
|
||||
@data
|
||||
Scénario: L'inscription fait converger le compteur dérivé du propriétaire
|
||||
Étant donné un événement "Formation CNV" existe
|
||||
Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV"
|
||||
Quand l'utilisateur s'inscrit à l'événement "Formation CNV"
|
||||
Alors le compteur dérivé de l'événement "Formation CNV" reflète l'inscription
|
||||
|
||||
@@ -88,7 +88,12 @@ export function CreateEventScreen() {
|
||||
endTime,
|
||||
location: location || 'Lieu à définir',
|
||||
description,
|
||||
participantCount: 1,
|
||||
// Option B: the creator does NOT auto-participate (no host notion). The count
|
||||
// starts at 0 and is DERIVED by the owner-materializer from real inbox
|
||||
// deposits (|active registrations|) — never a local literal. Passing 1 here was
|
||||
// the "local number" bug: the creator's own view showed 1 while the derived
|
||||
// truth (and every other viewer) was 0.
|
||||
participantCount: 0,
|
||||
themes: ['Social'],
|
||||
hostName: 'Moi',
|
||||
hostInitials: 'MD',
|
||||
|
||||
@@ -12,7 +12,12 @@ export function ParticipantsListScreen() {
|
||||
const event = eventId ? getEvent(eventId) : undefined;
|
||||
const participants = eventId ? getEventParticipants(eventId) : [];
|
||||
|
||||
const totalCount = event?.participantCount ?? participants.length;
|
||||
// The displayed NUMBER of participants comes ONLY from `event.participantCount`
|
||||
// (the owner-derived, single source of truth — Option B), never from a local
|
||||
// `participants.length` count. `participants` (named, cap-readable) only ever
|
||||
// REDUCES the "unknown" placeholder count; it never sets the total. When there is
|
||||
// no event at all, there is nothing to count → 0.
|
||||
const totalCount = event ? event.participantCount : 0;
|
||||
const unknownCount = Math.max(0, totalCount - participants.length);
|
||||
|
||||
const rows = [
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
// Creator does NOT auto-participate (@data). After createEvent, the creator's
|
||||
// isParticipating(E) === false and participantCount === 0; the creator can then
|
||||
// join (→ true) and leave (→ false) their own event like anyone else.
|
||||
|
||||
Given('le créateur relaie l\'événement {string}', { timeout: 180000 }, async function (this: FestipodWorld, title: string) {
|
||||
const out = await this.appFrame!.evaluate(async (title) => {
|
||||
const td = (window as any).__testData;
|
||||
await td.ensureCurrentUser();
|
||||
const created = await td.createEventReal(title);
|
||||
return { eventId: created.id };
|
||||
}, title);
|
||||
(this as any).creatorEventId = out.eventId;
|
||||
(this as any).creatorEventTitle = title;
|
||||
});
|
||||
|
||||
Then('le créateur n\'est pas participant de l\'événement {string}', { timeout: 60000 }, async function (this: FestipodWorld, title: string) {
|
||||
const eventId = (this as any).creatorEventId;
|
||||
// Wait for the REACTIVE state to settle to "not participating" (observes AD().*
|
||||
// fed by the subscription push — NOT a broker re-read), the sign the sync has
|
||||
// passed. Then do ONE authoritative broker read to prove the 0 is durable.
|
||||
await this.appFrame!.waitForFunction(
|
||||
(eventId) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = td.currentUserId;
|
||||
return !!uid && !td.isParticipating(eventId, uid);
|
||||
},
|
||||
eventId,
|
||||
{ timeout: 30000 },
|
||||
).catch(() => { /* fall through to the single authoritative read */ });
|
||||
const n = await this.appFrame!.evaluate(async ({ eventId }: { eventId: string }) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = await td.ensureCurrentUser();
|
||||
return td.authParticipationCount(eventId, uid);
|
||||
}, { eventId });
|
||||
expect(n, `creator must NOT be participating in "${title}" (broker count)`).to.equal(0);
|
||||
});
|
||||
|
||||
Then('le compteur de participants de l\'événement {string} vaut {int}', { timeout: 60000 }, async function (this: FestipodWorld, title: string, expected: number) {
|
||||
const eventId = (this as any).creatorEventId;
|
||||
// The count is DERIVED by the owner materializing the inbox. On a freshly
|
||||
// created event with no joins, no deposit exists → the reactive count reflects
|
||||
// the create-time value (0). Read the reactive event state.
|
||||
const count = await this.appFrame!.evaluate((eventId: string) => {
|
||||
const td = (window as any).__testData;
|
||||
const ev = td.getEvent(eventId);
|
||||
return ev ? (ev.participantCount ?? -1) : -2;
|
||||
}, eventId);
|
||||
expect(count, `participantCount of "${title}" must be ${expected}`).to.equal(expected);
|
||||
});
|
||||
|
||||
When('le créateur rejoint son événement {string}', { timeout: 120000 }, async function (this: FestipodWorld, _title: string) {
|
||||
const eventId = (this as any).creatorEventId;
|
||||
await this.appFrame!.evaluate(async ({ eventId }: { eventId: string }) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = await td.ensureCurrentUser();
|
||||
await td.appJoinEvent(eventId, uid);
|
||||
}, { eventId });
|
||||
});
|
||||
|
||||
When('le créateur quitte son événement {string}', { timeout: 120000 }, async function (this: FestipodWorld, _title: string) {
|
||||
const eventId = (this as any).creatorEventId;
|
||||
await this.appFrame!.evaluate(async ({ eventId }: { eventId: string }) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = await td.ensureCurrentUser();
|
||||
await td.appLeaveEvent(eventId, uid);
|
||||
}, { eventId });
|
||||
});
|
||||
|
||||
Then('le créateur est participant de l\'événement {string}', { timeout: 60000 }, async function (this: FestipodWorld, title: string) {
|
||||
const eventId = (this as any).creatorEventId;
|
||||
// Wait for the REACTIVE join to settle (observes AD().isParticipating fed by the
|
||||
// subscription push — NOT a broker re-read), then take ONE authoritative broker
|
||||
// count to prove the participation is durable at the data level.
|
||||
await this.appFrame!.waitForFunction(
|
||||
(eventId) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = td.currentUserId;
|
||||
return !!uid && td.isParticipating(eventId, uid);
|
||||
},
|
||||
eventId,
|
||||
{ timeout: 45000 },
|
||||
).catch(() => { /* fall through to the single authoritative read */ });
|
||||
const n = await this.appFrame!.evaluate(async ({ eventId }: { eventId: string }) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = await td.ensureCurrentUser();
|
||||
return td.authParticipationCount(eventId, uid);
|
||||
}, { eventId });
|
||||
expect(n, `creator must be participating in "${title}" after joining`).to.equal(1);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
// Data-layer proof of PUBLIC discovery via the GLOBAL INDEX against the REAL
|
||||
// broker. A publisher account creates its own public event document AND submits
|
||||
// its reference to the SDK discovery index (submitEventToIndex → deposit). A
|
||||
// separate, NON-connected discoverer account READS THE INDEX
|
||||
// (readDiscoveredEvents → materialize) and subscribes to the referenced document
|
||||
// via a real useShape({graphs}) — with no friendship/connection ever declared
|
||||
// between them, and NO cross-account fan-out. Discovery goes through the index
|
||||
// alone. See decision_2026-06-16_discovery-model (special-account index owner).
|
||||
|
||||
Given('le compte {string} publie un événement public {string}', async function (this: FestipodWorld, publisher: string, title: string) {
|
||||
const res = await this.appFrame!.evaluate(
|
||||
async (p) => await (window as any).__testData.publishPublicEventAs(p, ''),
|
||||
publisher,
|
||||
);
|
||||
(this as any).discovery = { publisher, title, doc: res.doc };
|
||||
expect(res.doc, 'publisher public event doc NURI').to.be.a('string');
|
||||
});
|
||||
|
||||
Given('le compte {string} n\'est pas connecté à {string}', function (this: FestipodWorld, discoverer: string, _publisher: string) {
|
||||
// No connection is ever declared: the discoverer only exists in the shim.
|
||||
// Recorded so the When step knows which account performs the discovery.
|
||||
(this as any).discovery = { ...(this as any).discovery, discoverer };
|
||||
});
|
||||
|
||||
When('{string} découvre les événements publics', async function (this: FestipodWorld, discoverer: string) {
|
||||
const { doc, title } = (this as any).discovery;
|
||||
// Discoverer READS THE GLOBAL INDEX (materialize) and mounts a multi-graph
|
||||
// subscription over the referenced docs — no cross-account fan-out.
|
||||
const res = await this.appFrame!.evaluate(
|
||||
async (d) => await (window as any).__testData.discoverPublicEventsAs(d),
|
||||
discoverer,
|
||||
);
|
||||
(this as any).discovery.listed = res.listed;
|
||||
// Wait for <FanoutProbe> to mount the useShape({graphs}) over the listed docs.
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => (window as any).__fanout?.ready === true,
|
||||
null,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
// Publisher writes its public event into its own doc (now part of the fan-out).
|
||||
await this.appFrame!.evaluate(
|
||||
([d, t]: [string, string]) => (window as any).__fanout.addEventTo(d, t),
|
||||
[doc, title] as [string, string],
|
||||
);
|
||||
});
|
||||
|
||||
Then('{string} voit l\'événement public {string}', async function (this: FestipodWorld, _discoverer: string, title: string) {
|
||||
await this.appFrame!.waitForFunction(
|
||||
(t) => ((window as any).__fanout.titles() as string[]).includes(t),
|
||||
title,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
const titles = await this.appFrame!.evaluate(() => (window as any).__fanout.titles());
|
||||
expect(titles, `discoverer should see the publisher's public event "${title}"`).to.include(title);
|
||||
});
|
||||
|
||||
Then('l\'index global liste le document de l\'événement', function (this: FestipodWorld) {
|
||||
const { doc, listed } = (this as any).discovery;
|
||||
expect(listed, 'the global index should list the publisher event doc').to.include(doc);
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
// T02.c @data proof: registration goes through the REAL FestipodDataContext
|
||||
// mutations (appData via the harness `appJoinEvent`/`appLeaveEvent` bridge), so
|
||||
// this faces the same inbox-deposit + host-notification + SPARQL-DELETE path as
|
||||
// the running app. It proves: (1) join persists + deposits into the host inbox +
|
||||
// mints a notification; (2) leave is DURABLE — the participation does not
|
||||
// resurrect after a materialization re-read (the caveat_participation-deletion
|
||||
// bug fixed via docs.sparqlUpdate DELETE-WHERE).
|
||||
|
||||
// --- Setup (app path) ---
|
||||
|
||||
// NOTE: app-path steps pass the LIVE current user id (resolved at call time via
|
||||
// await td.ensureCurrentUser(), guaranteed non-empty once users hydrated), so the Participation
|
||||
// carries a real principal (the ORM rejects an empty user IRI). Assertions read
|
||||
// `liveIsParticipating` with the SAME live id, so join/leave and the checks agree.
|
||||
|
||||
Given('l\'utilisateur n\'est pas inscrit à l\'événement {string} via l\'app', async function (this: FestipodWorld, eventTitle: string) {
|
||||
await this.appFrame!.evaluate(async (title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) await td.appLeaveEvent(event['@id'], await td.ensureCurrentUser());
|
||||
}, eventTitle);
|
||||
});
|
||||
|
||||
Given('l\'utilisateur est inscrit à l\'événement {string} via l\'app', async function (this: FestipodWorld, eventTitle: string) {
|
||||
await this.appFrame!.evaluate(async (title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) await td.appJoinEvent(event['@id'], await td.ensureCurrentUser());
|
||||
}, eventTitle);
|
||||
});
|
||||
|
||||
// --- Actions (app path) ---
|
||||
|
||||
When('l\'utilisateur s\'inscrit à l\'événement {string} via l\'app', async function (this: FestipodWorld, eventTitle: string) {
|
||||
await this.appFrame!.evaluate(async (title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) await td.appJoinEvent(event['@id'], await td.ensureCurrentUser());
|
||||
}, eventTitle);
|
||||
});
|
||||
|
||||
When('l\'utilisateur se désinscrit de l\'événement {string} via l\'app', async function (this: FestipodWorld, eventTitle: string) {
|
||||
await this.appFrame!.evaluate(async (title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) await td.appLeaveEvent(event['@id'], await td.ensureCurrentUser());
|
||||
}, eventTitle);
|
||||
});
|
||||
|
||||
// --- Assertions ---
|
||||
|
||||
Then('l\'inbox de l\'événement {string} contient au moins un dépôt', async function (this: FestipodWorld, eventTitle: string) {
|
||||
const count = await this.appFrame!.evaluate(async (title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (!event) return -1;
|
||||
const deposits = await td.readInboxDeposits(event['@id']);
|
||||
return deposits.length;
|
||||
}, eventTitle);
|
||||
expect(count, `host inbox of "${eventTitle}" should hold >= 1 deposit`).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
Then('une notification {string} est créée pour l\'événement {string}', async function (this: FestipodWorld, type: string, eventTitle: string) {
|
||||
const found = await this.appFrame!.evaluate(async ([type, title]: [string, string]) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (!event) return false;
|
||||
// The notification surfaces either via the reactive state (immediate on join)
|
||||
// or via the emulated curator reading the deposit back — accept either.
|
||||
const reactive = (td.appNotifications() as any[]).some(
|
||||
(n) => n.type === type && n.ref === event['@id'],
|
||||
);
|
||||
if (reactive) return true;
|
||||
const deposits = await td.readInboxDeposits(event['@id']);
|
||||
return deposits.some((d: any) => d?.payload?.kind === type);
|
||||
}, [type, eventTitle] as [string, string]);
|
||||
expect(found, `a "${type}" notification should exist for "${eventTitle}"`).to.be.true;
|
||||
});
|
||||
|
||||
Then('l\'utilisateur devient participant de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
// The app-path join writes to the FestipodDataContext participation set, which
|
||||
// converges with the harness's own useShape set via the shared store. Poll
|
||||
// in-browser (waitForFunction) until it appears, to absorb that sync latency.
|
||||
// Poll: the participation is written into its own protected doc and read back
|
||||
// reactively; under a busy wallet that read can lag, so accept the AUTHORITATIVE
|
||||
// broker count as well (the write is durable regardless of the reactive re-read).
|
||||
// Wait for the REACTIVE participation to settle (observes AD().liveIsParticipating
|
||||
// fed by the subscription push — NOT a broker re-read). Generous window: this
|
||||
// reactive convergence IS the sync-passed signal.
|
||||
await this.appFrame!.waitForFunction(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
if (!td.currentUserId) return false; // wait for the profile read to hydrate
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
return !!event && td.liveIsParticipating(event['@id']);
|
||||
},
|
||||
eventTitle,
|
||||
{ timeout: 45000 },
|
||||
).catch(() => { /* fall through to a single authoritative confirmation */ });
|
||||
// Then ONE authoritative broker read to prove the participation is durable.
|
||||
const n = await this.appFrame!.evaluate(async (title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (!event) return 0;
|
||||
const uid = await td.ensureCurrentUser();
|
||||
return td.authParticipationCount(event['@id'], uid);
|
||||
}, eventTitle);
|
||||
expect(n, `participation to "${eventTitle}" must exist on the broker`).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
Then('le broker ne contient plus aucune participation à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
// AUTHORITATIVE (not reactive): re-query the broker directly for the current
|
||||
// user's participations to "eventTitle". A durable désinscription leaves 0.
|
||||
const count = await this.appFrame!.evaluate(async (title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (!event) return -1;
|
||||
return td.authParticipationCount(event['@id'], await td.ensureCurrentUser());
|
||||
}, eventTitle);
|
||||
expect(count, `broker must hold 0 participations to "${eventTitle}" after leave (authoritative re-query)`).to.equal(0);
|
||||
});
|
||||
|
||||
Then('l\'utilisateur reste non-inscrit à l\'événement {string} après re-sync', async function (this: FestipodWorld, eventTitle: string) {
|
||||
// Give the broker sync time to (attempt to) resurrect the item, then assert it
|
||||
// stayed deleted. The SPARQL DELETE-WHERE fix means the participation must NOT
|
||||
// come back (the @wip bug: it resurrected). We poll in-browser: if it EVER
|
||||
// reappears within the window the waitForFunction below flips it back to
|
||||
// participating and the final check fails.
|
||||
await this.appFrame!.waitForTimeout(3000);
|
||||
const stillGone = await this.appFrame!.evaluate((title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (!event) return false;
|
||||
return !td.liveIsParticipating(event['@id']);
|
||||
}, eventTitle);
|
||||
expect(stillGone, `participation to "${eventTitle}" must NOT resurrect after re-sync`).to.be.true;
|
||||
});
|
||||
@@ -9,9 +9,9 @@ import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
Given('un événement {string} existe', async function (this: FestipodWorld, eventTitle: string) {
|
||||
// Ensure wallet has data (seed if empty)
|
||||
await this.appFrame!.evaluate(() => {
|
||||
await this.appFrame!.evaluate(async () => {
|
||||
const td = (window as any).__testData;
|
||||
if (td.events.size === 0) td.loadTestData();
|
||||
if (td.events.size === 0) await td.loadTestData();
|
||||
});
|
||||
// Wait for event to appear
|
||||
await this.appFrame!.waitForFunction(
|
||||
@@ -20,16 +20,22 @@ Given('un événement {string} existe', async function (this: FestipodWorld, eve
|
||||
return [...td.events].some((e: any) => e.title === title);
|
||||
},
|
||||
eventTitle,
|
||||
{ timeout: 10000 },
|
||||
{ timeout: 75000 },
|
||||
);
|
||||
});
|
||||
|
||||
Given('l\'utilisateur n\'est pas inscrit à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
// Clean slate for the current user's participation. The app leave is
|
||||
// authoritative (SPARQL DELETE on the participation's own doc) but reactive-gated
|
||||
// (it only acts on a participation present in the reactive set); enough for the
|
||||
// common case. Kept LIGHT (no full-wallet fan-out — that saturates the browser
|
||||
// on a busy wallet).
|
||||
await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
async (title) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = await td.ensureCurrentUser();
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) td.leaveEvent(event['@id'], td.currentUserId);
|
||||
if (event) await td.leaveEvent(event['@id'], uid);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
@@ -37,34 +43,25 @@ Given('l\'utilisateur n\'est pas inscrit à l\'événement {string}', async func
|
||||
|
||||
Given('l\'utilisateur est inscrit à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
async (title) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = await td.ensureCurrentUser();
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) td.joinEvent(event['@id'], td.currentUserId);
|
||||
if (event) await td.joinEvent(event['@id'], uid);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
});
|
||||
|
||||
Given('l\'événement {string} a {int} participants au départ', async function (this: FestipodWorld, eventTitle: string, count: number) {
|
||||
await this.appFrame!.evaluate(
|
||||
([title, c]: [string, number]) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) td.updateEvent(event['@id'], { participantCount: c });
|
||||
},
|
||||
[eventTitle, count] as [string, number],
|
||||
);
|
||||
});
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
When('l\'utilisateur s\'inscrit à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
async (title) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = await td.ensureCurrentUser();
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) td.joinEvent(event['@id'], td.currentUserId);
|
||||
if (event) await td.joinEvent(event['@id'], uid);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
@@ -72,10 +69,11 @@ When('l\'utilisateur s\'inscrit à l\'événement {string}', async function (thi
|
||||
|
||||
When('l\'utilisateur se désinscrit de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
async (title) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = await td.ensureCurrentUser();
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) td.leaveEvent(event['@id'], td.currentUserId);
|
||||
if (event) await td.leaveEvent(event['@id'], uid);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
@@ -83,10 +81,11 @@ When('l\'utilisateur se désinscrit de l\'événement {string}', async function
|
||||
|
||||
When('l\'utilisateur essaie de s\'inscrire une seconde fois à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
async (title) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = await td.ensureCurrentUser();
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) td.joinEvent(event['@id'], td.currentUserId);
|
||||
if (event) await td.joinEvent(event['@id'], uid);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
@@ -95,54 +94,117 @@ When('l\'utilisateur essaie de s\'inscrire une seconde fois à l\'événement {s
|
||||
// --- Assertions ---
|
||||
|
||||
Then('l\'utilisateur est participant de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
const participating = await this.appFrame!.evaluate(
|
||||
// The participation is written into its own protected document and read back
|
||||
// reactively — poll (the read lags the write against the broker), resolving the
|
||||
// current user id at call time.
|
||||
// Wait for the REACTIVE participation to settle (observes AD().isParticipating fed
|
||||
// by the subscription push — NOT a broker re-read). Generous window: this reactive
|
||||
// convergence IS the sync-passed signal.
|
||||
await this.appFrame!.waitForFunction(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = td.currentUserId;
|
||||
if (!uid) return false;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (!event) return false;
|
||||
return td.isParticipating(event['@id'], td.currentUserId);
|
||||
return !!event && td.isParticipating(event['@id'], uid);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
expect(participating, `User should be participating in "${eventTitle}"`).to.be.true;
|
||||
{ timeout: 45000 },
|
||||
).catch(() => { /* fall through to a single authoritative confirmation */ });
|
||||
// Then ONE authoritative broker read (bypasses the reactive set) to prove the
|
||||
// participation is durable at the data level.
|
||||
const n = await this.appFrame!.evaluate(async (title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (!event) return 0;
|
||||
const uid = await td.ensureCurrentUser();
|
||||
return td.authParticipationCount(event['@id'], uid);
|
||||
}, eventTitle);
|
||||
expect(n, `User should be participating in "${eventTitle}"`).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
Then('l\'utilisateur n\'est plus participant de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
const participating = await this.appFrame!.evaluate(
|
||||
// AUTHORITATIVE: the désinscription must be durable at the DATA level — the
|
||||
// broker itself must hold 0 participations for (event, user). The reactive read
|
||||
// can lag or briefly resurrect; the broker count is the source of truth. Poll it
|
||||
// to 0 (bounded).
|
||||
await this.appFrame!.waitForFunction(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (!event) return false;
|
||||
return td.isParticipating(event['@id'], td.currentUserId);
|
||||
return !!event && !td.isParticipating(event['@id'], td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
expect(participating, `User should NOT be participating in "${eventTitle}"`).to.be.false;
|
||||
{ timeout: 20000 },
|
||||
).catch(() => { /* fall through to the authoritative broker check */ });
|
||||
const n = await this.appFrame!.evaluate(async (title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (!event) return -1;
|
||||
return td.authParticipationCount(event['@id'], await td.ensureCurrentUser());
|
||||
}, eventTitle);
|
||||
expect(n, `broker must hold 0 participations to "${eventTitle}" after leave`).to.equal(0);
|
||||
});
|
||||
|
||||
Then('l\'événement {string} compte {int} participants', async function (this: FestipodWorld, eventTitle: string, expectedCount: number) {
|
||||
const count = await this.appFrame!.evaluate(
|
||||
Then('le compteur dérivé de l\'événement {string} reflète l\'inscription', async function (this: FestipodWorld, eventTitle: string) {
|
||||
// OPTION B — the count is DERIVED by the OWNER materializing the event's inbox
|
||||
// (see data-layer/knowledge_context-internals §participantCount). The event-id is
|
||||
// matched on its CANONICAL form (base repo id, stripping any `:v:<overlay>`) across
|
||||
// ownedEventIds / the deposit key / the materializer filter, so an owner
|
||||
// materializes deposits for an event it owns regardless of the id-form path (create
|
||||
// OR listMyEntityDocs OR after reload) — the fix that makes this converge.
|
||||
//
|
||||
// ASSERT THE DELTA, not an absolute count: the shared inbox anchor accumulates
|
||||
// deposits across the wallet's life, so |active| is not bounded to this scenario —
|
||||
// but "the just-joined user IS in the owner's derived active set" is deterministic.
|
||||
// (The absolute 1→2 convergence stays proven end-to-end by the @multibrowser
|
||||
// reactive scenario with a real owner A + joiner B.) Poll (the deposit's index
|
||||
// append + broker sync lag), bounded.
|
||||
// First wait for the REACTIVE join to settle (observes AD().isParticipating fed by
|
||||
// the subscription push — the sign the deposit's write path has run — NOT a broker
|
||||
// re-read). THEN materialize the owner's derived active set from the broker ONCE.
|
||||
await this.appFrame!.waitForFunction(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
return event?.participantCount ?? -1;
|
||||
const uid = td.currentUserId;
|
||||
if (!uid) return false;
|
||||
const ev = [...td.events].find((e: any) => e.title === title);
|
||||
return !!ev && td.isParticipating(ev['@id'], uid);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
expect(count, `Event "${eventTitle}" participant count`).to.equal(expectedCount);
|
||||
{ timeout: 45000 },
|
||||
).catch(() => { /* fall through to the single authoritative materialization */ });
|
||||
const inActive = await this.appFrame!.evaluate(async (title) => {
|
||||
const td = (window as any).__testData;
|
||||
const ev = [...td.events].find((e: any) => e.title === title);
|
||||
const uid = await td.ensureCurrentUser();
|
||||
if (!ev || !uid) return false;
|
||||
const users: (string | null)[] = await td.activeRegistrationUsers(ev['@id']);
|
||||
return users.includes(uid);
|
||||
}, eventTitle);
|
||||
expect(inActive, `the just-joined user must be in the owner-derived active set for "${eventTitle}"`).to.be.true;
|
||||
});
|
||||
|
||||
Then('l\'utilisateur apparaît dans la liste des participants de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
const found = await this.appFrame!.evaluate(
|
||||
await this.appFrame!.waitForFunction(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = td.currentUserId;
|
||||
if (!uid) return false;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (!event) return false;
|
||||
return td.getEventParticipants(event['@id']).some((p: any) => p.user === td.currentUserId);
|
||||
return !!event && td.getEventParticipants(event['@id']).some((p: any) => p.user === uid);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
expect(found, `User should appear in participants of "${eventTitle}"`).to.be.true;
|
||||
{ timeout: 30000 },
|
||||
).catch(async () => {
|
||||
const n = await this.appFrame!.evaluate(async (title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (!event) return 0;
|
||||
return td.authParticipationCount(event['@id'], await td.ensureCurrentUser());
|
||||
}, eventTitle);
|
||||
expect(n, `User should appear in participants of "${eventTitle}"`).to.be.greaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
Then('l\'utilisateur n\'apparaît plus dans la liste des participants de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
@@ -159,14 +221,34 @@ Then('l\'utilisateur n\'apparaît plus dans la liste des participants de l\'év
|
||||
});
|
||||
|
||||
Then('l\'inscription est idempotente pour l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
const count = await this.appFrame!.evaluate(
|
||||
// Idempotence at the DATA level: exactly ONE participation on the broker for
|
||||
// (event, user), no matter how many times the join was attempted. Assert the
|
||||
// AUTHORITATIVE broker count == 1 (bypasses reactive-read lag/dupes). POLL the
|
||||
// authoritative count: the participation is written into its own protected doc
|
||||
// and its index-append propagates async, so a single unpolled read can catch 0
|
||||
// before the write is queryable (observed flake) — poll until the durable state
|
||||
// (exactly 1) is visible, which also proves the second join did NOT add a dupe.
|
||||
// First wait for the REACTIVE participation to settle (observes AD().isParticipating
|
||||
// fed by the subscription push — the sync-passed signal — NOT a broker re-read).
|
||||
// THEN read the AUTHORITATIVE broker count ONCE: exactly 1 proves the second join
|
||||
// added no dupe (bypasses reactive-read lag).
|
||||
await this.appFrame!.waitForFunction(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = td.currentUserId;
|
||||
if (!uid) return false;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (!event) return 0;
|
||||
return td.getEventParticipants(event['@id']).filter((p: any) => p.user === td.currentUserId).length;
|
||||
return !!event && td.isParticipating(event['@id'], uid);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
expect(count, 'User should have exactly one participation record').to.equal(1);
|
||||
{ timeout: 45000 },
|
||||
).catch(() => { /* fall through to the single authoritative count */ });
|
||||
const n = await this.appFrame!.evaluate(async (title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (!event) return -1;
|
||||
const uid = await td.ensureCurrentUser();
|
||||
return td.authParticipationCount(event['@id'], uid);
|
||||
}, eventTitle);
|
||||
expect(n, 'User should have exactly one participation record on the broker').to.equal(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
import { pool } from '../../../../shared/support/browserPool';
|
||||
|
||||
// Two-identity isolation (@data, real broker). Identity A (the fresh per-scenario
|
||||
// username set in localStorage) creates an event E and joins it; a genuinely-
|
||||
// different identity B is brought up on the SAME wallet; B must read NONE of A's
|
||||
// protected participation, E must not be on B's home, isParticipating(E,B) false.
|
||||
//
|
||||
// B is brought up via a FRESH PAGE on the SAME persistent wallet context with B's
|
||||
// identifier in localStorage — the closest analogue to the real app's re-enter-
|
||||
// gate / reload path (a brand-new NgDataProvider mount, username=B, on a wallet
|
||||
// that already holds A's docs). This exercises the identity-switch reset that
|
||||
// keeps A's protected docs out of B's read set.
|
||||
|
||||
Given('l\'identité A crée l\'événement {string} et s\'y inscrit', { timeout: 180000 }, async function (this: FestipodWorld, title: string) {
|
||||
const out = await this.appFrame!.evaluate(async (title) => {
|
||||
const td = (window as any).__testData;
|
||||
const aId = await td.ensureCurrentUser();
|
||||
const created = await td.createEventReal(title);
|
||||
await td.appJoinEvent(created.id, aId);
|
||||
return { eventId: created.id, aId };
|
||||
}, title);
|
||||
// OBSERVE the REACTIVE set until A's own join has settled (isParticipating reads
|
||||
// AD() fed by the subscription push — NOT a broker re-read). This is the barrier
|
||||
// that A's participation exists before B arrives; no broker polling.
|
||||
const aParticipates = await this.appFrame!.waitForFunction(
|
||||
({ eventId, aId }: { eventId: string; aId: string }) =>
|
||||
(window as any).__testData.isParticipating(eventId, aId),
|
||||
{ eventId: out.eventId, aId: out.aId },
|
||||
{ timeout: 45000 },
|
||||
).then(() => true).catch(() => false);
|
||||
expect(aParticipates, 'A must be participating in its own event before B arrives').to.be.true;
|
||||
(this as any).isoEventId = out.eventId;
|
||||
(this as any).isoEventTitle = title;
|
||||
(this as any).isoAId = out.aId;
|
||||
});
|
||||
|
||||
When('une identité fraîche B arrive sur le même wallet partagé', { timeout: 120000 }, async function (this: FestipodWorld) {
|
||||
const bId = `iso-b-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
(this as any).isoBId = bId;
|
||||
const ctx = this.page!.context();
|
||||
const bPage = await ctx.newPage();
|
||||
await bPage.addInitScript((u: string) => {
|
||||
try { window.localStorage.setItem('festipod.account.identifier', u); } catch { /* opaque */ }
|
||||
}, bId);
|
||||
await bPage.addInitScript(() => {
|
||||
(globalThis as Record<string, unknown>).__FESTIPOD_ACCESS_GATE_DISABLED__ = true;
|
||||
});
|
||||
bPage.on('console', (msg) => { if (msg.type() === 'error') console.error('[Bpage console]', msg.text()); });
|
||||
const bFrame = await pool.setupBrokerPage!(bPage, pool.harnessUrl!);
|
||||
await bFrame.waitForFunction(() => (window as any).__testData?.ready === true, { timeout: 60000 });
|
||||
// Let B's listing effect + union read run (rebuilds the read set bounded to B).
|
||||
await bFrame.evaluate(async () => {
|
||||
const td = (window as any).__testData;
|
||||
await td.ensureCurrentUser();
|
||||
await new Promise(r => setTimeout(r, 6000));
|
||||
});
|
||||
(this as any).isoBFrame = bFrame;
|
||||
});
|
||||
|
||||
Then('l\'événement {string} n\'est pas sur l\'accueil de B', async function (this: FestipodWorld, title: string) {
|
||||
const bFrame = (this as any).isoBFrame;
|
||||
const onHome = await bFrame.evaluate((title: string) => {
|
||||
const td = (window as any).__testData;
|
||||
return td.homeEventTitles().includes(title);
|
||||
}, title);
|
||||
expect(onHome, `"${title}" must NOT appear on B's home (getUserEvents(B))`).to.be.false;
|
||||
});
|
||||
|
||||
Then('B n\'est pas participant de l\'événement {string}', async function (this: FestipodWorld, title: string) {
|
||||
const bFrame = (this as any).isoBFrame;
|
||||
const eventId = (this as any).isoEventId;
|
||||
const isPart = await bFrame.evaluate(async ({ eventId }: { eventId: string }) => {
|
||||
const td = (window as any).__testData;
|
||||
const bId = await td.ensureCurrentUser();
|
||||
return td.isParticipating(eventId, bId);
|
||||
}, { eventId });
|
||||
expect(isPart, `B must NOT be participating in "${title}"`).to.be.false;
|
||||
});
|
||||
|
||||
Then('B ne lit aucune participation de A', async function (this: FestipodWorld) {
|
||||
const bFrame = (this as any).isoBFrame;
|
||||
const aId = (this as any).isoAId;
|
||||
const leaks = await bFrame.evaluate((aId: string) => {
|
||||
const td = (window as any).__testData;
|
||||
return td.currentParticipations().filter((p: any) => p.userId === aId);
|
||||
}, aId);
|
||||
expect(leaks, `B must read NONE of A's protected participations (found ${JSON.stringify(leaks)})`).to.have.lengthOf(0);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
import { pool } from '../../../../shared/support/browserPool';
|
||||
|
||||
// RECONNECTION of the SAME identity on the SAME persistent wallet (@data, real
|
||||
// broker). Distinct from the two-identity isolation scenario: here the fresh page
|
||||
// re-enters under the SAME identifier A (same virtual wallet), on a NEW broker
|
||||
// login (fresh verifier session). The defect under test: the fresh page reads its
|
||||
// OWN data EMPTY (home=[], isParticipating=false, authCount=0) because the anchored
|
||||
// listing path (listMyEntityDocs → readScopeIndex, then readUnion/readDoc) queries
|
||||
// repos not yet in `self.repos` at cold-start and silently returns 0 rows.
|
||||
//
|
||||
// Identity A is the scenario's fresh virtual-wallet username (this.freshUser, set
|
||||
// by the Before hook into localStorage on every origin). A creates E via the REAL
|
||||
// app path (createEventReal) and joins it (appJoinEvent) on the MAIN page. Then a
|
||||
// FRESH PAGE is brought up on the SAME persistent wallet context with the SAME
|
||||
// identifier A in localStorage BEFORE any script — the closest analogue to the real
|
||||
// app's re-enter-gate / reload path (a brand-new NgDataProvider mount + a fresh
|
||||
// broker session that must re-open A's own repos). Montage identical to
|
||||
// isolation.steps.ts, except the fresh page reuses this.freshUser (SAME A) rather
|
||||
// than minting a new identifier B.
|
||||
|
||||
// The Given "l'identité A crée l'événement {string} et s'y inscrit" is REUSED from
|
||||
// isolation.steps.ts (same wording, same behavior — A creates E and joins on the
|
||||
// main page). It stores this.isoEventId / this.isoAId, which the steps below read.
|
||||
|
||||
When('une page fraîche pour la MÊME identité A recharge sur le même wallet', { timeout: 120000 }, async function (this: FestipodWorld) {
|
||||
// SAME identity A as the main page: reuse the scenario's fresh virtual-wallet
|
||||
// username (set by the Before hook). NOT a new identifier — this is a reconnect,
|
||||
// not an identity switch.
|
||||
const aIdentifier = (this as any).freshUser as string;
|
||||
const ctx = this.page!.context();
|
||||
const freshPage = await ctx.newPage();
|
||||
await freshPage.addInitScript((u: string) => {
|
||||
try { window.localStorage.setItem('festipod.account.identifier', u); } catch { /* opaque */ }
|
||||
}, aIdentifier);
|
||||
await freshPage.addInitScript(() => {
|
||||
(globalThis as Record<string, unknown>).__FESTIPOD_ACCESS_GATE_DISABLED__ = true;
|
||||
});
|
||||
freshPage.on('console', (msg) => { if (msg.type() === 'error') console.error('[FreshApage console]', msg.text()); });
|
||||
// New broker login → fresh verifier session on the SAME persistent wallet.
|
||||
const freshFrame = await pool.setupBrokerPage!(freshPage, pool.harnessUrl!);
|
||||
await freshFrame.waitForFunction(() => (window as any).__testData?.ready === true, { timeout: 60000 });
|
||||
// Let A's listing effect + anchored union read run on the fresh session (this is
|
||||
// exactly the cold-start read path the fix heals).
|
||||
await freshFrame.evaluate(async () => {
|
||||
const td = (window as any).__testData;
|
||||
await td.ensureCurrentUser();
|
||||
await new Promise(r => setTimeout(r, 6000));
|
||||
});
|
||||
(this as any).recoFreshFrame = freshFrame;
|
||||
});
|
||||
|
||||
// The cold-start read is a BOUNDED SYNC-LAG (measured: the just-created public
|
||||
// doc lists on the first cold attempt, home titles converge at ~1s), and the real
|
||||
// UI is reactive — so these assertions POLL for convergence (up to ~15s) instead
|
||||
// of reading once. A single-shot read races the first-open/sync window and would
|
||||
// flake. This mirrors how the app's own steps already poll participation.
|
||||
Then('l\'événement {string} est sur l\'accueil de la page fraîche A', { timeout: 60000 }, async function (this: FestipodWorld, title: string) {
|
||||
const freshFrame = (this as any).recoFreshFrame;
|
||||
// OBSERVE the REACTIVE home set (homeEventTitles reads AD().getUserEvents fed by
|
||||
// the subscription push — NOT a broker re-read). waitForFunction re-evaluates the
|
||||
// reactive getter until the cold-start union read converges; no broker polling.
|
||||
const onHome = await freshFrame.waitForFunction(
|
||||
(title: string) => (window as any).__testData.homeEventTitles().includes(title),
|
||||
title,
|
||||
{ timeout: 30000 },
|
||||
).then(() => true).catch(() => false);
|
||||
expect(onHome, `"${title}" MUST appear on the fresh A page's home (getUserEvents(A) after reconnect)`).to.be.true;
|
||||
});
|
||||
|
||||
Then('la page fraîche A est participante de l\'événement {string}', { timeout: 60000 }, async function (this: FestipodWorld, title: string) {
|
||||
const freshFrame = (this as any).recoFreshFrame;
|
||||
const eventId = (this as any).isoEventId;
|
||||
await freshFrame.evaluate(async () => { await (window as any).__testData.ensureCurrentUser(); });
|
||||
// OBSERVE the REACTIVE participation set (isParticipating reads AD() fed by the
|
||||
// subscription push — NOT a broker re-read). waitForFunction re-evaluates the
|
||||
// reactive getter until the cold-start union read converges; no broker polling.
|
||||
const isPart = await freshFrame.waitForFunction(
|
||||
(eventId: string) => {
|
||||
const td = (window as any).__testData;
|
||||
const aId = td.currentUserId;
|
||||
return !!aId && td.isParticipating(eventId, aId);
|
||||
},
|
||||
eventId,
|
||||
{ timeout: 30000 },
|
||||
).then(() => true).catch(() => false);
|
||||
expect(isPart, `The fresh A page MUST read its own participation in "${title}" after reconnect`).to.be.true;
|
||||
});
|
||||
|
||||
Then('le compte autoritatif de participation de A à l\'événement {string} est {int}', { timeout: 60000 }, async function (this: FestipodWorld, _title: string, expected: number) {
|
||||
const freshFrame = (this as any).recoFreshFrame;
|
||||
const eventId = (this as any).isoEventId;
|
||||
await freshFrame.evaluate(async () => { await (window as any).__testData.ensureCurrentUser(); });
|
||||
// First wait for the REACTIVE state to reflect the expectation (the sign the
|
||||
// cold-start sync has passed — observes AD().isParticipating fed by the push, NOT
|
||||
// a broker re-read). THEN do ONE authoritative broker read.
|
||||
await freshFrame.waitForFunction(
|
||||
({ eventId, expected }: { eventId: string; expected: number }) => {
|
||||
const td = (window as any).__testData;
|
||||
const aId = td.currentUserId;
|
||||
if (!aId) return false;
|
||||
const isPart = td.isParticipating(eventId, aId);
|
||||
return expected > 0 ? isPart : !isPart;
|
||||
},
|
||||
{ eventId, expected },
|
||||
{ timeout: 30000 },
|
||||
).catch(() => { /* fall through to the single authoritative read */ });
|
||||
const count = await freshFrame.evaluate(async ({ eventId }: { eventId: string }) => {
|
||||
const td = (window as any).__testData;
|
||||
const aId = await td.ensureCurrentUser();
|
||||
return td.authParticipationCount(eventId, aId);
|
||||
}, { eventId });
|
||||
expect(count, `Authoritative broker count of A's participation must be ${expected} after reconnect`).to.equal(expected);
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
import { When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
// T02.f — multi-browser validation of the demonstrable T02 features, driven
|
||||
// across TWO isolated browser contexts (independent NextGraph sessions/verifiers)
|
||||
// both carrying the shared wallet, each loaded into the real NG harness through
|
||||
// the broker. Steps that open the browsers / connect to NG are reused from
|
||||
// workshop/steps/data/multibrowser.steps.ts ("un navigateur … avec le wallet
|
||||
// partagé", "… charge l'application via le broker", "… est connecté à NextGraph").
|
||||
//
|
||||
// SCOPE: real crypto isolation (ReadCap/write-guard + distinct wallets) is
|
||||
// DEFERRED — both browsers share ONE NG identity. So these scenarios do NOT prove
|
||||
// crypto isolation; they prove that the demonstrable behaviours ROUND-TRIP through
|
||||
// the broker from one browser to the other (not just inside one page's reactive
|
||||
// set). The "second account" that registers is modelled by a DISTINCT participant
|
||||
// user id passed to the real app join path (a data-level second party — the
|
||||
// faithful thing available without wallet isolation).
|
||||
|
||||
// A stable, distinct "second participant" id for browser B, so its registration
|
||||
// is a REAL, non-duplicate join (the shared-wallet host identity already holds a
|
||||
// self-participation from createEvent). Kept in world state per scenario.
|
||||
function secondParticipant(world: FestipodWorld): string {
|
||||
const w = world as any;
|
||||
if (!w.__mbParticipantB) w.__mbParticipantB = `urn:festipod:mb-participant:${Date.now()}`;
|
||||
return w.__mbParticipantB;
|
||||
}
|
||||
|
||||
// --- Inscription + notification (T02.c) ---
|
||||
|
||||
When('le navigateur {string} crée l\'événement {string}', async function (this: FestipodWorld, name: string, title: string) {
|
||||
const frame = this.browser(name).appFrame!;
|
||||
const res = await frame.evaluate(
|
||||
async (t) => await (window as any).__testData.createEventReal(t),
|
||||
title,
|
||||
);
|
||||
expect(res.id, `event "${title}" created in browser ${name} must have an id`).to.be.a('string').and.not.equal('');
|
||||
(this as any).mbEvents = { ...((this as any).mbEvents || {}), [title]: res.id };
|
||||
});
|
||||
|
||||
When('le navigateur {string} s\'inscrit à l\'événement {string}', async function (this: FestipodWorld, name: string, title: string) {
|
||||
const frame = this.browser(name).appFrame!;
|
||||
const uid = secondParticipant(this);
|
||||
// Resolve the event id from the reactive set (the event created in browser A
|
||||
// has synced into browser B's independent session), then register via the REAL
|
||||
// app path (appJoinEvent → inbox deposit + notification + SPARQL persistence).
|
||||
await frame.waitForFunction(
|
||||
(t) => [...(window as any).__testData.events].some((e: any) => e.title === t),
|
||||
title,
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
await frame.evaluate(
|
||||
async ([t, u]: [string, string]) => {
|
||||
const td = (window as any).__testData;
|
||||
const ev = [...td.events].find((e: any) => e.title === t);
|
||||
await td.appJoinEvent(ev['@id'], u);
|
||||
},
|
||||
[title, uid] as [string, string],
|
||||
);
|
||||
});
|
||||
|
||||
Then('le navigateur {string} devient participant de l\'événement {string}', async function (this: FestipodWorld, name: string, title: string) {
|
||||
const frame = this.browser(name).appFrame!;
|
||||
const uid = secondParticipant(this);
|
||||
await frame.waitForFunction(
|
||||
([t, u]: [string, string]) => {
|
||||
const td = (window as any).__testData;
|
||||
const ev = [...td.events].find((e: any) => e.title === t);
|
||||
return !!ev && td.isParticipating(ev['@id'], u);
|
||||
},
|
||||
[title, uid] as [string, string],
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
});
|
||||
|
||||
Then('l\'inbox de l\'événement {string} reçoit un dépôt {string}', async function (this: FestipodWorld, title: string, kind: string) {
|
||||
// Host-facing: read the event's inbox deposits back (the emulated curator) and
|
||||
// assert the registration deposit landed. Read from browser A (the host).
|
||||
const frame = this.browser('A').appFrame!;
|
||||
const found = await frame.waitForFunction(
|
||||
async ([t, k]: [string, string]) => {
|
||||
const td = (window as any).__testData;
|
||||
const ev = [...td.events].find((e: any) => e.title === t);
|
||||
if (!ev) return false;
|
||||
const deposits = await td.readInboxDeposits(ev['@id']);
|
||||
return deposits.some((d: any) => d?.payload?.kind === k);
|
||||
},
|
||||
[title, kind] as [string, string],
|
||||
{ timeout: 20000 },
|
||||
).then(() => true).catch(() => false);
|
||||
expect(found, `host inbox of "${title}" should receive a "${kind}" deposit`).to.be.true;
|
||||
});
|
||||
|
||||
// --- Désinscription persistante (T02.c) ---
|
||||
|
||||
When('le navigateur {string} se désinscrit de l\'événement {string}', async function (this: FestipodWorld, name: string, title: string) {
|
||||
const frame = this.browser(name).appFrame!;
|
||||
const uid = secondParticipant(this);
|
||||
// `leaveEvent` finds the participation in the reactive set before it deposits the
|
||||
// "leave" marker (an absent participation → early return → no deposit → the owner
|
||||
// never sees the leave). In the same-session leave (désinscription-persistante)
|
||||
// the optimistic overlay makes it present instantly; in the reconnect flow (owner-
|
||||
// convergence) B reloaded, so wait for its participation to sync back into the set
|
||||
// first. Event-driven wait on the reactive state — not a broker re-query loop.
|
||||
await frame.waitForFunction(
|
||||
([t, u]: [string, string]) => {
|
||||
const td = (window as any).__testData;
|
||||
const ev = [...td.events].find((e: any) => e.title === t);
|
||||
return !!ev && td.isParticipating(ev['@id'], u);
|
||||
},
|
||||
[title, uid] as [string, string],
|
||||
{ timeout: 30000 },
|
||||
).catch(() => { /* proceed; leaveEvent will no-op if truly absent */ });
|
||||
await frame.evaluate(
|
||||
async ([t, u]: [string, string]) => {
|
||||
const td = (window as any).__testData;
|
||||
const ev = [...td.events].find((e: any) => e.title === t);
|
||||
await td.appLeaveEvent(ev['@id'], u);
|
||||
},
|
||||
[title, uid] as [string, string],
|
||||
);
|
||||
});
|
||||
|
||||
Then('le broker ne contient plus aucune participation à l\'événement {string} pour le navigateur {string}', async function (this: FestipodWorld, title: string, name: string) {
|
||||
const frame = this.browser(name).appFrame!;
|
||||
const uid = secondParticipant(this);
|
||||
// AUTHORITATIVE re-query (not the reactive set): the broker itself must report 0
|
||||
// for (event, secondParticipant). A durable désinscription leaves 0.
|
||||
const count = await frame.evaluate(
|
||||
async ([t, u]: [string, string]) => {
|
||||
const td = (window as any).__testData;
|
||||
const ev = [...td.events].find((e: any) => e.title === t);
|
||||
if (!ev) return -1;
|
||||
return await td.authParticipationCount(ev['@id'], u);
|
||||
},
|
||||
[title, uid] as [string, string],
|
||||
);
|
||||
expect(count, `broker must hold 0 participations to "${title}" after leave (authoritative re-query)`).to.equal(0);
|
||||
});
|
||||
|
||||
Then('l\'inscription de l\'événement {string} ne ressuscite pas dans le navigateur {string} après re-sync', async function (this: FestipodWorld, title: string, name: string) {
|
||||
const frame = this.browser(name).appFrame!;
|
||||
const uid = secondParticipant(this);
|
||||
// Give the broker sync time to (attempt to) resurrect the item, then assert it
|
||||
// stayed deleted — the SPARQL DELETE-WHERE fix means it must NOT come back.
|
||||
await frame.waitForTimeout(3000);
|
||||
const count = await frame.evaluate(
|
||||
async ([t, u]: [string, string]) => {
|
||||
const td = (window as any).__testData;
|
||||
const ev = [...td.events].find((e: any) => e.title === t);
|
||||
if (!ev) return -1;
|
||||
return await td.authParticipationCount(ev['@id'], u);
|
||||
},
|
||||
[title, uid] as [string, string],
|
||||
);
|
||||
expect(count, `participation to "${title}" must NOT resurrect after re-sync`).to.equal(0);
|
||||
});
|
||||
|
||||
// --- Lecture réactive cross-session (P3, brief §D.2) ---
|
||||
// A crée l'événement, en ouvre le détail (le contexte sélectionne l'event), et
|
||||
// observe son état RÉACTIF passer de 1 à 2 quand B s'inscrit — SANS recharger.
|
||||
// L'assertion attend un ÉVÉNEMENT (waitForFunction sur l'état réactif poussé par
|
||||
// doc_subscribe), pas un timeout fixe : le timeout de garde ne fait que borner
|
||||
// l'attente, il n'est pas la SOURCE de la mise à jour.
|
||||
|
||||
When('le navigateur {string} ouvre le détail de l\'événement {string}', async function (this: FestipodWorld, name: string, title: string) {
|
||||
const frame = this.browser(name).appFrame!;
|
||||
// Wait for the event to be in this session's reactive set, then select it (what
|
||||
// EventDetailScreen navigation does). Reads stay reactive via the subscription.
|
||||
await frame.waitForFunction(
|
||||
(t) => [...(window as any).__testData.events].some((e: any) => e.title === t),
|
||||
title,
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
await frame.evaluate(
|
||||
(t) => {
|
||||
const td = (window as any).__testData;
|
||||
const ev = [...td.events].find((e: any) => e.title === t);
|
||||
if (ev) td.appData.setSelectedEventId(ev['@id']);
|
||||
},
|
||||
title,
|
||||
);
|
||||
});
|
||||
|
||||
// --- Compteur fiable à la connexion (Option B, recadré 2026-07-13) ---
|
||||
// The requirement is NOT a live cross-session push: the count is stored in the
|
||||
// event, only the owner writes it, the registrant DEPOSITS into the event inbox,
|
||||
// and the owner processes those deposits at its NEXT CONNECTION. So this step
|
||||
// models exactly that: reload the browser's harness (a fresh verifier that PULLS
|
||||
// the broker state — including the registrant's synced deposit), wait until it is
|
||||
// ready and has re-materialized (the owner-materializer runs at connection with a
|
||||
// BARRIER-GATED inbox read — `inbox.readSynced` — so the synced deposit is seen),
|
||||
// then re-open the event detail. NO broker-polling loop (rule_no-broker-polling):
|
||||
// the reconnection is the trigger; the subsequent assertion waits on the reactive
|
||||
// state settling.
|
||||
When('le navigateur {string} se reconnecte et matérialise l\'inbox', async function (this: FestipodWorld, name: string) {
|
||||
const events = (this as any).mbEvents || {};
|
||||
// Reload the harness → fresh verifier re-loads the wallet from the broker,
|
||||
// pulling the other browser's synced writes (the deposit, or the owner's count).
|
||||
await this.loadAppInBrowser(name, 'harness');
|
||||
const frame = this.browser(name).appFrame!;
|
||||
await frame.waitForFunction(
|
||||
() => (window as any).__testData?.ready === true,
|
||||
null,
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
// Re-select every known event so the owner-materializer (keyed on ownedKey,
|
||||
// resolved from listMyEntityDocs on connect) has run its barrier-gated pass and
|
||||
// the detail view is back on the event. Best-effort per title.
|
||||
for (const title of Object.keys(events)) {
|
||||
await frame.waitForFunction(
|
||||
(t) => [...(window as any).__testData.events].some((e: any) => e.title === t),
|
||||
title,
|
||||
{ timeout: 30000 },
|
||||
).catch(() => { /* the event may not be in this browser's set yet */ });
|
||||
await frame.evaluate(
|
||||
(t) => {
|
||||
const td = (window as any).__testData;
|
||||
const ev = [...td.events].find((e: any) => e.title === t);
|
||||
if (ev) td.appData.setSelectedEventId(ev['@id']);
|
||||
},
|
||||
title,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Then('le compteur de participants réactif dans {string} pour {string} vaut {int}', async function (this: FestipodWorld, name: string, title: string, expected: number) {
|
||||
const frame = this.browser(name).appFrame!;
|
||||
// Wait (event-driven) for the reactive participantCount to reach `expected` — the
|
||||
// guard timeout only bounds the wait; the value arrives via the union re-read the
|
||||
// subscription triggers, never via the timeout itself.
|
||||
await frame.waitForFunction(
|
||||
([t, n]: [string, number]) => {
|
||||
const td = (window as any).__testData;
|
||||
const ev = [...td.events].find((e: any) => e.title === t);
|
||||
if (!ev) return false;
|
||||
const st = td.reactiveEventState(ev['@id']);
|
||||
return st.found && st.participantCount === n;
|
||||
},
|
||||
[title, expected] as [string, number],
|
||||
// The owner materializes ALL its owned events at connection (each with a
|
||||
// barrier-gated inbox read) before its own write pushes back into the reactive
|
||||
// set — so allow a generous window. This is an event-driven wait on the reactive
|
||||
// state settling (NOT a broker re-query loop, rule_no-broker-polling).
|
||||
{ timeout: 45000 },
|
||||
);
|
||||
const count = await frame.evaluate(
|
||||
(t) => {
|
||||
const td = (window as any).__testData;
|
||||
const ev = [...td.events].find((e: any) => e.title === t);
|
||||
return ev ? td.reactiveEventState(ev['@id']).participantCount : -1;
|
||||
},
|
||||
title,
|
||||
);
|
||||
expect(count, `reactive participantCount for "${title}" in browser ${name}`).to.equal(expected);
|
||||
});
|
||||
|
||||
Then('sans recharger, le compteur de participants réactif dans {string} pour {string} passe à {int}', async function (this: FestipodWorld, name: string, title: string, expected: number) {
|
||||
const frame = this.browser(name).appFrame!;
|
||||
// NO reload / no local action on A between B's join and this assertion — the
|
||||
// update MUST arrive through A's `doc_subscribe` on the (public) event doc that B
|
||||
// wrote. Event-driven wait: waitForFunction polls A's already-live reactive state
|
||||
// (no loadAppInBrowser here), succeeding only once the subscription push re-read.
|
||||
const reached = await frame.waitForFunction(
|
||||
([t, n]: [string, number]) => {
|
||||
const td = (window as any).__testData;
|
||||
const ev = [...td.events].find((e: any) => e.title === t);
|
||||
if (!ev) return false;
|
||||
const st = td.reactiveEventState(ev['@id']);
|
||||
return st.found && st.participantCount === n;
|
||||
},
|
||||
[title, expected] as [string, number],
|
||||
{ timeout: 30000 },
|
||||
).then(() => true).catch(() => false);
|
||||
expect(reached, `reactive participantCount for "${title}" in browser ${name} must reach ${expected} WITHOUT reload (via doc_subscribe)`).to.be.true;
|
||||
});
|
||||
|
||||
Then('le navigateur {string} affiche un participant {string} pour {string}', async function (this: FestipodWorld, name: string, _kind: string, title: string) {
|
||||
const frame = this.browser(name).appFrame!;
|
||||
// B is NOT a connection of A, so its participation doc is unreadable to A → it
|
||||
// never appears as a NAMED participant; it falls into the "unknown" placeholder
|
||||
// count (participantCount − knownCount ≥ 1), exactly EventDetailScreen's
|
||||
// "Voir tous les participants" path. Assert reactively (event-driven).
|
||||
const unknown = await frame.waitForFunction(
|
||||
(t) => {
|
||||
const td = (window as any).__testData;
|
||||
const ev = [...td.events].find((e: any) => e.title === t);
|
||||
if (!ev) return false;
|
||||
const st = td.reactiveEventState(ev['@id']);
|
||||
return st.found && st.unknownCount >= 1 ? st.unknownCount : false;
|
||||
},
|
||||
title,
|
||||
{ timeout: 20000 },
|
||||
).then(h => h.jsonValue()).catch(() => 0);
|
||||
expect(Number(unknown), `browser ${name} must show ≥1 "unknown" participant for "${title}"`).to.be.at.least(1);
|
||||
});
|
||||
|
||||
// --- Découverte publique cross-comptes (T02.e) ---
|
||||
|
||||
When('le compte {string} publie un événement public {string} dans le navigateur {string}', async function (this: FestipodWorld, publisher: string, title: string, name: string) {
|
||||
const frame = this.browser(name).appFrame!;
|
||||
const res = await frame.evaluate(
|
||||
async (p) => await (window as any).__testData.publishPublicEventAs(p, ''),
|
||||
publisher,
|
||||
);
|
||||
expect(res.doc, `publisher public event doc for ${publisher}`).to.be.a('string');
|
||||
(this as any).mbDiscovery = { publisher, title, doc: res.doc };
|
||||
});
|
||||
|
||||
When('le compte {string} découvre les événements publics dans le navigateur {string} sans être connecté à {string}', async function (this: FestipodWorld, discoverer: string, name: string, _publisher: string) {
|
||||
const { doc, title } = (this as any).mbDiscovery;
|
||||
// The publisher doc + its account were created in the OTHER browser's verifier.
|
||||
// For THIS browser's independent verifier to discover them, it must PULL those
|
||||
// wallet writes from the broker. Two independent NextGraph verifiers on the same
|
||||
// shared-wallet document converge eventually, but a `sparqlQuery` reads the LOCAL
|
||||
// verifier copy — it won't pull on its own. Re-bootstrapping this browser's
|
||||
// harness (fresh page → fresh verifier load from the broker) forces the pull.
|
||||
// So: poll discovery, and every few attempts reload this browser's harness to
|
||||
// re-sync from the broker, until the publisher doc surfaces.
|
||||
let listed: string[] = [];
|
||||
let frame = this.browser(name).appFrame!;
|
||||
for (let attempt = 0; attempt < 12 && !listed.includes(doc); attempt++) {
|
||||
if (attempt > 0 && attempt % 3 === 0) {
|
||||
// Reload the harness → the verifier re-loads the wallet from the broker,
|
||||
// pulling the publisher's shim account + index writes.
|
||||
await this.loadAppInBrowser(name, 'harness');
|
||||
frame = this.browser(name).appFrame!;
|
||||
await frame.waitForFunction(
|
||||
() => (window as any).__testData?.ready === true,
|
||||
null,
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
}
|
||||
const res = await frame.evaluate(
|
||||
async (d) => await (window as any).__testData.discoverPublicEventsAs(d),
|
||||
discoverer,
|
||||
);
|
||||
listed = res.listed;
|
||||
if (!listed.includes(doc)) await frame.waitForTimeout(1500);
|
||||
}
|
||||
(this as any).mbDiscovery.listed = listed;
|
||||
expect(listed, 'cross-account public index should list the publisher doc (after cross-verifier re-sync)').to.include(doc);
|
||||
// <FanoutProbe> mounts a useShape({graphs}) over the discovered docs; write the
|
||||
// publisher's public event into its own doc (now part of the discoverer fan-out).
|
||||
await frame.waitForFunction(
|
||||
() => (window as any).__fanout?.ready === true,
|
||||
null,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
await frame.evaluate(
|
||||
([d, t]: [string, string]) => (window as any).__fanout.addEventTo(d, t),
|
||||
[doc, title] as [string, string],
|
||||
);
|
||||
});
|
||||
|
||||
Then('le navigateur {string} voit l\'événement public {string}', async function (this: FestipodWorld, name: string, title: string) {
|
||||
const frame = this.browser(name).appFrame!;
|
||||
await frame.waitForFunction(
|
||||
(t) => ((window as any).__fanout.titles() as string[]).includes(t),
|
||||
title,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
const titles = await frame.evaluate(() => (window as any).__fanout.titles());
|
||||
expect(titles, `browser ${name} should see the public event "${title}"`).to.include(title);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
# language: fr
|
||||
#
|
||||
# SMOKE « crash de rendu connecté / page blanche ».
|
||||
#
|
||||
# Régression gardée : après connexion au broker, seul le bandeau de l'iframe
|
||||
# s'affichait et le vrai App ne rendait rien (page blanche). Aucun test
|
||||
# n'attrapait cette CLASSE de bug — un crash de rendu qui ne survient QUE une
|
||||
# fois l'app connectée et montée sur des données réelles.
|
||||
#
|
||||
# Ce smoke boote le VRAI App (serveur src/index.ts, pas le harness __testData),
|
||||
# se connecte au broker (fourni par le hook Before @e2e), navigue vers l'accueil
|
||||
# connecté (HomeScreen, qui consomme useFestipodData) et vérifie DEUX choses :
|
||||
# 1. l'accueil rend un élément d'app significatif (barre de navigation + bouton
|
||||
# « Relayer »), preuve que HomeScreen a monté — pas juste un spinner ou le
|
||||
# bandeau du broker ;
|
||||
# 2. AUCUNE erreur runtime (pageerror / console.error) n'a été émise pendant
|
||||
# le boot connecté — un throw dans un composant/provider monté après
|
||||
# connexion vire le smoke au rouge.
|
||||
#
|
||||
# Tout futur crash de rendu connecté = ce smoke passe au rouge.
|
||||
|
||||
@e2e @smoke
|
||||
Fonctionnalité: L'accueil connecté rend du contenu réel
|
||||
En tant qu'utilisateur qui vient de se connecter
|
||||
Je veux que l'écran d'accueil rende réellement l'application
|
||||
Afin de ne jamais retomber sur une page blanche une fois connecté
|
||||
|
||||
Scénario: L'accueil rend du contenu réel sans erreur après connexion
|
||||
Quand l'application connectée affiche l'accueil
|
||||
Alors l'accueil rend un contenu d'application réel
|
||||
Et aucune erreur runtime n'a été émise pendant le boot connecté
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Title, Card, AvatarStack, BottomNav, EventCover, EventMeetingPoints, type MeetingPointData } from '../../../shared/components/sketchy';
|
||||
import { Title, Card, AvatarStack, BottomNav, EventCover, EventMeetingPoints, Spinner, type MeetingPointData } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { usePendingQueries } from '../../../shared/data/pendingQueries';
|
||||
import { useNavigate } from '../../../app/router';
|
||||
|
||||
const PEOPLE = [
|
||||
@@ -65,6 +66,7 @@ function EventCardBody({
|
||||
export function HomeScreen() {
|
||||
const navigate = useNavigate();
|
||||
const { getUserEvents, currentUserId, getEventMeetingPoints } = useFestipodData();
|
||||
const pendingQueries = usePendingQueries();
|
||||
const [joinedIds, setJoinedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const myEvents = getUserEvents(currentUserId);
|
||||
@@ -94,7 +96,10 @@ export function HomeScreen() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<div style={{ padding: '12px 16px 8px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Title style={{ margin: 0 }}>Festipod</Title>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Title style={{ margin: 0 }}>Festipod</Title>
|
||||
{pendingQueries > 0 && <Spinner />}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/events/new')}
|
||||
aria-label="Relayer un événement"
|
||||
|
||||
@@ -12,8 +12,9 @@ export function SettingsScreen() {
|
||||
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.
|
||||
// Faux logout: clears the current identifier only — the shared wallet stays
|
||||
// open underneath. In staging this returns to the access barrier (identifier
|
||||
// prompt), since the gate shows until an identifier is set again.
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/');
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
// --- Smoke « crash de rendu connecté / page blanche » ---
|
||||
//
|
||||
// Le hook Before @e2e a déjà : booté le VRAI App via le broker (this.appFrame),
|
||||
// attendu que #root ait du contenu, et laissé 3s aux providers pour se
|
||||
// stabiliser. On ne re-boote donc PAS l'app ici — on réutilise this.appFrame.
|
||||
//
|
||||
// Ces steps prouvent que l'App a réellement MONTÉ l'accueil connecté (HomeScreen,
|
||||
// qui consomme useFestipodData), pas juste un spinner ou le bandeau du broker.
|
||||
|
||||
When("l'application connectée affiche l'accueil", async function (this: FestipodWorld) {
|
||||
// Un utilisateur connecté qui atterrit sur '/' est redirigé vers '/home' par
|
||||
// WelcomeScreen. On navigue explicitement pour rendre le smoke déterministe
|
||||
// quel que soit l'état de la redirection au moment du boot.
|
||||
await this.appFrame!.evaluate(() => {
|
||||
window.history.pushState(null, '', '/home');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
});
|
||||
// Laisse le routeur et HomeScreen (re)monter.
|
||||
await this.appFrame!.waitForTimeout(1000);
|
||||
});
|
||||
|
||||
Then("l'accueil rend un contenu d'application réel", async function (this: FestipodWorld) {
|
||||
// Marqueurs FORTS et propres à HomeScreen (absents de WelcomeScreen / d'un
|
||||
// simple spinner / du bandeau broker) :
|
||||
// - .app-navbar : la barre de navigation basse (BottomNav) — rendue par
|
||||
// HomeScreen, pas par l'écran d'onboarding ;
|
||||
// - le bouton « Relayer » (aria-label="Relayer un événement") propre à
|
||||
// l'en-tête de l'accueil.
|
||||
// Si un throw dans HomeScreen (ou un provider monté après connexion) blanchit
|
||||
// le rendu, React démonte l'arbre (aucun ErrorBoundary) et ces marqueurs
|
||||
// disparaissent → l'attente échoue.
|
||||
const rendered = await this.appFrame!.waitForFunction(
|
||||
() => {
|
||||
const root = document.getElementById('root');
|
||||
if (!root) return false;
|
||||
const hasNavbar = document.querySelector('.app-navbar') !== null;
|
||||
const hasRelayer =
|
||||
document.querySelector('[aria-label="Relayer un événement"]') !== null;
|
||||
return hasNavbar && hasRelayer;
|
||||
},
|
||||
{ timeout: 15000 },
|
||||
).then(() => true).catch(() => false);
|
||||
|
||||
if (!rendered) {
|
||||
const debug = await this.appFrame!.evaluate(() => ({
|
||||
pathname: window.location.pathname,
|
||||
hasNavbar: document.querySelector('.app-navbar') !== null,
|
||||
hasRelayer: document.querySelector('[aria-label="Relayer un événement"]') !== null,
|
||||
rootLen: document.getElementById('root')?.innerHTML.length ?? 0,
|
||||
rootText: document.getElementById('root')?.textContent?.substring(0, 400),
|
||||
}));
|
||||
expect.fail(
|
||||
`L'accueil connecté n'a pas rendu de contenu d'app réel (page blanche ?). ` +
|
||||
`path="${debug.pathname}", .app-navbar=${debug.hasNavbar}, ` +
|
||||
`bouton Relayer=${debug.hasRelayer}, #root length=${debug.rootLen}, ` +
|
||||
`texte: "${debug.rootText}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Then('aucune erreur runtime n\'a été émise pendant le boot connecté', function (this: FestipodWorld) {
|
||||
// this.pageErrors est peuplé par le hook Before (pageerror + console.error de
|
||||
// la page app), réinitialisé à chaque scénario. Un crash de rendu connecté
|
||||
// (throw non attrapé dans un composant/provider) émet un `pageerror` et
|
||||
// atterrit ici → assertion rouge avec la liste exacte.
|
||||
expect(
|
||||
this.pageErrors,
|
||||
`Des erreurs runtime ont été émises pendant le boot connecté :\n` +
|
||||
this.pageErrors.map((e, i) => ` [${i + 1}] ${e}`).join('\n'),
|
||||
).to.be.empty;
|
||||
});
|
||||
@@ -57,12 +57,17 @@ Fonctionnalité: Harness multi-navigateur — modèles private-wallet et shared-
|
||||
# embarquer (cf. nextgraph-platform → knowledge_broker-import-constraint).
|
||||
# (≠ scénario @shared-wallet ci-dessus, qui INJECTE le wallet via storageState
|
||||
# et court-circuite donc l'import — provisioning de TEST, pas le flux produit.)
|
||||
#
|
||||
# EXCLU DU RUN PAR DÉFAUT (cucumber.json : "not @wip and not @humain", T02.f) :
|
||||
# ce scénario pilote nextgraph.eu EN DIRECT (import du fichier wallet sur un site
|
||||
# externe non maîtrisé par Festipod) → non déterministe en CI et, en cas d'échec
|
||||
# réseau, il ferme le contexte navigateur et faisait CASCADER les scénarios @data
|
||||
# suivants. C'est une validation de FIDÉLITÉ HUMAINE, à lancer explicitement
|
||||
# (`--tags @humain`), pas un test automatisé du run par défaut.
|
||||
@shared-wallet @assisted-import @humain
|
||||
Scénario: Parcours humain — le testeur importe le portefeuille fourni par Festipod et se connecte
|
||||
Étant donné un nouveau testeur ouvre Festipod en staging sur un navigateur vierge
|
||||
Alors Festipod affiche l'écran d'accès avec le portefeuille à télécharger
|
||||
Quand le testeur télécharge le portefeuille et l'importe sur nextgraph.eu
|
||||
Et le testeur revient sur Festipod et clique « Entrer »
|
||||
Alors Festipod est connecté et propose de choisir un nom d'utilisateur
|
||||
Quand le testeur choisit un nom d'utilisateur
|
||||
Et le testeur revient sur Festipod, saisit un identifiant et clique « Entrer »
|
||||
Alors il arrive sur l'accueil de l'application
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# 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,19 @@
|
||||
# language: fr
|
||||
@WORKSHOP @priority-1
|
||||
Fonctionnalité: Isolation protégée par connexions (ng-eventually)
|
||||
En tant que développeur
|
||||
Je veux valider, contre le vrai broker, que l'isolation est ACTIVE via le SDK :
|
||||
un compte ne lit PAS l'entité PROTÉGÉE d'un autre compte tant qu'ils ne sont
|
||||
pas connectés, la lit une fois qu'ils se connectent, et lit toujours l'entité
|
||||
PUBLIQUE de cet autre compte — le tout appliqué par le SDK (filtre ReadCap +
|
||||
déclaration de connexions), pas par un filtre applicatif.
|
||||
|
||||
@data
|
||||
Scénario: Un compte non connecté ne lit pas l'entité protégée d'un autre, puis la lit après connexion
|
||||
Étant donné le wallet contient l'entité protégée du compte "alice"
|
||||
Et le compte "bob" est courant sans connexion à "alice"
|
||||
Alors "bob" ne voit aucune entité protégée d'"alice"
|
||||
Mais "bob" voit l'entité publique d'"alice"
|
||||
Quand l'app déclare la connexion entre "alice" et "bob"
|
||||
Alors "bob" voit l'entité protégée d'"alice"
|
||||
Et "bob" voit toujours l'entité publique d'"alice"
|
||||
@@ -0,0 +1,20 @@
|
||||
# language: fr
|
||||
@WORKSHOP @priority-1
|
||||
Fonctionnalité: Store protected natif — ouverture et aller-retour (axe A)
|
||||
En tant que développeur
|
||||
Je veux vérifier, contre le vrai broker NextGraph, que le store natif protected
|
||||
(`did:ng:${protected_store_id}`) s'ouvre pour lecture ET écriture comme le store
|
||||
private, avant de basculer les entités du domaine vers lui (T02.h, axe A).
|
||||
|
||||
# --- Data (broker réel) — ÉTAPE GATING ---
|
||||
|
||||
@data
|
||||
Scénario: L'ORM lit et écrit dans le store protected natif (aller-retour)
|
||||
Étant donné le store protected natif est souscrit via l'ORM
|
||||
Quand j'écris une participation dans le store protected via l'ORM
|
||||
Alors la participation est lisible dans le store protected
|
||||
|
||||
@data
|
||||
Scénario: SPARQL fait l'aller-retour dans le store protected natif
|
||||
Quand j'écris puis relis un triplet dans le store protected via SPARQL
|
||||
Alors le triplet est retrouvé dans le store protected sans RepoNotFound
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user