Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 412e5d63e7 | |||
| d48297fe94 | |||
| a5f6e01562 | |||
| 33b4588da4 | |||
| 0c4f6b6650 | |||
| b25bafbb90 | |||
| 3de86c8be2 | |||
| 4b4dfa6cad | |||
| 974d251079 | |||
| fd02570b8b | |||
| efdfcac4c1 | |||
| 750538bd73 | |||
| 20b3ef8712 | |||
| ff285455d2 | |||
| 35df3e9110 | |||
| db483726af | |||
| 46abbf2e64 | |||
| 753282c7b1 | |||
| d237753916 | |||
| 33d54c6e3e |
+3
-1
@@ -43,4 +43,6 @@ playwright/.auth/
|
||||
storybook-static
|
||||
dist-staging/
|
||||
*.ngw
|
||||
.tasks/
|
||||
|
||||
# per-developer contract source access (never committed)
|
||||
.project/contracts.local.yaml
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# 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/** : `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]]).
|
||||
- **auth/** : `login`
|
||||
|
||||
> Le mapping path → écran est dans [[knowledge_routing]]. La plupart des écrans consomment `useFestipodData()` (concept `data-layer`) ; exceptions : `WelcomeScreen` et la barrière `AccessGateScreen`.
|
||||
> Le mapping path → écran est dans [[knowledge_routing]]. La plupart des écrans consomment `useFestipodData()` (concept `data-layer`) ; exceptions : `LoginScreen`/`WelcomeScreen`.
|
||||
|
||||
## Piège : registre incomplet
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# 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,21 +1,23 @@
|
||||
---
|
||||
type: _overview
|
||||
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
|
||||
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
|
||||
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, scope, isolation]
|
||||
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]
|
||||
paths: ["src/modules/auth/**", "src/shared/context/NextGraphContext.tsx"]
|
||||
---
|
||||
|
||||
# App security
|
||||
|
||||
Le modèle de **sécurité, confidentialité et autorisations** de Festipod.
|
||||
Le modèle de **sécurité, confidentialité et autorisations** de Festipod. Le pilier se lit en deux temps :
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## Liens
|
||||
|
||||
- [[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)
|
||||
- [[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
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
---
|
||||
type: brief
|
||||
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
|
||||
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
|
||||
last_updated: 2026-05-18
|
||||
---
|
||||
|
||||
# Matrice d'autorisations et inventaire des requêtes
|
||||
|
||||
**Status:** Incubating — modèle cible, non figé en règles.
|
||||
**Status:** Incubating — analyse en cours
|
||||
**Last updated:** 2026-05-18
|
||||
|
||||
## Context
|
||||
|
||||
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]]).
|
||||
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é.
|
||||
|
||||
## Cadre
|
||||
|
||||
@@ -30,7 +33,7 @@ Le modèle **cible** de qui-peut-quoi. La confidentialité de Festipod se dériv
|
||||
- **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 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.
|
||||
- **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).
|
||||
- **Adhésion à une communauté / suivi : hors périmètre actuel.**
|
||||
|
||||
## Matrice par type de donnée
|
||||
@@ -60,7 +63,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). **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 ?
|
||||
**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).
|
||||
|
||||
### Événement
|
||||
|
||||
@@ -71,7 +74,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 (concept `functional-domain`, [[brief_2026-06-15_event-deduplication]]). 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 (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é) ?
|
||||
|
||||
### Profil utilisateur
|
||||
|
||||
@@ -86,7 +89,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 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é ?).
|
||||
**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é ?).
|
||||
|
||||
### Connexion (lien d'amitié)
|
||||
|
||||
@@ -102,18 +105,31 @@ 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)).
|
||||
|
||||
## Périmètres dérivés
|
||||
## Partitions naturelles dérivées
|
||||
|
||||
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 :
|
||||
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**.
|
||||
|
||||
| Périmètre | Écriture | Lecture | Données |
|
||||
| Périmètre | Écriture | Lecture | Données validées |
|
||||
|---|---|---|---|
|
||||
| **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) |
|
||||
| **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) |
|
||||
|
||||
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é.
|
||||
### 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.
|
||||
|
||||
## Inventaire des requêtes par écran
|
||||
|
||||
@@ -121,6 +137,7 @@ La **`Connexion` bilatérale** a *deux* écrivains → périmètre **dialog** d
|
||||
|
||||
## See Also
|
||||
|
||||
- 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
|
||||
- [[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
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
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]].
|
||||
@@ -1,32 +0,0 @@
|
||||
---
|
||||
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,22 +1,20 @@
|
||||
---
|
||||
type: knowledge
|
||||
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
|
||||
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
|
||||
---
|
||||
|
||||
# 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'authentification est **déléguée au SDK de données** (`@ng-eventually/client`) : ouvrir sa session, c'est ouvrir son wallet.
|
||||
**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.
|
||||
|
||||
## Flux
|
||||
|
||||
- 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`.
|
||||
- `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.
|
||||
|
||||
## Le wallet de test
|
||||
|
||||
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.
|
||||
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`).
|
||||
|
||||
> 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]].
|
||||
> 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]].
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
---
|
||||
type: knowledge
|
||||
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
|
||||
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
|
||||
---
|
||||
|
||||
# Modèle de confiance
|
||||
# Modèle de confiance actuel
|
||||
|
||||
**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`).
|
||||
**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**.
|
||||
|
||||
Principes :
|
||||
Conséquences (à connaître avant de raisonner sécurité) :
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Le point de vigilance
|
||||
## Le piège pour la suite
|
||||
|
||||
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**.
|
||||
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.
|
||||
|
||||
> À 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.
|
||||
> À 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.
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
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 **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`.
|
||||
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`.
|
||||
|
||||
## Config
|
||||
|
||||
`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`.
|
||||
`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`.
|
||||
|
||||
## Le harness de test est buildé à la demande
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
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)
|
||||
@@ -22,7 +21,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 initial est requis pour amorcer la session (sauvé en localStorage) ; sans lui, les écritures ne passeraient pas. 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 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.
|
||||
- **Runs suivants** : marker trouvé → login automatisé (click Login → wallet → password → submit) → harness en iframe → `window.__testData.ready`.
|
||||
- Credentials wallet : `festipod-tests` / `festipod-tests`.
|
||||
|
||||
@@ -33,34 +32,5 @@ 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).
|
||||
- **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.
|
||||
- **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`).
|
||||
|
||||
@@ -42,26 +42,6 @@ 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 + collecte `pageErrors`), `world.ts` (champs `page`/`appFrame`/`pageErrors`), `scripts/debug-browser.ts` (debug headed), `.playwright-profile{,-debug}/` (gitignored).
|
||||
`src/shared/support/hooks.ts` (lifecycle Playwright), `world.ts` (champs `page`/`appFrame`), `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**. Permet de tester à la fois le modèle « chacun son wallet » (`@private-wallet`) et le modèle « wallet partagé entre navigateurs » (`@shared-wallet`).
|
||||
Capacité du harness `@data`/`@e2e` à piloter **plusieurs navigateurs isolés** dans un même scénario, sous **deux axes orthogonaux**. Sert à tester le stopgap wallet partagé (cf. concept `nextgraph-platform` → `brief_2026-06-15_shared-wallet-shim`) **et** le modèle cible (chacun son wallet).
|
||||
|
||||
## Les deux axes (orthogonaux)
|
||||
|
||||
@@ -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** (chacun son wallet) **et en shared** (wallet partagé), 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** (utile dès que NextGraph livrera la lecture cross-wallet — le modèle cible) **et en shared** (stopgap), et on compare les deux setups avec les **mêmes** steps de comportement.
|
||||
|
||||
## Modèle de wallet : phrasing + tags
|
||||
|
||||
@@ -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. 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é).
|
||||
Scénario `@humain` : valide le flux RÉEL de distribution du wallet **de bout en bout, via la vraie app**, pas l'injection de test (cf. concept `nextgraph-platform` → `decision_2026-06-17_assisted-wallet-import`). Un navigateur vierge ouvre l'app staging → l'`AccessGateScreen` propose le **fichier** + le **mot de passe** → on télécharge le fichier **depuis l'écran**, on vérifie que le mot de passe affiché **égale** celui du wallet → import sur `nextgraph.eu` « Import a Wallet File » → retour → clic « Entrer » → app connectée (`ConnexionScreen`).
|
||||
|
||||
- **Wallet e2e** : un fichier `.ngw` (`festipod-e2e-tests`, mot de passe = identifiant) placé **à la racine du worktree** ; `findE2eWalletFile()` le localise (`*.ngw`). Gitignoré → chaque environnement doit l'ajouter (sinon erreur claire).
|
||||
- `pool.ensureStagingApp()` (`hooks.ts`) — build **isolé** `bun run build.ts --outdir=dist-staging` (barrière d'accès **ON par défaut** ; mot de passe gravé + **fichier copié** en `/shared-wallet.ngw`, cf. `build.ts`), servi statiquement. Mémoïsé, lazy (seul `@humain` le paie).
|
||||
- **Bypass de la barrière pour `@e2e`** : le harness fait `browserContext.addInitScript` sur le **contexte persistant** pour poser `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ = true` (s'applique à l'iframe app avant ses scripts) → `@e2e` voit l'app directement, pas la barrière. Les contextes frais (`@humain`) n'y touchent pas → barrière ON. 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 (cf. `decision_2026-06-17`). L'ancien `LoginScreen` `/login` a été retiré.
|
||||
- `pool.importWalletViaFile(page, filePath, password)` — `nextgraph.eu/#/wallet/login` → `setInputFiles('input[type=file]')` (attendre que la SPA rende, sinon `EncryptionError`) → champ password → unlock.
|
||||
- `pool.completeBrokerLogin(page, appUrl, walletPassword?)` — moitié « login broker » extraite de `setupBrokerPage`. **Attente robuste** : après le redirect (multi-hop), attend l'iframe app OU le lien « Click here to login with your wallet », puis déverrouille avec le mot de passe. La session broker n'étant **pas** persistée entre lancements, ce login wallet est requis à chaque run (warm-up + `@e2e` + `@humain`).
|
||||
|
||||
@@ -68,3 +68,4 @@ 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.
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,8 +0,0 @@
|
||||
# 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,33 +1,35 @@
|
||||
---
|
||||
type: _overview
|
||||
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
|
||||
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
|
||||
triggers:
|
||||
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"]
|
||||
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"]
|
||||
---
|
||||
|
||||
# Data layer
|
||||
|
||||
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**.
|
||||
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é.
|
||||
|
||||
> **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]].
|
||||
> 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)
|
||||
|
||||
## Modèle & données
|
||||
|
||||
- [[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_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_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)
|
||||
|
||||
## 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`.
|
||||
> Sécurité/confidentialité (mono-store, confiance broker) : concept `app-security`.
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
---
|
||||
type: contract
|
||||
summary: The Refine data, auth and live providers @activitypods/refine-providers exposes to an application over a Solid Pod — what each operation does, what a record looks like, and what the package does not offer
|
||||
built_from: https://github.com/activitypods/refine-providers.git/src
|
||||
built_version: 66616484b96fb2227cd8cdf02321aacfb4811365
|
||||
built_at: 2026-10-02
|
||||
---
|
||||
|
||||
# contract_activitypods-providers — `@activitypods/refine-providers`
|
||||
|
||||
**This is a RECONSTRUCTION, not the provider's promise.** The package publishes no engagement and no change policy, so this document is an observation made once, at adoption time, and pinned to the commit named in `built_version`. Nothing here is committed to by its authors: any release may move any of it. Re-observe at each upgrade — the pin is what makes a difference visible.
|
||||
|
||||
## Scope
|
||||
|
||||
Three Refine providers over a Solid Pod running ActivityPods: a `dataProvider` (CRUD over LDP containers), an `authProvider` (Solid-OIDC sign-in plus the app-registration dance ActivityPods requires), and a `liveProvider` (push over the Solid Notifications Protocol). Resources are not addressed by a path the caller chooses: each configured resource is mapped at runtime to an LDP container discovered in a WebID's Solid TypeIndex.
|
||||
|
||||
Two Ant Design components also ship (`antd-auth-page`, `antd-background-checks`) and are outside this scope — `antd` and `@ant-design/icons` are optional peer dependencies, so an app with its own interface consumes the three providers without them. `react-router` is likewise an optional peer dependency.
|
||||
|
||||
Package `@activitypods/refine-providers`, version 0.1.5 at the pinned commit. Peer dependency `@refinedev/core ^5.0.12`. Full typed shape: the package's shipped declarations, `./types` (`dist/types.d.ts`) and `./index` (`dist/index.d.ts`).
|
||||
|
||||
## Surface
|
||||
|
||||
```ts
|
||||
dataProvider({ resources, authProvider, jsonContext? }): DataProvider
|
||||
authProvider({ clientId, redirectUri?, allowAnonymous?, appStatusCheckInterval? }): AuthProvider
|
||||
liveProvider({ resources, authProvider, jsonContext? }): LiveProvider
|
||||
|
||||
type ResourceConfig = { shapeTreeUri?: string; types?: string | string[] }
|
||||
type ResourcesConfig = Record<string, ResourceConfig> // keyed by Refine resource name
|
||||
type JsonContext = string | string[] | Record<string, any>
|
||||
type AuthSession = { token: string; webId: string }
|
||||
```
|
||||
|
||||
`AuthProvider` is Refine's `AuthProvider` plus four members: `getSession(): AuthSession | undefined` (synchronous), `handleCallback(): Promise<{ webId, redirect }>`, `registerApp(webId?): Promise<string | undefined>`, `getAppStatus(): Promise<AppStatus>`. Its `login`/`register` take `{ issuer, redirect?, isSignup? }` — the Pod provider's base URL is a caller-supplied parameter, so the app must let the user pick one.
|
||||
|
||||
`DataProvider` implements `getList`, `getOne`, `getMany`, `create`, `update`, `deleteOne` and `getApiUrl`. Nothing else.
|
||||
|
||||
Utility functions are exported from the package root as well (notably the TypeIndex discovery and container resolution used internally); they are not part of what this document engages.
|
||||
|
||||
## Guarantees
|
||||
|
||||
Observed at the pinned commit.
|
||||
|
||||
**A record is a JSON-LD resource with an `id`.** `id` is the resource's own URI — `id` or `@id` of the fetched document, whichever is present; a resource carrying neither raises. The record also carries `@context` (the container's, on a listed record) and every other predicate verbatim. There is exactly ONE identifier: `getOne`, `update` and `deleteOne` take that same `id` and use it directly as the URL they fetch.
|
||||
|
||||
**`getList`** resolves the resource's container, fetches it whole, and maps `ldp:contains`. Refine's filters and sorters are then applied **in memory**, and pagination slices the result; `total` is the count before slicing. The filter set is broad (equality, `in`, substring, prefix/suffix, ordering, `between`, null checks, and `and`/`or` nesting), string comparison being case-insensitive unless the case-sensitive variant is used, and a filter field may be a dotted path.
|
||||
|
||||
**`create`** POSTs `{ "@context": jsonContext, "@type": <resolved types>, ...variables }` to the container, requires a `Location` response header, and returns the record re-fetched from it. The `@type` is the resource's configured `types` expanded through `jsonContext`, or, when only `shapeTreeUri` is configured, the `sh:targetClass` of the shape the shape tree points at.
|
||||
|
||||
**`update` merges — a partial write is safe.** It fetches the current resource, drops its `id` and `@context`, and PUTs `{ "@context": jsonContext, ...current, ...variables }`. So values the caller omits survive. The merge is **shallow**: a nested object in `variables` replaces its counterpart whole. It returns the record re-fetched after the write.
|
||||
|
||||
**`deleteOne`** DELETEs the resource and returns `{ id }` only.
|
||||
|
||||
**A configured resource's container is discovered, not configured.** Resolution reads the WebID document, then its `solid:publicTypeIndex`, then best-effort its `pim:preferencesFile` → `solid:privateTypeIndex`, and takes the first type registration whose `solid:forClass` matches the resource's resolved types and which carries a `solid:instanceContainer`. No match raises, naming the WebID and the types. A `shapeTreeUri` is fetched over plain HTTP, as is the shape it points at — so a shape tree the caller hosts itself works, provided it is reachable and carries `st:shape` → `sh:targetClass`.
|
||||
|
||||
**`jsonContext`** is sent as `@context` on every create and update, and is what expands configured CURIE types. It defaults to the ActivityStreams 2 context; an application with its own vocabulary passes its own.
|
||||
|
||||
**`liveProvider` pushes — the caller never polls.** It opens a `notify:WebSocketChannel2023` channel per topic, discovering the subscription endpoint from the Pod origin's `.well-known/solid`. Watching specific records subscribes to each record and reports `Update` → `updated`, `Delete`/`Remove` → `deleted`. Watching a list subscribes to the **container** and reports `Add` → `created`, `Remove` → `deleted`. Unsubscribing closes the socket and deletes the channel best-effort. A resource absent from `resources` yields a no-op unsubscribe rather than an error.
|
||||
|
||||
**Auth is Solid-OIDC with PKCE.** `login` redirects to the issuer's authorization endpoint with scope `openid webid offline_access`; `handleCallback`, called from the redirect route, completes the code grant and stores the ID token. The session's `webId` is the token's `webid` claim, and an expired token reads as no session. `logout` clears local state. `check` reports unauthenticated with `redirectTo: "/login"` unless `allowAnonymous` is set. `getIdentity` returns `{ id: webId, name, avatar }`, name and avatar read from the WebID document and the profile it links — ActivityPods' own profile vocabulary, unrelated to any vocabulary the app defines for its own user records. `registerApp` returns the AppRegistration URI when the app is registered and its grants current, and otherwise redirects the browser to the user's authorization agent and resolves `undefined`.
|
||||
|
||||
**Failures throw, and carry the HTTP status.** Every Pod call that returns non-2xx raises an `Error` with `status` and `body` attached, its message taken from the response body's error message when there is one. `onError` maps a 401 to logout.
|
||||
|
||||
## Non-guarantees
|
||||
|
||||
**Every read and write is scoped to the logged-in user's own Pod.** Container resolution always runs against the session's own WebID — in `getList`, in `create`, and in a list subscription alike. There is no parameter, filter or option through which an operation reaches another user's resources: `getList` returns what the current user holds, and nothing else. (`getOne`/`getMany` fetch a URI directly and so are not bound this way, but they require the caller to already hold that URI.)
|
||||
|
||||
**No server-side querying.** Filtering, sorting and pagination are applied after the whole container has been fetched, so cost grows with the container, not with the page.
|
||||
|
||||
**`getManyReference` is not implemented**, despite the data provider's own documentation mentioning it. Neither are `createMany`, `updateMany` and `deleteMany`.
|
||||
|
||||
**`getMany` drops what it cannot fetch.** Failed fetches are filtered out silently, so the returned array may be shorter than `ids` with no indication of which are missing.
|
||||
|
||||
**Caches are module-level and never invalidated** — shape trees by URI, type registrations by WebID, subscription endpoints by origin. A container that becomes available after a first lookup (the user granting access, for instance) is not seen until the page reloads, which is what the registration flow expects.
|
||||
|
||||
**`authProvider` installs process-global timers it never removes.** Unless `appStatusCheckInterval: false` is passed, construction registers a `setInterval` and a `visibilitychange` listener, with no teardown; constructing the provider more than once per page accumulates them.
|
||||
|
||||
**One session per page.** The token lives at a fixed `localStorage` key, so two concurrent sessions cannot coexist.
|
||||
|
||||
**A list subscription does not report field changes.** A container notifies about membership only, so a member's own predicates changing is invisible to a list; the record has to be watched directly.
|
||||
|
||||
**`types` wins over `shapeTreeUri` when both are set** — the opposite of what the configuration type's own documentation states. Configure exactly one. Likewise, when several type registrations match a resource's types, the first encountered wins, with no stated ordering.
|
||||
|
||||
**Unregistered apps read and write nothing.** An ActivityPods app must hold an AppRegistration before any data operation succeeds, and keeping it current is the app's business, through `registerApp` and `getAppStatus`.
|
||||
|
||||
## Change policy
|
||||
|
||||
None is published. The package carries no engagement document, no stated versioning commitment and, at the pinned commit, no git tags — its released versions are visible only as `package.json` version bumps. Treat every upgrade as potentially breaking: re-read the surface against the new commit and re-stamp this document. Divergences found between this reconstruction and observed behaviour are defects in this document, to be fixed here.
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
type: usage
|
||||
summary: What Festipod consumes from @activitypods/refine-providers as one of its interchangeable Refine backends, and the conditions its own vocabulary and confidentiality model put on that backend
|
||||
against: 66616484b96fb2227cd8cdf02321aacfb4811365
|
||||
---
|
||||
|
||||
# usage_festipod — Festipod on `@activitypods/refine-providers`
|
||||
|
||||
Festipod is a mobile-first web application: users create **meeting points** grafted onto public events, and sign up to them. Its entire persistence goes through Refine, behind a **backend seam** that exists so the app can support several Refine backends and let the user switch between them at runtime. This package is one of the two backends targeted; the other is a Refine provider over NextGraph plus its polyfill. The seam's own contract — what a backend must answer before it can be added — lives in code, in `src/shared/refine/providers/types.ts`.
|
||||
|
||||
Nothing is wired yet, so the list below is **declared, not measured**: it is what Festipod will call, derived from the seam's requirements and from the observation of the package pinned beside this declaration. `against:` names that observation's commit. The package publishes no engagement, so there is nothing to pull and nothing that can drift — what is recorded here is read against our own reconstruction, and re-read when it is re-stamped.
|
||||
|
||||
## Consumed surface
|
||||
|
||||
### Provider construction
|
||||
|
||||
- `authProvider({ clientId, redirectUri, appStatusCheckInterval: false })` — one call site, per backend selection. `appStatusCheckInterval` is disabled deliberately, see friction 1; Festipod drives the check itself.
|
||||
- `dataProvider({ authProvider, resources, jsonContext })` — one call site, handed the `authProvider` above.
|
||||
- `liveProvider({ authProvider, resources, jsonContext })` — same, so Refine can subscribe behind every query.
|
||||
|
||||
All three are built together, **per backend instance**, inside the seam's `build()`.
|
||||
|
||||
### Auth members
|
||||
|
||||
`login({ issuer, redirect })` and `register(...)` — Festipod supplies its own Pod-provider picker. `handleCallback()` from its own callback route. `logout()`. `check()`. `getSession()` for the `webId` and the token. `registerApp()` and `getAppStatus()`, the second one on Festipod's own schedule since the built-in interval is off.
|
||||
|
||||
### Resources and operations
|
||||
|
||||
Four resources, one per persisted entity: **events**, **meeting points**, **participations** and **user profiles**. Each is configured with `types` naming Festipod's own class URIs, and `jsonContext` carries the context that expands them.
|
||||
|
||||
Through Refine's hooks only, never the provider directly: `useList` → `getList`, `useCreate` → `create`, `useUpdate` → `update`, `useDelete` → `deleteOne`. `getOne` is reached by Refine's cache plumbing rather than an explicit call. The app mounts Refine with `liveMode: 'auto'` and `mutationMode: 'optimistic'`.
|
||||
|
||||
### Record handling
|
||||
|
||||
A record's `id` — the resource's URI — is the **only** identifier Festipod uses: the seam's `recordKey` and `domainId` both return it. `update` merging the fields it is given means the seam declares this backend as patching, and sends only what changed.
|
||||
|
||||
### Not consumed
|
||||
|
||||
- `shapeTreeUri` — exactly one of the two resource identifiers must be configured, and `types` names Festipod's classes directly, without a shape tree to host and keep reachable.
|
||||
- `getIdentity` — the signed-in identity and a Festipod user profile are two unrelated things in this app; the profile is read as an ordinary record, and only `getSession`'s `webId` is needed.
|
||||
- `getMany`, `getApiUrl`, `allowAnonymous`, and both Ant Design entry points — Festipod has its own interface.
|
||||
|
||||
## Constraints
|
||||
|
||||
What must keep holding for this backend to serve the app.
|
||||
|
||||
1. **Festipod's own vocabulary, not ActivityStreams.** The four entities are Festipod domain types defined by SHEX shapes this repo holds; none is an ActivityStreams type. A caller-supplied `jsonContext` and caller-supplied `types` are what make that possible, and both must stay caller-supplied.
|
||||
2. **The identifier a record is addressed by must stay the identifier other records reference.** The seam carries the two separately because they differ on the other targeted backend; here they coincide, and the app relies on that. Splitting them later would break every cross-entity reference silently.
|
||||
3. **A partial update must stay partial.** The app sends only changed fields. A move to replacing the resource would drop every field omitted, with no error.
|
||||
4. **Every participation must be readable by every user.** The participant count is the number of participations of an event, counted with no filtering. What is confidential is the **link between a participation and a person** — only a connection can read the profile a participation points at and recognise who it is — never the participation record itself. See need 1: this is the constraint the package does not currently let the app satisfy.
|
||||
5. **The count is never a stored, incremented field.** Reading a counter, adding one and writing it back derives each write from a read of the previous one and races itself through any data provider. This is measured, not theoretical, and it is why the app derives the count from the participation list at read time.
|
||||
6. **Changes must be pushed, not polled.** Neither the app nor its BDD suites has a polling loop, and adding one is ruled out by project doctrine. `liveMode: 'auto'` is mounted on that basis.
|
||||
7. **No process-global state that outlives a remount.** Two backends coexist in one page and the user switches between them at runtime; switching remounts Refine to discard the cache of the backend being left. Building this backend's providers must have no side effect that accumulates across switches — see friction 1, which is where this currently bites.
|
||||
8. **Each entity carries a declared visibility** — public, protected or private, per the product's confidentiality model — and it is the backend that honours it. Refine has no notion of it, so the intent is declared at the seam and enforcement is the backend's.
|
||||
|
||||
## Frictions
|
||||
|
||||
1. **`authProvider` installs a timer and a listener it never removes.** Construction registers a `setInterval` and a `visibilitychange` listener with no teardown. Festipod builds providers once per backend selection, so every switch to this backend would accumulate another pair — directly against constraint 7. Workaround carried: pass `appStatusCheckInterval: false` and call `getAppStatus()` on the app's own schedule. That works, but it means the package's own staleness handling is unusable here, and the only way to opt out is to opt out entirely.
|
||||
2. **`getList` fetches the whole container on every call.** Filtering, sorting and pagination run in memory afterwards, so the cost of listing an event's participations grows with the user's total number of participations rather than with the event. No workaround; it is why a filter on a `getList` buys nothing beyond convenience.
|
||||
3. **A list subscription reports membership only.** A container channel delivers `Add`/`Remove`, not a member's own fields changing, so a list of events does not refresh when an event's title, place or time is edited. Watching each record instead would open one channel per row. The event list is exactly the screen where this is visible.
|
||||
4. **`types` takes precedence over `shapeTreeUri`, the opposite of what the configuration type documents.** The declared behaviour is that `types` is "only used as a fallback when `shapeTreeUri` is not set"; a resource setting both is in fact resolved from `types`. Establishing which one actually wins cost a read of the resolution path. Configuring exactly one avoids it, but the documentation should say which wins.
|
||||
|
||||
## Needs
|
||||
|
||||
1. **A read that is not scoped to the logged-in user's own Pod.** Every container resolution — in `getList`, in `create`, and in a list subscription — runs against the session's own WebID. So `getList('events')` returns only the events the current user created, and `getList('participations')` only their own participations.
|
||||
|
||||
This blocks the product, not a detail of it. Festipod exists to let a user **discover other people's public events** and see **how many people have signed up** to a meeting point. Through this provider the app can show neither: no other user's event is reachable, and the participant count of constraint 4 cannot be computed from participations it cannot read.
|
||||
|
||||
The request is modest in shape, because the capability already sits one layer down: the package's own TypeIndex discovery takes a WebID as a parameter, and notes that the public TypeIndex is readable without any special grant. What is missing is a way to reach it **through the data provider** — a way to scope a `getList` (and the matching list subscription) to a given WebID, or to a set of them. Festipod has no interest in reimplementing container resolution beside the provider; that would put the app back in the business the provider exists to hold.
|
||||
|
||||
2. **Releases that can be pinned, and a statement of what changes between them.** The package carries no git tags, so the only thing this repo can pin is a commit on a moving branch, and the only way to learn what an upgrade changed is to re-read the source. Tagged releases plus a note of what moved in each — in whatever form suits the project — would let a consumer upgrade deliberately instead of re-observing from scratch every time.
|
||||
@@ -1,186 +0,0 @@
|
||||
---
|
||||
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** (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.
|
||||
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.
|
||||
|
||||
## Pour corriger (si on veut les persister)
|
||||
|
||||
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é**.
|
||||
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é**.
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
---
|
||||
type: caveat
|
||||
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
|
||||
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
|
||||
---
|
||||
|
||||
# Caveat : la désinscription doit être autoritative
|
||||
# Caveat : suppression de Participation via `ngSet.delete()`
|
||||
|
||||
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.
|
||||
**É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.
|
||||
|
||||
## Le piège
|
||||
## Histoire (important)
|
||||
|
||||
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.
|
||||
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é.
|
||||
|
||||
**À 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`).
|
||||
## 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.
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
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
@@ -0,0 +1,35 @@
|
||||
---
|
||||
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]].
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
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]].
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
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 — 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
|
||||
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
|
||||
---
|
||||
|
||||
# Internals & pièges de `FestipodDataContext`
|
||||
@@ -10,71 +10,20 @@ Comportements non évidents de `src/shared/context/FestipodDataContext.tsx` à c
|
||||
|
||||
## Résolution du `currentUser` (mode NG)
|
||||
|
||||
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é.
|
||||
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.
|
||||
|
||||
## Auto-seed de dev
|
||||
|
||||
**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]]).
|
||||
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.
|
||||
|
||||
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`.
|
||||
## `participantCount` muté en place
|
||||
|
||||
## `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.
|
||||
`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.
|
||||
|
||||
## Mutations no-op en mode local
|
||||
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
---
|
||||
type: knowledge
|
||||
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()
|
||||
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()
|
||||
---
|
||||
|
||||
# Modes de données & contextes
|
||||
|
||||
L'app a **deux modes**, tous deux consommés via le hook `useFestipodData()` :
|
||||
|
||||
1. **Connected** — shapes ORM du SDK `@ng-eventually/client` (P2P, chiffré, local-first)
|
||||
1. **Connected** — shapes ORM NextGraph (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 (l'utilisateur courant et son accès aux stores par scope).
|
||||
- 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).
|
||||
|
||||
## FestipodDataContext (`src/shared/context/FestipodDataContext.tsx`)
|
||||
|
||||
- Enveloppe les shapes via `useShapeWithDefaults()`.
|
||||
- Expose `useFestipodData()` (consommé par tous les écrans) + CRUD (`createEvent`, `updateEvent`, `joinEvent`, `leaveEvent`, etc.).
|
||||
- **Provider selon le statut de connexion** :
|
||||
- Expose `useFestipodData()` (consommé par tous les écrans) + CRUD (`createEvent`, `updateEvent`, etc.).
|
||||
- **Provider selon le statut NG** :
|
||||
- `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)
|
||||
|
||||
> 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]]).
|
||||
> 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.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
type: knowledge
|
||||
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
|
||||
summary: Types de données Fp* (Event, UserProfile, Participation persistés NextGraph ; MeetingPoint et Friendship encore local-only)
|
||||
---
|
||||
|
||||
# Entités de données
|
||||
@@ -10,15 +9,12 @@ last_checked: 2026-07-03
|
||||
|
||||
| Type | Persistance | Champs clés |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `FpFriendshipData` | **local-only** | userId + friendId |
|
||||
|
||||
`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]]).
|
||||
`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`.
|
||||
|
||||
> 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,34 +1,26 @@
|
||||
---
|
||||
type: knowledge
|
||||
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
|
||||
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
|
||||
---
|
||||
|
||||
# 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.
|
||||
# Stack NextGraph (côté app)
|
||||
|
||||
```
|
||||
@ng-eventually/client # LE SDK de données de l'app (ORM réactif useShape, docs, scopes, inbox)
|
||||
@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
|
||||
```
|
||||
|
||||
## Frontière SDK (règle d'or)
|
||||
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).
|
||||
|
||||
- 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 :
|
||||
## Shapes SHEX
|
||||
|
||||
`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 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`.
|
||||
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`.
|
||||
|
||||
> **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]]).
|
||||
> 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]].
|
||||
|
||||
@@ -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 en mode connected — déclenché uniquement par action explicite de l'utilisateur (« Charger données de test »).
|
||||
> `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`.
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
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]].
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
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]].
|
||||
@@ -1,112 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
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]].
|
||||
@@ -1,10 +0,0 @@
|
||||
# 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, ses concepts métier, et les périmètres de confidentialité (public/protected/private) par entité
|
||||
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
|
||||
triggers:
|
||||
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é]
|
||||
keywords: [point de rencontre, rencontre, greffe, greffer, événement, déclarant, hôte, inscrit, inscription, communauté, connexion, festival, déduplication]
|
||||
paths: ["src/modules/*/features/**"]
|
||||
---
|
||||
|
||||
@@ -16,14 +16,13 @@ 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 & confidentialité
|
||||
## Périmètre & sécurité
|
||||
|
||||
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]]).
|
||||
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.
|
||||
|
||||
## 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. **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. |
|
||||
| **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. |
|
||||
| **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. |
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
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
|
||||
|
||||
> 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.
|
||||
> 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.
|
||||
|
||||
## É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** : 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]]).
|
||||
- **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]].
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
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)
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
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/)
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
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`
|
||||
@@ -0,0 +1,183 @@
|
||||
---
|
||||
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`)
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
type: decision
|
||||
summary: Distribution du wallet partagé par IMPORT ASSISTÉ PAR FICHIER (.ngw) — l'auto-import zéro-touche étant impossible (broker hébergé), Festipod sert le FICHIER du wallet (téléchargement) + le mot de passe et guide un import unique sur nextgraph.eu « Import a Wallet File » depuis l'AccessGateScreen. Le TextCode a d'abord été retenu puis CORRIGÉ (transfert temporaire 5 min, inutilisable à embarquer). Barrière d'accès ON par défaut (ACCESS_GATE_DISABLED=1 pour bypass tests/dev), ancien LoginScreen retiré. Alternatives écartées (auto-import app, lien magique, self-host) ; provisioning de test (storageState) distinct
|
||||
last_updated: 2026-06-29
|
||||
---
|
||||
|
||||
# Décision 2026-06-17 — Distribution du wallet partagé par import assisté
|
||||
|
||||
Comment un utilisateur récupère le wallet partagé sur un nouveau navigateur, dans le stopgap [[brief_2026-06-15_shared-wallet-shim]]. Frozen.
|
||||
|
||||
## Contrainte de départ
|
||||
|
||||
L'auto-import zéro-touche par l'app est **impossible** avec le broker hébergé (fait vérifié : [[knowledge_broker-import-constraint]]). Le wallet doit préexister dans le navigateur, importé sur `nextgraph.eu` (cross-origin, non pilotable par Festipod). La question n'est donc pas « comment auto-importer » mais « comment **minimiser la friction de récupération** » — le problème initial étant que l'utilisateur devait d'abord *se procurer* le wallet.
|
||||
|
||||
## Décision : import assisté par FICHIER
|
||||
|
||||
Festipod **sert le FICHIER `.ngw`** du wallet partagé (téléchargement) et **affiche le mot de passe** dans l'`AccessGateScreen` (la barrière d'accès, cf. [[decision_2026-06-15_shared-wallet-login-flow]]), avec un guide en 3 étapes :
|
||||
|
||||
1. Télécharger le fichier du wallet partagé (bouton de téléchargement).
|
||||
2. Ouvrir `https://nextgraph.eu/#/wallet/login` (nouvel onglet) → « Import a Wallet File » → choisir le fichier → saisir le mot de passe affiché.
|
||||
3. Revenir et cliquer « Entrer » (redirect broker → demande de déverrouiller le wallet → mot de passe → app).
|
||||
|
||||
Festipod **fournit** ainsi le wallet (fin de la friction de récupération) ; l'**import lui-même reste un geste manuel unique par device**, incompressible avec le broker hébergé. Posture **zéro-sécurité, credential partagé** assumée (cf. brief) → embarquer le fichier + le mot de passe est cohérent.
|
||||
|
||||
> **Correction 2026-06-25 (le TextCode était une fausse piste)** : la 1ʳᵉ version embarquait le **TextCode**. Or le TextCode est un **transfert temporaire** (5 min, deux devices en ligne, usage unique — cf. [[knowledge_broker-import-constraint]]), donc **inutilisable embarqué** (un testeur arrivant plus tard aurait un code mort). Le test e2e passait quand même car il génère+importe le code dans la foulée. La primitive correcte est le **FICHIER statique**.
|
||||
|
||||
## Pourquoi (alternatives écartées)
|
||||
|
||||
- **(a) Auto-import embarqué par l'app** — *impossible* : le broker ne laisse aucune fenêtre d'exécution avant son gate wallet ([[knowledge_broker-import-constraint]]).
|
||||
- **(b) TextCode embarqué** — *cassé* : transfert temporaire 5 min, non réutilisable (cf. correction ci-dessus).
|
||||
- **(c) Lien magique pré-rempli** vers le broker — pas de route d'import par URL côté broker hébergé.
|
||||
- **(d) Self-host / fork du ng-app** — seule voie vers le **vrai zéro-touche**, mais lourde ; track séparé ([[brief_2026-05-21_fork-nextgraph-inbox]]). Non retenu pour le stopgap.
|
||||
|
||||
## Conséquences côté code (Festipod)
|
||||
|
||||
- `src/modules/auth/sharedWallet.ts` — `SHARED_WALLET_PASSWORD` lu depuis un **global gravé au build** `globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__` (`define` dans `build.ts`, depuis `FESTIPOD_SHARED_WALLET_PASSWORD`) ; `SHARED_WALLET_FILE_URL = /shared-wallet.ngw` ; `hasSharedWallet()` (mot de passe non vide) pilote l'affichage. Vide par défaut → la barrière retombe sur le flux simple.
|
||||
- `build.ts` — copie le fichier (`FESTIPOD_SHARED_WALLET_FILE`) dans le bundle en `/shared-wallet.ngw` + grave le mot de passe.
|
||||
- `src/modules/auth/screens/AccessGateScreen.tsx` — section assistée (téléchargement du fichier + mot de passe + guide) affichée si `hasSharedWallet()`.
|
||||
- `src/app/AuthGate.tsx` — la barrière est **ON PAR DÉFAUT** (« Festipod ne fonctionne jamais sans NextGraph »). **Révision 2026-06-29** : drapeau **inversé** — la barrière n'est désactivée que si `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true` (gravé par `build.ts` depuis `ACCESS_GATE_DISABLED=1`, ou injecté par le harness via `context.addInitScript` pour `@e2e`). Absent → barrière ON. (Remplace l'ancien `FESTIPOD_STAGING`/`__FESTIPOD_REQUIRE_NG__`, qui était OFF par défaut.)
|
||||
- **Ancien `LoginScreen` retiré** (`/login`, bouton « Se connecter avec NextGraph » + login démo email/mdp) : obsolète puisque l'`AccessGateScreen` précède le routeur. Évite le dead-end « connecter sans wallet ». Route `/login` supprimée.
|
||||
- **Atterrissage post-login** : après le choix du pseudo, `ConnexionScreen` navigue vers `/home` (la redirection vers l'accueil que faisait l'ancien `LoginScreen` avait disparu avec lui → on retombait sur l'onboarding `WelcomeScreen` à `/`). Filet pour les retours : `WelcomeScreen` redirige vers `/home` si déjà connecté. L'e2e `@humain` va désormais jusqu'à l'accueil pour couvrir ça.
|
||||
- L'admin exporte le fichier une fois (nextgraph.eu : menu wallet → Download/Export Wallet File) et l'injecte au build (`FESTIPOD_SHARED_WALLET_FILE=<chemin.ngw> FESTIPOD_SHARED_WALLET_PASSWORD=<mdp> bun run build` ; barrière ON par défaut, pas de drapeau à poser).
|
||||
|
||||
## À distinguer du provisioning de test
|
||||
|
||||
Le harness multi-navigateur provisionne le wallet partagé par **injection storageState** (niveau navigateur, sans la contrainte broker) — concept `bdd-testing` → `knowledge_multibrowser-harness`. Cela **prouve** « wallet partagé → app connectée » mais **court-circuite l'import**. Le mécanisme RÉEL est validé **de bout en bout par la vraie app** (e2e `@humain`) : un navigateur vierge ouvre l'app staging, **télécharge le fichier proposé par l'`AccessGateScreen`** (et vérifie que le mot de passe affiché est celui du wallet), l'importe via le vrai flux `nextgraph.eu` « Import a Wallet File », revient, clique « Entrer » et atteint l'app connectée. Les deux ne se confondent pas.
|
||||
|
||||
## See Also
|
||||
|
||||
- [[knowledge_broker-import-constraint]] — le fait technique qui force cette décision
|
||||
- [[brief_2026-06-15_shared-wallet-shim]] — le stopgap
|
||||
- [[decision_2026-06-15_shared-wallet-login-flow]] — le flux d'accès dont l'AccessGateScreen est la barrière
|
||||
- [[knowledge_stores-permissions]] — wallet, capabilities, limites SDK
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Le broker NextGraph hébergé n'autorise pas l'auto-import d'un wallet par une web-app tierce — init() top-level redirige, toute méthode ng.* exige d'être déjà loggé dans l'iframe, et le broker renvoie un device sans wallet vers nextgraph.eu (cross-origin, non pilotable). Un wallet doit préexister dans le navigateur. Des 4 méthodes d'import nextgraph.eu, seul le FICHIER .ngw est statique/réutilisable ; le TextCode/QR sont des transferts temporaires (5 min, deux devices en ligne) inutilisables à embarquer.
|
||||
last_checked: 2026-06-25
|
||||
---
|
||||
|
||||
# Contrainte : pas d'auto-import de wallet par une web-app tierce
|
||||
|
||||
Fait technique **vérifié empiriquement (2026-06-17)** : avec le broker NextGraph **hébergé** (`nextgraph.net`), une web-app tierce (Festipod) **ne peut pas** provisionner/importer un wallet par programme. Le wallet doit **préexister** dans le navigateur avant que le redirect d'authentification puisse réussir.
|
||||
|
||||
## Pourquoi (mécanisme du proxy `@ng-org/web`)
|
||||
|
||||
Lecture de `ngweb.js` (dist de `@ng-org/web`) :
|
||||
|
||||
- **`init()` top-level REDIRIGE** : si `window.self === window.top`, il fait `window.location.href = https://nextgraph.net/redir/#/?o=<url>`. Le code de l'app ne tourne plus.
|
||||
- **Toute méthode `ng.*` est relayée** par `parent.postMessage` vers `nextgraph.net`, et le handler **lève `"you must call init() first"` tant que la session n'est pas établie** (garde interne `d !== false`). Cela inclut `wallet_import_from_code`, `add_in_memory_wallet`, `session_in_memory_start`.
|
||||
- L'app tierce ne s'exécute **dans l'iframe qu'APRÈS** que le broker a déjà ouvert un wallet et établi la session. **Il n'existe aucune fenêtre** où notre code tourne *avant* le gate wallet du broker → **rien à quoi accrocher un auto-import**.
|
||||
|
||||
> Vérifier : `node_modules/@ng-org/web/dist/ngweb.js` — fonction `init` (redirect / postMessage selon top-vs-iframe) et le handler `apply` du Proxy `ng`. Cohérent avec [[knowledge_integration-model]] (le verifier tourne dans l'iframe du ng-app, le proxy ne fait que relayer).
|
||||
|
||||
## Ce que montre le broker (probe Playwright, navigateur frais sans wallet)
|
||||
|
||||
Sur `https://nextgraph.net/redir/#/?o=...`, le broker affiche **littéralement** :
|
||||
|
||||
> « We could not find a wallet in your browser. For now, creating a new wallet while a Web App is authenticating, is not implemented. Please create or import your wallet in a new tab. »
|
||||
|
||||
…et renvoie vers `https://nextgraph.eu/` (app wallet **standalone**). L'import standalone (`/#/wallet/login`) propose **4 méthodes**, mais elles ne sont **PAS équivalentes** :
|
||||
|
||||
| Méthode | Nature | Embarquable / réutilisable ? |
|
||||
|---|---|---|
|
||||
| **Import a Wallet File** (`.ngw`) | **fichier statique** (export portable, hors-ligne) | ✅ **oui** — statique, sans expiration, sans device source |
|
||||
| Import with TextCode | **transfert temporaire** device↔device via leurs serveurs : **5 min**, **les deux appareils en ligne**, usage unique | ❌ non |
|
||||
| Import with QR-Code | transfert live (même famille que TextCode) | ❌ non |
|
||||
| Import via Username | récupération liée à un compte | à étudier |
|
||||
|
||||
> **Piège vérifié (2026-06-25)** : le **TextCode n'est PAS un export statique** — l'écran nextgraph.eu le dit (« temporarily stored on our servers for up to 5 minutes », « both devices need to be online »). L'embarquer dans l'app est **inutilisable** : il expire / est à usage unique. Un test automatisé qui génère ET importe le code dans la foulée **passe** (live), masquant le problème — d'où une fausse piste initiale. **La primitive correcte pour un wallet partagé embarqué = le FICHIER `.ngw`.**
|
||||
|
||||
## Logique du redirect nextgraph.net (broker discovery) + ORDRE critique
|
||||
|
||||
`init()` redirige vers `nextgraph.net/redir`. Là, nextgraph.net regarde dans le **localStorage** s'il connaît un **broker** (le domaine du broker, posé lors d'un import/login wallet antérieur) :
|
||||
|
||||
- **trouvé** → redirige vers le broker (`nextgraph.eu/auth/#/wallet/login` → « Click here to login with your wallet <nom> » → mot de passe) → app. **Cas qui marche.**
|
||||
- **absent** → message « We could not find a wallet in your browser… Please create or import your wallet in a new tab by clicking here ». Le lien ouvre `nextgraph.eu` (page **Welcome**) → **Login** → **Import a Wallet File** → on rejoint l'import.
|
||||
|
||||
> **Ordre critique** : il faut **importer le wallet AVANT** d'atteindre nextgraph.net. Le guide de l'`AccessGateScreen` impose cet ordre (télécharger + importer, *puis* « Entrer »).
|
||||
|
||||
**Deux routes vers l'import** : **A** = lien direct `nextgraph.eu/#/wallet/login` (celui de Festipod + des tests — saute la page Welcome/Login) ; **B** = fallback nextgraph.net « no wallet → clicking here » → Welcome → **Login** → Import (si on atteint nextgraph.net sans wallet).
|
||||
|
||||
**Pourquoi l'e2e `@humain` ne bute pas sur le « no wallet »** : il importe le fichier (route A) **avant** le « Entrer », donc nextgraph.net trouve déjà le broker. Le test **ne couvre pas** la route B (message « no wallet » + page Welcome/Login de nextgraph.eu) — ce sont des comportements nextgraph, pas Festipod, mais un humain cliquant « Entrer » en premier y tombe.
|
||||
|
||||
> **Atténuation côté Festipod (2026-06-29)** : la barrière `AccessGateScreen` (qui **fournit** le fichier wallet + le mot de passe + le guide) est désormais l'écran d'entrée **par défaut** (cf. [[decision_2026-06-17_assisted-wallet-import]]) — l'ancien `LoginScreen` « Se connecter avec NextGraph » (qui menait directement au redirect sans fournir le wallet) a été retiré. Un utilisateur ne peut donc plus atteindre nextgraph.net **sans** que Festipod lui ait d'abord proposé le wallet. La route B reste possible s'il clique « Entrer » avant d'importer, mais il a le wallet sous les yeux pour le faire.
|
||||
|
||||
> **Contrainte UX irréductible + dé-piégeage** : « Entrer » fait une **redirection pleine-page**. Si on clique AVANT d'importer → message « no wallet » → l'import se fait dans un **autre onglet** sans retour auto vers Festipod (le broker hébergé ne sait pas revenir). Il faut donc **importer d'abord, PUIS Entrer** (guide de l'`AccessGateScreen`, ordre du `@humain`). Piège corrigé (2026-06-29) : au retour (back) après un « Entrer » prématuré, la page standalone était restaurée du bfcache avec l'état figé sur `connecting` → bouton « Accès en cours » bloqué ; `NextGraphContext` écoute `pageshow.persisted` (sans session) et réinitialise sur `disconnected` pour permettre de réessayer.
|
||||
|
||||
## Pistes d'élimination du va-et-vient — testées, ÉCARTÉES (2026-06-30)
|
||||
|
||||
Deux idées pour éviter le 2ᵉ onglet ; les deux **infaisables** sans fork :
|
||||
|
||||
1. **Embarquer `nextgraph.eu` en iframe** dans Festipod pour guider l'import sur le même écran. nextgraph.eu **n'a pas** d'en-tête anti-framing (embed possible, import OK dans l'iframe), **MAIS** le wallet importé atterrit dans le stockage **partitionné** `(top: festipod, frame: nextgraph.eu)` — invisible du login top-level → « no wallet ». **Confirmé en vrai navigateur (Chrome).** ⚠️ **Le Chromium de Playwright N'applique PAS ce partitioning → faux positif** : un probe Playwright montrait le wallet « transmis », alors que le vrai Chrome bloque. Ne jamais valider une question d'**isolation de stockage** via Playwright ; tester en vrai navigateur.
|
||||
2. **Déclencher l'écriture cross-origin sur nextgraph.net** : le mécanisme existe (pont `/auth` iframe+postMessage qui écrit `ng_bootstrap` sur nextgraph.net pendant le login nextgraph.eu) mais ce sont **les pages de NextGraph** qui l'orchestrent ; un tiers ne peut pas écrire le localStorage d'une autre origine (same-origin policy), et ça ne couvrirait que la découverte du broker, pas le wallet.
|
||||
|
||||
→ Seule élimination réelle = self-host/fork du ng-app ([[brief_2026-05-21_fork-nextgraph-inbox]]). Le flow stopgap reste l'import **en onglet séparé** (top-level `nextgraph.eu`, première-partie → pas de partitioning → fonctionne).
|
||||
|
||||
## Conséquences
|
||||
|
||||
- Le wallet doit être importé **sur `nextgraph.eu` (cross-origin)** — Festipod ne peut **pas** piloter ce flux ni pré-remplir l'import (pas de route d'import par URL côté broker hébergé).
|
||||
- Le **vrai zéro-touche** exigerait de **self-host/forker le ng-app** (territoire de [[brief_2026-05-21_fork-nextgraph-inbox]]).
|
||||
- Distribution produit retenue, vu cette contrainte : **import assisté par FICHIER** — Festipod fournit le `.ngw` (téléchargement) + le mot de passe et guide l'import — voir [[decision_2026-06-17_assisted-wallet-import]].
|
||||
- À ne pas confondre avec le **provisioning de TEST** (injection storageState dans le harness), qui n'a pas cette contrainte car il agit au niveau navigateur — concept `bdd-testing` → `knowledge_multibrowser-harness`.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
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).
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
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,19 +14,7 @@ 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*`, `/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
|
||||
```
|
||||
- `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`.
|
||||
|
||||
## Le harness de test est buildé à part
|
||||
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
---
|
||||
type: knowledge
|
||||
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
|
||||
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
|
||||
---
|
||||
|
||||
# Déploiement & infra
|
||||
|
||||
## Dockerfile
|
||||
|
||||
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.
|
||||
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).
|
||||
- `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.
|
||||
**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).
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
@@ -28,5 +26,3 @@ Un `Dockerfile` existe (multi-stage Bun Alpine). **L'installation passe par pnpm
|
||||
## 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 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)
|
||||
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)
|
||||
---
|
||||
|
||||
# Stack & commandes
|
||||
@@ -10,7 +10,6 @@ summary: Composants de la stack (Bun runtime/build/test, install via pnpm, React
|
||||
| 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`) |
|
||||
@@ -32,8 +31,7 @@ summary: Composants de la stack (Bun runtime/build/test, install via pnpm, React
|
||||
| `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` — (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]]. |
|
||||
| `build:ng` | `bash scripts/build-ng-packages.sh` — rebuild des `@ng-org/*` depuis le fork local (concept `nextgraph-platform`) |
|
||||
| `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 test/build, bunx, et pas d'express/ws/pg/dotenv. EXCEPTION : l'installation des paquets passe par pnpm (les deux repos), pas bun install
|
||||
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
|
||||
---
|
||||
|
||||
# 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 install`, `bun install` | **`pnpm install`** (voir exception ci-dessous) |
|
||||
| `npm/yarn/pnpm install` | `bun install` |
|
||||
| `npm run <script>` | `bun run <script>` |
|
||||
| `npx <pkg>` | `bunx <pkg>` |
|
||||
| `webpack`, `esbuild`, `vite` | `bun build` / bundler Bun (HTML imports) |
|
||||
@@ -26,14 +26,6 @@ Par défaut, utiliser **Bun** et ses APIs natives plutôt que les équivalents N
|
||||
|
||||
Détail des APIs : [[knowledge_bun-apis]].
|
||||
|
||||
## Exception : l'installation des paquets passe par pnpm
|
||||
## Pourquoi
|
||||
|
||||
**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.
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Inter-repo contracts. Festipod is a CONSUMER only: it publishes no interface of its own,
|
||||
# and what it lists under `publish:` is its own consumer-side DECLARATIONS — the channel a
|
||||
# provider pulls to learn what this app calls, what it needs, and what blocks it.
|
||||
#
|
||||
# Festipod's persistence sits behind interchangeable Refine backends (the backend contract
|
||||
# itself is in-code: `src/shared/refine/providers/types.ts`). Each candidate backend is a
|
||||
# separate interface, with its own folder inside `data-layer` — the concept that owns how
|
||||
# Festipod uses an external data surface.
|
||||
#
|
||||
# `@activitypods/refine-providers` publishes NO engagement: no contract document, no stated
|
||||
# change policy, not even a git tag. So its entry is `type: reconstruct` — the one bounded
|
||||
# crossing this protocol allows, made once at adoption time: the leaf under `into:` is OUR
|
||||
# observation of that package, authored here and pinned to the commit it was read at. It is
|
||||
# not a promise anyone made us, and every upgrade has to re-observe it.
|
||||
#
|
||||
# Once reconstructed, that leaf IS the specification this app codes against. Reading the
|
||||
# package's sources again at work time is not how a question gets answered — the leaf is
|
||||
# fixed or the gap goes into our declaration's `## Needs`.
|
||||
|
||||
publish:
|
||||
# Key qualified by interface: a second declaration is coming for the NextGraph+polyfill
|
||||
# backend, and publish keys must stay unique.
|
||||
activitypods-providers--festipod: concepts/data-layer/activitypods-providers/usage_festipod.md
|
||||
|
||||
consume:
|
||||
- contract: activitypods-providers
|
||||
into: concepts/data-layer/activitypods-providers/
|
||||
type: reconstruct
|
||||
# Canonical identity: the package's git remote plus the repo-relative folder the
|
||||
# observation was made from. Per-developer access to a checkout of it lives in
|
||||
# `.project/contracts.local.yaml`, which is never committed.
|
||||
reconstructFrom: https://github.com/activitypods/refine-providers.git/src
|
||||
# The branch the observation was read from. A tag would be the better pin, but this
|
||||
# repository publishes none — the resolver turns this into the commit actually read,
|
||||
# which is what `built_version` records.
|
||||
ref: master
|
||||
@@ -14,21 +14,18 @@ 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, périmètres public/protected/private par entité, découverte, défi déduplication |
|
||||
| `functional-domain` | Modèle produit : point de rencontre, acteurs, concepts métier, 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` | Persistance via le SDK `@ng-eventually/client` : entités-documents par scope, shapes SHEX/ORM, modes connected/demo, pièges |
|
||||
| `data-layer` | NextGraph actuel (mono-store), shapes, modes connected/demo, règles + pièges (suppression, champs perdus, internals) |
|
||||
| `bdd-testing` | Cucumber multi-couches FR, contrat `@ui`/`@data`/`@e2e`, harness broker, cookbook |
|
||||
| `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 |
|
||||
| `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) |
|
||||
|
||||
Pour **documenter** un fait projet : `/concept document <sujet>` (ne pas écrire en libre dans `.project/`).
|
||||
|
||||
+4
-12
@@ -1,19 +1,11 @@
|
||||
# Use the official Bun image (runtime stays Bun; only install moves to pnpm)
|
||||
# Use the official Bun image
|
||||
FROM oven/bun:1-alpine AS base
|
||||
WORKDIR /app
|
||||
|
||||
# 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.
|
||||
# Install dependencies
|
||||
FROM base AS install
|
||||
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 package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Copy source code and build assets
|
||||
FROM base AS release
|
||||
|
||||
@@ -142,8 +142,8 @@ 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).
|
||||
// The app may bootstrap demo data into an empty connected wallet only when
|
||||
// FESTIPOD_AUTO_SEED is set (see src/shared/utils/autoSeed.ts). Absent → OFF.
|
||||
"globalThis.__FESTIPOD_AUTO_SEED__": JSON.stringify(
|
||||
process.env.FESTIPOD_AUTO_SEED ?? "",
|
||||
),
|
||||
|
||||
+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"
|
||||
},
|
||||
|
||||
+17
-18
@@ -8,29 +8,27 @@
|
||||
"start": "NODE_ENV=production bun src/index.ts",
|
||||
"build": "bun run build.ts",
|
||||
"test:cucumber": "bun run cucumber:run && bun run cucumber:report && bun run features:parse && bun run steps:extract",
|
||||
"cucumber:run": "node --import tsx/esm node_modules/.bin/cucumber-js --config cucumber.json",
|
||||
"test:data": "node --import tsx/esm node_modules/.bin/cucumber-js --config cucumber.json --tags @data",
|
||||
"cucumber:run": "node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json",
|
||||
"test:data": "node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json --tags @data",
|
||||
"test:auth-setup": "bun scripts/setup-test-auth.ts",
|
||||
"cucumber:report": "bun scripts/parse-test-results.ts",
|
||||
"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:orm": "rdf-orm build --input ./src/shared/shapes/shex --output ./src/shared/shapes/orm",
|
||||
"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",
|
||||
"@ng-org/web": "0.1.2-alpha.13",
|
||||
"@ng-org/alien-deepsignals": "0.1.2-alpha.13",
|
||||
"@ng-org/orm": "0.1.2-alpha.21",
|
||||
"@ng-org/shex-orm": "0.1.2-alpha.9",
|
||||
"@ng-org/web": "0.1.2-alpha.14",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@refinedev/core": "^5.0.12",
|
||||
"bun-plugin-tailwind": "^0.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -43,6 +41,11 @@
|
||||
"@cucumber/cucumber": "^12.5.0",
|
||||
"@cucumber/gherkin": "^29.0.0",
|
||||
"@cucumber/messages": "^26.0.1",
|
||||
"@storybook/addon-a11y": "^10.3.5",
|
||||
"@storybook/addon-docs": "^10.3.5",
|
||||
"@storybook/addon-onboarding": "^10.3.5",
|
||||
"@storybook/addon-webpack5-compiler-swc": "^4.0.3",
|
||||
"@storybook/react-webpack5": "^10.3.5",
|
||||
"@types/bun": "latest",
|
||||
"@types/chai": "^5.2.3",
|
||||
"@types/react": "^19",
|
||||
@@ -50,19 +53,15 @@
|
||||
"chai": "^6.2.2",
|
||||
"happy-dom": "^16.6.0",
|
||||
"playwright": "^1.58.2",
|
||||
"storybook": "^10.3.5",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tsx": "^4.21.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"storybook": "^10.3.5",
|
||||
"@storybook/react-webpack5": "^10.3.5",
|
||||
"@storybook/addon-webpack5-compiler-swc": "^4.0.3",
|
||||
"@storybook/addon-a11y": "^10.3.5",
|
||||
"@storybook/addon-docs": "^10.3.5",
|
||||
"@storybook/addon-onboarding": "^10.3.5"
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"bun"
|
||||
]
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.26.0+sha512.3b3f6c725ebe712506c0ab1ad4133cf86b1f4b687effce62a9b38b4d72e3954242e643190fc51fa1642949c735f403debd44f5cb0edd657abe63a8b6a7e1e402"
|
||||
}
|
||||
|
||||
Generated
+1218
-1481
File diff suppressed because it is too large
Load Diff
@@ -1,106 +0,0 @@
|
||||
#!/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,160 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* overlay-local-checkout.ts — Reactive local overlay of a data-layer PROVIDER's checkout
|
||||
* into node_modules. One script, one provider per run; only the paths differ between them.
|
||||
*
|
||||
* PROVIDERS (first non-flag argument; defaults to `polyfill`):
|
||||
* polyfill → node_modules/@ng-eventually/polyfill override: NG_EVENTUALLY_LOCAL
|
||||
* indexing → node_modules/@ng-helpers/indexing override: NG_HELPERS_LOCAL
|
||||
*
|
||||
* WHY a copy-overlay and not a symlink (identical for every provider):
|
||||
* A committed prod dependency installs from Gitea (git+https) into pnpm's store WITHOUT
|
||||
* its own node_modules/@ng-org, so @ng-org/web resolves UP to Festipod → ONE @ng-org
|
||||
* instance (one verifier). A local CHECKOUT, however, carries its own node_modules/*
|
||||
* (links into that provider's own dev tree). Symlinking node_modules/<pkg> to the
|
||||
* checkout would put the checkout's copies in the resolution path → a SECOND @ng-org
|
||||
* (and, for `indexing`, a second @ng-eventually/polyfill) → broken SDK, two verifiers.
|
||||
* So we overlay a real directory containing ONLY the provider's source (no node_modules):
|
||||
* shared packages still resolve up to Festipod, single instance preserved.
|
||||
*
|
||||
* WHAT IT DOES:
|
||||
* 1. Replaces node_modules/<package> (the pnpm store symlink) with a real directory
|
||||
* holding the local checkout's package.json + src (NO node_modules).
|
||||
* 2. Asserts the single-instance invariant — every package this provider SHARES with
|
||||
* Festipod must resolve to the same realpath from Festipod and from the overlay —
|
||||
* and aborts if it would break.
|
||||
* 3. Watches the local checkout's src and copies each change into the overlay.
|
||||
*
|
||||
* ⚠️ RESTART `bun run dev` AFTER THIS SCRIPT WRITES — a rebuild is NOT a substitute.
|
||||
* A running dev server NEVER picks up a package refreshed inside node_modules, not even
|
||||
* across a genuine rebuild: VERIFIED in a controlled test, an application-source edit
|
||||
* produced a new bundle hash and the rebuilt bundle STILL carried the stale dependency.
|
||||
* The server's resolution of that import is pinned at process start and a rebuild does not
|
||||
* re-resolve it. Only restarting serves the fresh copy, and nothing warns you — a stale
|
||||
* server looks exactly like a current one. See
|
||||
* .project/concepts/tech-stack/caveat_polyfill-overlay-needs-a-dev-restart.md, which cost
|
||||
* an hour to learn. Watching copies the files; it does not make anything reload them.
|
||||
*
|
||||
* USAGE (reactive dev):
|
||||
* Terminal 1: pnpm run overlay:polyfill # or: pnpm run overlay:indexing
|
||||
* Terminal 2: bun run dev # portless festipod bun --hot src/index.ts
|
||||
* Edit the checkout's src → it lands in node_modules → RESTART dev to pick it up.
|
||||
*
|
||||
* pnpm run overlay:indexing --once # overlay + verify, no watch (CI / one-shot)
|
||||
* Return to the committed git-installed dependencies: pnpm install
|
||||
*/
|
||||
import { existsSync, lstatSync, mkdirSync, rmSync, cpSync, copyFileSync, realpathSync } from "node:fs";
|
||||
import { watch } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
|
||||
interface Provider {
|
||||
/** Package as installed, e.g. "@ng-eventually/polyfill" — also its node_modules path. */
|
||||
readonly packageName: string;
|
||||
/** Local checkout used when the env override is unset. */
|
||||
readonly defaultLocal: string;
|
||||
/** Env var overriding the local checkout path. */
|
||||
readonly envOverride: string;
|
||||
/**
|
||||
* Packages this provider SHARES with Festipod and that must stay single-instance.
|
||||
* Each is resolved from Festipod and from the overlay; the realpaths must match.
|
||||
*/
|
||||
readonly singletons: readonly string[];
|
||||
}
|
||||
|
||||
const PROVIDERS: Record<string, Provider> = {
|
||||
polyfill: {
|
||||
packageName: "@ng-eventually/polyfill",
|
||||
defaultLocal: "/home/sylvain/projects/nextgraph/ng-eventually-js/packages/polyfill",
|
||||
envOverride: "NG_EVENTUALLY_LOCAL",
|
||||
singletons: ["@ng-org/web"],
|
||||
},
|
||||
indexing: {
|
||||
packageName: "@ng-helpers/indexing",
|
||||
defaultLocal: "/home/sylvain/projects/nextgraph/ng-helpers",
|
||||
envOverride: "NG_HELPERS_LOCAL",
|
||||
// Consumes the polyfill, so BOTH it and the verifier underneath must stay single.
|
||||
singletons: ["@ng-eventually/polyfill", "@ng-org/web"],
|
||||
},
|
||||
};
|
||||
|
||||
const FESTIPOD = realpathSync(join(import.meta.dir, ".."));
|
||||
const args = process.argv.slice(2);
|
||||
const ONCE = args.includes("--once");
|
||||
const KEY = args.find((a) => !a.startsWith("-")) ?? "polyfill";
|
||||
|
||||
function fail(msg: string): never {
|
||||
console.error(`✖ overlay:${KEY} — ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const provider = PROVIDERS[KEY];
|
||||
if (!provider) {
|
||||
fail(`unknown provider "${KEY}" — expected one of: ${Object.keys(PROVIDERS).join(", ")}`);
|
||||
}
|
||||
|
||||
const LOCAL = process.env[provider.envOverride] ?? provider.defaultLocal;
|
||||
const TARGET = join(FESTIPOD, "node_modules", ...provider.packageName.split("/"));
|
||||
const SRC_LOCAL = join(LOCAL, "src");
|
||||
const SRC_TARGET = join(TARGET, "src");
|
||||
|
||||
if (!existsSync(join(LOCAL, "package.json"))) {
|
||||
fail(`local checkout not found at ${LOCAL} (set ${provider.envOverride} to override)`);
|
||||
}
|
||||
if (!existsSync(SRC_LOCAL)) {
|
||||
fail(`local checkout has no src/ at ${SRC_LOCAL}`);
|
||||
}
|
||||
|
||||
// 1. Replace the pnpm store symlink with a real overlay dir (metadata + src, NO node_modules).
|
||||
console.log(`→ overlaying local ${provider.packageName}: ${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 instances).
|
||||
cpSync(SRC_LOCAL, SRC_TARGET, { recursive: true });
|
||||
|
||||
// 2. Assert the single-instance invariant for every package shared with Festipod.
|
||||
const overlayReal = realpathSync(TARGET);
|
||||
for (const spec of provider.singletons) {
|
||||
const fromFestipod = realpathSync(Bun.resolveSync(spec, FESTIPOD));
|
||||
const fromOverlay = realpathSync(Bun.resolveSync(spec, overlayReal));
|
||||
console.log(` ${spec} (Festipod): ${fromFestipod}`);
|
||||
console.log(` ${spec} (overlay) : ${fromOverlay}`);
|
||||
if (fromFestipod !== fromOverlay) {
|
||||
fail(
|
||||
`single-instance invariant BROKEN — ${spec} resolves to two different realpaths.\n` +
|
||||
" The overlay must not contain its own node_modules. Aborting.",
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log(`✓ single instance preserved for: ${provider.singletons.join(", ")}`);
|
||||
|
||||
if (ONCE) {
|
||||
console.log("✓ overlay ready (--once, not watching)");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 3. Watch and copy on change. This keeps the overlay CURRENT; it does NOT make a running
|
||||
// dev server notice — not even across a rebuild (see the header). Restart it.
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -1,490 +0,0 @@
|
||||
#!/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);
|
||||
});
|
||||
+15
-10
@@ -2,6 +2,7 @@ import { RouterProvider, useRouter } from './router';
|
||||
import { ThemeProvider } from '../shared/context/ThemeContext';
|
||||
import { NextGraphProvider } from '../shared/context/NextGraphContext';
|
||||
import { AccountProvider } from '../shared/context/AccountContext';
|
||||
import { RefineProvider } from '../shared/refine/RefineProvider';
|
||||
import { FestipodDataProvider } from '../shared/context/FestipodDataContext';
|
||||
import { AuthGate } from './AuthGate';
|
||||
import { ToastContainer } from '../shared/components/sketchy';
|
||||
@@ -58,16 +59,20 @@ export function App() {
|
||||
<ThemeProvider>
|
||||
<NextGraphProvider>
|
||||
<AccountProvider>
|
||||
<FestipodDataProvider>
|
||||
<RouterProvider>
|
||||
<div className="app-container">
|
||||
<AuthGate>
|
||||
<AppContent />
|
||||
</AuthGate>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</RouterProvider>
|
||||
</FestipodDataProvider>
|
||||
{/* Refine wraps the data context: FestipodDataContext reads and writes
|
||||
events through Refine's hooks, so it must sit inside <Refine>. */}
|
||||
<RefineProvider>
|
||||
<FestipodDataProvider>
|
||||
<RouterProvider>
|
||||
<div className="app-container">
|
||||
<AuthGate>
|
||||
<AppContent />
|
||||
</AuthGate>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</RouterProvider>
|
||||
</FestipodDataProvider>
|
||||
</RefineProvider>
|
||||
</AccountProvider>
|
||||
</NextGraphProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
+17
-60
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* AuthGate — the stopgap access flow (see decision_2026-06-15_shared-wallet-login-flow):
|
||||
* 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.
|
||||
* 1. Technical access barrier (AccessGateScreen) → opens the SHARED wallet via
|
||||
* the broker redirect (with the wallet file + guide it hands the user).
|
||||
* 2. Perceived app login (ConnexionScreen) → pick a username.
|
||||
* 3. The app.
|
||||
*
|
||||
* The gate is ON BY DEFAULT (Festipod never functions without NextGraph). It is
|
||||
* disabled only when `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true` —
|
||||
@@ -13,11 +12,11 @@
|
||||
* screens, not the auth flow). Absent → gate ON.
|
||||
*/
|
||||
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useNextGraph } from '../shared/context/NextGraphContext';
|
||||
import { useAccount, normalizeIdentifier } from '../shared/context/AccountContext';
|
||||
import { useAccount } from '../shared/context/AccountContext';
|
||||
import { AccessGateScreen } from '../modules/auth/screens/AccessGateScreen';
|
||||
import { useRouter, useNavigate } from './router';
|
||||
import { ConnexionScreen } from '../modules/auth/screens/ConnexionScreen';
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
@@ -27,65 +26,23 @@ const GATE_DISABLED = globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true;
|
||||
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const { status, error, connect } = useNextGraph();
|
||||
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]);
|
||||
const { username } = useAccount();
|
||||
|
||||
// Gate explicitly disabled (no-gate build / @e2e harness) → straight to app.
|
||||
if (GATE_DISABLED) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// 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}
|
||||
/>
|
||||
);
|
||||
// 1. Technical access barrier (real NG login) — until the shared wallet opens.
|
||||
if (status !== 'connected') {
|
||||
return <AccessGateScreen status={status} error={error} onEnter={connect} />;
|
||||
}
|
||||
|
||||
// The app.
|
||||
// 2. Perceived app login — until a username is chosen.
|
||||
if (!username) {
|
||||
return <ConnexionScreen />;
|
||||
}
|
||||
|
||||
// 3. The app.
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
+15
-50
@@ -3,59 +3,24 @@
|
||||
* 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";
|
||||
|
||||
/** 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.
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
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,17 +333,6 @@ 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,28 +41,6 @@ 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,
|
||||
},
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# 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"
|
||||
@@ -1,42 +0,0 @@
|
||||
# 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,15 +4,12 @@
|
||||
* STOPGAP (see decision_2026-06-15_shared-wallet-login-flow). This is the
|
||||
* REAL NextGraph login, shown before the app renders. Because it precedes the
|
||||
* app, the user reads it as "access to the test environment", not as an app
|
||||
* login. The user also types an IDENTIFIER here — the id that names their
|
||||
* virtual space (a technical id, a pseudo in practice, not a Festipod username).
|
||||
* 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.
|
||||
* login. Clicking "Entrer" triggers `connect()`, which redirects to the broker
|
||||
* to open the SHARED wallet. After return (inside the broker iframe) NG
|
||||
* auto-connects and the app shows the perceived login (ConnexionScreen).
|
||||
*
|
||||
* ASSISTED IMPORT (see decision_2026-06-17). The hosted broker can't import a
|
||||
* wallet inline during
|
||||
* ASSISTED IMPORT (see the broker-import constraint, concept nextgraph-platform
|
||||
* + decision_2026-06-17). The hosted broker can't import a wallet inline during
|
||||
* web-app auth: a first-time device has no wallet, so the broker redirect would
|
||||
* dead-end. We therefore HAND the user the shared wallet FILE (download) + the
|
||||
* shared password and guide a one-time import on nextgraph.eu ("Import a Wallet
|
||||
@@ -22,20 +19,13 @@
|
||||
*/
|
||||
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Button, Input, Title, Text } from '../../../shared/components/sketchy';
|
||||
import { Button, Title, Text } from '../../../shared/components/sketchy';
|
||||
import { SHARED_WALLET_PASSWORD, SHARED_WALLET_FILE_URL, WALLET_IMPORT_URL, hasSharedWallet } from '../sharedWallet';
|
||||
|
||||
interface AccessGateScreenProps {
|
||||
status: 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
error?: string;
|
||||
/**
|
||||
* 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;
|
||||
onEnter: () => void;
|
||||
}
|
||||
|
||||
// One numbered step: a badge + a title + the action for that step.
|
||||
@@ -54,15 +44,9 @@ function Step({ n, title, children }: { n: number; title: string; children: Reac
|
||||
);
|
||||
}
|
||||
|
||||
export function AccessGateScreen({ status, error, initialIdentifier, onEnter }: AccessGateScreenProps) {
|
||||
export function AccessGateScreen({ status, error, 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 {
|
||||
@@ -74,31 +58,15 @@ export function AccessGateScreen({ status, error, initialIdentifier, onEnter }:
|
||||
}
|
||||
};
|
||||
|
||||
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 = (
|
||||
<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>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onEnter}
|
||||
disabled={connecting}
|
||||
style={{ width: '100%', opacity: connecting ? 0.6 : 1 }}
|
||||
>
|
||||
{connecting ? 'Accès en cours…' : 'Entrer'}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -107,7 +75,7 @@ export function AccessGateScreen({ status, error, initialIdentifier, onEnter }:
|
||||
<Title style={{ textAlign: 'center', fontSize: 30, marginBottom: 4 }}>Festipod</Title>
|
||||
<Text style={{ textAlign: 'center', marginBottom: 24, color: '#888' }}>Espace de test</Text>
|
||||
|
||||
{hasSharedWallet() && status !== 'connected' ? (
|
||||
{hasSharedWallet() ? (
|
||||
<>
|
||||
<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.
|
||||
@@ -146,7 +114,7 @@ export function AccessGateScreen({ status, error, initialIdentifier, onEnter }:
|
||||
</div>
|
||||
</Step>
|
||||
|
||||
<Step n={3} title="Revenez ici, choisissez un identifiant et entrez">
|
||||
<Step n={3} title="Revenez sur cet onglet et entrez">
|
||||
{entrer}
|
||||
</Step>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* ConnexionScreen — the *perceived* login of the stopgap.
|
||||
*
|
||||
* STOPGAP (see decision_2026-06-15_shared-wallet-login-flow). The real NG
|
||||
* login (AccessGateScreen) already happened and is not perceived as a login;
|
||||
* THIS screen is what the user experiences as "logging in": they pick a
|
||||
* username (no password — declarative). The username is persisted by
|
||||
* AccountContext (localStorage) and resolved against the accounts living in
|
||||
* the shared wallet.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button, Input, Title, Text, Avatar } from '../../../shared/components/sketchy';
|
||||
import { useAccount } from '../../../shared/context/AccountContext';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate } from '../../../app/router';
|
||||
|
||||
export function ConnexionScreen() {
|
||||
const { login } = useAccount();
|
||||
const { users } = useFestipodData();
|
||||
const navigate = useNavigate();
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
// Choosing a username completes the login → land on the app (not the '/'
|
||||
// welcome/onboarding screen, which the access gate has replaced upstream).
|
||||
const doLogin = (name: string) => {
|
||||
login(name);
|
||||
navigate('/home');
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (value.trim()) doLogin(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<Title style={{ textAlign: 'center', fontSize: 32, marginBottom: 8 }}>Connexion</Title>
|
||||
<Text style={{ textAlign: 'center', marginBottom: 32, color: '#888' }}>
|
||||
Choisissez votre nom d'utilisateur
|
||||
</Text>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 24 }}>
|
||||
<Input
|
||||
placeholder="@votrepseudo"
|
||||
value={value}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setValue(e.target.value)}
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') submit();
|
||||
}}
|
||||
/>
|
||||
<Button variant="primary" onClick={submit} disabled={!value.trim()} style={{ width: '100%' }}>
|
||||
Se connecter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{users.length > 0 && (
|
||||
<>
|
||||
<Text style={{ textAlign: 'center', fontSize: 13, color: '#888', margin: '8px 0 12px' }}>
|
||||
ou reprenez un compte existant
|
||||
</Text>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{users.map(u => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => doLogin(u.username)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: 10,
|
||||
border: '1px solid #eee',
|
||||
borderRadius: 12,
|
||||
background: '#fff',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<Avatar initials={u.initials} color="#E8590C" size="sm" />
|
||||
<span>
|
||||
<Text style={{ margin: 0, fontWeight: 600 }}>{u.name}</Text>
|
||||
<Text style={{ margin: 0, fontSize: 12, color: '#888' }}>{u.username}</Text>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* Shared wallet material for the staging stopgap.
|
||||
*
|
||||
* 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.
|
||||
* STOPGAP (see brief_2026-06-15_shared-wallet-shim + the broker-import
|
||||
* constraint, concept nextgraph-platform): the hosted broker can't auto-import
|
||||
* a wallet, so Festipod HANDS the user the shared wallet and guides a one-time
|
||||
* import on nextgraph.eu.
|
||||
*
|
||||
* The correct primitive is the **wallet FILE** (.ngw), NOT a TextCode: a
|
||||
* TextCode is a transient device-to-device transfer (5 min, source device
|
||||
|
||||
@@ -2,21 +2,31 @@ 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) {
|
||||
// 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.
|
||||
// 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
|
||||
const counts = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return { events: td.events.size, users: td.users.size };
|
||||
return { events: td.events.size, users: td.users.size, participations: td.participations.size };
|
||||
});
|
||||
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);
|
||||
expect(counts.events, 'Events should be empty').to.equal(0);
|
||||
expect(counts.users, 'Users should be empty').to.equal(0);
|
||||
});
|
||||
|
||||
Given('le portefeuille contient déjà des événements', async function (this: FestipodWorld) {
|
||||
@@ -30,9 +40,12 @@ Given('le portefeuille contient déjà des événements', async function (this:
|
||||
const td = (window as any).__testData;
|
||||
td.loadTestData();
|
||||
});
|
||||
// Wait for data to propagate
|
||||
// Wait for data to propagate. `waitForFunction(fn, arg, options)` — the
|
||||
// timeout goes in the THIRD slot; passed second it is silently taken as the
|
||||
// predicate's argument and the wait runs on the 30s default instead.
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => (window as any).__testData.events.size > 0,
|
||||
undefined,
|
||||
{ timeout: 75000 },
|
||||
);
|
||||
}
|
||||
@@ -59,19 +72,20 @@ When('je charge les données de test', async function (this: FestipodWorld) {
|
||||
});
|
||||
(this as any)._loadSeeded = seededResult.seeded;
|
||||
|
||||
// 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.
|
||||
// Wait for data to propagate, and for BOTH events and users — not just events.
|
||||
// They arrive by two independent paths here: events through the Refine read,
|
||||
// the seeded profiles through the ORM subscription on the protected scope. If
|
||||
// only events are awaited, `contient des utilisateurs` asserts before the
|
||||
// profile 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;
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 75000 },
|
||||
).catch(() => {
|
||||
// Timeout tolerated — the assertions below surface the real failure with a
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* @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);
|
||||
},
|
||||
);
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* @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,24 +47,20 @@ 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"
|
||||
|
||||
# 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
|
||||
# ngSet.delete() updates UI but doesn't persist — NG ORM limitation.
|
||||
@e2e @wip
|
||||
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 "J'y serai" si visible
|
||||
Alors l'écran contient le texte "Je participe"
|
||||
Quand l'utilisateur clique sur le bouton "Je participe"
|
||||
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é
|
||||
Alors l'écran contient le texte "J'y serai"
|
||||
|
||||
# --- Modification ---
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# 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
|
||||
@@ -1,109 +0,0 @@
|
||||
# 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"
|
||||
@@ -1,27 +0,0 @@
|
||||
# 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
|
||||
@@ -1,26 +0,0 @@
|
||||
# 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
|
||||
@@ -1,36 +0,0 @@
|
||||
# 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,22 +28,6 @@ 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,65 +24,41 @@ 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"
|
||||
|
||||
# --- 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
|
||||
Et l'événement "Formation CNV" compte 8 participants
|
||||
|
||||
@@ -88,12 +88,7 @@ export function CreateEventScreen() {
|
||||
endTime,
|
||||
location: location || 'Lieu à définir',
|
||||
description,
|
||||
// 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,
|
||||
participantCount: 1,
|
||||
themes: ['Social'],
|
||||
hostName: 'Moi',
|
||||
hostInitials: 'MD',
|
||||
|
||||
@@ -12,12 +12,7 @@ export function ParticipantsListScreen() {
|
||||
const event = eventId ? getEvent(eventId) : undefined;
|
||||
const participants = eventId ? getEventParticipants(eventId) : [];
|
||||
|
||||
// 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 totalCount = event?.participantCount ?? participants.length;
|
||||
const unknownCount = Math.max(0, totalCount - participants.length);
|
||||
|
||||
const rows = [
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,141 +0,0 @@
|
||||
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;
|
||||
});
|
||||
@@ -8,7 +8,10 @@ import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
// --- Setup ---
|
||||
|
||||
Given('un événement {string} existe', async function (this: FestipodWorld, eventTitle: string) {
|
||||
// Ensure wallet has data (seed if empty)
|
||||
// Ensure wallet has data (seed if empty). AWAITED, and given a budget that
|
||||
// matches what seeding costs once every entity is its own document: the seed
|
||||
// opens five of them, and opening a document takes seconds. The previous 10 s
|
||||
// was calibrated when seeding meant adding rows to an already-open graph.
|
||||
await this.appFrame!.evaluate(async () => {
|
||||
const td = (window as any).__testData;
|
||||
if (td.events.size === 0) await td.loadTestData();
|
||||
@@ -25,17 +28,11 @@ Given('un événement {string} existe', async function (this: FestipodWorld, eve
|
||||
});
|
||||
|
||||
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(
|
||||
async (title) => {
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = await td.ensureCurrentUser();
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) await td.leaveEvent(event['@id'], uid);
|
||||
if (event) td.leaveEvent(event['@id'], td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
@@ -43,25 +40,34 @@ 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(
|
||||
async (title) => {
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = await td.ensureCurrentUser();
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) await td.joinEvent(event['@id'], uid);
|
||||
if (event) td.joinEvent(event['@id'], td.currentUserId);
|
||||
},
|
||||
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(
|
||||
async (title) => {
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = await td.ensureCurrentUser();
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) await td.joinEvent(event['@id'], uid);
|
||||
if (event) td.joinEvent(event['@id'], td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
@@ -69,11 +75,10 @@ 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(
|
||||
async (title) => {
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = await td.ensureCurrentUser();
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) await td.leaveEvent(event['@id'], uid);
|
||||
if (event) td.leaveEvent(event['@id'], td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
@@ -81,11 +86,10 @@ 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(
|
||||
async (title) => {
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = await td.ensureCurrentUser();
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) await td.joinEvent(event['@id'], uid);
|
||||
if (event) td.joinEvent(event['@id'], td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
@@ -94,117 +98,54 @@ 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) {
|
||||
// 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(
|
||||
const participating = await this.appFrame!.evaluate(
|
||||
(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);
|
||||
return !!event && td.isParticipating(event['@id'], uid);
|
||||
if (!event) return false;
|
||||
return td.isParticipating(event['@id'], td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
{ 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);
|
||||
);
|
||||
expect(participating, `User should be participating in "${eventTitle}"`).to.be.true;
|
||||
});
|
||||
|
||||
Then('l\'utilisateur n\'est plus participant de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
// 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(
|
||||
const participating = await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
return !!event && !td.isParticipating(event['@id'], td.currentUserId);
|
||||
if (!event) return false;
|
||||
return td.isParticipating(event['@id'], td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
{ 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);
|
||||
);
|
||||
expect(participating, `User should NOT be participating in "${eventTitle}"`).to.be.false;
|
||||
});
|
||||
|
||||
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(
|
||||
Then('l\'événement {string} compte {int} participants', async function (this: FestipodWorld, eventTitle: string, expectedCount: number) {
|
||||
const count = await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
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);
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
return event?.participantCount ?? -1;
|
||||
},
|
||||
eventTitle,
|
||||
{ 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;
|
||||
);
|
||||
expect(count, `Event "${eventTitle}" participant count`).to.equal(expectedCount);
|
||||
});
|
||||
|
||||
Then('l\'utilisateur apparaît dans la liste des participants de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
await this.appFrame!.waitForFunction(
|
||||
const found = await this.appFrame!.evaluate(
|
||||
(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);
|
||||
return !!event && td.getEventParticipants(event['@id']).some((p: any) => p.user === uid);
|
||||
if (!event) return false;
|
||||
return td.getEventParticipants(event['@id']).some((p: any) => p.user === td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
{ 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);
|
||||
});
|
||||
);
|
||||
expect(found, `User should appear in participants of "${eventTitle}"`).to.be.true;
|
||||
});
|
||||
|
||||
Then('l\'utilisateur n\'apparaît plus dans la liste des participants de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
@@ -221,34 +162,14 @@ 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) {
|
||||
// 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(
|
||||
const count = await this.appFrame!.evaluate(
|
||||
(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);
|
||||
return !!event && td.isParticipating(event['@id'], uid);
|
||||
if (!event) return 0;
|
||||
return td.getEventParticipants(event['@id']).filter((p: any) => p.user === td.currentUserId).length;
|
||||
},
|
||||
eventTitle,
|
||||
{ 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);
|
||||
);
|
||||
expect(count, 'User should have exactly one participation record').to.equal(1);
|
||||
});
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,365 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
# 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,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Title, Card, AvatarStack, BottomNav, EventCover, EventMeetingPoints, Spinner, type MeetingPointData } from '../../../shared/components/sketchy';
|
||||
import { Title, Card, AvatarStack, BottomNav, EventCover, EventMeetingPoints, 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 = [
|
||||
@@ -66,7 +65,6 @@ 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);
|
||||
@@ -96,10 +94,7 @@ 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' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Title style={{ margin: 0 }}>Festipod</Title>
|
||||
{pendingQueries > 0 && <Spinner />}
|
||||
</div>
|
||||
<Title style={{ margin: 0 }}>Festipod</Title>
|
||||
<button
|
||||
onClick={() => navigate('/events/new')}
|
||||
aria-label="Relayer un événement"
|
||||
|
||||
@@ -12,9 +12,8 @@ export function SettingsScreen() {
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const [location, setLocation] = useState(true);
|
||||
|
||||
// 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.
|
||||
// Faux logout: clears the perceived login (username) only — the shared
|
||||
// wallet stays open underneath. In staging this returns to ConnexionScreen.
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/');
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
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;
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user